RPG
Deepseek v4 Pro · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 56,170 bytes · SHA-256 36ee72b3747f
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shadowgate Hollow — Action RPG</title>
<style>
:root { --ui-bg: #1a1a2e; --ui-text: #e0d8c0; --gold: #d4a843; --red: #c0392b; --green: #27ae60; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#0a0a14; display:flex; justify-content:center; align-items:center; height:100vh; overflow:hidden; font-family:'Georgia',serif; }
#wrapper { position:relative; width:960px; height:640px; }
canvas { display:block; border:3px solid #3a2a1a; border-radius:4px; image-rendering:pixelated; }
#ui-layer { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; }
#hud { position:absolute; top:12px; left:16px; display:flex; align-items:center; gap:6px; }
.heart { width:28px; height:26px; position:relative; }
.heart-full { color:#e74c3c; font-size:26px; line-height:1; text-shadow:0 0 6px #e74c3c; }
.heart-half { color:#e74c3c; font-size:26px; line-height:1; text-shadow:0 0 6px #e74c3c; opacity:0.5; }
.heart-empty { color:#555; font-size:26px; line-height:1; }
#money-display { position:absolute; top:12px; right:16px; color:var(--gold); font-size:20px; font-weight:bold; text-shadow:0 0 8px rgba(212,168,67,0.6); background:rgba(0,0,0,0.5); padding:4px 12px; border-radius:4px; }
#weapon-display { position:absolute; top:50px; right:16px; color:#ccc; font-size:13px; background:rgba(0,0,0,0.5); padding:4px 10px; border-radius:4px; }
#message-box { position:absolute; bottom:140px; left:50%; transform:translateX(-50%); color:#fff; font-size:16px; text-align:center; background:rgba(0,0,0,0.7); padding:6px 20px; border-radius:6px; opacity:0; transition:opacity 0.3s; pointer-events:none; white-space:nowrap; }
#inventory-panel { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:420px; background:var(--ui-bg); border:2px solid #5a4a2a; border-radius:8px; padding:16px; display:none; pointer-events:auto; color:var(--ui-text); }
#inventory-panel.open { display:block; }
#inventory-panel h3 { text-align:center; margin-bottom:10px; color:var(--gold); font-size:18px; }
#inventory-panel .slot { display:flex; align-items:center; gap:10px; padding:8px; margin:4px 0; background:rgba(255,255,255,0.05); border-radius:4px; cursor:pointer; transition:background 0.2s; }
#inventory-panel .slot:hover { background:rgba(255,255,255,0.12); }
#inventory-panel .slot .icon { font-size:22px; width:32px; text-align:center; }
#inventory-panel .slot .name { flex:1; font-size:14px; }
#inventory-panel .slot .use { color:var(--green); font-size:11px; }
#inventory-panel .close-btn { display:block; margin:12px auto 0; padding:6px 24px; background:#5a3a1a; color:#e0d8c0; border:1px solid #7a5a2a; border-radius:4px; cursor:pointer; font-family:inherit; font-size:14px; }
#shop-panel { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:440px; background:var(--ui-bg); border:2px solid #5a4a2a; border-radius:8px; padding:16px; display:none; pointer-events:auto; color:var(--ui-text); }
#shop-panel.open { display:block; }
#shop-panel h3 { text-align:center; margin-bottom:10px; color:var(--gold); font-size:18px; }
#shop-panel .shop-item { display:flex; align-items:center; gap:10px; padding:8px; margin:4px 0; background:rgba(255,255,255,0.05); border-radius:4px; }
#shop-panel .shop-item button { padding:4px 14px; background:#5a3a1a; color:#e0d8c0; border:1px solid #7a5a2a; border-radius:4px; cursor:pointer; font-family:inherit; }
#shop-panel .shop-item button:disabled { opacity:0.4; cursor:not-allowed; }
#shop-panel .close-btn { display:block; margin:12px auto 0; padding:6px 24px; background:#5a3a1a; color:#e0d8c0; border:1px solid #7a5a2a; border-radius:4px; cursor:pointer; font-family:inherit; font-size:14px; }
#controls-hint { position:absolute; bottom:8px; left:50%; transform:translateX(-50%); color:#666; font-size:11px; pointer-events:none; }
#minimap { position:absolute; bottom:16px; left:16px; background:rgba(0,0,0,0.6); border:1px solid #333; border-radius:4px; padding:4px; pointer-events:none; }
#death-screen { position:absolute; top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);display:none;flex-direction:column;align-items:center;justify-content:center;pointer-events:auto; }
#death-screen.active { display:flex; }
#death-screen h2 { color:#c0392b; font-size:36px; margin-bottom:16px; }
#death-screen button { padding:10px 32px; background:#5a3a1a; color:#e0d8c0; border:2px solid #7a5a2a; border-radius:6px; cursor:pointer; font-family:inherit; font-size:18px; }
#save-indicator { position:absolute; top:50%;left:50%;transform:translate(-50%,-50%);color:#5dade2;font-size:20px;opacity:0;transition:opacity 0.5s;pointer-events:none;text-shadow:0 0 10px #5dade2; }
</style>
</head>
<body>
<div id="wrapper">
<canvas id="game" width="960" height="640"></canvas>
<div id="ui-layer">
<div id="hud"></div>
<div id="money-display">🪙 0</div>
<div id="weapon-display">⚔️ Rusty Sword</div>
<div id="message-box"></div>
<div id="inventory-panel"><h3>🎒 Inventory</h3><div id="inv-slots"></div><button class="close-btn" id="close-inv">Close [I]</button></div>
<div id="shop-panel"><h3>🏪 Merchant's Wares</h3><div id="shop-items"></div><button class="close-btn" id="close-shop">Close [E]</button></div>
<div id="controls-hint">WASD/Arrows: Move | Space: Attack | E: Interact | I: Inventory | 1-5: Use Item</div>
<div id="minimap"><canvas id="minimap-canvas" width="100" height="80"></canvas></div>
<div id="save-indicator">💾 Game Saved</div>
<div id="death-screen"><h2>You Died</h2><button id="respawn-btn">Load Last Save</button></div>
</div>
</div>
<script>
// ═══════════════════════════════════════════
// SHADOWGATE HOLLOW — Action RPG
// ═══════════════════════════════════════════
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = 960, H = 640;
const TILE = 32;
const ROOM_COLS = 20, ROOM_ROWS = 14;
const MAP_W = ROOM_COLS * TILE; // 640
const MAP_H = ROOM_ROWS * TILE; // 448
const MAP_OX = (W - MAP_W) / 2; // 160
const MAP_OY = 60;
// ── Sound Engine (Web Audio API) ──
let audioCtx = null;
function initAudio() {
if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
if (audioCtx.state === 'suspended') audioCtx.resume();
}
function playSound(freq, type, duration, vol=0.12, slide=0) {
if (!audioCtx) return;
const t = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
if (slide) osc.frequency.linearRampToValueAtTime(freq + slide, t + duration);
gain.gain.setValueAtTime(vol, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(t); osc.stop(t + duration);
}
function sfx(name) {
switch(name) {
case 'step': playSound(80, 'sine', 0.08, 0.03); break;
case 'swing': playSound(200, 'sawtooth', 0.15, 0.08, -100); break;
case 'hit': playSound(60, 'square', 0.2, 0.15, -30); break;
case 'playerHit': playSound(40, 'sawtooth', 0.3, 0.2); break;
case 'pickup': playSound(600, 'sine', 0.1, 0.1); playSound(800, 'sine', 0.1, 0.1, 0.08); break;
case 'coin': playSound(900, 'triangle', 0.08, 0.08); playSound(1200, 'triangle', 0.06, 0.06, 0.04); break;
case 'shop': playSound(500, 'sine', 0.12, 0.12); playSound(700, 'sine', 0.08, 0.1, 0.06); break;
case 'save': playSound(300, 'sine', 0.15, 0.15); playSound(500, 'sine', 0.1, 0.2, 0.1); break;
case 'bow': playSound(150, 'triangle', 0.2, 0.2, -80); playSound(400, 'sine', 0.06, 0.1, 0.05); break;
case 'upgrade': playSound(400, 'sine', 0.1, 0.1); playSound(600, 'sine', 0.08, 0.1); playSound(900, 'sine', 0.06, 0.15, 0.08); break;
case 'explore': playSound(200, 'sine', 0.08, 0.3, 200); break;
case 'door': playSound(100, 'triangle', 0.2, 0.3, 50); break;
case 'drink': playSound(300, 'sine', 0.08, 0.2); break;
}
}
// ── Particle System ──
const particles = [];
class Particle {
constructor(x, y, vx, vy, life, color, size=3) {
this.x = x; this.y = y; this.vx = vx; this.vy = vy; this.life = life; this.maxLife = life; this.color = color; this.size = size;
}
update(dt) {
this.x += this.vx * dt; this.y += this.vy * dt; this.life -= dt; this.size *= 0.995;
}
get alive() { return this.life > 0; }
get alpha() { return Math.max(0, this.life / this.maxLife); }
}
function spawnParticles(x, y, count, color, speed=80, life=0.4, size=3) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = speed * (0.5 + Math.random());
particles.push(new Particle(x, y, Math.cos(angle) * spd, Math.sin(angle) * spd, life * (0.5 + Math.random()), color, size));
}
}
// ── Map Data ──
// Tile types: 0=floor, 1=wall, 2=doorU, 3=doorD, 4=doorL, 5=doorR, 6=water, 7=save, 8=shop, 9=chest, 10=exit
const T = { FLOOR:0, WALL:1, DOOR_U:2, DOOR_D:3, DOOR_L:4, DOOR_R:5, WATER:6, SAVE:7, SHOP:8, CHEST:9, EXIT:10 };
// Room definitions [roomId]: { map:2D array, enemies:[], pickups:[], chests:[], savePoint:bool, shop:bool }
const roomDefs = {};
function makeRoom(mapStr, opts={}) {
const rows = mapStr.trim().split('\n');
const map = rows.map(r => r.split('').map(c => parseInt(c)));
return { map, enemies: opts.enemies || [], pickups: opts.pickups || [], chests: opts.chests || [], decorations: opts.decorations || [], roomType: opts.roomType || 'normal' };
}
// Room 0: Starting chamber (safe)
roomDefs['0,0'] = makeRoom(`
11111111111111111111
10000000000000000001
10000000000000000001
10000000070000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000003000000000001
11111111111111111111
`, { roomType: 'start' });
// Room 1: Forest path (easy enemies, coins)
roomDefs['0,1'] = makeRoom(`
11111111111111111111
10000200000000000001
10000010000001000001
10000000000000000001
10010000000000001001
10000000000000000001
10000100000000010001
10000000000000000001
10000001000010000001
10000000000000000001
10000000000000000001
10000000000000000001
10000005000000000001
11111111111111111111
`, {
enemies: [
{ type:'slime', x:5, y:3 },
{ type:'slime', x:14, y:4 },
{ type:'slime', x:8, y:8 },
],
pickups: [
{ type:'coin', x:3, y:6, amount:5 },
{ type:'coin', x:16, y:3, amount:3 },
{ type:'coin', x:10, y:10, amount:8 },
],
chests: [
{ x:17, y:9, item:'healthPotion', opened:false }
]
});
// Room 2: Dense thicket
roomDefs['1,1'] = makeRoom(`
11111111111111111111
10000000000000000001
10000100000001000001
10000000000000000001
10000001001000000001
10000000000000000001
10001000000000100001
10000000000000000001
10000001001000000001
10000000000000000001
10000000000000000001
10000004000050000001
10000002000000000001
11111111111111111111
`, {
enemies: [
{ type:'slime', x:4, y:3 },
{ type:'slime', x:13, y:5 },
{ type:'skeleton', x:10, y:8 },
],
pickups: [
{ type:'coin', x:7, y:4, amount:6 },
{ type:'coin', x:15, y:7, amount:4 },
],
chests: [
{ x:3, y:10, item:'ironSword', opened:false }
]
});
// Room 3: Forest clearing (health upgrade)
roomDefs['1,0'] = makeRoom(`
11111111111111111111
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000090000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000003000000000001
10000000000000000001
11111111111111111111
`, {
enemies: [
{ type:'skeleton', x:7, y:3 },
{ type:'skeleton', x:13, y:4 },
],
pickups: [
{ type:'coin', x:5, y:8, amount:10 },
{ type:'coin', x:15, y:9, amount:10 },
],
chests: [
{ x:9, y:5, item:'heartContainer', opened:false }
]
});
// Room 4: Cave entrance
roomDefs['2,0'] = makeRoom(`
11111111111111111111
10000000000000000001
10000000000000000001
11110011111111001111
10000000000000000001
10000000070000000001
10000000000000000051
10000000000000000001
11110000111100001111
10000000000000000001
10000000000000000001
10000003000000000001
10000002000000000001
11111111111111111111
`, {
enemies: [
{ type:'skeleton', x:5, y:5 },
{ type:'skeleton', x:15, y:5 },
{ type:'slime', x:10, y:2 },
],
pickups: [
{ type:'coin', x:3, y:9, amount:8 },
{ type:'coin', x:17, y:9, amount:8 },
]
});
// Room 5: Deep cavern (mid-game - ranged weapon!)
roomDefs['2,1'] = makeRoom(`
11111111111111111111
10000200000000000001
10000000000000000001
10001110000001110001
10001000000000100001
10000000000000000001
10000000090000000001
10000000000000000001
10001000000000100001
10001110000001110001
10000000000000000001
10000004000000000001
10000005000903000001
11111111111111111111
`, {
enemies: [
{ type:'skeleton', x:4, y:5 },
{ type:'skeleton', x:16, y:5 },
{ type:'skeleton', x:10, y:3 },
{ type:'slime', x:8, y:9 },
{ type:'slime', x:12, y:9 },
],
pickups: [
{ type:'coin', x:3, y:3, amount:12 },
{ type:'coin', x:17, y:3, amount:12 },
],
chests: [
{ x:9, y:6, item:'huntersBow', opened:false, isRanged:true }
]
});
// Room 6: Underground lake
roomDefs['3,1'] = makeRoom(`
11111111111111111111
10000200000000000001
10000066666600000001
10000666666666000001
10000660000066000001
10000660000066000001
10000660000066000001
10000660000066000001
10000066666666000001
10000006666600000001
10000000000000000001
10000004000000000001
10000000000000000001
11111111111111111111
`, {
enemies: [
{ type:'skeleton', x:3, y:3 },
{ type:'skeleton', x:8, y:6 },
{ type:'skeleton', x:17, y:3 },
{ type:'skeleton', x:14, y:8 },
],
pickups: [
{ type:'coin', x:1, y:8, amount:15 },
{ type:'coin', x:18, y:8, amount:15 },
],
chests: [
{ x:10, y:9, item:'magicBlade', opened:false }
]
});
// Room 7: Shop
roomDefs['3,0'] = makeRoom(`
11111111111111111111
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000008888000000001
10000008888000000001
10000008888000000001
10000000000000000001
10000000070000000001
10000004000000000001
10000003000000000001
11111111111111111111
`, {
roomType: 'shop',
enemies: [],
pickups: [
{ type:'coin', x:3, y:2, amount:20 },
{ type:'coin', x:17, y:2, amount:20 },
]
});
// Room 8: Boss approach
roomDefs['2,2'] = makeRoom(`
11111111111111111111
10000000000000000001
10000000000000000001
10010000000000001001
10000000000000000001
10000100000000100001
10000000000000000001
10000001000100000001
10000000000000000001
10000000000000000001
10000000000000000001
10000002000000000001
10000005001000000001
11111111111111111111
`, {
enemies: [
{ type:'skeleton', x:7, y:4 },
{ type:'skeleton', x:13, y:4 },
{ type:'skeleton', x:10, y:7 },
{ type:'skeleton', x:4, y:9 },
{ type:'skeleton', x:16, y:9 },
],
pickups: [
{ type:'coin', x:10, y:5, amount:25 },
],
chests: [
{ x:13, y:12, item:'elixir', opened:false }
]
});
// Room 9: Boss room
roomDefs['3,2'] = makeRoom(`
11111111111111111111
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10000000000000000001
10040000000000000001
11111111111111111111
`, {
enemies: [
{ type:'boss', x:10, y:6 },
],
roomType: 'boss',
chests: [
{ x:10, y:11, item:'heartContainer2', opened:false }
]
});
// World grid: which roomDef key at each world coordinate
const worldGrid = {
'0,0': '0,0', '0,1': '0,1', '0,2': null,
'1,0': '1,0', '1,1': '1,1', '1,2': null,
'2,0': '2,0', '2,1': '2,1', '2,2': '2,2',
'3,0': '3,0', '3,1': '3,1', '3,2': '3,2',
};
// ── Game State ──
const state = {
playerX: 5 * TILE + TILE/2,
playerY: 7 * TILE + TILE/2,
worldX: 0, worldY: 0,
hp: 3, maxHp: 3,
weapon: 0, // 0=rusty sword, 1=iron sword, 2=magic blade
hasRanged: false,
money: 0,
inventory: [], // [{id, name, icon, desc, usable, effect}]
facing: 2, // 0=up,1=right,2=down,3=left
attacking: false, attackTimer: 0, attackCooldown: 0,
rangedTimer: 0, rangedCooldown: 0,
invincible: 0,
stepTimer: 0,
moving: false,
bossDefeated: false,
};
const roomState = {}; // per-room: { enemies:[], pickups:[], chests:[] }
function getRoomKey(wx, wy) { return `${wx},${wy}`; }
function getCurrentRoomDef() {
const key = getRoomKey(state.worldX, state.worldY);
const defKey = worldGrid[key];
if (!defKey) return null;
return roomDefs[defKey];
}
function getCurrentRoomState() {
const key = getRoomKey(state.worldX, state.worldY);
if (!roomState[key]) {
const def = getCurrentRoomDef();
if (!def) { roomState[key] = { enemies:[], pickups:[], chests:[] }; return roomState[key]; }
roomState[key] = {
enemies: def.enemies.map((e,i) => ({...e, id:i, hp: e.type==='boss'?12:(e.type==='skeleton'?5:3), alive:true})),
pickups: def.pickups.map((p,i) => ({...p, collected:false})),
chests: def.chests.map((c,i) => ({...c, opened:c.opened||false})),
};
}
return roomState[key];
}
// ── Input ──
const keys = {};
window.addEventListener('keydown', e => {
keys[e.key] = true;
initAudio();
if (e.key === 'i' || e.key === 'I') { toggleInventory(); e.preventDefault(); }
if (e.key >= '1' && e.key <= '5') { useInventoryItem(parseInt(e.key) - 1); e.preventDefault(); }
if (e.key === 'e' || e.key === 'E') { interact(); e.preventDefault(); }
if (e.key === ' ') { e.preventDefault(); }
});
window.addEventListener('keyup', e => { keys[e.key] = false; });
// ── UI Elements ──
const hudEl = document.getElementById('hud');
const moneyDisplay = document.getElementById('money-display');
const weaponDisplay = document.getElementById('weapon-display');
const messageBox = document.getElementById('message-box');
const invPanel = document.getElementById('inventory-panel');
const invSlots = document.getElementById('inv-slots');
const shopPanel = document.getElementById('shop-panel');
const shopItems = document.getElementById('shop-items');
const saveIndicator = document.getElementById('save-indicator');
const deathScreen = document.getElementById('death-screen');
const minimapCanvas = document.getElementById('minimap-canvas');
const minimapCtx = minimapCanvas.getContext('2d');
let messageTimeout = null;
function showMessage(msg, duration=2500) {
messageBox.textContent = msg;
messageBox.style.opacity = '1';
if (messageTimeout) clearTimeout(messageTimeout);
messageTimeout = setTimeout(() => { messageBox.style.opacity = '0'; }, duration);
}
function updateHUD() {
// Hearts
let html = '';
const fullHearts = Math.floor(state.hp / 2);
const halfHeart = state.hp % 2;
for (let i = 0; i < Math.floor(state.maxHp / 2); i++) {
if (i < fullHearts) html += '<span class="heart-full">♥</span>';
else if (i === fullHearts && halfHeart) html += '<span class="heart-half">♥</span>';
else html += '<span class="heart-empty">♥</span>';
}
hudEl.innerHTML = html;
moneyDisplay.textContent = `🪙 ${state.money}`;
const weaponNames = ['Rusty Sword', 'Iron Blade', 'Arcane Edge'];
const weaponIcons = ['🗡️', '⚔️', '✨'];
let wText = `${weaponIcons[state.weapon]} ${weaponNames[state.weapon]}`;
if (state.hasRanged) wText += ' 🏹 Bow';
weaponDisplay.textContent = wText;
}
function openShop() {
shopPanel.classList.add('open');
invPanel.classList.remove('open');
renderShop();
}
function closeShop() {
shopPanel.classList.remove('open');
}
function toggleInventory() {
if (shopPanel.classList.contains('open')) { closeShop(); return; }
invPanel.classList.toggle('open');
if (invPanel.classList.contains('open')) renderInventory();
}
function renderInventory() {
if (state.inventory.length === 0) {
invSlots.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">Your pack is empty.</div>';
} else {
invSlots.innerHTML = state.inventory.map((item, i) => `
<div class="slot" onclick="useInventoryItem(${i})">
<span class="icon">${item.icon}</span>
<span class="name">${item.name}</span>
${item.usable ? `<span class="use">[${i+1}] Use</span>` : ''}
</div>
`).join('');
}
}
document.getElementById('close-inv').addEventListener('click', () => invPanel.classList.remove('open'));
document.getElementById('close-shop').addEventListener('click', closeShop);
// ── Shop Items ──
const shopStock = [
{ id:'healthPotion', name:'Health Potion', icon:'🧪', desc:'Restore 2 HP', cost:15, usable:true, effect:()=>{ state.hp = Math.min(state.maxHp, state.hp+2); showMessage('Restored 2 HP!'); sfx('drink'); spawnParticles(state.playerX, state.playerY, 12, '#4ade80', 60, 0.5, 4); }},
{ id:'elixir', name:'Elixir of Power', icon:'⚗️', desc:'+1 damage for 60s', cost:25, usable:true, effect:()=>{ state.damageBoost=(state.damageBoost||0)+1; showMessage('Damage boosted!'); sfx('drink'); spawnParticles(state.playerX, state.playerY, 16, '#a78bfa', 50, 0.6, 5); }},
{ id:'ironSkin', name:'Iron Skin Tonic', icon:'🛡️', desc:'Reduce damage 60s', cost:30, usable:true, effect:()=>{ state.defenseBoost=(state.defenseBoost||0)+1; showMessage('Defense boosted!'); sfx('drink'); spawnParticles(state.playerX, state.playerY, 14, '#60a5fa', 50, 0.6, 5); }},
{ id:'bomb', name:'Bomb', icon:'💣', desc:'Damages all enemies in room', cost:20, usable:true, effect:()=>{ const rs = getCurrentRoomState(); rs.enemies.forEach(e=>{ if(e.alive){ e.hp-=3; if(e.hp<=0){ e.alive=false; spawnParticles(e.x*TILE+TILE/2, e.y*TILE+TILE/2, 20, '#fbbf24', 120, 0.5, 6); } } }); showMessage('BOOM!'); sfx('hit'); }},
{ id:'luckyCharm', name:'Lucky Charm', icon:'🍀', desc:'More coin drops (permanent)', cost:40, usable:false, effect:()=>{ state.luckyCharm=true; showMessage('Luck increased!'); sfx('upgrade'); }},
];
function renderShop() {
shopItems.innerHTML = shopStock.map((item, i) => {
const owned = state.inventory.some(inv => inv.id === item.id && !inv.usable);
const disabled = owned || state.money < item.cost;
return `<div class="shop-item">
<span style="font-size:20px;">${item.icon}</span>
<span style="flex:1;">${item.name} <small style="color:#aaa;">${item.desc}</small></span>
<span style="color:var(--gold);">🪙${item.cost}</span>
<button ${disabled?'disabled':''} onclick="buyItem(${i})">${owned?'Owned':'Buy'}</button>
</div>`;
}).join('');
}
function buyItem(index) {
const item = shopStock[index];
if (state.money < item.cost) return;
const owned = state.inventory.some(inv => inv.id === item.id && !inv.usable);
if (owned) return;
state.money -= item.cost;
state.inventory.push({...item});
showMessage(`Bought ${item.name}!`, 2000);
sfx('shop');
spawnParticles(state.playerX, state.playerY, 10, '#d4a843', 40, 0.4, 3);
renderShop();
renderInventory();
saveGame();
}
function useInventoryItem(index) {
if (index < 0 || index >= state.inventory.length) return;
const item = state.inventory[index];
if (!item.usable) return;
item.effect();
state.inventory.splice(index, 1);
renderInventory();
updateHUD();
saveGame();
}
// Expose to onclick
window.buyItem = buyItem;
window.useInventoryItem = useInventoryItem;
// ── Save System ──
function saveGame() {
const data = {
playerX: state.playerX, playerY: state.playerY,
worldX: state.worldX, worldY: state.worldY,
hp: state.hp, maxHp: state.maxHp,
weapon: state.weapon, hasRanged: state.hasRanged,
money: state.money, inventory: state.inventory,
bossDefeated: state.bossDefeated,
luckyCharm: state.luckyCharm||false,
damageBoost: state.damageBoost||0, defenseBoost: state.defenseBoost||0,
roomState: {},
};
// Save room states
for (const [key, rs] of Object.entries(roomState)) {
data.roomState[key] = {
enemies: rs.enemies.map(e => ({...e})),
pickups: rs.pickups.map(p => ({...p})),
chests: rs.chests.map(c => ({...c})),
};
}
localStorage.setItem('shadowgate_save', JSON.stringify(data));
saveIndicator.style.opacity = '1';
setTimeout(() => { saveIndicator.style.opacity = '0'; }, 1500);
}
function loadGame() {
const raw = localStorage.getItem('shadowgate_save');
if (!raw) return false;
try {
const data = JSON.parse(raw);
state.playerX = data.playerX; state.playerY = data.playerY;
state.worldX = data.worldX; state.worldY = data.worldY;
state.hp = data.hp; state.maxHp = data.maxHp;
state.weapon = data.weapon; state.hasRanged = data.hasRanged;
state.money = data.money; state.inventory = data.inventory || [];
state.bossDefeated = data.bossDefeated || false;
state.luckyCharm = data.luckyCharm || false;
state.damageBoost = data.damageBoost || 0;
state.defenseBoost = data.defenseBoost || 0;
if (data.roomState) {
for (const [key, rs] of Object.entries(data.roomState)) {
roomState[key] = rs;
}
}
return true;
} catch(e) { return false; }
}
document.getElementById('respawn-btn').addEventListener('click', () => {
if (loadGame()) {
state.hp = Math.max(1, state.hp);
state.invincible = 1.5;
deathScreen.classList.remove('active');
showMessage('Loaded last save.', 2000);
} else {
resetGame();
deathScreen.classList.remove('active');
}
updateHUD();
});
function resetGame() {
state.playerX = 5 * TILE + TILE/2;
state.playerY = 7 * TILE + TILE/2;
state.worldX = 0; state.worldY = 0;
state.hp = 3; state.maxHp = 3;
state.weapon = 0; state.hasRanged = false;
state.money = 0; state.inventory = [];
state.bossDefeated = false;
state.luckyCharm = false;
state.damageBoost = 0; state.defenseBoost = 0;
state.attacking = false; state.attackTimer = 0; state.attackCooldown = 0;
state.rangedTimer = 0; state.rangedCooldown = 0;
state.invincible = 0;
for (const k of Object.keys(roomState)) delete roomState[k];
}
// ── Collision Detection ──
function getTile(wx, wy, tx, ty) {
const def = getCurrentRoomDef();
if (!def) return T.WALL;
if (tx < 0 || tx >= ROOM_COLS || ty < 0 || ty >= ROOM_ROWS) return T.WALL;
return def.map[ty][tx];
}
function isSolid(tile) {
return tile === T.WALL || tile === T.WATER;
}
function canMoveTo(px, py, radius=10) {
const left = Math.floor((px - radius) / TILE);
const right = Math.floor((px + radius) / TILE);
const top = Math.floor((py - radius) / TILE);
const bottom = Math.floor((py + radius) / TILE);
for (let ty = top; ty <= bottom; ty++) {
for (let tx = left; tx <= right; tx++) {
if (isSolid(getTile(state.worldX, state.worldY, tx, ty))) return false;
}
}
return true;
}
function getDoorTile(px, py) {
const tx = Math.floor(px / TILE);
const ty = Math.floor(py / TILE);
return getTile(state.worldX, state.worldY, tx, ty);
}
// ── Player Update ──
function updatePlayer(dt) {
// Timers
if (state.invincible > 0) state.invincible -= dt;
if (state.attackTimer > 0) state.attackTimer -= dt;
if (state.attackCooldown > 0) state.attackCooldown -= dt;
if (state.rangedTimer > 0) state.rangedTimer -= dt;
if (state.rangedCooldown > 0) state.rangedCooldown -= dt;
if (state.stepTimer > 0) state.stepTimer -= dt;
if (state.damageBoost > 0 && state._damageBoostTimer) { state._damageBoostTimer -= dt; if (state._damageBoostTimer <= 0) { state.damageBoost = Math.max(0, (state.damageBoost||0)-1); if (state.damageBoost>0) state._damageBoostTimer=60; } }
if (state.defenseBoost > 0 && state._defenseBoostTimer) { state._defenseBoostTimer -= dt; if (state._defenseBoostTimer <= 0) { state.defenseBoost = Math.max(0, (state.defenseBoost||0)-1); if (state.defenseBoost>0) state._defenseBoostTimer=60; } }
// Movement
let dx = 0, dy = 0;
if (keys['ArrowUp'] || keys['w'] || keys['W']) dy = -1;
if (keys['ArrowDown'] || keys['s'] || keys['S']) dy = 1;
if (keys['ArrowLeft'] || keys['a'] || keys['A']) dx = -1;
if (keys['ArrowRight'] || keys['d'] || keys['D']) dx = 1;
state.moving = (dx !== 0 || dy !== 0);
if (state.moving) {
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707; }
const speed = 160;
const nx = state.playerX + dx * speed * dt;
const ny = state.playerY + dy * speed * dt;
if (canMoveTo(nx, state.playerY, 8)) state.playerX = nx;
if (canMoveTo(state.playerX, ny, 8)) state.playerY = ny;
// Facing
if (Math.abs(dx) > Math.abs(dy)) state.facing = dx > 0 ? 1 : 3;
else state.facing = dy > 0 ? 2 : 0;
// Step sound
if (state.stepTimer <= 0) { sfx('step'); state.stepTimer = 0.35; }
}
// Clamp to room
state.playerX = Math.max(TILE/2, Math.min(MAP_W - TILE/2, state.playerX));
state.playerY = Math.max(TILE/2, Math.min(MAP_H - TILE/2, state.playerY));
// Attack
if (keys[' '] && state.attackCooldown <= 0 && state.attackTimer <= 0) {
state.attacking = true;
state.attackTimer = 0.2;
state.attackCooldown = state.weapon >= 2 ? 0.3 : (state.weapon >= 1 ? 0.4 : 0.5);
sfx('swing');
// Check melee hit
const atkRange = state.weapon >= 2 ? 40 : (state.weapon >= 1 ? 34 : 28);
const atkDmg = (state.weapon >= 2 ? 5 : (state.weapon >= 1 ? 3 : 2)) + (state.damageBoost||0);
const facingAngle = state.facing * Math.PI/2;
const atkCX = state.playerX + Math.cos(facingAngle) * (atkRange/2 + 8);
const atkCY = state.playerY + Math.sin(facingAngle) * (atkRange/2 + 8);
spawnParticles(atkCX, atkCY, 5, '#fff', 40, 0.2, 3);
const rs = getCurrentRoomState();
for (const enemy of rs.enemies) {
if (!enemy.alive) continue;
const ex = enemy.x * TILE + TILE/2;
const ey = enemy.y * TILE + TILE/2;
const edx = ex - state.playerX;
const edy = ey - state.playerY;
const dist = Math.sqrt(edx*edx + edy*edy);
// Check if enemy is in front arc
const dot = (Math.cos(facingAngle) * edx + Math.sin(facingAngle) * edy) / Math.max(0.1, dist);
if (dist < atkRange && dot > 0.3) {
enemy.hp -= atkDmg;
spawnParticles(ex, ey, 8, '#ff6b6b', 80, 0.3, 4);
sfx('hit');
if (enemy.hp <= 0) {
enemy.alive = false;
spawnParticles(ex, ey, 16, '#fbbf24', 100, 0.5, 5);
// Drop coins
const coinDrop = enemy.type==='boss'?50:(enemy.type==='skeleton'?8:4);
const bonus = state.luckyCharm ? Math.floor(coinDrop*0.5) : 0;
state.money += coinDrop + bonus;
showMessage(`+${coinDrop+bonus} coins`, 1200);
sfx('coin');
// Boss defeat
if (enemy.type === 'boss') {
state.bossDefeated = true;
showMessage('The Shadow Lord is defeated!', 4000);
sfx('upgrade');
}
}
}
}
}
// Ranged attack
if ((keys['f'] || keys['F']) && state.hasRanged && state.rangedCooldown <= 0 && state.rangedTimer <= 0) {
state.rangedTimer = 0.15;
state.rangedCooldown = 0.7;
sfx('bow');
const arrowDmg = 3 + (state.damageBoost||0);
const facingAngle = state.facing * Math.PI/2;
const ax = state.playerX + Math.cos(facingAngle) * 20;
const ay = state.playerY + Math.sin(facingAngle) * 20;
spawnParticles(ax, ay, 6, '#87ceeb', 30, 0.3, 2);
// Arrow projectile
state.arrow = { x:ax, y:ay, vx:Math.cos(facingAngle)*300, vy:Math.sin(facingAngle)*300, life:0.8, dmg:arrowDmg };
}
// Arrow update
if (state.arrow) {
state.arrow.x += state.arrow.vx * dt;
state.arrow.y += state.arrow.vy * dt;
state.arrow.life -= dt;
// Check wall collision
const atx = Math.floor(state.arrow.x / TILE);
const aty = Math.floor(state.arrow.y / TILE);
if (isSolid(getTile(state.worldX, state.worldY, atx, aty)) || state.arrow.life <= 0) {
spawnParticles(state.arrow.x, state.arrow.y, 4, '#87ceeb', 30, 0.2, 2);
state.arrow = null;
} else {
// Check enemy hit
const rs = getCurrentRoomState();
for (const enemy of rs.enemies) {
if (!enemy.alive) continue;
const ex = enemy.x * TILE + TILE/2;
const ey = enemy.y * TILE + TILE/2;
const dist = Math.hypot(state.arrow.x - ex, state.arrow.y - ey);
if (dist < 18) {
enemy.hp -= state.arrow.dmg;
spawnParticles(ex, ey, 8, '#87ceeb', 60, 0.3, 3);
sfx('hit');
if (enemy.hp <= 0) {
enemy.alive = false;
spawnParticles(ex, ey, 16, '#fbbf24', 100, 0.5, 5);
const coinDrop = enemy.type==='boss'?50:(enemy.type==='skeleton'?8:4);
const bonus = state.luckyCharm ? Math.floor(coinDrop*0.5) : 0;
state.money += coinDrop + bonus;
showMessage(`+${coinDrop+bonus} coins`, 1200);
sfx('coin');
if (enemy.type === 'boss') {
state.bossDefeated = true;
showMessage('The Shadow Lord is defeated!', 4000);
sfx('upgrade');
}
}
state.arrow = null;
break;
}
}
}
}
// Door transitions
const tile = getDoorTile(state.playerX, state.playerY);
if (tile === T.DOOR_U || tile === T.DOOR_D || tile === T.DOOR_L || tile === T.DOOR_R) {
// Apply door transition (with cooldown)
if (!state._doorCooldown) {
state._doorCooldown = 0.6;
let nwx = state.worldX, nwy = state.worldY;
let npx = state.playerX, npy = state.playerY;
if (tile === T.DOOR_U) { nwy--; npy = MAP_H - TILE*2; }
else if (tile === T.DOOR_D) { nwy++; npy = TILE*2; }
else if (tile === T.DOOR_L) { nwx--; npx = MAP_W - TILE*2; }
else if (tile === T.DOOR_R) { nwx++; npx = TILE*2; }
const nkey = getRoomKey(nwx, nwy);
if (worldGrid[nkey]) {
state.worldX = nwx; state.worldY = nwy;
state.playerX = npx; state.playerY = npy;
sfx('door');
showMessage('Entering new area...', 1500);
updateHUD();
}
}
}
if (state._doorCooldown) { state._doorCooldown -= dt; if (state._doorCooldown <= 0) state._doorCooldown = 0; }
// Pickups
const rs = getCurrentRoomState();
for (const pickup of rs.pickups) {
if (pickup.collected) continue;
const px = pickup.x * TILE + TILE/2;
const py = pickup.y * TILE + TILE/2;
if (Math.hypot(state.playerX - px, state.playerY - py) < 20) {
pickup.collected = true;
if (pickup.type === 'coin') {
state.money += pickup.amount;
showMessage(`+${pickup.amount} coins`, 1200);
sfx('coin');
spawnParticles(px, py, 8, '#d4a843', 50, 0.3, 3);
}
}
}
}
// ── Interact ──
function interact() {
const px = state.playerX, py = state.playerY;
// Check chests
const rs = getCurrentRoomState();
for (const chest of rs.chests) {
if (chest.opened) continue;
const cx = chest.x * TILE + TILE/2;
const cy = chest.y * TILE + TILE/2;
if (Math.hypot(px - cx, py - cy) < 36) {
chest.opened = true;
handleChestItem(chest);
sfx('pickup');
spawnParticles(cx, cy, 20, '#fbbf24', 80, 0.6, 5);
return;
}
}
// Check save point
const tile = getDoorTile(px, py);
const def = getCurrentRoomDef();
if (def) {
// Check save tile
for (let ty = 0; ty < ROOM_ROWS; ty++) {
for (let tx = 0; tx < ROOM_COLS; tx++) {
if (def.map[ty][tx] === T.SAVE) {
const sx = tx * TILE + TILE/2;
const sy = ty * TILE + TILE/2;
if (Math.hypot(px - sx, py - sy) < 32) {
saveGame();
sfx('save');
showMessage('Game saved! 💾', 2000);
spawnParticles(sx, sy, 14, '#5dade2', 60, 0.5, 4);
return;
}
}
}
}
// Check shop
if (def.roomType === 'shop') {
for (let ty = 0; ty < ROOM_ROWS; ty++) {
for (let tx = 0; tx < ROOM_COLS; tx++) {
if (def.map[ty][tx] === T.SHOP) {
const sx = tx * TILE + TILE/2;
const sy = ty * TILE + TILE/2;
if (Math.hypot(px - sx, py - sy) < 48) {
openShop();
return;
}
}
}
}
}
}
}
function handleChestItem(chest) {
switch(chest.item) {
case 'healthPotion':
state.inventory.push({id:'healthPotion', name:'Health Potion', icon:'🧪', desc:'Restore 2 HP', usable:true, effect:shopStock[0].effect});
showMessage('Found: Health Potion!', 2000);
break;
case 'heartContainer':
state.maxHp = Math.min(5, state.maxHp + 1);
state.hp = Math.min(state.maxHp, state.hp + 2);
showMessage('Heart Container! Max HP +1!', 2500);
sfx('upgrade');
break;
case 'heartContainer2':
state.maxHp = Math.min(5, state.maxHp + 1);
state.hp = Math.min(state.maxHp, state.hp + 2);
showMessage('Heart Container! Max HP +1!', 2500);
sfx('upgrade');
break;
case 'ironSword':
state.weapon = Math.max(1, state.weapon);
showMessage('Iron Blade acquired! Damage + Range up!', 2500);
sfx('upgrade');
break;
case 'magicBlade':
state.weapon = Math.max(2, state.weapon);
showMessage('Arcane Edge! Speed + Damage maxed!', 2500);
sfx('upgrade');
break;
case 'huntersBow':
state.hasRanged = true;
showMessage('Hunter\'s Bow! Press F to fire arrows!', 2500);
sfx('upgrade');
break;
case 'elixir':
state.inventory.push({id:'elixir', name:'Elixir of Power', icon:'⚗️', desc:'+1 damage for 60s', usable:true, effect:shopStock[1].effect});
showMessage('Found: Elixir of Power!', 2000);
break;
}
updateHUD();
saveGame();
}
// ── Enemy AI ──
function updateEnemies(dt) {
const rs = getCurrentRoomState();
for (const enemy of rs.enemies) {
if (!enemy.alive) continue;
const ex = enemy.x * TILE + TILE/2;
const ey = enemy.y * TILE + TILE/2;
const dx = state.playerX - ex;
const dy = state.playerY - ey;
const dist = Math.hypot(dx, dy);
if (enemy.type === 'boss') {
// Boss: chase but also strafe
if (dist < 250 && state.invincible <= 0) {
const speed = 55;
const ndx = dx / Math.max(0.1, dist);
const ndy = dy / Math.max(0.1, dist);
const nx = ex + ndx * speed * dt;
const ny = ey + ndy * speed * dt;
if (canMoveTo(nx, ey, 10)) enemy.x = nx / TILE - 0.5;
if (canMoveTo(ex, ny, 10)) enemy.y = ny / TILE - 0.5;
// Boss attack on contact
if (dist < 28) {
damagePlayer(2);
const knockX = state.playerX + ndx * 30;
const knockY = state.playerY + ndy * 30;
if (canMoveTo(knockX, state.playerY, 8)) state.playerX = knockX;
if (canMoveTo(state.playerX, knockY, 8)) state.playerY = knockY;
}
}
// Boss projectiles
if (!enemy._shotTimer) enemy._shotTimer = 0;
enemy._shotTimer -= dt;
if (enemy._shotTimer <= 0 && dist < 300) {
enemy._shotTimer = 1.5;
// Spawn projectile toward player
if (!state._bossProjectiles) state._bossProjectiles = [];
const angle = Math.atan2(dy, dx);
state._bossProjectiles.push({x:ex, y:ey, vx:Math.cos(angle)*120, vy:Math.sin(angle)*120, life:2.5});
}
} else if (enemy.type === 'skeleton') {
if (dist < 200 && dist > 35) {
const speed = 70;
const ndx = dx / Math.max(0.1, dist);
const ndy = dy / Math.max(0.1, dist);
const nx = ex + ndx * speed * dt;
const ny = ey + ndy * speed * dt;
if (canMoveTo(nx, ey, 9)) enemy.x = nx / TILE - 0.5;
if (canMoveTo(ex, ny, 9)) enemy.y = ny / TILE - 0.5;
}
if (dist < 28 && state.invincible <= 0) {
damagePlayer(1);
}
} else if (enemy.type === 'slime') {
if (dist < 180 && dist > 28) {
const speed = 50;
const ndx = dx / Math.max(0.1, dist);
const ndy = dy / Math.max(0.1, dist);
const nx = ex + ndx * speed * dt;
const ny = ey + ndy * speed * dt;
if (canMoveTo(nx, ey, 8)) enemy.x = nx / TILE - 0.5;
if (canMoveTo(ex, ny, 8)) enemy.y = ny / TILE - 0.5;
}
if (dist < 24 && state.invincible <= 0) {
damagePlayer(1);
}
}
}
// Boss projectiles
if (state._bossProjectiles) {
for (let i = state._bossProjectiles.length-1; i >= 0; i--) {
const p = state._bossProjectiles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
const ptx = Math.floor(p.x / TILE);
const pty = Math.floor(p.y / TILE);
if (isSolid(getTile(state.worldX, state.worldY, ptx, pty)) || p.life <= 0) {
spawnParticles(p.x, p.y, 5, '#ff4444', 40, 0.2, 3);
state._bossProjectiles.splice(i,1);
} else if (state.invincible <= 0 && Math.hypot(p.x - state.playerX, p.y - state.playerY) < 16) {
damagePlayer(1);
spawnParticles(p.x, p.y, 8, '#ff4444', 60, 0.3, 4);
state._bossProjectiles.splice(i,1);
}
}
}
}
function damagePlayer(amount) {
if (state.invincible > 0) return;
const actualDmg = Math.max(1, amount - (state.defenseBoost||0));
state.hp -= actualDmg;
state.invincible = 0.8;
sfx('playerHit');
spawnParticles(state.playerX, state.playerY, 10, '#ff0000', 100, 0.4, 5);
updateHUD();
if (state.hp <= 0) {
state.hp = 0;
deathScreen.classList.add('active');
}
}
// ── Render ──
function drawTile(tx, ty, screenX, screenY) {
const tile = getTile(state.worldX, state.worldY, tx, ty);
if (tile === T.WALL) {
ctx.fillStyle = '#3a3028';
ctx.fillRect(screenX, screenY, TILE, TILE);
ctx.fillStyle = '#4a3a2a';
ctx.fillRect(screenX+2, screenY+2, TILE-4, TILE-4);
// Brick lines
ctx.strokeStyle = '#2a1a0a';
ctx.lineWidth = 1;
ctx.strokeRect(screenX+1, screenY+1, TILE-2, TILE-2);
} else if (tile === T.WATER) {
ctx.fillStyle = '#1a4a6a';
ctx.fillRect(screenX, screenY, TILE, TILE);
ctx.fillStyle = '#1e5a7e';
for (let i = 0; i < 3; i++) {
ctx.fillRect(screenX+4+i*10, screenY+8+(i%2)*6, 6, 2);
}
} else if (tile === T.DOOR_U || tile === T.DOOR_D || tile === T.DOOR_L || tile === T.DOOR_R) {
ctx.fillStyle = '#5a4a1a';
ctx.fillRect(screenX, screenY, TILE, TILE);
ctx.fillStyle = '#8a7a3a';
ctx.fillRect(screenX+6, screenY+6, TILE-12, TILE-12);
// Arrow indicator
ctx.fillStyle = '#d4a843';
ctx.beginPath();
const cx = screenX + TILE/2, cy = screenY + TILE/2;
if (tile === T.DOOR_U) { ctx.moveTo(cx,cy-8); ctx.lineTo(cx-6,cy+2); ctx.lineTo(cx+6,cy+2); }
else if (tile === T.DOOR_D) { ctx.moveTo(cx,cy+8); ctx.lineTo(cx-6,cy-2); ctx.lineTo(cx+6,cy-2); }
else if (tile === T.DOOR_L) { ctx.moveTo(cx-8,cy); ctx.lineTo(cx+2,cy-6); ctx.lineTo(cx+2,cy+6); }
else { ctx.moveTo(cx+8,cy); ctx.lineTo(cx-2,cy-6); ctx.lineTo(cx-2,cy+6); }
ctx.fill();
} else if (tile === T.SAVE) {
ctx.fillStyle = '#3a3028';
ctx.fillRect(screenX, screenY, TILE, TILE);
// Save statue
ctx.fillStyle = '#7a8a9a';
ctx.fillRect(screenX+10, screenY+4, 12, 24);
ctx.fillStyle = '#5dade2';
ctx.beginPath(); ctx.arc(screenX+16, screenY+4, 6, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = '10px serif'; ctx.fillText('S', screenX+12, screenY+20);
} else if (tile === T.SHOP) {
ctx.fillStyle = '#5a3a1a';
ctx.fillRect(screenX, screenY, TILE, TILE);
ctx.fillStyle = '#8a6a3a';
ctx.fillRect(screenX+2, screenY+2, TILE-4, TILE-16);
ctx.fillStyle = '#d4a843';
ctx.font = '18px serif'; ctx.fillText('🏪', screenX+6, screenY+20);
} else if (tile === T.CHEST) {
ctx.fillStyle = '#3a3028';
ctx.fillRect(screenX, screenY, TILE, TILE);
// Check if opened
const rs = getCurrentRoomState();
const chest = rs.chests.find(c => Math.floor(c.x) === tx && Math.floor(c.y) === ty);
if (chest && chest.opened) {
ctx.fillStyle = '#6a5a2a';
ctx.fillRect(screenX+4, screenY+10, TILE-8, TILE-14);
ctx.fillStyle = '#8a7a3a';
ctx.fillRect(screenX+2, screenY+6, TILE-4, 6);
} else {
ctx.fillStyle = '#8a6a2a';
ctx.fillRect(screenX+4, screenY+8, TILE-8, TILE-12);
ctx.fillStyle = '#d4a843';
ctx.fillRect(screenX+2, screenY+4, TILE-4, 8);
ctx.fillStyle = '#fbbf24';
ctx.beginPath(); ctx.arc(screenX+TILE/2, screenY+6, 4, 0, Math.PI*2); ctx.fill();
}
} else {
// Floor
ctx.fillStyle = '#2a2a1a';
ctx.fillRect(screenX, screenY, TILE, TILE);
// Subtle texture
if ((tx + ty) % 2 === 0) {
ctx.fillStyle = '#2d2d1d';
ctx.fillRect(screenX, screenY, TILE, TILE);
}
ctx.fillStyle = '#252515';
ctx.fillRect(screenX+1, screenY+1, 1, 1);
}
}
function drawPlayer() {
const px = MAP_OX + state.playerX;
const py = MAP_OY + state.playerY;
if (state.invincible > 0 && Math.floor(state.invincible * 20) % 2 === 0) return;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath(); ctx.ellipse(px, py+10, 10, 4, 0, 0, Math.PI*2); ctx.fill();
// Body
ctx.fillStyle = '#4a90d9';
ctx.fillRect(px-7, py-10, 14, 18);
// Equipment-dependent visuals
// Sword on back/side
const weaponColors = ['#888', '#aaa', '#88ccff'];
const weaponSizes = [[16,3], [18,4], [20,5]];
const [wLen, wWid] = weaponSizes[state.weapon];
ctx.fillStyle = weaponColors[state.weapon];
ctx.save();
ctx.translate(px, py-2);
// Sword held in facing direction
const facingAngles = [-Math.PI/2, 0, Math.PI/2, Math.PI]; // up, right, down, left
const angle = facingAngles[state.facing];
if (state.attacking && state.attackTimer > 0.1) {
const swingAngle = angle + (state.attackTimer - 0.1) * 10 * (state.facing % 2 === 0 ? 1 : -1);
ctx.rotate(swingAngle);
ctx.fillRect(6, -wWid/2, wLen, wWid);
// Glow for magic blade
if (state.weapon >= 2) {
ctx.fillStyle = 'rgba(136,204,255,0.4)';
ctx.fillRect(4, -wWid-1, wLen+4, wWid*3);
}
} else {
ctx.rotate(angle + 0.3);
ctx.fillRect(6, -wWid/2, wLen, wWid);
}
ctx.restore();
// Bow on back if has ranged
if (state.hasRanged) {
ctx.strokeStyle = '#8B4513';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px-10, py-4, 10, -Math.PI*0.7, Math.PI*0.7);
ctx.stroke();
ctx.strokeStyle = '#654321';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px-10, py-14); ctx.lineTo(px-10, py+6); ctx.stroke();
}
// Head
ctx.fillStyle = '#f4c89a';
ctx.beginPath(); ctx.arc(px, py-12, 7, 0, Math.PI*2); ctx.fill();
// Eyes
ctx.fillStyle = '#222';
const eyeOffX = state.facing === 1 ? 3 : (state.facing === 3 ? -3 : 0);
const eyeOffY = state.facing === 2 ? 2 : (state.facing === 0 ? -2 : 0);
ctx.beginPath(); ctx.arc(px+2+eyeOffX*0.3, py-13+eyeOffY*0.3, 2, 0, Math.PI*2); ctx.fill();
// Legs
ctx.fillStyle = '#3a3028';
if (state.moving) {
const legAnim = Math.sin(Date.now() * 0.01) * 3;
ctx.fillRect(px-5, py+6, 4, 8+legAnim);
ctx.fillRect(px+1, py+6, 4, 8-legAnim);
} else {
ctx.fillRect(px-5, py+6, 4, 8);
ctx.fillRect(px+1, py+6, 4, 8);
}
}
function drawEnemy(enemy) {
const ex = MAP_OX + enemy.x * TILE + TILE/2;
const ey = MAP_OY + enemy.y * TILE + TILE/2;
if (enemy.type === 'slime') {
ctx.fillStyle = '#4ade80';
ctx.beginPath();
ctx.arc(ex, ey+2, 11, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#22c55e';
ctx.beginPath(); ctx.arc(ex, ey-2, 8, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(ex-3, ey-3, 2.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(ex+3, ey-3, 2.5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(ex-3, ey-3, 1.2, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(ex+3, ey-3, 1.2, 0, Math.PI*2); ctx.fill();
} else if (enemy.type === 'skeleton') {
ctx.fillStyle = '#ddd';
ctx.fillRect(ex-6, ey-10, 12, 18);
ctx.fillStyle = '#ccc';
ctx.beginPath(); ctx.arc(ex, ey-12, 7, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(ex-2, ey-13, 1.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(ex+2, ey-13, 1.5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#555';
ctx.fillRect(ex-8, ey-4, 4, 10);
ctx.fillRect(ex+4, ey-4, 4, 10);
} else if (enemy.type === 'boss') {
// Shadow Lord - larger, menacing
const bob = Math.sin(Date.now() * 0.003) * 3;
ctx.fillStyle = '#2a0a2a';
ctx.beginPath(); ctx.arc(ex, ey-2+bob, 18, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#4a1a4a';
ctx.beginPath(); ctx.arc(ex, ey-4+bob, 14, 0, Math.PI*2); ctx.fill();
// Horns
ctx.fillStyle = '#1a0a1a';
ctx.beginPath(); ctx.moveTo(ex-8, ey-14+bob); ctx.lineTo(ex-4, ey-22+bob); ctx.lineTo(ex-2, ey-14+bob); ctx.fill();
ctx.beginPath(); ctx.moveTo(ex+8, ey-14+bob); ctx.lineTo(ex+4, ey-22+bob); ctx.lineTo(ex+2, ey-14+bob); ctx.fill();
// Eyes
ctx.fillStyle = '#ff0000';
ctx.beginPath(); ctx.arc(ex-5, ey-5+bob, 3, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(ex+5, ey-5+bob, 3, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#ff6666';
ctx.beginPath(); ctx.arc(ex-5, ey-5+bob, 1.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(ex+5, ey-5+bob, 1.5, 0, Math.PI*2); ctx.fill();
// Aura
ctx.strokeStyle = 'rgba(255,0,0,0.2)';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(ex, ey+bob, 22, 0, Math.PI*2); ctx.stroke();
}
// HP bar for boss/skeletons
if (enemy.type === 'boss' || enemy.type === 'skeleton') {
const maxHp = enemy.type === 'boss' ? 12 : 5;
const barW = enemy.type === 'boss' ? 30 : 20;
ctx.fillStyle = '#333';
ctx.fillRect(ex-barW/2, ey-20, barW, 4);
ctx.fillStyle = enemy.type === 'boss' ? '#ff4444' : '#ff8844';
ctx.fillRect(ex-barW/2, ey-20, barW * (enemy.hp/maxHp), 4);
}
}
function render() {
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = '#0a0a10';
ctx.fillRect(0, 0, W, H);
// Room background
ctx.fillStyle = '#1a1a0a';
ctx.fillRect(MAP_OX, MAP_OY, MAP_W, MAP_H);
const def = getCurrentRoomDef();
if (!def) return;
// Draw tiles
for (let ty = 0; ty < ROOM_ROWS; ty++) {
for (let tx = 0; tx < ROOM_COLS; tx++) {
drawTile(tx, ty, MAP_OX + tx * TILE, MAP_OY + ty * TILE);
}
}
// Draw pickups
const rs = getCurrentRoomState();
for (const pickup of rs.pickups) {
if (pickup.collected) continue;
const px = MAP_OX + pickup.x * TILE + TILE/2;
const py = MAP_OY + pickup.y * TILE + TILE/2;
const bob = Math.sin(Date.now() * 0.004) * 3;
if (pickup.type === 'coin') {
ctx.fillStyle = '#d4a843';
ctx.beginPath(); ctx.arc(px, py+bob, 8, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fbbf24';
ctx.beginPath(); ctx.arc(px, py-2+bob, 5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px serif'; ctx.fillText('$', px-4, py+3+bob);
}
}
// Draw chests (unopened)
for (const chest of rs.chests) {
if (chest.opened) {
const cx = MAP_OX + chest.x * TILE + TILE/2;
const cy = MAP_OY + chest.y * TILE + TILE/2;
ctx.fillStyle = '#6a5a2a';
ctx.fillRect(cx-10, cy-4, 20, 14);
ctx.fillStyle = '#4a3a1a';
ctx.fillRect(cx-8, cy-6, 16, 4);
}
}
// Draw enemies
for (const enemy of rs.enemies) {
if (!enemy.alive) continue;
drawEnemy(enemy);
}
// Draw boss projectiles
if (state._bossProjectiles) {
for (const p of state._bossProjectiles) {
ctx.fillStyle = '#ff4444';
ctx.beginPath(); ctx.arc(MAP_OX + p.x, MAP_OY + p.y, 5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#ff8888';
ctx.beginPath(); ctx.arc(MAP_OX + p.x, MAP_OY + p.y, 3, 0, Math.PI*2); ctx.fill();
}
}
// Draw arrow
if (state.arrow) {
ctx.fillStyle = '#87ceeb';
ctx.beginPath(); ctx.arc(MAP_OX + state.arrow.x, MAP_OY + state.arrow.y, 3, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(MAP_OX + state.arrow.x, MAP_OY + state.arrow.y);
ctx.lineTo(MAP_OX + state.arrow.x - state.arrow.vx * 0.03, MAP_OY + state.arrow.y - state.arrow.vy * 0.03);
ctx.stroke();
}
// Draw player
drawPlayer();
// Draw particles
for (const p of particles) {
ctx.fillStyle = p.color.replace(')', `,${p.alpha})`).replace('rgb', 'rgba');
if (p.color.startsWith('#')) {
const hex = p.color;
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
ctx.fillStyle = `rgba(${r},${g},${b},${p.alpha})`;
}
ctx.beginPath(); ctx.arc(MAP_OX + p.x, MAP_OY + p.y, p.size, 0, Math.PI*2); ctx.fill();
}
// Room label
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '11px Georgia';
const roomNames = {
'0,0':'Sanctuary', '0,1':'Forest Path', '1,0':'Forest Clearing',
'1,1':'Dense Thicket', '2,0':'Cave Entrance', '2,1':'Deep Cavern',
'3,0':'Underground Market', '3,1':'Underground Lake', '2,2':'Shadow Passage',
'3,2':'Shadow Lord\'s Lair'
};
const rkey = getRoomKey(state.worldX, state.worldY);
const rname = roomNames[rkey] || 'Unknown';
ctx.fillText(rname, MAP_OX + 8, MAP_OY + MAP_H - 8);
// Minimap
drawMinimap();
}
function drawMinimap() {
minimapCtx.clearRect(0, 0, 100, 80);
minimapCtx.fillStyle = 'rgba(0,0,0,0.8)';
minimapCtx.fillRect(0, 0, 100, 80);
const cellW = 18, cellH = 16;
const ox = 14, oy = 4;
for (let wy = 0; wy < 4; wy++) {
for (let wx = 0; wx < 3; wx++) {
const key = getRoomKey(wx, wy);
const exists = !!worldGrid[key];
const isCurrent = wx === state.worldX && wy === state.worldY;
const visited = roomState[key] !== undefined;
if (exists) {
minimapCtx.fillStyle = isCurrent ? '#d4a843' : (visited ? '#5a7a5a' : '#333');
minimapCtx.fillRect(ox + wx * cellW, oy + wy * cellH, cellW-2, cellH-2);
if (isCurrent) {
minimapCtx.fillStyle = '#fff';
minimapCtx.fillRect(ox + wx * cellW + 6, oy + wy * cellH + 4, 4, 4);
}
}
}
}
// Legend
minimapCtx.fillStyle = '#888';
minimapCtx.font = '7px monospace';
minimapCtx.fillText('gold=here', 4, 76);
}
// ── Game Loop ──
let lastTime = performance.now();
let gameTime = 0;
function gameLoop(timestamp) {
let dt = (timestamp - lastTime) / 1000;
if (dt > 0.1) dt = 0.1; // Cap to prevent jumps
if (dt <= 0) dt = 0.016;
lastTime = timestamp;
gameTime += dt;
if (!deathScreen.classList.contains('active') && !shopPanel.classList.contains('open') && !invPanel.classList.contains('open')) {
updatePlayer(dt);
updateEnemies(dt);
}
// Update particles
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update(dt);
if (!particles[i].alive) particles.splice(i, 1);
}
render();
updateHUD();
requestAnimationFrame(gameLoop);
}
// ── Initialize ──
function init() {
// Try to load save
if (!loadGame()) {
resetGame();
}
updateHUD();
invPanel.classList.remove('open');
shopPanel.classList.remove('open');
deathScreen.classList.remove('active');
requestAnimationFrame(gameLoop);
}
// Click handler for audio init
document.addEventListener('click', initAudio, {once:true});
document.addEventListener('keydown', initAudio, {once:true});
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4-pro","provider":"openrouter","persona":"full","sessionId":"989c3403-c768-4c88-9c28-66c165da40fb","tokensIn":1885855,"tokensOut":45255,"tokensTotal":1931110,"turns":32,"toolCalls":31,"failedToolCalls":1,"timestamp":"2026-07-18T19:43:24.310Z"} -->