feat(jewelquest): spell chaining and combat animations
- Allow spell chaining within a turn (Puzzle Quest rule): casting a spell no longer ends your turn, enabling multiple casts per turn followed by a gem swap to pass play. - Add procedural spell-icon textures (shield, swords, bolt, flame, meteor, thorn, poison, dagger, banner, leaf) for visual spell feedback. - Implement combat animations: skull-match convergence strike, spell damage fly-in, spell heal burst with particle ring, HP bar flash, and floating damage/heal numbers. - Refactor HP and mana updates into separate `updateHpBar` and `updateManaBar` methods so mana reacts immediately while HP drains at animation climax. - Defer stun application until the caster's turn actually ends. - Update tutorial and verification tests to match the new turn-passing rules.
This commit is contained in:
parent
59ee3eca0c
commit
2b224ff62b
|
|
@ -17,17 +17,17 @@
|
|||
export const SPELLS = {
|
||||
// ── Knight — skulls and raw damage ─────────────────────────────────────────
|
||||
shieldBash: {
|
||||
name: 'Shield Bash', cost: { red: 4 },
|
||||
name: 'Shield Bash', cost: { red: 4 }, icon: 'shield',
|
||||
effects: [{ kind: 'damage', amount: 3 }],
|
||||
desc: 'Slam your shield into the enemy for 3 damage.',
|
||||
},
|
||||
rallyingCry: {
|
||||
name: 'Rallying Cry', cost: { yellow: 6 },
|
||||
name: 'Rallying Cry', cost: { yellow: 6 }, icon: 'banner',
|
||||
effects: [{ kind: 'heal', amount: 6 }],
|
||||
desc: 'Steel your resolve and recover 6 life.',
|
||||
},
|
||||
cleave: {
|
||||
name: 'Cleave', cost: { red: 8, yellow: 4 },
|
||||
name: 'Cleave', cost: { red: 8, yellow: 4 }, icon: 'swords',
|
||||
effects: [{ kind: 'damage', amount: 8 }],
|
||||
desc: 'A mighty two-handed blow for 8 damage.',
|
||||
},
|
||||
|
|
@ -37,14 +37,14 @@ export const SPELLS = {
|
|||
desc: 'Forge 4 random gems into skulls.',
|
||||
},
|
||||
crusadersWrath: {
|
||||
name: "Crusader's Wrath", cost: { red: 12, yellow: 8 },
|
||||
name: "Crusader's Wrath", cost: { red: 12, yellow: 8 }, icon: 'swords',
|
||||
effects: [{ kind: 'buffSkullDamage', amount: 2 }, { kind: 'damage', amount: 8 }],
|
||||
desc: 'Deal 8 damage; your skull matches deal +2 for the rest of the battle.',
|
||||
},
|
||||
|
||||
// ── Sorcerer — big mana, big bursts ────────────────────────────────────────
|
||||
spark: {
|
||||
name: 'Spark', cost: { blue: 3 },
|
||||
name: 'Spark', cost: { blue: 3 }, icon: 'bolt',
|
||||
effects: [{ kind: 'damage', amount: 3 }],
|
||||
desc: 'A crackle of arcane energy for 3 damage.',
|
||||
},
|
||||
|
|
@ -54,7 +54,7 @@ export const SPELLS = {
|
|||
desc: 'Destroy a random column — you collect all its mana.',
|
||||
},
|
||||
fireball: {
|
||||
name: 'Fireball', cost: { red: 12 },
|
||||
name: 'Fireball', cost: { red: 12 }, icon: 'flame',
|
||||
effects: [{ kind: 'damage', amount: 13 }],
|
||||
desc: 'A roaring blast of flame for 13 damage.',
|
||||
},
|
||||
|
|
@ -64,7 +64,7 @@ export const SPELLS = {
|
|||
desc: 'Turn every yellow gem on the board blue.',
|
||||
},
|
||||
meteorStorm: {
|
||||
name: 'Meteor Storm', cost: { red: 14, blue: 10 },
|
||||
name: 'Meteor Storm', cost: { red: 14, blue: 10 }, icon: 'meteor',
|
||||
effects: [
|
||||
{ kind: 'destroyGems', selector: { mode: 'random', count: 8 } },
|
||||
{ kind: 'damage', amount: 6 },
|
||||
|
|
@ -74,12 +74,12 @@ export const SPELLS = {
|
|||
|
||||
// ── Druid — healing and board control ──────────────────────────────────────
|
||||
regrowth: {
|
||||
name: 'Regrowth', cost: { green: 5 },
|
||||
name: 'Regrowth', cost: { green: 5 }, icon: 'leaf',
|
||||
effects: [{ kind: 'heal', amount: 8 }],
|
||||
desc: 'Soothing vines restore 8 life.',
|
||||
},
|
||||
thornLash: {
|
||||
name: 'Thorn Lash', cost: { green: 6 },
|
||||
name: 'Thorn Lash', cost: { green: 6 }, icon: 'thorn',
|
||||
effects: [{ kind: 'damage', amount: 5 }],
|
||||
desc: 'A whip of thorns for 5 damage.',
|
||||
},
|
||||
|
|
@ -94,7 +94,7 @@ export const SPELLS = {
|
|||
desc: 'Bloom 5 random gems into green mana.',
|
||||
},
|
||||
naturesBalance: {
|
||||
name: "Nature's Balance", cost: { green: 12, yellow: 8 },
|
||||
name: "Nature's Balance", cost: { green: 12, yellow: 8 }, icon: 'leaf',
|
||||
effects: [
|
||||
{ kind: 'heal', amount: 10 },
|
||||
{ kind: 'destroyGems', selector: { mode: 'skulls', harmless: true } },
|
||||
|
|
@ -104,7 +104,7 @@ export const SPELLS = {
|
|||
|
||||
// ── Assassin — debuffs and theft ───────────────────────────────────────────
|
||||
poisonDart: {
|
||||
name: 'Poison Dart', cost: { green: 4 },
|
||||
name: 'Poison Dart', cost: { green: 4 }, icon: 'poison',
|
||||
effects: [
|
||||
{ kind: 'damage', amount: 3 },
|
||||
{ kind: 'drainMana', amount: 3, color: 'largest' },
|
||||
|
|
@ -117,7 +117,7 @@ export const SPELLS = {
|
|||
desc: 'Steal 6 mana from the enemy\'s deepest pool.',
|
||||
},
|
||||
backstab: {
|
||||
name: 'Backstab', cost: { green: 8, blue: 4 },
|
||||
name: 'Backstab', cost: { green: 8, blue: 4 }, icon: 'dagger',
|
||||
effects: [{ kind: 'damage', amount: 9 }],
|
||||
desc: 'Strike from the shadows for 9 damage.',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ const GEM_HEX = { red: '#e04444', green: '#2ecc71', blue: '#3f8efc', yellow: '#f
|
|||
|
||||
const PANEL_X = [320, GAME_WIDTH - 320]; // player left, enemy right
|
||||
|
||||
const D = { felt: -2, frame: -1, grid: 0, cells: 5, fx: 12, ui: 30, overlay: 60, overlayUI: 62 };
|
||||
const D = { felt: -2, frame: -1, grid: 0, cells: 5, fx: 12, ui: 30, strike: 50, overlay: 60, overlayUI: 62 };
|
||||
const CENTER_X = GAME_WIDTH / 2; // 960
|
||||
const CENTER_Y = GAME_HEIGHT / 2; // 540
|
||||
const HP_Y = 376;
|
||||
const PORTRAIT_Y = 240;
|
||||
const REPLAY_DELAY = {
|
||||
swap: 240, clear: 340, fall: 180, refill: 230,
|
||||
spell: 750, damage: 380, heal: 380, mana: 380, buff: 380, stun: 550,
|
||||
|
|
@ -49,6 +53,7 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
this.match = null;
|
||||
this.overlayUp = false;
|
||||
this.playerClass = null;
|
||||
this.activeSpellId = null;
|
||||
}
|
||||
|
||||
async create() {
|
||||
|
|
@ -180,6 +185,129 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
g.strokeRoundedRect(3, 3, CELL - 6, CELL - 6, 16);
|
||||
g.generateTexture('jq-select', CELL, CELL);
|
||||
g.destroy();
|
||||
|
||||
this.makeIconTextures();
|
||||
}
|
||||
|
||||
// ── Procedural spell-icon + particle textures (for combat animations) ────────
|
||||
|
||||
makeIconTextures() {
|
||||
if (this.textures.exists('jq-icon-flame')) return;
|
||||
const S = 96;
|
||||
const STEEL = 0xd8dee8;
|
||||
const ORANGE = 0xe98b2a;
|
||||
|
||||
const icon = (key, draw) => {
|
||||
const g = this.make.graphics({ add: false });
|
||||
draw(g);
|
||||
g.generateTexture(`jq-icon-${key}`, S, S);
|
||||
g.destroy();
|
||||
};
|
||||
|
||||
icon('shield', (g) => {
|
||||
g.fillStyle(GEM_INT.red, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(48, 14); g.lineTo(80, 26); g.lineTo(76, 56);
|
||||
g.lineTo(48, 84); g.lineTo(20, 56); g.lineTo(16, 26);
|
||||
g.closePath(); g.fillPath();
|
||||
g.lineStyle(4, 0xffffff, 0.6); g.strokePath();
|
||||
g.fillStyle(0xffffff, 0.85); g.fillCircle(48, 46, 8);
|
||||
});
|
||||
|
||||
icon('swords', (g) => {
|
||||
g.lineStyle(11, STEEL, 1);
|
||||
g.lineBetween(20, 80, 76, 16);
|
||||
g.lineBetween(76, 80, 20, 16);
|
||||
g.lineStyle(8, GEM_INT.red, 1);
|
||||
g.lineBetween(12, 84, 30, 66);
|
||||
g.lineBetween(84, 84, 66, 66);
|
||||
});
|
||||
|
||||
icon('bolt', (g) => {
|
||||
g.fillStyle(GEM_INT.yellow, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(54, 12); g.lineTo(30, 52); g.lineTo(46, 52);
|
||||
g.lineTo(40, 84); g.lineTo(70, 40); g.lineTo(52, 40);
|
||||
g.closePath(); g.fillPath();
|
||||
g.lineStyle(3, 0xffffff, 0.7); g.strokePath();
|
||||
});
|
||||
|
||||
icon('flame', (g) => {
|
||||
g.fillStyle(ORANGE, 1);
|
||||
g.fillCircle(48, 58, 24);
|
||||
g.fillTriangle(24, 58, 72, 58, 48, 8);
|
||||
g.fillStyle(GEM_INT.yellow, 1);
|
||||
g.fillCircle(48, 60, 13);
|
||||
g.fillTriangle(35, 60, 61, 60, 48, 28);
|
||||
});
|
||||
|
||||
icon('meteor', (g) => {
|
||||
g.fillStyle(ORANGE, 0.9);
|
||||
g.fillTriangle(46, 44, 10, 10, 32, 52);
|
||||
g.fillStyle(GEM_INT.yellow, 0.85);
|
||||
g.fillTriangle(46, 42, 20, 16, 32, 46);
|
||||
g.fillStyle(0x8a6a55, 1);
|
||||
g.fillCircle(58, 58, 20);
|
||||
g.fillStyle(0x6f5343, 1);
|
||||
g.fillCircle(52, 52, 5); g.fillCircle(64, 62, 4);
|
||||
});
|
||||
|
||||
icon('thorn', (g) => {
|
||||
g.lineStyle(6, GEM_INT.green, 1);
|
||||
g.lineBetween(18, 82, 80, 16);
|
||||
g.fillStyle(GEM_INT.green, 1);
|
||||
g.fillTriangle(42, 56, 30, 50, 46, 42);
|
||||
g.fillTriangle(58, 40, 46, 32, 62, 26);
|
||||
g.fillTriangle(34, 64, 50, 62, 44, 76);
|
||||
});
|
||||
|
||||
icon('poison', (g) => {
|
||||
g.fillStyle(GEM_INT.green, 1);
|
||||
g.fillCircle(48, 58, 22);
|
||||
g.fillTriangle(28, 50, 68, 50, 48, 12);
|
||||
g.fillStyle(0xffffff, 0.5); g.fillCircle(40, 54, 5);
|
||||
g.fillStyle(0x0a2a14, 0.5);
|
||||
g.fillCircle(54, 64, 4); g.fillCircle(45, 70, 3);
|
||||
});
|
||||
|
||||
icon('dagger', (g) => {
|
||||
g.fillStyle(STEEL, 1);
|
||||
g.fillTriangle(48, 8, 40, 60, 56, 60);
|
||||
g.fillStyle(GEM_INT.red, 1);
|
||||
g.fillRect(33, 58, 30, 7);
|
||||
g.fillStyle(0x7a4a26, 1);
|
||||
g.fillRect(43, 65, 10, 21);
|
||||
g.fillStyle(GEM_INT.red, 1);
|
||||
g.fillCircle(48, 88, 5);
|
||||
});
|
||||
|
||||
icon('banner', (g) => {
|
||||
g.lineStyle(5, 0x9a7b3a, 1);
|
||||
g.lineBetween(30, 12, 30, 86);
|
||||
g.fillStyle(GEM_INT.yellow, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(33, 16); g.lineTo(78, 22); g.lineTo(70, 38);
|
||||
g.lineTo(78, 54); g.lineTo(33, 48);
|
||||
g.closePath(); g.fillPath();
|
||||
g.lineStyle(3, 0xffffff, 0.5); g.strokePath();
|
||||
});
|
||||
|
||||
icon('leaf', (g) => {
|
||||
g.fillStyle(GEM_INT.green, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(48, 14); g.lineTo(74, 50); g.lineTo(48, 86); g.lineTo(22, 50);
|
||||
g.closePath(); g.fillPath();
|
||||
g.lineStyle(3, 0x0a3a18, 0.8);
|
||||
g.lineBetween(48, 18, 48, 82);
|
||||
g.lineBetween(48, 40, 64, 34); g.lineBetween(48, 40, 32, 34);
|
||||
g.lineBetween(48, 58, 64, 64); g.lineBetween(48, 58, 32, 64);
|
||||
});
|
||||
|
||||
const sp = this.make.graphics({ add: false });
|
||||
sp.fillStyle(0xffffff, 0.35); sp.fillCircle(8, 8, 8);
|
||||
sp.fillStyle(0xffffff, 1); sp.fillCircle(8, 8, 4);
|
||||
sp.generateTexture('jq-spark', 16, 16);
|
||||
sp.destroy();
|
||||
}
|
||||
|
||||
textureFor(cell) {
|
||||
|
|
@ -893,6 +1021,154 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
return { x: x / cells.length, y: y / cells.length };
|
||||
}
|
||||
|
||||
// ── Combat animations ────────────────────────────────────────────────────────
|
||||
// Each returns the rough total duration (ms); processEvent returns that so the
|
||||
// replay queue holds the next event until the sequence finishes.
|
||||
|
||||
iconForSpell(spellId) {
|
||||
const key = SPELLS[spellId]?.icon ?? 'swords';
|
||||
return `jq-icon-${key}`;
|
||||
}
|
||||
|
||||
// White flash over a player's HP bar (used as the attack/heal lands).
|
||||
flashHpBar(idx, color = 0xffffff) {
|
||||
const flash = this.add.rectangle(PANEL_X[idx], HP_Y, 392, 36, color, 0.9).setDepth(D.strike);
|
||||
this.layer.add(flash);
|
||||
this.tweens.add({
|
||||
targets: flash, alpha: 0, duration: 150, yoyo: true, repeat: 1,
|
||||
onComplete: () => flash.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
// Shared tail: a "damage line" sized to the amount flies from center to the
|
||||
// target's HP bar, then flashes it and drains the HP in sync.
|
||||
strikeHpBar(targetIdx, amount, players, color = GEM_INT.red) {
|
||||
const lineW = Phaser.Math.Clamp(amount * 14, 36, 320);
|
||||
const line = this.add.rectangle(CENTER_X, CENTER_Y, lineW, 18, color)
|
||||
.setStrokeStyle(2, 0xffffff, 0.8).setScale(0.2, 1).setDepth(D.strike);
|
||||
this.layer.add(line);
|
||||
this.tweens.add({
|
||||
targets: line, scaleX: 1, duration: 180, ease: 'Back.easeOut',
|
||||
onComplete: () => {
|
||||
this.tweens.add({
|
||||
targets: line, x: PANEL_X[targetIdx], y: HP_Y, duration: 600, ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
line.destroy();
|
||||
if (!this.scene.isActive()) return;
|
||||
playSound(this, SFX.SWORD_SLICE);
|
||||
this.flashHpBar(targetIdx);
|
||||
this.updateHpBar(targetIdx, players, { tween: true });
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Skull match: matched skulls converge to center, merge into a big skull with
|
||||
// the total damage, then morph into the damage line and strike the foe.
|
||||
animateSkullStrike(e) {
|
||||
const skullGroups = (e.groups ?? []).filter((g) => g.cls === 'skull');
|
||||
const cells = skullGroups.flatMap((g) => g.cells);
|
||||
const totalDmg = skullGroups.reduce((s, g) => s + (g.damage || 0), 0);
|
||||
const targetIdx = 1 - e.actor;
|
||||
|
||||
for (const cell of cells) {
|
||||
const { x, y } = this.cellXY(cell.r, cell.c);
|
||||
const img = this.add.image(x, y, 'jq-skull').setDepth(D.strike);
|
||||
this.layer.add(img);
|
||||
this.tweens.add({
|
||||
targets: img, x: CENTER_X, y: CENTER_Y, scale: 1.15, duration: 480, ease: 'Cubic.easeIn',
|
||||
onComplete: () => img.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
this.time.delayedCall(480, () => {
|
||||
if (!this.scene.isActive()) return;
|
||||
const merged = this.add.image(CENTER_X, CENTER_Y, 'jq-skull').setScale(0.6).setDepth(D.strike);
|
||||
const label = this.add.text(CENTER_X, CENTER_Y + 6, `-${totalDmg}`, {
|
||||
fontFamily: 'Righteous', fontSize: '46px', color: GEM_HEX.red, stroke: '#000000', strokeThickness: 6,
|
||||
}).setOrigin(0.5).setScale(0.5).setDepth(D.strike + 1);
|
||||
this.layer.add([merged, label]);
|
||||
playSound(this, SFX.SWORD_HIT);
|
||||
this.tweens.add({ targets: merged, scale: 2.4, duration: 420, ease: 'Back.easeOut' });
|
||||
this.tweens.add({ targets: label, scale: 1.2, duration: 420, ease: 'Back.easeOut' });
|
||||
|
||||
this.time.delayedCall(560, () => {
|
||||
this.tweens.add({
|
||||
targets: [merged, label], alpha: 0, scale: '*=0.6', duration: 180,
|
||||
onComplete: () => { merged.destroy(); label.destroy(); },
|
||||
});
|
||||
if (!this.scene.isActive()) return;
|
||||
this.strikeHpBar(targetIdx, totalDmg, e.players, GEM_INT.red);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Damage spell: class/damage-type icon flies from caster portrait to center,
|
||||
// pulses, then morphs into the damage line and strikes the foe.
|
||||
animateSpellDamage(spellId, casterIdx, amount, players) {
|
||||
const icon = this.add.image(PANEL_X[casterIdx], PORTRAIT_Y, this.iconForSpell(spellId))
|
||||
.setScale(0.5).setDepth(D.strike);
|
||||
this.layer.add(icon);
|
||||
this.tweens.add({
|
||||
targets: icon, x: CENTER_X, y: CENTER_Y, scale: 2.2, duration: 700, ease: 'Cubic.easeOut',
|
||||
onComplete: () => {
|
||||
if (!this.scene.isActive()) { icon.destroy(); return; }
|
||||
playSound(this, SFX.SWORD_HIT);
|
||||
this.tweens.add({
|
||||
targets: icon, scale: 2.7, duration: 260, yoyo: true, ease: 'Sine.easeInOut',
|
||||
onComplete: () => {
|
||||
this.tweens.add({ targets: icon, alpha: 0, scale: '*=0.7', duration: 180, onComplete: () => icon.destroy() });
|
||||
if (!this.scene.isActive()) return;
|
||||
this.strikeHpBar(1 - casterIdx, amount, players, GEM_INT.red);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Healing spell: icon grows at center, flies back to the caster's portrait and
|
||||
// bursts a radiating particle effect while their HP fills up.
|
||||
animateSpellHeal(spellId, casterIdx, amount, players) {
|
||||
const icon = this.add.image(CENTER_X, CENTER_Y, this.iconForSpell(spellId))
|
||||
.setScale(0.4).setDepth(D.strike);
|
||||
this.layer.add(icon);
|
||||
this.tweens.add({
|
||||
targets: icon, scale: 2.2, duration: 700, ease: 'Back.easeOut',
|
||||
onComplete: () => {
|
||||
if (!this.scene.isActive()) { icon.destroy(); return; }
|
||||
this.tweens.add({
|
||||
targets: icon, x: PANEL_X[casterIdx], y: PORTRAIT_Y, scale: 0.7, duration: 600, ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
icon.destroy();
|
||||
if (!this.scene.isActive()) return;
|
||||
playSound(this, SFX.CARD_SHOW);
|
||||
this.radiateHeal(PANEL_X[casterIdx], PORTRAIT_Y);
|
||||
this.flashHpBar(casterIdx, 0x9be7b4);
|
||||
this.updateHpBar(casterIdx, players, { tween: true });
|
||||
this.floatText(PANEL_X[casterIdx], 300, `+${amount}`, '#9be7b4', PANEL_X[casterIdx], HP_Y);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Expanding ring + particle burst around a portrait (heal feedback).
|
||||
radiateHeal(x, y) {
|
||||
const ring = this.add.circle(x, y, 62, 0x2ecc71, 0)
|
||||
.setStrokeStyle(5, 0x9be7b4, 0.9).setDepth(D.strike);
|
||||
this.layer.add(ring);
|
||||
this.tweens.add({ targets: ring, scale: 1.8, alpha: 0, duration: 650, ease: 'Cubic.easeOut', onComplete: () => ring.destroy() });
|
||||
try {
|
||||
const em = this.add.particles(x, y, 'jq-spark', {
|
||||
speed: { min: 120, max: 260 }, angle: { min: 0, max: 360 }, lifespan: 650,
|
||||
scale: { start: 1.1, end: 0 }, quantity: 26, tint: 0x9be7b4, blendMode: 'ADD', emitting: false,
|
||||
}).setDepth(D.overlay); // scene-root emitter, renders above the board container
|
||||
em.explode(26);
|
||||
this.time.delayedCall(800, () => { try { em.destroy(); } catch (_) { /* scene torn down */ } });
|
||||
} catch (_) { /* particles optional */ }
|
||||
}
|
||||
|
||||
processEvent(e) {
|
||||
switch (e.type) {
|
||||
case 'swap':
|
||||
|
|
@ -900,15 +1176,13 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
this.flashCells([e.a, e.b], 0xffffff);
|
||||
break;
|
||||
case 'clear': {
|
||||
const hasSkullMatch = (e.groups ?? []).some((grp) => grp.cls === 'skull' && grp.cells.length >= 3);
|
||||
playSound(this, hasSkullMatch ? SFX.SWORD_HIT : (SFX.MASTERMIND_MATCH ?? SFX.CARD_SHOW));
|
||||
const hasSkull = (e.groups ?? []).some((grp) => grp.cls === 'skull');
|
||||
playSound(this, hasSkull ? SFX.SWORD_HIT : (SFX.MASTERMIND_MATCH ?? SFX.CARD_SHOW));
|
||||
const actorPanel = PANEL_X[e.actor];
|
||||
const foePanel = PANEL_X[1 - e.actor];
|
||||
for (const grp of e.groups ?? []) {
|
||||
const { x, y } = this.groupCentroid(grp.cells);
|
||||
if (grp.cls === 'skull') {
|
||||
this.flashCells(grp.cells, GEM_INT.red);
|
||||
this.floatText(x, y, `-${grp.damage}`, GEM_HEX.red, foePanel, 376);
|
||||
} else {
|
||||
this.flashCells(grp.cells, GEM_INT[grp.cls]);
|
||||
this.floatText(x, y, `+${grp.mana}`, GEM_HEX[grp.cls], actorPanel, 480);
|
||||
|
|
@ -919,6 +1193,13 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
this.oppPortrait?.playEmotion(e.actor === 0 ? 'upset' : 'happy');
|
||||
}
|
||||
this.renderBoardCells(e.board);
|
||||
if (hasSkull) {
|
||||
// Mana updates now; HP drains at the strike climax inside the animation.
|
||||
this.updateManaFrom(e);
|
||||
this.animateSkullStrike(e);
|
||||
this.refreshSpellButtons();
|
||||
return 2200;
|
||||
}
|
||||
this.updateMetersFrom(e);
|
||||
break;
|
||||
}
|
||||
|
|
@ -928,6 +1209,7 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
break;
|
||||
case 'spell': {
|
||||
playSound(this, SFX.CARD_SHOW);
|
||||
this.activeSpellId = e.spellId; // consumed by the next damage/heal event
|
||||
const caster = e.caster === 0 ? 'YOU' : this.opponent.name.toUpperCase();
|
||||
this.showCallout(`${caster}: ${e.name}!`, e.caster === 0 ? '#9be7b4' : '#ff8a8a');
|
||||
this.oppPortrait?.playEmotion(e.caster === 0 ? 'upset' : 'happy');
|
||||
|
|
@ -936,14 +1218,15 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
break;
|
||||
}
|
||||
case 'damage':
|
||||
playSound(this, SFX.SWORD_SLICE);
|
||||
this.floatText(PANEL_X[e.target], 320, `-${e.amount}`, GEM_HEX.red, PANEL_X[e.target], 376);
|
||||
this.updateMetersFrom(e);
|
||||
break;
|
||||
this.updateManaFrom(e);
|
||||
this.animateSpellDamage(this.activeSpellId, 1 - e.target, e.amount, e.players);
|
||||
this.refreshSpellButtons();
|
||||
return 2400;
|
||||
case 'heal':
|
||||
this.floatText(PANEL_X[e.target], 320, `+${e.amount}`, '#9be7b4', PANEL_X[e.target], 376);
|
||||
this.updateMetersFrom(e);
|
||||
break;
|
||||
this.updateManaFrom(e);
|
||||
this.animateSpellHeal(this.activeSpellId, e.target, e.amount, e.players);
|
||||
this.refreshSpellButtons();
|
||||
return 2400;
|
||||
case 'mana':
|
||||
this.floatText(PANEL_X[e.target], 480, `-${e.amount} ${e.color}`, GEM_HEX[e.color] ?? '#ffffff', PANEL_X[e.target], 520);
|
||||
this.updateMetersFrom(e);
|
||||
|
|
@ -1026,17 +1309,44 @@ export default class JewelQuestGame extends Phaser.Scene {
|
|||
const players = e?.players ?? this.match?.players;
|
||||
if (!players || !this.hpBars) return;
|
||||
for (const i of [0, 1]) {
|
||||
const p = players[i];
|
||||
const frac = Math.max(0, Math.min(1, p.hp / p.maxHp));
|
||||
this.hpBars[i].fill.width = this.hpBars[i].w * frac;
|
||||
this.hpBars[i].fill.setFillStyle(frac > 0.5 ? 0x2ecc71 : frac > 0.25 ? 0xf1c40f : 0xe04444);
|
||||
const buff = p.status?.skullBuff > 0 ? ` ⚔+${p.status.skullBuff}` : '';
|
||||
this.hpTexts[i].setText(`${p.hp} / ${p.maxHp}${buff}`);
|
||||
for (const color of MANA_COLORS) {
|
||||
const bar = this.manaBars[i][color];
|
||||
bar.fill.width = bar.w * Math.max(0, Math.min(1, p.mana[color] / MANA_CAP));
|
||||
this.manaTexts[i][color].setText(`${p.mana[color]}/${MANA_CAP}`);
|
||||
}
|
||||
this.updateHpBar(i, players);
|
||||
this.updateManaBar(i, players);
|
||||
}
|
||||
}
|
||||
|
||||
// Mana bars only — used by animated events so mana reacts immediately while
|
||||
// the HP change is deferred to the strike/heal climax.
|
||||
updateManaFrom(e) {
|
||||
const players = e?.players ?? this.match?.players;
|
||||
if (!players || !this.manaBars) return;
|
||||
for (const i of [0, 1]) this.updateManaBar(i, players);
|
||||
}
|
||||
|
||||
updateManaBar(i, players) {
|
||||
const p = players[i];
|
||||
for (const color of MANA_COLORS) {
|
||||
const bar = this.manaBars[i][color];
|
||||
bar.fill.width = bar.w * Math.max(0, Math.min(1, p.mana[color] / MANA_CAP));
|
||||
this.manaTexts[i][color].setText(`${p.mana[color]}/${MANA_CAP}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the HP bar to the snapshot value. When `tween` is true the fill width
|
||||
// eases from its current on-screen value to the new one (the climax of an
|
||||
// attack/heal animation); otherwise it snaps.
|
||||
updateHpBar(i, players, { tween = false } = {}) {
|
||||
if (!this.hpBars) return;
|
||||
const p = players[i];
|
||||
const frac = Math.max(0, Math.min(1, p.hp / p.maxHp));
|
||||
const w = this.hpBars[i].w * frac;
|
||||
const color = frac > 0.5 ? 0x2ecc71 : frac > 0.25 ? 0xf1c40f : 0xe04444;
|
||||
this.hpBars[i].fill.setFillStyle(color);
|
||||
const buff = p.status?.skullBuff > 0 ? ` ⚔+${p.status.skullBuff}` : '';
|
||||
this.hpTexts[i].setText(`${p.hp} / ${p.maxHp}${buff}`);
|
||||
if (tween) {
|
||||
this.tweens.add({ targets: this.hpBars[i].fill, width: w, duration: 360, ease: 'Cubic.easeOut' });
|
||||
} else {
|
||||
this.hpBars[i].fill.width = w;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -396,20 +396,25 @@ function resolveBoard(match, actorIdx, push) {
|
|||
|
||||
// Turn bookkeeping shared by swaps and spell casts. Guarantees the board has a
|
||||
// legal move before control returns (reshuffling if needed).
|
||||
function finishAction(match, extraTurn, push) {
|
||||
function finishAction(match, extraTurn, push, { passTurn = true } = {}) {
|
||||
if (match.over) {
|
||||
push('gameOver', { winner: match.winner });
|
||||
return;
|
||||
}
|
||||
if (extraTurn) {
|
||||
push('extraTurn', {});
|
||||
} else {
|
||||
match.turn = 1 - match.turn;
|
||||
const next = match.players[match.turn];
|
||||
if (next.status.stunned) {
|
||||
next.status.stunned = false;
|
||||
push('skipTurn', { skipped: match.turn });
|
||||
// Spells keep control (passTurn = false, Puzzle Quest rule): the caster may
|
||||
// cast again or make a gem move — only a swap (or an exhausted extra turn)
|
||||
// hands the turn to the opponent.
|
||||
if (passTurn) {
|
||||
if (extraTurn) {
|
||||
push('extraTurn', {});
|
||||
} else {
|
||||
match.turn = 1 - match.turn;
|
||||
const next = match.players[match.turn];
|
||||
if (next.status.stunned) {
|
||||
next.status.stunned = false;
|
||||
push('skipTurn', { skipped: match.turn });
|
||||
match.turn = 1 - match.turn;
|
||||
}
|
||||
}
|
||||
}
|
||||
// AI lookahead clones (match.sim) skip the move guard — the cost isn't
|
||||
|
|
@ -594,11 +599,11 @@ export function castSpell(match, spellId) {
|
|||
if (moves.length) push('fall', { moves });
|
||||
const filled = refill(match);
|
||||
if (filled.length) push('refill', { cells: filled });
|
||||
// Cascades triggered by the spell credit the caster (Puzzle Quest rule),
|
||||
// but spells never grant an extra turn.
|
||||
// Cascades triggered by the spell credit the caster (Puzzle Quest rule).
|
||||
resolveBoard(match, casterIdx, push);
|
||||
}
|
||||
finishAction(match, false, push);
|
||||
// Casting does not end the turn — the caster retains control (see finishAction).
|
||||
finishAction(match, false, push, { passTurn: false });
|
||||
return { legal: true, events };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ each rival on the ladder to unlock the next.
|
|||
- Matching **colored gems** (red, green, blue, yellow) fills your mana pools,
|
||||
up to 25 of each color.
|
||||
- Spend mana on your class's **spells** — healing, fireballs, stuns, mana
|
||||
theft, board-warping magic, and more. Casting a spell takes your turn.
|
||||
theft, board-warping magic, and more. Casting a spell **doesn't end your
|
||||
turn**: cast as many as you can afford, then make a gem move to pass play.
|
||||
- **Wildcard gems** match any color and **multiply** the mana from the run
|
||||
they complete (×2 or ×3).
|
||||
|
||||
|
|
|
|||
|
|
@ -251,16 +251,30 @@ console.log('Fixtures:');
|
|||
|
||||
console.log('Spell fixtures:');
|
||||
{
|
||||
// Damage spell: cost deducted, hp reduced, turn passes.
|
||||
// Damage spell: cost deducted, hp reduced, turn is KEPT (Puzzle Quest rule).
|
||||
const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'] });
|
||||
m.players[0].mana.red = 4;
|
||||
const res = castSpell(m, 'shieldBash');
|
||||
check('shieldBash legal', res.legal);
|
||||
check('shieldBash deals 3', m.players[1].hp === 47);
|
||||
check('mana deducted', m.players[0].mana.red === 0);
|
||||
check('casting ends the turn', m.turn === 1);
|
||||
check('casting keeps the turn', m.turn === 0);
|
||||
check('spellsCast recorded', m.players[0].stats.spellsCast === 1);
|
||||
}
|
||||
{
|
||||
// Spells chain within a turn: cast twice, then a gem swap ends the turn.
|
||||
const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']],
|
||||
{ classes: ['knight', 'druid'] });
|
||||
m.players[0].mana.red = 8;
|
||||
check('first cast legal', castSpell(m, 'shieldBash').legal);
|
||||
check("still the caster's turn after one spell", m.turn === 0);
|
||||
check('second cast legal (same turn)', castSpell(m, 'shieldBash').legal);
|
||||
check('both casts landed (50 - 3 - 3)', m.players[1].hp === 44);
|
||||
check("still the caster's turn after two spells", m.turn === 0);
|
||||
stubRng(m, ['yellow', 'blue']);
|
||||
const swap = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
|
||||
check('a gem swap ends the turn', swap.legal && m.turn === 1);
|
||||
}
|
||||
{
|
||||
// Unaffordable cast rejected without deduction.
|
||||
const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'] });
|
||||
|
|
@ -321,13 +335,21 @@ console.log('Spell fixtures:');
|
|||
check('board refilled after column destroy', fullBoard(m.board));
|
||||
}
|
||||
{
|
||||
// Stun: opponent's next turn is skipped.
|
||||
const m = fixtureMatch(BASE, [], { classes: ['druid', 'knight'] });
|
||||
// Stun is pending: a spell doesn't end the turn, so the opponent's skip is
|
||||
// applied when the caster's turn actually ends (a gem swap).
|
||||
const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']],
|
||||
{ classes: ['druid', 'knight'] });
|
||||
m.players[0].mana.green = 9;
|
||||
const res = castSpell(m, 'entangle');
|
||||
check('casting entangle keeps the caster in control', m.turn === 0);
|
||||
check('opponent marked stunned (pending)', m.players[1].status.stunned === true);
|
||||
check('no skipTurn emitted on the cast', !res.events.some((e) => e.type === 'skipTurn'));
|
||||
stubRng(m, ['yellow', 'blue']);
|
||||
const swap = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 });
|
||||
check('turn-ending swap is legal', swap.legal);
|
||||
check('stun returns the turn to the caster', m.turn === 0);
|
||||
check('stun flag cleared after the skip', m.players[1].status.stunned === false);
|
||||
check('skipTurn event emitted', res.events.some((e) => e.type === 'skipTurn'));
|
||||
check('skipTurn event emitted on turn end', swap.events.some((e) => e.type === 'skipTurn'));
|
||||
}
|
||||
{
|
||||
// Transform-all + spell-triggered cascade credits caster, never grants
|
||||
|
|
@ -353,7 +375,7 @@ console.log('Spell fixtures:');
|
|||
check('transmute converts exactly the 2 yellows', tf && tf.cells.length === 2);
|
||||
check('transform event board has no yellow', tf && countCells(tf.board, (x) => x.type === 'yellow') === 0);
|
||||
check('spell cascade credits caster 4 blue', m.players[0].mana.blue === 4, `got ${m.players[0].mana.blue}`);
|
||||
check('spell 4-run does NOT grant extra turn', m.turn === 1);
|
||||
check('spell keeps the turn (cascade 4-run grants no extra turn)', m.turn === 0);
|
||||
}
|
||||
{
|
||||
// Harmless skull destruction deals no damage.
|
||||
|
|
@ -468,7 +490,9 @@ function playGame({ skills, classes, seed, hp = [50, 50], invariants = false })
|
|||
createAI({ skill: skills[1], seed: seed * 13 + 5 }),
|
||||
];
|
||||
let turns = 0;
|
||||
const MAX_TURNS = 300;
|
||||
// Spells no longer end the turn, so a turn can span several actions — raise
|
||||
// the per-game action cap accordingly.
|
||||
const MAX_TURNS = 600;
|
||||
while (!match.over && turns < MAX_TURNS) {
|
||||
const pIdx = match.turn;
|
||||
const action = chooseAction(ais[pIdx], match, pIdx);
|
||||
|
|
|
|||
Loading…
Reference in New Issue