feat(risk): add rich combat animations and enhanced move modal
- Add animated attack arrows with quadratic bezier curves from attacker to defender - Implement battle modal that zooms from battlefield to center, shows animated dice rolling, and resolves pair-by-pair with colored arrows - Add conquest animation with straight-line arrow and floating +/- army count text - Store attacker/defender seat info in lastBattle for proper color theming - Add Min/Max quick-select buttons to army deployment modal - Ensure busy state is held during conquest animations to prevent input conflicts
This commit is contained in:
parent
036947885d
commit
cb506696a6
|
|
@ -396,11 +396,15 @@ export default class RiskGame extends Phaser.Scene {
|
||||||
if (this.gs.pendingConquest) {
|
if (this.gs.pendingConquest) {
|
||||||
const pc = this.gs.pendingConquest;
|
const pc = this.gs.pendingConquest;
|
||||||
this.busy = false;
|
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);
|
this.gs = advanceArmies(this.gs, n);
|
||||||
// keep attacking from same territory if still able
|
// keep attacking from same territory if still able
|
||||||
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);
|
||||||
|
this.busy = false;
|
||||||
|
this.render();
|
||||||
if (isGameOver(this.gs)) this.showGameOver();
|
if (isGameOver(this.gs)) this.showGameOver();
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -500,7 +504,7 @@ export default class RiskGame extends Phaser.Scene {
|
||||||
let val = Math.max(min, Math.min(initial, max));
|
let val = Math.max(min, Math.min(initial, max));
|
||||||
const objs = [];
|
const objs = [];
|
||||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
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 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);
|
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);
|
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,
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||||
}).setOrigin(0.5).setDepth(DEPTH.popup + 2); objs.push(rng);
|
}).setOrigin(0.5).setDepth(DEPTH.popup + 2); objs.push(rng);
|
||||||
const upd = () => valTxt.setText(String(val));
|
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 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 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 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);
|
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 ────────────────────────────────────────────────────────────
|
// ── combat animation ────────────────────────────────────────────────────────────
|
||||||
async animateBattle(b) {
|
async animateAttackArrow(b) {
|
||||||
if (!b) return;
|
const seat = this.gs.owner[b.from];
|
||||||
playSound(this, SFX.DICE_ROLL);
|
const color = this.colorOf(seat);
|
||||||
const from = this.boardToScreen(TERRITORIES[b.from].x, TERRITORIES[b.from].y);
|
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 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 = [];
|
// Quadratic bezier control point arching upward (negative-Y) from the midpoint
|
||||||
const drawDie = (x, y, val, col) => {
|
const dx = to.x - from.x, dy = to.y - from.y;
|
||||||
const g = this.add.graphics().setDepth(DEPTH.dice);
|
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
g.fillStyle(col, 1); g.fillRoundedRect(x - 18, y - 18, 36, 36, 6);
|
let perpX = -dy / len, perpY = dx / len;
|
||||||
g.lineStyle(2, 0x000000, 0.6); g.strokeRoundedRect(x - 18, y - 18, 36, 36, 6);
|
if (perpY > 0) { perpX = -perpX; perpY = -perpY; } // always arch toward top of screen
|
||||||
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);
|
const archH = Math.min(len * 0.35, 180);
|
||||||
objs.push(g, t);
|
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();
|
||||||
};
|
};
|
||||||
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));
|
// white outline pass, then colored fill pass
|
||||||
const res = this.add.text(mx, my + 64, `−${b.aLoss} / −${b.dLoss}`, {
|
drawPath(LINE_W + STROKE_W * 2, 0xffffff, 0.85);
|
||||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex, backgroundColor: '#000000aa', padding: { x: 6, y: 3 },
|
drawPath(LINE_W, color, 0.92);
|
||||||
}).setOrigin(0.5).setDepth(DEPTH.dice + 1); objs.push(res);
|
|
||||||
|
// 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;
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
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);
|
if (b.conquered) playSound(this, SFX.SWORD_HIT);
|
||||||
await this.delay(b.conquered ? 720 : 560);
|
await this.delay(600);
|
||||||
objs.forEach((o) => o.destroy());
|
|
||||||
|
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 ───────────────────────────────────────────────────────────────────
|
// ── turn loop ───────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -592,8 +936,15 @@ export default class RiskGame extends Phaser.Scene {
|
||||||
if (!atk) { this.gs = endAttack(this.gs); break; }
|
if (!atk) { this.gs = endAttack(this.gs); break; }
|
||||||
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);
|
||||||
if (this.gs.pendingConquest) this.gs = advanceArmies(this.gs, chooseAdvance(this.gs, seat, skill));
|
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();
|
this.render();
|
||||||
|
await this.animateConquest(cFrom, cTo, n, seat);
|
||||||
|
} else {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
if (isGameOver(this.gs)) { this.setPortraitThinking(seat, false); return; }
|
if (isGameOver(this.gs)) { this.setPortraitThinking(seat, false); return; }
|
||||||
await this.delay(200);
|
await this.delay(200);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ export function resolveAttack(s0, from, to, numDice) {
|
||||||
s.armies[to] -= dLoss;
|
s.armies[to] -= dLoss;
|
||||||
|
|
||||||
const conquered = s.armies[to] <= 0;
|
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) {
|
if (conquered) {
|
||||||
const defender = s.owner[to];
|
const defender = s.owner[to];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue