diff --git a/public/src/games/jewelquest/JewelQuestData.js b/public/src/games/jewelquest/JewelQuestData.js index b6df10b..03fa286 100644 --- a/public/src/games/jewelquest/JewelQuestData.js +++ b/public/src/games/jewelquest/JewelQuestData.js @@ -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.', }, diff --git a/public/src/games/jewelquest/JewelQuestGame.js b/public/src/games/jewelquest/JewelQuestGame.js index de34089..d2b29d9 100644 --- a/public/src/games/jewelquest/JewelQuestGame.js +++ b/public/src/games/jewelquest/JewelQuestGame.js @@ -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; } } diff --git a/public/src/games/jewelquest/JewelQuestLogic.js b/public/src/games/jewelquest/JewelQuestLogic.js index 0ed072c..430e311 100644 --- a/public/src/games/jewelquest/JewelQuestLogic.js +++ b/public/src/games/jewelquest/JewelQuestLogic.js @@ -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 }; } diff --git a/public/src/games/jewelquest/tutorial.md b/public/src/games/jewelquest/tutorial.md index bd9e687..1ce105e 100644 --- a/public/src/games/jewelquest/tutorial.md +++ b/public/src/games/jewelquest/tutorial.md @@ -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). diff --git a/server/scripts/verifyJewelQuest.js b/server/scripts/verifyJewelQuest.js index 1558eac..565c511 100644 --- a/server/scripts/verifyJewelQuest.js +++ b/server/scripts/verifyJewelQuest.js @@ -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);