diff --git a/public/src/games/risk/RiskGame.js b/public/src/games/risk/RiskGame.js index 18f0cb2..1e11b16 100644 --- a/public/src/games/risk/RiskGame.js +++ b/public/src/games/risk/RiskGame.js @@ -396,11 +396,15 @@ export default class RiskGame extends Phaser.Scene { if (this.gs.pendingConquest) { const pc = this.gs.pendingConquest; this.busy = false; - this.openMoveModal(pc.minMove, pc.maxMove, pc.minMove, (n) => { + this.openMoveModal(pc.minMove, pc.maxMove, pc.minMove, async (n) => { + this.busy = true; this.gs = advanceArmies(this.gs, n); // keep attacking from same territory if still able if (this.gs.owner[from] === before.current && this.gs.armies[from] < 2) this.selFrom = null; this.render(); + await this.animateConquest(from, to, n, this.humanSeat); + this.busy = false; + this.render(); if (isGameOver(this.gs)) this.showGameOver(); }); return; @@ -500,7 +504,7 @@ export default class RiskGame extends Phaser.Scene { let val = Math.max(min, Math.min(initial, max)); const objs = []; const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; - const W = 460, H = 240; + const W = 640, H = 240; const overlay = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive().setDepth(DEPTH.popup); objs.push(overlay); const panel = this.add.graphics().setDepth(DEPTH.popup + 1); objs.push(panel); panel.fillStyle(COLORS.panel, 1); panel.fillRoundedRect(cx - W / 2, cy - H / 2, W, H, 14); @@ -515,34 +519,374 @@ export default class RiskGame extends Phaser.Scene { fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.popup + 2); objs.push(rng); const upd = () => valTxt.setText(String(val)); - const minus = new Button(this, cx - 150, cy - 4, '−', () => { val = Math.max(min, val - 1); upd(); }, { width: 64, height: 64, fontSize: 34 }).setDepth(DEPTH.popup + 2); objs.push(minus); - const plus = new Button(this, cx + 150, cy - 4, '+', () => { val = Math.min(max, val + 1); upd(); }, { width: 64, height: 64, fontSize: 34 }).setDepth(DEPTH.popup + 2); objs.push(plus); + const btnMin = new Button(this, cx - 230, cy - 4, 'Min', () => { val = min; upd(); }, { width: 80, height: 64, fontSize: 20, variant: 'ghost' }).setDepth(DEPTH.popup + 2); objs.push(btnMin); + const minus = new Button(this, cx - 130, cy - 4, '−', () => { val = Math.max(min, val - 1); upd(); }, { width: 64, height: 64, fontSize: 34 }).setDepth(DEPTH.popup + 2); objs.push(minus); + const plus = new Button(this, cx + 130, cy - 4, '+', () => { val = Math.min(max, val + 1); upd(); }, { width: 64, height: 64, fontSize: 34 }).setDepth(DEPTH.popup + 2); objs.push(plus); + const btnMax = new Button(this, cx + 230, cy - 4, 'Max', () => { val = max; upd(); }, { width: 80, height: 64, fontSize: 20, variant: 'ghost' }).setDepth(DEPTH.popup + 2); objs.push(btnMax); const ok = new Button(this, cx, cy + H / 2 - 34, 'Confirm', () => { objs.forEach((o) => o.destroy()); onConfirm(val); }, { width: 260, height: 46, fontSize: 22 }).setDepth(DEPTH.popup + 2); objs.push(ok); } // ── combat animation ──────────────────────────────────────────────────────────── + async animateAttackArrow(b) { + const seat = this.gs.owner[b.from]; + const color = this.colorOf(seat); + const from = this.boardToScreen(TERRITORIES[b.from].x, TERRITORIES[b.from].y); + const to = this.boardToScreen(TERRITORIES[b.to].x, TERRITORIES[b.to].y); + + // Quadratic bezier control point arching upward (negative-Y) from the midpoint + const dx = to.x - from.x, dy = to.y - from.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + let perpX = -dy / len, perpY = dx / len; + if (perpY > 0) { perpX = -perpX; perpY = -perpY; } // always arch toward top of screen + const archH = Math.min(len * 0.35, 180); + const cpx = (from.x + to.x) / 2 + perpX * archH; + const cpy = (from.y + to.y) / 2 + perpY * archH; + + const g = this.add.graphics().setDepth(DEPTH.badge - 1); + const LINE_W = 6, STROKE_W = 3, ARROW_LEN = 26, ARROW_WID = 12; + + const drawArc = (t) => { + g.clear(); + const steps = Math.max(2, Math.ceil(t * 64)); + + // compute bezier points once, reuse for both passes + const pts = []; + for (let i = 0; i <= steps; i++) { + const tt = (i / steps) * t; + pts.push( + (1 - tt) * (1 - tt) * from.x + 2 * (1 - tt) * tt * cpx + tt * tt * to.x, + (1 - tt) * (1 - tt) * from.y + 2 * (1 - tt) * tt * cpy + tt * tt * to.y, + ); + } + + const drawPath = (w, col, alpha) => { + g.lineStyle(w, col, alpha); + g.beginPath(); + for (let i = 0; i <= steps; i++) { + if (i === 0) g.moveTo(pts[i * 2], pts[i * 2 + 1]); + else g.lineTo(pts[i * 2], pts[i * 2 + 1]); + } + g.strokePath(); + }; + + // white outline pass, then colored fill pass + drawPath(LINE_W + STROKE_W * 2, 0xffffff, 0.85); + drawPath(LINE_W, color, 0.92); + + // arrowhead at the current tip, aligned to the curve tangent + if (t > 0.05) { + const prevT = Math.max(0, t - 0.04); + const tx = (1-t)*(1-t)*from.x + 2*(1-t)*t*cpx + t*t*to.x; + const ty = (1-t)*(1-t)*from.y + 2*(1-t)*t*cpy + t*t*to.y; + const px = (1-prevT)*(1-prevT)*from.x + 2*(1-prevT)*prevT*cpx + prevT*prevT*to.x; + const py = (1-prevT)*(1-prevT)*from.y + 2*(1-prevT)*prevT*cpy + prevT*prevT*to.y; + const adx = tx - px, ady = ty - py; + const aLen = Math.sqrt(adx * adx + ady * ady) || 1; + const ax = adx / aLen, ay = ady / aLen; + const s = STROKE_W; + // white outline triangle slightly larger + g.fillStyle(0xffffff, 0.85); + g.fillTriangle( + tx + ax * s, ty + ay * s, + tx - ax * (ARROW_LEN + s) + ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) - ax * (ARROW_WID + s), + tx - ax * (ARROW_LEN + s) - ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) + ax * (ARROW_WID + s), + ); + // colored fill triangle + g.fillStyle(color, 0.95); + g.fillTriangle( + tx, ty, + tx - ax * ARROW_LEN + ay * ARROW_WID, ty - ay * ARROW_LEN - ax * ARROW_WID, + tx - ax * ARROW_LEN - ay * ARROW_WID, ty - ay * ARROW_LEN + ax * ARROW_WID, + ); + } + }; + + await new Promise((resolve) => { + const progress = { t: 0 }; + this.tweens.add({ + targets: progress, t: 1, duration: 1000, ease: 'Sine.easeInOut', + onUpdate: () => drawArc(progress.t), + onComplete: () => { drawArc(1); resolve(); }, + }); + }); + + return g; + } + async animateBattle(b) { if (!b) return; - playSound(this, SFX.DICE_ROLL); - const from = this.boardToScreen(TERRITORIES[b.from].x, TERRITORIES[b.from].y); - const to = this.boardToScreen(TERRITORIES[b.to].x, TERRITORIES[b.to].y); - const mx = (from.x + to.x) / 2, my = (from.y + to.y) / 2; - const objs = []; - const drawDie = (x, y, val, col) => { - const g = this.add.graphics().setDepth(DEPTH.dice); - g.fillStyle(col, 1); g.fillRoundedRect(x - 18, y - 18, 36, 36, 6); - g.lineStyle(2, 0x000000, 0.6); g.strokeRoundedRect(x - 18, y - 18, 36, 36, 6); - const t = this.add.text(x, y, String(val), { fontFamily: 'Righteous', fontSize: '22px', color: col === 0xffffff ? '#222' : '#fff' }).setOrigin(0.5).setDepth(DEPTH.dice + 1); - objs.push(g, t); + const arrowGfx = await this.animateAttackArrow(b); + await this.animateBattleModal(b); + arrowGfx?.destroy(); + } + + async animateBattleModal(b) { + const attackerSeat = b.attackerSeat ?? this.gs.owner[b.from]; + const defenderSeat = b.defenderSeat ?? this.gs.owner[b.to]; + const attackerColor = this.colorOf(attackerSeat); + const defenderColor = this.colorOf(defenderSeat); + const attackerColorHex = this.colorHexOf(attackerSeat); + const defenderColorHex = this.colorHexOf(defenderSeat); + const attackerName = this.gs.players[attackerSeat].name; + const defenderName = this.gs.players[defenderSeat].name; + + const W = 560, H = 320; + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const startPos = this.boardToScreen(TERRITORIES[b.to].x, TERRITORIES[b.to].y); + + // ── Container (all modal children use local coordinates) ────────────────── + const container = this.add.container(startPos.x, startPos.y).setDepth(DEPTH.popup).setScale(0.05); + + // Background panel + const bg = this.add.graphics(); + bg.fillStyle(0xf5f0e8, 0.97); + bg.fillRoundedRect(-W / 2, -H / 2, W, H, 14); + bg.lineStyle(3, COLORS.gold, 1); + bg.strokeRoundedRect(-W / 2, -H / 2, W, H, 14); + container.add(bg); + + // Heading — three separate text objects so each territory name uses its owner's color + const headY = -H / 2 + 36; + const headStyle = { fontFamily: 'Righteous', fontSize: '22px' }; + const tA = this.add.text(0, headY, TERRITORIES[b.from].name, { ...headStyle, color: attackerColorHex }).setOrigin(0.5); + const tSep = this.add.text(0, headY, ' ⚔ ', { ...headStyle, color: COLORS.goldHex }).setOrigin(0.5); + const tD = this.add.text(0, headY, TERRITORIES[b.to].name, { ...headStyle, color: defenderColorHex }).setOrigin(0.5); + // Reposition as a centered row now that widths are known + const totalW = tA.width + tSep.width + tD.width; + tA.setX( -totalW / 2 + tA.width / 2); + tSep.setX(-totalW / 2 + tA.width + tSep.width / 2); + tD.setX( -totalW / 2 + tA.width + tSep.width + tD.width / 2); + container.add([tA, tSep, tD]); + + // Divider + const div = this.add.graphics(); + div.lineStyle(1, COLORS.accent, 0.4); + div.lineBetween(-W / 2 + 20, -H / 2 + 64, W / 2 - 20, -H / 2 + 64); + container.add(div); + + // Player name labels + container.add(this.add.text(-110, -H / 2 + 82, attackerName, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: attackerColorHex, align: 'center', + }).setOrigin(0.5)); + container.add(this.add.text(110, -H / 2 + 82, defenderName, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: defenderColorHex, align: 'center', + }).setOrigin(0.5)); + + // ── Dice layout ────────────────────────────────────────────────────────── + const DIE = 52, STRIDE = 62; + const dieYs = (count) => { + const totalH = count * STRIDE - (STRIDE - DIE); + const top = -totalH / 2 + DIE / 2 + 30; // center in lower portion of modal + return Array.from({ length: count }, (_, i) => top + i * STRIDE); }; - b.aRolls.forEach((v, i) => drawDie(mx - 28, my - 26 + i * 40, v, 0xd83a3a)); - b.dRolls.forEach((v, i) => drawDie(mx + 28, my - 26 + i * 40, v, 0xffffff)); - const res = this.add.text(mx, my + 64, `−${b.aLoss} / −${b.dLoss}`, { - fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex, backgroundColor: '#000000aa', padding: { x: 6, y: 3 }, - }).setOrigin(0.5).setDepth(DEPTH.dice + 1); objs.push(res); + const aYs = dieYs(b.aRolls.length); + const dYs = dieYs(b.dRolls.length); + + const makeDie = (localX, localY, color) => { + const g = this.add.graphics(); + g.fillStyle(color, 1); + g.fillRoundedRect(localX - DIE / 2, localY - DIE / 2, DIE, DIE, 8); + g.lineStyle(3, 0xffffff, 0.6); + g.strokeRoundedRect(localX - DIE / 2, localY - DIE / 2, DIE, DIE, 8); + const t = this.add.text(localX, localY, '?', { + fontFamily: 'Righteous', fontSize: '28px', color: '#ffffff', + }).setOrigin(0.5); + container.add([g, t]); + return { g, t, localX, localY }; + }; + + const aDice = aYs.map((y) => makeDie(-110, y, attackerColor)); + const dDice = dYs.map((y) => makeDie( 110, y, defenderColor)); + + // ── Zoom-in tween ───────────────────────────────────────────────────────── + await new Promise((resolve) => { + this.tweens.add({ + targets: container, x: cx, y: cy, scaleX: 1, scaleY: 1, + duration: 1000, ease: 'Back.easeOut', onComplete: resolve, + }); + }); + + // ── Dice rolling ───────────────────────────────────────────────────────── + playSound(this, SFX.DICE_ROLL); + for (let tick = 0; tick < 10; tick++) { + aDice.forEach((d) => d.t.setText(String(Math.ceil(Math.random() * 6)))); + dDice.forEach((d) => d.t.setText(String(Math.ceil(Math.random() * 6)))); + await this.delay(60); + } + aDice.forEach((d, i) => d.t.setText(String(b.aRolls[i]))); + dDice.forEach((d, i) => d.t.setText(String(b.dRolls[i]))); + + // ── Pair-by-pair resolution arrows ──────────────────────────────────────── + const pairs = Math.min(b.aRolls.length, b.dRolls.length); + const diceArrows = []; + for (let i = 0; i < pairs; i++) { + const aWins = b.aRolls[i] > b.dRolls[i]; + const winCol = aWins ? attackerColor : defenderColor; + const fromPos = { x: cx + (aWins ? -110 : 110), y: cy + (aWins ? aYs[i] : dYs[i]) }; + const toPos = { x: cx + (aWins ? 110 : -110), y: cy + (aWins ? dYs[i] : aYs[i]) }; + const loser = aWins ? dDice[i] : aDice[i]; + + const ag = await this.animateDiceArrow(fromPos, toPos, winCol); + diceArrows.push(ag); + loser.g.setAlpha(0.22); + loser.t.setAlpha(0.22); + await this.delay(200); + } + if (b.conquered) playSound(this, SFX.SWORD_HIT); - await this.delay(b.conquered ? 720 : 560); - objs.forEach((o) => o.destroy()); + await this.delay(600); + + container.destroy(true); + diceArrows.forEach((g) => g.destroy()); + } + + async animateDiceArrow(from, to, color) { + const dx = to.x - from.x, dy = to.y - from.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + let perpX = -dy / len, perpY = dx / len; + if (perpY > 0) { perpX = -perpX; perpY = -perpY; } + const archH = Math.min(len * 0.5, 80); + const cpx = (from.x + to.x) / 2 + perpX * archH; + const cpy = (from.y + to.y) / 2 + perpY * archH; + + const g = this.add.graphics().setDepth(DEPTH.popup + 2); + const LINE_W = 4, STROKE_W = 2, ARROW_LEN = 18, ARROW_WID = 9; + + const drawArc = (t) => { + g.clear(); + const steps = Math.max(2, Math.ceil(t * 48)); + const pts = []; + for (let i = 0; i <= steps; i++) { + const tt = (i / steps) * t; + pts.push( + (1 - tt) * (1 - tt) * from.x + 2 * (1 - tt) * tt * cpx + tt * tt * to.x, + (1 - tt) * (1 - tt) * from.y + 2 * (1 - tt) * tt * cpy + tt * tt * to.y, + ); + } + const drawPath = (w, col, alpha) => { + g.lineStyle(w, col, alpha); + g.beginPath(); + for (let i = 0; i <= steps; i++) { + if (i === 0) g.moveTo(pts[i * 2], pts[i * 2 + 1]); + else g.lineTo(pts[i * 2], pts[i * 2 + 1]); + } + g.strokePath(); + }; + drawPath(LINE_W + STROKE_W * 2, 0xffffff, 0.85); + drawPath(LINE_W, color, 0.92); + + if (t > 0.05) { + const prevT = Math.max(0, t - 0.04); + const tx = (1-t)*(1-t)*from.x + 2*(1-t)*t*cpx + t*t*to.x; + const ty = (1-t)*(1-t)*from.y + 2*(1-t)*t*cpy + t*t*to.y; + const px = (1-prevT)*(1-prevT)*from.x + 2*(1-prevT)*prevT*cpx + prevT*prevT*to.x; + const py = (1-prevT)*(1-prevT)*from.y + 2*(1-prevT)*prevT*cpy + prevT*prevT*to.y; + const adx = tx - px, ady = ty - py; + const aLen = Math.sqrt(adx * adx + ady * ady) || 1; + const ax = adx / aLen, ay = ady / aLen; + const s = STROKE_W; + g.fillStyle(0xffffff, 0.85); + g.fillTriangle( + tx + ax * s, ty + ay * s, + tx - ax * (ARROW_LEN + s) + ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) - ax * (ARROW_WID + s), + tx - ax * (ARROW_LEN + s) - ay * (ARROW_WID + s), ty - ay * (ARROW_LEN + s) + ax * (ARROW_WID + s), + ); + g.fillStyle(color, 0.95); + g.fillTriangle( + tx, ty, + tx - ax * ARROW_LEN + ay * ARROW_WID, ty - ay * ARROW_LEN - ax * ARROW_WID, + tx - ax * ARROW_LEN - ay * ARROW_WID, ty - ay * ARROW_LEN + ax * ARROW_WID, + ); + } + }; + + await new Promise((resolve) => { + const progress = { t: 0 }; + this.tweens.add({ + targets: progress, t: 1, duration: 1000, ease: 'Sine.easeInOut', + onUpdate: () => drawArc(progress.t), + onComplete: () => { drawArc(1); resolve(); }, + }); + }); + + return g; + } + + async animateConquest(fromId, toId, n, attackerSeat) { + const color = this.colorOf(attackerSeat); + const from = this.boardToScreen(TERRITORIES[fromId].x, TERRITORIES[fromId].y); + const to = this.boardToScreen(TERRITORIES[toId].x, TERRITORIES[toId].y); + + const dx = to.x - from.x, dy = to.y - from.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const ax = dx / len, ay = dy / len; + + const LINE_W = 6, STROKE_W = 3, ARROW_LEN = 26, ARROW_WID = 12; + const g = this.add.graphics().setDepth(DEPTH.badge - 1); + + const drawLine = (t) => { + g.clear(); + const tipX = from.x + dx * t, tipY = from.y + dy * t; + g.lineStyle(LINE_W + STROKE_W * 2, 0xffffff, 0.85); + g.beginPath(); g.moveTo(from.x, from.y); g.lineTo(tipX, tipY); g.strokePath(); + g.lineStyle(LINE_W, color, 0.92); + g.beginPath(); g.moveTo(from.x, from.y); g.lineTo(tipX, tipY); g.strokePath(); + if (t > 0.05) { + const s = STROKE_W; + g.fillStyle(0xffffff, 0.85); + g.fillTriangle( + tipX + ax * s, tipY + ay * s, + tipX - ax * (ARROW_LEN + s) + ay * (ARROW_WID + s), tipY - ay * (ARROW_LEN + s) - ax * (ARROW_WID + s), + tipX - ax * (ARROW_LEN + s) - ay * (ARROW_WID + s), tipY - ay * (ARROW_LEN + s) + ax * (ARROW_WID + s), + ); + g.fillStyle(color, 0.95); + g.fillTriangle( + tipX, tipY, + tipX - ax * ARROW_LEN + ay * ARROW_WID, tipY - ay * ARROW_LEN - ax * ARROW_WID, + tipX - ax * ARROW_LEN - ay * ARROW_WID, tipY - ay * ARROW_LEN + ax * ARROW_WID, + ); + } + }; + + // Phase 1: draw straight line with arrow over 1 s + await new Promise((resolve) => { + const progress = { t: 0 }; + this.tweens.add({ + targets: progress, t: 1, duration: 1000, ease: 'Sine.easeInOut', + onUpdate: () => drawLine(progress.t), + onComplete: () => { drawLine(1); resolve(); }, + }); + }); + + // Phase 2: army count floats rise over 1 s while arrow stays visible + const lossText = this.add.text(from.x, from.y - BADGE_R - 8, `−${n}`, { + fontFamily: 'Righteous', fontSize: '26px', color: COLORS.dangerHex, + stroke: '#000000', strokeThickness: 3, + }).setOrigin(0.5).setDepth(DEPTH.label + 1); + const gainText = this.add.text(to.x, to.y - BADGE_R - 8, `+${n}`, { + fontFamily: 'Righteous', fontSize: '26px', color: '#4fd06a', + stroke: '#000000', strokeThickness: 3, + }).setOrigin(0.5).setDepth(DEPTH.label + 1); + + await new Promise((resolve) => { + this.tweens.add({ + targets: [lossText, gainText], y: '-=55', duration: 1000, + ease: 'Sine.easeOut', onComplete: resolve, + }); + }); + + // Phase 3: fade everything out + await new Promise((resolve) => { + this.tweens.add({ targets: g, alpha: 0, duration: 400, ease: 'Linear' }); + this.tweens.add({ + targets: [lossText, gainText], alpha: 0, duration: 400, + ease: 'Linear', onComplete: resolve, + }); + }); + + g.destroy(); + lossText.destroy(); + gainText.destroy(); } // ── turn loop ─────────────────────────────────────────────────────────────────── @@ -592,8 +936,15 @@ export default class RiskGame extends Phaser.Scene { if (!atk) { this.gs = endAttack(this.gs); break; } this.gs = resolveAttack(this.gs, atk.from, atk.to, atk.numDice); await this.animateBattle(this.gs.lastBattle); - if (this.gs.pendingConquest) this.gs = advanceArmies(this.gs, chooseAdvance(this.gs, seat, skill)); - this.render(); + if (this.gs.pendingConquest) { + const n = chooseAdvance(this.gs, seat, skill); + const { from: cFrom, to: cTo } = this.gs.lastBattle; + this.gs = advanceArmies(this.gs, n); + this.render(); + await this.animateConquest(cFrom, cTo, n, seat); + } else { + this.render(); + } if (isGameOver(this.gs)) { this.setPortraitThinking(seat, false); return; } await this.delay(200); } diff --git a/public/src/games/risk/RiskLogic.js b/public/src/games/risk/RiskLogic.js index db6df21..f3d9b6d 100644 --- a/public/src/games/risk/RiskLogic.js +++ b/public/src/games/risk/RiskLogic.js @@ -234,7 +234,7 @@ export function resolveAttack(s0, from, to, numDice) { s.armies[to] -= dLoss; const conquered = s.armies[to] <= 0; - s.lastBattle = { from, to, aRolls, dRolls, aLoss, dLoss, conquered }; + s.lastBattle = { from, to, aRolls, dRolls, aLoss, dLoss, conquered, attackerSeat: seat, defenderSeat: s.owner[to] }; if (conquered) { const defender = s.owner[to];