diff --git a/genSpireClimbCreatures.js b/genSpireClimbCreatures.js new file mode 100644 index 0000000..f8bff29 --- /dev/null +++ b/genSpireClimbCreatures.js @@ -0,0 +1,176 @@ +// genSpireClimbCreatures.js — generates a PLACEHOLDER creature spritesheet for +// Spire Climb so the game ships with dedicated (non-shared) creature art that an +// artist can paint over. Pure Node (zlib only); no image libraries. +// +// node genSpireClimbCreatures.js +// +// Output: public/assets/images/spireclimb-creatures.png +// 10 frames, 300×300 each, laid out 5 columns × 2 rows (Phaser frame order is +// row-major, so frame 0 = top-left … frame 9 = bottom-right). The frame index +// under each creature matches /data/spireclimb-artwork.json "creatures". +// +// To replace with real art: keep the same 300×300 / 5×2 layout and frame order, +// drop your PNG at the same path. No code or JSON changes needed. + +import zlib from 'node:zlib'; +import { writeFileSync } from 'node:fs'; + +const FW = 300, FH = 300, COLS = 5, ROWS = 2; +const W = FW * COLS, H = FH * ROWS; + +// id → { frame, color, shape, tier } (mirrors SpireClimbData ENEMIES placeholders) +const CREATURES = [ + { id: 'jawworm', frame: 0, color: [0x9c, 0x6b, 0x3a], shape: 'worm', tier: 1 }, + { id: 'cultist', frame: 1, color: [0x6a, 0x4d, 0x8a], shape: 'robed', tier: 1 }, + { id: 'louse', frame: 2, color: [0xc0, 0x53, 0x3a], shape: 'bug', tier: 1 }, + { id: 'fungi', frame: 3, color: [0x4b, 0x8c, 0x5a], shape: 'shroom',tier: 1 }, + { id: 'spikeslime', frame: 4, color: [0x4a, 0x6b, 0xbf], shape: 'spiky', tier: 1 }, + { id: 'sentry', frame: 5, color: [0x9a, 0xa7, 0xb5], shape: 'orb', tier: 2 }, + { id: 'gremlinnob', frame: 6, color: [0xb5, 0x50, 0x3a], shape: 'horns', tier: 2 }, + { id: 'lagavulin', frame: 7, color: [0x3a, 0x7a, 0x6b], shape: 'slime', tier: 2 }, + { id: 'guardian', frame: 8, color: [0x8a, 0x6b, 0x3a], shape: 'golem', tier: 3 }, + { id: 'slimeboss', frame: 9, color: [0x4a, 0x6b, 0xbf], shape: 'slime', tier: 3 }, +]; + +// RGBA pixel buffer +const buf = new Uint8Array(W * H * 4); // transparent by default + +const px = (x, y, r, g, b, a = 255) => { + if (x < 0 || y < 0 || x >= W || y >= H) return; + const i = (y * W + x) * 4; + const ia = a / 255; + buf[i] = Math.round(r * ia + buf[i] * (1 - ia)); + buf[i + 1] = Math.round(g * ia + buf[i + 1] * (1 - ia)); + buf[i + 2] = Math.round(b * ia + buf[i + 2] * (1 - ia)); + buf[i + 3] = Math.max(buf[i + 3], a); +}; +const rect = (x0, y0, w, h, c, a = 255) => { for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) px(x, y, c[0], c[1], c[2], a); }; +const disc = (cx, cy, rad, c, a = 255) => { for (let y = cy - rad; y <= cy + rad; y++) for (let x = cx - rad; x <= cx + rad; x++) { const dx = x - cx, dy = y - cy; if (dx * dx + dy * dy <= rad * rad) px(x, y, c[0], c[1], c[2], a); } }; +const ellipse = (cx, cy, rx, ry, c, a = 255) => { for (let y = cy - ry; y <= cy + ry; y++) for (let x = cx - rx; x <= cx + rx; x++) { const dx = (x - cx) / rx, dy = (y - cy) / ry; if (dx * dx + dy * dy <= 1) px(x, y, c[0], c[1], c[2], a); } }; +const tri = (x0, y0, x1, y1, x2, y2, c, a = 255) => { + const minx = Math.min(x0, x1, x2), maxx = Math.max(x0, x1, x2), miny = Math.min(y0, y1, y2), maxy = Math.max(y0, y1, y2); + const area = (ax, ay, bx, by, cx, cy) => (bx - ax) * (cy - ay) - (cx - ax) * (by - ay); + const A = area(x0, y0, x1, y1, x2, y2); + for (let y = miny; y <= maxy; y++) for (let x = minx; x <= maxx; x++) { + const w0 = area(x1, y1, x2, y2, x, y), w1 = area(x2, y2, x0, y0, x, y), w2 = area(x0, y0, x1, y1, x, y); + if (A === 0) continue; + if ((w0 >= 0 && w1 >= 0 && w2 >= 0) || (w0 <= 0 && w1 <= 0 && w2 <= 0)) px(x, y, c[0], c[1], c[2], a); + } +}; +const dark = (c, f = 0.55) => c.map((v) => Math.round(v * f)); +const light = (c, f = 0.4) => c.map((v) => Math.round(v + (255 - v) * f)); + +// tiny 3×5 digit font for the frame-index badge +const FONT = { + '0': ['111', '101', '101', '101', '111'], '1': ['010', '110', '010', '010', '111'], + '2': ['111', '001', '111', '100', '111'], '3': ['111', '001', '111', '001', '111'], + '4': ['101', '101', '111', '001', '001'], '5': ['111', '100', '111', '001', '111'], + '6': ['111', '100', '111', '101', '111'], '7': ['111', '001', '010', '010', '010'], + '8': ['111', '101', '111', '101', '111'], '9': ['111', '101', '111', '001', '111'], +}; +const digit = (ch, x, y, s, c) => { const g = FONT[ch]; if (!g) return; for (let r = 0; r < 5; r++) for (let col = 0; col < 3; col++) if (g[r][col] === '1') rect(x + col * s, y + r * s, s, s, c); }; +const number = (n, x, y, s, c) => { const str = String(n); str.split('').forEach((ch, i) => digit(ch, x + i * (4 * s), y, s, c)); }; + +function drawFrame(cr) { + const ox = (cr.frame % COLS) * FW; + const oy = Math.floor(cr.frame / COLS) * FH; + const cx = ox + FW / 2, cy = oy + FH / 2; + const base = cr.color, bg = dark(base, 0.28), edge = light(base, 0.5); + + // cell background + border + corner tier accent + rect(ox, oy, FW, FH, bg); + // vignette glow + disc(cx, cy + 10, 130, dark(base, 0.42), 110); + // border + rect(ox, oy, FW, 6, edge); rect(ox, oy + FH - 6, FW, 6, edge); + rect(ox, oy, 6, FH, edge); rect(ox + FW - 6, oy, 6, FH, edge); + + const sz = cr.tier === 3 ? 1.25 : cr.tier === 2 ? 1.08 : 0.92; // boss bigger + const body = base, belly = light(base, 0.35), shade = dark(base, 0.6); + const r = Math.round(78 * sz); + + // shape-specific silhouette + switch (cr.shape) { + case 'worm': + for (let s = 0; s < 4; s++) disc(cx - 60 + s * 40, cy + 20 - s * 6, Math.round(34 * sz), s % 2 ? body : light(body, 0.15)); + ellipse(cx + 78, cy - 4, Math.round(40 * sz), Math.round(34 * sz), body); + break; + case 'shroom': + rect(cx - 26, cy, 52, Math.round(70 * sz), belly); // stalk + ellipse(cx, cy - 6, Math.round(86 * sz), Math.round(54 * sz), body); // cap + for (let i = 0; i < 5; i++) disc(cx - 60 + i * 30, cy - 16, 10, light(body, 0.5)); + break; + case 'slime': + ellipse(cx, cy + 20, Math.round(96 * sz), Math.round(70 * sz), body); + ellipse(cx, cy + 44, Math.round(96 * sz), Math.round(30 * sz), shade); // base shadow + ellipse(cx - 26, cy - 6, 22, 16, belly, 160); + break; + case 'spiky': + for (let a = 0; a < 12; a++) { const an = (a / 12) * Math.PI * 2; tri(cx + Math.cos(an) * r, cy + Math.sin(an) * r, cx + Math.cos(an + 0.25) * (r + 34), cy + Math.sin(an + 0.25) * (r + 34), cx + Math.cos(an + 0.5) * r, cy + Math.sin(an + 0.5) * r, shade); } + disc(cx, cy, r, body); + break; + case 'orb': + disc(cx, cy, r, shade); disc(cx, cy, Math.round(r * 0.8), body); disc(cx, cy, Math.round(r * 0.32), light(body, 0.6)); + break; + case 'horns': + tri(cx - r + 4, cy - 40, cx - r - 34, cy - 96, cx - r + 30, cy - 70, light(body, 0.2)); + tri(cx + r - 4, cy - 40, cx + r + 34, cy - 96, cx + r - 30, cy - 70, light(body, 0.2)); + disc(cx, cy + 6, r, body); + break; + case 'golem': + rect(cx - r, cy - r, r * 2, Math.round(r * 2.1), body); // blocky body + rect(cx - r, cy - r, r * 2, 10, light(body, 0.3)); + rect(cx - r + 14, cy + 30, 28, r, shade); rect(cx + r - 42, cy + 30, 28, r, shade); // leg gap + break; + case 'robed': + tri(cx, cy - r - 10, cx - Math.round(r * 0.95), cy + r, cx + Math.round(r * 0.95), cy + r, body); // hood/robe + disc(cx, cy - Math.round(r * 0.4), Math.round(r * 0.42), shade); // face shadow + break; + case 'bug': + default: + ellipse(cx, cy + 6, Math.round(r * 0.95), Math.round(r * 1.05), body); + for (let i = 0; i < 3; i++) { rect(cx - r, cy - 20 + i * 26, 26, 6, shade); rect(cx + r - 26, cy - 20 + i * 26, 26, 6, shade); } // legs + break; + } + + // eyes (skip for golem/orb which read as constructs but give them a core eye) + const ey = cy - Math.round(r * 0.2), espread = Math.round(r * 0.42), eR = Math.max(9, Math.round(r * 0.2)); + if (cr.shape === 'orb' || cr.shape === 'golem') { + disc(cx, cy - (cr.shape === 'golem' ? Math.round(r * 0.4) : 0), eR, [0xff, 0x66, 0x44]); + disc(cx, cy - (cr.shape === 'golem' ? Math.round(r * 0.4) : 0), Math.round(eR * 0.5), [0xff, 0xdd, 0xaa]); + } else { + for (const sx of [cx - espread, cx + espread]) { + disc(sx, ey, eR, [0xf6, 0xf2, 0xe8]); + disc(sx + 2, ey + 2, Math.round(eR * 0.5), [0x18, 0x12, 0x1e]); + } + } + + // frame-index badge (top-left of cell) + rect(ox + 12, oy + 12, 4 * 6 * String(cr.frame).length + 8, 30, [0, 0, 0], 150); + number(cr.frame, ox + 16, oy + 17, 4, light(base, 0.7)); +} + +for (const cr of CREATURES) drawFrame(cr); + +// ── encode PNG (truecolour + alpha, 8-bit) ── +function crc32(b) { let c = ~0; for (let i = 0; i < b.length; i++) { c ^= b[i]; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); } return ~c >>> 0; } +function chunk(type, data) { + const t = Buffer.from(type, 'ascii'); + const len = Buffer.alloc(4); len.writeUInt32BE(data.length); + const body = Buffer.concat([t, data]); + const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} +const ihdr = Buffer.alloc(13); +ihdr.writeUInt32BE(W, 0); ihdr.writeUInt32BE(H, 4); ihdr[8] = 8; ihdr[9] = 6; // RGBA +// filtered scanlines (filter 0) +const raw = Buffer.alloc((W * 4 + 1) * H); +for (let y = 0; y < H; y++) { raw[y * (W * 4 + 1)] = 0; Buffer.from(buf.buffer, y * W * 4, W * 4).copy(raw, y * (W * 4 + 1) + 1); } +const idat = zlib.deflateSync(raw, { level: 9 }); +const png = Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0)), +]); +const out = 'public/assets/images/spireclimb-creatures.png'; +writeFileSync(out, png); +console.log(`Wrote ${out} (${W}×${H}, ${CREATURES.length} frames)`); diff --git a/public/assets/images/spireclimb-cards.png b/public/assets/images/spireclimb-cards.png new file mode 100644 index 0000000..4de3197 Binary files /dev/null and b/public/assets/images/spireclimb-cards.png differ diff --git a/public/assets/images/spireclimb-cards.psd b/public/assets/images/spireclimb-cards.psd new file mode 100644 index 0000000..9e1186e Binary files /dev/null and b/public/assets/images/spireclimb-cards.psd differ diff --git a/public/assets/images/spireclimb-creatures.png b/public/assets/images/spireclimb-creatures.png new file mode 100644 index 0000000..9dc0c96 Binary files /dev/null and b/public/assets/images/spireclimb-creatures.png differ diff --git a/public/data/spireclimb-artwork.json b/public/data/spireclimb-artwork.json new file mode 100644 index 0000000..33e407a --- /dev/null +++ b/public/data/spireclimb-artwork.json @@ -0,0 +1,36 @@ +{ + "_readme": [ + "Drop-in art for Spire Climb.", + "CREATURES: a dedicated placeholder sheet ships at the creatureSheet.path below", + " (spireclimb-creatures.png, 1500x600 = 10 frames of 300x300, 5 cols x 2 rows,", + " row-major frame order). Regenerate it with `node genSpireClimbCreatures.js`, or", + " paint over it keeping the same layout/frame order. The 'creatures' map ties each", + " enemy id to its frame index.", + "CARDS: still procedural until you add a sheet. To add card art:", + " 1. Drop spireclimb-cards.png into /assets/images/.", + " 2. Set cardSheet.path below + frameWidth/frameHeight.", + " 3. Map each card id to its 0-based frame index in 'cards'.", + "Cards show the illustration inside the procedural card frame's art window (no", + "border/text needed). Recommended card frame 250x160 (art-window aspect). See", + "src/games/spireclimb/sprites.md for the full sprite spec + frame maps." + ], + "cardSheet": { "key": "spireclimb-cards", "path": "/assets/images/spireclimb-cards.png", "frameWidth": 250, "frameHeight": 160 }, + "creatureSheet": { "key": "spireclimb-creatures", "path": "/assets/images/spireclimb-creatures.png", "frameWidth": 300, "frameHeight": 300 }, + + "cards": { + "strike": 0, "defend": 1, "bash": 2, "neutralize": 3, "survivor": 4, + "ironwave": 5, "pommelstrike": 6, "cleave": 7, "shrugitoff": 8, "clothesline": 9, + "inflame": 10, "uppercut": 11, "ghostlyarmor": 12, "bodyslam": 13, "metallicize": 14, + "whirlwind": 15, "limitbreak": 16, "demonform": 17, + "daggerthrow": 18, "poisonedstab": 19, "deadlypoison": 20, "backflip": 21, "sneakyhit": 22, + "footwork": 23, "bladedance": 24, "caltrops": 25, "bouncingblade": 26, "crippling": 27, "catalyst": 28, + "bandage": 29, "flashofsteel": 30, + "wound": 31, "dazed": 32, "burn": 33 + }, + + "creatures": { + "jawworm": 0, "cultist": 1, "louse": 2, "fungi": 3, "spikeslime": 4, + "sentry": 5, "gremlinnob": 6, "lagavulin": 7, + "guardian": 8, "slimeboss": 9 + } +} diff --git a/public/src/games/spireclimb/SpireClimbData.js b/public/src/games/spireclimb/SpireClimbData.js new file mode 100644 index 0000000..a777a8a --- /dev/null +++ b/public/src/games/spireclimb/SpireClimbData.js @@ -0,0 +1,469 @@ +// SpireClimbData.js +// Static content for Spire Climb: card pools (Warrior + Rogue + neutral/status/curse), +// relics, potions, enemy/elite/boss definitions, and act/map tuning. +// +// All card art is procedural by default. To drop in real art later, populate +// /data/spireclimb-artwork.json mapping a card `id` (or enemy `id`) to a +// { key, frame } in a loaded spritesheet — SpireClimbGame resolves art through +// that map and falls back to procedural drawing / the shared `opponents` sheet. + +export const CLASSES = { + warrior: { + id: 'warrior', + name: 'Warrior', + blurb: 'Strength & block. Trade blows, stack armor, and hit like a truck.', + color: 0xb5392f, + colorHex: '#b5392f', + maxHp: 80, + startRelic: 'burning-blood', + // starter deck: 5 Strike, 4 Defend, 1 Bash + startingDeck: ['strike', 'strike', 'strike', 'strike', 'strike', 'defend', 'defend', 'defend', 'defend', 'bash'], + }, + rogue: { + id: 'rogue', + name: 'Rogue', + blurb: 'Poison & dexterity. Death by a thousand cuts; dodge what you can.', + color: 0x4b9c5a, + colorHex: '#4b9c5a', + maxHp: 70, + startRelic: 'ring-of-the-snake', + // starter deck: 5 Strike, 4 Defend, 1 Neutralize, 1 Survivor (draws extra at start via relic) + startingDeck: ['strike', 'strike', 'strike', 'strike', 'strike', 'defend', 'defend', 'defend', 'defend', 'neutralize', 'survivor'], + }, +}; + +// ── Status / buff definitions (display + behavior live in Logic) ────────────── +export const STATUS = { + strength: { name: 'Strength', kind: 'buff', color: 0xe0533a, desc: 'Attacks deal +X damage.' }, + dexterity: { name: 'Dexterity', kind: 'buff', color: 0x4bce6b, desc: 'Block cards grant +X block.' }, + vulnerable: { name: 'Vulnerable', kind: 'debuff', color: 0xd23b6a, desc: 'Takes 50% more attack damage.' }, + weak: { name: 'Weak', kind: 'debuff', color: 0x8a6bd1, desc: 'Deals 25% less attack damage.' }, + frail: { name: 'Frail', kind: 'debuff', color: 0xb88a3a, desc: 'Gains 25% less block.' }, + poison: { name: 'Poison', kind: 'debuff', color: 0x6fae3a, desc: 'Lose X HP at turn start, then X drops by 1.' }, + ritual: { name: 'Ritual', kind: 'buff', color: 0xc94f8a, desc: 'Gains X strength each turn.' }, + regen: { name: 'Regen', kind: 'buff', color: 0x4bce6b, desc: 'Heal X at turn end, then X drops by 1.' }, + metallicize:{ name: 'Metallicize',kind: 'buff', color: 0x9aa7b5, desc: 'Gain X block at turn end.' }, +}; + +// ── CARD POOL ───────────────────────────────────────────────────────────────── +// type: attack | skill | power | status | curse +// target: enemy | self | all | none +// effects: ordered list of structured ops resolved by SpireClimbLogic.resolveCard +// upgrade: partial overrides applied when a card is upgraded (name suffixed with +) +export const CARDS = { + // —— Basic / starter —— + strike: { + id: 'strike', name: 'Strike', cls: 'neutral', type: 'attack', rarity: 'starter', + cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 6 }], + text: 'Deal 6 damage.', + upgrade: { effects: [{ op: 'damage', amount: 9 }], text: 'Deal 9 damage.' }, + }, + defend: { + id: 'defend', name: 'Defend', cls: 'neutral', type: 'skill', rarity: 'starter', + cost: 1, target: 'self', effects: [{ op: 'block', amount: 5 }], + text: 'Gain 5 block.', + upgrade: { effects: [{ op: 'block', amount: 8 }], text: 'Gain 8 block.' }, + }, + bash: { + id: 'bash', name: 'Bash', cls: 'warrior', type: 'attack', rarity: 'starter', + cost: 2, target: 'enemy', + effects: [{ op: 'damage', amount: 8 }, { op: 'debuff', status: 'vulnerable', amount: 2 }], + text: 'Deal 8 damage. Apply 2 Vulnerable.', + upgrade: { effects: [{ op: 'damage', amount: 10 }, { op: 'debuff', status: 'vulnerable', amount: 3 }], text: 'Deal 10 damage. Apply 3 Vulnerable.' }, + }, + neutralize: { + id: 'neutralize', name: 'Neutralize', cls: 'rogue', type: 'attack', rarity: 'starter', + cost: 0, target: 'enemy', + effects: [{ op: 'damage', amount: 3 }, { op: 'debuff', status: 'weak', amount: 1 }], + text: 'Deal 3 damage. Apply 1 Weak.', + upgrade: { effects: [{ op: 'damage', amount: 4 }, { op: 'debuff', status: 'weak', amount: 2 }], text: 'Deal 4 damage. Apply 2 Weak.' }, + }, + survivor: { + id: 'survivor', name: 'Survivor', cls: 'rogue', type: 'skill', rarity: 'starter', + cost: 1, target: 'self', effects: [{ op: 'block', amount: 8 }, { op: 'discard', amount: 1 }], + text: 'Gain 8 block. Discard 1 card.', + upgrade: { effects: [{ op: 'block', amount: 11 }, { op: 'discard', amount: 1 }], text: 'Gain 11 block. Discard 1 card.' }, + }, + + // —— Warrior common —— + ironwave: { + id: 'ironwave', name: 'Iron Wave', cls: 'warrior', type: 'attack', rarity: 'common', + cost: 1, target: 'enemy', effects: [{ op: 'block', amount: 5 }, { op: 'damage', amount: 5 }], + text: 'Gain 5 block. Deal 5 damage.', + upgrade: { effects: [{ op: 'block', amount: 7 }, { op: 'damage', amount: 7 }], text: 'Gain 7 block. Deal 7 damage.' }, + }, + pommelstrike: { + id: 'pommelstrike', name: 'Pommel Strike', cls: 'warrior', type: 'attack', rarity: 'common', + cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 9 }, { op: 'draw', amount: 1 }], + text: 'Deal 9 damage. Draw 1 card.', + upgrade: { effects: [{ op: 'damage', amount: 10 }, { op: 'draw', amount: 2 }], text: 'Deal 10 damage. Draw 2 cards.' }, + }, + cleave: { + id: 'cleave', name: 'Cleave', cls: 'warrior', type: 'attack', rarity: 'common', + cost: 1, target: 'all', effects: [{ op: 'damageAll', amount: 8 }], + text: 'Deal 8 damage to ALL enemies.', + upgrade: { effects: [{ op: 'damageAll', amount: 11 }], text: 'Deal 11 damage to ALL enemies.' }, + }, + shrugitoff: { + id: 'shrugitoff', name: 'Shrug It Off', cls: 'warrior', type: 'skill', rarity: 'common', + cost: 1, target: 'self', effects: [{ op: 'block', amount: 8 }, { op: 'draw', amount: 1 }], + text: 'Gain 8 block. Draw 1 card.', + upgrade: { effects: [{ op: 'block', amount: 11 }, { op: 'draw', amount: 1 }], text: 'Gain 11 block. Draw 1 card.' }, + }, + clothesline: { + id: 'clothesline', name: 'Clothesline', cls: 'warrior', type: 'attack', rarity: 'common', + cost: 2, target: 'enemy', effects: [{ op: 'damage', amount: 12 }, { op: 'debuff', status: 'weak', amount: 2 }], + text: 'Deal 12 damage. Apply 2 Weak.', + upgrade: { effects: [{ op: 'damage', amount: 14 }, { op: 'debuff', status: 'weak', amount: 3 }], text: 'Deal 14 damage. Apply 3 Weak.' }, + }, + // —— Warrior uncommon/rare —— + inflame: { + id: 'inflame', name: 'Inflame', cls: 'warrior', type: 'power', rarity: 'uncommon', + cost: 1, target: 'self', effects: [{ op: 'buffSelf', status: 'strength', amount: 2 }], + text: 'Gain 2 Strength.', + upgrade: { effects: [{ op: 'buffSelf', status: 'strength', amount: 3 }], text: 'Gain 3 Strength.' }, + }, + uppercut: { + id: 'uppercut', name: 'Uppercut', cls: 'warrior', type: 'attack', rarity: 'uncommon', + cost: 2, target: 'enemy', + effects: [{ op: 'damage', amount: 13 }, { op: 'debuff', status: 'weak', amount: 1 }, { op: 'debuff', status: 'vulnerable', amount: 1 }], + text: 'Deal 13 damage. Apply 1 Weak. Apply 1 Vulnerable.', + upgrade: { effects: [{ op: 'damage', amount: 13 }, { op: 'debuff', status: 'weak', amount: 2 }, { op: 'debuff', status: 'vulnerable', amount: 2 }], text: 'Deal 13 damage. Apply 2 Weak. Apply 2 Vulnerable.' }, + }, + ghostlyarmor: { + id: 'ghostlyarmor', name: 'Ghostly Armor', cls: 'warrior', type: 'skill', rarity: 'uncommon', + cost: 1, target: 'self', effects: [{ op: 'block', amount: 10 }], + text: 'Gain 10 block.', + upgrade: { effects: [{ op: 'block', amount: 13 }], text: 'Gain 13 block.' }, + }, + bodyslam: { + id: 'bodyslam', name: 'Body Slam', cls: 'warrior', type: 'attack', rarity: 'uncommon', + cost: 1, target: 'enemy', effects: [{ op: 'damageEqualBlock' }], + text: 'Deal damage equal to your current block.', + upgrade: { cost: 0, text: 'Deal damage equal to your current block.' }, + }, + metallicize: { + id: 'metallicize', name: 'Metallicize', cls: 'warrior', type: 'power', rarity: 'uncommon', + cost: 1, target: 'self', effects: [{ op: 'buffSelf', status: 'metallicize', amount: 3 }], + text: 'At the end of your turn, gain 3 block.', + upgrade: { effects: [{ op: 'buffSelf', status: 'metallicize', amount: 4 }], text: 'At the end of your turn, gain 4 block.' }, + }, + whirlwind: { + id: 'whirlwind', name: 'Whirlwind', cls: 'warrior', type: 'attack', rarity: 'uncommon', + cost: 2, target: 'all', effects: [{ op: 'damageAll', amount: 5, times: 3 }], + text: 'Deal 5 damage to ALL enemies 3 times.', + upgrade: { effects: [{ op: 'damageAll', amount: 8, times: 3 }], text: 'Deal 8 damage to ALL enemies 3 times.' }, + }, + limitbreak: { + id: 'limitbreak', name: 'Limit Break', cls: 'warrior', type: 'skill', rarity: 'rare', + cost: 1, target: 'self', exhaust: true, effects: [{ op: 'doubleStrength' }], + text: 'Double your Strength. Exhaust.', + upgrade: { exhaust: false, text: 'Double your Strength.' }, + }, + demonform: { + id: 'demonform', name: 'Demon Form', cls: 'warrior', type: 'power', rarity: 'rare', + cost: 3, target: 'self', effects: [{ op: 'buffSelf', status: 'ritual', amount: 2 }], + text: 'At the start of each turn, gain 2 Strength.', + upgrade: { effects: [{ op: 'buffSelf', status: 'ritual', amount: 3 }], text: 'At the start of each turn, gain 3 Strength.' }, + }, + + // —— Rogue common —— + daggerthrow: { + id: 'daggerthrow', name: 'Dagger Throw', cls: 'rogue', type: 'attack', rarity: 'common', + cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 9 }, { op: 'draw', amount: 1 }, { op: 'discard', amount: 1 }], + text: 'Deal 9 damage. Draw 1, discard 1.', + upgrade: { effects: [{ op: 'damage', amount: 12 }, { op: 'draw', amount: 1 }, { op: 'discard', amount: 1 }], text: 'Deal 12 damage. Draw 1, discard 1.' }, + }, + poisonedstab: { + id: 'poisonedstab', name: 'Poisoned Stab', cls: 'rogue', type: 'attack', rarity: 'common', + cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 6 }, { op: 'poison', amount: 3 }], + text: 'Deal 6 damage. Apply 3 Poison.', + upgrade: { effects: [{ op: 'damage', amount: 8 }, { op: 'poison', amount: 4 }], text: 'Deal 8 damage. Apply 4 Poison.' }, + }, + deadlypoison: { + id: 'deadlypoison', name: 'Deadly Poison', cls: 'rogue', type: 'skill', rarity: 'common', + cost: 1, target: 'enemy', effects: [{ op: 'poison', amount: 5 }], + text: 'Apply 5 Poison.', + upgrade: { effects: [{ op: 'poison', amount: 7 }], text: 'Apply 7 Poison.' }, + }, + backflip: { + id: 'backflip', name: 'Backflip', cls: 'rogue', type: 'skill', rarity: 'common', + cost: 1, target: 'self', effects: [{ op: 'block', amount: 5 }, { op: 'draw', amount: 2 }], + text: 'Gain 5 block. Draw 2 cards.', + upgrade: { effects: [{ op: 'block', amount: 8 }, { op: 'draw', amount: 2 }], text: 'Gain 8 block. Draw 2 cards.' }, + }, + sneakyhit: { + id: 'sneakyhit', name: 'Sneaky Hit', cls: 'rogue', type: 'attack', rarity: 'common', + cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 12 }], + text: 'Deal 12 damage.', + upgrade: { effects: [{ op: 'damage', amount: 14 }], text: 'Deal 14 damage.' }, + }, + // —— Rogue uncommon/rare —— + footwork: { + id: 'footwork', name: 'Footwork', cls: 'rogue', type: 'power', rarity: 'uncommon', + cost: 1, target: 'self', effects: [{ op: 'buffSelf', status: 'dexterity', amount: 2 }], + text: 'Gain 2 Dexterity.', + upgrade: { effects: [{ op: 'buffSelf', status: 'dexterity', amount: 3 }], text: 'Gain 3 Dexterity.' }, + }, + bladedance: { + id: 'bladedance', name: 'Blade Dance', cls: 'rogue', type: 'attack', rarity: 'uncommon', + cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 4, times: 3 }], + text: 'Deal 4 damage 3 times.', + upgrade: { effects: [{ op: 'damage', amount: 6, times: 3 }], text: 'Deal 6 damage 3 times.' }, + }, + caltrops: { + id: 'caltrops', name: 'Caltrops', cls: 'rogue', type: 'power', rarity: 'uncommon', + cost: 1, target: 'self', effects: [{ op: 'buffSelf', status: 'metallicize', amount: 2 }], + text: 'At the end of your turn, gain 2 block.', + upgrade: { effects: [{ op: 'buffSelf', status: 'metallicize', amount: 3 }], text: 'At the end of your turn, gain 3 block.' }, + }, + bouncingblade: { + id: 'bouncingblade', name: 'Bouncing Blade', cls: 'rogue', type: 'attack', rarity: 'uncommon', + cost: 1, target: 'all', effects: [{ op: 'damageAll', amount: 3, times: 3 }], + text: 'Deal 3 damage to ALL enemies 3 times.', + upgrade: { effects: [{ op: 'damageAll', amount: 4, times: 3 }], text: 'Deal 4 damage to ALL enemies 3 times.' }, + }, + crippling: { + id: 'crippling', name: 'Crippling Cloud', cls: 'rogue', type: 'skill', rarity: 'rare', + cost: 2, target: 'all', exhaust: true, + effects: [{ op: 'poisonAll', amount: 4 }, { op: 'debuffAll', status: 'weak', amount: 2 }], + text: 'Apply 4 Poison and 2 Weak to ALL enemies. Exhaust.', + upgrade: { effects: [{ op: 'poisonAll', amount: 7 }, { op: 'debuffAll', status: 'weak', amount: 2 }], text: 'Apply 7 Poison and 2 Weak to ALL enemies. Exhaust.' }, + }, + catalyst: { + id: 'catalyst', name: 'Catalyst', cls: 'rogue', type: 'skill', rarity: 'rare', + cost: 1, target: 'enemy', exhaust: true, effects: [{ op: 'multiplyPoison', factor: 2 }], + text: "Double an enemy's Poison. Exhaust.", + upgrade: { effects: [{ op: 'multiplyPoison', factor: 3 }], text: "Triple an enemy's Poison. Exhaust." }, + }, + + // —— Neutral / colorless —— + bandage: { + id: 'bandage', name: 'Bandage Up', cls: 'neutral', type: 'skill', rarity: 'uncommon', + cost: 0, target: 'self', exhaust: true, effects: [{ op: 'heal', amount: 4 }], + text: 'Heal 4 HP. Exhaust.', + upgrade: { effects: [{ op: 'heal', amount: 6 }], text: 'Heal 6 HP. Exhaust.' }, + }, + flashofsteel: { + id: 'flashofsteel', name: 'Flash of Steel', cls: 'neutral', type: 'attack', rarity: 'common', + cost: 0, target: 'enemy', effects: [{ op: 'damage', amount: 3 }, { op: 'draw', amount: 1 }], + text: 'Deal 3 damage. Draw 1 card.', + upgrade: { effects: [{ op: 'damage', amount: 6 }, { op: 'draw', amount: 1 }], text: 'Deal 6 damage. Draw 1 card.' }, + }, + + // —— Status / curse (non-removable in battle; clutter the deck) —— + wound: { id: 'wound', name: 'Wound', cls: 'status', type: 'status', rarity: 'special', cost: -1, unplayable: true, target: 'none', effects: [], text: 'Unplayable.' }, + dazed: { id: 'dazed', name: 'Dazed', cls: 'status', type: 'status', rarity: 'special', cost: -1, unplayable: true, ethereal: true, target: 'none', effects: [], text: 'Unplayable. Ethereal.' }, + burn: { id: 'burn', name: 'Burn', cls: 'status', type: 'status', rarity: 'special', cost: -1, unplayable: true, target: 'none', effects: [], text: 'Unplayable. At end of turn, take 2 damage.', endTurnSelfDamage: 2 }, +}; + +// Reward pools by class (excludes starters/status/curse) +export function cardPoolFor(className) { + return Object.values(CARDS).filter( + (c) => (c.cls === className || c.cls === 'neutral') && + c.rarity !== 'starter' && c.rarity !== 'special' + ); +} + +// ── RELICS ────────────────────────────────────────────────────────────────── +// hooks are interpreted by Logic at the noted trigger points. +export const RELICS = { + 'burning-blood': { id: 'burning-blood', name: 'Burning Blood', rarity: 'starter', desc: 'At the end of combat, heal 6 HP.', hook: 'combatEndHeal', value: 6 }, + 'ring-of-the-snake': { id: 'ring-of-the-snake', name: 'Ring of the Snake', rarity: 'starter', desc: 'At the start of each combat, draw 2 extra cards.', hook: 'combatStartDraw', value: 2 }, + 'bag-of-marbles': { id: 'bag-of-marbles', name: 'Bag of Marbles', rarity: 'common', desc: 'At combat start, apply 1 Vulnerable to ALL enemies.', hook: 'combatStartVulnAll', value: 1 }, + 'anchor': { id: 'anchor', name: 'Anchor', rarity: 'common', desc: 'At combat start, gain 10 block.', hook: 'combatStartBlock', value: 10 }, + 'vajra': { id: 'vajra', name: 'Vajra', rarity: 'common', desc: 'At combat start, gain 1 Strength.', hook: 'combatStartStrength', value: 1 }, + 'oddly-smooth': { id: 'oddly-smooth', name: 'Oddly Smooth Stone', rarity: 'common', desc: 'At combat start, gain 1 Dexterity.', hook: 'combatStartDexterity', value: 1 }, + 'blood-vial': { id: 'blood-vial', name: 'Blood Vial', rarity: 'common', desc: 'At combat start, heal 2 HP.', hook: 'combatStartHeal', value: 2 }, + 'bronze-scales': { id: 'bronze-scales', name: 'Bronze Scales', rarity: 'common', desc: 'Whenever you take attack damage, deal 3 back.', hook: 'thorns', value: 3 }, + 'kunai': { id: 'kunai', name: 'Kunai', rarity: 'uncommon', desc: 'Every 3rd attack played each turn, gain 1 Dexterity.', hook: 'kunai', value: 1 }, + 'pen-nib': { id: 'pen-nib', name: 'Pen Nib', rarity: 'uncommon', desc: 'Every 10th attack deals double damage.', hook: 'penNib', value: 10 }, + 'energy-core': { id: 'energy-core', name: 'Energy Core', rarity: 'uncommon', desc: 'Gain 1 extra energy each turn.', hook: 'extraEnergy', value: 1 }, + 'meat-on-the-bone': { id: 'meat-on-the-bone', name: 'Meat on the Bone', rarity: 'uncommon', desc: 'If you end combat below 50% HP, heal 12.', hook: 'meatOnBone', value: 12 }, +}; + +export function relicPool(rarity) { + return Object.values(RELICS).filter((r) => r.rarity === rarity); +} + +// ── POTIONS ─────────────────────────────────────────────────────────────────── +export const POTIONS = { + 'fire-potion': { id: 'fire-potion', name: 'Fire Potion', desc: 'Deal 20 damage to an enemy.', target: 'enemy', effects: [{ op: 'damage', amount: 20 }] }, + 'block-potion': { id: 'block-potion', name: 'Block Potion', desc: 'Gain 12 block.', target: 'self', effects: [{ op: 'block', amount: 12 }] }, + 'strength-potion': { id: 'strength-potion', name: 'Strength Potion', desc: 'Gain 2 Strength.', target: 'self', effects: [{ op: 'buffSelf', status: 'strength', amount: 2 }] }, + 'dexterity-potion':{ id: 'dexterity-potion', name: 'Dexterity Potion', desc: 'Gain 2 Dexterity.', target: 'self', effects: [{ op: 'buffSelf', status: 'dexterity', amount: 2 }] }, + 'weak-potion': { id: 'weak-potion', name: 'Weak Potion', desc: 'Apply 3 Weak to an enemy.', target: 'enemy', effects: [{ op: 'debuff', status: 'weak', amount: 3 }] }, + 'poison-potion': { id: 'poison-potion', name: 'Poison Potion', desc: 'Apply 6 Poison to an enemy.', target: 'enemy', effects: [{ op: 'poison', amount: 6 }] }, + 'energy-potion': { id: 'energy-potion', name: 'Energy Potion', desc: 'Gain 2 energy this turn.', target: 'self', effects: [{ op: 'energy', amount: 2 }] }, + 'heal-potion': { id: 'heal-potion', name: 'Healing Potion', desc: 'Heal 20% of max HP.', target: 'self', effects: [{ op: 'healPct', amount: 0.2 }] }, +}; +export const POTION_IDS = Object.keys(POTIONS); + +// ── ENEMIES ─────────────────────────────────────────────────────────────────── +// placeholderFrame indexes the shared `opponents` spritesheet (300×300) for +// placeholder art. moves[] drive intents; ai picks a move each enemy turn. +// effect ops on enemy moves mirror the card resolver but originate from the enemy. +export const ENEMIES = { + // —— normal —— + jawworm: { + id: 'jawworm', name: 'Jaw Worm', tier: 'normal', placeholderFrame: 3, + hp: [40, 44], color: 0x9c6b3a, + moves: [ + { id: 'chomp', intent: 'attack', value: 11, effects: [{ op: 'damage', amount: 11 }] }, + { id: 'thrash', intent: 'attackdefend', value: 7, block: 5, effects: [{ op: 'damage', amount: 7 }, { op: 'blockSelf', amount: 5 }] }, + { id: 'bellow', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 3 }, { op: 'blockSelf', amount: 6 }] }, + ], + ai: 'jawworm', + }, + cultist: { + id: 'cultist', name: 'Cultist', tier: 'normal', placeholderFrame: 12, hp: [48, 54], color: 0x6a4d8a, + moves: [ + { id: 'incantation', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'ritual', amount: 3 }] }, + { id: 'darkstrike', intent: 'attack', value: 6, effects: [{ op: 'damage', amount: 6 }] }, + ], + ai: 'cultist', + }, + louse: { + id: 'louse', name: 'Red Louse', tier: 'normal', placeholderFrame: 9, hp: [11, 16], color: 0xc0533a, + moves: [ + { id: 'bite', intent: 'attack', value: 6, effects: [{ op: 'damage', amount: 6 }] }, + { id: 'grow', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 3 }] }, + ], + ai: 'random', + }, + fungi: { + id: 'fungi', name: 'Fungi Beast', tier: 'normal', placeholderFrame: 5, hp: [22, 28], color: 0x4b8c5a, + moves: [ + { id: 'bite', intent: 'attack', value: 6, effects: [{ op: 'damage', amount: 6 }] }, + { id: 'grow', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 4 }] }, + ], + ai: 'random', + dies: { op: 'debuffPlayer', status: 'frail', amount: 0 }, // spore handled in logic on death + }, + spikeslime: { + id: 'spikeslime', name: 'Spike Slime', tier: 'normal', placeholderFrame: 21, hp: [28, 32], color: 0x4a6bbf, + moves: [ + { id: 'flame', intent: 'attackdebuff', value: 8, effects: [{ op: 'damage', amount: 8 }, { op: 'addCardToDiscard', card: 'wound', amount: 1 }] }, + { id: 'lick', intent: 'debuff', effects: [{ op: 'debuffPlayer', status: 'frail', amount: 1 }] }, + ], + ai: 'alternate', + }, + // —— elites —— + sentry: { + id: 'sentry', name: 'Sentry', tier: 'elite', placeholderFrame: 22, hp: [38, 42], color: 0x9aa7b5, + moves: [ + { id: 'beam', intent: 'attack', value: 9, effects: [{ op: 'damage', amount: 9 }] }, + { id: 'bolt', intent: 'debuff', effects: [{ op: 'addCardToDiscard', card: 'dazed', amount: 2 }] }, + ], + ai: 'alternate', + }, + gremlinnob: { + id: 'gremlinnob', name: 'Gremlin Nob', tier: 'elite', placeholderFrame: 23, hp: [82, 86], color: 0xb5503a, + moves: [ + { id: 'bellow', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 2 }] }, + { id: 'rush', intent: 'attack', value: 14, effects: [{ op: 'damage', amount: 14 }] }, + { id: 'skullbash', intent: 'attackdebuff', value: 6, effects: [{ op: 'damage', amount: 6 }, { op: 'debuffPlayer', status: 'vulnerable', amount: 2 }] }, + ], + ai: 'gremlinnob', + }, + lagavulin: { + id: 'lagavulin', name: 'Lagavulin', tier: 'elite', placeholderFrame: 19, hp: [105, 112], color: 0x3a7a6b, + moves: [ + { id: 'sleep', intent: 'sleep', effects: [{ op: 'blockSelf', amount: 8 }] }, + { id: 'attack', intent: 'attack', value: 18, effects: [{ op: 'damage', amount: 18 }] }, + { id: 'siphon', intent: 'debuff', effects: [{ op: 'debuffPlayer', status: 'strength', amount: -1 }, { op: 'debuffPlayer', status: 'dexterity', amount: -1 }] }, + ], + ai: 'lagavulin', + }, + // —— bosses —— + guardian: { + id: 'guardian', name: 'The Guardian', tier: 'boss', placeholderFrame: 13, hp: [180, 180], color: 0x8a6b3a, + moves: [ + { id: 'slam', intent: 'attack', value: 16, effects: [{ op: 'damage', amount: 16 }] }, + { id: 'whirl', intent: 'attack', value: 5, times: 4, effects: [{ op: 'damage', amount: 5, times: 4 }] }, + { id: 'shell', intent: 'defend', block: 20, effects: [{ op: 'blockSelf', amount: 20 }, { op: 'buffSelfEnemy', status: 'metallicize', amount: 4 }] }, + { id: 'roll', intent: 'attackdebuff', value: 10, effects: [{ op: 'damage', amount: 10 }, { op: 'addCardToDiscard', card: 'dazed', amount: 2 }] }, + ], + ai: 'sequence', + }, + slimeboss: { + id: 'slimeboss', name: 'Slime Boss', tier: 'boss', placeholderFrame: 27, hp: [150, 150], color: 0x4a6bbf, + moves: [ + { id: 'goop', intent: 'debuff', effects: [{ op: 'addCardToDiscard', card: 'dazed', amount: 3 }] }, + { id: 'prep', intent: 'unknown', effects: [] }, + { id: 'slam', intent: 'attack', value: 22, effects: [{ op: 'damage', amount: 22 }] }, + ], + ai: 'sequence', + }, +}; + +export function enemiesByTier(tier) { + return Object.values(ENEMIES).filter((e) => e.tier === tier); +} + +// ── ENCOUNTER TABLES (which enemy groups appear) ────────────────────────────── +export const ENCOUNTERS = { + normal: [ + ['jawworm'], + ['cultist'], + ['louse', 'louse'], + ['fungi', 'fungi'], + ['spikeslime'], + ['louse', 'spikeslime'], + ['cultist', 'louse'], + ], + elite: [ + ['gremlinnob'], + ['lagavulin'], + ['sentry', 'sentry', 'sentry'], + ], + boss: [ + ['guardian'], + ['slimeboss'], + ], +}; + +// ── MAP / ACT TUNING ────────────────────────────────────────────────────────── +export const ACT = { + rows: 11, // floors, last is boss + minWidth: 1, + maxWidth: 4, + // node-type weights for the middle floors (start/rest/boss are forced) + weights: { combat: 0.45, event: 0.22, elite: 0.16, rest: 0.10, shop: 0.07 }, + startGold: 99, + cardRewardChoices: 3, + shopPrices: { common: 50, uncommon: 75, rare: 150, relic: 150, potion: 50, removal: 75 }, +}; + +// non-combat event deck (simple branching choices resolved in Logic) +export const EVENTS = [ + { + id: 'bonfire', title: 'Dying Embers', + text: 'A bonfire of forgotten cards crackles before you.', + options: [ + { label: 'Rest (heal 25% HP)', effect: { op: 'healPct', amount: 0.25 } }, + { label: 'Smith (upgrade a card)', effect: { op: 'upgrade' } }, + { label: 'Toss a card in (remove + gain 30 gold)', effect: { op: 'removeForGold', gold: 30 } }, + ], + }, + { + id: 'shrine', title: 'Golden Shrine', + text: 'An idol promises power for a price.', + options: [ + { label: 'Pray (gain a relic, take 1 Curse)', effect: { op: 'relicAndCurse' } }, + { label: 'Desecrate (gain 75 gold, lose 8 HP)', effect: { op: 'goldForHp', gold: 75, hp: 8 } }, + { label: 'Leave', effect: { op: 'nothing' } }, + ], + }, + { + id: 'wanderer', title: 'Wounded Wanderer', + text: 'A traveler offers a trade.', + options: [ + { label: 'Heal them (lose 6 HP, gain a potion)', effect: { op: 'hpForPotion', hp: 6 } }, + { label: 'Rob them (gain 60 gold, take 1 Curse)', effect: { op: 'goldAndCurse', gold: 60 } }, + { label: 'Leave', effect: { op: 'nothing' } }, + ], + }, +]; + +export const NODE_TYPES = ['start', 'combat', 'elite', 'event', 'rest', 'shop', 'treasure', 'boss']; diff --git a/public/src/games/spireclimb/SpireClimbGame.js b/public/src/games/spireclimb/SpireClimbGame.js new file mode 100644 index 0000000..4746981 --- /dev/null +++ b/public/src/games/spireclimb/SpireClimbGame.js @@ -0,0 +1,1373 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { api } from '../../services/api.js'; +import { + CLASSES, CARDS, RELICS, POTIONS, STATUS, ENEMIES, EVENTS, ACT, +} from './SpireClimbData.js'; +import { + newRun, availableNodes, enterNode, nodeById, encounterForNode, + startCombat, playCard, canPlay, usePotion, isCombatOver, + beginEnemyPhase, enemyUpkeep, resolveEnemyMove, finishEnemyPhase, intentDamage, + settleCombat, resolvedCard, cardCost, statusOf, makeRng, + addCardToDeck, removeCardFromDeck, upgradeCardInDeck, addRelic, + addPotion, removePotion, restHeal, generateShop, +} from './SpireClimbLogic.js'; + +// ── palette ── +const C = { + bg: 0x14101c, bgTop: 0x241a33, panel: 0x1d1726, panelEdge: 0x3a2f4d, + ink: '#f2ead8', muted: '#b3a7c4', gold: '#e7c14b', goldI: 0xe7c14b, + hp: 0x3fae54, hpBack: 0x4a1c20, hpLow: 0xc24040, block: 0x6fa8dc, blockI: 0x6fa8dc, + energy: 0xf2c84b, attack: 0xd2603a, skill: 0x3f7fd0, power: 0x9a5fd0, + intentAtk: 0xd2603a, intentDef: 0x6fa8dc, intentBuff: 0xe7c14b, intentDebuff: 0xb05fd0, +}; +const NODE_ICON = { start: '◆', combat: '⚔', elite: '★', event: '?', rest: '♥', shop: '$', treasure: '▣', boss: '☠' }; +const NODE_LABEL = { combat: 'Monster', elite: 'Elite', event: 'Unknown', rest: 'Rest Site', shop: 'Merchant', treasure: 'Treasure', boss: 'Boss' }; + +export default class SpireClimbGame extends Phaser.Scene { + constructor() { super('SpireClimbGame'); } + + init(data) { + this.gameDef = data?.game ?? { slug: 'spireclimb', name: 'Spire Climb' }; + this.run = null; + this.combat = null; + this.view = 'classselect'; + this.pendingCard = null; + this.pendingPotion = null; + this.animating = false; + this.shop = null; + this.activeEvent = null; + this._logCursor = 0; + this._enemySprites = []; + this._handSprites = {}; + this._barGeom = {}; + this._dealHand = false; + this._pHpOverlay = null; + this._pHpText = null; + this._targetFx = []; + } + + create() { + try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) {} + + // art config + this.art = this.cache.json.get('spireclimb-artwork') || {}; + + // tiny soft dot used by the target-shimmer particle emitters (Dominion-style) + if (!this.textures.exists('spire-sparkle')) { + const g = this.add.graphics(); + g.fillStyle(0xffffff, 1); g.fillCircle(4, 4, 4); + g.generateTexture('spire-sparkle', 8, 8); + g.destroy(); + } + + // layers (low → high): board, targeting arrow, hand cards, transient FX. + // The arrow sits between the board and the hand so it appears to emerge from + // behind the selected card. + this.bgLayer = this.add.container(0, 0); + this.viewLayer = this.add.container(0, 0).setDepth(10); + this.arrowLayer = this.add.container(0, 0).setDepth(35); + this.handLayer = this.add.container(0, 0).setDepth(40); + this.fxLayer = this.add.container(0, 0).setDepth(80); + + this.drawBackdrop(); + this.renderView(); + } + + // Per-frame: keep the targeting arrow glued to the cursor while a card is armed. + update() { + if (this.view === 'combat' && this.pendingCard) this.drawTargetArrow(); + else if (this._targetArrow) this._targetArrow.clear(); + } + + // Thick gold arrow from behind the armed card's center to the cursor. + drawTargetArrow() { + if (!this._targetArrow) { this._targetArrow = this.add.graphics(); this.arrowLayer.add(this._targetArrow); } + const g = this._targetArrow; g.clear(); + const sp = this._handSprites && this._handSprites[this.pendingCard.uid]; + if (!sp) return; + const p = this.input.activePointer; + const x0 = sp.x, y0 = sp.y, x1 = p.x, y1 = p.y; + if (Math.hypot(x1 - x0, y1 - y0) < 8) return; + const ang = Math.atan2(y1 - y0, x1 - x0); + const cos = Math.cos(ang), sin = Math.sin(ang); + const px = -sin, py = cos; // perpendicular unit + const drawArrow = (color, alpha, lineW, headLen, headW) => { + const bx = x1 - cos * headLen, by = y1 - sin * headLen; // arrowhead base center + g.lineStyle(lineW, color, alpha); + g.beginPath(); g.moveTo(x0, y0); g.lineTo(bx, by); g.strokePath(); + g.fillStyle(color, alpha); + g.beginPath(); + g.moveTo(x1, y1); + g.lineTo(bx + px * headW, by + py * headW); + g.lineTo(bx - px * headW, by - py * headW); + g.closePath(); g.fillPath(); + }; + drawArrow(0x241806, 0.85, 24, 54, 35); // dark outline + drawArrow(0xe7c14b, 0.97, 14, 50, 29); // gold core + } + + // ════════════════════════════════════════════════════════ helpers ══════════ + drawBackdrop() { + const g = this.add.graphics(); + g.fillGradientStyle(C.bgTop, C.bgTop, C.bg, C.bg, 1); + g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + // faint vignette + g.fillStyle(0x000000, 0.25); g.fillRect(0, 0, GAME_WIDTH, 8); + this.bgLayer.add(g); + } + + clearView() { + // Visual reset only — does NOT touch targeting state, so re-rendering the + // combat view mid-action keeps an armed card/potion selected. + this.viewLayer.removeAll(true); + if (this.handLayer) this.handLayer.removeAll(true); + } + + setView(v) { + // Real view transitions clear any in-progress target selection. + this.view = v; + this.pendingCard = null; + this.pendingPotion = null; + this.renderView(); + } + + renderView() { + this.clearView(); + switch (this.view) { + case 'classselect': return this.renderClassSelect(); + case 'map': return this.renderMap(); + case 'combat': return this.renderCombat(); + case 'reward': return this.renderReward(); + case 'rest': return this.renderRest(); + case 'shop': return this.renderShop(); + case 'event': return this.renderEvent(); + case 'treasure': return this.renderTreasure(); + case 'gameover': return this.renderGameOver(); + default: return this.renderClassSelect(); + } + } + + add2(obj) { this.viewLayer.add(obj); return obj; } + + text(x, y, str, size, color = C.ink, opts = {}) { + const t = this.add.text(x, y, str, { + fontFamily: opts.font || '"Julius Sans One"', fontSize: `${size}px`, color, + align: opts.align || 'left', wordWrap: opts.wrap ? { width: opts.wrap } : undefined, + fontStyle: opts.bold ? 'bold' : 'normal', + }).setOrigin(opts.ox ?? 0, opts.oy ?? 0); + return this.add2(t); + } + + panel(x, y, w, h, opts = {}) { + const g = this.add.graphics(); + g.fillStyle(opts.fill ?? C.panel, opts.alpha ?? 0.96); + g.fillRoundedRect(x, y, w, h, opts.radius ?? 14); + g.lineStyle(2, opts.edge ?? C.panelEdge, 1); + g.strokeRoundedRect(x, y, w, h, opts.radius ?? 14); + return this.add2(g); + } + + backButton(label = 'Leave Run') { + const b = new Button(this, 130, 50, label, () => this.confirmLeave(), { width: 200, height: 56, variant: 'ghost' }); + this.add2(b); + } + + confirmLeave() { this.scene.start('GameMenu'); } + + sfx(key) { try { playSound(this, key); } catch (_) {} } + + // art resolution + cardArt(inst) { + const sheet = this.art.cardSheet; + if (sheet?.key && this.textures.exists(sheet.key)) { + const f = this.art.cards?.[inst.id]; + if (f != null) return { key: sheet.key, frame: f }; + } + return null; + } + creatureArt(defId) { + const sheet = this.art.creatureSheet; + if (sheet?.key && this.textures.exists(sheet.key)) { + const f = this.art.creatures?.[defId]; + if (f != null) return { key: sheet.key, frame: f }; + } + if (this.textures.exists('opponents')) return { key: 'opponents', frame: ENEMIES[defId]?.placeholderFrame ?? 0 }; + return null; + } + + // ═══════════════════════════════════════════════════ class select ══════════ + renderClassSelect() { + const cx = GAME_WIDTH / 2; + this.text(cx, 90, 'SPIRE CLIMB', 72, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + this.text(cx, 156, 'Choose your climber — build a deck, ascend the spire, slay the boss.', 26, C.muted, { ox: 0.5, oy: 0.5 }); + + const ids = Object.keys(CLASSES); + const cardW = 520, gap = 80; + const totalW = ids.length * cardW + (ids.length - 1) * gap; + let x = cx - totalW / 2; + for (const id of ids) { + const cls = CLASSES[id]; + this.renderClassCard(x, 250, cardW, 540, cls); + x += cardW + gap; + } + this.text(cx, 870, 'A single act: 11 floors of monsters, elites, events, and a final boss.', 22, C.muted, { ox: 0.5, oy: 0.5 }); + this.backButton('Back to Menu'); + } + + renderClassCard(x, y, w, h, cls) { + const g = this.add.graphics(); + g.fillStyle(C.panel, 0.97); g.fillRoundedRect(x, y, w, h, 18); + g.lineStyle(3, cls.color, 1); g.strokeRoundedRect(x, y, w, h, 18); + g.fillStyle(cls.color, 0.18); g.fillRoundedRect(x, y, w, 96, 18); + this.add2(g); + this.text(x + w / 2, y + 48, cls.name, 44, cls.colorHex, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + this.text(x + w / 2, y + 150, `${cls.maxHp} HP`, 30, C.ink, { ox: 0.5, oy: 0.5 }); + this.text(x + w / 2, y + 230, cls.blurb, 24, C.muted, { ox: 0.5, oy: 0.5, align: 'center', wrap: w - 80 }); + const relic = RELICS[cls.startRelic]; + this.text(x + w / 2, y + 340, `Starting Relic — ${relic.name}`, 22, C.gold, { ox: 0.5, oy: 0.5 }); + this.text(x + w / 2, y + 374, relic.desc, 20, C.muted, { ox: 0.5, oy: 0.5, align: 'center', wrap: w - 80 }); + const b = new Button(this, x + w / 2, y + h - 56, `Climb as ${cls.name}`, () => this.startRun(cls.id), { width: w - 120, height: 64 }); + this.add2(b); + } + + startRun(className) { + this.run = newRun(className); + this.sfx(SFX.CARD_SHUFFLE); + this.setView('map'); + } + + // ═══════════════════════════════════════════════════════════ map ═══════════ + renderMap() { + this.renderRunHud(); + const cx = GAME_WIDTH / 2; + this.text(cx, 96, `The Spire — Floor ${this.run.floor}/${this.run.map.rows}`, 36, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + + const grid = this.run.map.grid; + const rows = this.run.map.rows; + const top = 150, bottom = GAME_HEIGHT - 150; + const rowY = (r) => bottom - (r / (rows - 1)) * (bottom - top); + const colX = (row) => (i) => cx + (i - (row.length - 1) / 2) * 230; + + const avail = new Set(availableNodes(this.run).map((n) => n.id)); + const pos = {}; + grid.forEach((row, r) => row.forEach((n, i) => { pos[n.id] = { x: colX(row)(i), y: rowY(r) }; })); + + // edges + const eg = this.add.graphics(); this.add2(eg); + grid.forEach((row) => row.forEach((n) => { + n.edges.forEach((tid) => { + const a = pos[n.id], b = pos[tid]; + const live = (this.run.currentNodeId === n.id || (!this.run.currentNodeId && n.row === 0)) && avail.has(tid); + eg.lineStyle(live ? 5 : 3, live ? C.goldI : 0x4a3f5d, live ? 0.95 : 0.5); + eg.beginPath(); eg.moveTo(a.x, a.y); eg.lineTo(b.x, b.y); eg.strokePath(); + }); + })); + + // nodes + grid.forEach((row) => row.forEach((n) => { + const p = pos[n.id]; + const isAvail = avail.has(n.id); + const isCurrent = this.run.currentNodeId === n.id; + const isVisited = this.run.visited.includes(n.id); + this.renderMapNode(n, p.x, p.y, isAvail, isCurrent, isVisited); + })); + + this.backButton(); + } + + renderMapNode(node, x, y, isAvail, isCurrent, isVisited) { + const r = 34; + const typeColor = node.type === 'boss' ? 0xc24040 : node.type === 'elite' ? 0xe07b3a + : node.type === 'rest' ? 0x4fae6b : node.type === 'shop' ? 0xe7c14b + : node.type === 'event' ? 0x9a5fd0 : node.type === 'treasure' ? 0xd2a84b : 0x8a8a9a; + const g = this.add.graphics(); + const alpha = isAvail || isCurrent ? 1 : isVisited ? 0.85 : 0.45; + g.fillStyle(C.panel, 0.95); g.fillCircle(x, y, r); + g.lineStyle(isAvail ? 5 : 3, isCurrent ? C.goldI : typeColor, alpha); + g.strokeCircle(x, y, r); + if (isAvail) { g.lineStyle(2, C.goldI, 0.5); g.strokeCircle(x, y, r + 6); } + this.add2(g); + this.text(x, y - 2, NODE_ICON[node.type] || '?', 34, isCurrent ? C.gold : Phaser.Display.Color.IntegerToColor(typeColor).rgba, { ox: 0.5, oy: 0.5 }); + + if (isAvail) { + const hit = this.add.circle(x, y, r + 8, 0xffffff, 0.001).setInteractive({ useHandCursor: true }); + const hl = this.add.graphics(); this.add2(hl); + hit.on('pointerover', () => { hl.clear(); hl.fillStyle(typeColor, 0.3); hl.fillCircle(x, y, r); hl.lineStyle(5, C.goldI, 1); hl.strokeCircle(x, y, r); }); + hit.on('pointerout', () => hl.clear()); + hit.on('pointerdown', () => this.chooseNode(node.id)); + this.add2(hit); + this.text(x, y + r + 16, NODE_LABEL[node.type], 16, C.gold, { ox: 0.5, oy: 0.5 }); + } + } + + chooseNode(nodeId) { + const node = enterNode(this.run, nodeId); + this.sfx(SFX.PIECE_CLICK); + switch (node.type) { + case 'combat': case 'elite': case 'boss': return this.beginCombat(node); + case 'rest': return this.setView('rest'); + case 'shop': this.shop = generateShop(this.run, makeRng((this.run.seed ^ this.hashNode(nodeId)) >>> 0)); return this.setView('shop'); + case 'event': this.activeEvent = this.pickEvent(nodeId); return this.setView('event'); + case 'treasure': this.pendingTreasure = this.rollTreasure(nodeId); return this.setView('treasure'); + default: return this.setView('map'); + } + } + + hashNode(id) { let h = 2166136261; for (let i = 0; i < id.length; i++) { h ^= id.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } + + // ═══════════════════════════════════════════════════════ combat ════════════ + beginCombat(node) { + this.combatNode = node; + const rng = makeRng((this.run.seed ^ this.hashNode(node.id)) >>> 0); + const enemyIds = encounterForNode(this.run, node, rng); + this.combat = startCombat(this.run, enemyIds, (this.run.seed ^ this.hashNode(node.id) ^ 0x9e37) >>> 0); + this.combat.isBoss = node.type === 'boss'; + this.combat.isElite = node.type === 'elite'; + this._logCursor = this.combat.log.length; + this._combatResolved = false; + this.animating = false; + this._dealHand = true; // fly the opening hand in + this.setView('combat'); + } + + renderCombat() { + const cb = this.combat; + this._enemySprites = []; + this._barGeom = {}; + this._targetFx = []; + this.renderCombatHud(); + + // ── enemies ── + const alive = cb.enemies; + const n = alive.length; + const spread = Math.min(520, 1100 / Math.max(1, n)); + const startX = GAME_WIDTH / 2 + 120 - (n - 1) * spread / 2; + alive.forEach((e, i) => this.renderEnemy(e, startX + i * spread, 330)); + + // ── player ── + this.renderPlayer(300, 560); + + // ── hand ── + this.renderHand(); + + // ── energy + end turn + piles ── + this.renderEnergy(190, 880); + const endBtn = new Button(this, GAME_WIDTH - 170, 880, 'End Turn', () => this.onEndTurn(), { width: 240, height: 80 }); + this.add2(endBtn); + if (cb.phase !== 'player') endBtn.setAlpha(0.5); + + this.text(120, 1000, `Draw: ${cb.draw.length}`, 22, C.muted, { ox: 0.5, oy: 0.5 }); + this.text(GAME_WIDTH - 120, 1000, `Discard: ${cb.discard.length}`, 22, C.muted, { ox: 0.5, oy: 0.5 }); + if (cb.exhaust.length) this.text(GAME_WIDTH - 120, 1030, `Exhaust: ${cb.exhaust.length}`, 18, '#7a6f8c', { ox: 0.5, oy: 0.5 }); + + if (this.pendingCard) this.text(GAME_WIDTH / 2, 760, 'Choose a target', 26, C.gold, { ox: 0.5, oy: 0.5 }); + + const over = isCombatOver(cb); + if (over && !this._combatResolved) { this._combatResolved = true; this.time.delayedCall(450, () => this.onCombatOver(over)); } + } + + renderCombatHud() { + // relics top-left + this.run.relics.forEach((rid, i) => { + const x = 60 + i * 56, y = 50; + const c = this.add.circle(x, y, 22, 0x2a2235).setStrokeStyle(2, C.goldI).setInteractive({ useHandCursor: true }); + this.add2(c); + this.add2(this.add.text(x, y, (RELICS[rid]?.name || '?')[0], { fontFamily: 'Righteous', fontSize: '20px', color: C.gold }).setOrigin(0.5)); + c.on('pointerover', () => this.showTip(x, y + 36, `${RELICS[rid].name}: ${RELICS[rid].desc}`)); + c.on('pointerout', () => this.hideTip()); + }); + // potions top-right + this.run.potions.forEach((pid, i) => { + const x = GAME_WIDTH - 60 - i * 64, y = 125; + this.renderPotion(x, y, pid, i, true); + }); + // turn indicator + this.text(GAME_WIDTH / 2, 40, `Floor ${this.run.floor} — ${this.combat.isBoss ? 'BOSS' : this.combat.isElite ? 'ELITE' : 'Battle'}`, 24, C.muted, { ox: 0.5, oy: 0.5 }); + } + + renderPotion(x, y, pid, idx, inCombat) { + const pot = POTIONS[pid]; + const c = this.add.circle(x, y, 26, 0x35204a).setStrokeStyle(2, 0xb05fd0).setInteractive({ useHandCursor: true }); + this.add2(c); + this.add2(this.add.text(x, y, '⚗', { fontSize: '26px', color: '#d7a8ff' }).setOrigin(0.5)); + c.on('pointerover', () => this.showTip(x, y + 40, `${pot.name}: ${pot.desc}${inCombat ? ' (click to use)' : ''}`)); + c.on('pointerout', () => this.hideTip()); + if (inCombat) c.on('pointerdown', () => this.onUsePotion(idx, pid)); + } + + onUsePotion(idx, pid) { + const pot = POTIONS[pid]; + if (this.animating || this.combat.phase !== 'player') return; + if (pot.target === 'enemy' && this.combat.enemies.filter((e) => e.alive).length > 1) { + this.pendingPotion = { idx, pid }; + this.hideTip(); + this.renderView(); + return; + } + removePotion(this.run, idx); + usePotion(this.combat, pid, null); + this.afterAction(); + } + + renderEnemy(e, x, y) { + if (!e.alive) { + this.text(x, y, '☠', 60, '#5a4f6c', { ox: 0.5, oy: 0.5 }); + return; + } + const art = this.creatureArt(e.defId); + let sprite; + if (art) { + sprite = this.add.image(x, y, art.key, art.frame); + const sc = 200 / Math.max(sprite.width, 1); + sprite.setScale(sc); + } else { + const g = this.add.graphics(); + g.fillStyle(e.color || 0x8a5a5a, 1); g.fillRoundedRect(-80, -80, 160, 160, 24); + g.lineStyle(3, 0x000000, 0.3); g.strokeRoundedRect(-80, -80, 160, 160, 24); + g.setPosition(x, y); // origin at (x,y) so shake/lunge tweens work + sprite = g; + } + this.add2(sprite); + this._enemySprites.push({ slot: e.slot, x, y, sprite }); + + // name + this.text(x, y - 130, e.name, 22, C.ink, { ox: 0.5, oy: 0.5 }); + // intent + this.renderIntent(e, x, y - 175); + // hp bar + this.renderBar(x - 90, y + 110, 180, 22, e.hp, e.maxHp, e.hp <= e.maxHp * 0.3 ? C.hpLow : C.hp, e.block, 'e' + e.slot); + // statuses + this.renderStatuses(e, x - 90, y + 142); + + // targeting / hover + const needTarget = this.pendingCard || this.pendingPotion; + const hit = this.add.rectangle(x, y, 200, 230, 0xffffff, 0.001).setInteractive({ useHandCursor: true }); + this.add2(hit); + if (needTarget) { + this.addTargetShimmer(x, y); + hit.on('pointerdown', () => this.onEnemyTargeted(e.slot)); + } + } + + // Dominion-style "this is selectable" shimmer: a softly pulsing gold border + // plus gold/white sparkles drifting up off the creature. + addTargetShimmer(x, y) { + const border = this.add.graphics(); + border.lineStyle(3, C.goldI, 0.95); + border.strokeRoundedRect(x - 102, y - 112, 204, 252, 16); + this.add2(border); + this.tweens.add({ targets: border, alpha: 0.3, duration: 620, yoyo: true, repeat: -1, ease: 'Sine.InOut' }); + + const em = this.add.particles(x, y, 'spire-sparkle', { + x: { min: -96, max: 96 }, + y: { min: 70, max: 120 }, + speedX: { min: -22, max: 22 }, + speedY: { min: -130, max: -60 }, + alpha: { start: 0.95, end: 0 }, + scale: { start: 0.95, end: 0.1 }, + lifespan: 1050, + frequency: 70, + tint: [0xffffff, 0xffeebb, 0xe7c14b, 0xc8a84b], + blendMode: 'ADD', + }); + this.add2(em); + this._targetFx.push(border, em); + } + + clearTargetFx() { + (this._targetFx || []).forEach((o) => o.destroy()); + this._targetFx = []; + if (this._targetArrow) this._targetArrow.clear(); + } + + renderIntent(e, x, y) { + const it = e.intent; + if (!it) return; + let txt = '', col = C.intentBuff; + if (it.type === 'attack' || it.type === 'attackdebuff' || it.type === 'attackdefend') { + const perHit = intentDamage(this.combat, e); // live: includes your Vulnerable + txt = it.times > 1 ? `⚔ ${perHit}×${it.times}` : `⚔ ${perHit}`; col = C.intentAtk; + } else if (it.type === 'defend') { txt = `🛡 ${it.block}`; col = C.intentDef; } + else if (it.type === 'buff') { txt = '↑ buff'; col = C.intentBuff; } + else if (it.type === 'debuff') { txt = '↓ debuff'; col = C.intentDebuff; } + else if (it.type === 'sleep') { txt = '💤'; col = 0x8a8a9a; } + else { txt = '?'; col = 0x8a8a9a; } + const w = 110, h = 40; + const g = this.add.graphics(); + g.fillStyle(0x000000, 0.5); g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 10); + g.lineStyle(2, col, 1); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 10); + this.add2(g); + this.add2(this.add.text(x, y, txt, { fontFamily: '"Julius Sans One"', fontSize: '24px', color: Phaser.Display.Color.IntegerToColor(col).rgba }).setOrigin(0.5)); + } + + renderPlayer(x, y) { + this._playerPos = { x, y }; + const p = this.combat.player; + const cls = CLASSES[this.run.className]; + const g = this.add.graphics(); + g.fillStyle(cls.color, 0.9); g.fillCircle(x, y, 70); + g.lineStyle(4, 0xffffff, 0.2); g.strokeCircle(x, y, 70); + this.add2(g); + this.add2(this.add.text(x, y, cls.name[0], { fontFamily: 'Righteous', fontSize: '60px', color: '#ffffff' }).setOrigin(0.5)); + this.text(x, y - 100, cls.name, 24, C.ink, { ox: 0.5, oy: 0.5 }); + this.renderBar(x - 100, y + 90, 200, 26, p.hp, p.maxHp, p.hp <= p.maxHp * 0.3 ? C.hpLow : C.hp, p.block, 'player'); + this.renderStatuses(p, x - 100, y + 128); + } + + renderBar(x, y, w, h, cur, max, color, block = 0, key = null) { + if (key) { if (!this._barGeom) this._barGeom = {}; this._barGeom[key] = { x, y, w, h }; } + const g = this.add.graphics(); + g.fillStyle(C.hpBack, 1); g.fillRoundedRect(x, y, w, h, 6); + const frac = Math.max(0, Math.min(1, cur / max)); + g.fillStyle(color, 1); if (frac > 0) g.fillRoundedRect(x, y, w * frac, h, 6); + g.lineStyle(2, 0x000000, 0.4); g.strokeRoundedRect(x, y, w, h, 6); + this.add2(g); + this.add2(this.add.text(x + w / 2, y + h / 2, `${Math.max(0, cur)}/${max}`, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#ffffff' }).setOrigin(0.5)); + if (block > 0) { + const bx = x - 26; + const sg = this.add.graphics(); sg.fillStyle(C.blockI, 1); sg.fillCircle(bx, y + h / 2, 18); sg.lineStyle(2, 0xffffff, 0.3); sg.strokeCircle(bx, y + h / 2, 18); + this.add2(sg); + this.add2(this.add.text(bx, y + h / 2, `${block}`, { fontFamily: 'Righteous', fontSize: '18px', color: '#0a1422' }).setOrigin(0.5)); + } + } + + renderStatuses(unit, x, y) { + let i = 0; + for (const [key, val] of Object.entries(unit.statuses)) { + if (!val) continue; + const sd = STATUS[key]; if (!sd) continue; + const bx = x + i * 54 + 22; + const c = this.add.circle(bx, y, 19, sd.color, 0.85).setStrokeStyle(2, 0x000000, 0.3).setInteractive({ useHandCursor: true }); + this.add2(c); + this.add2(this.add.text(bx, y, `${val}`, { fontFamily: 'Righteous', fontSize: '18px', color: '#ffffff' }).setOrigin(0.5)); + c.on('pointerover', () => this.showTip(bx, y + 32, `${sd.name}: ${sd.desc}`)); + c.on('pointerout', () => this.hideTip()); + i++; + } + } + + renderEnergy(x, y) { + const p = this.combat.player; + const g = this.add.graphics(); + g.fillStyle(C.energy, 1); g.fillCircle(x, y, 38); + g.lineStyle(4, 0xfff2b0, 0.6); g.strokeCircle(x, y, 38); + this.add2(g); + this.add2(this.add.text(x, y, `${p.energy}/${p.maxEnergy + (p.relics.includes('energy-core') ? 1 : 0)}`, { fontFamily: 'Righteous', fontSize: '26px', color: '#3a2a00' }).setOrigin(0.5)); + } + + renderHand() { + const cb = this.combat; + const hand = cb.hand; + const n = hand.length; + this._handSprites = {}; + if (n === 0) { this._dealHand = false; return; } + // Consume the one-shot "new hand was just dealt" flag — only a fresh + // turn-start hand flies in, not re-renders or mid-turn draws. + const dealAnim = this._dealHand; + this._dealHand = false; + + const cardW = 168, overlap = Math.min(cardW + 18, (1320) / Math.max(1, n)); + const totalW = (n - 1) * overlap + cardW; + const startX = GAME_WIDTH / 2 - totalW / 2 + cardW / 2; + hand.forEach((inst, i) => { + const x = startX + i * overlap; + const playable = canPlay(cb, inst) && !this.pendingPotion; + const sp = this.makeCardSprite(x, 960, inst, 1, { parent: this.handLayer, playable, onClick: () => this.onCardClicked(inst), selected: this.pendingCard === inst }); + this._handSprites[inst.uid] = sp; + if (dealAnim) { + // Start offscreen-left, vertically centered, then slide into the slot. + sp.x = -240; sp.y = GAME_HEIGHT / 2; sp.setScale(0.7); sp.setAlpha(0); + this.tweens.add({ targets: sp, x, y: 960, scaleX: 1, scaleY: 1, alpha: 1, delay: i * 80, duration: 340, ease: 'Cubic.easeOut' }); + } + }); + if (dealAnim) { + this.animating = true; // lock input until the deal lands + this.sfx(SFX.CARD_DEAL); + this.time.delayedCall((n - 1) * 80 + 360, () => { this.animating = false; }); + } + } + + // Reusable card. Returns the container. + makeCardSprite(x, y, inst, scale, opts = {}) { + const c = resolvedCard(inst); + const w = 168, h = 232; + const cont = this.add.container(x, y).setScale(scale); + const typeColor = c.type === 'attack' ? C.attack : c.type === 'power' ? C.power : c.type === 'skill' ? C.skill : 0x6a6070; + const g = this.add.graphics(); + g.fillStyle(0xf7f5ef, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 14); // white card face + g.lineStyle(3, opts.selected ? C.goldI : typeColor, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 14); + // art window + g.fillStyle(0xe7e2d6, 1); g.fillRoundedRect(-w / 2 + 12, -h / 2 + 40, w - 24, 92, 8); + g.lineStyle(1.5, 0x000000, 0.18); g.strokeRoundedRect(-w / 2 + 12, -h / 2 + 40, w - 24, 92, 8); + cont.add(g); + + const art = this.cardArt(inst); + if (art) { + // Fit the illustration inside the art window (w-24 × 92) by the tighter + // axis so it never spills past the frame. No mask — keeps it in sync with + // the hover tween and avoids leaking graphics each re-render. + const img = this.add.image(0, -h / 2 + 86, art.key, art.frame); + const sc = Math.min((w - 24) / Math.max(img.width, 1), 92 / Math.max(img.height, 1)); + img.setScale(sc); + cont.add(img); + } else { + // procedural glyph + const glyph = c.type === 'attack' ? '⚔' : c.type === 'power' ? '✦' : '🛡'; + cont.add(this.add.text(0, -h / 2 + 86, glyph, { fontSize: '54px', color: Phaser.Display.Color.IntegerToColor(typeColor).rgba }).setOrigin(0.5)); + } + + // cost orb + const costG = this.add.graphics(); + costG.fillStyle(C.energy, 1); costG.fillCircle(-w / 2 + 22, -h / 2 + 22, 20); + costG.lineStyle(2, 0x3a2a00, 0.6); costG.strokeCircle(-w / 2 + 22, -h / 2 + 22, 20); + cont.add(costG); + cont.add(this.add.text(-w / 2 + 22, -h / 2 + 22, c.cost < 0 ? '–' : `${c.cost}`, { fontFamily: 'Righteous', fontSize: '24px', color: '#3a2a00' }).setOrigin(0.5)); + + // name + cont.add(this.add.text(0, -h / 2 + 24, c.name, { fontFamily: 'Righteous', fontSize: '19px', color: c.upgraded ? '#1f7a34' : '#17131d', align: 'center', wordWrap: { width: w - 20 } }).setOrigin(0.5)); + // type label + cont.add(this.add.text(0, 6, c.type.toUpperCase(), { fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#6b6475' }).setOrigin(0.5)); + // text + cont.add(this.add.text(0, 60, c.text, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#22202a', align: 'center', wordWrap: { width: w - 24 } }).setOrigin(0.5)); + + if (opts.playable === false && opts.onClick) cont.setAlpha(0.55); + + if (opts.onClick) { + cont.setSize(w, h); + cont.setInteractive({ useHandCursor: true }); + cont.on('pointerover', () => { if (opts.playable !== false && !this.animating) this.tweens.add({ targets: cont, y: y - 30 * scale, scale: scale * 1.06, duration: 110 }); }); + cont.on('pointerout', () => { this.tweens.add({ targets: cont, y, scale, duration: 110 }); }); + cont.on('pointerdown', () => opts.onClick()); + } + (opts.parent || this.viewLayer).add(cont); + return cont; + } + + onCardClicked(inst) { + const cb = this.combat; + if (this.animating || cb.phase !== 'player' || this.pendingPotion) return; + if (!canPlay(cb, inst)) { this.sfx(SFX.SCIFI_PLONK); return; } + const c = resolvedCard(inst); + if (c.target === 'enemy') { + const aliveE = cb.enemies.filter((e) => e.alive); + if (aliveE.length === 1) { this.doPlayCard(inst, aliveE[0].slot); return; } + this.pendingCard = (this.pendingCard === inst) ? null : inst; + this.renderView(); + return; + } + this.doPlayCard(inst, null); + } + + onEnemyTargeted(slot) { + if (this.animating) return; + if (this.pendingPotion) { + const { idx, pid } = this.pendingPotion; + this.pendingPotion = null; + removePotion(this.run, idx); + usePotion(this.combat, pid, slot); + this.afterAction(); + return; + } + if (this.pendingCard) { + const inst = this.pendingCard; + this.pendingCard = null; + this.doPlayCard(inst, slot); + } + } + + // Animated card play: hand → center (grow, 1.0s) → neon shimmer hold (1.2s) → + // fly to each affected character (shrink, 0.75s) → resolve effects on arrival. + // The engine (playCard) is not called until the card(s) land, so HP/block + // changes and damage numbers only appear once the card reaches its target. + doPlayCard(inst, slot) { + if (this.animating) return; + this.animating = true; + this.pendingCard = null; this.pendingPotion = null; + this.clearTargetFx(); // stop the target shimmer before the card flies + const c = resolvedCard(inst); + + const orig = this._handSprites && this._handSprites[inst.uid]; + const startX = orig ? orig.x : GAME_WIDTH / 2; + const startY = orig ? orig.y : 960; + const startScale = orig ? orig.scaleX : 1; + if (orig) { this.tweens.killTweensOf(orig); orig.setVisible(false); orig.disableInteractive(); } + + const cx = GAME_WIDTH / 2, cy = 480; + const card = this.makeCardSprite(startX, startY, inst, startScale, { parent: this.fxLayer }); + card.setDepth(84); + this.sfx(SFX.CARD_SHOW); + + // Phase A — fly to center & grow (1.0s) + this.tweens.add({ + targets: card, x: cx, y: cy, scaleX: 1.85, scaleY: 1.85, duration: 1000, ease: 'Cubic.easeOut', + onComplete: () => this.cardShimmerThenStrike(inst, slot, c, card, cx, cy), + }); + } + + cardShimmerThenStrike(inst, slot, c, card, cx, cy) { + // Phase B — neon shimmer + hold (1.2s) + const sh = this.shimmer(cx, cy, 168, 232, 1.85); + this.time.delayedCall(1200, () => { + sh.tw.stop(); sh.g.destroy(); + // Phase C — split to every affected character & shrink (0.75s) + const dests = this.cardDestinations(c, slot); + this.sfx(c.type === 'attack' ? SFX.SWORD_HIT : SFX.CARD_PLACE); + const cards = dests.map((d, i) => { + if (i === 0) return card; + const clone = this.makeCardSprite(cx, cy, inst, 1.85, { parent: this.fxLayer }); + clone.setDepth(84); + return clone; + }); + let remaining = dests.length; + dests.forEach((d, i) => { + this.tweens.add({ + targets: cards[i], x: d.x, y: d.y, scaleX: 0.5, scaleY: 0.5, alpha: 0.92, duration: 750, ease: 'Cubic.easeIn', + onComplete: () => { if (--remaining <= 0) this.finishPlayCard(inst, slot, cards); }, + }); + }); + }); + } + + finishPlayCard(inst, slot, cards) { + // Snapshot HP before the engine applies the card, so once it lands we can + // animate the slice of each character's health bar that was removed. + const beforeHp = {}; + this.combat.enemies.forEach((e) => { beforeHp[e.slot] = e.hp; }); + const playerBeforeHp = this.combat.player.hp; + + playCard(this.combat, inst, slot); // effects resolve now that the card has arrived + this.flushDamageFx(); // floating damage numbers pop on impact + cards.forEach((cd) => this.tweens.add({ targets: cd, alpha: 0, scaleX: 0.25, scaleY: 0.25, duration: 200, onComplete: () => cd.destroy() })); + this.renderView(); // bars redraw at the NEW (post-hit) values + + // Flash + drain the lost slice on every surviving character that took damage. + let anyDrain = false; + this.combat.enemies.forEach((e) => { + const old = beforeHp[e.slot]; + if (e.alive && old != null && e.hp < old) { + const geom = this._barGeom['e' + e.slot]; + if (geom) { this.animateHealthLoss(geom, e.hp / e.maxHp, old / e.maxHp); anyDrain = true; } + } + }); + const p = this.combat.player; + if (p.hp < playerBeforeHp && this._barGeom.player) { + this.animateHealthLoss(this._barGeom.player, p.hp / p.maxHp, playerBeforeHp / p.maxHp); + anyDrain = true; + } + + // Hold input until the drain finishes (only when something actually drained). + if (anyDrain) this.time.delayedCall(900, () => { this.animating = false; }); + else this.animating = false; + } + + // Flash the slice of a health bar that was just lost, then drain it toward the + // new value. rect = bar {x,y,w,h}; newFrac/oldFrac are HP fractions after/before. + animateHealthLoss(rect, newFrac, oldFrac, accent = 0xff5a5a) { + const lostLeft = rect.x + rect.w * Math.max(0, Math.min(1, newFrac)); + const lostW = rect.w * Math.max(0, Math.min(1, oldFrac) - Math.max(0, newFrac)); + if (lostW <= 1) return; + const g = this.add.graphics().setDepth(88); + this.fxLayer.add(g); + const st = { w: lostW, a: 1 }; + const draw = (color) => { g.clear(); g.fillStyle(color, st.a); g.fillRect(lostLeft, rect.y, st.w, rect.h); }; + // Phase 1 — flash the lost slice white + this.tweens.add({ + targets: st, a: 0.2, duration: 80, yoyo: true, repeat: 2, + onUpdate: () => draw(0xffffff), + onComplete: () => { + st.a = 1; + // Phase 2 — drain the slice down to the new value + this.tweens.add({ + targets: st, w: 0, duration: 440, ease: 'Cubic.easeIn', + onUpdate: () => draw(accent), + onComplete: () => g.destroy(), + }); + }, + }); + } + + // World positions the card should fly to, one per affected character. + cardDestinations(c, slot) { + if (c.target === 'self') return [this._playerPos || { x: 300, y: 560 }]; + if (c.target === 'all') { + const ds = this._enemySprites.map((e) => ({ x: e.x, y: e.y })); + return ds.length ? ds : [{ x: GAME_WIDTH / 2, y: 330 }]; + } + const e = this._enemySprites.find((es) => es.slot === slot) || this._enemySprites[0]; + return [e ? { x: e.x, y: e.y } : { x: GAME_WIDTH / 2, y: 330 }]; + } + + // Hue-cycling neon glow stroked around a card at (x,y). Caller stops + destroys. + shimmer(x, y, w, h, scale) { + const g = this.add.graphics().setDepth(85); + this.fxLayer.add(g); + const obj = { p: 0 }; + const tw = this.tweens.add({ + targets: obj, p: 1, duration: 1200, ease: 'Linear', repeat: -1, + onUpdate: () => { + g.clear(); + const hw = (w / 2) * scale + 14, hh = (h / 2) * scale + 14; + for (let i = 0; i < 3; i++) { + const hue = (obj.p + i * 0.14) % 1; + const col = Phaser.Display.Color.HSVToRGB(hue, 0.9, 1).color; + const pulse = 0.25 + 0.4 * (0.5 + 0.5 * Math.sin(obj.p * Math.PI * 4 + i * 1.3)); + g.lineStyle(7 - i * 2, col, Math.max(0.08, pulse)); + g.strokeRoundedRect(x - hw - i * 5, y - hh - i * 5, hw * 2 + i * 10, hh * 2 + i * 10, 20); + } + }, + }); + return { g, tw }; + } + + afterAction() { + this.flushDamageFx(); + this.renderView(); + } + + onEndTurn() { + if (this.animating || this.combat.phase !== 'player') return; + this.pendingCard = null; this.pendingPotion = null; + this.animating = true; + + // Sweep the remaining hand to the discard pile first, then run the enemy + // phase one beat at a time (see runEnemyTurnAnimated). + const sprites = Object.values(this._handSprites || {}); + this.discardHand(sprites, () => { + // Player end-of-turn upkeep, then render the enemy phase (enemies @home, + // empty hand) so we have fresh sprite + bar geometry to animate against. + beginEnemyPhase(this.combat); + this.sfx(SFX.SCIFI_WOOSH); + this.flushDamageFx(); // any burn damage + this.renderView(); + if (this.combat.phase === 'lost') { this.animating = false; return; } // burn killed the player + this.runEnemyTurnAnimated(() => this.afterEnemyTurn()); + }); + } + + afterEnemyTurn() { + this.clearPlayerHpOverlay(); + if (this.combat.phase === 'lost') { this.animating = false; this.renderView(); return; } + finishEnemyPhase(this.combat); // pick next intents + draw new hand + this._dealHand = true; + this.animating = false; // deal-in re-locks during the new hand + this.renderView(); + } + + // Walk the living enemies and play each one's action in sequence. + runEnemyTurnAnimated(onDone) { + const order = this.combat.enemies.filter((e) => e.alive).map((e) => e.slot); + const step = (k) => { + if (k >= order.length || this.combat.phase === 'lost') { onDone(); return; } + const e = this.combat.enemies[order[k]]; + if (!e || !e.alive) { step(k + 1); return; } + enemyUpkeep(this.combat, e); // block reset / ritual / poison + this.flushDamageFx(); + if (!e.alive || this.combat.phase === 'lost') { step(k + 1); return; } + this.animateEnemyAction(e, () => step(k + 1)); + }; + step(0); + } + + animateEnemyAction(e, done) { + const ref = this._enemySprites.find((s) => s.slot === e.slot); + const sprite = ref && ref.sprite; + const type = e.intent ? e.intent.type : 'unknown'; + if (!sprite) { resolveEnemyMove(this.combat, e); this.flushDamageFx(); done(); return; } + const home = { x: sprite.x, y: sprite.y }; + if (type === 'attack' || type === 'attackdebuff' || type === 'attackdefend') { + this.animateEnemyAttack(e, sprite, home, done); + } else { + this.animateEnemyBuff(e, sprite, home, done); + } + } + + // Shake → lunge at the player → resolve (damage lands) + player HP drain → return. + animateEnemyAttack(e, sprite, home, done) { + this.shakeSprite(sprite, () => { + const tx = this._playerPos.x + 160, ty = this._playerPos.y - 20; + this.tweens.add({ + targets: sprite, x: tx, y: ty, duration: 180, ease: 'Cubic.easeIn', + onComplete: () => { + const before = this.combat.player.hp; + resolveEnemyMove(this.combat, e); // damage applies the instant it reaches you + this.flushDamageFx(); + this.sfx(SFX.SWORD_HIT); + if (this.combat.player.hp < before) this.animatePlayerHpBar(before, this.combat.player.hp); + this.tweens.add({ + targets: sprite, x: home.x, y: home.y, duration: 240, ease: 'Cubic.easeOut', delay: 140, + onComplete: () => done(), + }); + }, + }); + }); + } + + // Shake + colored neon outline + a type-specific signifier while the buff applies. + animateEnemyBuff(e, sprite, home, done) { + const cat = this.buffCategory(e); + resolveEnemyMove(this.combat, e); + this.flushDamageFx(); + this.sfx(SFX.SCIFI_PLINK); + this.neonOutline(home.x, home.y, cat.color, 860); + this.spawnBuffSignifier(home.x, home.y, cat, sprite); + this.shakeSprite(sprite, null, 7, 320); + this.time.delayedCall(900, () => done()); + } + + // Which kind of self-buff is the enemy doing? Drives color + signifier. + buffCategory(e) { + const effs = (e.intent && e.intent.move && e.intent.move.effects) || []; + const has = (pred) => effs.some(pred); + if (has((x) => x.op === 'buffSelfEnemy' && (x.status === 'strength' || x.status === 'ritual'))) + return { kind: 'strength', color: 0xe0533a }; + if (has((x) => x.op === 'blockSelf' || (x.op === 'buffSelfEnemy' && x.status === 'metallicize'))) + return { kind: 'block', color: 0x6fa8dc }; + if (has((x) => x.op === 'debuffPlayer' || x.op === 'addCardToDiscard')) + return { kind: 'debuff', color: 0xb05fd0 }; + return { kind: 'other', color: 0x8a8a9a }; + } + + shakeSprite(sprite, onComplete, intensity = 10, dur = 260) { + const ox = sprite.x; + this.tweens.add({ + targets: sprite, x: ox - intensity, duration: 40, yoyo: true, repeat: Math.max(1, Math.floor(dur / 80)), + onComplete: () => { sprite.x = ox; if (onComplete) onComplete(); }, + }); + } + + // Pulsing single-color neon outline around a unit, then it self-destructs. + neonOutline(x, y, color, duration) { + const g = this.add.graphics().setDepth(83); + this.fxLayer.add(g); + const obj = { p: 0 }; + this.tweens.add({ + targets: obj, p: 1, duration, ease: 'Linear', + onUpdate: () => { + g.clear(); + for (let i = 0; i < 3; i++) { + const pulse = 0.18 + 0.5 * (0.5 + 0.5 * Math.sin(obj.p * Math.PI * 6 + i)); + g.lineStyle(7 - i * 2, color, Math.max(0.06, pulse) * (1 - obj.p * 0.25)); + g.strokeRoundedRect(x - 110 - i * 5, y - 120 - i * 5, 220 + i * 10, 250 + i * 10, 18); + } + }, + onComplete: () => g.destroy(), + }); + } + + expandRing(x, y, color) { + const g = this.add.graphics().setDepth(82); + this.fxLayer.add(g); + const obj = { r: 30, a: 0.85 }; + this.tweens.add({ + targets: obj, r: 150, a: 0, duration: 600, ease: 'Cubic.easeOut', + onUpdate: () => { g.clear(); g.lineStyle(5, color, obj.a); g.strokeCircle(x, y, obj.r); }, + onComplete: () => g.destroy(), + }); + } + + // The "meaningful" extra animation per buff type. + spawnBuffSignifier(x, y, cat, sprite) { + if (cat.kind === 'strength') { + // red arrows surging up + the enemy flexes bigger + for (let i = 0; i < 3; i++) { + const a = this.add.text(x - 32 + i * 32, y - 30, '▲', { fontSize: '40px', color: '#ff6a44' }).setOrigin(0.5).setDepth(86); + this.fxLayer.add(a); + this.tweens.add({ targets: a, y: y - 150, alpha: 0, duration: 700, delay: i * 110, ease: 'Cubic.easeOut', onComplete: () => a.destroy() }); + } + this.tweens.add({ targets: sprite, scaleX: sprite.scaleX * 1.18, scaleY: sprite.scaleY * 1.18, duration: 170, yoyo: true, ease: 'Quad.easeOut' }); + } else if (cat.kind === 'block') { + // shield rises + a blue shockwave ring + const sh = this.add.text(x, y - 60, '🛡', { fontSize: '58px' }).setOrigin(0.5).setDepth(86).setAlpha(0); + this.fxLayer.add(sh); + this.tweens.add({ targets: sh, alpha: 1, y: y - 100, duration: 280, yoyo: true, hold: 280, onComplete: () => sh.destroy() }); + this.expandRing(x, y, 0x6fa8dc); + } else if (cat.kind === 'debuff') { + // purple arrows rain toward the player + a purple shockwave + for (let i = 0; i < 3; i++) { + const a = this.add.text(x - 32 + i * 32, y - 10, '▼', { fontSize: '40px', color: '#d77bff' }).setOrigin(0.5).setDepth(86); + this.fxLayer.add(a); + this.tweens.add({ targets: a, y: y + 130, x: a.x + (this._playerPos.x - x) * 0.12, alpha: 0, duration: 720, delay: i * 110, ease: 'Cubic.easeIn', onComplete: () => a.destroy() }); + } + this.expandRing(x, y, 0xb05fd0); + } else { + const t = this.add.text(x, y - 50, '💤', { fontSize: '46px' }).setOrigin(0.5).setDepth(86); + this.fxLayer.add(t); + this.tweens.add({ targets: t, y: y - 120, alpha: 0, duration: 850, onComplete: () => t.destroy() }); + } + } + + // Persistent player HP-bar overlay that flashes the lost slice, then drains to + // the new value. Stays up (showing current HP) until the enemy turn ends. + animatePlayerHpBar(oldHp, newHp) { + const geom = this._barGeom.player; + if (!geom) return; + const max = this.combat.player.maxHp; + this.clearPlayerHpOverlay(); + const { x, y, w, h } = geom; + const g = this.add.graphics().setDepth(89); + this.fxLayer.add(g); + const txt = this.add.text(x + w / 2, y + h / 2, `${Math.max(0, newHp)}/${max}`, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#ffffff' }).setOrigin(0.5).setDepth(90); + this.fxLayer.add(txt); + this._pHpOverlay = g; this._pHpText = txt; + const newFrac = Math.max(0, newHp / max); + const st = { ghostRight: Math.max(0, oldHp / max), a: 1 }; + const draw = (ghostColor) => { + g.clear(); + g.fillStyle(C.hpBack, 1); g.fillRoundedRect(x, y, w, h, 6); + if (newFrac > 0) { g.fillStyle(newHp <= max * 0.3 ? C.hpLow : C.hp, 1); g.fillRoundedRect(x, y, w * newFrac, h, 6); } + const gw = w * (st.ghostRight - newFrac); + if (gw > 0.5) { g.fillStyle(ghostColor, st.a); g.fillRect(x + w * newFrac, y, gw, h); } + g.lineStyle(2, 0x000000, 0.4); g.strokeRoundedRect(x, y, w, h, 6); + }; + // Phase 1 — flash the lost slice white + this.tweens.add({ + targets: st, a: 0.2, duration: 80, yoyo: true, repeat: 2, + onUpdate: () => draw(0xffffff), + onComplete: () => { + st.a = 1; + // Phase 2 — drain it down to the new value + this.tweens.add({ targets: st, ghostRight: newFrac, duration: 460, ease: 'Cubic.easeIn', onUpdate: () => draw(0xff5a5a), onComplete: () => draw(0xff5a5a) }); + }, + }); + } + + clearPlayerHpOverlay() { + if (this._pHpOverlay) { this._pHpOverlay.destroy(); this._pHpOverlay = null; } + if (this._pHpText) { this._pHpText.destroy(); this._pHpText = null; } + } + + // Fly each remaining hand card to the discard pile (bottom-right), one at a + // time, shrinking, then a quick fade once it lands. onDone fires when empty. + discardHand(sprites, onDone) { + if (!sprites.length) { onDone(); return; } + const dx = GAME_WIDTH - 120, dy = 1000; // discard-pile counter position + sprites.sort((a, b) => a.x - b.x); + let remaining = sprites.length; + sprites.forEach((sp, i) => { + this.tweens.killTweensOf(sp); + sp.disableInteractive(); + this.fxLayer.add(sp); // lift above the board; lives until destroyed + sp.setDepth(82); + this.tweens.add({ + targets: sp, x: dx, y: dy, scaleX: 0.26, scaleY: 0.26, + delay: i * 100, duration: 240, ease: 'Cubic.easeIn', + onComplete: () => { + this.sfx(SFX.CARD_PLACE); + this.tweens.add({ + targets: sp, alpha: 0, duration: 110, + onComplete: () => { sp.destroy(); if (--remaining <= 0) onDone(); }, + }); + }, + }); + }); + } + + flushDamageFx() { + const cb = this.combat; + const fresh = cb.log.slice(this._logCursor); + this._logCursor = cb.log.length; + for (const ev of fresh) { + if (ev.t !== 'damage' || ev.amount <= 0) continue; + let x, y; + if (ev.target === 'player') { x = 300; y = 480; } + else { const s = this._enemySprites.find((es) => es.slot === ev.target); if (!s) continue; x = s.x; y = s.y - 40; } + const t = this.add.text(x + (Math.random() * 40 - 20), y, `-${ev.amount}`, { fontFamily: 'Righteous', fontSize: '40px', color: '#ff6a6a' }).setOrigin(0.5).setDepth(90); + this.fxLayer.add(t); + this.tweens.add({ targets: t, y: y - 70, alpha: 0, duration: 750, onComplete: () => t.destroy() }); + } + } + + onCombatOver(result) { + if (this._settled) return; this._settled = true; + const rng = makeRng((this.run.seed ^ this.hashNode(this.combatNode.id) ^ 0x1234) >>> 0); + const outcome = settleCombat(this.combat, this.combatNode, rng); + this._settled = false; + if (result === 'lost') { + this.sfx(SFX.CASINO_LOSE); + this.recordHistory(false); + return this.setView('gameover'); + } + this.sfx(SFX.CASINO_WIN); + if (this.run.finished && this.run.victory) { + this.recordHistory(true); + return this.setView('gameover'); + } + this.pendingReward = outcome.rewards; + this.rewardTaken = { card: false, potion: false, relic: false }; + this.setView('reward'); + } + + // ═══════════════════════════════════════════════════════ reward ════════════ + renderReward() { + this.renderRunHud(); + const cx = GAME_WIDTH / 2; + this.text(cx, 110, 'Victory!', 56, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + const rw = this.pendingReward; + let y = 210; + + // gold (auto-collected once) + if (!this._goldCollected) { this.run.gold += rw.gold; this._goldCollected = true; } + this.text(cx, y, `+${rw.gold} gold`, 30, C.gold, { ox: 0.5, oy: 0.5 }); y += 60; + + if (rw.relic && !this.rewardTaken.relic) { + const relic = RELICS[rw.relic]; + const b = new Button(this, cx, y, `Take Relic — ${relic.name}`, () => { addRelic(this.run, rw.relic); this.rewardTaken.relic = true; this.sfx(SFX.COINS); this.renderView(); }, { width: 560, height: 56 }); + this.add2(b); y += 76; + this.text(cx, y - 18, relic.desc, 18, C.muted, { ox: 0.5, oy: 0.5 }); y += 24; + } + if (rw.potion && !this.rewardTaken.potion) { + const pot = POTIONS[rw.potion]; + const can = this.run.potions.length < this.run.maxPotions; + const b = new Button(this, cx, y, can ? `Take Potion — ${pot.name}` : 'Potion belt full', () => { if (addPotion(this.run, rw.potion)) { this.rewardTaken.potion = true; this.renderView(); } }, { width: 560, height: 56 }); + this.add2(b); if (!can) b.setAlpha(0.6); y += 76; + } + + // card choices + if (!this.rewardTaken.card && rw.cards.length) { + this.text(cx, y + 6, 'Add a card to your deck:', 26, C.ink, { ox: 0.5, oy: 0.5 }); y += 50; + const n = rw.cards.length; + const gap = 200; const startX = cx - (n - 1) * gap / 2; + rw.cards.forEach((cr, i) => { + const inst = { uid: -1 - i, id: cr.id, upgraded: cr.upgraded }; + this.makeCardSprite(startX + i * gap, y + 130, inst, 0.92, { playable: true, onClick: () => { addCardToDeck(this.run, cr.id, cr.upgraded); this.rewardTaken.card = true; this.sfx(SFX.CARD_PLACE); this.renderView(); } }); + }); + const skip = new Button(this, cx, y + 300, 'Skip card', () => { this.rewardTaken.card = true; this.renderView(); }, { width: 240, height: 50, variant: 'ghost' }); + this.add2(skip); + } + + const proceed = new Button(this, cx, GAME_HEIGHT - 70, 'Continue', () => this.leaveReward(), { width: 320, height: 70 }); + this.add2(proceed); + } + + leaveReward() { + this._goldCollected = false; + this.pendingReward = null; + this.setView('map'); + } + + // ═══════════════════════════════════════════════════════ rest ══════════════ + renderRest() { + this.renderRunHud(); + const cx = GAME_WIDTH / 2; + this.text(cx, 130, 'Rest Site', 52, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + this.text(cx, 200, 'A quiet fire. Tend your wounds, or hone a blade.', 24, C.muted, { ox: 0.5, oy: 0.5 }); + const healAmt = Math.floor(this.run.maxHp * 0.3); + const b1 = new Button(this, cx - 240, 340, `Rest — heal ${healAmt} HP`, () => { restHeal(this.run); this.sfx(SFX.CASINO_WIN); this.setView('map'); }, { width: 420, height: 80 }); + const b2 = new Button(this, cx + 240, 340, 'Smith — upgrade a card', () => this.openUpgradePicker(), { width: 420, height: 80 }); + this.add2(b1); this.add2(b2); + this.backButton('Skip'); + } + + openUpgradePicker() { + this.clearView(); + this.renderRunHud(); + const cx = GAME_WIDTH / 2; + this.text(cx, 80, 'Choose a card to upgrade', 36, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + const upgradeable = this.run.deck.filter((c) => CARDS[c.id]?.upgrade && !c.upgraded); + this.renderDeckGrid(upgradeable, (inst) => { upgradeCardInDeck(this.run, inst.uid); this.sfx(SFX.SWORD_SLICE); this.setView('map'); }, 'Nothing left to upgrade.'); + const back = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.setView('rest'), { width: 220, height: 56, variant: 'ghost' }); + this.add2(back); + } + + renderDeckGrid(cards, onPick, emptyMsg) { + if (!cards.length) { this.text(GAME_WIDTH / 2, 400, emptyMsg, 28, C.muted, { ox: 0.5, oy: 0.5 }); return; } + const perRow = 8, scale = 0.82, gapX = 195, gapY = 290; + const cols = Math.min(perRow, cards.length); + const startX = GAME_WIDTH / 2 - (cols - 1) * gapX / 2; + cards.forEach((inst, i) => { + const r = Math.floor(i / perRow), col = i % perRow; + const x = startX + col * gapX, y = 280 + r * gapY; + this.makeCardSprite(x, y, inst, scale, { playable: true, onClick: () => onPick(inst) }); + }); + } + + // ═══════════════════════════════════════════════════════ shop ══════════════ + renderShop() { + this.renderRunHud(); + const cx = GAME_WIDTH / 2; + this.text(cx, 80, 'Merchant', 48, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + const shop = this.shop; + + // cards row + const n = shop.cards.length; + const gap = 215; const startX = cx - (n - 1) * gap / 2; + shop.cards.forEach((entry, i) => { + if (entry.bought) return; + const inst = { uid: -100 - i, id: entry.id, upgraded: false }; + const x = startX + i * gap; + this.makeCardSprite(x, 280, inst, 0.86, { playable: this.run.gold >= entry.price, onClick: () => this.buyCard(i) }); + this.text(x, 420, `${entry.price}g`, 24, this.run.gold >= entry.price ? C.gold : '#a05050', { ox: 0.5, oy: 0.5 }); + }); + + // relics + potions row + let rx = cx - 300; + shop.relics.forEach((entry, i) => { + if (entry.bought) return; + const relic = RELICS[entry.id]; + const b = new Button(this, rx, 600, `${relic.name} — ${entry.price}g`, () => this.buyRelic(i), { width: 360, height: 64 }); + this.add2(b); if (this.run.gold < entry.price) b.setAlpha(0.6); + this.text(rx, 650, relic.desc, 16, C.muted, { ox: 0.5, oy: 0.5 }); + rx += 380; + }); + shop.potions.forEach((entry, i) => { + if (entry.bought) return; + const pot = POTIONS[entry.id]; + const b = new Button(this, cx - 200 + i * 400, 740, `${pot.name} — ${entry.price}g`, () => this.buyPotion(i), { width: 360, height: 60 }); + this.add2(b); if (this.run.gold < entry.price || this.run.potions.length >= this.run.maxPotions) b.setAlpha(0.6); + }); + + // card removal + if (!shop.removalUsed) { + const b = new Button(this, cx, 860, `Remove a card — ${shop.removalPrice}g`, () => this.openRemovalPicker(), { width: 420, height: 64, variant: 'ghost' }); + this.add2(b); if (this.run.gold < shop.removalPrice) b.setAlpha(0.6); + } + + const leave = new Button(this, cx, GAME_HEIGHT - 60, 'Leave Shop', () => this.setView('map'), { width: 280, height: 64 }); + this.add2(leave); + } + + buyCard(i) { const e = this.shop.cards[i]; if (e.bought || this.run.gold < e.price) return; this.run.gold -= e.price; e.bought = true; addCardToDeck(this.run, e.id); this.sfx(SFX.PURCHASE); this.renderView(); } + buyRelic(i) { const e = this.shop.relics[i]; if (e.bought || this.run.gold < e.price) return; this.run.gold -= e.price; e.bought = true; addRelic(this.run, e.id); this.sfx(SFX.PURCHASE); this.renderView(); } + buyPotion(i) { const e = this.shop.potions[i]; if (e.bought || this.run.gold < e.price || this.run.potions.length >= this.run.maxPotions) return; if (addPotion(this.run, e.id)) { this.run.gold -= e.price; e.bought = true; this.sfx(SFX.PURCHASE); this.renderView(); } } + + openRemovalPicker() { + if (this.run.gold < this.shop.removalPrice) return; + this.clearView(); + this.renderRunHud(); + this.text(GAME_WIDTH / 2, 80, 'Remove a card from your deck', 34, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + this.renderDeckGrid(this.run.deck.slice(), (inst) => { removeCardFromDeck(this.run, inst.uid); this.run.gold -= this.shop.removalPrice; this.shop.removalUsed = true; this.sfx(SFX.SCIFI_PLINK); this.setView('shop'); }, 'Deck is empty.'); + const back = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 60, 'Cancel', () => this.setView('shop'), { width: 220, height: 56, variant: 'ghost' }); + this.add2(back); + } + + // ═══════════════════════════════════════════════════════ event ═════════════ + pickEvent(nodeId) { const rng = makeRng((this.run.seed ^ this.hashNode(nodeId)) >>> 0); return rng.pick(EVENTS); } + + renderEvent() { + this.renderRunHud(); + const cx = GAME_WIDTH / 2; + const ev = this.activeEvent; + this.panel(cx - 500, 160, 1000, 240, {}); + this.text(cx, 210, ev.title, 42, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + this.text(cx, 300, ev.text, 26, C.ink, { ox: 0.5, oy: 0.5, align: 'center', wrap: 900 }); + ev.options.forEach((opt, i) => { + const b = new Button(this, cx, 470 + i * 100, opt.label, () => this.resolveEvent(opt), { width: 760, height: 76 }); + this.add2(b); + }); + } + + resolveEvent(opt) { + const e = opt.effect; const run = this.run; const rng = makeRng((run.seed ^ Date.now()) >>> 0); + let toast = ''; + switch (e.op) { + case 'healPct': run.hp = Math.min(run.maxHp, run.hp + Math.floor(run.maxHp * e.amount)); toast = `Healed ${Math.floor(run.maxHp * e.amount)} HP`; break; + case 'upgrade': return this.openUpgradePickerFromEvent(); + case 'removeForGold': if (run.deck.length) { return this.openRemovalFromEvent(e.gold); } toast = 'No cards to remove'; break; + case 'relicAndCurse': { const rid = this.rollEventRelic(rng); if (rid) addRelic(run, rid); addCardToDeck(run, 'wound'); toast = rid ? `Gained ${RELICS[rid].name} + a Wound` : 'Nothing happened'; break; } + case 'goldForHp': run.gold += e.gold; run.hp = Math.max(1, run.hp - e.hp); toast = `+${e.gold} gold, -${e.hp} HP`; break; + case 'hpForPotion': run.hp = Math.max(1, run.hp - e.hp); { const pid = ['fire-potion', 'block-potion', 'heal-potion', 'strength-potion'][rng.int(4)]; addPotion(run, pid); toast = `-${e.hp} HP, gained ${POTIONS[pid].name}`; } break; + case 'goldAndCurse': run.gold += e.gold; addCardToDeck(run, 'wound'); toast = `+${e.gold} gold, gained a Wound`; break; + default: toast = 'You move on.'; break; + } + this.sfx(SFX.PIECE_CLICK); + this.eventToast = toast; + this.setView('map'); + } + + rollEventRelic(rng) { + const owned = new Set(this.run.relics); + const pool = Object.values(RELICS).filter((r) => r.rarity !== 'starter' && !owned.has(r.id)); + return pool.length ? pool[rng.int(pool.length)].id : null; + } + + openUpgradePickerFromEvent() { + this.clearView(); this.renderRunHud(); + this.text(GAME_WIDTH / 2, 80, 'Upgrade a card', 36, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + const up = this.run.deck.filter((c) => CARDS[c.id]?.upgrade && !c.upgraded); + this.renderDeckGrid(up, (inst) => { upgradeCardInDeck(this.run, inst.uid); this.sfx(SFX.SWORD_SLICE); this.setView('map'); }, 'Nothing to upgrade.'); + } + openRemovalFromEvent(gold) { + this.clearView(); this.renderRunHud(); + this.text(GAME_WIDTH / 2, 80, 'Remove a card', 36, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + this.renderDeckGrid(this.run.deck.slice(), (inst) => { removeCardFromDeck(this.run, inst.uid); this.run.gold += gold; this.sfx(SFX.SCIFI_PLINK); this.setView('map'); }, 'Deck empty.'); + } + + // ═══════════════════════════════════════════════════════ treasure ══════════ + rollTreasure(nodeId) { const rng = makeRng((this.run.seed ^ this.hashNode(nodeId) ^ 0x77) >>> 0); return this.rollEventRelic(rng); } + + renderTreasure() { + this.renderRunHud(); + const cx = GAME_WIDTH / 2; + this.text(cx, 160, 'Treasure', 52, C.gold, { font: 'Righteous', ox: 0.5, oy: 0.5 }); + const rid = this.pendingTreasure; + if (rid) { + const relic = RELICS[rid]; + this.text(cx, 320, relic.name, 38, C.ink, { ox: 0.5, oy: 0.5 }); + this.text(cx, 380, relic.desc, 24, C.muted, { ox: 0.5, oy: 0.5, align: 'center', wrap: 800 }); + const take = new Button(this, cx, 500, 'Take it', () => { addRelic(this.run, rid); this.sfx(SFX.COINS); this.setView('map'); }, { width: 320, height: 70 }); + this.add2(take); + } else { + this.text(cx, 320, 'The chest is empty.', 28, C.muted, { ox: 0.5, oy: 0.5 }); + } + const leave = new Button(this, cx, GAME_HEIGHT - 80, 'Leave', () => this.setView('map'), { width: 240, height: 60, variant: 'ghost' }); + this.add2(leave); + } + + // ═══════════════════════════════════════════════════════ game over ═════════ + renderGameOver() { + const cx = GAME_WIDTH / 2; + const win = this.run.victory; + this.text(cx, 240, win ? 'THE SPIRE IS YOURS' : 'YOU DIED', 72, win ? C.gold : '#c24040', { font: 'Righteous', ox: 0.5, oy: 0.5 }); + this.text(cx, 340, win ? 'You climbed Spire Climb and slew its boss.' : `You fell on floor ${this.run.floor}.`, 28, C.muted, { ox: 0.5, oy: 0.5 }); + this.text(cx, 410, `Cards in deck: ${this.run.deck.length} Relics: ${this.run.relics.length} Gold: ${this.run.gold}`, 24, C.ink, { ox: 0.5, oy: 0.5 }); + const again = new Button(this, cx - 180, 560, 'New Run', () => { this.init({ game: this.gameDef }); this.clearView(); this.renderView(); }, { width: 300, height: 76 }); + const menu = new Button(this, cx + 180, 560, 'Back to Menu', () => this.scene.start('GameMenu'), { width: 300, height: 76, variant: 'ghost' }); + this.add2(again); this.add2(menu); + } + + recordHistory(victory) { + try { + api.post('/history/single-player', { + game: 'spireclimb', won: victory, + score: this.run.floor, detail: { className: this.run.className, floor: this.run.floor }, + }).catch(() => {}); + } catch (_) {} + } + + // ═══════════════════════════════════════════════════════ run hud ═══════════ + renderRunHud() { + if (!this.run) return; + // top bar: HP, gold, floor, relics + const g = this.add.graphics(); + g.fillStyle(0x000000, 0.35); g.fillRect(0, 0, GAME_WIDTH, 96); + this.add2(g); + this.renderBar(40, 36, 240, 26, this.run.hp, this.run.maxHp, this.run.hp <= this.run.maxHp * 0.3 ? C.hpLow : C.hp); + this.text(320, 36, `${this.run.gold} g`, 26, C.gold, { ox: 0, oy: 0 }); + this.text(GAME_WIDTH / 2, 49, `${CLASSES[this.run.className].name} · Floor ${this.run.floor}`, 22, C.muted, { ox: 0.5, oy: 0.5 }); + // relics + this.run.relics.forEach((rid, i) => { + const x = GAME_WIDTH - 60 - i * 52, y = 48; + const c = this.add.circle(x, y, 22, 0x2a2235).setStrokeStyle(2, C.goldI).setInteractive({ useHandCursor: true }); + this.add2(c); + this.add2(this.add.text(x, y, (RELICS[rid]?.name || '?')[0], { fontFamily: 'Righteous', fontSize: '20px', color: C.gold }).setOrigin(0.5)); + c.on('pointerover', () => this.showTip(x, y + 34, `${RELICS[rid].name}: ${RELICS[rid].desc}`)); + c.on('pointerout', () => this.hideTip()); + }); + // potions + this.run.potions.forEach((pid, i) => { this.renderPotion(120 + i * 60, 70, pid, i, false); }); + if (this.eventToast) { this.text(GAME_WIDTH / 2, 130, this.eventToast, 24, C.gold, { ox: 0.5, oy: 0.5 }); this.eventToast = null; } + } + + // ═══════════════════════════════════════════════════════ tooltip ═══════════ + showTip(x, y, str) { + this.hideTip(); + const t = this.add.text(x, y, str, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: C.ink, align: 'center', wordWrap: { width: 320 }, backgroundColor: '#000000cc', padding: { x: 10, y: 6 } }).setOrigin(0.5, 0).setDepth(120); + this.fxLayer.add(t); + this._tip = t; + } + hideTip() { if (this._tip) { this._tip.destroy(); this._tip = null; } } +} diff --git a/public/src/games/spireclimb/SpireClimbLogic.js b/public/src/games/spireclimb/SpireClimbLogic.js new file mode 100644 index 0000000..4b53d04 --- /dev/null +++ b/public/src/games/spireclimb/SpireClimbLogic.js @@ -0,0 +1,732 @@ +// SpireClimbLogic.js +// Pure (no Phaser) engine for Spire Climb: seeded RNG, map generation, the +// turn-based combat state machine (energy / draw-discard-exhaust / block / +// statuses / enemy intents), relic hooks, and run-meta helpers (rewards, rest, +// shop, events). SpireClimbGame renders state and forwards player input here; +// verifySpireClimb.js drives it headless. + +import { + CLASSES, CARDS, RELICS, POTIONS, POTION_IDS, ENEMIES, ENCOUNTERS, EVENTS, + ACT, cardPoolFor, relicPool, +} from './SpireClimbData.js'; + +// ── RNG (mulberry32, seedable & serializable) ──────────────────────────────── +export function makeRng(seed) { + let s = (seed >>> 0) || 1; + const fn = () => { + s |= 0; s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + fn.int = (n) => Math.floor(fn() * n); + fn.pick = (arr) => arr[fn.int(arr.length)]; + fn.range = (lo, hi) => lo + fn.int(hi - lo + 1); + fn.shuffle = (arr) => { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = fn.int(i + 1); [a[i], a[j]] = [a[j], a[i]]; } return a; }; + return fn; +} + +let _uid = 1; +export function cardInstance(cardId, upgraded = false) { + return { uid: _uid++, id: cardId, upgraded }; +} + +// Returns the card definition with upgrade overrides folded in. +export function resolvedCard(inst) { + const base = CARDS[inst.id]; + if (!base) return null; + if (!inst.upgraded || !base.upgrade) return base; + return { ...base, ...base.upgrade, name: `${base.name}+`, id: base.id, type: base.type, cls: base.cls, rarity: base.rarity, target: base.target }; +} + +export function cardCost(inst) { + const c = resolvedCard(inst); + return c.cost; +} + +// ── RUN / MAP ──────────────────────────────────────────────────────────────── +// A run holds persistent player state + the generated map. Combat is a separate +// transient object created from a run + an encounter. +export function newRun(className, seed = (Math.random() * 1e9) | 0) { + const cls = CLASSES[className] || CLASSES.warrior; + const rng = makeRng(seed); + const deck = cls.startingDeck.map((id) => cardInstance(id)); + const run = { + seed, + className: cls.id, + maxHp: cls.maxHp, + hp: cls.maxHp, + gold: ACT.startGold, + deck, + relics: [cls.startRelic], + potions: [], + maxPotions: 3, + map: null, + currentNodeId: null, // null until first row picked + floor: 0, + visited: [], + act: 1, + finished: false, + victory: false, + }; + run.map = generateMap(rng); + return run; +} + +// Build a layered branching map: rows of nodes with edges to the next row. +// Row 0 = entry combats; last row = boss; rest forced one row below boss. +export function generateMap(rng) { + const rows = ACT.rows; + const grid = []; + for (let r = 0; r < rows; r++) { + let width; + if (r === 0) width = rng.range(2, 3); + else if (r === rows - 1) width = 1; // boss + else if (r === rows - 2) width = rng.range(1, 2); + else width = rng.range(ACT.minWidth + 1, ACT.maxWidth); + const nodes = []; + for (let i = 0; i < width; i++) { + nodes.push({ + id: `n${r}_${i}`, row: r, col: i, + type: assignType(r, rows, rng), + edges: [], + }); + } + grid.push(nodes); + } + // boss + grid[rows - 1][0].type = 'boss'; + // guarantee a rest right before the boss + grid[rows - 2].forEach((n) => { n.type = 'rest'; }); + // first row are always combats; treasure node mid-act guarantee + grid[0].forEach((n) => { n.type = 'combat'; }); + const midRow = Math.floor(rows / 2); + if (grid[midRow][0]) grid[midRow][0].type = 'treasure'; + + // connect each node to 1–2 nodes on the next row (nearest cols), ensure + // every next-row node has at least one parent. + for (let r = 0; r < rows - 1; r++) { + const cur = grid[r]; + const nxt = grid[r + 1]; + const parented = new Set(); + cur.forEach((node) => { + const links = rng.range(1, 2); + const order = nxt.map((_, i) => i).sort((a, b) => Math.abs(a - node.col) - Math.abs(b - node.col)); + for (let k = 0; k < links && k < order.length; k++) { + const t = nxt[order[k]]; + if (!node.edges.includes(t.id)) node.edges.push(t.id); + parented.add(order[k]); + } + }); + nxt.forEach((t, i) => { + if (!parented.has(i)) { + // attach orphan to nearest current node + let best = cur[0]; + for (const c of cur) if (Math.abs(c.col - t.col) < Math.abs(best.col - t.col)) best = c; + if (!best.edges.includes(t.id)) best.edges.push(t.id); + } + }); + } + return { rows, grid }; +} + +function assignType(r, rows, rng) { + if (r === 0) return 'combat'; + if (r === rows - 1) return 'boss'; + // weighted pick; no elites/rests too early + const w = { ...ACT.weights }; + if (r <= 1) { w.elite = 0; w.rest = 0; w.shop = 0; } + const total = Object.values(w).reduce((a, b) => a + b, 0); + let roll = rng() * total; + for (const [type, weight] of Object.entries(w)) { + roll -= weight; + if (roll <= 0) return type; + } + return 'combat'; +} + +export function nodeById(run, id) { + for (const row of run.map.grid) for (const n of row) if (n.id === id) return n; + return null; +} + +// Nodes the player may move to next. +export function availableNodes(run) { + if (!run.currentNodeId) return run.map.grid[0].slice(); + const cur = nodeById(run, run.currentNodeId); + if (!cur) return []; + return cur.edges.map((id) => nodeById(run, id)).filter(Boolean); +} + +export function enterNode(run, nodeId) { + const node = nodeById(run, nodeId); + if (!node) return null; + run.currentNodeId = nodeId; + run.floor = node.row + 1; + run.visited.push(nodeId); + return node; +} + +// ── COMBAT ─────────────────────────────────────────────────────────────────── +export function encounterForNode(run, node, rng) { + let table; + if (node.type === 'boss') table = ENCOUNTERS.boss; + else if (node.type === 'elite') table = ENCOUNTERS.elite; + else table = ENCOUNTERS.normal; + return rng.pick(table); +} + +export function startCombat(run, enemyIds, seed) { + const rng = makeRng(seed); + const player = { + hp: run.hp, maxHp: run.maxHp, block: 0, + alive: true, // so enemy attacks treat the player as a valid target + energy: 0, maxEnergy: 3, + statuses: {}, // {strength, dexterity, vulnerable, weak, frail, poison, ritual, regen, metallicize} + relics: run.relics.slice(), + attacksThisTurn: 0, + attacksTotal: 0, + }; + const enemies = enemyIds.map((id, idx) => { + const def = ENEMIES[id]; + const hp = rng.range(def.hp[0], def.hp[1]); + return { + slot: idx, defId: id, name: def.name, color: def.color, + placeholderFrame: def.placeholderFrame, + hp, maxHp: hp, block: 0, alive: true, + statuses: {}, + moveIdx: 0, moveCount: 0, lastMoveId: null, + intent: null, + }; + }); + const combat = { + seed, rng, turn: 0, phase: 'start', + player, + enemies, + draw: rng.shuffle(run.deck.map((c) => cardInstance(c.id, c.upgraded))), + hand: [], + discard: [], + exhaust: [], + log: [], + handSize: 5, + isBoss: false, isElite: false, + run, // back-reference for persistence on combat end + }; + applyCombatStartRelics(combat); + // choose initial intents + combat.enemies.forEach((e) => chooseEnemyMove(combat, e)); + startPlayerTurn(combat, true); + return combat; +} + +function applyCombatStartRelics(combat) { + const p = combat.player; + for (const rid of p.relics) { + const r = RELICS[rid]; + if (!r) continue; + switch (r.hook) { + case 'combatStartBlock': p.block += r.value; break; + case 'combatStartStrength': addStatus(p, 'strength', r.value); break; + case 'combatStartDexterity': addStatus(p, 'dexterity', r.value); break; + case 'combatStartHeal': p.hp = Math.min(p.maxHp, p.hp + r.value); break; + case 'combatStartVulnAll': combat.enemies.forEach((e) => addStatus(e, 'vulnerable', r.value)); break; + default: break; + } + } +} + +function hasRelic(combat, id) { return combat.player.relics.includes(id); } +function relicVal(combat, id) { return hasRelic(combat, id) ? RELICS[id].value : 0; } + +function startPlayerTurn(combat, first = false) { + const p = combat.player; + combat.turn += 1; + combat.phase = 'player'; + p.block = 0; + p.attacksThisTurn = 0; + p.energy = p.maxEnergy + relicVal(combat, 'energy-core'); + // ritual / demon form, regen handled at relevant ends; strength-from-ritual is enemy-only here + // turn-start poison on player + tickPoison(combat, p, 'You'); + if (combat.phase === 'lost') return; + // draw + let drawN = combat.handSize; + if (first) drawN += relicVal(combat, 'ring-of-the-snake'); + drawCards(combat, drawN); +} + +export function drawCards(combat, n) { + for (let i = 0; i < n; i++) { + if (combat.draw.length === 0) { + if (combat.discard.length === 0) break; + combat.draw = combat.rng.shuffle(combat.discard); + combat.discard = []; + } + combat.hand.push(combat.draw.shift()); + } +} + +// ── status helpers ── +export function addStatus(unit, key, amount) { + unit.statuses[key] = (unit.statuses[key] || 0) + amount; + if (unit.statuses[key] === 0) delete unit.statuses[key]; +} +export function statusOf(unit, key) { return unit.statuses[key] || 0; } + +function tickPoison(combat, unit, label) { + const p = statusOf(unit, 'poison'); + if (p > 0) { + dealRaw(combat, unit, p, label + ' poison'); + unit.statuses.poison = p - 1; + if (unit.statuses.poison <= 0) delete unit.statuses.poison; + } +} + +// raw damage straight to HP through block +function dealRaw(combat, unit, amount, source) { + let dmg = amount; + if (unit.block > 0) { + const absorbed = Math.min(unit.block, dmg); + unit.block -= absorbed; dmg -= absorbed; + } + if (dmg > 0) unit.hp -= dmg; + combat.log.push({ t: 'damage', target: unit === combat.player ? 'player' : unit.slot, amount: dmg, source }); + if (unit === combat.player) { if (unit.hp <= 0) loseCombat(combat); } + else if (unit.hp <= 0) killEnemy(combat, unit); + return dmg; +} + +// attack damage with modifiers (strength already added by caller for attacker) +function dealAttack(combat, attacker, target, baseAmount, opts = {}) { + let dmg = baseAmount; + // attacker strength + if (attacker) dmg += statusOf(attacker, 'strength'); + // weak on attacker + if (attacker && statusOf(attacker, 'weak') > 0) dmg = Math.floor(dmg * 0.75); + // vulnerable on target + if (statusOf(target, 'vulnerable') > 0) dmg = Math.floor(dmg * 1.5); + // pen nib relic (player only) + if (opts.penNibDouble) dmg *= 2; + if (dmg < 0) dmg = 0; + const dealt = dealRaw(combat, target, dmg, opts.source || 'attack'); + // thorns / bronze scales when player is the target + if (target === combat.player && dealt > 0 && hasRelic(combat, 'bronze-scales') && attacker && attacker !== combat.player && attacker.alive) { + dealRaw(combat, attacker, relicVal(combat, 'bronze-scales'), 'thorns'); + } + return dealt; +} + +function gainBlock(unit, amount, isPlayer) { + let b = amount + statusOf(unit, 'dexterity'); + if (isPlayer && statusOf(unit, 'frail') > 0) b = Math.floor(b * 0.75); + if (b < 0) b = 0; + unit.block += b; + return b; +} + +function killEnemy(combat, enemy) { + if (!enemy.alive) return; + enemy.alive = false; + enemy.hp = 0; + combat.log.push({ t: 'death', target: enemy.slot }); + if (combat.enemies.every((e) => !e.alive)) winCombat(combat); +} + +function loseCombat(combat) { combat.phase = 'lost'; combat.player.alive = false; } +function winCombat(combat) { combat.phase = 'won'; applyCombatEndRelics(combat); } + +function applyCombatEndRelics(combat) { + const p = combat.player; + if (hasRelic(combat, 'burning-blood')) p.hp = Math.min(p.maxHp, p.hp + RELICS['burning-blood'].value); + if (hasRelic(combat, 'meat-on-the-bone') && p.hp < p.maxHp / 2) p.hp = Math.min(p.maxHp, p.hp + RELICS['meat-on-the-bone'].value); +} + +export function isCombatOver(combat) { + if (combat.phase === 'won') return 'won'; + if (combat.phase === 'lost') return 'lost'; + return null; +} + +// Cards that can legally be played right now (cost affordable, playable). +export function playableHand(combat) { + return combat.hand.filter((inst) => { + const c = resolvedCard(inst); + if (c.unplayable) return false; + return combat.player.energy >= Math.max(0, c.cost); + }); +} + +export function canPlay(combat, inst) { + const c = resolvedCard(inst); + if (!c || c.unplayable) return false; + if (combat.phase !== 'player') return false; + return combat.player.energy >= Math.max(0, c.cost); +} + +// Play a card from hand. targetSlot = enemy slot index (for enemy-target cards). +export function playCard(combat, inst, targetSlot = null) { + if (!canPlay(combat, inst)) return false; + const c = resolvedCard(inst); + const hi = combat.hand.indexOf(inst); + if (hi < 0) return false; + combat.hand.splice(hi, 1); + combat.player.energy -= Math.max(0, c.cost); + + const target = targetSlot != null ? combat.enemies[targetSlot] : pickDefaultTarget(combat); + resolveEffects(combat, c.effects || [], { isCard: true, cardType: c.type, target }); + + // exhaust vs discard + if (c.exhaust) combat.exhaust.push(inst); + else combat.discard.push(inst); + + // post-resolution death checks already handled inside deal* + if (combat.phase !== 'lost' && combat.enemies.every((e) => !e.alive) && combat.phase !== 'won') winCombat(combat); + return true; +} + +function pickDefaultTarget(combat) { + return combat.enemies.find((e) => e.alive) || combat.enemies[0]; +} + +// Shared effect resolver for cards, potions, and (mostly) enemy moves. +// ctx.target is the chosen enemy for enemy-target ops; attacker is the player +// for cards and the enemy for enemy moves. +function resolveEffects(combat, effects, ctx) { + const p = combat.player; + const attacker = ctx.attacker || p; + const isPlayerSource = attacker === p; + for (const e of effects) { + switch (e.op) { + case 'damage': { + const times = e.times || 1; + const tgt = ctx.target && ctx.target.alive ? ctx.target : pickDefaultTarget(combat); + for (let i = 0; i < times; i++) { + if (!tgt || !tgt.alive) break; + countAttack(combat, p, isPlayerSource); + dealAttack(combat, attacker, tgt, e.amount, { source: 'card', penNibDouble: isPlayerSource && penNibTriggers(combat, p) }); + } + break; + } + case 'damageAll': { + const times = e.times || 1; + for (let i = 0; i < times; i++) { + combat.enemies.filter((en) => en.alive).forEach((en) => { + countAttack(combat, p, isPlayerSource); + dealAttack(combat, attacker, en, e.amount, { source: 'card', penNibDouble: isPlayerSource && penNibTriggers(combat, p) }); + }); + } + break; + } + case 'damageEqualBlock': + if (ctx.target && ctx.target.alive) { countAttack(combat, p, isPlayerSource); dealAttack(combat, attacker, ctx.target, p.block, { source: 'card' }); } + break; + case 'block': gainBlock(p, e.amount, true); break; + case 'blockSelf': attacker.block += Math.max(0, e.amount); break; // enemy block (no frail/dex) + case 'buffSelf': addStatus(p, e.status, e.amount); break; + case 'buffSelfEnemy': addStatus(attacker, e.status, e.amount); break; + case 'debuff': if (ctx.target && ctx.target.alive) addStatus(ctx.target, e.status, e.amount); break; + case 'debuffAll': combat.enemies.filter((en) => en.alive).forEach((en) => addStatus(en, e.status, e.amount)); break; + case 'debuffPlayer': addStatus(p, e.status, e.amount); break; + case 'poison': if (ctx.target && ctx.target.alive) addStatus(ctx.target, 'poison', e.amount); break; + case 'poisonAll': combat.enemies.filter((en) => en.alive).forEach((en) => addStatus(en, 'poison', e.amount)); break; + case 'multiplyPoison': if (ctx.target && ctx.target.alive) { const cur = statusOf(ctx.target, 'poison'); ctx.target.statuses.poison = cur * e.factor; } break; + case 'draw': drawCards(combat, e.amount); break; + case 'discard': /* UI-driven; auto-discard random for AI/headless */ autoDiscard(combat, e.amount); break; + case 'energy': p.energy += e.amount; break; + case 'heal': p.hp = Math.min(p.maxHp, p.hp + e.amount); break; + case 'healPct': p.hp = Math.min(p.maxHp, p.hp + Math.floor(p.maxHp * e.amount)); break; + case 'loseHp': dealRaw(combat, p, e.amount, 'self'); break; + case 'doubleStrength': p.statuses.strength = statusOf(p, 'strength') * 2; if (!p.statuses.strength) delete p.statuses.strength; break; + case 'addCardToDiscard': for (let i = 0; i < (e.amount || 1); i++) combat.discard.push(cardInstance(e.card)); break; + default: break; + } + if (combat.phase === 'lost' || combat.phase === 'won') break; + } +} + +function autoDiscard(combat, n) { + for (let i = 0; i < n && combat.hand.length; i++) { + const idx = combat.rng.int(combat.hand.length); + combat.discard.push(combat.hand.splice(idx, 1)[0]); + } +} + +// ── relic attack counters (player) ── +function countAttack(combat, p, isPlayerSource) { + if (!isPlayerSource) return; + p.attacksThisTurn += 1; + p.attacksTotal += 1; + if (hasRelic(combat, 'kunai') && p.attacksThisTurn % 3 === 0) addStatus(p, 'dexterity', RELICS.kunai.value); +} +function penNibTriggers(combat, p) { + if (!hasRelic(combat, 'pen-nib')) return false; + // double on every 10th attack (next attack count is attacksTotal+1) + return (p.attacksTotal + 1) % RELICS['pen-nib'].value === 0; +} + +// ── END PLAYER TURN → ENEMY TURN ── +export function endTurn(combat) { + if (combat.phase !== 'player') return; + const p = combat.player; + // end-of-turn: metallicize, regen, burn cards in hand + if (statusOf(p, 'metallicize') > 0) p.block += statusOf(p, 'metallicize'); + if (statusOf(p, 'regen') > 0) { p.hp = Math.min(p.maxHp, p.hp + statusOf(p, 'regen')); addStatus(p, 'regen', -1); } + for (const inst of combat.hand) { const c = resolvedCard(inst); if (c.endTurnSelfDamage) dealRaw(combat, p, c.endTurnSelfDamage, 'burn'); } + if (combat.phase === 'lost') return; + // ethereal cards exhaust at end of turn; the rest discard + for (const inst of combat.hand) { const c = resolvedCard(inst); if (c.ethereal) combat.exhaust.push(inst); else combat.discard.push(inst); } + combat.hand = []; + combat.phase = 'enemy'; + runEnemyTurn(combat); + if (combat.phase === 'enemy') startPlayerTurn(combat); +} + +function runEnemyTurn(combat) { + for (const e of combat.enemies) { + if (!e.alive) continue; + e.block = 0; + // ritual: gain strength at start of its turn + const ritual = statusOf(e, 'ritual'); + if (ritual > 0) addStatus(e, 'strength', ritual); + tickPoison(combat, e, e.name); + if (!e.alive) continue; + executeEnemyMove(combat, e); + if (combat.phase === 'lost') return; + // metallicize on enemies (e.g. Guardian shell) + if (statusOf(e, 'metallicize') > 0) e.block += statusOf(e, 'metallicize'); + } + // pick next intents + combat.enemies.forEach((e) => { if (e.alive) chooseEnemyMove(combat, e); }); +} + +function executeEnemyMove(combat, enemy) { + const move = enemy.intent && enemy.intent.move; + if (!move) return; + resolveEffects(combat, move.effects || [], { attacker: enemy, target: combat.player }); +} + +// ── Granular enemy-phase API ── +// endTurn() above resolves the whole enemy turn at once (used by the headless +// verifier). The animated scene instead drives the turn one beat at a time with +// these so it can play per-enemy lunge/buff animations and time the player's +// health loss to the moment an attack lands. + +// Player end-of-turn upkeep, discard/exhaust the hand, hand off to the enemies. +export function beginEnemyPhase(combat) { + if (combat.phase !== 'player') return; + const p = combat.player; + if (statusOf(p, 'metallicize') > 0) p.block += statusOf(p, 'metallicize'); + if (statusOf(p, 'regen') > 0) { p.hp = Math.min(p.maxHp, p.hp + statusOf(p, 'regen')); addStatus(p, 'regen', -1); } + for (const inst of combat.hand) { const c = resolvedCard(inst); if (c.endTurnSelfDamage) dealRaw(combat, p, c.endTurnSelfDamage, 'burn'); } + if (combat.phase === 'lost') return; + for (const inst of combat.hand) { const c = resolvedCard(inst); if (c.ethereal) combat.exhaust.push(inst); else combat.discard.push(inst); } + combat.hand = []; + combat.phase = 'enemy'; +} + +// One enemy's start-of-turn upkeep (block reset, ritual, poison). Returns whether +// the enemy is still alive afterward. +export function enemyUpkeep(combat, enemy) { + if (!enemy.alive) return false; + enemy.block = 0; + const ritual = statusOf(enemy, 'ritual'); + if (ritual > 0) addStatus(enemy, 'strength', ritual); + tickPoison(combat, enemy, enemy.name); + return enemy.alive; +} + +// Resolve a single enemy's chosen move (apply its effects now). +export function resolveEnemyMove(combat, enemy) { + if (!enemy.alive || combat.phase === 'lost') return; + executeEnemyMove(combat, enemy); + if (enemy.alive && statusOf(enemy, 'metallicize') > 0) enemy.block += statusOf(enemy, 'metallicize'); +} + +// After every enemy has acted: pick next intents and start the player's turn. +export function finishEnemyPhase(combat) { + if (combat.phase !== 'enemy') return; + combat.enemies.forEach((e) => { if (e.alive) chooseEnemyMove(combat, e); }); + startPlayerTurn(combat); +} + +// ── ENEMY AI (intent selection) ── +export function chooseEnemyMove(combat, enemy) { + const def = ENEMIES[enemy.defId]; + const rng = combat.rng; + let move; + switch (def.ai) { + case 'jawworm': { + if (enemy.moveCount === 0) move = byId(def, 'chomp'); + else { const r = rng(); move = r < 0.45 ? byId(def, 'bellow') : (r < 0.75 ? byId(def, 'thrash') : byId(def, 'chomp')); } + break; + } + case 'cultist': move = enemy.moveCount === 0 ? byId(def, 'incantation') : byId(def, 'darkstrike'); break; + case 'gremlinnob': { + if (enemy.moveCount === 0) move = byId(def, 'bellow'); + else { move = rng() < 0.33 ? byId(def, 'skullbash') : byId(def, 'rush'); } + break; + } + case 'lagavulin': { + if (enemy.moveCount < 3) move = byId(def, 'sleep'); + else if (enemy.moveCount % 3 === 0) move = byId(def, 'siphon'); + else move = byId(def, 'attack'); + break; + } + case 'alternate': move = def.moves[enemy.moveCount % def.moves.length]; break; + case 'sequence': move = def.moves[enemy.moveCount % def.moves.length]; break; + default: { + // avoid repeating the same move 3x + do { move = rng.pick(def.moves); } while (def.moves.length > 1 && move.id === enemy.lastMoveId && rng() < 0.6); + break; + } + } + enemy.lastMoveId = move.id; + enemy.moveCount += 1; + enemy.intent = { move, type: move.intent, value: scaledIntentValue(enemy, move), times: move.times || 1, block: move.block || 0 }; +} +function byId(def, id) { return def.moves.find((m) => m.id === id); } +function scaledIntentValue(enemy, move) { + if (move.value == null) return null; + let v = move.value + statusOf(enemy, 'strength'); + if (statusOf(enemy, 'weak') > 0) v = Math.floor(v * 0.75); + return Math.max(0, v); +} + +// Per-hit damage the enemy's CURRENT move would deal to the player right now. +// Mirrors dealAttack exactly (strength → enemy Weak → player Vulnerable) so the +// displayed intent matches what actually lands. Multiply by intent.times for +// multi-hit attacks. Recompute each render — the player's Vulnerable can change. +export function intentDamage(combat, enemy) { + const move = enemy.intent && enemy.intent.move; + if (!move || move.value == null) return null; + let dmg = move.value + statusOf(enemy, 'strength'); + if (statusOf(enemy, 'weak') > 0) dmg = Math.floor(dmg * 0.75); + if (statusOf(combat.player, 'vulnerable') > 0) dmg = Math.floor(dmg * 1.5); + return Math.max(0, dmg); +} + +// ── A simple greedy/heuristic auto-player for headless verification ── +export function autoPlayTurn(combat) { + // play affordable cards greedily: powers > attacks on lowest-hp enemy > block + let safety = 40; + while (combat.phase === 'player' && safety-- > 0) { + const playable = playableHand(combat); + if (!playable.length) break; + // prefer cheapest power, then attack, then skill + const score = (inst) => { + const c = resolvedCard(inst); + if (c.type === 'power') return 0; + if (c.type === 'attack') return 1; + return 2; + }; + playable.sort((a, b) => score(a) - score(b) || cardCost(a) - cardCost(b)); + const inst = playable[0]; + const c = resolvedCard(inst); + let target = null; + if (c.target === 'enemy') { + const alive = combat.enemies.filter((e) => e.alive); + alive.sort((a, b) => a.hp - b.hp); + target = alive.length ? alive[0].slot : null; + } + const ok = playCard(combat, inst, target); + if (!ok) break; + if (combat.phase !== 'player') break; + } + if (combat.phase === 'player') endTurn(combat); +} + +// ── COMBAT RESULT → RUN ── +// Persist combat outcome back to the run (hp). Returns {result, rewards|null}. +export function settleCombat(combat, node, rng) { + const run = combat.run; + run.hp = Math.max(0, combat.player.hp); + if (combat.phase === 'lost' || run.hp <= 0) { + run.finished = true; run.victory = false; + return { result: 'lost' }; + } + // won + if (node && node.type === 'boss') { run.finished = true; run.victory = true; } + return { result: 'won', rewards: rollRewards(run, node, rng) }; +} + +export function rollRewards(run, node, rng) { + const isElite = node && node.type === 'elite'; + const isBoss = node && node.type === 'boss'; + const gold = isBoss ? rng.range(95, 105) : isElite ? rng.range(25, 35) : rng.range(10, 20); + const cards = rollCardChoices(run, rng, ACT.cardRewardChoices); + const reward = { gold, cards, relic: null, potion: null }; + if (isElite || isBoss) reward.relic = rollRelic(run, rng, isBoss ? 'uncommon' : 'common'); + if (!isBoss && rng() < 0.4) reward.potion = rng.pick(POTION_IDS); + return reward; +} + +// 3 distinct card choices weighted by rarity. +export function rollCardChoices(run, rng, n) { + const pool = cardPoolFor(run.className); + const out = []; + const used = new Set(); + let guard = 100; + while (out.length < n && guard-- > 0) { + const roll = rng(); + const rarity = roll < 0.62 ? 'common' : roll < 0.92 ? 'uncommon' : 'rare'; + const choices = pool.filter((c) => c.rarity === rarity && !used.has(c.id)); + const pickFrom = choices.length ? choices : pool.filter((c) => !used.has(c.id)); + if (!pickFrom.length) break; + const card = rng.pick(pickFrom); + used.add(card.id); + out.push({ id: card.id, upgraded: false }); + } + return out; +} + +export function rollRelic(run, rng, rarity = 'common') { + const owned = new Set(run.relics); + let pool = relicPool(rarity).filter((r) => !owned.has(r.id)); + if (!pool.length) pool = relicPool('common').filter((r) => !owned.has(r.id)); + if (!pool.length) pool = Object.values(RELICS).filter((r) => !owned.has(r.id) && r.rarity !== 'starter'); + return pool.length ? rng.pick(pool).id : null; +} + +// ── RUN-META MUTATORS ── +export function addCardToDeck(run, cardId, upgraded = false) { run.deck.push(cardInstance(cardId, upgraded)); } +export function removeCardFromDeck(run, uid) { const i = run.deck.findIndex((c) => c.uid === uid); if (i >= 0) run.deck.splice(i, 1); } +export function upgradeCardInDeck(run, uid) { const c = run.deck.find((x) => x.uid === uid); if (c && CARDS[c.id]?.upgrade && !c.upgraded) c.upgraded = true; } +export function addRelic(run, relicId) { if (!run.relics.includes(relicId)) run.relics.push(relicId); } +export function addPotion(run, potionId) { if (run.potions.length < run.maxPotions) { run.potions.push(potionId); return true; } return false; } +export function removePotion(run, index) { run.potions.splice(index, 1); } +export function restHeal(run) { run.hp = Math.min(run.maxHp, run.hp + Math.floor(run.maxHp * 0.3)); } + +// ── SHOP ── +export function generateShop(run, rng) { + const pool = cardPoolFor(run.className); + const cards = []; + const used = new Set(); + for (let i = 0; i < 5; i++) { + const roll = rng(); + const rarity = roll < 0.5 ? 'common' : roll < 0.85 ? 'uncommon' : 'rare'; + const choices = pool.filter((c) => c.rarity === rarity && !used.has(c.id)); + const pickFrom = choices.length ? choices : pool.filter((c) => !used.has(c.id)); + if (!pickFrom.length) break; + const card = rng.pick(pickFrom); + used.add(card.id); + cards.push({ id: card.id, price: Math.round(ACT.shopPrices[card.rarity] * (0.85 + rng() * 0.3)) }); + } + const relics = []; + const rid = rollRelic(run, rng, rng() < 0.7 ? 'common' : 'uncommon'); + if (rid) relics.push({ id: rid, price: ACT.shopPrices.relic }); + const potions = []; + for (let i = 0; i < 2; i++) potions.push({ id: rng.pick(POTION_IDS), price: ACT.shopPrices.potion }); + return { cards, relics, potions, removalPrice: ACT.shopPrices.removal, removalUsed: false }; +} + +// ── potions used in combat ── +export function usePotion(combat, potionId, targetSlot = null) { + const pot = POTIONS[potionId]; + if (!pot) return false; + const target = targetSlot != null ? combat.enemies[targetSlot] : pickDefaultTarget(combat); + resolveEffects(combat, pot.effects, { isCard: false, target, attacker: combat.player }); + if (combat.phase !== 'lost' && combat.enemies.every((e) => !e.alive) && combat.phase !== 'won') winCombat(combat); + return true; +} + +export { EVENTS }; diff --git a/public/src/games/spireclimb/sprites.md b/public/src/games/spireclimb/sprites.md new file mode 100644 index 0000000..7c8491a --- /dev/null +++ b/public/src/games/spireclimb/sprites.md @@ -0,0 +1,146 @@ +# Spire Climb — Sprite / Art Spec + +Everything in Spire Climb renders procedurally out of the box, so the game is +fully playable with **no** art. This document lists the optional sprite sheets +you can drop in to replace the placeholders, with exact dimensions, layout, and +frame-by-frame maps. + +All art is wired through **`public/data/spireclimb-artwork.json`**. The scene +checks whether each sheet's texture is loaded; if it is, it uses your frame, +otherwise it falls back to procedural drawing (or the shared `opponents` sheet +for creatures). You never need to touch code — just add a PNG and, if needed, +set its `path` in that JSON. + +Frame numbering everywhere is **row-major, 0-based**: frame 0 is top-left, count +left-to-right then down to the next row. + +--- + +## 1. Creature sheet — `spireclimb-creatures.png` + +| | | +|---|---| +| **Path** | `public/assets/images/spireclimb-creatures.png` | +| **Sheet size** | **1500 × 600 px** | +| **Frame size** | **300 × 300 px** | +| **Layout** | 5 columns × 2 rows = 10 frames | +| **Status** | ✅ Placeholder ships now (regenerate with `node genSpireClimbCreatures.js`) | +| **JSON** | `creatureSheet` (path already set) + `creatures` map | + +Frames are square; the creature is drawn centered and auto-scaled to ~200 px +tall in combat. Transparent background recommended (PNG alpha) so creatures sit +on the battlefield cleanly — but an opaque square also works. + +### Frame map + +| Frame | id | Name | Tier | Notes | +|---:|---|---|---|---| +| 0 | `jawworm` | Jaw Worm | normal | basic bruiser | +| 1 | `cultist` | Cultist | normal | buffs itself (Ritual) | +| 2 | `louse` | Red Louse | normal | small, appears in pairs | +| 3 | `fungi` | Fungi Beast | normal | mushroom | +| 4 | `spikeslime` | Spike Slime | normal | spiky blue blob | +| 5 | `sentry` | Sentry | elite | mechanical orb | +| 6 | `gremlinnob` | Gremlin Nob | elite | big horned brute | +| 7 | `lagavulin` | Lagavulin | elite | armored sleeper | +| 8 | `guardian` | The Guardian | **boss** | huge construct | +| 9 | `slimeboss` | Slime Boss | **boss** | giant slime | + +> Tip: bosses read better a little bigger/more detailed since they fill more of +> the screen. Keep the same 300×300 cell — the scale-to-fit handles the rest. + +--- + +## 2. Card art sheet — `spireclimb-cards.png` *(not yet created)* + +| | | +|---|---| +| **Path** | `public/assets/images/spireclimb-cards.png` | +| **Frame size** | **250 × 160 px** (aspect ≈ 1.565 : 1, matches the card art window) | +| **Sheet size** | your choice — e.g. **a 6 × 6 grid = 1500 × 960 px** holds all 34 frames | +| **Status** | ⛔ Procedural until you create it (then set `cardSheet.path` in the JSON) | +| **JSON** | `cardSheet` (set `path`) + `cards` map | + +**Only paint the illustration.** The card frame, border, energy-cost orb, name, +type label, and rules text are all drawn procedurally on top — your art shows +inside the card's "art window" (an inset rounded rectangle near the top of the +card). Art is scaled to **fit** that window (no cropping), so anything at the +~1.565:1 aspect fills it edge-to-edge; other aspects just letterbox. Opaque +rectangular art is fine; design it to read at small size (the window is ~144×92 +on a resting hand card, ~266×170 during the play-to-center zoom). + +You don't have to fill all 34 frames at once — any card without art (or any +frame index missing from the `cards` map) simply stays procedural. + +### Frame map + +Class legend: **N** = neutral, **W** = Warrior, **R** = Rogue, **S** = status. + +| Frame | id | Name | Class | Type | +|---:|---|---|---|---| +| 0 | `strike` | Strike | N | attack | +| 1 | `defend` | Defend | N | skill | +| 2 | `bash` | Bash | W | attack | +| 3 | `neutralize` | Neutralize | R | attack | +| 4 | `survivor` | Survivor | R | skill | +| 5 | `ironwave` | Iron Wave | W | attack | +| 6 | `pommelstrike` | Pommel Strike | W | attack | +| 7 | `cleave` | Cleave | W | attack | +| 8 | `shrugitoff` | Shrug It Off | W | skill | +| 9 | `clothesline` | Clothesline | W | attack | +| 10 | `inflame` | Inflame | W | power | +| 11 | `uppercut` | Uppercut | W | attack | +| 12 | `ghostlyarmor` | Ghostly Armor | W | skill | +| 13 | `bodyslam` | Body Slam | W | attack | +| 14 | `metallicize` | Metallicize | W | power | +| 15 | `whirlwind` | Whirlwind | W | attack | +| 16 | `limitbreak` | Limit Break | W | skill | +| 17 | `demonform` | Demon Form | W | power | +| 18 | `daggerthrow` | Dagger Throw | R | attack | +| 19 | `poisonedstab` | Poisoned Stab | R | attack | +| 20 | `deadlypoison` | Deadly Poison | R | skill | +| 21 | `backflip` | Backflip | R | skill | +| 22 | `sneakyhit` | Sneaky Hit | R | attack | +| 23 | `footwork` | Footwork | R | power | +| 24 | `bladedance` | Blade Dance | R | attack | +| 25 | `caltrops` | Caltrops | R | power | +| 26 | `bouncingblade` | Bouncing Blade | R | attack | +| 27 | `crippling` | Crippling Cloud | R | skill | +| 28 | `catalyst` | Catalyst | R | skill | +| 29 | `bandage` | Bandage Up | N | skill | +| 30 | `flashofsteel` | Flash of Steel | N | attack | +| 31 | `wound` | Wound | S | status (clutter) | +| 32 | `dazed` | Dazed | S | status (clutter) | +| 33 | `burn` | Burn | S | status (clutter) | + +> The three status cards (31–33) are unplayable junk cards enemies shuffle into +> your deck — give them a grimy/cursed look. Lowest priority. + +--- + +## 3. Menu icon — `game-icons.png` frame **74** *(shared sheet)* + +| | | +|---|---| +| **File** | `public/assets/images/game-icons.png` (existing shared sheet) | +| **Sheet size** | 660 × 660 px | +| **Frame size** | **44 × 44 px** | +| **Layout** | 15 columns × 15 rows | +| **Spire Climb frame** | **74** → row 4, col 14 → pixel box **x 616–660, y 176–220** | + +Add a 44×44 icon for Spire Climb into cell 74 of the existing sheet (the menu +tile pulls `game-icons` frame `74`). Keep it readable at tiny size — e.g. a +stylized spire/tower or a single hero card. This is the only sprite that goes +into an **existing** sheet rather than a new file. + +--- + +## Quick checklist + +- [ ] `spireclimb-creatures.png` — 1500×600, 10 × (300×300). *(placeholder exists; replace to taste)* +- [ ] `spireclimb-cards.png` — 250×160 frames, 34 cards; then set `cardSheet.path` in `spireclimb-artwork.json`. +- [ ] `game-icons.png` frame 74 — 44×44 menu icon. + +After dropping in `spireclimb-cards.png`, set its `path` (and confirm +`frameWidth`/`frameHeight`) in `public/data/spireclimb-artwork.json`. The +creature sheet path is already set; the icon needs no config. diff --git a/public/src/main.js b/public/src/main.js index 329ba66..b93d30d 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -84,6 +84,7 @@ import GeniusSquareGame from './games/geniussquare/GeniusSquareGame.js'; import KataminoGame from './games/katamino/KataminoGame.js'; import BookworkGame from './games/bookwork/BookworkGame.js'; import PaiGowPokerGame from './games/paigow/PaiGowPokerGame.js'; +import SpireClimbGame from './games/spireclimb/SpireClimbGame.js'; const config = { type: Phaser.AUTO, @@ -181,6 +182,7 @@ const config = { KataminoGame, BookworkGame, PaiGowPokerGame, + SpireClimbGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 5dd7128..cb58199 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 083078b..63ae7a8 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -72,6 +72,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('dotlink', '/data/dotlink.json'); this.load.json('katamino', '/data/katamino.json'); this.load.json('bookwork', '/data/bookwork.json'); + this.load.json('spireclimb-artwork', '/data/spireclimb-artwork.json'); this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3'); this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3'); @@ -210,8 +211,16 @@ export default class PreloadScene extends Phaser.Scene { ...(slotsArt?.artwork ?? []).filter((a) => a.path && a.key && !this.textures.exists(a.key)), ]; - if (toLoad.length > 0) { + // Spire Climb drop-in spritesheets (card art + creature art). Only loaded + // once the artist sets a `path` in /data/spireclimb-artwork.json; until then + // the game renders cards/creatures procedurally. + const scArt = this.cache.json.get('spireclimb-artwork'); + const scSheets = [scArt?.cardSheet, scArt?.creatureSheet] + .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); + + if (toLoad.length > 0 || scSheets.length > 0) { for (const asset of toLoad) this.load.image(asset.key, asset.path); + for (const s of scSheets) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight }); await new Promise((resolve) => { this.load.once('complete', resolve); this.load.start(); diff --git a/server/games/registry.js b/server/games/registry.js index 024a138..4459102 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -100,3 +100,4 @@ registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', m registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 }); registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 }); registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 6, minOpponents: 0, maxOpponents: 5, defaultOpponents: 5, iconFrame: 73 }); +registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 74 }); diff --git a/verifySpireClimb.js b/verifySpireClimb.js new file mode 100644 index 0000000..7c3e652 --- /dev/null +++ b/verifySpireClimb.js @@ -0,0 +1,138 @@ +// verifySpireClimb.js — headless engine verification for Spire Climb. +// Runs map generation, full auto-played combats for both classes, reward/shop +// rolls, and a handful of invariant checks. Pure-logic (no Phaser/browser). +// +// node verifySpireClimb.js + +import { + newRun, generateMap, availableNodes, enterNode, encounterForNode, + startCombat, autoPlayTurn, isCombatOver, settleCombat, makeRng, + rollRewards, generateShop, rollCardChoices, rollRelic, addCardToDeck, + upgradeCardInDeck, addRelic, resolvedCard, playableHand, nodeById, +} from './public/src/games/spireclimb/SpireClimbLogic.js'; +import { CLASSES, CARDS, ENEMIES, ENCOUNTERS, ACT } from './public/src/games/spireclimb/SpireClimbData.js'; + +let pass = 0, fail = 0; +const fails = []; +function ok(cond, msg) { if (cond) pass++; else { fail++; fails.push(msg); } } + +// ── 1. content sanity ── +for (const [cid, c] of Object.entries(CARDS)) { + ok(typeof c.name === 'string' && c.name.length > 0, `card ${cid} has name`); + ok(c.type && ['attack', 'skill', 'power', 'status', 'curse'].includes(c.type), `card ${cid} valid type`); + if (c.upgrade) { + const inst = { uid: 1, id: cid, upgraded: true }; + const up = resolvedCard(inst); + ok(up.name.endsWith('+'), `card ${cid} upgrade name suffix`); + } +} +for (const [eid, e] of Object.entries(ENEMIES)) { + ok(Array.isArray(e.moves) && e.moves.length > 0, `enemy ${eid} has moves`); + ok(e.hp[0] <= e.hp[1], `enemy ${eid} hp range ordered`); + ok(Number.isInteger(e.placeholderFrame), `enemy ${eid} has placeholderFrame`); +} +for (const tier of ['normal', 'elite', 'boss']) { + ok(ENCOUNTERS[tier].length > 0, `encounter table ${tier} non-empty`); + for (const grp of ENCOUNTERS[tier]) for (const id of grp) ok(!!ENEMIES[id], `encounter enemy ${id} exists`); +} + +// ── 2. map generation ── +for (let s = 0; s < 50; s++) { + const rng = makeRng(s + 1); + const map = generateMap(rng); + ok(map.grid.length === ACT.rows, `map seed ${s} rows`); + ok(map.grid[map.rows - 1][0].type === 'boss', `map seed ${s} boss at top`); + ok(map.grid[0].every((n) => n.type === 'combat'), `map seed ${s} row0 combat`); + // connectivity: every non-top node has an edge; every non-row0 node has a parent + const hasParent = new Set(); + for (let r = 0; r < map.rows - 1; r++) for (const n of map.grid[r]) { + ok(n.edges.length > 0, `map seed ${s} node ${n.id} has out-edge`); + n.edges.forEach((id) => hasParent.add(id)); + } + for (let r = 1; r < map.rows; r++) for (const n of map.grid[r]) ok(hasParent.has(n.id), `map seed ${s} node ${n.id} reachable`); + // a full path exists from row0 to boss + let frontier = map.grid[0].map((n) => n.id); + for (let r = 0; r < map.rows - 1; r++) { + const next = new Set(); + for (const id of frontier) nodeById({ map }, id).edges.forEach((e) => next.add(e)); + frontier = [...next]; + } + ok(frontier.includes('n' + (map.rows - 1) + '_0'), `map seed ${s} boss reachable from start`); +} + +// ── 3. full auto-played combats, both classes, many seeds ── +function runCombat(className, enemyIds, seed) { + const run = newRun(className, seed); + const combat = startCombat(run, enemyIds, seed * 7 + 3); + let guard = 200; + while (!isCombatOver(combat) && guard-- > 0) autoPlayTurn(combat); + return { run, combat, looped: guard <= 0 }; +} + +let playerWins = 0, total = 0, neverLooped = true; +for (const className of ['warrior', 'rogue']) { + for (const tier of ['normal', 'elite', 'boss']) { + for (const grp of ENCOUNTERS[tier]) { + for (let s = 0; s < 12; s++) { + const { combat, looped } = runCombat(className, grp, s + 1); + if (looped) neverLooped = false; + const res = isCombatOver(combat); + ok(res === 'won' || res === 'lost', `${className} vs ${grp.join('+')} seed ${s} resolved (${res})`); + // hp invariants + ok(combat.player.hp <= combat.player.maxHp, `${className} hp <= max (${grp.join('+')})`); + combat.enemies.forEach((e) => ok(e.hp <= e.maxHp, `enemy hp<=max ${e.name}`)); + if (res === 'won') { + combat.enemies.forEach((e) => ok(!e.alive, `won => all dead ${e.name}`)); + playerWins++; + } + total++; + } + } + } +} +ok(neverLooped, 'no combat hit the turn-guard (possible infinite loop)'); +ok(playerWins > 0, 'player wins at least some auto-played combats'); + +// ── 4. rewards / shop / deck ops ── +{ + const run = newRun('warrior', 123); + const rng = makeRng(99); + const node = { type: 'elite' }; + const reward = rollRewards(run, node, rng); + ok(reward.gold > 0, 'reward has gold'); + ok(reward.cards.length === ACT.cardRewardChoices, 'reward card choices count'); + ok(reward.cards.every((c) => CARDS[c.id]), 'reward cards valid'); + ok(reward.relic && true, 'elite reward has relic'); + const before = run.deck.length; + addCardToDeck(run, reward.cards[0].id); + ok(run.deck.length === before + 1, 'addCardToDeck grows deck'); + const upTarget = run.deck.find((c) => CARDS[c.id].upgrade && !c.upgraded); + if (upTarget) { upgradeCardInDeck(run, upTarget.uid); ok(upTarget.upgraded, 'upgradeCardInDeck flips flag'); } + + const shop = generateShop(run, rng); + ok(shop.cards.length > 0, 'shop has cards'); + ok(shop.cards.every((c) => c.price > 0), 'shop card prices positive'); +} + +// ── 5. determinism: same seed => same map & same combat trace ── +{ + const a = generateMap(makeRng(7)); + const b = generateMap(makeRng(7)); + ok(JSON.stringify(a) === JSON.stringify(b), 'map generation deterministic for fixed seed'); + const r1 = runCombat('rogue', ['jawworm'], 5); + const r2 = runCombat('rogue', ['jawworm'], 5); + ok(r1.combat.player.hp === r2.combat.player.hp && isCombatOver(r1.combat) === isCombatOver(r2.combat), 'combat deterministic for fixed seed'); +} + +// ── 6. starter decks well-formed ── +for (const cls of Object.values(CLASSES)) { + ok(cls.startingDeck.every((id) => CARDS[id]), `${cls.id} starter deck valid card ids`); + ok(cls.startingDeck.length >= 10, `${cls.id} starter deck size`); +} + +// ── report ── +console.log(`\nSpire Climb verification`); +console.log(` combats auto-played: ${total} (player win rate ${(100 * playerWins / total).toFixed(0)}%)`); +console.log(` checks: ${pass} passed, ${fail} failed`); +if (fail) { console.log('\nFAILURES:'); fails.slice(0, 30).forEach((m) => console.log(' ✗ ' + m)); process.exit(1); } +console.log(' ✓ all checks passed');