RPG
Grok 4.5 · full

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 101,976 bytes · SHA-256 084ca5543a9e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ember Path — Action RPG</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Share+Tech+Mono&display=swap');
:root {
--bg: #0b0e14;
--panel: #141a24;
--panel-border: #2a3548;
--accent: #e8a838;
--accent2: #4ecdc4;
--danger: #e74c3c;
--text: #e8eef7;
--muted: #7a8899;
--slot: #1c2433;
--hp: #e74c3c;
--hp-half: #c0392b;
--hp-empty: #3a2222;
--gold: #f1c40f;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Share Tech Mono', monospace;
overflow: hidden;
user-select: none;
}
#app {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100%;
max-width: 1200px;
margin: 0 auto;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: linear-gradient(180deg, #121824, #0d121a);
border-bottom: 2px solid var(--panel-border);
gap: 12px;
flex-wrap: wrap;
}
.brand {
font-family: 'Press Start 2P', monospace;
font-size: 12px;
color: var(--accent);
letter-spacing: 1px;
text-shadow: 0 0 12px rgba(232,168,56,0.45);
}
.hud-stats {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.stat {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.stat label { color: var(--muted); font-size: 11px; letter-spacing: 1px; }
#hearts {
display: flex;
gap: 4px;
align-items: center;
}
.heart {
width: 18px; height: 16px;
position: relative;
display: inline-block;
}
.heart::before, .heart::after {
content: '';
position: absolute;
width: 10px; height: 16px;
background: var(--hp-empty);
border-radius: 10px 10px 0 0;
top: 0;
}
.heart::before { left: 0; transform: rotate(-45deg); transform-origin: bottom right; }
.heart::after { right: 0; transform: rotate(45deg); transform-origin: bottom left; }
.heart.full::before, .heart.full::after { background: var(--hp); box-shadow: 0 0 6px rgba(231,76,60,0.6); }
.heart.half::before { background: var(--hp); }
.heart.half::after { background: var(--hp-empty); }
#gold-display { color: var(--gold); font-weight: bold; }
#weapon-display { color: var(--accent2); }
#zone-display { color: var(--accent); }
#game-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: #06080c;
overflow: hidden;
min-height: 0;
}
canvas#game {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
background: #1a2030;
box-shadow: 0 0 0 3px #2a3548, 0 10px 40px rgba(0,0,0,0.6);
max-width: 100%;
max-height: 100%;
cursor: crosshair;
}
/* Overlays */
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(6,8,12,0.86);
z-index: 20;
backdrop-filter: blur(3px);
}
.overlay.hidden { display: none; }
.panel {
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 10px;
padding: 28px 32px;
max-width: 560px;
width: 92%;
box-shadow: 0 20px 60px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.04);
}
.panel h1 {
font-family: 'Press Start 2P', monospace;
font-size: 18px;
color: var(--accent);
margin-bottom: 14px;
line-height: 1.5;
}
.panel h2 {
font-family: 'Press Start 2P', monospace;
font-size: 13px;
color: var(--accent2);
margin-bottom: 12px;
}
.panel p { color: var(--muted); line-height: 1.6; margin-bottom: 10px; font-size: 14px; }
.panel .story { color: var(--text); margin-bottom: 16px; }
.btn-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
button.btn {
font-family: 'Press Start 2P', monospace;
font-size: 10px;
padding: 12px 16px;
background: linear-gradient(180deg, #2a3a28, #1a2818);
color: #b8e0a0;
border: 2px solid #4a6a40;
border-radius: 6px;
cursor: pointer;
transition: transform 0.08s, box-shadow 0.08s, filter 0.08s;
}
button.btn:hover { filter: brightness(1.15); box-shadow: 0 0 16px rgba(78,205,100,0.25); }
button.btn:active { transform: translateY(1px); }
button.btn.primary {
background: linear-gradient(180deg, #6a4a18, #4a3210);
border-color: #c4a040;
color: #ffe9a0;
}
button.btn.danger {
background: linear-gradient(180deg, #4a2020, #301414);
border-color: #a04040;
color: #f0a0a0;
}
button.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
filter: none;
box-shadow: none;
}
/* Inventory / Shop panels */
.inv-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin: 12px 0;
}
.inv-slot {
aspect-ratio: 1;
background: var(--slot);
border: 2px solid var(--panel-border);
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 22px;
cursor: pointer;
position: relative;
transition: border-color 0.12s, background 0.12s;
}
.inv-slot:hover, .inv-slot.selected {
border-color: var(--accent);
background: #243044;
}
.inv-slot .qty {
position: absolute;
right: 4px; bottom: 2px;
font-size: 11px;
color: var(--text);
}
.inv-slot.empty { opacity: 0.45; cursor: default; }
.inv-detail {
min-height: 64px;
background: #0f141c;
border: 1px solid var(--panel-border);
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
color: var(--muted);
}
.inv-detail strong { color: var(--text); display: block; margin-bottom: 4px; }
.shop-list { display: flex; flex-direction: column; gap: 8px; max-height: 280px; overflow-y: auto; }
.shop-item {
display: grid;
grid-template-columns: 36px 1fr auto auto;
gap: 10px;
align-items: center;
background: var(--slot);
border: 1px solid var(--panel-border);
border-radius: 6px;
padding: 8px 10px;
}
.shop-item .ico { font-size: 22px; text-align: center; }
.shop-item .nm { color: var(--text); font-size: 13px; }
.shop-item .ds { color: var(--muted); font-size: 11px; }
.shop-item .pr { color: var(--gold); font-size: 13px; min-width: 52px; text-align: right; }
/* Bottom bar */
footer {
background: linear-gradient(0deg, #121824, #0d121a);
border-top: 2px solid var(--panel-border);
padding: 8px 14px;
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
}
.hotbar {
display: flex;
gap: 6px;
align-items: center;
}
.hot-slot {
width: 42px; height: 42px;
background: var(--slot);
border: 2px solid var(--panel-border);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
position: relative;
}
.hot-slot .key {
position: absolute;
top: -2px; left: 3px;
font-size: 9px;
color: var(--muted);
}
.hot-slot.active { border-color: var(--accent); box-shadow: 0 0 10px rgba(232,168,56,0.35); }
.controls-hint {
font-size: 11px;
color: var(--muted);
text-align: right;
line-height: 1.5;
}
.controls-hint kbd {
background: #1c2433;
border: 1px solid #3a4558;
border-radius: 3px;
padding: 1px 5px;
color: var(--text);
font-family: inherit;
font-size: 10px;
}
#toast {
position: absolute;
top: 18px;
left: 50%;
transform: translateX(-50%);
background: rgba(20,26,36,0.94);
border: 1px solid var(--accent);
color: var(--text);
padding: 10px 18px;
border-radius: 8px;
font-size: 13px;
z-index: 30;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
max-width: 80%;
text-align: center;
}
#toast.show {
opacity: 1;
transform: translateX(-50%) translateY(4px);
}
#dialogue {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
width: min(560px, 90%);
background: rgba(14,18,28,0.95);
border: 2px solid var(--accent2);
border-radius: 10px;
padding: 14px 16px;
z-index: 25;
display: none;
}
#dialogue.show { display: block; }
#dialogue .who {
font-family: 'Press Start 2P', monospace;
font-size: 10px;
color: var(--accent2);
margin-bottom: 8px;
}
#dialogue .txt { font-size: 14px; line-height: 1.5; color: var(--text); }
#dialogue .cont {
margin-top: 8px;
font-size: 11px;
color: var(--muted);
text-align: right;
}
#minimap {
position: absolute;
top: 12px;
right: 12px;
width: 120px;
height: 90px;
background: rgba(10,14,22,0.82);
border: 2px solid var(--panel-border);
border-radius: 6px;
z-index: 10;
image-rendering: pixelated;
}
#quest {
position: absolute;
top: 12px;
left: 12px;
background: rgba(10,14,22,0.82);
border: 1px solid var(--panel-border);
border-radius: 6px;
padding: 8px 10px;
z-index: 10;
max-width: 220px;
font-size: 11px;
}
#quest .qt {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: var(--accent);
margin-bottom: 6px;
}
#quest .qd { color: var(--muted); line-height: 1.4; }
#quest .qd b { color: var(--text); }
.dmg-flash {
position: absolute;
inset: 0;
background: rgba(200,30,30,0.22);
pointer-events: none;
opacity: 0;
z-index: 15;
transition: opacity 0.1s;
}
.dmg-flash.on { opacity: 1; }
@media (max-width: 700px) {
.brand { font-size: 9px; }
.controls-hint { display: none; }
header { padding: 6px 8px; }
.stat { font-size: 11px; }
}
</style>
</head>
<body>
<div id="app">
<header>
<div class="brand">EMBER PATH</div>
<div class="hud-stats">
<div class="stat"><label>HP</label><div id="hearts"></div></div>
<div class="stat"><label>GOLD</label><span id="gold-display">0</span></div>
<div class="stat"><label>WEAPON</label><span id="weapon-display">Oak Blade</span></div>
<div class="stat"><label>ZONE</label><span id="zone-display">Village</span></div>
</div>
</header>
<div id="game-wrap">
<canvas id="game" width="960" height="540"></canvas>
<canvas id="minimap" width="120" height="90"></canvas>
<div id="quest">
<div class="qt">QUEST</div>
<div class="qd" id="quest-text">Find Elder Myra in the village.</div>
</div>
<div id="toast"></div>
<div id="dialogue"><div class="who" id="dlg-who"></div><div class="txt" id="dlg-txt"></div><div class="cont">[E / Click] continue</div></div>
<div class="dmg-flash" id="dmg-flash"></div>
<!-- Title -->
<div class="overlay" id="overlay-title">
<div class="panel">
<h1>EMBER PATH</h1>
<p class="story">The Ashen Seal shattered. Shadows pour from the Hollow Spire. You are the last Pathwalker — blade in hand, embers in your blood.</p>
<p>Explore the wilds, forge your weapon, unlock forgotten arts, and seal the breach.</p>
<p style="color:var(--accent2);font-size:12px;">~ 45–60 min · Save at stone circles · Shop in the village</p>
<div class="btn-row">
<button class="btn primary" id="btn-new">NEW JOURNEY</button>
<button class="btn" id="btn-continue" disabled>CONTINUE</button>
</div>
</div>
</div>
<!-- Pause / Inventory -->
<div class="overlay hidden" id="overlay-inv">
<div class="panel" style="max-width:520px">
<h2>INVENTORY</h2>
<div class="inv-grid" id="inv-grid"></div>
<div class="inv-detail" id="inv-detail">Select an item.</div>
<div class="btn-row">
<button class="btn primary" id="btn-use-item">USE / EQUIP</button>
<button class="btn" id="btn-close-inv">CLOSE (I / ESC)</button>
</div>
</div>
</div>
<!-- Shop -->
<div class="overlay hidden" id="overlay-shop">
<div class="panel" style="max-width:540px">
<h2>MYRA'S EMPORIUM</h2>
<p style="margin-bottom:8px">Gold: <span id="shop-gold" style="color:var(--gold)">0</span></p>
<div class="shop-list" id="shop-list"></div>
<div class="btn-row">
<button class="btn" id="btn-close-shop">LEAVE SHOP</button>
</div>
</div>
</div>
<!-- Pause menu -->
<div class="overlay hidden" id="overlay-pause">
<div class="panel">
<h2>PAUSED</h2>
<p>Progress saves only at glowing Save Stones.</p>
<div class="btn-row">
<button class="btn primary" id="btn-resume">RESUME</button>
<button class="btn" id="btn-save-manual" title="Only works near a save stone">SAVE (need stone)</button>
<button class="btn danger" id="btn-to-title">TITLE SCREEN</button>
</div>
</div>
</div>
<!-- Victory / Defeat -->
<div class="overlay hidden" id="overlay-end">
<div class="panel">
<h1 id="end-title">VICTORY</h1>
<p class="story" id="end-text"></p>
<div class="btn-row">
<button class="btn primary" id="btn-end-title">TITLE SCREEN</button>
</div>
</div>
</div>
</div>
<footer>
<div class="hotbar">
<div class="hot-slot active" id="hot-melee" title="Melee"><span class="key">1</span><span id="hot-melee-ico">🗡</span></div>
<div class="hot-slot" id="hot-ranged" title="Ranged"><span class="key">2</span><span id="hot-ranged-ico">—</span></div>
<div class="hot-slot" id="hot-item" title="Quick item (Q)"><span class="key">Q</span><span id="hot-item-ico">+</span></div>
<div class="hot-slot" id="hot-bomb" title="Bomb (F)"><span class="key">F</span>💣</div>
</div>
<div class="controls-hint">
<kbd>WASD</kbd> move · <kbd>Space/Click</kbd> attack · <kbd>Shift</kbd> dash ·
<kbd>E</kbd> interact · <kbd>I</kbd> inventory · <kbd>1/2</kbd> weapons · <kbd>Esc</kbd> pause
</div>
</footer>
</div>
<script>
(() => {
'use strict';
// ============================================================
// AUDIO (WebAudio synthesized SFX)
// ============================================================
const AudioFX = (() => {
let ctx = null;
const ensure = () => {
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
if (ctx.state === 'suspended') ctx.resume();
return ctx;
};
const tone = (freq, dur, type = 'square', gain = 0.06, slide = 0) => {
try {
const c = ensure();
const t0 = c.currentTime;
const o = c.createOscillator();
const g = c.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t0);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t0 + dur);
g.gain.setValueAtTime(gain, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
o.connect(g); g.connect(c.destination);
o.start(t0); o.stop(t0 + dur + 0.02);
} catch (_) {}
};
const noise = (dur, gain = 0.04, bp = 800) => {
try {
const c = ensure();
const t0 = c.currentTime;
const n = c.createBufferSource();
const buf = c.createBuffer(1, c.sampleRate * dur, c.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
n.buffer = buf;
const f = c.createBiquadFilter();
f.type = 'bandpass'; f.frequency.value = bp;
const g = c.createGain();
g.gain.setValueAtTime(gain, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
n.connect(f); f.connect(g); g.connect(c.destination);
n.start(t0); n.stop(t0 + dur);
} catch (_) {}
};
return {
resume: ensure,
step: () => tone(120 + Math.random() * 40, 0.04, 'triangle', 0.02),
swing: () => { tone(280, 0.07, 'sawtooth', 0.05, -120); noise(0.06, 0.03, 1200); },
hit: () => { tone(180, 0.09, 'square', 0.07, -80); noise(0.08, 0.05, 600); },
hurt: () => { tone(140, 0.15, 'sawtooth', 0.08, -90); tone(90, 0.18, 'square', 0.05); },
pickup: () => { tone(520, 0.06, 'sine', 0.05); setTimeout(() => tone(780, 0.08, 'sine', 0.05), 50); },
coin: () => { tone(880, 0.05, 'square', 0.04); setTimeout(() => tone(1175, 0.08, 'square', 0.04), 40); },
shop: () => { tone(440, 0.08, 'triangle', 0.05); tone(554, 0.1, 'triangle', 0.04); },
buy: () => { tone(660, 0.07, 'sine', 0.05); setTimeout(() => tone(880, 0.1, 'sine', 0.05), 60); },
save: () => { tone(392, 0.1, 'sine', 0.05); setTimeout(() => tone(523, 0.12, 'sine', 0.05), 90); setTimeout(() => tone(659, 0.16, 'sine', 0.05), 180); },
dash: () => noise(0.1, 0.04, 400),
shoot: () => { tone(640, 0.05, 'square', 0.05, -300); noise(0.05, 0.03, 2000); },
explosion: () => { noise(0.25, 0.09, 200); tone(80, 0.3, 'sawtooth', 0.08, -40); },
heal: () => { tone(523, 0.08, 'sine', 0.05); setTimeout(() => tone(659, 0.08, 'sine', 0.05), 70); setTimeout(() => tone(784, 0.12, 'sine', 0.05), 140); },
levelup: () => { [523,659,784,1046].forEach((f,i) => setTimeout(() => tone(f, 0.12, 'triangle', 0.06), i * 90)); },
door: () => { tone(200, 0.1, 'triangle', 0.05); noise(0.12, 0.03, 300); },
puzzle: () => { tone(440, 0.08, 'sine', 0.05); setTimeout(() => tone(660, 0.12, 'sine', 0.05), 100); },
death: () => { tone(220, 0.4, 'sawtooth', 0.08, -160); noise(0.4, 0.05, 150); },
boss: () => { tone(55, 0.4, 'square', 0.08); noise(0.3, 0.06, 100); },
ui: () => tone(500, 0.04, 'sine', 0.03),
};
})();
// ============================================================
// CONSTANTS & DATA
// ============================================================
const TILE = 48;
const VIEW_W = 960;
const VIEW_H = 540;
const COLS = 40;
const ROWS = 30;
const T = {
GRASS: 0, PATH: 1, WATER: 2, WALL: 3, TREE: 4, SAND: 5,
FLOOR: 6, LAVA: 7, BRIDGE: 8, BUSH: 9, ROCK: 10, FLOWER: 11,
CARPET: 12, SNOW: 13, ASH: 14, SPIKE: 15,
};
const WEAPONS = {
oak: {
id: 'oak', name: 'Oak Blade', tier: 0,
dmg: 1, range: 42, cooldown: 0.38, knock: 180, color: '#c4a574',
arc: 0.9, desc: 'A sturdy starter blade.',
},
ember: {
id: 'ember', name: 'Ember Edge', tier: 1,
dmg: 2, range: 48, cooldown: 0.32, knock: 220, color: '#e07030',
arc: 1.1, burn: true, desc: 'Forged in village fire. Burns foes.',
},
storm: {
id: 'storm', name: 'Storm Cleaver', tier: 2,
dmg: 3, range: 58, cooldown: 0.28, knock: 280, color: '#70d0ff',
arc: 1.35, chain: true, desc: 'Lightning leaps between enemies.',
},
};
const RANGED = {
none: null,
ashbow: {
id: 'ashbow', name: 'Ash Bow',
dmg: 1, speed: 420, cooldown: 0.48, range: 16, color: '#d0a060',
desc: 'Found in the Ruined Watch. Pierces shade.',
},
};
// Accessories & consumables (5+ distinct)
const ITEMS = {
potion: {
id: 'potion', name: 'Life Vial', type: 'consumable', icon: '🧪',
desc: 'Restore 2 HP.', price: 15, stack: true,
use: (p) => { if (p.hp >= p.maxHp) return false; p.hp = Math.min(p.maxHp, p.hp + 2); AudioFX.heal(); spawnHeal(p.x, p.y); return true; },
},
elixir: {
id: 'elixir', name: 'Ember Elixir', type: 'consumable', icon: '🧴',
desc: 'Fully restore HP.', price: 40, stack: true,
use: (p) => { if (p.hp >= p.maxHp) return false; p.hp = p.maxHp; AudioFX.heal(); spawnHeal(p.x, p.y); return true; },
},
bomb: {
id: 'bomb', name: 'Powder Bomb', type: 'consumable', icon: '💣',
desc: 'Place a bomb (F). Damages foes & cracked walls.', price: 20, stack: true,
use: () => false, // used via F key
},
amulet: {
id: 'amulet', name: 'Guardian Amulet', type: 'accessory', icon: '📿',
desc: 'Equipped: +1 max HP (heart container half).', price: 50, stack: false,
onEquip: (p) => { p.maxHp = Math.min(5, p.baseMaxHp + 1 + (p.accessories.includes('ring') ? 1 : 0)); p.hp = Math.min(p.hp + 1, p.maxHp); },
onUnequip: (p) => { p.maxHp = p.baseMaxHp + (p.accessories.includes('ring') ? 1 : 0); p.hp = Math.min(p.hp, p.maxHp); },
},
boots: {
id: 'boots', name: 'Windstep Boots', type: 'accessory', icon: '👢',
desc: 'Equipped: faster move & lower dash cooldown.', price: 35, stack: false,
onEquip: (p) => { p.speedMod = 1.22; p.dashCdMod = 0.7; },
onUnequip: (p) => { p.speedMod = 1; p.dashCdMod = 1; },
},
ring: {
id: 'ring', name: 'Heartstone Ring', type: 'accessory', icon: '💍',
desc: 'Equipped: +1 max HP. Found in Crystal Caves.', price: 0, stack: false,
onEquip: (p) => { p.maxHp = Math.min(5, p.baseMaxHp + 1 + (p.accessories.includes('amulet') ? 1 : 0)); p.hp = Math.min(p.hp + 1, p.maxHp); },
onUnequip: (p) => { p.maxHp = p.baseMaxHp + (p.accessories.includes('amulet') ? 1 : 0); p.hp = Math.min(p.hp, p.maxHp); },
},
charm: {
id: 'charm', name: 'Lucky Charm', type: 'accessory', icon: '🍀',
desc: 'Equipped: enemies drop more gold.', price: 30, stack: false,
onEquip: (p) => { p.goldMod = 1.5; },
onUnequip: (p) => { p.goldMod = 1; },
},
map: {
id: 'map', name: 'Cartographer\'s Map', type: 'key', icon: '🗺️',
desc: 'Reveals secrets on the minimap.', price: 25, stack: false,
},
key_iron: {
id: 'key_iron', name: 'Iron Key', type: 'key', icon: '🔑',
desc: 'Opens iron gates.', price: 0, stack: true,
},
key_ember: {
id: 'key_ember', name: 'Ember Key', type: 'key', icon: '🗝️',
desc: 'Opens the Spire gate.', price: 0, stack: false,
},
};
const SHOP_STOCK = ['potion', 'elixir', 'bomb', 'boots', 'charm', 'amulet', 'map'];
// ============================================================
// STATE
// ============================================================
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const mmCanvas = document.getElementById('minimap');
const mmCtx = mmCanvas.getContext('2d');
const keys = Object.create(null);
const mouse = { x: 0, y: 0, down: false, worldX: 0, worldY: 0 };
let state = 'title'; // title | play | dialogue | inv | shop | pause | end
let lastT = 0;
let gameTime = 0;
const camera = { x: 0, y: 0 };
// particles, float texts, projectiles, bombs
const particles = [];
const floaters = [];
const projectiles = [];
const bombs = [];
const hitsplats = [];
// World
let world = null;
let player = null;
let flags = null;
let questId = 'meet_elder';
const QUESTS = {
meet_elder: { text: 'Speak with <b>Elder Myra</b> in the village square.' },
get_blade: { text: 'Claim the <b>Ember Edge</b> from the forge after clearing the grove.' },
open_wilds: { text: 'Explore the <b>Wilds</b>. Find the ruined watchtower.' },
find_bow: { text: 'Retrieve the <b>Ash Bow</b> from the Ruined Watch.' },
crystal: { text: 'Delve the <b>Crystal Caves</b> east of the wilds. Claim the Heartstone.' },
storm_forge: { text: 'Return to the forge with rare ore for the <b>Storm Cleaver</b>.' },
spire: { text: 'Use the Ember Key. Enter the <b>Hollow Spire</b> and seal the breach.' },
done: { text: 'The Seal is restored. Rest, Pathwalker.' },
};
// ============================================================
// UTIL
// ============================================================
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const dist = (ax, ay, bx, by) => Math.hypot(bx - ax, by - ay);
const rand = (a, b) => a + Math.random() * (b - a);
const irand = (a, b) => (a + Math.floor(Math.random() * (b - a + 1)));
const choice = (arr) => arr[Math.floor(Math.random() * arr.length)];
const angTo = (ax, ay, bx, by) => Math.atan2(by - ay, bx - ax);
function toast(msg, ms = 2200) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove('show'), ms);
}
function updateQuestUI() {
document.getElementById('quest-text').innerHTML = (QUESTS[questId] || QUESTS.done).text;
}
function updateHearts() {
const el = document.getElementById('hearts');
el.innerHTML = '';
// each heart = 2 HP; maxHp up to 5
const slots = Math.ceil(player.maxHp / 2);
for (let i = 0; i < slots; i++) {
const h = document.createElement('div');
h.className = 'heart';
const filled = player.hp - i * 2;
if (filled >= 2) h.classList.add('full');
else if (filled === 1) h.classList.add('half');
el.appendChild(h);
}
}
function updateHUD() {
if (!player) return;
updateHearts();
document.getElementById('gold-display').textContent = Math.floor(player.gold);
document.getElementById('weapon-display').textContent = WEAPONS[player.weapon].name;
document.getElementById('zone-display').textContent = currentZoneName();
document.getElementById('hot-melee-ico').textContent = player.weapon === 'storm' ? '⚡' : player.weapon === 'ember' ? '🔥' : '🗡';
document.getElementById('hot-ranged-ico').textContent = player.ranged ? '🏹' : '—';
document.getElementById('hot-ranged').style.opacity = player.ranged ? '1' : '0.35';
document.getElementById('hot-melee').classList.toggle('active', player.mode === 'melee');
document.getElementById('hot-ranged').classList.toggle('active', player.mode === 'ranged');
const pots = countItem('potion');
document.getElementById('hot-item-ico').textContent = pots > 0 ? '🧪' : '+';
}
function countItem(id) {
return player.inventory.filter(i => i === id).length;
}
function hasItem(id) {
return player.inventory.includes(id);
}
function giveItem(id, n = 1) {
for (let i = 0; i < n; i++) player.inventory.push(id);
AudioFX.pickup();
toast(`Got ${ITEMS[id].icon} ${ITEMS[id].name}`);
}
function takeItem(id, n = 1) {
for (let k = 0; k < n; k++) {
const ix = player.inventory.indexOf(id);
if (ix < 0) return false;
player.inventory.splice(ix, 1);
}
return true;
}
function equipAccessory(id) {
const it = ITEMS[id];
if (!it || it.type !== 'accessory') return;
if (player.accessories.includes(id)) return;
// max 3 accessories
if (player.accessories.length >= 3) {
toast('Accessory limit (3). Unequip one first.');
return;
}
if (!hasItem(id)) return;
player.accessories.push(id);
if (it.onEquip) it.onEquip(player);
AudioFX.levelup();
toast(`Equipped ${it.name}`);
updateHUD();
}
function unequipAccessory(id) {
const ix = player.accessories.indexOf(id);
if (ix < 0) return;
player.accessories.splice(ix, 1);
const it = ITEMS[id];
if (it.onUnequip) it.onUnequip(player);
updateHUD();
}
// ============================================================
// PARTICLES
// ============================================================
function spawnParticles(x, y, n, color, speed = 80, life = 0.5, size = 3) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const s = rand(speed * 0.3, speed);
particles.push({
x, y,
vx: Math.cos(a) * s, vy: Math.sin(a) * s,
life, max: life,
color, size: rand(size * 0.5, size),
gravity: 0,
});
}
}
function spawnHeal(x, y) {
for (let i = 0; i < 12; i++) {
particles.push({
x: x + rand(-12, 12), y: y + rand(-8, 8),
vx: rand(-10, 10), vy: rand(-40, -20),
life: 0.7, max: 0.7, color: '#5d5', size: 3, gravity: -20,
});
}
}
function spawnFloat(x, y, text, color = '#fff') {
floaters.push({ x, y, text, color, life: 0.9, vy: -30 });
}
// ============================================================
// WORLD GENERATION
// ============================================================
function makeGrid(fill) {
const g = new Array(ROWS);
for (let y = 0; y < ROWS; y++) {
g[y] = new Array(COLS);
for (let x = 0; x < COLS; x++) g[y][x] = fill;
}
return g;
}
function inBounds(tx, ty) {
return tx >= 0 && ty >= 0 && tx < COLS && ty < ROWS;
}
function tileAt(px, py) {
const tx = Math.floor(px / TILE);
const ty = Math.floor(py / TILE);
if (!inBounds(tx, ty)) return T.WALL;
return world.tiles[ty][tx];
}
function setTile(tx, ty, v) {
if (inBounds(tx, ty)) world.tiles[ty][tx] = v;
}
function solidTile(t) {
return t === T.WALL || t === T.WATER || t === T.TREE || t === T.ROCK || t === T.LAVA;
}
function blocks(px, py, r = 10) {
// circle vs tile solids
const pts = [
[px - r, py - r], [px + r, py - r], [px - r, py + r], [px + r, py + r],
[px, py - r], [px, py + r], [px - r, py], [px + r, py],
];
for (const [x, y] of pts) {
if (solidTile(tileAt(x, y))) return true;
}
// entity walls / doors
for (const e of world.entities) {
if (e.solid && dist(px, py, e.x, e.y) < r + (e.radius || 16)) {
if ((e.type === 'door' || e.type === 'gate') && e.open) continue;
if (e.type === 'cracked' && e.broken) continue;
return true;
}
}
return false;
}
function tryMove(ent, dx, dy, r = 10) {
const nx = ent.x + dx;
const ny = ent.y + dy;
if (!blocks(nx, ent.y, r)) ent.x = nx;
if (!blocks(ent.x, ny, r)) ent.y = ny;
}
function zoneAt(px, py) {
// regions of the overworld map
const tx = px / TILE, ty = py / TILE;
if (tx < 12 && ty < 12) return 'village';
if (tx < 12 && ty >= 12 && ty < 20) return 'grove';
if (tx >= 12 && tx < 24 && ty < 14) return 'wilds';
if (tx >= 12 && tx < 24 && ty >= 14 && ty < 22) return 'watch';
if (tx >= 24 && ty < 16) return 'caves';
if (tx >= 24 && ty >= 16) return 'spire';
if (ty >= 22) return 'shore';
return 'wilds';
}
function currentZoneName() {
if (!player) return '—';
const z = zoneAt(player.x, player.y);
return ({
village: 'Village',
grove: 'Whisper Grove',
wilds: 'Ashen Wilds',
watch: 'Ruined Watch',
caves: 'Crystal Caves',
spire: 'Hollow Spire',
shore: 'Gray Shore',
})[z] || z;
}
function buildWorld() {
const tiles = makeGrid(T.GRASS);
const entities = [];
const enemies = [];
const pickups = [];
// helpers
const fillRect = (x, y, w, h, t) => {
for (let j = y; j < y + h; j++)
for (let i = x; i < x + w; i++)
if (inBounds(i, j)) tiles[j][i] = t;
};
const ring = (x, y, w, h, t) => {
for (let i = x; i < x + w; i++) {
if (inBounds(i, y)) tiles[y][i] = t;
if (inBounds(i, y + h - 1)) tiles[y + h - 1][i] = t;
}
for (let j = y; j < y + h; j++) {
if (inBounds(x, j)) tiles[j][x] = t;
if (inBounds(x + w - 1, j)) tiles[j][x + w - 1] = t;
}
};
const scatter = (x, y, w, h, t, chance) => {
for (let j = y; j < y + h; j++)
for (let i = x; i < x + w; i++)
if (inBounds(i, j) && Math.random() < chance && tiles[j][i] === T.GRASS)
tiles[j][i] = t;
};
// --- VILLAGE (0,0)-(12,12) ---
fillRect(0, 0, 12, 12, T.PATH);
fillRect(1, 1, 10, 10, T.GRASS);
fillRect(4, 4, 4, 4, T.PATH);
// buildings (walls)
fillRect(1, 1, 3, 3, T.WALL); // house
tiles[3][2] = T.PATH; // door gap
fillRect(8, 1, 3, 3, T.WALL); // shop
tiles[3][9] = T.PATH;
fillRect(1, 7, 3, 3, T.WALL); // forge
tiles[8][3] = T.PATH;
// carpet plaza
fillRect(5, 5, 2, 2, T.CARPET);
scatter(1, 1, 10, 10, T.FLOWER, 0.08);
// --- GROVE south of village ---
fillRect(0, 12, 12, 8, T.GRASS);
scatter(0, 12, 12, 8, T.TREE, 0.22);
scatter(0, 12, 12, 8, T.BUSH, 0.08);
fillRect(5, 12, 2, 8, T.PATH); // road south
// clear grove center ritual
fillRect(3, 15, 5, 4, T.GRASS);
fillRect(4, 16, 3, 2, T.FLOWER);
// water band
fillRect(0, 20, 14, 3, T.WATER);
fillRect(5, 20, 2, 3, T.BRIDGE);
fillRect(0, 23, 40, 7, T.SAND);
scatter(0, 23, 40, 7, T.ROCK, 0.05);
// --- WILDS ---
fillRect(12, 0, 12, 14, T.GRASS);
scatter(12, 0, 12, 14, T.TREE, 0.1);
scatter(12, 0, 12, 14, T.BUSH, 0.06);
scatter(12, 0, 12, 14, T.ROCK, 0.03);
fillRect(11, 5, 8, 2, T.PATH); // east road
// --- WATCH / ruins ---
fillRect(12, 14, 12, 8, T.ASH);
ring(14, 15, 8, 6, T.WALL);
fillRect(15, 16, 6, 4, T.FLOOR);
tiles[18][14] = T.FLOOR; // entrance
tiles[18][15] = T.FLOOR;
// cracked wall inside
// internal
fillRect(17, 16, 1, 3, T.WALL);
// --- CAVES ---
fillRect(24, 0, 16, 16, T.ROCK);
fillRect(25, 1, 14, 14, T.FLOOR);
// crystal lake
fillRect(30, 6, 5, 4, T.WATER);
fillRect(24, 7, 2, 2, T.FLOOR); // entrance corridor
tiles[7][24] = T.FLOOR;
tiles[8][24] = T.FLOOR;
// maze walls
fillRect(27, 2, 1, 5, T.ROCK);
fillRect(27, 8, 1, 5, T.ROCK);
fillRect(32, 2, 1, 4, T.ROCK);
fillRect(34, 10, 4, 1, T.ROCK);
fillRect(28, 12, 6, 1, T.ROCK);
// spike traps
tiles[4][29] = T.SPIKE;
tiles[5][35] = T.SPIKE;
tiles[11][31] = T.SPIKE;
// --- SPIRE ---
fillRect(24, 16, 16, 14, T.ASH);
// outer walls
ring(28, 18, 10, 10, T.WALL);
fillRect(29, 19, 8, 8, T.FLOOR);
fillRect(32, 27, 2, 2, T.FLOOR); // door
// lava moat
fillRect(28, 27, 4, 1, T.LAVA);
fillRect(34, 27, 4, 1, T.LAVA);
fillRect(32, 27, 2, 1, T.BRIDGE);
// inner sanctum
fillRect(31, 21, 4, 4, T.CARPET);
tiles[20][32] = T.FLOOR;
tiles[20][33] = T.FLOOR;
// road eccentricity
fillRect(11, 8, 1, 1, T.PATH);
// ========== ENTITIES ==========
// NPCs
entities.push({
type: 'npc', id: 'elder', name: 'Elder Myra',
x: 6.5 * TILE, y: 5.5 * TILE, radius: 14, solid: true,
color: '#c9a0e8', glyph: 'M',
lines: [],
});
entities.push({
type: 'npc', id: 'smith', name: 'Smith Borin',
x: 2.5 * TILE, y: 8.5 * TILE, radius: 14, solid: true,
color: '#d08050', glyph: 'B',
});
entities.push({
type: 'npc', id: 'shopkeep', name: 'Shopkeep Lila',
x: 9.5 * TILE, y: 2.5 * TILE, radius: 14, solid: true,
color: '#50c8a0', glyph: 'L', shop: true,
});
entities.push({
type: 'npc', id: 'scout', name: 'Scout Renn',
x: 10.5 * TILE, y: 10.5 * TILE, radius: 14, solid: true,
color: '#80b0e0', glyph: 'R',
});
// Save stones
const saveSpots = [
[6, 9], [6, 18], [18, 6], [18, 18], [30, 3], [32, 25],
];
for (const [sx, sy] of saveSpots) {
entities.push({
type: 'save', x: (sx + 0.5) * TILE, y: (sy + 0.5) * TILE,
radius: 16, solid: false, pulse: 0,
});
if (tiles[sy][sx] === T.WALL || tiles[sy][sx] === T.WATER || tiles[sy][sx] === T.ROCK)
tiles[sy][sx] = T.PATH;
}
// Chests & pickups markers as entities
entities.push({ type: 'chest', id: 'chest_grove', x: 5.5 * TILE, y: 17.5 * TILE, radius: 14, solid: true, open: false, loot: () => {
if (!flags.ember_ready) { toast('The chest is sealed by grove spirits...'); return; }
flags.chest_grove = true;
if (player.weapon === 'oak') {
player.weapon = 'ember';
AudioFX.levelup();
toast('Ember Edge obtained!');
spawnParticles(player.x, player.y, 24, '#e07030', 140, 0.7, 4);
questId = 'open_wilds';
updateQuestUI();
} else {
player.gold += 20;
toast('+20 gold');
}
}});
entities.push({ type: 'chest', id: 'chest_bow', x: 19.5 * TILE, y: 16.5 * TILE, radius: 14, solid: true, open: false, loot: () => {
flags.chest_bow = true;
player.ranged = 'ashbow';
player.mode = 'ranged';
AudioFX.levelup();
toast('Ash Bow recovered! Press 2 to aim & fire.');
spawnParticles(player.x, player.y, 28, '#d0a060', 150, 0.8, 4);
questId = 'crystal';
updateQuestUI();
// drop potion too
giveItem('potion', 1);
}});
entities.push({ type: 'chest', id: 'chest_ring', x: 36.5 * TILE, y: 13.5 * TILE, radius: 14, solid: true, open: false, loot: () => {
flags.chest_ring = true;
giveItem('ring');
equipAccessory('ring');
toast('Heartstone Ring — max HP increased!');
questId = flags.has_ore ? 'storm_forge' : 'crystal';
if (flags.has_ore) questId = 'storm_forge';
updateQuestUI();
}});
entities.push({ type: 'chest', id: 'chest_ore', x: 26.5 * TILE, y: 3.5 * TILE, radius: 14, solid: true, open: false, loot: () => {
flags.chest_ore = true;
flags.has_ore = true;
toast('Storm Ore acquired! Return it to Smith Borin.');
giveItem('potion', 1);
questId = 'storm_forge';
updateQuestUI();
}});
entities.push({ type: 'chest', id: 'chest_spire', x: 33.5 * TILE, y: 22.5 * TILE, radius: 14, solid: true, open: false, loot: () => {
flags.chest_spire = true;
giveItem('elixir', 1);
player.gold += 40;
toast('Spire cache: Elixir + 40 gold');
}});
// Pressure plate puzzle in caves
entities.push({
type: 'plate', id: 'plate1', x: 29.5 * TILE, y: 9.5 * TILE, radius: 12, solid: false, on: false,
});
entities.push({
type: 'plate', id: 'plate2', x: 35.5 * TILE, y: 4.5 * TILE, radius: 12, solid: false, on: false,
});
entities.push({
type: 'gate', id: 'crystal_gate', x: 36.5 * TILE, y: 12.5 * TILE, radius: 20, solid: true, open: false,
color: '#80e0ff',
});
// Iron gate to wilds (east of village) — opens after grove cleared
entities.push({
type: 'gate', id: 'wilds_gate', x: 11.5 * TILE, y: 5.5 * TILE, radius: 22, solid: true, open: false,
color: '#8899aa', needs: 'grove_cleared',
});
// Spire gate
entities.push({
type: 'door', id: 'spire_door', x: 32.5 * TILE, y: 27.5 * TILE, radius: 20, solid: true, open: false,
needsKey: 'key_ember', color: '#e06040',
});
// Cracked walls (bombable)
entities.push({
type: 'cracked', id: 'crack1', x: 17.5 * TILE, y: 17.5 * TILE, radius: 18, solid: true, broken: false,
tx: 17, ty: 17,
});
entities.push({
type: 'cracked', id: 'crack2', x: 31.5 * TILE, y: 11.5 * TILE, radius: 18, solid: true, broken: false,
tx: 31, ty: 11,
});
// Signs
entities.push({
type: 'sign', x: 7.5 * TILE, y: 11.5 * TILE, radius: 12, solid: false,
text: 'Whisper Grove — restless sprites guard an old chest.',
});
entities.push({
type: 'sign', x: 13.5 * TILE, y: 5.5 * TILE, radius: 12, solid: false,
text: 'Ashen Wilds — watchtowers hold old war-tech. Beware shades.',
});
entities.push({
type: 'sign', x: 25.5 * TILE, y: 8.5 * TILE, radius: 12, solid: false,
text: 'Crystal Caves — twin plates awaken the azure gate.',
});
// Boss pedestal / seal
entities.push({
type: 'seal', id: 'final_seal', x: 32.5 * TILE, y: 22.5 * TILE, radius: 22, solid: false,
active: true,
});
// ========== ENEMIES ==========
const addEnemy = (kind, tx, ty, opts = {}) => {
const defs = {
slime: { hp: 3, spd: 40, dmg: 1, radius: 12, color: '#5cb85c', xp: 3, gold: [1, 4], ai: 'chase' },
sprite: { hp: 2, spd: 70, dmg: 1, radius: 10, color: '#c0e060', xp: 4, gold: [2, 5], ai: 'dart' },
shade: { hp: 5, spd: 55, dmg: 1, radius: 13, color: '#6060a0', xp: 6, gold: [3, 8], ai: 'chase' },
crystal: { hp: 6, spd: 45, dmg: 1, radius: 14, color: '#60d8ff', xp: 8, gold: [5, 12], ai: 'shoot' },
ashguard: { hp: 10, spd: 50, dmg: 1, radius: 15, color: '#c05030', xp: 12, gold: [8, 16], ai: 'chase' },
boss: { hp: 40, spd: 60, dmg: 1, radius: 22, color: '#a02040', xp: 50, gold: [40, 60], ai: 'boss' },
};
const d = defs[kind];
enemies.push({
kind, ...d, ...opts,
x: (tx + 0.5) * TILE, y: (ty + 0.5) * TILE,
maxHp: opts.hp || d.hp, hp: opts.hp || d.hp,
vx: 0, vy: 0, hurt: 0, cd: 0, alive: true,
phase: 0, timer: rand(0, 2), facing: 0,
homeX: (tx + 0.5) * TILE, homeY: (ty + 0.5) * TILE,
});
};
// Grove
addEnemy('slime', 3, 14);
addEnemy('slime', 8, 15);
addEnemy('sprite', 4, 17);
addEnemy('sprite', 7, 18);
addEnemy('slime', 2, 18);
// Wilds
addEnemy('slime', 14, 3);
addEnemy('slime', 20, 4);
addEnemy('shade', 16, 8);
addEnemy('shade', 21, 10);
addEnemy('sprite', 18, 2);
addEnemy('slime', 15, 11);
// Watch
addEnemy('shade', 16, 17);
addEnemy('shade', 20, 17);
addEnemy('ashguard', 18, 16);
// Caves
addEnemy('crystal', 28, 4);
addEnemy('crystal', 34, 5);
addEnemy('slime', 29, 11);
addEnemy('crystal', 33, 13);
addEnemy('shade', 26, 12);
// Spire approach
addEnemy('ashguard', 26, 20);
addEnemy('ashguard', 36, 20);
addEnemy('shade', 30, 24);
addEnemy('shade', 35, 24);
// Boss
addEnemy('boss', 32, 22, { hp: 40 });
// ========== LOOSE PICKUPS ==========
const addCoin = (tx, ty, n = 5) => {
pickups.push({ type: 'gold', x: (tx + 0.5) * TILE, y: (ty + 0.5) * TILE, v: n, r: 8 });
};
const addPot = (tx, ty) => {
pickups.push({ type: 'item', id: 'potion', x: (tx + 0.5) * TILE, y: (ty + 0.5) * TILE, r: 10 });
};
addCoin(9, 9, 5);
addCoin(3, 10, 3);
addCoin(14, 13, 8);
addCoin(22, 7, 6);
addPot(19, 12);
addCoin(27, 14, 10);
// hidden bomb pickup shore
pickups.push({ type: 'item', id: 'bomb', x: 3.5 * TILE, y: 25.5 * TILE, r: 10 });
pickups.push({ type: 'item', id: 'bomb', x: 22.5 * TILE, y: 19.5 * TILE, r: 10 });
// Ember key held by… we'll drop from boss or place after mid
// Place key after caves progress, near spire entrance after ore/ring? drop from ashguard captain
// Actually give key at seal approach via special enemy drop - place pickup after boss of watch
pickups.push({
type: 'item', id: 'key_iron', x: 6.5 * TILE, y: 16.5 * TILE, r: 10,
// iron key for nothing critical - optional
});
world = { tiles, entities, enemies, pickups };
}
// ============================================================
// PLAYER
// ============================================================
function createPlayer(px, py) {
return {
x: px, y: py,
vx: 0, vy: 0,
radius: 12,
hp: 3, maxHp: 3, baseMaxHp: 3,
gold: 0,
weapon: 'oak',
ranged: null,
mode: 'melee',
inventory: ['potion', 'potion'],
accessories: [],
speedMod: 1,
dashCdMod: 1,
goldMod: 1,
facing: 0,
atkCd: 0,
dashCd: 0,
dashT: 0,
invuln: 0,
anim: 0,
walking: false,
attackT: 0,
attackAng: 0,
hurtFlash: 0,
kills: 0,
};
}
// ============================================================
// SAVE / LOAD
// ============================================================
const SAVE_KEY = 'ember_path_save_v1';
function canSaveHere() {
return world.entities.some(e => e.type === 'save' && dist(player.x, player.y, e.x, e.y) < 40);
}
function saveGame() {
if (!player) return false;
const data = {
x: player.x, y: player.y,
hp: player.hp, maxHp: player.maxHp, baseMaxHp: player.baseMaxHp,
gold: player.gold,
weapon: player.weapon,
ranged: player.ranged,
inventory: player.inventory.slice(),
accessories: player.accessories.slice(),
speedMod: player.speedMod,
dashCdMod: player.dashCdMod,
goldMod: player.goldMod,
flags: { ...flags },
questId,
// entity flags
openedChests: world.entities.filter(e => e.type === 'chest' && e.open).map(e => e.id),
brokenWalls: world.entities.filter(e => e.type === 'cracked' && e.broken).map(e => e.id),
openGates: world.entities.filter(e => (e.type === 'gate' || e.type === 'door') && e.open).map(e => e.id),
deadEnemies: world.enemies.map((e, i) => e.alive ? null : i).filter(i => i !== null),
pickupsTaken: world.pickups.map((p, i) => p.taken ? i : null).filter(i => i !== null),
gameTime,
};
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(data));
} catch (e) {
toast('Save failed.');
return false;
}
try { AudioFX.save(); } catch (_) {}
toast('Game saved.');
spawnParticles(player.x, player.y, 20, '#80e0ff', 100, 0.8, 3);
return true;
}
function hasSave() {
try { return !!localStorage.getItem(SAVE_KEY); } catch { return false; }
}
function loadGame() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
buildWorld();
player = createPlayer(data.x, data.y);
Object.assign(player, {
hp: data.hp, maxHp: data.maxHp, baseMaxHp: data.baseMaxHp,
gold: data.gold, weapon: data.weapon, ranged: data.ranged,
inventory: data.inventory || [],
accessories: data.accessories || [],
speedMod: data.speedMod || 1,
dashCdMod: data.dashCdMod || 1,
goldMod: data.goldMod || 1,
});
flags = Object.assign(defaultFlags(), data.flags || {});
questId = data.questId || 'meet_elder';
gameTime = data.gameTime || 0;
for (const id of (data.openedChests || [])) {
const c = world.entities.find(e => e.id === id);
if (c) c.open = true;
}
for (const id of (data.brokenWalls || [])) {
const c = world.entities.find(e => e.id === id);
if (c) { c.broken = true; c.solid = false; if (c.tx != null) setTile(c.tx, c.ty, T.FLOOR); }
}
for (const id of (data.openGates || [])) {
const c = world.entities.find(e => e.id === id);
if (c) { c.open = true; c.solid = false; }
}
for (const i of (data.deadEnemies || [])) {
if (world.enemies[i]) { world.enemies[i].alive = false; world.enemies[i].hp = 0; }
}
for (const i of (data.pickupsTaken || [])) {
if (world.pickups[i]) world.pickups[i].taken = true;
}
projectiles.length = 0;
bombs.length = 0;
particles.length = 0;
updateQuestUI();
updateHUD();
return true;
} catch (e) {
console.error(e);
return false;
}
}
function defaultFlags() {
return {
met_elder: false,
grove_cleared: false,
ember_ready: false,
wilds_open: false,
chest_grove: false,
chest_bow: false,
chest_ring: false,
chest_ore: false,
has_ore: false,
storm_forged: false,
has_ember_key: false,
boss_dead: false,
won: false,
};
}
function newGame() {
buildWorld();
player = createPlayer(6.5 * TILE, 8.5 * TILE);
flags = defaultFlags();
questId = 'meet_elder';
gameTime = 0;
projectiles.length = 0;
bombs.length = 0;
particles.length = 0;
floaters.length = 0;
updateQuestUI();
updateHUD();
state = 'play';
hideOverlays();
toast('Find Elder Myra in the square.');
AudioFX.resume();
}
// ============================================================
// DIALOGUE / NPC LOGIC
// ============================================================
let dlgQueue = [];
let dlgSpeaker = '';
function startDialogue(speaker, lines) {
dlgSpeaker = speaker;
dlgQueue = lines.slice();
state = 'dialogue';
showDialogue();
}
function showDialogue() {
const box = document.getElementById('dialogue');
if (!dlgQueue.length) {
box.classList.remove('show');
state = 'play';
return;
}
document.getElementById('dlg-who').textContent = dlgSpeaker;
document.getElementById('dlg-txt').textContent = dlgQueue.shift();
box.classList.add('show');
AudioFX.ui();
}
function advanceDialogue() {
if (dlgQueue.length) showDialogue();
else {
document.getElementById('dialogue').classList.remove('show');
state = 'play';
onDialogueEnd();
}
}
let dialogueEndHook = null;
function onDialogueEnd() {
if (dialogueEndHook) {
const h = dialogueEndHook;
dialogueEndHook = null;
h();
}
}
function interactNPC(npc) {
if (npc.shop) {
openShop();
return;
}
if (npc.id === 'elder') {
if (!flags.met_elder) {
flags.met_elder = true;
startDialogue(npc.name, [
'Pathwalker. The Ashen Seal cracks, and the Spire drinks our light.',
'South lies Whisper Grove. Calm the spirits. A chest there holds Ember steel.',
'Return when your blade hungers for the storm. The wilds open when the grove rests.',
]);
dialogueEndHook = () => {
questId = 'get_blade';
updateQuestUI();
flags.ember_ready = true;
toast('Quest: claim Ember Edge in the Grove.');
};
} else if (flags.boss_dead) {
startDialogue(npc.name, [
'The Seal hums again. You have walked the Ember Path to its end.',
'Rest now. The village will sing your name.',
]);
} else if (flags.storm_forged) {
startDialogue(npc.name, [
'Storm and bow — you are ready. The Ember Key answers only the Spire gate.',
'I entrust it to you. End this.',
]);
dialogueEndHook = () => {
if (!flags.has_ember_key) {
flags.has_ember_key = true;
giveItem('key_ember');
questId = 'spire';
updateQuestUI();
}
};
} else if (player.ranged) {
startDialogue(npc.name, [
'The Ash Bow remembers old wars. Seek Heartstone and Storm Ore in the east caves.',
'Borin can reforge your edge when the ore is yours.',
]);
} else {
startDialogue(npc.name, [
'The grove chest opens only when its guardians fall. South, then east to the tower.',
]);
}
} else if (npc.id === 'smith') {
if (flags.has_ore && player.weapon !== 'storm') {
startDialogue(npc.name, [
'Storm Ore! Ha! Stand back —',
'Your blade is reborn: Storm Cleaver. Swing wide. Lightning follows.',
]);
dialogueEndHook = () => {
player.weapon = 'storm';
flags.storm_forged = true;
flags.has_ore = false;
AudioFX.levelup();
toast('Storm Cleaver forged!');
spawnParticles(player.x, player.y, 30, '#70d0ff', 160, 0.9, 4);
updateHUD();
questId = 'spire';
updateQuestUI();
// encourage talking to elder for key
toast('Speak to Elder Myra for the Ember Key.');
};
} else if (player.weapon === 'oak' && flags.met_elder) {
startDialogue(npc.name, [
'I poured the last ember-steel into a grove casket. Earn it.',
'Bring me rare ore later — I\'ll wake lightning in your steel.',
]);
} else if (player.weapon === 'storm') {
startDialogue(npc.name, [
'That cleaver could split the Spire itself. Don\'t prove me wrong.',
]);
} else {
startDialogue(npc.name, [
'Need bombs? Lila sells them. Cracked stone fears firepowder.',
]);
}
} else if (npc.id === 'scout') {
startDialogue(npc.name, [
'Shades stalk the wilds past the east gate. It unlocks when the grove is quiet.',
'I saw crystal light eastward — and something vast moving in the Spire.',
'Save Stones remember you. Touch them. Always.',
]);
}
}
function checkGroveCleared() {
if (flags.grove_cleared) return;
const groveEnemies = world.enemies.filter(e => e.alive && zoneAt(e.x, e.y) === 'grove');
if (groveEnemies.length === 0) {
flags.grove_cleared = true;
flags.ember_ready = true;
flags.wilds_open = true;
const g = world.entities.find(e => e.id === 'wilds_gate');
if (g) { g.open = true; g.solid = false; }
AudioFX.puzzle();
toast('Grove pacified. East gate opened!');
spawnParticles(11.5 * TILE, 5.5 * TILE, 20, '#c4a040', 100, 0.7, 3);
if (questId === 'get_blade') { /* chest still needed */ }
}
}
// ============================================================
// SHOP & INVENTORY UI
// ============================================================
function hideOverlays() {
['overlay-title','overlay-inv','overlay-shop','overlay-pause','overlay-end'].forEach(id => {
document.getElementById(id).classList.add('hidden');
});
// title stays until new game
}
function openInventory() {
state = 'inv';
document.getElementById('overlay-inv').classList.remove('hidden');
renderInvGrid();
AudioFX.ui();
}
function closeInventory() {
document.getElementById('overlay-inv').classList.add('hidden');
state = 'play';
}
let invSelected = null;
function renderInvGrid() {
const grid = document.getElementById('inv-grid');
grid.innerHTML = '';
// unique stack view
const counts = {};
for (const id of player.inventory) counts[id] = (counts[id] || 0) + 1;
const equipped = new Set(player.accessories);
const ids = Object.keys(counts);
invSelected = null;
document.getElementById('inv-detail').innerHTML = 'Select an item.';
for (let i = 0; i < 15; i++) {
const slot = document.createElement('div');
slot.className = 'inv-slot';
if (i < ids.length) {
const id = ids[i];
const it = ITEMS[id];
slot.textContent = it.icon;
if (counts[id] > 1) {
const q = document.createElement('span');
q.className = 'qty';
q.textContent = counts[id];
slot.appendChild(q);
}
if (equipped.has(id)) slot.style.borderColor = 'var(--accent2)';
slot.addEventListener('click', () => {
document.querySelectorAll('.inv-slot').forEach(s => s.classList.remove('selected'));
slot.classList.add('selected');
invSelected = id;
const eq = equipped.has(id) ? ' [EQUIPPED]' : '';
document.getElementById('inv-detail').innerHTML =
`<strong>${it.icon} ${it.name}${eq}</strong>${it.desc}<br><span style="color:var(--muted)">Type: ${it.type}</span>`;
AudioFX.ui();
});
} else {
slot.classList.add('empty');
}
grid.appendChild(slot);
}
}
function useSelectedItem() {
if (!invSelected) return;
const it = ITEMS[invSelected];
if (it.type === 'consumable') {
if (invSelected === 'bomb') { toast('Press F in-game to place bombs.'); return; }
if (it.use && it.use(player)) {
takeItem(invSelected);
updateHUD();
renderInvGrid();
} else toast('Cannot use now.');
} else if (it.type === 'accessory') {
if (player.accessories.includes(invSelected)) {
unequipAccessory(invSelected);
toast(`Unequipped ${it.name}`);
} else {
equipAccessory(invSelected);
}
renderInvGrid();
updateHUD();
} else {
toast('Key item — used automatically when needed.');
}
}
function openShop() {
state = 'shop';
document.getElementById('overlay-shop').classList.remove('hidden');
document.getElementById('shop-gold').textContent = Math.floor(player.gold);
const list = document.getElementById('shop-list');
list.innerHTML = '';
AudioFX.shop();
for (const id of SHOP_STOCK) {
const it = ITEMS[id];
// unique accessories only once
if (it.type === 'accessory' && (hasItem(id) || player.accessories.includes(id))) continue;
if (id === 'map' && hasItem('map')) continue;
const row = document.createElement('div');
row.className = 'shop-item';
row.innerHTML = `
<div class="ico">${it.icon}</div>
<div><div class="nm">${it.name}</div><div class="ds">${it.desc}</div></div>
<div class="pr">${it.price}g</div>
`;
const btn = document.createElement('button');
btn.className = 'btn';
btn.textContent = 'BUY';
btn.disabled = player.gold < it.price;
btn.addEventListener('click', () => {
if (player.gold < it.price) return;
player.gold -= it.price;
giveItem(id);
AudioFX.buy();
updateHUD();
openShop(); // refresh
});
row.appendChild(btn);
list.appendChild(row);
}
if (!list.children.length) {
list.innerHTML = '<p style="color:var(--muted)">Sold out of unique wares. Potions restock in spirit only — restart for full stock.</p>';
// restock consumables always
for (const id of ['potion', 'elixir', 'bomb']) {
const it = ITEMS[id];
const row = document.createElement('div');
row.className = 'shop-item';
row.innerHTML = `
<div class="ico">${it.icon}</div>
<div><div class="nm">${it.name}</div><div class="ds">${it.desc}</div></div>
<div class="pr">${it.price}g</div>
`;
const btn = document.createElement('button');
btn.className = 'btn';
btn.textContent = 'BUY';
btn.disabled = player.gold < it.price;
btn.addEventListener('click', () => {
if (player.gold < it.price) return;
player.gold -= it.price;
giveItem(id);
AudioFX.buy();
updateHUD();
document.getElementById('shop-gold').textContent = Math.floor(player.gold);
btn.disabled = player.gold < it.price;
});
row.appendChild(btn);
list.appendChild(row);
}
}
}
function closeShop() {
document.getElementById('overlay-shop').classList.add('hidden');
state = 'play';
}
// ============================================================
// COMBAT
// ============================================================
function playerAttack() {
if (player.atkCd > 0 || player.attackT > 0) return;
if (player.mode === 'ranged' && player.ranged) {
fireBow();
return;
}
const w = WEAPONS[player.weapon];
player.atkCd = w.cooldown;
player.attackT = 0.18;
player.attackAng = player.facing;
AudioFX.swing();
// hit enemies in arc
const reach = w.range;
let hits = 0;
for (const e of world.enemies) {
if (!e.alive || e.hurt > 0.05) continue;
const d = dist(player.x, player.y, e.x, e.y);
if (d > reach + e.radius) continue;
const a = angTo(player.x, player.y, e.x, e.y);
let da = Math.abs(Math.atan2(Math.sin(a - player.facing), Math.cos(a - player.facing)));
if (da > w.arc) continue;
damageEnemy(e, w.dmg, player.facing, w.knock);
hits++;
if (w.burn) {
e.burn = 1.5;
spawnParticles(e.x, e.y, 6, '#e07030', 60, 0.4, 2);
}
if (w.chain) {
// arc to nearby
for (const o of world.enemies) {
if (o === e || !o.alive) continue;
if (dist(e.x, e.y, o.x, o.y) < 70) {
damageEnemy(o, Math.max(1, w.dmg - 1), angTo(e.x, e.y, o.x, o.y), w.knock * 0.5);
spawnParticles((e.x + o.x) / 2, (e.y + o.y) / 2, 5, '#70d0ff', 40, 0.3, 2);
}
}
}
}
// break pots? crack soft bushes
// visual slash particles
spawnParticles(
player.x + Math.cos(player.facing) * 24,
player.y + Math.sin(player.facing) * 24,
8, w.color, 90, 0.25, 3
);
}
function fireBow() {
const r = RANGED[player.ranged];
if (!r) return;
player.atkCd = r.cooldown;
const a = player.facing;
projectiles.push({
x: player.x + Math.cos(a) * 16,
y: player.y + Math.sin(a) * 16,
vx: Math.cos(a) * r.speed,
vy: Math.sin(a) * r.speed,
dmg: r.dmg, friendly: true, life: 1.2, r: 5, color: r.color,
});
AudioFX.shoot();
}
function damageEnemy(e, dmg, ang, knock) {
e.hp -= dmg;
e.hurt = 0.2;
e.vx += Math.cos(ang) * knock;
e.vy += Math.sin(ang) * knock;
AudioFX.hit();
spawnParticles(e.x, e.y, 10, e.color, 120, 0.35, 3);
spawnFloat(e.x, e.y - 10, `-${dmg}`, '#ffe0a0');
if (e.hp <= 0) killEnemy(e);
}
function killEnemy(e) {
e.alive = false;
e.hp = 0;
player.kills++;
const g = Math.floor(rand(e.gold[0], e.gold[1] + 1) * player.goldMod);
player.gold += g;
spawnParticles(e.x, e.y, 20, e.color, 150, 0.6, 4);
spawnFloat(e.x, e.y, `+${g}g`, '#f1c40f');
AudioFX.coin();
// drops
if (Math.random() < 0.12) {
world.pickups.push({ type: 'item', id: 'potion', x: e.x, y: e.y, r: 10 });
}
if (e.kind === 'boss') {
flags.boss_dead = true;
giveItem('elixir', 2);
player.gold += 50;
AudioFX.boss();
toast('The Breach Warden falls!');
// win sequence
setTimeout(() => victory(), 1200);
}
if (e.kind === 'ashguard' && zoneAt(e.x, e.y) === 'watch' && !flags.has_ember_key_drop) {
// optional mid drop - actually key from elder
}
checkGroveCleared();
updateHUD();
}
function hurtPlayer(dmg, ax, ay) {
if (player.invuln > 0 || player.dashT > 0) return;
player.hp -= dmg;
player.invuln = 0.9;
player.hurtFlash = 0.3;
AudioFX.hurt();
const a = angTo(ax, ay, player.x, player.y);
player.vx += Math.cos(a) * 200;
player.vy += Math.sin(a) * 200;
spawnParticles(player.x, player.y, 12, '#e74c3c', 100, 0.4, 3);
spawnFloat(player.x, player.y - 12, `-${dmg}`, '#e74c3c');
const flash = document.getElementById('dmg-flash');
flash.classList.add('on');
setTimeout(() => flash.classList.remove('on'), 100);
updateHUD();
if (player.hp <= 0) {
player.hp = 0;
AudioFX.death();
gameOver();
}
}
function gameOver() {
state = 'end';
document.getElementById('end-title').textContent = 'DEFEATED';
document.getElementById('end-text').textContent =
'Your embers fade… Load a Save Stone checkpoint from the title screen, or begin anew.';
document.getElementById('overlay-end').classList.remove('hidden');
}
function victory() {
flags.won = true;
questId = 'done';
updateQuestUI();
state = 'end';
document.getElementById('end-title').textContent = 'SEAL RESTORED';
document.getElementById('end-text').textContent =
`The Hollow Spire stills. Gold earned: ${Math.floor(player.gold)}. Foes felled: ${player.kills}. Time: ${formatTime(gameTime)}. The Ember Path is complete.`;
document.getElementById('overlay-end').classList.remove('hidden');
AudioFX.levelup();
}
function formatTime(t) {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}m ${s}s`;
}
// ============================================================
// BOMBS
// ============================================================
function placeBomb() {
if (!hasItem('bomb')) { toast('No bombs.'); return; }
if (bombs.length >= 3) return;
takeItem('bomb');
bombs.push({
x: player.x, y: player.y, t: 1.2, r: 56,
});
updateHUD();
AudioFX.ui();
}
function explodeBomb(b) {
AudioFX.explosion();
spawnParticles(b.x, b.y, 36, '#e08040', 200, 0.6, 5);
spawnParticles(b.x, b.y, 16, '#555', 120, 0.5, 4);
for (const e of world.enemies) {
if (!e.alive) continue;
if (dist(b.x, b.y, e.x, e.y) < b.r + e.radius) {
damageEnemy(e, 3, angTo(b.x, b.y, e.x, e.y), 320);
}
}
if (dist(b.x, b.y, player.x, player.y) < b.r) {
hurtPlayer(1, b.x, b.y);
}
for (const ent of world.entities) {
if (ent.type === 'cracked' && !ent.broken && dist(b.x, b.y, ent.x, ent.y) < b.r) {
ent.broken = true;
ent.solid = false;
if (ent.tx != null) setTile(ent.tx, ent.ty, T.FLOOR);
AudioFX.puzzle();
toast('Cracked wall shattered!');
spawnParticles(ent.x, ent.y, 20, '#888', 100, 0.5, 4);
}
}
}
// ============================================================
// INTERACT
// ============================================================
function interact() {
// NPCs
for (const e of world.entities) {
if (dist(player.x, player.y, e.x, e.y) > 36) continue;
if (e.type === 'npc') { interactNPC(e); return; }
if (e.type === 'sign') {
startDialogue('Sign', [e.text]);
return;
}
if (e.type === 'save') {
saveGame();
return;
}
if (e.type === 'chest' && !e.open) {
e.open = true;
AudioFX.door();
e.loot();
spawnParticles(e.x, e.y, 14, '#f1c40f', 80, 0.5, 3);
return;
}
if (e.type === 'door' && !e.open) {
if (e.needsKey && hasItem(e.needsKey)) {
e.open = true;
e.solid = false;
takeItem(e.needsKey);
AudioFX.door();
toast('Gate unlocked.');
spawnParticles(e.x, e.y, 16, e.color, 90, 0.5, 3);
} else {
toast('Sealed. Needs the Ember Key.');
AudioFX.ui();
}
return;
}
if (e.type === 'seal' && e.active && !flags.boss_dead) {
startDialogue('Ashen Seal', [
'The breach screams. Defeat the Warden to restore the Seal.',
]);
return;
}
}
}
// ============================================================
// UPDATE
// ============================================================
function update(dt) {
if (state !== 'play') return;
gameTime += dt;
// facing toward mouse
player.facing = angTo(player.x, player.y, mouse.worldX, mouse.worldY);
// timers
player.atkCd = Math.max(0, player.atkCd - dt);
player.dashCd = Math.max(0, player.dashCd - dt);
player.invuln = Math.max(0, player.invuln - dt);
player.hurtFlash = Math.max(0, player.hurtFlash - dt);
player.attackT = Math.max(0, player.attackT - dt);
player.dashT = Math.max(0, player.dashT - dt);
// movement
let ix = 0, iy = 0;
if (keys['KeyW'] || keys['ArrowUp']) iy -= 1;
if (keys['KeyS'] || keys['ArrowDown']) iy += 1;
if (keys['KeyA'] || keys['ArrowLeft']) ix -= 1;
if (keys['KeyD'] || keys['ArrowRight']) ix += 1;
const len = Math.hypot(ix, iy) || 1;
ix /= len; iy /= len;
const baseSpd = 145 * player.speedMod;
let spd = baseSpd;
if (player.dashT > 0) spd = baseSpd * 2.6;
// friction velocity from knockback
player.vx *= Math.pow(0.05, dt);
player.vy *= Math.pow(0.05, dt);
const mx = ix * spd * dt + player.vx * dt;
const my = iy * spd * dt + player.vy * dt;
tryMove(player, mx, my, player.radius);
player.walking = (ix !== 0 || iy !== 0) && player.dashT <= 0;
if (player.walking) {
player.anim += dt * 10;
if (Math.floor(player.anim) % 4 === 0 && player._stepGate !== Math.floor(player.anim)) {
player._stepGate = Math.floor(player.anim);
if (Math.random() < 0.4) AudioFX.step();
}
}
// dash
if ((keys['ShiftLeft'] || keys['ShiftRight']) && player.dashCd <= 0 && (ix || iy)) {
player.dashT = 0.16;
player.dashCd = 0.7 * player.dashCdMod;
player.invuln = Math.max(player.invuln, 0.16);
AudioFX.dash();
spawnParticles(player.x, player.y, 8, '#a0c0e0', 60, 0.3, 2);
}
// spikes / lava
const tUnder = tileAt(player.x, player.y);
if (tUnder === T.SPIKE || tUnder === T.LAVA) {
hurtPlayer(1, player.x, player.y + 1);
}
// attack
if (mouse.down || keys['Space']) playerAttack();
// pickups
for (const p of world.pickups) {
if (p.taken) continue;
if (dist(player.x, player.y, p.x, p.y) < player.radius + p.r) {
p.taken = true;
if (p.type === 'gold') {
player.gold += p.v;
AudioFX.coin();
spawnFloat(p.x, p.y, `+${p.v}g`, '#f1c40f');
spawnParticles(p.x, p.y, 8, '#f1c40f', 60, 0.4, 2);
} else if (p.type === 'item') {
giveItem(p.id);
spawnParticles(p.x, p.y, 10, '#fff', 70, 0.4, 2);
}
updateHUD();
}
}
// plates puzzle
const plates = world.entities.filter(e => e.type === 'plate');
for (const pl of plates) {
const was = pl.on;
pl.on = dist(player.x, player.y, pl.x, pl.y) < 20
|| world.enemies.some(e => e.alive && dist(e.x, e.y, pl.x, pl.y) < 18);
// also bombs? skip
if (pl.on && !was) AudioFX.puzzle();
}
const gate = world.entities.find(e => e.id === 'crystal_gate');
if (gate && !gate.open) {
if (plates.every(p => p.on)) {
gate.open = true;
gate.solid = false;
AudioFX.puzzle();
toast('Azure gate opens!');
spawnParticles(gate.x, gate.y, 24, '#80e0ff', 120, 0.7, 3);
}
}
// enemies
for (const e of world.enemies) {
if (!e.alive) continue;
e.hurt = Math.max(0, e.hurt - dt);
e.cd = Math.max(0, e.cd - dt);
e.timer -= dt;
if (e.burn > 0) {
e.burn -= dt;
if (Math.random() < dt * 4) {
e.hp -= 0.25;
spawnParticles(e.x, e.y, 2, '#e07030', 30, 0.3, 2);
if (e.hp <= 0) killEnemy(e);
}
}
// knock friction
e.vx *= Math.pow(0.02, dt);
e.vy *= Math.pow(0.02, dt);
const dPlayer = dist(e.x, e.y, player.x, player.y);
const aggro = e.kind === 'boss' ? 280 : 200;
if (e.ai === 'chase' || e.ai === 'boss') {
if (dPlayer < aggro) {
const a = angTo(e.x, e.y, player.x, player.y);
e.facing = a;
const spd = e.spd * (e.kind === 'boss' && e.hp < e.maxHp * 0.4 ? 1.35 : 1);
tryMove(e, Math.cos(a) * spd * dt + e.vx * dt, Math.sin(a) * spd * dt + e.vy * dt, e.radius);
if (dPlayer < e.radius + player.radius + 4 && e.cd <= 0) {
hurtPlayer(e.dmg, e.x, e.y);
e.cd = e.kind === 'boss' ? 0.7 : 0.85;
}
// boss specials
if (e.kind === 'boss' && e.timer <= 0) {
e.timer = 2.2;
// ring shot
const n = e.hp < e.maxHp * 0.5 ? 10 : 6;
for (let i = 0; i < n; i++) {
const ang = (i / n) * Math.PI * 2 + gameTime;
projectiles.push({
x: e.x, y: e.y,
vx: Math.cos(ang) * 160, vy: Math.sin(ang) * 160,
dmg: 1, friendly: false, life: 2.5, r: 6, color: '#e04060',
});
}
AudioFX.shoot();
spawnParticles(e.x, e.y, 12, '#a02040', 80, 0.4, 3);
}
} else {
// wander home
const a = angTo(e.x, e.y, e.homeX, e.homeY);
if (dist(e.x, e.y, e.homeX, e.homeY) > 10)
tryMove(e, Math.cos(a) * e.spd * 0.4 * dt, Math.sin(a) * e.spd * 0.4 * dt, e.radius);
}
} else if (e.ai === 'dart') {
if (e.timer <= 0) {
e.timer = rand(0.6, 1.4);
if (dPlayer < aggro) {
const a = angTo(e.x, e.y, player.x, player.y) + rand(-0.5, 0.5);
e.vx = Math.cos(a) * 220;
e.vy = Math.sin(a) * 220;
} else {
const a = Math.random() * Math.PI * 2;
e.vx = Math.cos(a) * 120;
e.vy = Math.sin(a) * 120;
}
}
tryMove(e, e.vx * dt, e.vy * dt, e.radius);
if (dPlayer < e.radius + player.radius + 2 && e.cd <= 0) {
hurtPlayer(e.dmg, e.x, e.y);
e.cd = 0.8;
}
} else if (e.ai === 'shoot') {
if (dPlayer < aggro) {
const a = angTo(e.x, e.y, player.x, player.y);
e.facing = a;
// keep distance
if (dPlayer < 100) tryMove(e, -Math.cos(a) * e.spd * dt, -Math.sin(a) * e.spd * dt, e.radius);
else if (dPlayer > 160) tryMove(e, Math.cos(a) * e.spd * dt, Math.sin(a) * e.spd * dt, e.radius);
if (e.cd <= 0) {
e.cd = 1.4;
projectiles.push({
x: e.x, y: e.y,
vx: Math.cos(a) * 200, vy: Math.sin(a) * 200,
dmg: 1, friendly: false, life: 2, r: 5, color: '#80e0ff',
});
AudioFX.shoot();
}
}
tryMove(e, e.vx * dt, e.vy * dt, e.radius);
}
}
// projectiles
for (const p of projectiles) {
p.life -= dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
if (solidTile(tileAt(p.x, p.y))) { p.life = 0; spawnParticles(p.x, p.y, 4, p.color, 40, 0.2, 2); continue; }
if (p.friendly) {
for (const e of world.enemies) {
if (!e.alive) continue;
if (dist(p.x, p.y, e.x, e.y) < e.radius + p.r) {
damageEnemy(e, p.dmg, Math.atan2(p.vy, p.vx), 120);
p.life = 0;
break;
}
}
} else {
if (dist(p.x, p.y, player.x, player.y) < player.radius + p.r) {
hurtPlayer(p.dmg, p.x, p.y);
p.life = 0;
}
}
}
for (let i = projectiles.length - 1; i >= 0; i--) if (projectiles[i].life <= 0) projectiles.splice(i, 1);
// bombs
for (const b of bombs) {
b.t -= dt;
if (b.t <= 0) explodeBomb(b);
}
for (let i = bombs.length - 1; i >= 0; i--) if (bombs[i].t <= 0) bombs.splice(i, 1);
// particles
for (const p of particles) {
p.life -= dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += (p.gravity || 80) * dt;
p.vx *= 0.98; p.vy *= 0.98;
}
for (let i = particles.length - 1; i >= 0; i--) if (particles[i].life <= 0) particles.splice(i, 1);
for (const f of floaters) {
f.life -= dt;
f.y += f.vy * dt;
}
for (let i = floaters.length - 1; i >= 0; i--) if (floaters[i].life <= 0) floaters.splice(i, 1);
// save pulse
for (const e of world.entities) {
if (e.type === 'save') e.pulse += dt * 3;
}
// camera
camera.x = clamp(player.x - VIEW_W / 2, 0, COLS * TILE - VIEW_W);
camera.y = clamp(player.y - VIEW_H / 2, 0, ROWS * TILE - VIEW_H);
// soft zone toast
const z = currentZoneName();
if (z !== player._lastZone) {
player._lastZone = z;
document.getElementById('zone-display').textContent = z;
if (player._zinit) toast(z);
player._zinit = true;
}
}
// ============================================================
// RENDER
// ============================================================
const tileColors = {
[T.GRASS]: ['#3d6b3a', '#355f33'],
[T.PATH]: ['#8a7a58', '#7a6a4c'],
[T.WATER]: ['#2a5a8a', '#254e78'],
[T.WALL]: ['#4a4a58', '#3a3a48'],
[T.TREE]: ['#2d5a2a', '#244a22'],
[T.SAND]: ['#c2b280', '#b0a070'],
[T.FLOOR]: ['#5a5858', '#4a4848'],
[T.LAVA]: ['#c04010', '#a03010'],
[T.BRIDGE]: ['#6a5030', '#5a4028'],
[T.BUSH]: ['#3a7a38', '#326832'],
[T.ROCK]: ['#606068', '#505058'],
[T.FLOWER]: ['#3d6b3a', '#355f33'],
[T.CARPET]: ['#6a3050', '#5a2844'],
[T.SNOW]: ['#d0d8e0', '#c0c8d0'],
[T.ASH]: ['#4a4440', '#3e3936'],
[T.SPIKE]: ['#5a5050', '#4a4040'],
};
function draw() {
ctx.clearRect(0, 0, VIEW_W, VIEW_H);
if (!world || !player) {
ctx.fillStyle = '#0b0e14';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
return;
}
const camX = camera.x, camY = camera.y;
const t0x = Math.floor(camX / TILE);
const t0y = Math.floor(camY / TILE);
const t1x = Math.ceil((camX + VIEW_W) / TILE);
const t1y = Math.ceil((camY + VIEW_H) / TILE);
// tiles
for (let ty = t0y; ty <= t1y; ty++) {
for (let tx = t0x; tx <= t1x; tx++) {
if (!inBounds(tx, ty)) continue;
const t = world.tiles[ty][tx];
const c = tileColors[t] || tileColors[T.GRASS];
const px = tx * TILE - camX;
const py = ty * TILE - camY;
const checker = (tx + ty) % 2;
ctx.fillStyle = c[checker];
ctx.fillRect(px, py, TILE + 0.5, TILE + 0.5);
// details
if (t === T.WATER) {
ctx.fillStyle = 'rgba(150,200,255,0.12)';
const wo = Math.sin(gameTime * 2 + tx * 0.7 + ty * 0.5) * 3;
ctx.fillRect(px + 8, py + 14 + wo, 12, 4);
ctx.fillRect(px + 26, py + 28 - wo, 10, 3);
} else if (t === T.LAVA) {
ctx.fillStyle = `rgba(255,180,40,${0.3 + Math.sin(gameTime * 4 + tx) * 0.15})`;
ctx.fillRect(px + 4, py + 4, TILE - 8, TILE - 8);
} else if (t === T.TREE) {
ctx.fillStyle = '#1a3a18';
ctx.fillRect(px + 20, py + 28, 8, 14);
ctx.fillStyle = '#2f7a2c';
ctx.beginPath();
ctx.arc(px + 24, py + 20, 16, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#3a9a34';
ctx.beginPath();
ctx.arc(px + 18, py + 18, 10, 0, Math.PI * 2);
ctx.fill();
} else if (t === T.BUSH) {
ctx.fillStyle = '#2f8030';
ctx.beginPath();
ctx.arc(px + 24, py + 28, 14, 0, Math.PI * 2);
ctx.fill();
} else if (t === T.ROCK) {
ctx.fillStyle = '#707078';
ctx.beginPath();
ctx.moveTo(px + 10, py + 36);
ctx.lineTo(px + 18, py + 12);
ctx.lineTo(px + 36, py + 16);
ctx.lineTo(px + 40, py + 36);
ctx.fill();
} else if (t === T.FLOWER) {
ctx.fillStyle = '#e080b0';
ctx.beginPath();
ctx.arc(px + 18, py + 22, 3, 0, Math.PI * 2);
ctx.arc(px + 30, py + 30, 3, 0, Math.PI * 2);
ctx.fill();
} else if (t === T.SPIKE) {
ctx.fillStyle = '#bbb';
for (let i = 0; i < 3; i++) {
const sx = px + 12 + i * 12;
ctx.beginPath();
ctx.moveTo(sx, py + 36);
ctx.lineTo(sx + 5, py + 14);
ctx.lineTo(sx + 10, py + 36);
ctx.fill();
}
} else if (t === T.WALL) {
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.fillRect(px, py + TILE - 10, TILE, 10);
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.strokeRect(px + 1, py + 1, TILE - 2, TILE - 2);
}
}
}
// entities under
const drawList = [];
for (const e of world.entities) {
drawList.push({ y: e.y, draw: () => drawEntity(e, camX, camY) });
}
for (const p of world.pickups) {
if (p.taken) continue;
drawList.push({ y: p.y, draw: () => drawPickup(p, camX, camY) });
}
for (const e of world.enemies) {
if (!e.alive) continue;
drawList.push({ y: e.y, draw: () => drawEnemy(e, camX, camY) });
}
for (const b of bombs) {
drawList.push({ y: b.y, draw: () => drawBomb(b, camX, camY) });
}
drawList.push({ y: player.y, draw: () => drawPlayer(camX, camY) });
drawList.sort((a, b) => a.y - b.y);
for (const d of drawList) d.draw();
// projectiles
for (const p of projectiles) {
const px = p.x - camX, py = p.y - camY;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(px, py, p.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath();
ctx.arc(px - 1, py - 1, p.r * 0.4, 0, Math.PI * 2);
ctx.fill();
}
// attack arc
if (player.attackT > 0) {
const w = WEAPONS[player.weapon];
const px = player.x - camX, py = player.y - camY;
const a0 = player.attackAng - w.arc;
const a1 = player.attackAng + w.arc;
ctx.strokeStyle = w.color;
ctx.globalAlpha = player.attackT / 0.18 * 0.7;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(px, py, w.range * 0.85, a0, a1);
ctx.stroke();
ctx.globalAlpha = 1;
}
// particles
for (const p of particles) {
ctx.globalAlpha = clamp(p.life / p.max, 0, 1);
ctx.fillStyle = p.color;
ctx.fillRect(p.x - camX - p.size / 2, p.y - camY - p.size / 2, p.size, p.size);
}
ctx.globalAlpha = 1;
// floaters
ctx.font = '12px Share Tech Mono, monospace';
ctx.textAlign = 'center';
for (const f of floaters) {
ctx.globalAlpha = clamp(f.life / 0.9, 0, 1);
ctx.fillStyle = f.color;
ctx.fillText(f.text, f.x - camX, f.y - camY);
}
ctx.globalAlpha = 1;
// interaction prompt
let prompt = null;
for (const e of world.entities) {
if (dist(player.x, player.y, e.x, e.y) > 36) continue;
if (e.type === 'npc') prompt = `E — Talk (${e.name})`;
else if (e.type === 'save') prompt = 'E — Save Stone';
else if (e.type === 'chest' && !e.open) prompt = 'E — Open Chest';
else if (e.type === 'door' && !e.open) prompt = 'E — Unlock';
else if (e.type === 'sign') prompt = 'E — Read';
else if (e.type === 'seal') prompt = 'E — Examine Seal';
if (prompt) break;
}
if (prompt) {
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.font = '13px Share Tech Mono, monospace';
const tw = ctx.measureText(prompt).width;
const px = player.x - camX;
const py = player.y - camY - 40;
ctx.fillRect(px - tw / 2 - 8, py - 14, tw + 16, 22);
ctx.strokeStyle = '#4ecdc4';
ctx.strokeRect(px - tw / 2 - 8, py - 14, tw + 16, 22);
ctx.fillStyle = '#e8eef7';
ctx.fillText(prompt, px, py + 2);
}
// boss hp bar
const boss = world.enemies.find(e => e.kind === 'boss' && e.alive);
if (boss && dist(player.x, player.y, boss.x, boss.y) < 320) {
const bw = 320, bx = (VIEW_W - bw) / 2, by = 16;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(bx - 2, by - 2, bw + 4, 16);
ctx.fillStyle = '#3a1018';
ctx.fillRect(bx, by, bw, 12);
ctx.fillStyle = '#e04060';
ctx.fillRect(bx, by, bw * (boss.hp / boss.maxHp), 12);
ctx.fillStyle = '#fff';
ctx.font = '10px Press Start 2P, monospace';
ctx.textAlign = 'center';
ctx.fillText('BREACH WARDEN', VIEW_W / 2, by + 10);
}
drawMinimap();
}
function drawEntity(e, camX, camY) {
const px = e.x - camX, py = e.y - camY;
if (px < -40 || py < -40 || px > VIEW_W + 40 || py > VIEW_H + 40) return;
if (e.type === 'npc') {
// body
ctx.fillStyle = e.color;
ctx.beginPath();
ctx.arc(px, py - 4, 12, 0, Math.PI * 2);
ctx.fill();
// head
ctx.fillStyle = '#f0d0b0';
ctx.beginPath();
ctx.arc(px, py - 18, 8, 0, Math.PI * 2);
ctx.fill();
// label
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.font = '10px Share Tech Mono, monospace';
ctx.textAlign = 'center';
const nm = e.name.split(' ').pop();
ctx.fillText(nm, px, py + 18);
// shop icon
if (e.shop) {
ctx.fillStyle = '#f1c40f';
ctx.fillText('₪', px + 12, py - 22);
}
} else if (e.type === 'save') {
const pulse = 0.5 + Math.sin(e.pulse) * 0.5;
ctx.strokeStyle = `rgba(100,220,255,${0.4 + pulse * 0.5})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px, py, 14 + pulse * 4, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = '#6ad0e8';
ctx.beginPath();
// stone diamond
ctx.moveTo(px, py - 14);
ctx.lineTo(px + 12, py);
ctx.lineTo(px, py + 14);
ctx.lineTo(px - 12, py);
ctx.closePath();
ctx.fill();
ctx.fillStyle = `rgba(200,255,255,${0.5 + pulse * 0.4})`;
ctx.beginPath();
ctx.arc(px, py, 4, 0, Math.PI * 2);
ctx.fill();
} else if (e.type === 'chest') {
ctx.fillStyle = e.open ? '#3a2a18' : '#a07830';
ctx.fillRect(px - 12, py - 8, 24, 18);
ctx.fillStyle = e.open ? '#2a1a10' : '#c4a040';
ctx.fillRect(px - 12, py - 14, 24, 10);
if (!e.open) {
ctx.fillStyle = '#f1c40f';
ctx.fillRect(px - 2, py - 6, 4, 6);
}
} else if (e.type === 'gate' || e.type === 'door') {
if (e.open) {
ctx.globalAlpha = 0.25;
}
ctx.fillStyle = e.color || '#888';
ctx.fillRect(px - 18, py - 20, 36, 40);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fillRect(px - 6, py - 4, 12, 20);
ctx.globalAlpha = 1;
if (!e.open) {
ctx.strokeStyle = '#fff4';
ctx.strokeRect(px - 18, py - 20, 36, 40);
}
} else if (e.type === 'cracked') {
if (e.broken) return;
ctx.fillStyle = '#5a5a68';
ctx.fillRect(px - 16, py - 16, 32, 32);
ctx.strokeStyle = '#2a2a30';
ctx.beginPath();
ctx.moveTo(px - 8, py - 14);
ctx.lineTo(px - 2, py);
ctx.lineTo(px + 6, py - 8);
ctx.lineTo(px + 10, py + 14);
ctx.stroke();
ctx.fillStyle = '#e08040';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('💣', px, py + 4);
} else if (e.type === 'plate') {
ctx.fillStyle = e.on ? '#80e0a0' : '#506070';
ctx.beginPath();
ctx.ellipse(px, py, 14, 10, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = e.on ? '#c0ffe0' : '#304050';
ctx.stroke();
} else if (e.type === 'sign') {
ctx.fillStyle = '#6a5030';
ctx.fillRect(px - 2, py - 4, 4, 16);
ctx.fillStyle = '#c4a060';
ctx.fillRect(px - 10, py - 20, 20, 16);
} else if (e.type === 'seal') {
if (flags.boss_dead) {
ctx.strokeStyle = '#80e0ff';
ctx.globalAlpha = 0.7 + Math.sin(gameTime * 3) * 0.3;
} else {
ctx.strokeStyle = '#e04060';
ctx.globalAlpha = 0.5 + Math.sin(gameTime * 5) * 0.3;
}
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(px, py, 20, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(px, py, 10, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
}
}
function drawPickup(p, camX, camY) {
const px = p.x - camX, py = p.y - camY + Math.sin(gameTime * 4 + p.x) * 3;
if (p.type === 'gold') {
ctx.fillStyle = '#f1c40f';
ctx.beginPath();
ctx.arc(px, py, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff8';
ctx.beginPath();
ctx.arc(px - 2, py - 2, 2, 0, Math.PI * 2);
ctx.fill();
} else {
const it = ITEMS[p.id];
ctx.font = '16px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(it ? it.icon : '?', px, py + 5);
}
}
function drawEnemy(e, camX, camY) {
const px = e.x - camX, py = e.y - camY;
const flash = e.hurt > 0;
ctx.save();
ctx.translate(px, py);
if (flash) ctx.globalAlpha = 0.7;
if (e.kind === 'slime') {
ctx.fillStyle = flash ? '#fff' : e.color;
ctx.beginPath();
ctx.ellipse(0, 4, e.radius, e.radius * 0.75, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#1a1a1a';
ctx.beginPath();
ctx.arc(-4, 0, 2, 0, Math.PI * 2);
ctx.arc(4, 0, 2, 0, Math.PI * 2);
ctx.fill();
} else if (e.kind === 'sprite') {
ctx.fillStyle = flash ? '#fff' : e.color;
const flap = Math.sin(gameTime * 12 + e.x) * 5;
ctx.beginPath();
ctx.ellipse(-10, flap, 8, 4, -0.4, 0, Math.PI * 2);
ctx.ellipse(10, -flap, 8, 4, 0.4, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(0, 0, 8, 0, Math.PI * 2);
ctx.fill();
} else if (e.kind === 'shade') {
ctx.fillStyle = flash ? '#ccc' : e.color;
ctx.beginPath();
ctx.moveTo(0, -14);
ctx.bezierCurveTo(14, -10, 12, 10, 0, 16);
ctx.bezierCurveTo(-12, 10, -14, -10, 0, -14);
ctx.fill();
ctx.fillStyle = '#f44';
ctx.beginPath();
ctx.arc(-4, -2, 2.5, 0, Math.PI * 2);
ctx.arc(4, -2, 2.5, 0, Math.PI * 2);
ctx.fill();
} else if (e.kind === 'crystal') {
ctx.fillStyle = flash ? '#fff' : e.color;
ctx.beginPath();
ctx.moveTo(0, -16);
ctx.lineTo(12, 0);
ctx.lineTo(0, 16);
ctx.lineTo(-12, 0);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#fff8';
ctx.beginPath();
ctx.moveTo(0, -8);
ctx.lineTo(5, 0);
ctx.lineTo(0, 4);
ctx.closePath();
ctx.fill();
} else if (e.kind === 'ashguard') {
ctx.fillStyle = flash ? '#fff' : e.color;
ctx.fillRect(-10, -12, 20, 24);
ctx.fillStyle = '#222';
ctx.fillRect(-10, -16, 20, 6);
ctx.fillStyle = '#e08040';
ctx.fillRect(10, -8, 14, 4);
} else if (e.kind === 'boss') {
const sc = 1 + Math.sin(gameTime * 2) * 0.03;
ctx.scale(sc, sc);
ctx.fillStyle = flash ? '#fff' : e.color;
ctx.beginPath();
ctx.arc(0, 0, e.radius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#2a0810';
ctx.beginPath();
ctx.arc(-8, -4, 5, 0, Math.PI * 2);
ctx.arc(8, -4, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#f1c40f';
ctx.beginPath();
ctx.arc(-8, -4, 2, 0, Math.PI * 2);
ctx.arc(8, -4, 2, 0, Math.PI * 2);
ctx.fill();
// horns
ctx.fillStyle = '#601028';
ctx.beginPath();
ctx.moveTo(-12, -16); ctx.lineTo(-18, -30); ctx.lineTo(-4, -18);
ctx.moveTo(12, -16); ctx.lineTo(18, -30); ctx.lineTo(4, -18);
ctx.fill();
}
// hp bar
if (e.hp < e.maxHp && e.kind !== 'boss') {
ctx.fillStyle = '#000';
ctx.fillRect(-12, -e.radius - 10, 24, 4);
ctx.fillStyle = '#e74c3c';
ctx.fillRect(-12, -e.radius - 10, 24 * (e.hp / e.maxHp), 4);
}
if (e.burn > 0) {
ctx.fillStyle = '#e07030';
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.arc(rand(-6, 6), rand(-6, 6), 3, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function drawBomb(b, camX, camY) {
const px = b.x - camX, py = b.y - camY;
const blink = b.t < 0.4 && Math.floor(gameTime * 10) % 2 === 0;
ctx.fillStyle = blink ? '#fff' : '#333';
ctx.beginPath();
ctx.arc(px, py, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e74c3c';
ctx.fillRect(px - 1, py - 14, 2, 6);
ctx.strokeStyle = `rgba(230,100,40,${0.3})`;
ctx.beginPath();
ctx.arc(px, py, b.r * (1 - b.t / 1.2) * 0.3 + 10, 0, Math.PI * 2);
ctx.stroke();
}
function drawPlayer(camX, camY) {
const px = player.x - camX;
const py = player.y - camY;
ctx.save();
ctx.translate(px, py);
// shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, 12, 12, 5, 0, 0, Math.PI * 2);
ctx.fill();
if (player.invuln > 0 && Math.floor(gameTime * 20) % 2 === 0) {
ctx.globalAlpha = 0.4;
}
// walk bob
const bob = player.walking ? Math.sin(player.anim * 1.5) * 2 : 0;
const dashStretch = player.dashT > 0 ? 1.2 : 1;
// legs
ctx.fillStyle = player.accessories.includes('boots') ? '#60a0e0' : '#2a3040';
const leg = player.walking ? Math.sin(player.anim) * 4 : 0;
ctx.fillRect(-7, 4 + bob, 5, 10 + leg);
ctx.fillRect(2, 4 + bob, 5, 10 - leg);
// body color by weapon tier
const bodyCol = player.weapon === 'storm' ? '#2a4060'
: player.weapon === 'ember' ? '#5a3020' : '#3a4a5a';
ctx.fillStyle = player.hurtFlash > 0 ? '#e74c3c' : bodyCol;
ctx.beginPath();
ctx.ellipse(0, -2 + bob, 11 * dashStretch, 13, 0, 0, Math.PI * 2);
ctx.fill();
// scarf / amulet
if (player.accessories.includes('amulet')) {
ctx.fillStyle = '#e0c060';
ctx.beginPath();
ctx.arc(0, 2 + bob, 4, 0, Math.PI * 2);
ctx.fill();
}
if (player.accessories.includes('charm')) {
ctx.fillStyle = '#4c4';
ctx.fillRect(8, -8 + bob, 5, 5);
}
if (player.accessories.includes('ring')) {
ctx.fillStyle = '#e06080';
ctx.beginPath();
ctx.arc(-8, 0 + bob, 3, 0, Math.PI * 2);
ctx.fill();
}
// head
ctx.fillStyle = '#f0d0b0';
ctx.beginPath();
ctx.arc(0, -16 + bob, 8, 0, Math.PI * 2);
ctx.fill();
// hair / hat
ctx.fillStyle = '#2a2030';
ctx.beginPath();
ctx.arc(0, -20 + bob, 7, Math.PI, 0);
ctx.fill();
// facing eyes
const ex = Math.cos(player.facing) * 2;
const ey = Math.sin(player.facing) * 1.5;
ctx.fillStyle = '#1a1a1a';
ctx.beginPath();
ctx.arc(-3 + ex, -16 + bob + ey, 1.5, 0, Math.PI * 2);
ctx.arc(3 + ex, -16 + bob + ey, 1.5, 0, Math.PI * 2);
ctx.fill();
// weapon visual
const w = WEAPONS[player.weapon];
ctx.save();
ctx.translate(0, -2 + bob);
ctx.rotate(player.facing + (player.attackT > 0 ? (0.18 - player.attackT) / 0.18 * w.arc * 2 - w.arc : 0.3));
if (player.mode === 'melee' || player.attackT > 0) {
ctx.fillStyle = w.color;
ctx.fillRect(8, -3, w.range * 0.55, 6);
ctx.fillStyle = '#ddd';
ctx.fillRect(8 + w.range * 0.55 - 4, -5, 8, 10);
if (player.weapon === 'ember') {
ctx.fillStyle = '#ff8020';
ctx.globalAlpha = 0.7;
ctx.beginPath();
ctx.arc(8 + w.range * 0.5, 0, 4, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
if (player.weapon === 'storm') {
ctx.strokeStyle = '#a0e0ff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(12, -6);
ctx.lineTo(18, 0);
ctx.lineTo(14, 6);
ctx.stroke();
}
} else if (player.mode === 'ranged' && player.ranged) {
// bow
ctx.strokeStyle = '#d0a060';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(14, 0, 12, -1.2, 1.2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(14 + Math.cos(-1.2) * 12, Math.sin(-1.2) * 12);
ctx.lineTo(14 + Math.cos(1.2) * 12, Math.sin(1.2) * 12);
ctx.stroke();
}
ctx.restore();
ctx.restore();
}
function drawMinimap() {
const w = 120, h = 90;
mmCtx.fillStyle = 'rgba(8,10,16,0.9)';
mmCtx.fillRect(0, 0, w, h);
const sx = w / (COLS * TILE);
const sy = h / (ROWS * TILE);
// simplified tiles
for (let ty = 0; ty < ROWS; ty += 1) {
for (let tx = 0; tx < COLS; tx += 1) {
const t = world.tiles[ty][tx];
if (t === T.WALL || t === T.ROCK || t === T.TREE) mmCtx.fillStyle = '#3a4558';
else if (t === T.WATER) mmCtx.fillStyle = '#1a3a5a';
else if (t === T.LAVA) mmCtx.fillStyle = '#8a3010';
else if (t === T.PATH || t === T.FLOOR || t === T.BRIDGE) mmCtx.fillStyle = '#4a5840';
else if (t === T.ASH) mmCtx.fillStyle = '#3a3836';
else mmCtx.fillStyle = '#2a3828';
mmCtx.fillRect(tx * TILE * sx, ty * TILE * sy, Math.ceil(TILE * sx), Math.ceil(TILE * sy));
}
}
// secrets if map
if (hasItem('map')) {
for (const e of world.entities) {
if (e.type === 'chest' && !e.open) {
mmCtx.fillStyle = '#f1c40f';
mmCtx.fillRect(e.x * sx - 1, e.y * sy - 1, 3, 3);
}
if (e.type === 'save') {
mmCtx.fillStyle = '#6ad0e8';
mmCtx.fillRect(e.x * sx - 1, e.y * sy - 1, 3, 3);
}
}
}
// enemies
mmCtx.fillStyle = '#e74c3c';
for (const e of world.enemies) {
if (!e.alive) continue;
mmCtx.fillRect(e.x * sx - 1, e.y * sy - 1, 2, 2);
}
// player
mmCtx.fillStyle = '#fff';
mmCtx.beginPath();
mmCtx.arc(player.x * sx, player.y * sy, 2.5, 0, Math.PI * 2);
mmCtx.fill();
// camera rect
mmCtx.strokeStyle = 'rgba(255,255,255,0.25)';
mmCtx.strokeRect(camera.x * sx, camera.y * sy, VIEW_W * sx, VIEW_H * sy);
}
// ============================================================
// INPUT
// ============================================================
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
AudioFX.resume();
if (state === 'dialogue') {
if (e.code === 'KeyE' || e.code === 'Space' || e.code === 'Enter') advanceDialogue();
return;
}
if (state === 'title' || state === 'end') return;
if (e.code === 'Escape') {
if (state === 'inv') closeInventory();
else if (state === 'shop') closeShop();
else if (state === 'pause') {
document.getElementById('overlay-pause').classList.add('hidden');
state = 'play';
} else if (state === 'play') {
state = 'pause';
document.getElementById('overlay-pause').classList.remove('hidden');
document.getElementById('btn-save-manual').disabled = !canSaveHere();
}
return;
}
if (state === 'pause' || state === 'shop' || state === 'inv') {
if (e.code === 'KeyI' && state === 'inv') closeInventory();
return;
}
if (state !== 'play') return;
if (e.code === 'KeyE') interact();
if (e.code === 'KeyI') openInventory();
if (e.code === 'Digit1') { player.mode = 'melee'; updateHUD(); }
if (e.code === 'Digit2') {
if (player.ranged) { player.mode = 'ranged'; updateHUD(); }
else toast('No ranged weapon yet.');
}
if (e.code === 'KeyQ') {
// quick potion
if (hasItem('potion')) {
if (ITEMS.potion.use(player)) {
takeItem('potion');
updateHUD();
toast('Used Life Vial');
} else toast('HP is full.');
} else toast('No Life Vials.');
}
if (e.code === 'KeyF') placeBomb();
});
window.addEventListener('keyup', (e) => { keys[e.code] = false; });
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
mouse.x = (e.clientX - rect.left) * sx;
mouse.y = (e.clientY - rect.top) * sy;
mouse.worldX = mouse.x + camera.x;
mouse.worldY = mouse.y + camera.y;
});
canvas.addEventListener('mousedown', (e) => {
mouse.down = true;
AudioFX.resume();
if (state === 'dialogue') { advanceDialogue(); return; }
if (state === 'play') {
// update facing immediately
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
mouse.x = (e.clientX - rect.left) * sx;
mouse.y = (e.clientY - rect.top) * sy;
mouse.worldX = mouse.x + camera.x;
mouse.worldY = mouse.y + camera.y;
player.facing = angTo(player.x, player.y, mouse.worldX, mouse.worldY);
playerAttack();
}
});
window.addEventListener('mouseup', () => { mouse.down = false; });
document.getElementById('dialogue').addEventListener('click', () => {
if (state === 'dialogue') advanceDialogue();
});
// UI buttons
document.getElementById('btn-new').addEventListener('click', () => {
document.getElementById('overlay-title').classList.add('hidden');
newGame();
});
document.getElementById('btn-continue').addEventListener('click', () => {
if (loadGame()) {
document.getElementById('overlay-title').classList.add('hidden');
state = 'play';
toast('Save loaded.');
AudioFX.resume();
}
});
document.getElementById('btn-close-inv').addEventListener('click', closeInventory);
document.getElementById('btn-use-item').addEventListener('click', useSelectedItem);
document.getElementById('btn-close-shop').addEventListener('click', closeShop);
document.getElementById('btn-resume').addEventListener('click', () => {
document.getElementById('overlay-pause').classList.add('hidden');
state = 'play';
});
document.getElementById('btn-save-manual').addEventListener('click', () => {
if (canSaveHere()) saveGame();
else toast('Stand by a Save Stone to save.');
});
document.getElementById('btn-to-title').addEventListener('click', () => {
document.getElementById('overlay-pause').classList.add('hidden');
document.getElementById('overlay-title').classList.remove('hidden');
document.getElementById('btn-continue').disabled = !hasSave();
state = 'title';
});
document.getElementById('btn-end-title').addEventListener('click', () => {
document.getElementById('overlay-end').classList.add('hidden');
document.getElementById('overlay-title').classList.remove('hidden');
document.getElementById('btn-continue').disabled = !hasSave();
state = 'title';
});
// ============================================================
// LOOP
// ============================================================
function frame(t) {
const dt = Math.min(0.033, (t - lastT) / 1000 || 0);
lastT = t;
// refresh mouse world
mouse.worldX = mouse.x + camera.x;
mouse.worldY = mouse.y + camera.y;
update(dt);
draw();
requestAnimationFrame(frame);
}
// init
document.getElementById('btn-continue').disabled = !hasSave();
requestAnimationFrame(frame);
// Debug/test hooks (read-only-ish helpers)
window.__ember = {
get state() { return state; },
get player() { return player; },
get flags() { return flags; },
get questId() { return questId; },
get world() { return world; },
newGame,
saveGame,
loadGame,
giveItem,
updateHUD,
toast,
setQuest: (q) => { questId = q; updateQuestUI(); },
};
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"x-ai/grok-4.5","provider":"openrouter","persona":"full","sessionId":"2b644906-ddce-47ac-8085-cb4438ad7589","tokensIn":2645594,"tokensOut":52250,"tokensTotal":2697844,"turns":43,"toolCalls":42,"failedToolCalls":10,"timestamp":"2026-07-18T16:32:27.561Z"} -->