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:
Brian Fertig 2026-06-14 05:18:45 -06:00
parent 59ee3eca0c
commit 2b224ff62b
5 changed files with 395 additions and 55 deletions

View File

@ -17,17 +17,17 @@
export const SPELLS = { export const SPELLS = {
// ── Knight — skulls and raw damage ───────────────────────────────────────── // ── Knight — skulls and raw damage ─────────────────────────────────────────
shieldBash: { shieldBash: {
name: 'Shield Bash', cost: { red: 4 }, name: 'Shield Bash', cost: { red: 4 }, icon: 'shield',
effects: [{ kind: 'damage', amount: 3 }], effects: [{ kind: 'damage', amount: 3 }],
desc: 'Slam your shield into the enemy for 3 damage.', desc: 'Slam your shield into the enemy for 3 damage.',
}, },
rallyingCry: { rallyingCry: {
name: 'Rallying Cry', cost: { yellow: 6 }, name: 'Rallying Cry', cost: { yellow: 6 }, icon: 'banner',
effects: [{ kind: 'heal', amount: 6 }], effects: [{ kind: 'heal', amount: 6 }],
desc: 'Steel your resolve and recover 6 life.', desc: 'Steel your resolve and recover 6 life.',
}, },
cleave: { cleave: {
name: 'Cleave', cost: { red: 8, yellow: 4 }, name: 'Cleave', cost: { red: 8, yellow: 4 }, icon: 'swords',
effects: [{ kind: 'damage', amount: 8 }], effects: [{ kind: 'damage', amount: 8 }],
desc: 'A mighty two-handed blow for 8 damage.', desc: 'A mighty two-handed blow for 8 damage.',
}, },
@ -37,14 +37,14 @@ export const SPELLS = {
desc: 'Forge 4 random gems into skulls.', desc: 'Forge 4 random gems into skulls.',
}, },
crusadersWrath: { 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 }], effects: [{ kind: 'buffSkullDamage', amount: 2 }, { kind: 'damage', amount: 8 }],
desc: 'Deal 8 damage; your skull matches deal +2 for the rest of the battle.', desc: 'Deal 8 damage; your skull matches deal +2 for the rest of the battle.',
}, },
// ── Sorcerer — big mana, big bursts ──────────────────────────────────────── // ── Sorcerer — big mana, big bursts ────────────────────────────────────────
spark: { spark: {
name: 'Spark', cost: { blue: 3 }, name: 'Spark', cost: { blue: 3 }, icon: 'bolt',
effects: [{ kind: 'damage', amount: 3 }], effects: [{ kind: 'damage', amount: 3 }],
desc: 'A crackle of arcane energy for 3 damage.', 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.', desc: 'Destroy a random column — you collect all its mana.',
}, },
fireball: { fireball: {
name: 'Fireball', cost: { red: 12 }, name: 'Fireball', cost: { red: 12 }, icon: 'flame',
effects: [{ kind: 'damage', amount: 13 }], effects: [{ kind: 'damage', amount: 13 }],
desc: 'A roaring blast of flame for 13 damage.', 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.', desc: 'Turn every yellow gem on the board blue.',
}, },
meteorStorm: { meteorStorm: {
name: 'Meteor Storm', cost: { red: 14, blue: 10 }, name: 'Meteor Storm', cost: { red: 14, blue: 10 }, icon: 'meteor',
effects: [ effects: [
{ kind: 'destroyGems', selector: { mode: 'random', count: 8 } }, { kind: 'destroyGems', selector: { mode: 'random', count: 8 } },
{ kind: 'damage', amount: 6 }, { kind: 'damage', amount: 6 },
@ -74,12 +74,12 @@ export const SPELLS = {
// ── Druid — healing and board control ────────────────────────────────────── // ── Druid — healing and board control ──────────────────────────────────────
regrowth: { regrowth: {
name: 'Regrowth', cost: { green: 5 }, name: 'Regrowth', cost: { green: 5 }, icon: 'leaf',
effects: [{ kind: 'heal', amount: 8 }], effects: [{ kind: 'heal', amount: 8 }],
desc: 'Soothing vines restore 8 life.', desc: 'Soothing vines restore 8 life.',
}, },
thornLash: { thornLash: {
name: 'Thorn Lash', cost: { green: 6 }, name: 'Thorn Lash', cost: { green: 6 }, icon: 'thorn',
effects: [{ kind: 'damage', amount: 5 }], effects: [{ kind: 'damage', amount: 5 }],
desc: 'A whip of thorns for 5 damage.', desc: 'A whip of thorns for 5 damage.',
}, },
@ -94,7 +94,7 @@ export const SPELLS = {
desc: 'Bloom 5 random gems into green mana.', desc: 'Bloom 5 random gems into green mana.',
}, },
naturesBalance: { naturesBalance: {
name: "Nature's Balance", cost: { green: 12, yellow: 8 }, name: "Nature's Balance", cost: { green: 12, yellow: 8 }, icon: 'leaf',
effects: [ effects: [
{ kind: 'heal', amount: 10 }, { kind: 'heal', amount: 10 },
{ kind: 'destroyGems', selector: { mode: 'skulls', harmless: true } }, { kind: 'destroyGems', selector: { mode: 'skulls', harmless: true } },
@ -104,7 +104,7 @@ export const SPELLS = {
// ── Assassin — debuffs and theft ─────────────────────────────────────────── // ── Assassin — debuffs and theft ───────────────────────────────────────────
poisonDart: { poisonDart: {
name: 'Poison Dart', cost: { green: 4 }, name: 'Poison Dart', cost: { green: 4 }, icon: 'poison',
effects: [ effects: [
{ kind: 'damage', amount: 3 }, { kind: 'damage', amount: 3 },
{ kind: 'drainMana', amount: 3, color: 'largest' }, { kind: 'drainMana', amount: 3, color: 'largest' },
@ -117,7 +117,7 @@ export const SPELLS = {
desc: 'Steal 6 mana from the enemy\'s deepest pool.', desc: 'Steal 6 mana from the enemy\'s deepest pool.',
}, },
backstab: { backstab: {
name: 'Backstab', cost: { green: 8, blue: 4 }, name: 'Backstab', cost: { green: 8, blue: 4 }, icon: 'dagger',
effects: [{ kind: 'damage', amount: 9 }], effects: [{ kind: 'damage', amount: 9 }],
desc: 'Strike from the shadows for 9 damage.', desc: 'Strike from the shadows for 9 damage.',
}, },

View File

@ -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 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 = { const REPLAY_DELAY = {
swap: 240, clear: 340, fall: 180, refill: 230, swap: 240, clear: 340, fall: 180, refill: 230,
spell: 750, damage: 380, heal: 380, mana: 380, buff: 380, stun: 550, 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.match = null;
this.overlayUp = false; this.overlayUp = false;
this.playerClass = null; this.playerClass = null;
this.activeSpellId = null;
} }
async create() { async create() {
@ -180,6 +185,129 @@ export default class JewelQuestGame extends Phaser.Scene {
g.strokeRoundedRect(3, 3, CELL - 6, CELL - 6, 16); g.strokeRoundedRect(3, 3, CELL - 6, CELL - 6, 16);
g.generateTexture('jq-select', CELL, CELL); g.generateTexture('jq-select', CELL, CELL);
g.destroy(); 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) { textureFor(cell) {
@ -893,6 +1021,154 @@ export default class JewelQuestGame extends Phaser.Scene {
return { x: x / cells.length, y: y / cells.length }; 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) { processEvent(e) {
switch (e.type) { switch (e.type) {
case 'swap': case 'swap':
@ -900,15 +1176,13 @@ export default class JewelQuestGame extends Phaser.Scene {
this.flashCells([e.a, e.b], 0xffffff); this.flashCells([e.a, e.b], 0xffffff);
break; break;
case 'clear': { case 'clear': {
const hasSkullMatch = (e.groups ?? []).some((grp) => grp.cls === 'skull' && grp.cells.length >= 3); const hasSkull = (e.groups ?? []).some((grp) => grp.cls === 'skull');
playSound(this, hasSkullMatch ? SFX.SWORD_HIT : (SFX.MASTERMIND_MATCH ?? SFX.CARD_SHOW)); playSound(this, hasSkull ? SFX.SWORD_HIT : (SFX.MASTERMIND_MATCH ?? SFX.CARD_SHOW));
const actorPanel = PANEL_X[e.actor]; const actorPanel = PANEL_X[e.actor];
const foePanel = PANEL_X[1 - e.actor];
for (const grp of e.groups ?? []) { for (const grp of e.groups ?? []) {
const { x, y } = this.groupCentroid(grp.cells); const { x, y } = this.groupCentroid(grp.cells);
if (grp.cls === 'skull') { if (grp.cls === 'skull') {
this.flashCells(grp.cells, GEM_INT.red); this.flashCells(grp.cells, GEM_INT.red);
this.floatText(x, y, `-${grp.damage}`, GEM_HEX.red, foePanel, 376);
} else { } else {
this.flashCells(grp.cells, GEM_INT[grp.cls]); this.flashCells(grp.cells, GEM_INT[grp.cls]);
this.floatText(x, y, `+${grp.mana}`, GEM_HEX[grp.cls], actorPanel, 480); 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.oppPortrait?.playEmotion(e.actor === 0 ? 'upset' : 'happy');
} }
this.renderBoardCells(e.board); 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); this.updateMetersFrom(e);
break; break;
} }
@ -928,6 +1209,7 @@ export default class JewelQuestGame extends Phaser.Scene {
break; break;
case 'spell': { case 'spell': {
playSound(this, SFX.CARD_SHOW); 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(); const caster = e.caster === 0 ? 'YOU' : this.opponent.name.toUpperCase();
this.showCallout(`${caster}: ${e.name}!`, e.caster === 0 ? '#9be7b4' : '#ff8a8a'); this.showCallout(`${caster}: ${e.name}!`, e.caster === 0 ? '#9be7b4' : '#ff8a8a');
this.oppPortrait?.playEmotion(e.caster === 0 ? 'upset' : 'happy'); this.oppPortrait?.playEmotion(e.caster === 0 ? 'upset' : 'happy');
@ -936,14 +1218,15 @@ export default class JewelQuestGame extends Phaser.Scene {
break; break;
} }
case 'damage': case 'damage':
playSound(this, SFX.SWORD_SLICE); this.updateManaFrom(e);
this.floatText(PANEL_X[e.target], 320, `-${e.amount}`, GEM_HEX.red, PANEL_X[e.target], 376); this.animateSpellDamage(this.activeSpellId, 1 - e.target, e.amount, e.players);
this.updateMetersFrom(e); this.refreshSpellButtons();
break; return 2400;
case 'heal': case 'heal':
this.floatText(PANEL_X[e.target], 320, `+${e.amount}`, '#9be7b4', PANEL_X[e.target], 376); this.updateManaFrom(e);
this.updateMetersFrom(e); this.animateSpellHeal(this.activeSpellId, e.target, e.amount, e.players);
break; this.refreshSpellButtons();
return 2400;
case 'mana': case 'mana':
this.floatText(PANEL_X[e.target], 480, `-${e.amount} ${e.color}`, GEM_HEX[e.color] ?? '#ffffff', PANEL_X[e.target], 520); this.floatText(PANEL_X[e.target], 480, `-${e.amount} ${e.color}`, GEM_HEX[e.color] ?? '#ffffff', PANEL_X[e.target], 520);
this.updateMetersFrom(e); this.updateMetersFrom(e);
@ -1026,17 +1309,44 @@ export default class JewelQuestGame extends Phaser.Scene {
const players = e?.players ?? this.match?.players; const players = e?.players ?? this.match?.players;
if (!players || !this.hpBars) return; if (!players || !this.hpBars) return;
for (const i of [0, 1]) { for (const i of [0, 1]) {
const p = players[i]; this.updateHpBar(i, players);
const frac = Math.max(0, Math.min(1, p.hp / p.maxHp)); this.updateManaBar(i, players);
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}`); // Mana bars only — used by animated events so mana reacts immediately while
for (const color of MANA_COLORS) { // the HP change is deferred to the strike/heal climax.
const bar = this.manaBars[i][color]; updateManaFrom(e) {
bar.fill.width = bar.w * Math.max(0, Math.min(1, p.mana[color] / MANA_CAP)); const players = e?.players ?? this.match?.players;
this.manaTexts[i][color].setText(`${p.mana[color]}/${MANA_CAP}`); 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;
} }
} }

View File

@ -396,20 +396,25 @@ function resolveBoard(match, actorIdx, push) {
// Turn bookkeeping shared by swaps and spell casts. Guarantees the board has a // Turn bookkeeping shared by swaps and spell casts. Guarantees the board has a
// legal move before control returns (reshuffling if needed). // legal move before control returns (reshuffling if needed).
function finishAction(match, extraTurn, push) { function finishAction(match, extraTurn, push, { passTurn = true } = {}) {
if (match.over) { if (match.over) {
push('gameOver', { winner: match.winner }); push('gameOver', { winner: match.winner });
return; return;
} }
if (extraTurn) { // Spells keep control (passTurn = false, Puzzle Quest rule): the caster may
push('extraTurn', {}); // cast again or make a gem move — only a swap (or an exhausted extra turn)
} else { // hands the turn to the opponent.
match.turn = 1 - match.turn; if (passTurn) {
const next = match.players[match.turn]; if (extraTurn) {
if (next.status.stunned) { push('extraTurn', {});
next.status.stunned = false; } else {
push('skipTurn', { skipped: match.turn });
match.turn = 1 - match.turn; 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 // 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 }); if (moves.length) push('fall', { moves });
const filled = refill(match); const filled = refill(match);
if (filled.length) push('refill', { cells: filled }); if (filled.length) push('refill', { cells: filled });
// Cascades triggered by the spell credit the caster (Puzzle Quest rule), // Cascades triggered by the spell credit the caster (Puzzle Quest rule).
// but spells never grant an extra turn.
resolveBoard(match, casterIdx, push); 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 }; return { legal: true, events };
} }

View File

@ -17,7 +17,8 @@ each rival on the ladder to unlock the next.
- Matching **colored gems** (red, green, blue, yellow) fills your mana pools, - Matching **colored gems** (red, green, blue, yellow) fills your mana pools,
up to 25 of each color. up to 25 of each color.
- Spend mana on your class's **spells** — healing, fireballs, stuns, mana - 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 - **Wildcard gems** match any color and **multiply** the mana from the run
they complete (×2 or ×3). they complete (×2 or ×3).

View File

@ -251,16 +251,30 @@ console.log('Fixtures:');
console.log('Spell 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'] }); const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'] });
m.players[0].mana.red = 4; m.players[0].mana.red = 4;
const res = castSpell(m, 'shieldBash'); const res = castSpell(m, 'shieldBash');
check('shieldBash legal', res.legal); check('shieldBash legal', res.legal);
check('shieldBash deals 3', m.players[1].hp === 47); check('shieldBash deals 3', m.players[1].hp === 47);
check('mana deducted', m.players[0].mana.red === 0); 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); 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. // Unaffordable cast rejected without deduction.
const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'] }); const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'] });
@ -321,13 +335,21 @@ console.log('Spell fixtures:');
check('board refilled after column destroy', fullBoard(m.board)); check('board refilled after column destroy', fullBoard(m.board));
} }
{ {
// Stun: opponent's next turn is skipped. // Stun is pending: a spell doesn't end the turn, so the opponent's skip is
const m = fixtureMatch(BASE, [], { classes: ['druid', 'knight'] }); // 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; m.players[0].mana.green = 9;
const res = castSpell(m, 'entangle'); 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 returns the turn to the caster', m.turn === 0);
check('stun flag cleared after the skip', m.players[1].status.stunned === false); 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 // 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('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('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 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. // 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 }), createAI({ skill: skills[1], seed: seed * 13 + 5 }),
]; ];
let turns = 0; 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) { while (!match.over && turns < MAX_TURNS) {
const pIdx = match.turn; const pIdx = match.turn;
const action = chooseAction(ais[pIdx], match, pIdx); const action = chooseAction(ais[pIdx], match, pIdx);