feat: add Spire Climb single-player roguelike deckbuilder
Implement a complete Slay-the-Spire inspired game with: - **Core engine** (`SpireClimbLogic.js`): Turn-based combat state machine, seeded RNG, map generation, enemy AI, relic hooks, and a headless verifier (`verifySpireClimb.js`) that auto-plays 1,000+ combats across both classes. - **Game scene** (`SpireClimbGame.js`): Phaser UI for class select, branching map, animated combat (card fly-in, health-bar drain, enemy lunge/buff FX), rewards, shop, events, and treasure nodes. - **Content** (`SpireClimbData.js`): Two classes (Warrior, Rogue), 30+ cards with upgrades, relics, potions, 10 enemies/elites/bosses, events, and map tuning. - **Art pipeline**: Procedural rendering by default with drop-in sprite sheets (`spireclimb-creatures.png`, `spireclimb-cards.png`) wired through `spireclimb-artwork.json`. Includes a Node script to regenerate the creature spritesheet and a full art spec (`sprites.md`).
This commit is contained in:
parent
63ffa35aa4
commit
5f9f7c8bd8
|
|
@ -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)`);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'];
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 };
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
Loading…
Reference in New Issue