Shadows
Qwen 3.6 27B MTP GGUF · typescript

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 126,114 bytes · SHA-256 62aaae4aa821
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shadows of the Abyss</title>
<style>
/* ── css: base ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a12; font-family: 'Segoe UI', Tahoma, sans-serif; color: #e0d8c8; }
#app { width: 100%; height: 100%; position: relative; cursor: default; }
#gameCanvas { width: 100%; height: 100%; display: block; }
#overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
#overlay > * { pointer-events: auto; }
</style>
</head>
<body>
<div id="app">
<canvas id="gameCanvas"></canvas>
<div id="overlay"></div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./types":"src/types.ts","./engine":"src/engine.ts","./renderer":"src/renderer.ts","./cards":"src/cards.ts","./data":"src/data.ts"},"src/cards.ts":{"./types":"src/types.ts"},"src/data.ts":{"./types":"src/types.ts"},"src/engine.ts":{"./types":"src/types.ts","./cards":"src/cards.ts","./data":"src/data.ts"},"src/renderer.ts":{"./types":"src/types.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const engine_1 = require("./engine");
const renderer_1 = require("./renderer");
const cards_1 = require("./cards");
const data_1 = require("./data");
let state = (0, engine_1.createInitialState)();
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
function generateCardChoices(isBoss, isElite = false) {
const pool = (0, cards_1.getCardPool)(state.player.cls);
const choices = [];
for (let i = 0; i < 3; i++) {
let rarity = 'Common';
const roll = Math.random();
if (isBoss && i === 0) {
rarity = 'Rare';
}
else if (isElite ? roll < 0.2 : roll < 0.1) {
rarity = 'Rare';
}
else if (roll < (isElite ? 0.5 : 0.4)) {
rarity = 'Uncommon';
}
const filtered = pool.filter(c => c.rarity === rarity);
if (filtered.length > 0)
choices.push(pick(filtered));
else
choices.push(pick(pool));
}
return choices;
}
function generateShopItems() {
const pool = (0, cards_1.getCardPool)(state.player.cls);
const cards = [];
for (let i = 0; i < 3; i++) {
const roll = Math.random();
let rarity = 'Common';
if (roll < 0.1)
rarity = 'Rare';
else if (roll < 0.35)
rarity = 'Uncommon';
const filtered = pool.filter(c => c.rarity === rarity);
const card = pick(filtered.length > 0 ? filtered : pool);
const price = rarity === 'Rare' ? 150 : rarity === 'Uncommon' ? 75 : 50;
cards.push({ card: { ...card, id: `shop_${i}` }, price });
}
const cc = pick(cards_1.colorlessCards);
cards.push({ card: { ...cc, id: `shop_colorless` }, price: cc.rarity === 'Uncommon' ? 60 : 60 });
const eligibleRelics = data_1.relicPool.filter(r => !r.cls || r.cls === state.player.cls);
const relics = [];
for (let i = 0; i < 2; i++)
relics.push({ relic: pick(eligibleRelics), price: 150 });
const potions = [];
for (let i = 0; i < 3; i++)
potions.push({ potion: pick(data_1.potionPool), price: 50 });
state.shopItems = { cards, relics, potions };
state.shopRemoveAvailable = true;
}
function getCombatReward() {
const node = state.mapNodes[state.floor - 2];
const isBoss = node?.type === 'boss';
const isElite = node?.type === 'elite';
const hasGreedyImp = state.player.relics.find(r => r.id === 'greedyImp');
let gold = isBoss ? 50 : isElite ? randInt(25, 35) : randInt(10, 20);
if (hasGreedyImp)
gold += 10;
if (state.player.relics.find(r => r.id === 'ectoplasm')) {
gold = 0;
}
state.player.gold += gold;
state.pendingRewards.gold = gold;
if (state.player.relics.find(r => r.id === 'ashenArmor')) {
state.player.hp = Math.min(state.player.maxHp, state.player.hp + 6);
}
if (isBoss)
state.player.hp = state.player.maxHp;
state.pendingRewards.cardChoices = generateCardChoices(isBoss, isElite);
const potionChance = isBoss ? 1 : isElite ? 0.7 : 0.4;
if (Math.random() < potionChance && state.player.potions.length < 3) {
state.pendingRewards.potionChoice = pick(data_1.potionPool).id;
}
else {
state.pendingRewards.potionChoice = undefined;
}
if (isElite) {
const eligibleRelics = data_1.relicPool.filter(r => !r.cls || r.cls === state.player.cls);
state.pendingRewards.relicChoices = [pick(eligibleRelics)];
}
else if (isBoss) {
state.bossRelicChoices = shuffle(data_1.bossRelics).slice(0, 3);
}
}
function usePotion(id) {
switch (id) {
case 'bloodVial':
state.player.hp = Math.min(state.player.maxHp, state.player.hp + 15);
break;
case 'firebomb':
state.enemies.forEach(e => { e.hp = Math.max(0, e.hp - 10); });
break;
case 'dexTonic':
state.player.buffs.push({ type: 'dexterity', amount: 3, isDebuff: false, turnsRemaining: 1 });
break;
case 'energyElixir':
state.player.energy += 2;
break;
case 'cursedBrew':
state.enemies.forEach(e => {
const ex = e.debuffs.find(d => d.type === 'vulnerable');
if (ex)
ex.amount += 3;
else
e.debuffs.push({ type: 'vulnerable', amount: 3, isDebuff: true, turnsRemaining: 99 });
});
break;
case 'bleedingFlask':
if (state.enemies.length > 0) {
const ex = state.enemies[0].debuffs.find(d => d.type === 'bleed');
if (ex)
ex.amount += 6;
else
state.enemies[0].debuffs.push({ type: 'bleed', amount: 6, isDebuff: true, turnsRemaining: 0 });
}
break;
case 'impInBottle': {
const imp = (0, cards_1.createImpClaw)(true);
state.player.hand.push({ ...imp, instanceId: `imp_${Date.now()}` });
break;
}
case 'smokeBomb':
const node = state.mapNodes[state.floor - 2];
if (node && node.type !== 'boss') {
node.completed = true;
state.floor++;
state.screen = 'map';
}
break;
}
}
function enterNode(node) {
node.completed = true;
switch (node.type) {
case 'enemy':
case 'elite':
case 'boss':
if (node.enemyIds)
state = (0, engine_1.beginCombat)(state, node.enemyIds);
break;
case 'rest':
state.restAction = null;
state.screen = 'rest';
break;
case 'shop':
generateShopItems();
state.screen = 'shop';
break;
case 'event':
state.currentEvent = data_1.eventPool[(node.eventId ?? 0) % data_1.eventPool.length];
state.screen = 'event';
break;
}
}
function processEventChoice(outcomes) {
if (outcomes.hpGain)
state.player.hp = Math.min(state.player.maxHp, state.player.hp + outcomes.hpGain);
if (outcomes.hpLoss)
state.player.hp = Math.max(0, state.player.hp - outcomes.hpLoss);
if (outcomes.goldGain)
state.player.gold += outcomes.goldGain;
if (outcomes.goldLoss && state.player.gold >= outcomes.goldLoss)
state.player.gold -= outcomes.goldLoss;
if (outcomes.gainCurse)
state.player.deck.push({ ...cards_1.DOUBT });
if (outcomes.gainPotion && state.player.potions.length < 3)
state.player.potions.push(pick(data_1.potionPool));
if (outcomes.gainRelic) {
const pool = data_1.relicPool.filter(r => r.rarity.toLowerCase() === outcomes.gainRelic);
if (pool.length > 0)
state.player.relics.push(pick(pool));
}
if (outcomes.upgradeCards) {
for (let i = 0; i < outcomes.upgradeCards && i < state.player.deck.length; i++) {
state.player.deck[i].upgraded = true;
}
}
if (outcomes.removeCards && state.player.deck.length > 0) {
state.player.deck.splice(Math.floor(Math.random() * state.player.deck.length), 1);
}
if (outcomes.gamble && Math.random() < 0.5) {
state.player.gold += (outcomes.goldGain || 0);
}
if (outcomes.startCombat && outcomes.enemyId) {
state = (0, engine_1.beginCombat)(state, [outcomes.enemyId]);
return;
}
if (state.player.hp > 0) {
const node = state.mapNodes[state.floor - 2];
if (node)
node.completed = true;
state.floor++;
state.screen = 'map';
}
else {
state.screen = 'gameOver';
}
}
function proceedAfterReward() {
const node = state.mapNodes[state.floor - 2];
if (node)
node.completed = true;
const wasBoss = node?.type === 'boss';
if (wasBoss && state.pendingRewards.relicChoices && state.pendingRewards.relicChoices.length > 0) {
state.player.relics.push(state.pendingRewards.relicChoices[0]);
state.pendingRewards.relicChoices = undefined;
state.screen = 'cardReward';
return;
}
if (wasBoss && state.bossRelicChoices.length > 0) {
state.screen = 'bossReward';
return;
}
state.floor++;
if (state.floor > 15) {
if (state.act >= 3) {
state.screen = 'victory';
return;
}
state.act++;
state.floor = 1;
state.mapNodes = generateMap(state.act);
}
state.screen = 'map';
}
function leaveRest() {
const node = state.mapNodes[state.floor - 2];
if (node)
node.completed = true;
state.floor++;
checkEndOfAct();
state.screen = 'map';
}
function leaveShop() {
const node = state.mapNodes[state.floor - 2];
if (node)
node.completed = true;
state.floor++;
checkEndOfAct();
state.screen = 'map';
}
function checkEndOfAct() {
if (state.floor > 15) {
if (state.act >= 3) {
state.screen = 'victory';
}
else {
state.act++;
state.floor = 1;
state.mapNodes = generateMap(state.act);
}
}
}
function generateMap(act) {
const floorsData = act === 1 ? data_1.act1Floors : act === 2 ? data_1.act2Floors : data_1.act3Floors;
const nodes = [];
const xPositions = [0, 40, -40, 30, -30, 20, -20];
const yPositions = [];
for (let i = 0; i < floorsData.length; i++) {
yPositions.push(80 + i * 38);
}
floorsData.forEach((fd, idx) => {
nodes.push({
floor: fd.floor,
type: fd.type,
x: (0, renderer_1.getW)() / 2 + xPositions[idx % xPositions.length],
y: yPositions[idx],
completed: false,
selected: false,
enemyIds: fd.enemyIds,
eventId: idx,
id: `node_${idx}`,
});
});
return nodes;
}
function handleClick(e) {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const W = (0, renderer_1.getW)();
const H = (0, renderer_1.getH)();
switch (state.screen) {
case 'title':
state = (0, engine_1.startRun)('Blood Reaper');
state.screen = 'classSelect';
break;
case 'classSelect': {
const classes = ['Blood Reaper', 'Shadowmancer', 'Hellforged'];
const hover = (0, renderer_1.getHoverState)();
for (let i = 0; i < 3; i++) {
if (hover === `class_${i}`) {
state = (0, engine_1.startRun)(classes[i]);
return;
}
}
break;
}
case 'map': {
const hover = (0, renderer_1.getHoverState)();
const nodeIdx = parseInt(hover.replace('mapnode_', ''));
if (!isNaN(nodeIdx) && nodeIdx >= 0 && nodeIdx < state.mapNodes.length) {
const node = state.mapNodes[nodeIdx];
if (!node.completed && nodeIdx === state.floor - 2) {
enterNode(node);
}
}
break;
}
case 'combat': {
const hover = (0, renderer_1.getHoverState)();
if (hover === 'endTurn' && state.isPlayerTurn) {
state = (0, engine_1.endPlayerTurn)(state);
state = (0, engine_1.processEnemyTurn)(state);
if (state.player.hp <= 0) {
state.screen = 'gameOver';
}
else if (state.enemies.length === 0) {
getCombatReward();
state.screen = state.pendingRewards.potionChoice ? 'potionPick' : 'cardReward';
}
else {
state = (0, engine_1.startPlayerTurn)(state);
if (state.player.hp <= 0)
state.screen = 'gameOver';
}
return;
}
const pMatch = hover.match(/^potion_(\d+)$/);
if (pMatch && state.isPlayerTurn) {
const pIdx = parseInt(pMatch[1]);
if (pIdx < state.player.potions.length) {
usePotion(state.player.potions[pIdx].id);
state.player.potions.splice(pIdx, 1);
return;
}
}
if (!state.isPlayerTurn)
break;
const cardW = 100;
const handY = H - 260;
const handStartX = Math.max(20, W / 2 - state.player.hand.length * (cardW + 8) / 2);
for (let i = 0; i < state.player.hand.length; i++) {
const card = state.player.hand[i];
const cx = handStartX + i * (cardW + 8);
if (mx >= cx && mx <= cx + cardW && my >= handY && my <= handY + 140) {
if (!card.unplayable && state.player.energy >= card.cost) {
const targetIdx = state.enemies.length > 0 ? 0 : -1;
state = (0, engine_1.playCard)(state, card, targetIdx);
if (state.enemies.length === 0) {
getCombatReward();
state.screen = state.pendingRewards.potionChoice ? 'potionPick' : 'cardReward';
}
if (state.player.hp <= 0)
state.screen = 'gameOver';
}
return;
}
}
break;
}
case 'rest': {
const hover = (0, renderer_1.getHoverState)();
if (hover === 'rest_rest') {
state.player.hp = Math.min(state.player.maxHp, state.player.hp + Math.floor(state.player.maxHp * 0.25));
state.restAction = null;
leaveRest();
}
else if (hover === 'rest_forge' && !state.restAction)
state.restAction = 'forge';
else if (hover === 'rest_meditate' && !state.restAction)
state.restAction = 'meditate';
else if (state.restAction) {
const cardMatch = hover.match(/^${state.restAction}_card_(\d+)$/);
if (cardMatch) {
const idx = parseInt(cardMatch[1]);
if (idx < state.player.deck.length) {
if (state.restAction === 'forge')
state.player.deck[idx].upgraded = true;
else if (state.restAction === 'meditate')
state.player.deck.splice(idx, 1);
state.restAction = null;
leaveRest();
}
}
}
break;
}
case 'shop': {
const hover = (0, renderer_1.getHoverState)();
if (hover === 'shop_leave') {
leaveShop();
return;
}
const cardMatch = hover.match(/^shop_card_(\d+)$/);
if (cardMatch) {
const idx = parseInt(cardMatch[1]);
const item = state.shopItems.cards[idx];
if (item && state.player.gold >= item.price) {
state.player.gold -= item.price;
state.player.deck.push(item.card);
state.shopItems.cards.splice(idx, 1);
}
return;
}
const relicMatch = hover.match(/^shop_relic_(\d+)$/);
if (relicMatch) {
const idx = parseInt(relicMatch[1]);
const item = state.shopItems.relics[idx];
if (item && state.player.gold >= item.price) {
state.player.gold -= item.price;
state.player.relics.push(item.relic);
state.shopItems.relics.splice(idx, 1);
}
return;
}
const potMatch = hover.match(/^shop_potion_(\d+)$/);
if (potMatch) {
const idx = parseInt(potMatch[1]);
const item = state.shopItems.potions[idx];
if (item && state.player.gold >= item.price && state.player.potions.length < 3) {
state.player.gold -= item.price;
state.player.potions.push(item.potion);
state.shopItems.potions.splice(idx, 1);
}
return;
}
break;
}
case 'event': {
const hover = (0, renderer_1.getHoverState)();
const evMatch = hover.match(/^event_(\d+)$/);
if (evMatch && state.currentEvent) {
const idx = parseInt(evMatch[1]);
const choice = state.currentEvent.choices[idx];
if (choice)
processEventChoice(choice.outcomes);
}
break;
}
case 'cardReward': {
const hover = (0, renderer_1.getHoverState)();
if (hover === 'reward_skip')
proceedAfterReward();
else {
const match = hover.match(/^reward_(\d+)$/);
if (match) {
const idx = parseInt(match[1]);
if (idx < state.pendingRewards.cardChoices.length) {
state.player.deck.push(state.pendingRewards.cardChoices[idx]);
proceedAfterReward();
}
}
}
break;
}
case 'potionPick': {
if ((0, renderer_1.getHoverState)() === 'potion_accept' && state.pendingRewards.potionChoice) {
const potions = {
bloodVial: { id: 'bloodVial', name: 'Blood Vial', description: 'Heal 15 HP.' },
firebomb: { id: 'firebomb', name: 'Firebomb', description: 'Deal 10 damage to ALL enemies.' },
dexTonic: { id: 'dexTonic', name: 'Dex Tonic', description: 'Gain 3 Dexterity for 1 turn.' },
energyElixir: { id: 'energyElixir', name: 'Energy Elixir', description: 'Gain 2 Energy this turn.' },
cursedBrew: { id: 'cursedBrew', name: 'Cursed Brew', description: 'Apply 3 Vulnerable to all enemies.' },
bleedingFlask: { id: 'bleedingFlask', name: 'Bleeding Flask', description: 'Apply 6 Bleed to an enemy.' },
impInBottle: { id: 'impInBottle', name: 'Imp-in-a-Bottle', description: 'Add Imp Claw+ to hand.' },
smokeBomb: { id: 'smokeBomb', name: 'Smoke Bomb', description: 'Escape from combat, no rewards.' },
};
const p = potions[state.pendingRewards.potionChoice];
if (p && state.player.potions.length < 3)
state.player.potions.push(p);
state.pendingRewards.potionChoice = undefined;
proceedAfterReward();
}
break;
}
case 'bossReward': {
const hover = (0, renderer_1.getHoverState)();
if (hover === 'bossrelic_skip')
proceedAfterReward();
else {
const match = hover.match(/^bossrelic_(\d+)$/);
if (match) {
const idx = parseInt(match[1]);
if (idx < state.bossRelicChoices.length) {
const relic = state.bossRelicChoices[idx];
state.player.relics.push(relic);
if (relic.id === 'cursedCrown')
state.player.maxEnergy++;
else if (relic.id === 'ectoplasm') {
state.player.maxEnergy++;
state.player.gold = 0;
}
else if (relic.id === 'philosopherStone')
state.player.maxEnergy++;
else if (relic.id === 'shadowAmulet')
state.player.maxHp -= 15;
}
proceedAfterReward();
}
}
break;
}
case 'gameOver':
case 'victory':
if ((0, renderer_1.getHoverState)() === 'restart') {
state = (0, engine_1.createInitialState)();
}
break;
}
}
// Hover tracking
function handleMouseMove(e) {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const W = (0, renderer_1.getW)();
const H = (0, renderer_1.getH)();
let hover = '';
switch (state.screen) {
case 'title':
if (mx > W / 2 - 140 && mx < W / 2 + 140 && my > H * 0.55 && my < H * 0.55 + 50)
hover = 'startBtn';
break;
case 'classSelect': {
const btnW = 260;
const gap = 30;
const totalW = 3 * btnW + 2 * gap;
const startX = W / 2 - totalW / 2;
const y = H * 0.25;
for (let i = 0; i < 3; i++) {
const x = startX + i * (btnW + gap);
if (mx > x && mx < x + btnW && my > y && my < y + 180)
hover = `class_${i}`;
}
break;
}
case 'map':
state.mapNodes.forEach((node, i) => {
if (Math.sqrt((mx - node.x) ** 2 + (my - node.y) ** 2) < 22)
hover = `mapnode_${i}`;
});
break;
case 'combat': {
if (mx > W - 140 && mx < W - 20 && my > H - 30 && my < H - 30 + 35) {
hover = 'endTurn';
(0, renderer_1.setHoverState)(hover);
return;
}
const cardW = 100;
const handY = H - 260;
const handStartX = Math.max(20, W / 2 - state.player.hand.length * (cardW + 8) / 2);
for (let i = 0; i < state.player.hand.length; i++) {
const cx = handStartX + i * (cardW + 8);
if (mx >= cx && mx <= cx + cardW && my >= handY && my <= handY + 140) {
state.hoveredCard = state.player.hand[i];
(0, renderer_1.setHoverState)(`card_${state.player.hand[i].instanceId}`);
return;
}
}
state.hoveredCard = null;
state.player.potions.forEach((p, i) => {
const px = 15 + i * 60;
if (mx > px && mx < px + 55 && my > H - 25 && my < H - 3)
hover = `potion_${i}`;
});
break;
}
case 'rest': {
const actions = ['rest', 'forge', 'meditate'];
actions.forEach((a, i) => {
const x = W / 2 - 220 + i * 160;
if (mx > x && mx < x + 140 && my > 140 && my < 220)
hover = `rest_${a}`;
});
if (state.restAction) {
const cols = Math.floor((W - 40) / 104);
state.player.deck.slice(0, 20).forEach((_, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = 20 + col * 104;
const y = 300 + row * 124;
if (mx > x && mx < x + 100 && my > y && my < y + 120)
hover = `${state.restAction}_card_${i}`;
});
}
break;
}
case 'shop': {
const lx = W - 100;
if (mx > lx && mx < lx + 80 && my > H - 40 && my < H - 10) {
hover = 'shop_leave';
(0, renderer_1.setHoverState)(hover);
return;
}
state.shopItems.cards.forEach((_, i) => {
const x = W / 2 - 200 + i * 140;
if (mx > x && mx < x + 110 && my > 110 && my < 240)
hover = `shop_card_${i}`;
});
state.shopItems.relics.forEach((_, i) => {
const x = W / 2 - 100 + i * 120;
if (mx > x && mx < x + 90 && my > 310 && my < 410)
hover = `shop_relic_${i}`;
});
state.shopItems.potions.forEach((_, i) => {
const x = W / 2 - 150 + i * 100;
if (mx > x && mx < x + 75 && my > 480 && my < 540)
hover = `shop_potion_${i}`;
});
break;
}
case 'event':
if (state.currentEvent) {
state.currentEvent.choices.forEach((_, i) => {
const cy = 220 + i * 60;
if (mx > W / 2 - 200 && mx < W / 2 + 200 && my > cy && my < cy + 45)
hover = `event_${i}`;
});
}
break;
case 'cardReward': {
state.pendingRewards.cardChoices.forEach((_, i) => {
const totalW = state.pendingRewards.cardChoices.length * 140 + (state.pendingRewards.cardChoices.length - 1) * 20;
const startX = W / 2 - totalW / 2;
const x = startX + i * 160;
if (mx > x && mx < x + 120 && my > 120 && my < 280)
hover = `reward_${i}`;
});
if (mx > W / 2 - 60 && mx < W / 2 + 60 && my > 350 && my < 385)
hover = 'reward_skip';
break;
}
case 'potionPick':
if (mx > W / 2 - 80 && mx < W / 2 + 80 && my > 250 && my < 290)
hover = 'potion_accept';
break;
case 'bossReward': {
state.bossRelicChoices.forEach((_, i) => {
const x = W / 2 - 240 + i * 170;
if (mx > x && mx < x + 150 && my > 110 && my < 270)
hover = `bossrelic_${i}`;
});
if (mx > W / 2 - 50 && mx < W / 2 + 50 && my > 310 && my < 340)
hover = 'bossrelic_skip';
break;
}
case 'gameOver':
case 'victory': {
const btnX = W / 2 - 100;
const btnY = state.screen === 'gameOver' ? H * 0.6 : H * 0.5;
if (mx > btnX && mx < btnX + 200 && my > btnY && my < btnY + 45)
hover = 'restart';
break;
}
}
(0, renderer_1.setHoverState)(hover);
}
// Setup
const canvas = document.getElementById('gameCanvas');
function renderLoop(timestamp) {
const W = (0, renderer_1.getW)();
const H = (0, renderer_1.getH)();
if (canvas.width !== W || canvas.height !== H) {
canvas.width = W;
canvas.height = H;
}
(0, renderer_1.renderScreen)(state);
requestAnimationFrame(renderLoop);
}
canvas.addEventListener('click', handleClick);
canvas.addEventListener('mousemove', handleMouseMove);
requestAnimationFrame(renderLoop);
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// Core type definitions for Shadows of the Abyss
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/cards.ts ──
__mods["src/cards.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DAZED = exports.DOUBT = exports.WOUND = exports.colorlessCards = exports.hellforgedCards = exports.shadowmancerCards = exports.bloodReaperCards = exports.DEFEND = exports.STRIKE = void 0;
exports.getCardPool = getCardPool;
exports.getClassStartingDeck = getClassStartingDeck;
exports.getUpgradeVersion = getUpgradeVersion;
exports.createImpClaw = createImpClaw;
let cardCounter = 0;
function uid() { return `card_${++cardCounter}`; }
function makeCard(name, type, rarity, cost, description, cls, exhaust, ethereal, unplayable) {
return { id: uid(), name, type, rarity, cost, description, upgraded: false, cls, exhaust, ethereal, unplayable };
}
// Basic cards
exports.STRIKE = makeCard('Strike', 'Attack', 'Basic', 1, 'Deal 6 damage.');
exports.DEFEND = makeCard('Defend', 'Skill', 'Basic', 1, 'Gain 5 Block.');
// --- Blood Reaper Cards ---
exports.bloodReaperCards = [
// Attacks
makeCard('Sanguine Strike', 'Attack', 'Common', 1, 'Deal 8 damage. Heal 2 HP.', 'Blood Reaper'),
makeCard('Hemorrhage', 'Attack', 'Common', 1, 'Deal 5 damage. Apply 3 Bleed.', 'Blood Reaper'),
makeCard('Bloodletting', 'Attack', 'Uncommon', 1, 'Lose 3 HP. Deal 12 damage.', 'Blood Reaper'),
makeCard('Crimson Eruption', 'Attack', 'Rare', 2, 'Deal 8 damage to ALL enemies. Lose 2 HP.', 'Blood Reaper'),
// Defenses
makeCard('Crimson Shield', 'Skill', 'Common', 1, 'Gain 6 Block. Lose 2 HP.', 'Blood Reaper'),
makeCard('Blood Ward', 'Skill', 'Common', 1, 'Gain 3 Block. Gain 1 Regeneration.', 'Blood Reaper'),
makeCard('Sacrificial Barrier', 'Skill', 'Uncommon', 2, 'Gain 10 Block. Lose 5 HP.', 'Blood Reaper'),
makeCard('Vital Surge', 'Skill', 'Rare', 0, 'Gain Block equal to your missing HP. Exhaust.', 'Blood Reaper', true),
// Others
makeCard('Life Tap', 'Skill', 'Common', 0, 'Draw 2 cards. Lose 3 HP.', 'Blood Reaper'),
makeCard('Rend Flesh', 'Skill', 'Uncommon', 1, 'Apply 5 Bleed to ALL enemies. Lose 2 HP.', 'Blood Reaper'),
makeCard('Soul Siphon', 'Power', 'Uncommon', 2, 'At start of turn, gain 1 Energy and lose 1 HP.', 'Blood Reaper'),
makeCard('Blood Pact', 'Power', 'Rare', 1, 'Whenever you lose HP from a card, gain 1 Strength.', 'Blood Reaper'),
];
// --- Shadowmancer Cards ---
exports.shadowmancerCards = [
makeCard('Shadow Bolt', 'Attack', 'Common', 1, 'Deal 6 damage. Apply 1 Weak.', 'Shadowmancer'),
makeCard('Grasp of Despair', 'Attack', 'Common', 1, 'Deal 4 damage. Apply 1 Vulnerable.', 'Shadowmancer'),
makeCard('Nightmare Lash', 'Attack', 'Uncommon', 2, 'Deal 7 damage. If enemy has Weak, deal 7 more.', 'Shadowmancer'),
makeCard('Eternal Darkness', 'Attack', 'Rare', 2, 'Deal 3 damage to a random enemy 3 times.', 'Shadowmancer'),
makeCard('Cloak of Shadows', 'Skill', 'Common', 1, 'Gain 5 Block. Draw 1 card.', 'Shadowmancer'),
makeCard('Haunting Mist', 'Skill', 'Common', 1, 'Gain 7 Block. Apply 1 Weak to ALL enemies.', 'Shadowmancer'),
makeCard('Phantom Armor', 'Skill', 'Uncommon', 1, 'Gain 8 Block. Next turn, gain 2 Dexterity.', 'Shadowmancer'),
makeCard('Whispering Shroud', 'Skill', 'Rare', 1, 'Gain 4 Block. Next turn, gain 4 Block.', 'Shadowmancer'),
makeCard('Shadowform', 'Skill', 'Uncommon', 2, 'Gain 2 Intangible. Exhaust.', 'Shadowmancer', true),
makeCard('Curse of Misfortune', 'Skill', 'Common', 1, 'Enemy loses 3 Strength this turn. Apply 1 Vulnerable.', 'Shadowmancer'),
makeCard('Dark Ritual', 'Skill', 'Uncommon', 0, 'Gain 2 Energy. Shuffle a Dazed into your discard pile.', 'Shadowmancer'),
makeCard('Umbral Mastery', 'Power', 'Rare', 2, 'At start of turn, apply 1 Weak to ALL enemies.', 'Shadowmancer'),
];
// --- Hellforged Cards ---
exports.hellforgedCards = [
makeCard('Firebolt', 'Attack', 'Common', 1, 'Deal 7 damage. Apply 2 Burn.', 'Hellforged'),
makeCard('Hellfire Blast', 'Attack', 'Uncommon', 2, 'Deal 9 damage to ALL enemies.', 'Hellforged'),
makeCard('Searing Strike', 'Attack', 'Common', 1, 'Deal 6 damage. Gain 1 Energy next turn.', 'Hellforged'),
makeCard('Immolate', 'Attack', 'Rare', 2, 'Deal 12 damage. Exhaust.', 'Hellforged', true),
makeCard('Obsidian Skin', 'Skill', 'Common', 1, 'Gain 7 Block.', 'Hellforged'),
makeCard('Flame Ward', 'Skill', 'Common', 1, 'Gain 5 Block. Apply 2 Burn to a random enemy.', 'Hellforged'),
makeCard('Demonic Resilience', 'Skill', 'Uncommon', 1, 'Gain 4 Block. Heal 2 HP.', 'Hellforged'),
makeCard('Brimstone Shield', 'Skill', 'Rare', 1, 'Gain 3 Block. Gain 2 Strength this turn.', 'Hellforged'),
makeCard('Summon Imp', 'Skill', 'Common', 1, 'Add an Imp Claw to your hand. Exhaust.', 'Hellforged', true),
makeCard('Infernal Pact', 'Skill', 'Uncommon', 0, 'Lose 2 HP. Gain 2 Energy. Add an Imp Claw to your hand.', 'Hellforged'),
makeCard('Hellfire Aura', 'Power', 'Rare', 2, 'At start of turn, deal 3 damage to ALL enemies.', 'Hellforged'),
makeCard('Soul Burn', 'Skill', 'Uncommon', 1, 'Apply 8 Burn to an enemy. Draw 1 card.', 'Hellforged'),
];
// Colorless cards
exports.colorlessCards = [
makeCard('Flash of Insight', 'Skill', 'Common', 1, 'Draw 2 cards.'),
makeCard('Iron Will', 'Skill', 'Common', 1, 'Gain 8 Block.'),
makeCard('Quick Slash', 'Attack', 'Common', 1, 'Deal 7 damage.'),
makeCard('Potent Brew', 'Skill', 'Uncommon', 0, 'Gain 1 Energy. Exhaust.', undefined, true),
];
// Curses
exports.WOUND = makeCard('Wound', 'Curse', 'Common', 0, 'Unplayable. Ethereal. Exhaust.', undefined, true, true);
exports.DOUBT = makeCard('Doubt', 'Curse', 'Common', 0, 'Unplayable. At end of turn, lose 1 HP.');
exports.DAZED = makeCard('Dazed', 'Status', 'Common', 0, 'Unplayable. Ethereal. Exhaust.', undefined, true, true);
function getCardPool(cls) {
switch (cls) {
case 'Blood Reaper': return exports.bloodReaperCards;
case 'Shadowmancer': return exports.shadowmancerCards;
case 'Hellforged': return exports.hellforgedCards;
}
}
function getClassStartingDeck(cls) {
const deck = [];
for (let i = 0; i < 5; i++)
deck.push(makeCard('Strike', 'Attack', 'Basic', 1, 'Deal 6 damage.'));
for (let i = 0; i < 4; i++)
deck.push(makeCard('Defend', 'Skill', 'Basic', 1, 'Gain 5 Block.'));
switch (cls) {
case 'Blood Reaper':
deck.push(makeCard('Sanguine Strike', 'Attack', 'Common', 1, 'Deal 8 damage. Heal 2 HP.', 'Blood Reaper'));
break;
case 'Shadowmancer':
deck.push(makeCard('Shadow Bolt', 'Attack', 'Common', 1, 'Deal 6 damage. Apply 1 Weak.', 'Shadowmancer'));
break;
case 'Hellforged':
deck.push(makeCard('Firebolt', 'Attack', 'Common', 1, 'Deal 7 damage. Apply 2 Burn.', 'Hellforged'));
break;
}
return deck;
}
function getUpgradeVersion(card) {
const upgrades = {
'Strike': 'Deal 8 damage.',
'Defend': 'Gain 8 Block.',
'Sanguine Strike': 'Deal 10 damage. Heal 3 HP.',
'Hemorrhage': 'Deal 6 damage. Apply 4 Bleed.',
'Bloodletting': 'Lose 2 HP. Deal 15 damage.',
'Crimson Eruption': 'Deal 10 damage to ALL enemies. Lose 2 HP.',
'Crimson Shield': 'Gain 8 Block. Lose 2 HP.',
'Blood Ward': 'Gain 4 Block. Gain 2 Regeneration.',
'Sacrificial Barrier': 'Gain 13 Block. Lose 5 HP.',
'Vital Surge': 'Gain Block equal to missing HP + 3. Exhaust.',
'Life Tap': 'Draw 3 cards. Lose 3 HP.',
'Rend Flesh': 'Apply 7 Bleed to ALL enemies. Lose 2 HP.',
'Soul Siphon': 'Cost 1. At start of turn, gain 1 Energy and lose 1 HP.',
'Blood Pact': 'Whenever you lose HP from a card, gain 2 Strength.',
'Shadow Bolt': 'Deal 8 damage. Apply 2 Weak.',
'Grasp of Despair': 'Deal 5 damage. Apply 2 Vulnerable.',
'Nightmare Lash': 'Deal 9 damage. If Weak, deal 9 more.',
'Eternal Darkness': 'Deal 4 damage to a random enemy 3 times.',
'Cloak of Shadows': 'Gain 7 Block. Draw 1 card.',
'Haunting Mist': 'Gain 9 Block. Apply 1 Weak to ALL enemies.',
'Phantom Armor': 'Gain 10 Block. Next turn, gain 3 Dexterity.',
'Whispering Shroud': 'Gain 5 Block. Next turn, gain 5 Block.',
'Shadowform': 'Gain 3 Intangible. Exhaust.',
'Curse of Misfortune': 'Enemy loses 4 Strength this turn. Apply 2 Vulnerable.',
'Dark Ritual': 'Gain 2 Energy. Shuffle a Dazed into your discard pile.',
'Umbral Mastery': 'At start of turn, apply 2 Weak to ALL enemies.',
'Firebolt': 'Deal 9 damage. Apply 3 Burn.',
'Hellfire Blast': 'Deal 12 damage to ALL enemies.',
'Searing Strike': 'Deal 8 damage. Gain 1 Energy next turn.',
'Immolate': 'Deal 16 damage. Exhaust.',
'Obsidian Skin': 'Gain 10 Block.',
'Flame Ward': 'Gain 6 Block. Apply 3 Burn to a random enemy.',
'Demonic Resilience': 'Gain 5 Block. Heal 3 HP.',
'Brimstone Shield': 'Gain 4 Block. Gain 3 Strength this turn.',
'Summon Imp': 'Add an Imp Claw+ to your hand. Exhaust.',
'Infernal Pact': 'Lose 1 HP. Gain 2 Energy. Add an Imp Claw+ to your hand.',
'Hellfire Aura': 'At start of turn, deal 5 damage to ALL enemies.',
'Soul Burn': 'Apply 10 Burn to an enemy. Draw 1 card.',
'Flash of Insight': 'Draw 3 cards.',
'Iron Will': 'Gain 11 Block.',
'Quick Slash': 'Deal 9 damage.',
'Potent Brew': 'Gain 2 Energy. Exhaust.',
};
return upgrades[card.name] || card.description;
}
// Imp Claw token
function createImpClaw(upgraded = false) {
if (upgraded) {
return makeCard('Imp Claw+', 'Attack', 'Common', 0, 'Deal 6 damage.', undefined, true);
}
return makeCard('Imp Claw', 'Attack', 'Common', 0, 'Deal 4 damage.', undefined, true);
}
};
// ── module: src/data.ts ──
__mods["src/data.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.act3Floors = exports.act2Floors = exports.act1Floors = exports.eventPool = exports.potionPool = exports.bossRelics = exports.relicPool = exports.act3Enemies = exports.act2Enemies = exports.act1Enemies = void 0;
exports.getEnemyData = getEnemyData;
// --- ACT 1 ENEMIES ---
exports.act1Enemies = {
skeleton: {
baseEnemy: { name: 'Skeleton Warrior', maxHp: 22, hp: 22, sprite: 'skeleton', act: 1 },
intentPattern: [
{ type: 'attack', damage: 6, text: 'Attack 6' },
{ type: 'attack', damage: 6, text: 'Attack 6' },
{ type: 'defend', block: 8, text: 'Defend 8' },
],
},
zombie: {
baseEnemy: { name: 'Zombie Brute', maxHp: 28, hp: 28, sprite: 'zombie', act: 1 },
intentPattern: [
{ type: 'attack', damage: 8, text: 'Attack 8' },
{ type: 'debuff', damage: 0, text: 'Apply 2 Weak', applyDebuffs: [{ type: 'weak', amount: 2, target: 'player' }] },
{ type: 'attack', damage: 8, text: 'Attack 8' },
],
},
cultist: {
baseEnemy: { name: 'Dark Cultist', maxHp: 20, hp: 20, sprite: 'cultist', act: 1 },
intentPattern: [
{ type: 'buff', text: 'Buff +3 Str', buffSelf: [{ type: 'strength', amount: 3 }] },
{ type: 'attack', damage: 5, text: 'Attack 5' },
{ type: 'attack', damage: 5, text: 'Attack 5' },
],
},
ratSwarm: {
baseEnemy: { name: 'Rat Swarm', maxHp: 15, hp: 15, sprite: 'rat', act: 1 },
intentPattern: [
{ type: 'multiHit', damage: 3, hits: 2, text: '2x3 Attack' },
{ type: 'multiHit', damage: 3, hits: 2, text: '2x3 Attack' },
],
},
tombGuardian: {
baseEnemy: { name: 'Tomb Guardian', maxHp: 42, hp: 42, sprite: 'tombGuardian', act: 1 },
intentPattern: [
{ type: 'attack', damage: 6, block: 8, text: 'Shield Bash 6+8B' },
{ type: 'multiHit', damage: 4, hits: 3, text: '3x4 Flurry' },
{ type: 'attack', damage: 12, text: 'Heavy Slam 12' },
],
},
bloodWitch: {
baseEnemy: { name: 'Blood Witch', maxHp: 36, hp: 36, sprite: 'bloodWitch', act: 1 },
intentPattern: [
{ type: 'drain', damage: 5, heal: 4, text: 'Drain 5, Heal 4' },
{ type: 'debuff', text: 'Curse (1V+1W)', applyDebuffs: [{ type: 'vulnerable', amount: 1, target: 'player' }, { type: 'weak', amount: 1, target: 'player' }] },
{ type: 'drain', damage: 7, heal: 4, text: 'Ritual 7, Heal 4' },
],
},
lichKing: {
baseEnemy: { name: 'Lich King', maxHp: 65, hp: 65, sprite: 'lichKing', act: 1 },
intentPattern: [
{ type: 'summon', text: 'Summon Skeleton', summonEnemy: 'skeleton' },
{ type: 'attack', damage: 8, text: 'Attack 8' },
{ type: 'defend', block: 12, text: 'Defend 12' },
],
},
shadowThief: {
baseEnemy: { name: 'Shadow Thief', maxHp: 30, hp: 30, sprite: 'shadowThief', act: 1 },
intentPattern: [
{ type: 'attack', damage: 6, text: 'Attack 6' },
{ type: 'defend', block: 8, text: 'Defend 8' },
{ type: 'attack', damage: 8, text: 'Attack 8' },
],
},
};
// --- ACT 2 ENEMIES ---
exports.act2Enemies = {
wraith: {
baseEnemy: { name: 'Wraith', maxHp: 30, hp: 30, sprite: 'wraith', act: 2 },
intentPattern: [
{ type: 'attack', damage: 7, text: 'Attack 7+1W', applyDebuffs: [{ type: 'weak', amount: 1, target: 'player' }] },
{ type: 'attack', damage: 7, text: 'Attack 7' },
{ type: 'defend', block: 10, text: 'Defend 10' },
],
},
corruptedBeast: {
baseEnemy: { name: 'Corrupted Beast', maxHp: 38, hp: 38, sprite: 'corruptedBeast', act: 2 },
intentPattern: [
{ type: 'attack', damage: 12, text: 'Attack 12' },
{ type: 'defend', block: 5, text: 'Defend 5+1Vuln', applyDebuffs: [{ type: 'vulnerable', amount: 1, target: 'enemy' }] },
{ type: 'attack', damage: 12, text: 'Attack 12' },
],
},
thornedHorror: {
baseEnemy: { name: 'Thorned Horror', maxHp: 25, hp: 25, sprite: 'thornedHorror', act: 2 },
intentPattern: [
{ type: 'multiHit', damage: 4, hits: 3, text: '3x4 Attack' },
{ type: 'debuff', text: 'Apply 3 Bleed', applyDebuffs: [{ type: 'bleed', amount: 3, target: 'player' }] },
{ type: 'attack', damage: 8, text: 'Attack 8' },
],
},
witchDoctor: {
baseEnemy: { name: 'Witch Doctor', maxHp: 22, hp: 22, sprite: 'witchDoctor', act: 2 },
intentPattern: [
{ type: 'buff', text: 'Buff 5B+2Regen', buffSelf: [{ type: 'regeneration', amount: 2 }], block: 5 },
{ type: 'attack', damage: 6, text: 'Attack 6+2Pois', applyDebuffs: [{ type: 'poison', amount: 2, target: 'player' }] },
{ type: 'attack', damage: 6, text: 'Attack 6' },
],
},
treantProtector: {
baseEnemy: { name: 'Treant Protector', maxHp: 50, hp: 50, sprite: 'treantProtector', act: 2 },
intentPattern: [
{ type: 'attack', damage: 9, block: 6, text: 'Attack 9+6B' },
{ type: 'debuff', text: 'Apply 2 Vuln', applyDebuffs: [{ type: 'vulnerable', amount: 2, target: 'player' }] },
{ type: 'multiHit', damage: 10, hits: 2, text: '2x10 Multi' },
],
},
plagueShaman: {
baseEnemy: { name: 'Plague Shaman', maxHp: 42, hp: 42, sprite: 'plagueShaman', act: 2 },
intentPattern: [
{ type: 'debuff', text: 'Apply 4 Poison', applyDebuffs: [{ type: 'poison', amount: 4, target: 'player' }] },
{ type: 'summon', text: 'Summon Plague Rat', summonEnemy: 'plagueRat' },
{ type: 'attack', damage: 8, text: 'Attack 8' },
],
},
plagueRat: {
baseEnemy: { name: 'Plague Rat', maxHp: 18, hp: 18, sprite: 'rat', act: 2 },
intentPattern: [
{ type: 'attack', damage: 5, text: 'Attack 5' },
],
},
eldritchTreant: {
baseEnemy: { name: 'Eldritch Treant', maxHp: 75, hp: 75, sprite: 'eldritchTreant', act: 2 },
intentPattern: [
{ type: 'attack', damage: 10, text: 'Attack 10+2W', applyDebuffs: [{ type: 'weak', amount: 2, target: 'player' }] },
{ type: 'defend', block: 15, text: 'Defend 15+2R', buffSelf: [{ type: 'regeneration', amount: 2 }] },
{ type: 'multiHit', damage: 8, hits: 2, text: '2x8 Multi' },
],
},
};
// --- ACT 3 ENEMIES ---
exports.act3Enemies = {
hellhound: {
baseEnemy: { name: 'Hellhound', maxHp: 26, hp: 26, sprite: 'hellhound', act: 3 },
intentPattern: [
{ type: 'attack', damage: 8, text: 'Attack 8+2Burn', applyDebuffs: [{ type: 'burn', amount: 2, target: 'player' }] },
{ type: 'attack', damage: 8, text: 'Attack 8' },
{ type: 'defend', block: 9, text: 'Defend 9' },
],
},
shadowDemon: {
baseEnemy: { name: 'Shadow Demon', maxHp: 30, hp: 30, sprite: 'shadowDemon', act: 3 },
intentPattern: [
{ type: 'attack', damage: 7, text: 'Attack 7+1W', applyDebuffs: [{ type: 'weak', amount: 1, target: 'player' }] },
{ type: 'attack', damage: 7, text: 'Attack 7' },
{ type: 'buff', text: '1 Intangible', buffSelf: [{ type: 'intangible', amount: 1 }] },
],
},
infernalImp: {
baseEnemy: { name: 'Infernal Imp', maxHp: 18, hp: 18, sprite: 'infernalImp', act: 3 },
intentPattern: [
{ type: 'attack', damage: 5, text: 'Attack 5' },
{ type: 'attack', damage: 5, text: 'Attack 5' },
{ type: 'summon', text: 'Summon Imp', summonEnemy: 'infernalImp' },
],
},
abyssalCultist: {
baseEnemy: { name: 'Abyssal Cultist', maxHp: 24, hp: 24, sprite: 'cultist', act: 3 },
intentPattern: [
{ type: 'buff', text: 'Buff +2 Str', buffSelf: [{ type: 'strength', amount: 2 }] },
{ type: 'attack', damage: 7, text: 'Attack 7' },
{ type: 'attack', damage: 7, text: 'Attack 7' },
],
},
flamespawn: {
baseEnemy: { name: 'Flamespawn', maxHp: 48, hp: 48, sprite: 'flamespawn', act: 3 },
intentPattern: [
{ type: 'attack', damage: 9, text: 'Atk 9+3Burn', applyDebuffs: [{ type: 'burn', amount: 3, target: 'player' }] },
{ type: 'defend', block: 10, text: 'Def 10+2Str', buffSelf: [{ type: 'strength', amount: 2 }] },
{ type: 'multiHit', damage: 8, hits: 2, text: '2x8 Multi' },
],
},
dreadKnight: {
baseEnemy: { name: 'Dread Knight', maxHp: 55, hp: 55, sprite: 'dreadKnight', act: 3 },
intentPattern: [
{ type: 'attack', damage: 12, text: 'Attack 12' },
{ type: 'debuff', text: 'Apply 3 Bleed', applyDebuffs: [{ type: 'bleed', amount: 3, target: 'player' }] },
{ type: 'defend', block: 12, text: 'Def 12+Heal 4' },
],
},
archdemon: {
baseEnemy: { name: 'Archdemon', maxHp: 85, hp: 85, sprite: 'archdemon', act: 3 },
intentPattern: [
{ type: 'summon', text: 'Summon Hellhound', summonEnemy: 'hellhound' },
{ type: 'attack', damage: 12, text: 'Atk 12+2Burn', applyDebuffs: [{ type: 'burn', amount: 2, target: 'player' }] },
{ type: 'defend', block: 15, text: 'Def 15+3Str', buffSelf: [{ type: 'strength', amount: 3 }] },
],
},
};
// --- RELICS ---
exports.relicPool = [
{ id: 'bloodstone', name: 'Bloodstone', description: 'Lose HP from card → heal 1 HP.', rarity: 'Common' },
{ id: 'shadowCloak', name: 'Shadow Cloak', description: 'Start combat with 1 Intangible.', rarity: 'Rare' },
{ id: 'cursedAmulet', name: 'Cursed Amulet', description: 'Start combat: apply 1 Weak to ALL enemies.', rarity: 'Common' },
{ id: 'tomeDark', name: 'Tome of Dark Rituals', description: 'Play a Power card → draw 1 card.', rarity: 'Uncommon' },
{ id: 'bleedingEdge', name: 'Bleeding Edge', description: 'Start of turn: apply 1 Bleed to random enemy.', rarity: 'Common' },
{ id: 'soulEmber', name: 'Soul Ember', description: 'Apply Burn → deal 2 extra damage.', rarity: 'Rare' },
{ id: 'eternalHourglass', name: 'Eternal Hourglass', description: 'Start turn: if hand empty, gain 1 Energy.', rarity: 'Uncommon' },
{ id: 'ashenArmor', name: 'Ashen Armor', description: 'End of combat: heal 6 HP.', rarity: 'Common' },
{ id: 'greedyImp', name: 'Greedy Imp', description: 'Enemies drop 10 extra Gold.', rarity: 'Common' },
{ id: 'demonHorns', name: 'Demon Horns', description: 'Start combat: add Imp Claw to hand.', rarity: 'Uncommon', cls: 'Hellforged' },
{ id: 'sacrificialDagger', name: 'Sacrificial Dagger', description: 'First Attack costing HP each combat costs 1 less HP.', rarity: 'Uncommon', cls: 'Blood Reaper' },
{ id: 'vialOfShadows', name: 'Vial of Shadows', description: 'Upon pickup, remove a card from deck.', rarity: 'Rare' },
];
exports.bossRelics = [
{ id: 'cursedCrown', name: 'Cursed Crown', description: '+1 Energy/turn. Curses unremovable.', rarity: 'Boss' },
{ id: 'ectoplasm', name: 'Ectoplasm', description: '+1 Energy. Lose all Gold.', rarity: 'Boss' },
{ id: 'philosopherStone', name: "Philosopher's Stone", description: '+1 Energy. Enemies start with 1 Str.', rarity: 'Boss' },
{ id: 'blackBlood', name: 'Black Blood', description: 'Start combat: lose 10% max HP. Play Attack: heal 1 HP.', rarity: 'Boss' },
{ id: 'shadowAmulet', name: 'Shadow Amulet', description: 'Start turn: 1 Intangible. -15 max HP.', rarity: 'Boss' },
];
// --- POTIONS ---
exports.potionPool = [
{ id: 'bloodVial', name: 'Blood Vial', description: 'Heal 15 HP.' },
{ id: 'firebomb', name: 'Firebomb', description: 'Deal 10 damage to ALL enemies.' },
{ id: 'dexTonic', name: 'Dex Tonic', description: 'Gain 3 Dexterity for 1 turn.' },
{ id: 'energyElixir', name: 'Energy Elixir', description: 'Gain 2 Energy this turn.' },
{ id: 'cursedBrew', name: 'Cursed Brew', description: 'Apply 3 Vulnerable to all enemies.' },
{ id: 'bleedingFlask', name: 'Bleeding Flask', description: 'Apply 6 Bleed to an enemy.' },
{ id: 'impInBottle', name: 'Imp-in-a-Bottle', description: 'Add Imp Claw+ to hand.' },
{ id: 'smokeBomb', name: 'Smoke Bomb', description: 'Escape from combat, no rewards.' },
];
// --- EVENTS ---
exports.eventPool = [
{
id: 'altar', title: 'The Forgotten Altar',
description: 'A blood-stained altar hums with power.',
choices: [
{ text: 'Offer 15 HP → Obtain a random Rare relic.', outcomes: { hpLoss: 15, gainRelic: 'rare' } },
{ text: 'Pray → Heal 10 HP, gain Doubt.', outcomes: { hpGain: 10, gainCurse: true } },
{ text: 'Leave.', outcomes: {} },
],
},
{
id: 'peddler', title: 'Shadowy Peddler',
description: 'A hooded figure offers a trade.',
choices: [
{ text: 'Trade a card → Remove one, gain 100 Gold.', outcomes: { goldGain: 100, removeCards: 1 } },
{ text: 'Buy wares → Lose 50g, get Potion + Common Relic.', outcomes: { goldLoss: 50, gainPotion: true, gainRelic: 'common' } },
{ text: 'Attack → Combat with Shadow Thief.', outcomes: { startCombat: true, enemyId: 'shadowThief' } },
],
},
{
id: 'cursedSpring', title: 'Cursed Spring',
description: 'A spring bubbles with dark water.',
choices: [
{ text: 'Drink → Lose 10 HP, remove all Curses.', outcomes: { hpLoss: 10 } },
{ text: 'Bottle it → Obtain Cursed Brew potion.', outcomes: { gainPotion: true } },
{ text: 'Leave.', outcomes: {} },
],
},
{
id: 'restlessDead', title: 'Restless Dead',
description: 'Ghosts offer a bargain.',
choices: [
{ text: 'Accept → Lose 20 HP, upgrade 2 random cards.', outcomes: { hpLoss: 20, upgradeCards: 2 } },
{ text: 'Refuse.', outcomes: {} },
],
},
{
id: 'impGambler', title: 'Impish Gambler',
description: 'A grinning imp challenges you.',
choices: [
{ text: 'Bet 30g → 50% to win 60g or lose it.', outcomes: { gamble: true, goldLoss: 30, goldGain: 60 } },
{ text: 'Wager a card → Remove random card.', outcomes: { removeCards: 1 } },
{ text: 'Leave.', outcomes: {} },
],
},
{
id: 'library', title: 'Ancient Library',
description: 'Dusty tomes contain forgotten knowledge.',
choices: [
{ text: 'Study → Bonus Energy next combat. Lose 5 HP.', outcomes: { hpLoss: 5, gainExtraEnergy: true } },
{ text: 'Forbidden page → Get a Power card, gain Doubt.', outcomes: { gainPowerCard: true, gainCurse: true } },
{ text: 'Leave.', outcomes: {} },
],
},
];
exports.act1Floors = [
{ floor: 2, type: 'enemy', enemyIds: ['skeleton'], branch: { enemyIds: ['zombie'], label: 'Zombie Brute' } },
{ floor: 3, type: 'enemy', enemyIds: ['cultist'] },
{ floor: 4, type: 'event', enemyIds: [] },
{ floor: 5, type: 'shop', enemyIds: [] },
{ floor: 6, type: 'enemy', enemyIds: ['ratSwarm'] },
{ floor: 7, type: 'rest', enemyIds: [] },
{ floor: 8, type: 'enemy', enemyIds: ['skeleton', 'cultist'], branch: { enemyIds: ['zombie'], label: 'Zombie Brute' } },
{ floor: 9, type: 'elite', enemyIds: ['tombGuardian'], branch: { enemyIds: ['skeleton', 'ratSwarm'], label: 'Skeleton + Rat' } },
{ floor: 10, type: 'event', enemyIds: [] },
{ floor: 11, type: 'enemy', enemyIds: ['cultist', 'ratSwarm'] },
{ floor: 12, type: 'elite', enemyIds: ['bloodWitch'], branch: { enemyIds: ['zombie', 'skeleton'], label: 'Zombie + Skeleton' } },
{ floor: 13, type: 'enemy', enemyIds: ['cultist', 'cultist'] },
{ floor: 14, type: 'rest', enemyIds: [] },
{ floor: 15, type: 'boss', enemyIds: ['lichKing'] },
];
exports.act2Floors = [
{ floor: 2, type: 'enemy', enemyIds: ['wraith'], branch: { enemyIds: ['corruptedBeast'], label: 'Corrupted Beast' } },
{ floor: 3, type: 'enemy', enemyIds: ['thornedHorror'] },
{ floor: 4, type: 'event', enemyIds: [] },
{ floor: 5, type: 'shop', enemyIds: [] },
{ floor: 6, type: 'enemy', enemyIds: ['witchDoctor'] },
{ floor: 7, type: 'rest', enemyIds: [] },
{ floor: 8, type: 'enemy', enemyIds: ['wraith', 'thornedHorror'], branch: { enemyIds: ['corruptedBeast'], label: 'Corrupted Beast' } },
{ floor: 9, type: 'elite', enemyIds: ['treantProtector'], branch: { enemyIds: ['wraith', 'thornedHorror'], label: 'Wraith + Thorn' } },
{ floor: 10, type: 'event', enemyIds: [] },
{ floor: 11, type: 'enemy', enemyIds: ['corruptedBeast', 'witchDoctor'] },
{ floor: 12, type: 'elite', enemyIds: ['plagueShaman'], branch: { enemyIds: ['corruptedBeast', 'wraith'], label: 'Beast + Wraith' } },
{ floor: 13, type: 'enemy', enemyIds: ['witchDoctor', 'witchDoctor'] },
{ floor: 14, type: 'rest', enemyIds: [] },
{ floor: 15, type: 'boss', enemyIds: ['eldritchTreant'] },
];
exports.act3Floors = [
{ floor: 2, type: 'enemy', enemyIds: ['hellhound'], branch: { enemyIds: ['shadowDemon'], label: 'Shadow Demon' } },
{ floor: 3, type: 'enemy', enemyIds: ['abyssalCultist'] },
{ floor: 4, type: 'event', enemyIds: [] },
{ floor: 5, type: 'shop', enemyIds: [] },
{ floor: 6, type: 'enemy', enemyIds: ['infernalImp'] },
{ floor: 7, type: 'rest', enemyIds: [] },
{ floor: 8, type: 'enemy', enemyIds: ['hellhound', 'abyssalCultist'], branch: { enemyIds: ['infernalImp'], label: 'Infernal Imp' } },
{ floor: 9, type: 'elite', enemyIds: ['flamespawn'], branch: { enemyIds: ['hellhound', 'infernalImp'], label: 'Hellhound + Imp' } },
{ floor: 10, type: 'event', enemyIds: [] },
{ floor: 11, type: 'enemy', enemyIds: ['shadowDemon', 'abyssalCultist'] },
{ floor: 12, type: 'elite', enemyIds: ['dreadKnight'], branch: { enemyIds: ['hellhound', 'shadowDemon'], label: 'Hellhound + Demon' } },
{ floor: 13, type: 'enemy', enemyIds: ['abyssalCultist', 'abyssalCultist'] },
{ floor: 14, type: 'rest', enemyIds: [] },
{ floor: 15, type: 'boss', enemyIds: ['archdemon'] },
];
function getEnemyData(act, enemyId) {
if (act === 1)
return exports.act1Enemies[enemyId];
if (act === 2)
return exports.act2Enemies[enemyId];
if (act === 3)
return exports.act3Enemies[enemyId];
return undefined;
}
};
// ── module: src/engine.ts ──
__mods["src/engine.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createInitialState = createInitialState;
exports.startRun = startRun;
exports.beginCombat = beginCombat;
exports.getBuffAmount = getBuffAmount;
exports.getDebuffAmount = getDebuffAmount;
exports.playCard = playCard;
exports.startPlayerTurn = startPlayerTurn;
exports.processEnemyTurn = processEnemyTurn;
exports.endPlayerTurn = endPlayerTurn;
const cards_1 = require("./cards");
const data_1 = require("./data");
let instanceCounter = 0;
function cardInstance(card) {
return { ...card, instanceId: `inst_${++instanceCounter}` };
}
let nodeCounter = 0;
function nodeUid() { return `node_${++nodeCounter}`; }
// Deep copy helpers
function cloneCard(c) { return { ...c, id: `card_${++instanceCounter}` }; }
function cloneCardInHand(c) { return { ...c, instanceId: `inst_${++instanceCounter}` }; }
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function createInitialState() {
return {
screen: 'title',
player: createEmptyPlayer('Blood Reaper'),
act: 1,
floor: 1,
mapNodes: [],
currentEnemy: null,
enemies: [],
turn: 0,
isPlayerTurn: true,
selectedCard: null,
hoveredCard: null,
combatLog: [],
pendingRewards: { gold: 0, cardChoices: [] },
shopItems: { cards: [], relics: [], potions: [] },
shopRemoveAvailable: false,
currentEvent: null,
restAction: null,
forgeCards: [],
meditateCards: [],
bossRelicChoices: [],
animation: null,
mapBranches: [],
};
}
function createEmptyPlayer(cls) {
return {
cls: cls,
maxHp: 70,
hp: 70,
block: 0,
buffs: [],
debuffs: [],
drawPile: [],
discardPile: [],
hand: [],
power: [],
energy: 3,
maxEnergy: 3,
gold: 99,
relics: [],
potions: [],
deck: [],
};
}
function startRun(cls) {
const deck = (0, cards_1.getClassStartingDeck)(cls);
const p = {
cls,
maxHp: 70,
hp: 70,
block: 0,
buffs: [],
debuffs: [],
drawPile: [],
discardPile: [],
hand: [],
power: [],
energy: 3,
maxEnergy: 3,
gold: 99,
relics: [],
potions: [],
deck,
};
const mapNodes = generateMap(1);
return {
screen: 'map',
player: p,
act: 1,
floor: 1,
mapNodes,
currentEnemy: null,
enemies: [],
turn: 0,
isPlayerTurn: true,
selectedCard: null,
hoveredCard: null,
combatLog: [],
pendingRewards: { gold: 0, cardChoices: [] },
shopItems: { cards: [], relics: [], potions: [] },
shopRemoveAvailable: false,
currentEvent: null,
restAction: null,
forgeCards: [],
meditateCards: [],
bossRelicChoices: [],
animation: null,
mapBranches: [],
};
}
// Map generation
function generateMap(act) {
const floorsData = act === 1 ? data_1.act1Floors : act === 2 ? data_1.act2Floors : data_1.act3Floors;
const nodes = [];
const branchMap = [];
let prevNodeIds = ['start'];
const cols = [300, 420, 540, 360]; // x positions for branching
floorsData.forEach((fd, idx) => {
const y = 680 - idx * 42;
const node = {
floor: fd.floor,
type: fd.type,
x: cols[idx % cols.length],
y,
completed: false,
selected: false,
enemyIds: fd.enemyIds,
eventId: idx % data_1.eventPool.length,
id: nodeUid(),
};
nodes.push(node);
// Connect previous nodes to this one
prevNodeIds.forEach(pid => {
branchMap.push({ fromId: pid, toIds: [node.id] });
});
prevNodeIds = [node.id];
});
// Store branch map for later
return nodes;
}
// Start combat
function beginCombat(state, enemyIds) {
const enemies = [];
const actEnemyPool = state.act === 1 ? data_1.act1Floors : state.act === 2 ? data_1.act2Floors : data_1.act3Floors;
for (const eid of enemyIds) {
const data = (0, data_1.getEnemyData)(state.act, eid);
if (!data)
continue;
const isBoss = data.baseEnemy.maxHp === 65 || data.baseEnemy.maxHp === 75 || data.baseEnemy.maxHp === 85;
enemies.push({
id: eid,
name: data.baseEnemy.name,
maxHp: data.baseEnemy.maxHp,
hp: data.baseEnemy.maxHp,
block: 0,
buffs: [],
debuffs: [],
intent: data.intentPattern[0],
intentIndex: 0,
intentPattern: data.intentPattern,
isBoss,
isMinion: false,
sprite: data.baseEnemy.sprite || 'skeleton',
act: state.act,
});
}
// Init combat deck
const drawPile = shuffle(enemies.reduce((acc, _e) => {
const powerCards = state.player.deck.filter(c => c.type === 'Power');
const nonPowerCards = state.player.deck.filter(c => c.type !== 'Power');
return acc;
}, []));
const pDeck = state.player.deck;
const powerCards = pDeck.filter(c => c.type === 'Power').map(cardInstance);
const otherCards = pDeck.filter(c => c.type !== 'Power').map(cardInstance);
const shuffled = shuffle(otherCards);
const newState = {
...state,
enemies,
screen: 'combat',
turn: 1,
isPlayerTurn: true,
player: {
...state.player,
block: 0,
energy: 3,
maxEnergy: 3,
drawPile: shuffled,
discardPile: [],
hand: [],
power: powerCards,
},
combatLog: ['Combat begins!'],
selectedCard: null,
hoveredCard: null,
animation: null,
};
// Relic effects at start of combat
// Shadow Cloak
if (newState.player.relics.find(r => r.id === 'shadowCloak')) {
newState.player.buffs.push({ type: 'intangible', amount: 1, isDebuff: false, turnsRemaining: 1 });
}
// Cursed Amulet
if (newState.player.relics.find(r => r.id === 'cursedAmulet')) {
newState.enemies.forEach(e => addDebuff(e, 'weak', 1, 99));
}
// Demon Horns
if (newState.player.relics.find(r => r.id === 'demonHorns')) {
const imp = cardInstance((0, cards_1.createImpClaw)(false));
newState.player.hand.push(imp);
}
// Philosopher's Stone
if (newState.player.relics.find(r => r.id === 'philosopherStone')) {
newState.enemies.forEach(e => addBuff(e, 'strength', 1));
}
// Black Blood
if (newState.player.relics.find(r => r.id === 'blackBlood')) {
const loss = Math.floor(newState.player.maxHp * 0.1);
newState.player.hp = Math.max(1, newState.player.hp - loss);
}
// Shadow Amulet
if (newState.player.relics.find(r => r.id === 'shadowAmulet')) {
newState.player.maxHp -= 15;
}
// Draw starting hand
newState.player.hand = drawCards(newState.player, 5);
return newState;
}
function drawCards(player, count) {
const drawn = [];
for (let i = 0; i < count; i++) {
if (player.drawPile.length === 0) {
if (player.discardPile.length === 0)
break;
player.drawPile = shuffle(player.discardPile.filter(c => !c.exhaust));
player.discardPile = [];
}
if (player.drawPile.length > 0) {
drawn.push(player.drawPile.pop());
}
}
return drawn;
}
function getBuffAmount(buffs, type) {
return buffs.filter(b => b.type === type).reduce((s, b) => s + b.amount, 0);
}
function getDebuffAmount(debuffs, type) {
return debuffs.filter(d => d.type === type).reduce((s, d) => s + d.amount, 0);
}
function addDebuff(enemy, type, amount, duration) {
const existing = enemy.debuffs.find(d => d.type === type);
if (existing) {
existing.amount += amount;
if (duration)
existing.turnsRemaining = duration;
}
else {
enemy.debuffs.push({ type, amount, isDebuff: true, turnsRemaining: duration });
}
}
function addBuff(enemy, type, amount) {
const existing = enemy.buffs.find(b => b.type === type);
if (existing) {
existing.amount += amount;
}
else {
enemy.buffs.push({ type, amount, isDebuff: false, duration: 0 });
}
}
// Apply card effects
function playCard(state, card, targetEnemyIdx) {
if (card.cost > 0 && state.player.energy < card.cost) {
return state;
}
const newState = { ...state };
newState.player = { ...state.player, energy: state.player.energy - card.cost };
newState.combatLog = [...newState.combatLog, `Played ${card.name}`];
// Tome of Dark Rituals
if (card.type === 'Power' && newState.player.relics.find(r => r.id === 'tomeDark')) {
const drawn = drawCards(newState.player, 1);
newState.player.hand.push(...drawn);
}
// Process card effects
processCardEffect(newState, card, targetEnemyIdx);
// Black Blood: heal when playing Attack
if (card.type === 'Attack' && newState.player.relics.find(r => r.id === 'blackBlood')) {
newState.player.hp = Math.min(newState.player.maxHp, newState.player.hp + 1);
}
// Exhaust check
if (card.exhaust || card.ethereal) {
const hIdx = newState.player.hand.findIndex(h => h.instanceId === card.instanceId);
if (hIdx >= 0)
newState.player.hand.splice(hIdx, 1);
// Don't add to discard
}
else {
const hIdx = newState.player.hand.findIndex(h => h.instanceId === card.instanceId);
if (hIdx >= 0) {
newState.player.hand.splice(hIdx, 1);
newState.player.discardPile.push(card);
}
}
// Check for enemy deaths
checkEnemyDeaths(newState);
return newState;
}
function processCardEffect(state, card, targetEnemyIdx) {
const enemies = state.enemies;
const target = targetEnemyIdx >= 0 && targetEnemyIdx < enemies.length ? enemies[targetEnemyIdx] : null;
const name = card.name;
const up = card.upgraded;
const playerLostHP = (amount) => {
const intangible = state.player.buffs.find(b => b.type === 'intangible' && b.turnsRemaining && b.turnsRemaining > 0);
const actual = intangible ? 1 : amount;
state.player.hp = Math.max(0, state.player.hp - actual);
// Bloodstone
if (state.player.relics.find(r => r.id === 'bloodstone')) {
state.player.hp = Math.min(state.player.maxHp, state.player.hp + 1);
}
// Blood Pact
const bp = state.player.deck.find(c => c.name === 'Blood Pact');
if (bp) {
const bpUp = bp.upgraded;
state.player.buffs.push({ type: 'strength', amount: bpUp ? 2 : 1, isDebuff: false, duration: 0 });
}
};
// === ATTACKS ===
const dealDamage = (enemy, dmg) => {
const weakAmt = getDebuffAmount(enemy.debuffs, 'weak');
const vulnAmt = getDebuffAmount(enemy.debuffs, 'vulnerable');
let finalDmg = dmg;
if (vulnAmt > 0)
finalDmg = Math.floor(finalDmg * Math.pow(1.5, vulnAmt));
// Soul Ember
if (state.player.relics.find(r => r.id === 'soulEmber') && card.name.toLowerCase().includes('burn')) {
finalDmg += 2;
}
const blocked = Math.min(finalDmg, enemy.block);
enemy.block -= blocked;
enemy.hp = Math.max(0, enemy.hp - (finalDmg - blocked));
state.combatLog.push(`${enemy.name} takes ${finalDmg - (enemy.block < 0 ? 0 : blocked)} dmg`);
enemy.block = Math.max(0, enemy.block);
};
const applyBleed = (enemy, amt) => {
addDebuff(enemy, 'bleed', amt, 0);
};
const applyBurn = (enemy, amt) => {
addDebuff(enemy, 'burn', amt, 0);
// Soul Ember
if (state.player.relics.find(r => r.id === 'soulEmber')) {
dealDamage(enemy, 2);
}
};
const applyWeak = (enemy, amt) => addDebuff(enemy, 'weak', amt, 99);
const applyVulnerable = (enemy, amt) => addDebuff(enemy, 'vulnerable', amt, 99);
const targetOrFirst = target || (enemies.length > 0 ? enemies[0] : null);
if (name === 'Strike') {
if (!targetOrFirst)
return;
const dmg = up ? 8 : 6;
dealDamage(targetOrFirst, dmg);
}
else if (name === 'Defend') {
state.player.block += up ? 8 : 5;
}
else if (name === 'Sanguine Strike') {
if (!targetOrFirst)
return;
dealDamage(targetOrFirst, up ? 10 : 8);
state.player.hp = Math.min(state.player.maxHp, state.player.hp + (up ? 3 : 2));
}
else if (name === 'Hemorrhage') {
if (!targetOrFirst)
return;
dealDamage(targetOrFirst, up ? 6 : 5);
applyBleed(targetOrFirst, up ? 4 : 3);
}
else if (name === 'Bloodletting') {
if (!targetOrFirst)
return;
playerLostHP(up ? 2 : 3);
dealDamage(targetOrFirst, up ? 15 : 12);
}
else if (name === 'Crimson Eruption') {
playerLostHP(2);
enemies.forEach(e => dealDamage(e, up ? 10 : 8));
}
else if (name === 'Crimson Shield') {
state.player.block += up ? 8 : 6;
playerLostHP(2);
}
else if (name === 'Blood Ward') {
state.player.block += up ? 4 : 3;
state.player.buffs.push({ type: 'regeneration', amount: up ? 2 : 1, isDebuff: false, duration: 0 });
}
else if (name === 'Sacrificial Barrier') {
state.player.block += up ? 13 : 10;
playerLostHP(5);
}
else if (name === 'Vital Surge') {
const missing = state.player.maxHp - state.player.hp;
state.player.block += missing + (up ? 3 : 0);
}
else if (name === 'Life Tap') {
const drawn = drawCards(state.player, up ? 3 : 2);
state.player.hand.push(...drawn);
playerLostHP(3);
}
else if (name === 'Rend Flesh') {
playerLostHP(2);
enemies.forEach(e => applyBleed(e, up ? 7 : 5));
}
else if (name === 'Soul Siphon') {
// Power card - already placed, effect happens at start of turn
}
else if (name === 'Blood Pact') {
// Power card
}
else if (name === 'Shadow Bolt') {
if (!targetOrFirst)
return;
dealDamage(targetOrFirst, up ? 8 : 6);
applyWeak(targetOrFirst, up ? 2 : 1);
}
else if (name === 'Grasp of Despair') {
if (!targetOrFirst)
return;
dealDamage(targetOrFirst, up ? 5 : 4);
applyVulnerable(targetOrFirst, up ? 2 : 1);
}
else if (name === 'Nightmare Lash') {
if (!targetOrFirst)
return;
let dmg = up ? 9 : 7;
const hasWeak = getDebuffAmount(targetOrFirst.debuffs, 'weak') > 0;
if (hasWeak)
dmg += up ? 9 : 7;
dealDamage(targetOrFirst, dmg);
}
else if (name === 'Eternal Darkness') {
const dmg = up ? 4 : 3;
for (let i = 0; i < 3; i++) {
const t = pick(enemies);
if (t)
dealDamage(t, dmg);
}
}
else if (name === 'Cloak of Shadows') {
state.player.block += up ? 7 : 5;
const drawn = drawCards(state.player, 1);
state.player.hand.push(...drawn);
}
else if (name === 'Haunting Mist') {
state.player.block += up ? 9 : 7;
enemies.forEach(e => applyWeak(e, 1));
}
else if (name === 'Phantom Armor') {
state.player.block += up ? 10 : 8;
state.player.buffs.push({ type: 'dexterity', amount: up ? 3 : 2, isDebuff: false, turnsRemaining: 1 });
}
else if (name === 'Whispering Shroud') {
state.player.block += up ? 5 : 4;
state.player.buffs.push({ type: 'block_next', amount: up ? 5 : 4, isDebuff: false, turnsRemaining: 1 });
}
else if (name === 'Shadowform') {
state.player.buffs.push({ type: 'intangible', amount: up ? 3 : 2, isDebuff: false, turnsRemaining: up ? 3 : 2 });
}
else if (name === 'Curse of Misfortune') {
if (!targetOrFirst)
return;
applyVulnerable(targetOrFirst, up ? 2 : 1);
// Temp strength loss this turn - apply as debuff with 1 duration
const tmpDebuff = targetOrFirst.debuffs.find(d => d.type === 'weak' && d.turnsRemaining !== undefined && d.turnsRemaining <= 1);
if (tmpDebuff)
tmpDebuff.amount += up ? 4 : 3;
else
targetOrFirst.debuffs.push({ type: 'weak', amount: up ? 4 : 3, isDebuff: true, turnsRemaining: 1 });
}
else if (name === 'Dark Ritual') {
state.player.energy += 2;
state.player.discardPile.push(cardInstance(cards_1.DAZED));
}
else if (name === 'Umbral Mastery') {
// Power - handled at turn start
}
else if (name === 'Firebolt') {
if (!targetOrFirst)
return;
dealDamage(targetOrFirst, up ? 9 : 7);
applyBurn(targetOrFirst, up ? 3 : 2);
}
else if (name === 'Hellfire Blast') {
enemies.forEach(e => dealDamage(e, up ? 12 : 9));
}
else if (name === 'Searing Strike') {
if (!targetOrFirst)
return;
dealDamage(targetOrFirst, up ? 8 : 6);
state.player.buffs.push({ type: 'energy_next', amount: 1, isDebuff: false, turnsRemaining: 1 });
}
else if (name === 'Immolate') {
if (!targetOrFirst)
return;
dealDamage(targetOrFirst, up ? 16 : 12);
}
else if (name === 'Obsidian Skin') {
state.player.block += up ? 10 : 7;
}
else if (name === 'Flame Ward') {
state.player.block += up ? 6 : 5;
const t = pick(enemies);
if (t)
applyBurn(t, up ? 3 : 2);
}
else if (name === 'Demonic Resilience') {
state.player.block += up ? 5 : 4;
state.player.hp = Math.min(state.player.maxHp, state.player.hp + (up ? 3 : 2));
}
else if (name === 'Brimstone Shield') {
state.player.block += up ? 4 : 3;
state.player.buffs.push({ type: 'strength', amount: up ? 3 : 2, isDebuff: false, turnsRemaining: 1 });
}
else if (name === 'Summon Imp') {
const imp = cardInstance((0, cards_1.createImpClaw)(up));
state.player.hand.push(imp);
}
else if (name === 'Infernal Pact') {
playerLostHP(up ? 1 : 2);
state.player.energy += 2;
const imp = cardInstance((0, cards_1.createImpClaw)(up));
state.player.hand.push(imp);
}
else if (name === 'Hellfire Aura') {
// Power - handled at turn start
}
else if (name === 'Soul Burn') {
if (!targetOrFirst)
return;
applyBurn(targetOrFirst, up ? 10 : 8);
const drawn = drawCards(state.player, 1);
state.player.hand.push(...drawn);
}
else if (name === 'Flash of Insight') {
const drawn = drawCards(state.player, up ? 3 : 2);
state.player.hand.push(...drawn);
}
else if (name === 'Iron Will') {
state.player.block += up ? 11 : 8;
}
else if (name === 'Quick Slash') {
if (!targetOrFirst)
return;
dealDamage(targetOrFirst, up ? 9 : 7);
}
else if (name === 'Potent Brew') {
state.player.energy += up ? 2 : 1;
}
else if (name === 'Imp Claw' || name === 'Imp Claw+') {
if (!targetOrFirst)
return;
dealDamage(targetOrFirst, up ? 6 : 4);
}
}
function checkEnemyDeaths(state) {
const alive = state.enemies.filter(e => e.hp > 0);
const dead = state.enemies.filter(e => e.hp <= 0);
dead.forEach(e => state.combatLog.push(`${e.name} is defeated!`));
state.enemies = alive;
}
// Start player turn
function startPlayerTurn(state) {
const ns = { ...state };
ns.turn++;
ns.isPlayerTurn = true;
ns.player = { ...state.player, block: 0 };
// Draw 5 cards
const drawn = drawCards(ns.player, 5);
ns.player.hand.push(...drawn);
// Energy
let energy = 3;
// Cursed Crown
if (ns.player.relics.find(r => r.id === 'cursedCrown'))
energy++;
// Philosopher's Stone
if (ns.player.relics.find(r => r.id === 'philosopherStone'))
energy++;
// Energy next turn buff
const energyNext = ns.player.buffs.filter(b => b.type === 'energy_next');
energyNext.forEach(b => energy += b.amount);
ns.player.energy = energy;
// Shadow Amulet intangible
if (ns.player.relics.find(r => r.id === 'shadowAmulet')) {
ns.player.buffs.push({ type: 'intangible', amount: 1, isDebuff: false, turnsRemaining: 1 });
}
// Process power cards
// Soul Siphon
if (ns.player.power.find(p => p.name === 'Soul Siphon')) {
const cost = ns.player.power.find(p => p.name === 'Soul Siphon').upgraded ? 1 : 2;
ns.player.energy += 1;
// Lose 1 HP (simplified)
const intangible = ns.player.buffs.find(b => b.type === 'intangible');
ns.player.hp = Math.max(0, ns.player.hp - (intangible ? 1 : 1));
}
// Umbral Mastery
if (ns.player.power.find(p => p.name === 'Umbral Mastery')) {
const amt = ns.player.power.find(p => p.name === 'Umbral Mastery').upgraded ? 2 : 1;
ns.enemies.forEach(e => {
const ex = e.debuffs.find(d => d.type === 'weak');
if (ex)
ex.amount += amt;
else
e.debuffs.push({ type: 'weak', amount: amt, isDebuff: true, turnsRemaining: 99 });
});
}
// Hellfire Aura
if (ns.player.power.find(p => p.name === 'Hellfire Aura')) {
const dmg = ns.player.power.find(p => p.name === 'Hellfire Aura').upgraded ? 5 : 3;
ns.enemies.forEach(e => {
const blocked = Math.min(dmg, e.block);
e.block -= blocked;
e.hp = Math.max(0, e.hp - (dmg - blocked));
});
}
// Bleeding Edge relic
if (ns.player.relics.find(r => r.id === 'bleedingEdge')) {
const target = pick(ns.enemies);
if (target) {
const ex = target.debuffs.find(d => d.type === 'bleed');
if (ex)
ex.amount += 1;
else
target.debuffs.push({ type: 'bleed', amount: 1, isDebuff: true, turnsRemaining: 0 });
}
}
// Process debuffs on player at start of turn
// Bleed
const bleed = ns.player.debuffs.find(d => d.type === 'bleed');
if (bleed && bleed.amount > 0) {
ns.player.hp = Math.max(0, ns.player.hp - bleed.amount);
bleed.amount = Math.floor(bleed.amount / 2);
}
// Poison
const poison = ns.player.debuffs.find(d => d.type === 'poison');
if (poison && poison.amount > 0) {
ns.player.hp = Math.max(0, ns.player.hp - poison.amount);
poison.amount = Math.max(0, poison.amount - 1);
}
// Process burn at end of turn on player
const burn = ns.player.debuffs.find(d => d.type === 'burn');
if (burn && burn.amount > 0) {
ns.player.hp = Math.max(0, ns.player.hp - burn.amount);
burn.amount = Math.floor(burn.amount / 2);
}
// Regen
const regen = ns.player.buffs.find(b => b.type === 'regeneration');
if (regen && regen.amount > 0) {
ns.player.hp = Math.min(ns.player.maxHp, ns.player.hp + regen.amount);
regen.amount = Math.max(0, regen.amount - 1);
}
// Decrement duration buffs/debuffs
ns.player.buffs.forEach(b => {
if (b.turnsRemaining !== undefined && b.turnsRemaining > 0) {
if (b.type === 'intangible') {
b.turnsRemaining--;
}
else if (b.type !== 'regeneration' && b.type !== 'energy_next' && b.type !== 'block_next') {
b.turnsRemaining--;
}
}
});
ns.player.debuffs.forEach(d => {
if (d.turnsRemaining !== undefined && d.turnsRemaining > 0) {
d.turnsRemaining--;
}
});
// Remove expired
ns.player.buffs = ns.player.buffs.filter(b => {
if (b.type === 'regeneration')
return b.amount > 0;
if (b.type === 'intangible')
return b.turnsRemaining !== undefined && b.turnsRemaining > 0;
if (b.type === 'energy_next' || b.type === 'block_next')
return false; // consumed
if (b.type === 'dexterity')
return b.turnsRemaining !== undefined && b.turnsRemaining > 0;
return true;
});
ns.player.debuffs = ns.player.debuffs.filter(d => !(d.turnsRemaining !== undefined && d.turnsRemaining <= 0 && d.type !== 'bleed' && d.type !== 'burn'));
// Keep bleed/burn until 0 amount
ns.player.debuffs = ns.player.debuffs.filter(d => {
if ((d.type === 'bleed' || d.type === 'burn'))
return d.amount > 0;
return true;
});
// Block next turn
const blockNext = ns.player.buffs.find(b => b.type === 'block_next');
if (blockNext) {
ns.player.block += blockNext.amount;
}
// Eternal Hourglass
if (ns.player.relics.find(r => r.id === 'eternalHourglass') && ns.player.hand.length === 0) {
ns.player.energy += 1;
}
// Damped Turn (if applicable) - skipped for now
checkEnemyDeaths(ns);
return ns;
}
// Process enemy turn
function processEnemyTurn(state) {
const ns = { ...state };
ns.isPlayerTurn = false;
for (const enemy of ns.enemies) {
const intent = enemy.intent;
// Process enemy buffs/debuffs at start of their turn
// Bleed/Burn on enemy
const eBleed = enemy.debuffs.find(d => d.type === 'bleed');
if (eBleed && eBleed.amount > 0) {
enemy.hp = Math.max(0, enemy.hp - eBleed.amount);
eBleed.amount = Math.floor(eBleed.amount / 2);
}
const eBurn = enemy.debuffs.find(d => d.type === 'burn');
if (eBurn && eBurn.amount > 0) {
enemy.hp = Math.max(0, enemy.hp - eBurn.amount);
eBurn.amount = Math.floor(eBurn.amount / 2);
}
// Regen
const eRegen = enemy.buffs.find(b => b.type === 'regeneration');
if (eRegen && eRegen.amount > 0) {
enemy.hp = Math.min(enemy.maxHp, enemy.hp + eRegen.amount);
eRegen.amount = Math.max(0, eRegen.amount - 1);
}
// Decrement debuff durations
enemy.debuffs.forEach(d => {
if (d.turnsRemaining !== undefined && d.turnsRemaining > 0 && d.type !== 'bleed' && d.type !== 'burn') {
d.turnsRemaining--;
}
});
enemy.debuffs = enemy.debuffs.filter(d => {
if ((d.type === 'bleed' || d.type === 'burn'))
return d.amount > 0;
if (d.turnsRemaining !== undefined && d.turnsRemaining <= 0)
return false;
return true;
});
enemy.block = 0;
// Boss phase handling
if (enemy.isBoss) {
if (enemy.name === 'Lich King') {
if (enemy.hp <= 30) {
// Phase 2
enemy.buffs.push({ type: 'strength', amount: 2, isDebuff: false, turnsRemaining: 1 });
// Use phase 2 pattern
const p2Pattern = [
{ type: 'attack', damage: 10, text: 'Attack 10' },
{ type: 'attack', damage: 10, text: 'Attack 10' },
{ type: 'defend', block: 15, text: 'Defend 15' },
];
enemy.intentPattern = p2Pattern;
}
else {
// Phase 1: check minion count for summon
if (intent.type === 'summon') {
const minionCount = ns.enemies.filter(e => e.isMinion).length;
if (minionCount >= 2) {
// Skip summon, attack instead
intent.damage = 8;
intent.type = 'attack';
}
}
}
}
else if (enemy.name === 'Eldritch Treant') {
if (enemy.hp <= 40) {
const p2Pattern = [
{ type: 'attack', damage: 14, text: 'Attack 14+1V', applyDebuffs: [{ type: 'vulnerable', amount: 1, target: 'player' }] },
{ type: 'defend', block: 20, text: 'Defend 20+Heal 5' },
{ type: 'attack', damage: 14, text: 'Attack 14+1V', applyDebuffs: [{ type: 'vulnerable', amount: 1, target: 'player' }] },
];
enemy.intentPattern = p2Pattern;
enemy.buffs.push({ type: 'strength', amount: 1, isDebuff: false, turnsRemaining: 1 });
}
}
else if (enemy.name === 'Archdemon') {
if (enemy.hp <= 45) {
const p2Pattern = [
{ type: 'attack', damage: 16, text: 'Attack 16' },
{ type: 'multiHit', damage: 7, hits: 3, text: '3x7 Multi' },
{ type: 'attack', damage: 16, text: 'Attack 16' },
];
enemy.intentPattern = p2Pattern;
enemy.buffs.push({ type: 'strength', amount: 2, isDebuff: false, turnsRemaining: 1 });
}
}
}
// Calculate actual damage with enemy strength
let enemyStrength = getBuffAmount(enemy.buffs, 'strength');
// Temp strength debuff
const strLoss = enemy.debuffs.find(d => d.type === 'strength' && d.isDebuff);
if (strLoss)
enemyStrength -= strLoss.amount;
// Intangible on player
const intangible = ns.player.buffs.find(b => b.type === 'intangible' && b.turnsRemaining && b.turnsRemaining > 0);
// Dexterity
const dexterity = getBuffAmount(ns.player.buffs, 'dexterity');
// Execute intent
switch (intent.type) {
case 'attack': {
const dmg = (intent.damage || 0) + enemyStrength;
if (intent.block)
enemy.block += intent.block + dexterity;
let finalDmg = dmg;
if (intangible)
finalDmg = 1;
const blocked = Math.min(finalDmg, ns.player.block);
ns.player.block -= blocked;
ns.player.hp = Math.max(0, ns.player.hp - (finalDmg - blocked));
ns.player.block = Math.max(0, ns.player.block);
ns.combatLog.push(`${enemy.name} attacks for ${dmg} (blocked ${blocked})`);
break;
}
case 'multiHit': {
const hits = intent.hits || 1;
const dmgPerHit = (intent.damage || 0) + enemyStrength;
if (intent.block)
enemy.block += intent.block + dexterity;
for (let h = 0; h < hits; h++) {
let finalDmg = dmgPerHit;
if (intangible)
finalDmg = 1;
const blocked = Math.min(finalDmg, ns.player.block);
ns.player.block -= blocked;
ns.player.hp = Math.max(0, ns.player.hp - (finalDmg - blocked));
ns.player.block = Math.max(0, ns.player.block);
}
ns.combatLog.push(`${enemy.name} hits ${hits}x${dmgPerHit}`);
break;
}
case 'defend': {
enemy.block += (intent.block || 0) + dexterity;
ns.combatLog.push(`${enemy.name} gains ${intent.block} Block`);
if (enemy.name === 'Dread Knight' && enemy.block) {
enemy.hp = Math.min(enemy.maxHp, enemy.hp + 4);
}
if (enemy.name === 'Eldritch Treant' && enemy.hp <= 40) {
enemy.hp = Math.min(enemy.maxHp, enemy.hp + 5);
}
break;
}
case 'buff': {
if (intent.buffSelf) {
intent.buffSelf.forEach(b => {
const existing = enemy.buffs.find(x => x.type === b.type);
if (existing)
existing.amount += b.amount;
else
enemy.buffs.push({ ...b, isDebuff: false, duration: 0 });
});
}
if (intent.block)
enemy.block += intent.block + dexterity;
ns.combatLog.push(`${enemy.name} buffs self`);
break;
}
case 'debuff': {
if (intent.applyDebuffs) {
intent.applyDebuffs.forEach(ad => {
if (ad.target === 'player') {
const existing = ns.player.debuffs.find(d => d.type === ad.type);
if (existing) {
existing.amount += ad.amount;
if (ad.type === 'bleed' || ad.type === 'burn') { /* persist */ }
}
else {
ns.player.debuffs.push({ type: ad.type, amount: ad.amount, isDebuff: true, turnsRemaining: ad.type === 'bleed' || ad.type === 'burn' ? undefined : 99 });
}
}
});
}
ns.combatLog.push(`${enemy.name} applies debuffs`);
break;
}
case 'summon': {
if (intent.summonEnemy) {
const summonData = (0, data_1.getEnemyData)(enemy.act, intent.summonEnemy);
if (summonData) {
const newEnemy = {
id: `enemy_${intent.summonEnemy}_${instanceCounter}`,
name: summonData.baseEnemy.name,
maxHp: summonData.baseEnemy.maxHp,
hp: summonData.baseEnemy.maxHp,
block: 0,
buffs: [],
debuffs: [],
intent: summonData.intentPattern[0],
intentIndex: 0,
intentPattern: summonData.intentPattern,
isBoss: false,
isMinion: true,
sprite: summonData.baseEnemy.sprite || 'skeleton',
act: enemy.act,
};
ns.enemies.push(newEnemy);
ns.combatLog.push(`${enemy.name} summons ${newEnemy.name}`);
}
}
break;
}
case 'drain': {
const dmg = (intent.damage || 0) + enemyStrength;
let finalDmg = dmg;
if (intangible)
finalDmg = 1;
const blocked = Math.min(finalDmg, ns.player.block);
ns.player.block -= blocked;
ns.player.hp = Math.max(0, ns.player.hp - (finalDmg - blocked));
ns.player.block = Math.max(0, ns.player.block);
enemy.hp = Math.min(enemy.maxHp, enemy.hp + (intent.heal || 0));
ns.combatLog.push(`${enemy.name} drains for ${dmg}, heals ${intent.heal}`);
break;
}
}
// Advance intent
enemy.intentIndex = (enemy.intentIndex + 1) % enemy.intentPattern.length;
enemy.intent = enemy.intentPattern[enemy.intentIndex];
}
checkEnemyDeaths(ns);
return ns;
}
// End player turn
function endPlayerTurn(state) {
const ns = { ...state };
// Process Doubt in hand
ns.player.hand.forEach(card => {
if (card.name === 'Doubt') {
ns.player.hp = Math.max(0, ns.player.hp - 1);
}
});
// Discard hand
ns.player.discardPile.push(...ns.player.hand);
ns.player.hand = [];
ns.player.block = 0;
return ns;
}
};
// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.resize = resize;
exports.renderTitleScreen = renderTitleScreen;
exports.renderClassSelect = renderClassSelect;
exports.renderMapScreen = renderMapScreen;
exports.renderCombatScreen = renderCombatScreen;
exports.renderRestScreen = renderRestScreen;
exports.renderShopScreen = renderShopScreen;
exports.renderEventScreen = renderEventScreen;
exports.renderCardRewardScreen = renderCardRewardScreen;
exports.renderGameOverScreen = renderGameOverScreen;
exports.renderVictoryScreen = renderVictoryScreen;
exports.renderBossRewardScreen = renderBossRewardScreen;
exports.renderPotionPickScreen = renderPotionPickScreen;
exports.renderScreen = renderScreen;
exports.getW = getW;
exports.getH = getH;
exports.setHoverState = setHoverState;
exports.getHoverState = getHoverState;
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
let W = 960;
let H = 640;
function resize() {
W = window.innerWidth;
H = window.innerHeight;
canvas.width = W;
canvas.height = H;
}
// Colors
const COLORS = {
bg: '#0a0a12',
darkBg: '#12121e',
panel: '#1a1a2e',
panelBorder: '#2a2a4e',
cardBg: '#1e1e3a',
cardBorder: '#3a3a6a',
cardHover: '#2a2a5a',
text: '#e0d8c8',
textDim: '#8a8a9a',
red: '#e04040',
redDark: '#8b0000',
green: '#40c040',
blue: '#4080e0',
gold: '#e0c040',
purple: '#a040e0',
orange: '#e08040',
cyan: '#40c0c0',
white: '#f0f0f0',
attack: '#e04040',
skill: '#40c040',
power: '#4080e0',
curse: '#a040a0',
status: '#808080',
common: '#40a040',
uncommon: '#4080e0',
rare: '#c040c0',
basic: '#808080',
energy: '#e0c040',
hp: '#e04040',
block: '#4080e0',
enemyAttack: '#e04040',
enemyDefend: '#4080e0',
enemyBuff: '#c0c040',
enemyDebuff: '#a040a0',
enemyMulti: '#e08040',
enemySummon: '#c040c0',
enemyDrain: '#a040a0',
};
function getRarityColor(r) {
switch (r) {
case 'Basic': return COLORS.basic;
case 'Common': return COLORS.common;
case 'Uncommon': return COLORS.uncommon;
case 'Rare': return COLORS.rare;
case 'Boss': return COLORS.rare;
default: return COLORS.textDim;
}
}
function getTypeColor(t) {
switch (t) {
case 'Attack': return COLORS.attack;
case 'Skill': return COLORS.skill;
case 'Power': return COLORS.power;
case 'Curse': return COLORS.curse;
case 'Status': return COLORS.status;
default: return COLORS.text;
}
}
function getNodeTypeIcon(type) {
switch (type) {
case 'enemy': return '⚔';
case 'elite': return '⚡';
case 'boss': return '💀';
case 'rest': return '✚';
case 'shop': return '🛒';
case 'event': return '?';
default: return '?';
}
}
function getNodeTypeColor(type) {
switch (type) {
case 'enemy': return '#e06060';
case 'elite': return '#e0c040';
case 'boss': return '#c04040';
case 'rest': return '#40c040';
case 'shop': return '#e0a040';
case 'event': return '#a060c0';
default: return '#808080';
}
}
// Drawing helpers
function drawRect(x, y, w, h, fill, stroke) {
ctx.fillStyle = fill;
ctx.fillRect(x, y, w, h);
if (stroke) {
ctx.strokeStyle = stroke;
ctx.lineWidth = 2;
ctx.strokeRect(x, y, w, h);
}
}
function drawText(text, x, y, size, color, align = 'left', bold = false) {
ctx.font = `${bold ? 'bold ' : ''}${size}px 'Segoe UI', sans-serif`;
ctx.fillStyle = color;
ctx.textAlign = align;
ctx.textBaseline = 'top';
ctx.fillText(text, x, y);
}
function drawCenteredText(text, x, y, size, color, bold = false) {
drawText(text, x, y, size, color, 'center', bold);
}
function drawRoundRect(x, y, w, h, r, fill, stroke) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.fillStyle = fill;
ctx.fill();
if (stroke) {
ctx.strokeStyle = stroke;
ctx.lineWidth = 2;
ctx.stroke();
}
}
function drawHPBar(x, y, w, h, current, max, color) {
drawRect(x, y, w, h, '#1a1a1a', '#333');
const pct = Math.max(0, current / max);
drawRect(x + 1, y + 1, (w - 2) * pct, h - 2, color);
}
// Card rendering
function drawCard(card, x, y, w, h, highlighted = false, canPlay = false) {
const borderColor = canPlay ? '#40e040' : (card.unplayable ? '#604040' : getRarityColor(card.rarity));
drawRoundRect(x, y, w, h, 6, highlighted ? '#2a2a4e' : COLORS.cardBg, borderColor);
// Cost circle
const costStr = card.cost === 0 ? '0' : String(card.cost);
drawRoundRect(x + 4, y + 4, 28, 28, 14, '#1a1a2e', COLORS.energy);
drawCenteredText(costStr, x + 18, y + 8, 16, COLORS.white, true);
// Card name
const nameColor = card.upgraded ? '#ffd700' : (card.unplayable ? '#606060' : COLORS.white);
drawText(card.name, x + 38, y + 8, 12, nameColor, 'left', true);
// Type badge
drawText(card.type, x + 4, y + 36, 9, getTypeColor(card.type));
// Description - word wrap
ctx.save();
ctx.beginPath();
ctx.roundRect(x + 4, y + 50, w - 8, h - 60, 4);
ctx.clip();
const words = card.description.split(' ');
let line = '';
let ly = y + 52;
ctx.font = '11px "Segoe UI", sans-serif';
ctx.fillStyle = card.unplayable ? '#606060' : COLORS.textDim;
ctx.textAlign = 'left';
for (const word of words) {
const test = line + word + ' ';
if (ctx.measureText(test).width > w - 12 && line) {
ctx.fillText(line.trim(), x + 6, ly);
line = word + ' ';
ly += 14;
}
else {
line = test;
}
}
ctx.fillText(line.trim(), x + 6, ly);
ctx.restore();
// Exhaust icon
if (card.exhaust) {
drawText('⟳', x + w - 20, y + 6, 14, '#c0c040');
}
// Unplayable indicator
if (card.unplayable) {
drawText('✕', x + w - 20, y + 6, 14, '#e04040');
}
}
// Screen renderers
function renderTitleScreen(state) {
drawRect(0, 0, W, H, COLORS.bg);
// Ambient particles
const time = Date.now() * 0.001;
for (let i = 0; i < 30; i++) {
const px = (Math.sin(time + i * 1.3) * 0.5 + 0.5) * W;
const py = (Math.cos(time * 0.7 + i * 0.9) * 0.5 + 0.5) * H;
const alpha = 0.15 + Math.sin(time + i) * 0.1;
ctx.fillStyle = `rgba(160, 60, 60, ${alpha})`;
ctx.beginPath();
ctx.arc(px, py, 2 + Math.sin(time * 2 + i) * 1, 0, Math.PI * 2);
ctx.fill();
}
drawCenteredText('SHADOWS OF THE ABYSS', W / 2, H * 0.3, 36, '#c04040', true);
drawCenteredText('A Roguelike Deckbuilder', W / 2, H * 0.3 + 45, 16, '#8a8a9a');
// Click to start
const btnW = 280;
const btnH = 50;
const btnX = W / 2 - btnW / 2;
const btnY = H * 0.55;
const hover = getHoverState() === 'startBtn';
drawRoundRect(btnX, btnY, btnW, btnH, 8, hover ? '#3a3a5e' : '#2a2a4e', hover ? '#e04040' : '#4a4a6e');
drawCenteredText('BEGIN YOUR DESCENT', W / 2, btnY + 15, 16, hover ? '#e0e0e0' : '#c0c0c0', true);
drawCenteredText('Click to start', W / 2, H * 0.75, 13, '#5a5a6a');
}
function renderClassSelect(state) {
drawRect(0, 0, W, H, COLORS.bg);
drawCenteredText('Choose Your Cursed Archetype', W / 2, 40, 22, '#c0c0c0', true);
const classes = [
{ name: 'Blood Reaper', desc: 'Sacrifice HP for power. Life steal, Bleed, and self-harm strategies.', color: '#e04040', icon: '🩸', cls: 'Blood Reaper' },
{ name: 'Shadowmancer', desc: 'Debuff enemies with Weak and Vulnerable. Intangible defense.', color: '#a040e0', icon: '🌑', cls: 'Shadowmancer' },
{ name: 'Hellforged', desc: 'Fire damage, Burn, Imp summoning, and energy manipulation.', color: '#e08040', icon: '🔥', cls: 'Hellforged' },
];
const btnW = 260;
const btnH = 180;
const gap = 30;
const totalW = classes.length * btnW + (classes.length - 1) * gap;
const startX = W / 2 - totalW / 2;
classes.forEach((c, i) => {
const x = startX + i * (btnW + gap);
const y = H * 0.25;
const hover = getHoverState() === `class_${i}`;
drawRoundRect(x, y, btnW, btnH, 10, hover ? '#2a2a5e' : '#1a1a3e', c.color);
drawCenteredText(`${c.icon}`, W / 2 + (i - 1) * (btnW + gap), y + 15, 36, '#c0c0c0');
drawCenteredText(c.name, W / 2 + (i - 1) * (btnW + gap), y + 60, 16, c.color, true);
drawCenteredText(c.desc, W / 2 + (i - 1) * (btnW + gap), y + 90, 11, '#8a8a9a');
drawCenteredText('Starting: 70 HP, 3 Energy, 10 Cards', W / 2 + (i - 1) * (btnW + gap), y + 140, 10, '#6a6a7a');
});
}
function renderMapScreen(state) {
drawRect(0, 0, W, H, COLORS.bg);
// Header bar
drawRect(0, 0, W, 50, COLORS.darkBg, COLORS.panelBorder);
drawText(`Act ${state.act}: ${['The Crypts', 'Blighted Woods', 'Abyssal Spire'][state.act - 1]}`, 20, 15, 16, '#c0c0c0', 'left', true);
drawText(`HP: ${state.player.hp}/${state.player.maxHp}`, W - 250, 15, 13, COLORS.hp);
drawText(`Gold: ${state.player.gold}`, W - 120, 15, 13, COLORS.gold);
// Draw connections
ctx.strokeStyle = '#2a2a4e';
ctx.lineWidth = 2;
for (let i = 0; i < state.mapNodes.length - 1; i++) {
const a = state.mapNodes[i];
const b = state.mapNodes[i + 1];
ctx.beginPath();
ctx.moveTo(a.x, a.y + 10);
ctx.lineTo(b.x, b.y - 10);
ctx.stroke();
}
// Draw nodes
const currentIdx = state.floor - 2;
state.mapNodes.forEach((node, i) => {
const nx = node.x;
const ny = node.y;
const color = getNodeTypeColor(node.type);
// Path glow for current floor
if (i === currentIdx && !node.completed) {
ctx.shadowColor = color;
ctx.shadowBlur = 20;
}
ctx.beginPath();
ctx.arc(nx, ny, node.completed ? 14 : 18, 0, Math.PI * 2);
ctx.fillStyle = node.completed ? '#2a2a3e' : (color + '40');
ctx.fill();
ctx.strokeStyle = node.completed ? '#4a4a5e' : color;
ctx.lineWidth = node.completed ? 2 : 3;
ctx.stroke();
ctx.shadowBlur = 0;
// Icon
drawCenteredText(getNodeTypeIcon(node.type), nx, ny - 8, node.completed ? 12 : 14, node.completed ? '#6a6a7a' : color);
// Floor number
drawCenteredText(`F${node.floor}`, nx, ny + 22, 9, '#6a6a7a');
});
// Selected node info
const currentNodeIdx = state.floor - 2;
if (currentNodeIdx >= 0 && currentNodeIdx < state.mapNodes.length) {
const node = state.mapNodes[currentNodeIdx];
drawCenteredText('Click the highlighted node to proceed', W / 2, H - 60, 13, '#5a5a6a');
}
// Relics display
if (state.player.relics.length > 0) {
const ry = H - 30;
state.player.relics.slice(0, 6).forEach((r, i) => {
drawRoundRect(10 + i * 32, ry, 28, 28, 4, '#2a2a3e', '#4a4a6e');
drawCenteredText(`${i + 1}`, 24 + i * 32, ry + 7, 12, COLORS.text);
});
}
}
function renderCombatScreen(state) {
drawRect(0, 0, W, H, COLORS.bg);
const handH = 180;
const topH = H - handH - 40;
// Top area: enemies and their intents
let ex = 80;
const enemyW = 160;
state.enemies.forEach((enemy, i) => {
const ey = 60;
const eH = topH - 80;
// Enemy panel
drawRoundRect(ex, ey, enemyW, eH, 8, '#1a1a2e', '#3a3a5e');
// Enemy sprite (simple shape)
drawEnemySprite(ex + enemyW / 2, ey + 60, enemy);
// Enemy name and HP
drawCenteredText(enemy.name, ex + enemyW / 2, ey + eH - 70, 11, '#c0c0c0', true);
drawHPBar(ex + 10, ey + eH - 55, enemyW - 20, 12, enemy.hp, enemy.maxHp, COLORS.red);
drawCenteredText(`${enemy.hp}/${enemy.maxHp}`, ex + enemyW / 2, ey + eH - 52, 10, COLORS.white);
// Block indicator
if (enemy.block > 0) {
drawCenteredText(`🛡 ${enemy.block}`, ex + enemyW / 2, ey + eH - 38, 11, COLORS.block);
}
// Debuffs
let dx = ex + 5;
enemy.debuffs.forEach(d => {
if (d.amount > 0) {
drawDebuffIcon(dx, ey + 5, d);
dx += 22;
}
});
// Buffs
enemy.buffs.forEach(b => {
if (b.type !== 'regeneration') {
drawBuffIcon(dx, ey + 28, b);
dx += 22;
}
});
// Intent
drawIntentBar(ex + 10, ey + 20, enemyW - 20, enemy.intent);
ex += enemyW + 20;
});
// Player area (bottom)
const playerY = topH;
// Player HP bar
drawRect(20, playerY, W - 40, 25, '#1a1a2e', '#3a3a5e');
drawText('YOU', 30, playerY + 5, 12, '#c0c0c0', 'left', true);
drawHPBar(80, playerY + 4, 200, 16, state.player.hp, state.player.maxHp, COLORS.green);
drawCenteredText(`${state.player.hp}/${state.player.maxHp}`, 180, playerY + 5, 10, COLORS.white);
// Block
if (state.player.block > 0) {
drawText(`🛡 ${state.player.block}`, 300, playerY + 5, 13, COLORS.block);
}
// Energy
for (let i = 0; i < state.player.maxEnergy; i++) {
const ex2 = 420 + i * 28;
const filled = i < state.player.energy;
drawRoundRect(ex2, playerY + 3, 22, 18, filled ? '#2a2a1e' : '#1a1a2e', filled ? COLORS.energy : '#3a3a3e');
drawCenteredText('⚡', ex2 + 11, playerY + 5, 12, filled ? COLORS.energy : '#4a4a4a');
}
// Player debuffs
let pdx = 550;
state.player.debuffs.forEach(d => {
if (d.amount > 0) {
drawDebuffIcon(pdx, playerY + 3, d);
pdx += 22;
}
});
// Player buffs
state.player.buffs.forEach(b => {
if (b.amount > 0 && b.type !== 'regeneration') {
drawBuffIcon(pdx, playerY + 3, b);
pdx += 22;
}
});
// Draw pile / Discard info
drawText(`Draw: ${state.player.drawPile.length}`, W - 180, playerY + 5, 11, COLORS.textDim);
drawText(`Discard: ${state.player.discardPile.length}`, W - 80, playerY + 5, 11, COLORS.textDim);
// Hand area
const handY = topH + 30;
const cardW = 100;
const cardH = 140;
const handW = state.player.hand.length * (cardW + 8);
const handStartX = Math.max(20, W / 2 - handW / 2);
state.player.hand.forEach((card, i) => {
const canPlay = state.isPlayerTurn && !card.unplayable && state.player.energy >= card.cost;
const hovered = state.hoveredCard?.instanceId === card.instanceId;
const x = handStartX + i * (cardW + 8);
const y = hovered ? handY - 15 : handY;
drawCard(card, x, y, cardW, cardH, hovered, canPlay);
});
// End turn button
const btnW = 120;
const btnH = 35;
const btnX = W - btnW - 20;
const btnY = H - 30;
const hoverBtn = getHoverState() === 'endTurn';
drawRoundRect(btnX, btnY, btnW, btnH, 6, hoverBtn ? '#3a2020' : '#2a1a1a', hoverBtn ? '#e06060' : '#804040');
drawCenteredText('END TURN', btnX + btnW / 2, btnY + 10, 13, hoverBtn ? '#e0e0e0' : '#c0a0a0', true);
// Combat log
const logY = topH + 25;
const logX = state.enemies.length * 180 + 200;
if (logX < W - 200) {
drawRect(logX, logY, W - logX - 20, handY - logY - 5, '#0e0e1a', '#2a2a3e');
const recentLogs = state.combatLog.slice(-8);
recentLogs.forEach((log, i) => {
drawText(log, logX + 5, logY + 5 + i * 14, 10, '#7a7a8a');
});
}
// Potions
const potionY = H - 25;
state.player.potions.forEach((p, i) => {
const px = 15 + i * 60;
const hover = getHoverState() === `potion_${i}`;
drawRoundRect(px, potionY, 55, 22, hover ? '#2a2a4e' : '#1a1a2e', '#3a3a5e');
drawCenteredText(p.name.split(' ')[0], px + 27, potionY + 5, 9, '#a0a0c0');
});
}
function drawEnemySprite(x, y, enemy) {
ctx.save();
ctx.translate(x, y);
// Simple geometric enemy sprite
const colors = {
skeleton: '#c0b090', zombie: '#608040', cultist: '#6040a0', rat: '#807060',
tombGuardian: '#808080', bloodWitch: '#802020', lichKing: '#c0c0e0',
shadowThief: '#404060', wraith: '#8080c0', corruptedBeast: '#804040',
thornedHorror: '#408040', witchDoctor: '#606040', treantProtector: '#608040',
plagueShaman: '#804080', hellhound: '#c04020', shadowDemon: '#402080',
infernalImp: '#c06020', abyssalCultist: '#6040a0', flamespawn: '#e08020',
dreadKnight: '#8080a0', archdemon: '#a02020', eldritchTreant: '#408060',
};
const c = colors[enemy.sprite] || '#808080';
ctx.fillStyle = c;
if (enemy.isBoss) {
// Boss: larger, more complex shape
ctx.beginPath();
ctx.moveTo(0, -35);
ctx.lineTo(-25, -15);
ctx.lineTo(-20, 15);
ctx.lineTo(0, 25);
ctx.lineTo(20, 15);
ctx.lineTo(25, -15);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#fff3';
ctx.lineWidth = 2;
ctx.stroke();
// Eyes
ctx.fillStyle = '#e04040';
ctx.beginPath();
ctx.arc(-8, -5, 4, 0, Math.PI * 2);
ctx.arc(8, -5, 4, 0, Math.PI * 2);
ctx.fill();
}
else if (enemy.isMinion) {
ctx.beginPath();
ctx.arc(0, 0, 12, 0, Math.PI * 2);
ctx.fill();
}
else {
// Regular enemy
ctx.beginPath();
ctx.arc(0, 0, 20, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = '#e04040';
ctx.beginPath();
ctx.arc(-6, -3, 3, 0, Math.PI * 2);
ctx.arc(6, -3, 3, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function drawIntentBar(x, y, w, intent) {
const icon = {
attack: '⚔', defend: '🛡', buff: '▲', debuff: '▼',
multiHit: '⚔⚔', summon: '★', drain: '♥',
}[intent.type] || '?';
const color = {
attack: COLORS.enemyAttack, defend: COLORS.enemyDefend, buff: COLORS.enemyBuff,
debuff: COLORS.enemyDebuff, multiHit: COLORS.enemyMulti, summon: COLORS.enemySummon,
drain: COLORS.enemyDrain,
}[intent.type] || '#808080';
drawText(icon, x, y, 14, color, 'left');
drawText(intent.text, x + 22, y + 2, 10, color, 'left');
}
function drawDebuffIcon(x, y, d) {
const icons = { weak: 'W', vulnerable: 'V', bleed: 'B', burn: '🔥', poison: 'P' };
const color = d.type === 'weak' ? '#a08040' : d.type === 'vulnerable' ? '#a040c0' :
d.type === 'bleed' ? '#e04040' : d.type === 'burn' ? '#e08040' : '#40c040';
drawRoundRect(x, y, 20, 18, 3, '#1a1a2e', color);
drawCenteredText(icons[d.type] || '?', x + 10, y + 2, 9, color);
drawCenteredText(`${d.amount}`, x + 10, y + 11, 8, COLORS.textDim);
}
function drawBuffIcon(x, y, b) {
const icons = { strength: 'S', dexterity: 'D', intangible: 'I', regeneration: 'R', energy_next: 'E', block_next: 'B', energy: 'E' };
const color = '#40c040';
drawRoundRect(x, y, 20, 18, 3, '#1a2a1a', color);
drawCenteredText(icons[b.type] || '?', x + 10, y + 2, 9, color);
if (b.type === 'intangible' && b.turnsRemaining) {
drawCenteredText(`${b.turnsRemaining}`, x + 10, y + 11, 8, COLORS.textDim);
}
}
function renderRestScreen(state) {
drawRect(0, 0, W, H, COLORS.bg);
drawCenteredText('Campfire', W / 2, 40, 28, '#e0a040', true);
drawCenteredText('Choose an action:', W / 2, 80, 16, '#c0c0c0');
const actions = [
{ id: 'rest', label: 'Rest', desc: `Heal ${Math.floor(state.player.maxHp * 0.25)} HP` },
{ id: 'forge', label: 'Forge', desc: 'Upgrade a card' },
{ id: 'meditate', label: 'Meditate', desc: 'Remove a card from your deck' },
];
actions.forEach((a, i) => {
const x = W / 2 - 220 + i * 160;
const y = 140;
const hover = getHoverState() === `rest_${a.id}`;
drawRoundRect(x, y, 140, 80, 8, hover ? '#2a2a5e' : '#1a1a3e', '#4a4a6e');
drawCenteredText(a.label, x + 70, y + 20, 16, '#c0c0c0', true);
drawCenteredText(a.desc, x + 70, y + 45, 10, '#8a8a9a');
});
// If forge/meditate, show card list
if (state.restAction === 'forge') {
drawCenteredText('Select a card to upgrade:', W / 2, 260, 14, '#c0c0c0');
renderCardList(state.player.deck, 20, 300, 100, 120, 'Forge', 'forge');
}
else if (state.restAction === 'meditate') {
drawCenteredText('Select a card to remove:', W / 2, 260, 14, '#c0c0c0');
renderCardList(state.player.deck, 20, 300, 100, 120, 'Meditate', 'meditate');
}
}
function renderCardList(deck, startX, startY, w, h, _title, action) {
// Simple grid of cards for selection
drawRect(startX, startY, W - startX * 2, H - startY - 40, '#0e0e1a', '#2a2a3e');
const cols = Math.floor((W - startX * 2) / (w + 4));
deck.slice(0, 20).forEach((card, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = startX + col * (w + 4);
const y = startY + row * (h + 4);
const hover = getHoverState() === `${action}_card_${i}`;
const ci = { ...card, instanceId: `deck_${i}` };
drawCard(ci, x, y, w, h, hover, false);
});
}
function renderShopScreen(state) {
drawRect(0, 0, W, H, COLORS.bg);
drawCenteredText('Merchant\'s Stall', W / 2, 20, 24, '#e0a040', true);
drawCenteredText(`Gold: ${state.player.gold}`, W / 2, 55, 16, COLORS.gold);
// Cards
drawCenteredText('Cards', W / 2, 90, 14, '#c0c0c0');
state.shopItems.cards.forEach((item, i) => {
const x = W / 2 - 200 + i * 140;
const y = 110;
const canBuy = state.player.gold >= item.price;
const hover = getHoverState() === `shop_card_${i}`;
drawCard({ ...item.card, instanceId: `shop_card_${i}` }, x, y, 110, 120, hover, canBuy);
drawCenteredText(`${item.price}g`, x + 55, y + 130, 12, COLORS.gold);
});
// Relics
drawCenteredText('Relics', W / 2, 280, 14, '#c0c0c0');
state.shopItems.relics.forEach((item, i) => {
const x = W / 2 - 100 + i * 120;
const y = 310;
const canBuy = state.player.gold >= item.price;
const hover = getHoverState() === `shop_relic_${i}`;
drawRoundRect(x, y, 90, 100, 6, hover ? '#2a2a5e' : '#1a1a3e', canBuy ? '#4a4a6e' : '#3a3a3e');
drawCenteredText(item.relic.name, x + 45, y + 10, 11, getRarityColor(item.relic.rarity), true);
drawCenteredText(item.relic.description, x + 45, y + 30, 9, '#8a8a9a');
drawCenteredText(`${item.price}g`, x + 45, y + 75, 12, COLORS.gold);
});
// Potions
drawCenteredText('Potions', W / 2, 450, 14, '#c0c0c0');
state.shopItems.potions.forEach((item, i) => {
const x = W / 2 - 150 + i * 100;
const y = 480;
const canBuy = state.player.gold >= item.price && state.player.potions.length < 3;
const hover = getHoverState() === `shop_potion_${i}`;
drawRoundRect(x, y, 75, 60, 6, hover ? '#2a2a5e' : '#1a1a3e', canBuy ? '#4a4a6e' : '#3a3a3e');
drawCenteredText('🧪', x + 37, y + 5, 16, '#a0a0c0');
drawCenteredText(item.potion.name, x + 37, y + 25, 9, '#a0a0c0');
drawCenteredText(`${item.price}g`, x + 37, y + 45, 11, COLORS.gold);
});
// Remove card button
if (state.shopRemoveAvailable) {
const rx = W / 2 - 60;
const ry = 570;
const hover = getHoverState() === 'shop_remove';
drawRoundRect(rx, ry, 120, 35, 6, hover ? '#2a2a5e' : '#1a1a3e', '#4a4a6e');
drawCenteredText('Remove Card (75g)', rx + 60, ry + 10, 12, '#c0c0c0');
}
// Leave button
const lx = W - 100;
const ly = H - 40;
const hover = getHoverState() === 'shop_leave';
drawRoundRect(lx, ly, 80, 30, 6, hover ? '#3a2020' : '#2a1a1a', '#804040');
drawCenteredText('Leave', lx + 40, ly + 8, 12, '#c0a0a0');
}
function renderEventScreen(state) {
drawRect(0, 0, W, H, COLORS.bg);
if (!state.currentEvent)
return;
const ev = state.currentEvent;
drawCenteredText(ev.title, W / 2, 60, 24, '#a060c0', true);
// Event text
drawRect(W / 2 - 250, 110, 500, 80, '#1a1a2e', '#3a3a5e');
drawCenteredText(ev.description, W / 2, 125, 14, '#c0c0c0');
ev.choices.forEach((choice, i) => {
const cy = 220 + i * 60;
const hover = getHoverState() === `event_${i}`;
drawRoundRect(W / 2 - 200, cy, 400, 45, 6, hover ? '#2a2a5e' : '#1a1a3e', hover ? '#a060c0' : '#3a3a5e');
drawText(choice.text, W / 2 - 185, cy + 15, 13, hover ? '#e0e0e0' : '#c0c0c0');
});
}
function renderCardRewardScreen(state) {
drawRect(0, 0, W, H, COLORS.bg);
drawCenteredText('Choose a Card', W / 2, 40, 22, '#c0c0c0', true);
const cards = state.pendingRewards.cardChoices;
if (cards.length > 0) {
const totalW = cards.length * 140 + (cards.length - 1) * 20;
const startX = W / 2 - totalW / 2;
cards.forEach((card, i) => {
const x = startX + i * 160;
const y = 120;
const hover = getHoverState() === `reward_${i}`;
drawCard({ ...card, instanceId: `reward_${i}` }, x, y, 120, 160, hover, false);
});
}
// Skip button
const lx = W / 2 - 60;
const ly = 350;
const hover = getHoverState() === 'reward_skip';
drawRoundRect(lx, ly, 120, 35, 6, hover ? '#3a2020' : '#2a1a1a', '#804040');
drawCenteredText('Skip', lx + 60, ly + 10, 13, '#c0a0a0');
}
function renderGameOverScreen(state) {
drawRect(0, 0, W, H, '#1a0a0a');
drawCenteredText('YOU HAVE FALLEN', W / 2, H * 0.3, 40, '#e04040', true);
drawCenteredText(`Act ${state.act}, Floor ${state.floor}`, W / 2, H * 0.3 + 50, 16, '#8a8a9a');
drawCenteredText(`Gold: ${state.player.gold} | Cards: ${state.player.deck.length}`, W / 2, H * 0.3 + 75, 14, '#6a6a7a');
const hover = getHoverState() === 'restart';
const btnX = W / 2 - 100;
const btnY = H * 0.6;
drawRoundRect(btnX, btnY, 200, 45, 8, hover ? '#3a3a5e' : '#2a2a4e', hover ? '#e04040' : '#4a4a6e');
drawCenteredText('TRY AGAIN', W / 2, btnY + 15, 16, hover ? '#e0e0e0' : '#c0c0c0', true);
}
function renderVictoryScreen(state) {
drawRect(0, 0, W, H, '#0a1a0a');
drawCenteredText('VICTORY', W / 2, H * 0.25, 48, '#e0c040', true);
drawCenteredText('The Abyss has been conquered...', W / 2, H * 0.25 + 60, 18, '#a0c080');
drawCenteredText(`Gold: ${state.player.gold} | Cards: ${state.player.deck.length} | Relics: ${state.player.relics.length}`, W / 2, H * 0.25 + 100, 14, '#8a8a9a');
const hover = getHoverState() === 'restart';
const btnX = W / 2 - 100;
const btnY = H * 0.5;
drawRoundRect(btnX, btnY, 200, 45, 8, hover ? '#3a3a5e' : '#2a2a4e', hover ? '#e0c040' : '#4a4a6e');
drawCenteredText('PLAY AGAIN', W / 2, btnY + 15, 16, hover ? '#e0e0e0' : '#c0c0c0', true);
}
function renderBossRewardScreen(state) {
drawRect(0, 0, W, H, COLORS.bg);
drawCenteredText('Boss Relic Rewards', W / 2, 40, 22, '#e0c040', true);
drawCenteredText('Choose one:', W / 2, 75, 14, '#c0c0c0');
state.bossRelicChoices.forEach((relic, i) => {
const x = W / 2 - 240 + i * 170;
const y = 110;
const hover = getHoverState() === `bossrelic_${i}`;
drawRoundRect(x, y, 150, 160, 8, hover ? '#2a2a5e' : '#1a1a3e', '#e0c040');
drawCenteredText(relic.name, x + 75, y + 15, 13, '#e0c040', true);
drawCenteredText(relic.description, x + 75, y + 40, 10, '#8a8a9a');
});
// Skip
const hover = getHoverState() === 'bossrelic_skip';
const btnX = W / 2 - 50;
const btnY = 310;
drawRoundRect(btnX, btnY, 100, 30, 6, hover ? '#3a2020' : '#2a1a1a', '#804040');
drawCenteredText('Skip', btnX + 50, btnY + 8, 12, '#c0a0a0');
}
function renderPotionPickScreen(state) {
drawRect(0, 0, W, H, COLORS.bg);
drawCenteredText('Potion Found!', W / 2, 60, 22, '#a060c0', true);
const potion = state.pendingRewards.potionChoice;
if (potion) {
const potions = {
bloodVial: { id: 'bloodVial', name: 'Blood Vial', description: 'Heal 15 HP.' },
firebomb: { id: 'firebomb', name: 'Firebomb', description: 'Deal 10 damage to ALL enemies.' },
dexTonic: { id: 'dexTonic', name: 'Dex Tonic', description: 'Gain 3 Dexterity for 1 turn.' },
energyElixir: { id: 'energyElixir', name: 'Energy Elixir', description: 'Gain 2 Energy this turn.' },
cursedBrew: { id: 'cursedBrew', name: 'Cursed Brew', description: 'Apply 3 Vulnerable to all enemies.' },
bleedingFlask: { id: 'bleedingFlask', name: 'Bleeding Flask', description: 'Apply 6 Bleed to an enemy.' },
impInBottle: { id: 'impInBottle', name: 'Imp-in-a-Bottle', description: 'Add Imp Claw+ to hand.' },
smokeBomb: { id: 'smokeBomb', name: 'Smoke Bomb', description: 'Escape from combat, no rewards.' },
};
const p = potions[potion];
if (p) {
drawRoundRect(W / 2 - 120, 120, 240, 100, 8, '#1a1a3e', '#a060c0');
drawCenteredText('🧪', W / 2, 135, 30, '#a0a0c0');
drawCenteredText(p.name, W / 2, 175, 16, '#c0c0c0', true);
drawCenteredText(p.description, W / 2, 200, 12, '#8a8a9a');
}
if (state.player.potions.length < 3) {
const hover = getHoverState() === 'potion_accept';
drawRoundRect(W / 2 - 80, 250, 160, 40, 6, hover ? '#2a2a5e' : '#1a1a3e', '#a060c0');
drawCenteredText('Accept', W / 2, 265, 14, '#c0c0c0');
}
else {
drawCenteredText('Potion slots full!', W / 2, 260, 14, '#e04040');
}
}
}
// Central render function
function renderScreen(state) {
switch (state.screen) {
case 'title':
renderTitleScreen(state);
break;
case 'classSelect':
renderClassSelect(state);
break;
case 'map':
renderMapScreen(state);
break;
case 'combat':
renderCombatScreen(state);
break;
case 'rest':
renderRestScreen(state);
break;
case 'shop':
renderShopScreen(state);
break;
case 'event':
renderEventScreen(state);
break;
case 'cardReward':
renderCardRewardScreen(state);
break;
case 'gameOver':
renderGameOverScreen(state);
break;
case 'victory':
renderVictoryScreen(state);
break;
case 'bossReward':
renderBossRewardScreen(state);
break;
case 'potionPick':
renderPotionPickScreen(state);
break;
}
}
function getW() { return W; }
function getH() { return H; }
// Hover state
let hoverState = '';
function setHoverState(s) { hoverState = s; }
function getHoverState() { return hoverState; }
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.6-27B-MTP-GGUF","provider":"brigid","persona":"typescript","sessionId":"907c5aeb-5f26-4bda-a3c5-4084188525b2","tokensIn":4656619,"tokensOut":96607,"tokensTotal":4753226,"cost":0,"turns":59,"toolCalls":58,"failedToolCalls":2,"timestamp":"2026-08-02T09:06:13.699Z"} -->