feat(risk): add portrait emotions for attacks, conquests, and army losses

- Add portraitHappy/portraitUpset helpers to react to game events
- Track army snapshotted counts on attack phase entry
- Trigger upset emotion when a player loses >8 armies in a turn
- Show happy emotion on continent capture or defeating 5+ defender armies
- Show happy on successful card trades (human and AI)
- Enable opponent portrait intros in Risk
- Remove deprecated setPortraitThinking method

feat(paigow): widen card spread in comparison modal

- Add MODAL_CARD_SPREAD constant (55px) for clearer card visibility
- Use modal spread in slotX calculation for comparison display
- Move Next Round button up 200px for better centering

feat(opponents): add Jerry conversation audio references

- Add "convo" array to Jerry's opponent entry
- Reference maurice and mario conversation audio files
This commit is contained in:
Brian Fertig 2026-06-25 22:57:50 -06:00
parent 0be58058b4
commit 63ffa35aa4
6 changed files with 62 additions and 11 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -330,6 +330,10 @@
"name": "Jerry", "name": "Jerry",
"bio": "Well now yall know I want to play some them games!", "bio": "Well now yall know I want to play some them games!",
"voice": "Life is Strange - Jed Lucan", "voice": "Life is Strange - Jed Lucan",
"convo": [
"maurice",
"mario"
],
"speech": { "speech": {
"intro": [ "intro": [
"jerry-intro-01", "jerry-intro-01",

View File

@ -21,7 +21,8 @@ const CARD_W = 90;
const CARD_H = 126; const CARD_H = 126;
const CARD_R = 8; const CARD_R = 8;
const CARD_SPREAD_FAN = 22; // overlap in 7-card fan at opponent seats const CARD_SPREAD_FAN = 22; // overlap in 7-card fan at opponent seats
const CARD_SPREAD_SPLIT = 10; // tighter spread for 5-card split display const CARD_SPREAD_SPLIT = 10; // tighter spread for 5-card split display on main table
const MODAL_CARD_SPREAD = 55; // wider spread so each card is clearly visible in comparison modal
const TABLE_CY = 475; // table centre — slightly higher than Blackjack to make room for panel const TABLE_CY = 475; // table centre — slightly higher than Blackjack to make room for panel
@ -1269,8 +1270,7 @@ export default class PaiGowPokerGame extends Phaser.Scene {
const LOW_CX = 1270; const LOW_CX = 1270;
const RESULT_CENTER_Y = MODAL_TOP + Math.round(MODAL_H / 2); // 415 const RESULT_CENTER_Y = MODAL_TOP + Math.round(MODAL_H / 2); // 415
// slot x for card i in a n-card spread (CARD_SPREAD_SPLIT=10) const slotX = (cx, count, i) => cx - ((count - 1) * MODAL_CARD_SPREAD) / 2 + i * MODAL_CARD_SPREAD;
const slotX = (cx, count, i) => cx - ((count - 1) * CARD_SPREAD_SPLIT) / 2 + i * CARD_SPREAD_SPLIT;
this._modal = { MODAL_X, MODAL_TOP, MODAL_W, MODAL_H, DEALER_ROW_Y, PLAYER_ROW_Y, this._modal = { MODAL_X, MODAL_TOP, MODAL_W, MODAL_H, DEALER_ROW_Y, PLAYER_ROW_Y,
ARROW_Y, HIGH_CX, LOW_CX, RESULT_CENTER_Y, slotX }; ARROW_Y, HIGH_CX, LOW_CX, RESULT_CENTER_Y, slotX };
@ -1706,7 +1706,7 @@ export default class PaiGowPokerGame extends Phaser.Scene {
return; return;
} }
const btn = new Button(this, CX, GAME_HEIGHT / 2 + 200, 'Next Round', () => { const btn = new Button(this, CX, GAME_HEIGHT / 2, 'Next Round', () => {
btn.destroy(); btn.destroy();
this.startNewRound(); this.startNewRound();
}, { width: 220, height: 56, fontSize: 24 }); }, { width: 220, height: 56, fontSize: 24 });

View File

@ -69,6 +69,7 @@ export default class RiskGame extends Phaser.Scene {
this.debug = false; this.debug = false;
this.gameOverShown = false; this.gameOverShown = false;
this._lastAnnouncedPhase = null; // tracks which phase was last announced for the human this._lastAnnouncedPhase = null; // tracks which phase was last announced for the human
this._atkSnap = null;
} }
create() { create() {
@ -160,7 +161,7 @@ export default class RiskGame extends Phaser.Scene {
for (let seat = 0; seat < this.gs.playerCount; seat++) { for (let seat = 0; seat < this.gs.playerCount; seat++) {
let portrait = null; let portrait = null;
if (seat === this.humanSeat) portrait = createPlayerPortrait(this, PORTRAIT_CX, y, PORTRAIT_R, DEPTH.panel + 1, 'RiskGame'); if (seat === this.humanSeat) portrait = createPlayerPortrait(this, PORTRAIT_CX, y, PORTRAIT_R, DEPTH.panel + 1, 'RiskGame');
else portrait = createOpponentPortrait(this, this.opponents[seat - 1], PORTRAIT_CX, y, PORTRAIT_R, DEPTH.panel + 1, { playIntro: false }); else portrait = createOpponentPortrait(this, this.opponents[seat - 1], PORTRAIT_CX, y, PORTRAIT_R, DEPTH.panel + 1, { playIntro: true });
// colored ring matching the player's piece color // colored ring matching the player's piece color
const ring = this.add.graphics().setDepth(DEPTH.panel + 2); const ring = this.add.graphics().setDepth(DEPTH.panel + 2);
ring.lineStyle(7, this.colorOf(seat), 1); ring.lineStyle(7, this.colorOf(seat), 1);
@ -427,8 +428,13 @@ export default class RiskGame extends Phaser.Scene {
async doHumanAttack(from, to) { async doHumanAttack(from, to) {
this.busy = true; this.busy = true;
const before = this.gs; const before = this.gs;
const defenderArmiesBefore = this.gs.armies[to];
const defenderSeat = this.gs.owner[to];
const ownedContsBefore = new Set(CONTINENTS.filter((c) => ownsContinent(this.gs, this.humanSeat, c.id)).map((c) => c.id));
const defContsBefore = new Set(CONTINENTS.filter((c) => ownsContinent(this.gs, defenderSeat, c.id)).map((c) => c.id));
this.gs = resolveAttack(this.gs, from, to); this.gs = resolveAttack(this.gs, from, to);
await this.animateBattle(this.gs.lastBattle); await this.animateBattle(this.gs.lastBattle);
this._checkArmyLosses();
this.render(); this.render();
if (this.gs.pendingConquest) { if (this.gs.pendingConquest) {
@ -441,6 +447,11 @@ export default class RiskGame extends Phaser.Scene {
if (this.gs.owner[from] === before.current && this.gs.armies[from] < 2) this.selFrom = null; if (this.gs.owner[from] === before.current && this.gs.armies[from] < 2) this.selFrom = null;
this.render(); this.render();
await this.animateConquest(from, to, n, this.humanSeat); await this.animateConquest(from, to, n, this.humanSeat);
const newCont = CONTINENTS.some((c) => !ownedContsBefore.has(c.id) && ownsContinent(this.gs, this.humanSeat, c.id));
if (defenderArmiesBefore >= 5 || newCont) this.portraitHappy(this.humanSeat);
if (defContsBefore.size > 0 && CONTINENTS.some((c) => defContsBefore.has(c.id) && !ownsContinent(this.gs, defenderSeat, c.id))) {
this.portraitUpset(defenderSeat);
}
this.busy = false; this.busy = false;
this.render(); this.render();
if (isGameOver(this.gs)) this.showGameOver(); if (isGameOver(this.gs)) this.showGameOver();
@ -542,6 +553,7 @@ export default class RiskGame extends Phaser.Scene {
if (tradeBtn.disabledForTrade) return; if (tradeBtn.disabledForTrade) return;
const ids = [...selected].map((i) => cards[i].id); const ids = [...selected].map((i) => cards[i].id);
this.gs = tradeCards(this.gs, ids); this.gs = tradeCards(this.gs, ids);
this.portraitHappy(seat);
playSound(this, SFX.COINS); playSound(this, SFX.COINS);
close(); close();
if (mustTradeCards(this.gs, seat)) { this.openCardsModal(); return; } if (mustTradeCards(this.gs, seat)) { this.openCardsModal(); return; }
@ -1039,6 +1051,7 @@ export default class RiskGame extends Phaser.Scene {
if (this.gs.phase !== this._lastAnnouncedPhase) { if (this.gs.phase !== this._lastAnnouncedPhase) {
this._lastAnnouncedPhase = this.gs.phase; this._lastAnnouncedPhase = this.gs.phase;
this.announcePhase(this.gs.phase); this.announcePhase(this.gs.phase);
if (this.gs.phase === 'attack') this._initAtkSnap();
} }
return; return;
} }
@ -1094,7 +1107,7 @@ export default class RiskGame extends Phaser.Scene {
async runAiTurn(seat) { async runAiTurn(seat) {
const skill = this.gs.players[seat].skill; const skill = this.gs.players[seat].skill;
this.setPortraitThinking(seat, true);
await this.delay(nextThinkDelay(skill)); await this.delay(nextThinkDelay(skill));
// reinforce: trades then placements // reinforce: trades then placements
@ -1103,6 +1116,7 @@ export default class RiskGame extends Phaser.Scene {
const set = chooseTrade(this.gs, seat, skill); const set = chooseTrade(this.gs, seat, skill);
if (!set) break; if (!set) break;
this.gs = tradeCards(this.gs, set); this.gs = tradeCards(this.gs, set);
this.portraitHappy(seat);
this.render(); await this.delay(420); this.render(); await this.delay(420);
} }
for (const step of planReinforcements(this.gs, seat, skill)) { for (const step of planReinforcements(this.gs, seat, skill)) {
@ -1118,21 +1132,32 @@ export default class RiskGame extends Phaser.Scene {
// attack // attack
g = 0; g = 0;
this._initAtkSnap();
while (this.gs.phase === 'attack' && g++ < 400) { while (this.gs.phase === 'attack' && g++ < 400) {
const atk = chooseAttack(this.gs, seat, skill); const atk = chooseAttack(this.gs, seat, skill);
if (!atk) { this.gs = endAttack(this.gs); break; } if (!atk) { this.gs = endAttack(this.gs); break; }
const defenderArmiesBefore = this.gs.armies[atk.to];
const defenderSeat = this.gs.owner[atk.to];
const ownedContsBefore = new Set(CONTINENTS.filter((c) => ownsContinent(this.gs, seat, c.id)).map((c) => c.id));
const defContsBefore = new Set(CONTINENTS.filter((c) => ownsContinent(this.gs, defenderSeat, c.id)).map((c) => c.id));
this.gs = resolveAttack(this.gs, atk.from, atk.to, atk.numDice); this.gs = resolveAttack(this.gs, atk.from, atk.to, atk.numDice);
await this.animateBattle(this.gs.lastBattle); await this.animateBattle(this.gs.lastBattle);
this._checkArmyLosses();
if (this.gs.pendingConquest) { if (this.gs.pendingConquest) {
const n = chooseAdvance(this.gs, seat, skill); const n = chooseAdvance(this.gs, seat, skill);
const { from: cFrom, to: cTo } = this.gs.lastBattle; const { from: cFrom, to: cTo } = this.gs.lastBattle;
this.gs = advanceArmies(this.gs, n); this.gs = advanceArmies(this.gs, n);
this.render(); this.render();
await this.animateConquest(cFrom, cTo, n, seat); await this.animateConquest(cFrom, cTo, n, seat);
const newCont = CONTINENTS.some((c) => !ownedContsBefore.has(c.id) && ownsContinent(this.gs, seat, c.id));
if (defenderArmiesBefore >= 5 || newCont) this.portraitHappy(seat);
if (defContsBefore.size > 0 && CONTINENTS.some((c) => defContsBefore.has(c.id) && !ownsContinent(this.gs, defenderSeat, c.id))) {
this.portraitUpset(defenderSeat);
}
} else { } else {
this.render(); this.render();
} }
if (isGameOver(this.gs)) { this.setPortraitThinking(seat, false); return; } if (isGameOver(this.gs)) return;
await this.delay(200); await this.delay(200);
} }
@ -1142,12 +1167,34 @@ export default class RiskGame extends Phaser.Scene {
this.gs = f ? fortify(this.gs, f.from, f.to, f.n) : endTurn(this.gs); this.gs = f ? fortify(this.gs, f.from, f.to, f.n) : endTurn(this.gs);
this.render(); await this.delay(220); this.render(); await this.delay(220);
} }
this.setPortraitThinking(seat, false);
} }
setPortraitThinking(seat, on) { portraitHappy(seat) {
const p = this.portraits.find((x) => x.seat === seat); this.portraits.find((x) => x.seat === seat)?.portrait?.playEmotion?.('happy');
p?.portrait?.playEmotion?.(on ? 'happy' : 'idle'); }
portraitUpset(seat) {
this.portraits.find((x) => x.seat === seat)?.portrait?.playEmotion?.('upset');
}
_initAtkSnap() {
const snap = Array.from({ length: this.gs.playerCount }, (_, s) =>
this.gs.armies.reduce((sum, n, t) => this.gs.owner[t] === s ? sum + n : sum, 0)
);
this._atkSnap = { snap, upsetFired: new Set() };
}
_checkArmyLosses() {
if (!this._atkSnap) return;
const { snap, upsetFired } = this._atkSnap;
for (let s = 0; s < this.gs.playerCount; s++) {
if (upsetFired.has(s) || !this.gs.players[s].alive) continue;
const cur = this.gs.armies.reduce((sum, n, t) => this.gs.owner[t] === s ? sum + n : sum, 0);
if (snap[s] - cur > 8) {
this.portraitUpset(s);
upsetFired.add(s);
}
}
} }
// ── game over ─────────────────────────────────────────────────────────────────── // ── game over ───────────────────────────────────────────────────────────────────