RPG
muse spark 1.1 · composer

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 49,470 bytes · SHA-256 728558a02b9d
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hollow of the Ember Grove - Mini ARPG</title>
<style>
/* ── css: base ── */
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Fraunces:opsz,wght@9..144,600;9..144,900&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0f1210;--panel:#1a211b;--accent:#f2c94c;--accent2:#7ae0a6;--danger:#ff6b6b;--text:#e8ede9;--muted:#8aa090}
html,body{height:100%;background:radial-gradient(120% 120% at 20% 10%,#1d2a1f 0%,#0f1210 60%);color:var(--text);font-family:'JetBrains Mono',monospace;overflow:hidden}
#app{height:100vh;display:flex;align-items:center;justify-content:center}
#gameShell{width:min(1020px,96vw);display:flex;flex-direction:column;gap:10px}
#topBar{display:flex;align-items:center;gap:14px;background:rgba(22,30,23,0.9);border:1px solid #2a3a2d;border-radius:12px;padding:10px 14px;backdrop-filter:blur(8px)}
.hudGroup{display:flex;gap:4px;align-items:center}
.heart{width:26px;height:26px;display:inline-grid;place-items:center;font-size:20px;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.5));transition:transform .15s}
.heart.hit{transform:scale(1.6) rotate(-10deg)}
#staminaWrap{width:120px;height:10px;background:#0a0f0b;border-radius:999px;border:1px solid #2d3d30;overflow:hidden;position:relative}
#staminaBar{height:100%;width:100%;background:linear-gradient(90deg,#7ae0a6,#4ecdc4);transition:width .12s}
#currencyWrap{margin-left:auto;display:flex;align-items:center;gap:6px;background:#111815;border:1px solid #2e3f33;border-radius:999px;padding:4px 10px;font-weight:700}
#coinIcon{color:var(--accent)}
#weaponHud{display:flex;gap:6px}
.wIcon{width:36px;height:36px;border-radius:9px;display:grid;place-items:center;background:#121a14;border:1px solid #2a3a2d;opacity:.6;transition:.2s;font-size:18px}
.wIcon.active{opacity:1;border-color:var(--accent);box-shadow:0 0 0 2px rgba(242,201,76,.2);transform:translateY(-1px)}
.wIcon.locked{filter:grayscale(1)}
#canvasWrap{position:relative;border-radius:16px;overflow:hidden;border:1px solid #273328;box-shadow:0 20px 60px rgba(0,0,0,.6), inset 0 0 0 1px rgba(255,255,255,.04);background:#0c100d}
#c{display:block;width:100%;image-rendering:pixelated;cursor:none}
#tutorial{position:absolute;left:12px;bottom:54px;background:rgba(10,14,11,.75);border:1px solid #2b3c2f;border-radius:8px;padding:6px 9px;font-size:10px;color:var(--muted);letter-spacing:.04em}
#invBar{position:absolute;left:12px;bottom:12px;display:flex;gap:8px}
.invSlot{width:42px;height:42px;border-radius:10px;background:rgba(18,26,20,.9);border:1px solid #2c3e32;display:grid;place-items:center;font-size:18px;position:relative;transition:.2s}
.invSlot.active{border-color:var(--accent);box-shadow:0 0 0 2px rgba(242,201,76,.25);transform:translateY(-3px)}
.invSlot .cnt{position:absolute;right:-4px;top:-4px;background:var(--accent);color:#000;font-size:10px;font-weight:800;border-radius:999px;min-width:16px;height:16px;display:grid;place-items:center;padding:0 3px}
#dialogBox{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(520px,86%);background:linear-gradient(180deg,#1e2a21,#161e18);border:1px solid #324438;border-radius:16px;padding:18px 20px;box-shadow:0 20px 60px rgba(0,0,0,.6);z-index:5}
#dialogText{line-height:1.6;font-size:13.5px}
#dialogHint{margin-top:12px;font-size:10px;color:var(--muted);text-align:right;opacity:.8}
#shopUI{position:absolute;inset:16px;background:rgba(14,20,16,.96);border:1px solid #334e3a;border-radius:18px;padding:16px;z-index:6;display:flex;flex-direction:column;gap:12px;backdrop-filter:blur(12px)}
.shopHead{display:flex;align-items:center;gap:12px}
.shopHead h2{font-family:'Fraunces',serif;font-size:22px;letter-spacing:-.02em}
#shopMoney{margin-left:auto;background:var(--accent);color:#000;padding:4px 10px;border-radius:999px;font-weight:800}
#shopClose{width:32px;height:32px;border-radius:50%;border:1px solid #3a4f40;background:transparent;color:var(--text);cursor:pointer}
#shopGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;max-height:300px;overflow:auto;padding-right:4px}
.shopItem{background:#1b2820;border:1px solid #2e4535;border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:6px;cursor:pointer;transition:.15s}
.shopItem:hover{border-color:var(--accent);transform:translateY(-2px)}
.shopItem .sIcon{font-size:22px}
.shopItem .sName{font-weight:700;font-size:12px}
.shopItem .sPrice{font-size:11px;color:var(--accent)}
.shopItem.disabled{opacity:.4;pointer-events:none}
#shopDesc{font-size:11px;color:var(--muted);background:#121a14;border:1px dashed #2b3f31;border-radius:10px;padding:8px 10px;min-height:36px}
#centerMsg{position:absolute;left:50%;top:22%;transform:translateX(-50%);background:rgba(0,0,0,.7);border:1px solid #3a4f40;border-radius:999px;padding:8px 14px;font-size:12px;z-index:4;backdrop-filter:blur(6px)}
#saveFlash{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) scale(.9);background:var(--accent);color:#000;font-weight:800;padding:8px 16px;border-radius:999px;z-index:10;box-shadow:0 8px 30px rgba(242,201,76,.4);transition:.3s}
#saveFlash.show{transform:translate(-50%,-50%) scale(1)}
#bottomBar{display:flex;align-items:center;gap:16px;background:rgba(22,30,23,.85);border:1px solid #2a3a2d;border-radius:12px;padding:8px 12px}
#locName{font-weight:700;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--accent2)}
#progressBarWrap{flex:1;display:flex;align-items:center;gap:10px}
.pLabel{font-size:9px;letter-spacing:.12em;color:var(--muted)}
#progressTrack{flex:1;height:8px;background:#0b120c;border-radius:999px;border:1px solid #233026;overflow:hidden}
#progressFill{height:100%;width:0%;background:linear-gradient(90deg,var(--accent),#ff8e53);transition:width .6s cubic-bezier(.2,.8,.2,1)}
#saveBtn{background:transparent;border:1px solid #334a39;color:var(--muted);border-radius:999px;padding:6px 12px;font-family:inherit;font-size:10px;cursor:pointer}
#saveBtn:hover{color:var(--text);border-color:var(--accent2)}
.hidden{display:none !important}
#titleScreen{position:fixed;inset:0;background:radial-gradient(120% 120% at 30% 20%,#223524 0%,#0d1210 55%,#080a09 100%);display:grid;place-items:center;z-index:20;transition:opacity .8s}
#titleScreen.fade{opacity:0;pointer-events:none}
.titleCard{width:min(560px,90vw);background:linear-gradient(180deg,rgba(30,42,33,.95),rgba(16,24,18,.98));border:1px solid #2d4432;border-radius:24px;padding:28px;box-shadow:0 30px 80px rgba(0,0,0,.7)}
.titleCard h1{font-family:'Fraunces',serif;font-size:44px;line-height:.9;letter-spacing:-.04em;font-weight:900}
.titleCard h1 span{display:block;font-size:16px;letter-spacing:.3em;color:var(--muted);margin:6px 0;font-weight:600}
.subtitle{margin:12px 0 16px;color:var(--muted);font-size:13px;line-height:1.5}
.featList{list-style:none;display:flex;flex-direction:column;gap:8px;margin:16px 0}
.featList li{background:#121a14;border:1px solid #233026;border-radius:10px;padding:8px 10px;font-size:12px}
#startBtn{display:block;width:100%;margin-top:8px;padding:14px;background:var(--accent);color:#000;border:none;border-radius:12px;font-weight:800;font-family:inherit;font-size:14px;letter-spacing:.04em;cursor:pointer;box-shadow:0 8px 24px rgba(242,201,76,.3);transition:.2s}
#startBtn:hover{transform:translateY(-1px);box-shadow:0 12px 32px rgba(242,201,76,.4)}
button.secondary{width:100%;margin-top:8px;padding:12px;background:transparent;border:1px solid #334a39;color:var(--text);border-radius:12px;font-family:inherit;cursor:pointer}
.keys{margin-top:12px;font-size:10px;color:var(--muted);text-align:center;opacity:.7}
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-3px)}75%{transform:translateX(3px)}}
.shake{animation:shake .2s 2}
</style>
</head>
<body>
<div id="gameShell">
<div id="topBar">
<div id="hearts" class="hudGroup"></div>
<div id="staminaWrap"><div id="staminaBar"></div></div>
<div id="currencyWrap"><span id="coinIcon">◍</span><span id="coinCount">0</span></div>
<div id="weaponHud">
<div id="meleeIcon" class="wIcon active">⚔</div>
<div id="rangedIcon" class="wIcon locked">➶</div>
</div>
</div>
<div id="canvasWrap">
<canvas id="c" width="960" height="600"></canvas>
<div id="dialogBox" class="hidden">
<div id="dialogText"></div>
<div id="dialogHint">[E] Continue • [Q] Close</div>
</div>
<div id="shopUI" class="hidden">
<div class="shopHead"><h2>Elder Myra's Hollow Shop</h2><span id="shopMoney"></span><button id="shopClose">×</button></div>
<div id="shopGrid"></div>
<div id="shopDesc"></div>
</div>
<div id="invBar"></div>
<div id="centerMsg" class="hidden"></div>
<div id="saveFlash" class="hidden">Progress Echoed</div>
<div id="tutorial">WASD Move • SHIFT Sprint • J Attack • K Ranged • E Interact • I Cycle Item • Use with E</div>
</div>
<div id="bottomBar">
<div id="locName">Whispering Meadow</div>
<div id="progressBarWrap"><div class="pLabel">SOUL GROWTH</div><div id="progressTrack"><div id="progressFill"></div></div></div>
<button id="saveBtn">Shrine Save</button>
</div>
</div>
<div id="titleScreen">
<div class="titleCard">
<h1>HOLLOW<span>of the</span>EMBER GROVE</h1>
<p class="subtitle">Find two blade evolutions, discover the bow at mid-game, grow 3 → 5 hearts. Forage 6 unique relics.</p>
<ul class="featList">
<li><b>3→5 Hearts</b> • Each heart = 2 HP • Visible half-hearts</li>
<li><b>Weapon Path</b> Rust Blade → Iron Edge (+speed) → Frostbrand (+range & chill)</li>
<li><b>Mid-Game Bow</b> behind cracked walls of Deep Hollow</li>
</ul>
<button id="startBtn">Enter the Grove</button>
<button id="contBtn" class="secondary hidden">Continue Echo</button>
<div class="keys">WASD move • SHIFT sprint costs stamina • J melee • K bow • E interact • I quick cycle</div>
</div>
</div>
<script>
'use strict';
// ── const: TILE ──
const TILE = { GRASS:0, DIRT:1, TREE:2, WATER:3, STONE:4, CRACK:5, SAND:6, MOSSY:7, FLOWER:8 };
// ── const: TILE_COLOR ──
const TILE_COLOR = { 0:'#1a2a1e',1:'#2b2616',2:'#0f1a0f',3:'#123043',4:'#3a3d42',5:'#4a3720',6:'#3a3520',7:'#1e3222',8:'#1a2a1e' };
// ── const: WORLD_W ──
const WORLD_W = 80;
// ── const: WORLD_H ──
const WORLD_H = 50;
// ── function: rand ──
function rand(a,b){ return Math.random()*(b-a)+a; }
// ── function: dist ──
function dist(ax,ay,bx,by){ return Math.hypot(ax-bx,ay-by); }
// ── function: clamp ──
function clamp(v,a,b){ return Math.max(a,Math.min(b,v)); }
// ── function: lerp ──
function lerp(a,b,t){ return a+(b-a)*t; }
// ── const: TILE_SOLID ──
const TILE_SOLID = new Set([2,4,5]);
// ── const: WEAPONS ──
const WEAPONS = {
rust: { name:"Rusty Blade", dmg:1, range:28, speed:0.4, color:"#8a8f98", label:"Rust" },
iron: { name:"Iron Edge", dmg:2, range:32, speed:0.28, color:"#c9d1d9", label:"Iron" },
frost: { name:"Frostbrand", dmg:3, range:48, speed:0.35, color:"#7ec8e3", label:"Frost", chill:true }
};
// ── const: ITEMS_DEF ──
const ITEMS_DEF = {
potion: { icon:"🧪", name:"Sun Vial", desc:"Restores 4 HP (2 hearts)", type:"consumable", price:25 },
leaf: { icon:"🍃", name:"Stamina Leaf", desc:"+40 stamina, buff sprint 5s", type:"consumable", price:18 },
key: { icon:"🗝️", name:"Iron Key", desc:"Opens stone-locked chest", type:"key", price:0 },
boots: { icon:"🥾", name:"Swift Boots", desc:"+35% move, -30% stamina drain, light step anim", type:"accessory", price:70 },
bomb: { icon:"💣", name:"Gloom Bomb", desc:"Break cracked rocks (3 uses)", type:"tool", price:40 },
charm: { icon:"🧿", name:"Wind Amulet", desc:"Dash afterimage + grants heart capacity", type:"accessory", price:95 },
heartpiece: { icon:"💗", name:"Heart Loom", desc:"+1 heart container", type:"upgrade", price:0 },
bow: { icon:"🏹", name:"Hunter Bow", desc:"Ranged mid-game discovery", type:"weapon", price:0 }
};
// ── const: ROOM_ZONES ──
const ROOM_ZONES = [
{name:"Whispering Meadow", x:0,y:0,w:40,h:25, enemy:1},
{name:"Thorned Hollow", x:40,y:0,w:40,h:25, enemy:2},
{name:"Deep Grove Cavern", x:0,y:25,w:40,h:25, enemy:3},
{name:"Ember Mountain Shrine", x:40,y:25,w:40,h:25, enemy:4}
];
// ── class: AudioFX ──
class AudioFX {
constructor(){ this.ctx=null; this.enabled=true; }
init(){ if(this.ctx) return; this.ctx = new (window.AudioContext||window.webkitAudioContext)(); }
tone(freq, dur, type='sine', vol=0.2, slide=0){
if(!this.enabled || !this.ctx) return;
let o = this.ctx.createOscillator(); let g = this.ctx.createGain();
o.type=type; o.frequency.value=freq;
if(slide) o.frequency.exponentialRampToValueAtTime(freq*slide, this.ctx.currentTime+dur);
g.gain.setValueAtTime(vol, this.ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime+dur);
o.connect(g); g.connect(this.ctx.destination);
o.start(); o.stop(this.ctx.currentTime+dur);
}
noise(dur, vol=0.15){
if(!this.enabled||!this.ctx) return;
let buffer = this.ctx.createBuffer(1, this.ctx.sampleRate*dur, this.ctx.sampleRate);
let data = buffer.getChannelData(0);
for(let i=0;i<data.length;i++) data[i]=(Math.random()*2-1)*0.5;
let src=this.ctx.createBufferSource(); src.buffer=buffer;
let g=this.ctx.createGain(); g.gain.value=vol;
g.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime+dur);
src.connect(g); g.connect(this.ctx.destination); src.start();
}
sfx(name){
this.init();
if(name==='step'){ this.tone(90+Math.random()*30,0.12,'sine',0.06); }
else if(name==='attack'){
this.tone(260,0.08,'square',0.22); setTimeout(()=>this.tone(120,0.12,'sawtooth',0.15),50);
}
else if(name==='hit'){ this.tone(180,0.2,'sawtooth',0.25,0.5); this.noise(0.15,0.08); }
else if(name==='hurt'){ this.tone(220,0.35,'triangle',0.3,0.4); this.tone(110,0.4,'sine',0.2); }
else if(name==='pickup'){ this.tone(440,0.15,'sine',0.2,1.5); setTimeout(()=>this.tone(660,0.25,'sine',0.18,1.3),80); }
else if(name==='shop'){ this.tone(500,0.12,'triangle',0.18,2); setTimeout(()=>this.tone(700,0.18,'sine',0.15),70); }
else if(name==='bow'){ this.tone(300,0.08,'triangle',0.2,2.5); this.noise(0.05,0.05); }
else if(name==='save'){ this.tone(330,0.3,'sine',0.2,1.5); setTimeout(()=>this.tone(495,0.4,'sine',0.18),120); setTimeout(()=>this.tone(660,0.5,'sine',0.15),240); }
else if(name==='levelup'){ this.tone(200,0.2,'sine',0.2,2); setTimeout(()=>this.tone(400,0.2,'sine',0.2,1.8),150); setTimeout(()=>this.tone(800,0.5,'sine',0.2),300); }
else if(name==='bomb'){ this.tone(80,0.5,'sawtooth',0.35,0.3); this.noise(0.4,0.25); }
}
}
// ── class: Particle ──
class Particle {
constructor(x,y,vx,vy,life,color,size, type){
this.x=x; this.y=y; this.vx=vx; this.vy=vy; this.life=life; this.max=life; this.color=color; this.size=size; this.type=type||'dot';
}
update(){ this.x+=this.vx; this.y+=this.vy; this.vy+=0.08; this.vx*=0.98; this.life--; }
}
// ── class: ParticleSystem ──
class ParticleSystem {
constructor(){ this.p=[]; }
emit(x,y,count,color, spread=2){
for(let i=0;i<count;i++){
let ang=Math.random()*Math.PI*2; let sp=rand(0.5,spread);
this.p.push(new Particle(x,y,Math.cos(ang)*sp, Math.sin(ang)*sp -rand(0,1), rand(12,28), color, rand(2,5)));
}
}
impact(x,y,color="#fff"){ this.emit(x,y,12,color,3.2); }
pickup(x,y){ this.emit(x,y,16,"#f2c94c",2.5); }
heal(x,y){ this.emit(x,y,10,"#7ae0a6",1.5); }
update(){ for(let i=this.p.length-1;i>=0;i--){ this.p[i].update(); if(this.p[i].life<=0) this.p.splice(i,1); } }
draw(ctx,cam){
for(let pt of this.p){
let a=pt.life/pt.max; ctx.globalAlpha=a;
ctx.fillStyle=pt.color;
if(pt.type==='dot'){ ctx.beginPath(); ctx.arc((pt.x-cam.x)*32, (pt.y-cam.y)*32, pt.size*a,0,Math.PI*2); ctx.fill(); }
}
ctx.globalAlpha=1;
}
}
// ── class: InputMgr ──
class InputMgr {
constructor(){ this.keys={}; this.just={}; this.mouse={x:0,y:0, down:false}; }
bind(){
addEventListener('keydown',e=>{
if(!this.keys[e.code]) this.just[e.code]=true;
this.keys[e.code]=true;
if(['Space','KeyJ','KeyK','KeyE','KeyI','KeyP'].includes(e.code)) e.preventDefault();
});
addEventListener('keyup',e=>{ this.keys[e.code]=false; });
let canvas=document.getElementById('c');
canvas.addEventListener('mousemove',e=>{
let r=canvas.getBoundingClientRect();
this.mouse.x=(e.clientX-r.left)/r.width*960;
this.mouse.y=(e.clientY-r.top)/r.height*600;
});
}
pressed(k){ return !!this.keys[k]; }
justPressed(k){ let v=!!this.just[k]; this.just[k]=false; return v; }
consume(k){ this.just[k]=false; }
}
// ── class: TileWorld ──
class TileWorld {
constructor(){
this.w=WORLD_W; this.h=WORLD_H;
this.tiles=[]; this.objects=[]; this.enemies=[]; this.pickups=[]; this.shrines=[]; this.chests=[]; this.switches=[];
}
generate(){
// fill noise based on zones
for(let y=0;y<this.h;y++){
this.tiles[y]=[];
for(let x=0;x<this.w;x++){
let n=Math.random();
if(n<0.08) this.tiles[y][x]=TILE.TREE;
else if(n<0.10) this.tiles[y][x]=TILE.FLOWER;
else this.tiles[y][x]=TILE.GRASS;
}
}
// carve paths + add walls around center river
for(let y=10;y<35;y++){ this.tiles[y][38]=TILE.WATER; this.tiles[y][39]=TILE.WATER; }
// bridge
this.tiles[20][38]=TILE.DIRT; this.tiles[20][39]=TILE.DIRT;
this.tiles[32][38]=TILE.DIRT; this.tiles[32][39]=TILE.DIRT;
// top left: dense forest
for(let y=0;y<12;y++) for(let x=0;x<15;x++) if(Math.random()<0.2) this.tiles[y][x]=TILE.TREE;
// bottom right: stone ruins
for(let i=0;i<80;i++){ let x=50+Math.floor(Math.random()*28), y=30+Math.floor(Math.random()*18); if(x<79&&y<49) this.tiles[y][x]=TILE.STONE; }
// cracked walls
this.tiles[22][22]=TILE.CRACK; this.tiles[23][22]=TILE.CRACK;
this.tiles[27][55]=TILE.CRACK; this.tiles[28][55]=TILE.CRACK; this.tiles[28][54]=TILE.CRACK;
// special objects placement
this.shrines.push({x:5,y:5, id:'start', used:false});
this.shrines.push({x:58,y:8, id:'mid', used:false});
this.shrines.push({x:65,y:42, id:'end', used:false});
// shop at 20,12
this.objects.push({type:'shop', x:20, y:12});
// chests
this.chests.push({x:10,y:18, locked:false, loot:'heartpiece', opened:false}); // first heart
this.chests.push({x:32,y:6, locked:true, loot:'iron', opened:false, key:'key'}); // iron upgrade need key
this.chests.push({x:25,y:22, locked:false, loot:'key', opened:false}); // key near cracked
this.chests.push({x:56,y:14, locked:false, loot:'bow', opened:false}); // mid game bow behind cracked (we gate by crack check)
this.chests.push({x:62,y:36, locked:false, loot:'frost', opened:false}); // final frost
this.chests.push({x:70,y:10, locked:false, loot:'heartpiece', opened:false}); // second heart
// switches for puzzle: two switches open bridge?
this.switches.push({x:45,y:20, activated:false, target:'bridge'});
this.switches.push({x:44,y:21, activated:false, target:'bridge'});
// enemies
let spots=[[8,10],[14,12],[30,10],[35,18],[48,8],[52,16],[10,35],[18,38],[55,35],[68,38],[62,28]];
spots.forEach((s,i)=>{
this.enemies.push({x:s[0]+0.5,y:s[1]+0.5, hp: (i>5?3:2), max: (i>5?3:2), type: i>7?'brute':'slime', state:'idle', t:0});
});
// coin pickups
for(let i=0;i<90;i++){
let x=Math.floor(Math.random()*(WORLD_W-4))+2, y=Math.floor(Math.random()*(WORLD_H-4))+2;
if(this.tiles[y][x]===TILE.GRASS && Math.random()<0.6) this.pickups.push({x:x+0.5,y:y+0.5, type:'coin', val: rand(3,7)|0, taken:false});
}
// special items in wild
this.pickups.push({x:15.5,y:8.5,type:'item', id:'boots', taken:false});
this.pickups.push({x:48.5,y:32.5,type:'item', id:'charm', taken:false});
}
isSolid(tx,ty){
if(tx<0||ty<0||tx>=this.w||ty>=this.h) return true;
return TILE_SOLID.has(this.tiles[ty][tx]);
}
}
// ── class: PlayerEnt ──
class PlayerEnt {
constructor(x,y){
this.x=x; this.y=y; this.vx=0; this.vy=0; this.w=0.7; this.h=0.7;
this.maxHearts=3; this.hp=6; // 3 hearts *2
this.stamina=100; this.maxStam=100; this.boostTime=0;
this.money=0; this.facing=[1,0]; this.attackT=0; this.hurtT=0;
this.weapon='rust'; this.hasBow=false; this.bowCD=0;
this.inventory={}; this.invOrder=[]; this.activeIdx=0;
this.speed=0.085; this.hasBoots=false; this.hasCharm=false;
this.progress=0;
}
addItem(id, cnt=1){
if(!this.inventory[id]) { this.inventory[id]=0; this.invOrder.push(id); }
this.inventory[id]+=cnt;
if(id==='boots') { this.hasBoots=true; this.speed=0.115; }
if(id==='charm') { this.hasCharm=true; this.maxHearts=Math.min(5, this.maxHearts+1); this.hp=Math.min(this.maxHearts*2,this.hp+2); }
if(id==='heartpiece'){ this.maxHearts=Math.min(5,this.maxHearts+1); this.hp=this.maxHearts*2; }
}
heal(pts){
this.hp=clamp(this.hp+pts,0,this.maxHearts*2);
}
takeDamage(d){
if(this.hurtT>0) return;
this.hp-=d; this.hurtT=30; this.stamina=Math.max(0,this.stamina-10);
}
update(inp, world, particles, audio, game){
let mvx=0,mvy=0;
if(inp.pressed('KeyW')) mvy-=1;
if(inp.pressed('KeyS')) mvy+=1;
if(inp.pressed('KeyA')) mvx-=1;
if(inp.pressed('KeyD')) mvx+=1;
let mag=Math.hypot(mvx,mvy);
if(mag>0){ mvx/=mag; mvy/=mag; this.facing=[mvx||this.facing[0], mvy||this.facing[1]]; }
let sprint = inp.pressed('ShiftLeft')||inp.pressed('ShiftRight');
let spd=this.speed;
let stamDrain = this.hasBoots?0.25:0.38;
if(sprint && mag>0 && this.stamina>0){
spd*=1.55; this.stamina=clamp(this.stamina-(this.boostTime>0?0:stamDrain),0,100);
if(Math.random()<0.4) particles.emit(this.x,this.y,1,'#7ae0a6',0.8);
} else {
this.stamina=clamp(this.stamina+0.45,0,100);
}
if(this.boostTime>0) this.boostTime--;
// footstep
if(mag>0 && game.t% (sprint?7:12)==0 && this.attackT<=0){ audio.sfx('step'); }
// attempt move with collision
let nx=this.x+mvx*spd, ny=this.y+mvy*spd;
if(!world.isSolid(Math.floor(nx+this.w*0.5), Math.floor(this.y+this.h*0.5))){
this.x=nx;
}
if(!world.isSolid(Math.floor(this.x+this.w*0.5), Math.floor(ny+this.h*0.5))){
this.y=ny;
}
this.x=clamp(this.x,0,world.w-1); this.y=clamp(this.y,0,world.h-1);
if(this.attackT>0) this.attackT--;
if(this.hurtT>0) this.hurtT--;
if(this.bowCD>0) this.bowCD--;
if(inp.justPressed('KeyJ') || inp.justPressed('Space')){
if(this.attackT<=0){ this.attackT= WEAPONS[this.weapon].speed*60+10; audio.sfx('attack'); // attack trigger for world to handle
game.pendingMelee=true;
}
}
if((inp.justPressed('KeyK')) && this.hasBow && this.bowCD<=0){
game.spawnArrow(this.x+0.35, this.y+0.35, this.facing[0], this.facing[1]);
this.bowCD=28; audio.sfx('bow');
}
}
}
// ── class: ArrowEnt ──
class ArrowEnt {
constructor(x,y,dx,dy){ this.x=x; this.y=y; this.dx=dx; this.dy=dy; this.life=60; this.speed=0.32; }
update(world){ this.x+=this.dx*this.speed; this.y+=this.dy*this.speed; this.life--; if(world.isSolid(Math.floor(this.x),Math.floor(this.y))) this.life=0; }
}
// ── class: GameCore ──
class GameCore {
constructor(){
this.canvas=document.getElementById('c');
this.ctx=this.canvas.getContext('2d');
this.audio=new AudioFX();
this.particles=new ParticleSystem();
this.input=new InputMgr();
this.world=new TileWorld();
this.world.generate();
this.player=new PlayerEnt(5.5,5.5);
this.arrows=[];
this.cam={x:0,y:0};
this.t=0;
this.pendingMelee=false;
this.messageT=0;
this.currentZone="Whispering Meadow";
this.dialogQueue=[];
this.paused=false; this.shopOpen=false;
this.shopStock=[
{id:'potion', stock:5}, {id:'leaf', stock:8}, {id:'bomb', stock:3}, {id:'boots', stock:1}, {id:'charm', stock:1}
];
this.objective=0;
this.bossAlive=true;
}
getTileUnderPlayer(){ return this.world.tiles[Math.floor(this.player.y+0.35)][Math.floor(this.player.x+0.35)]; }
zoneAt(x,y){
for(let z of ROOM_ZONES){ if(x>=z.x && x<z.x+z.w && y>=z.y && y<z.y+z.h) return z; }
return ROOM_ZONES[0];
}
interact(){
let px=Math.floor(this.player.x+0.35+this.player.facing[0]*1.0);
let py=Math.floor(this.player.y+0.35+this.player.facing[1]*1.0);
// shrine
for(let s of this.world.shrines){
if(dist(s.x,s.y,this.player.x,this.player.y)<1.2){
this.saveGame(s.id); this.audio.sfx('save'); this.flashSave(); s.used=true; this.particles.emit(s.x,s.y,24,"#f2c94c",2.8);
this.showCenter(`Echo saved at ${s.id} shrine`,90);
return;
}
}
// chest
for(let ch of this.world.chests){
if(dist(ch.x,ch.y,this.player.x,this.player.y)<1.3 && !ch.opened){
if(ch.locked){
if(this.player.inventory['key']>0){
this.player.inventory['key']--; if(this.player.inventory['key']<=0){ delete this.player.inventory['key']; this.player.invOrder=this.player.invOrder.filter(k=>k!=='key'); }
ch.locked=false; this.audio.sfx('shop'); this.showDialog(`Iron lock clicks open with a rusty key. The chest breathes...`);
} else {
this.showDialog(`Chest locked. Requires Iron Key from the cracked passage.`); this.audio.sfx('hit'); return;
}
}
if(!ch.locked){
ch.opened=true; this.handleLoot(ch.loot);
}
return;
}
}
// shop
for(let o of this.world.objects){
if(o.type==='shop' && dist(o.x,o.y,this.player.x,this.player.y)<1.6){
this.openShop(); return;
}
}
// switches
for(let sw of this.world.switches){
if(dist(sw.x,sw.y,this.player.x,this.player.y)<1.2 && !sw.activated){
sw.activated=true; this.particles.impact(sw.x,sw.y,"#7ae0a6"); this.audio.sfx('shop');
// open path
this.world.tiles[22][38]=TILE.DIRT; this.world.tiles[22][39]=TILE.DIRT;
this.showCenter("Ancient mechanism hums... Eastern crossing opened!",120);
}
}
// cracked wall bomb
if(this.world.tiles[py] && this.world.tiles[py][px]===TILE.CRACK){
if(this.player.inventory['bomb']>0){
this.world.tiles[py][px]=TILE.DIRT; this.player.inventory['bomb']--; if(this.player.inventory['bomb']<=0){ delete this.player.inventory['bomb']; this.player.invOrder=this.player.invOrder.filter(k=>k!=='bomb'); }
this.audio.sfx('bomb'); this.particles.emit(px,py,22,"#c9a36a",3.5); this.showCenter("Cracked stone shattered!",80);
} else {
this.showDialog("Cracked stone blocks the way. It looks fragile... maybe a Gloom Bomb could break it? Sold in Myra's shop, or hidden around.");
}
return;
}
// use consumable from active slot
let activeId=this.player.invOrder[this.player.activeIdx];
if(activeId){
let def=ITEMS_DEF[activeId];
if(def && (def.type==='consumable' || def.type==='tool')){
// we prevent bomb auto use unless targeting crack - else just message
if(activeId==='bomb'){ this.showDialog("Equip bombs then press E near cracked rock to detonate."); return; }
if(activeId==='potion' || activeId==='leaf'){
if(activeId==='potion' && this.player.hp>=this.player.maxHearts*2){ this.showDialog("Health full."); return; }
this.applyItem(activeId);
}
}
}
}
applyItem(id){
let count=this.player.inventory[id]||0; if(count<=0) return;
if(id==='potion'){ this.player.heal(4); this.particles.heal(this.player.x,this.player.y); this.audio.sfx('pickup'); }
if(id==='leaf'){ this.player.stamina=Math.min(100,this.player.stamina+40); this.player.boostTime=300; this.audio.sfx('pickup'); }
this.player.inventory[id]--; if(this.player.inventory[id]<=0){ delete this.player.inventory[id]; this.player.invOrder=this.player.invOrder.filter(k=>k!==id); if(this.player.activeIdx>=this.player.invOrder.length) this.player.activeIdx=0; }
this.updateInvHUD();
}
handleLoot(lootId){
this.audio.sfx('levelup');
if(lootId==='iron'){ this.player.weapon='iron'; this.player.progress=Math.max(this.player.progress,1); this.showDialog(`You found IRON EDGE! Melee speed +30%, damage +1. Your stance shifts, blade gleams. Press J to feel it. This is upgrade 1/2.`); this.particles.emit(this.player.x,this.player.y,28,"#c9d1d9",3); }
else if(lootId==='frost'){ this.player.weapon='frost'; this.player.progress=Math.max(this.player.progress,3); this.showDialog(`FROSTBRAND awakened! Range +50%, chill slows foes. Final evolution (2/2) complete! Your silhouette now trails icy light.`); this.particles.emit(this.player.x,this.player.y,32,"#7ec8e3",3.2); }
else if(lootId==='bow'){ this.player.hasBow=true; document.getElementById('rangedIcon').classList.remove('locked'); this.player.progress=Math.max(this.player.progress,2); this.showDialog(`Mid-game discovery: HUNTER BOW! Press K to loose arrows. Parallax shot hits switches & cracks. Found only after reaching deep hollow (progress half-way).`); this.particles.emit(this.player.x,this.player.y,24,"#a0d995",2.8); this.showCenter("BOW UNLOCKED",100); }
else if(lootId==='key'){ this.player.addItem('key',1); this.showCenter("Iron Key acquired!",80); this.particles.pickup(this.player.x,this.player.y); }
else if(lootId==='heartpiece'){ this.player.addItem('heartpiece',1); this.showCenter("HEART LOOM +1 Heart Container!",120); this.particles.heal(this.player.x,this.player.y); }
this.updateHUD();
}
spawnArrow(x,y,dx,dy){
let len=Math.hypot(dx,dy)||1; dx/=len; dy/=len;
this.arrows.push(new ArrowEnt(x,y,dx,dy));
}
saveGame(id){
let data={ x:this.player.x, y:this.player.y, hp:this.player.hp, max:this.player.maxHearts, weap:this.player.weapon, hasBow:this.player.hasBow, inv:this.player.inventory, order:this.player.invOrder, money:this.player.money, chest:this.world.chests, pickups:this.world.pickups.map(p=>p.taken), shrine:id, progress:this.player.progress };
localStorage.setItem('emberGroveSave', JSON.stringify(data));
let el=document.getElementById('saveFlash'); el.classList.remove('hidden'); el.classList.add('show'); setTimeout(()=>{ el.classList.add('hidden'); el.classList.remove('show'); },900);
}
loadGame(){
try{
let raw=localStorage.getItem('emberGroveSave'); if(!raw) return false;
let d=JSON.parse(raw);
this.player.x=d.x; this.player.y=d.y; this.player.hp=d.hp; this.player.maxHearts=d.max; this.player.weapon=d.weap; this.player.hasBow=d.hasBow; this.player.inventory=d.inv; this.player.invOrder=d.order; this.player.money=d.money; this.player.progress=d.progress||0;
d.chest.forEach((c,i)=>{ if(this.world.chests[i]){ this.world.chests[i].opened=c.opened; this.world.chests[i].locked=c.locked; }});
d.pickups.forEach((taken,i)=>{ if(this.world.pickups[i]) this.world.pickups[i].taken=taken; });
if(this.player.hasBow) document.getElementById('rangedIcon').classList.remove('locked');
return true;
}catch(e){ return false; }
}
showDialog(txt){ let box=document.getElementById('dialogBox'); document.getElementById('dialogText').innerHTML=txt; box.classList.remove('hidden'); this.dialogOpen=true; }
closeDialog(){ document.getElementById('dialogBox').classList.add('hidden'); this.dialogOpen=false; }
showCenter(txt, dur=60){ let el=document.getElementById('centerMsg'); el.textContent=txt; el.classList.remove('hidden'); this.messageT=dur; }
flashSave(){}
openShop(){
this.shopOpen=true; document.getElementById('shopUI').classList.remove('hidden'); this.audio.sfx('shop'); this.renderShop();
}
closeShop(){ this.shopOpen=false; document.getElementById('shopUI').classList.add('hidden'); }
renderShop(){
let grid=document.getElementById('shopGrid'); grid.innerHTML='';
document.getElementById('shopMoney').textContent=`◍ ${this.player.money}`;
this.shopStock.forEach((s,i)=>{
let def=ITEMS_DEF[s.id]; if(!def) return;
let div=document.createElement('div'); div.className='shopItem'+(this.player.money<def.price || s.stock<=0?' disabled':'');
div.innerHTML=`<div class="sIcon">${def.icon}</div><div class="sName">${def.name}</div><div class="sPrice">${s.stock>0?def.price+'◍':'SOLD'}</div>`;
div.onmouseenter=()=>{ document.getElementById('shopDesc').textContent=def.desc; };
div.onclick=()=>{
if(this.player.money>=def.price && s.stock>0){
this.player.money-=def.price; s.stock--;
if(def.type==='accessory' && (s.id==='boots'||s.id==='charm')){ this.player.addItem(s.id,1); }
else if(s.id==='bomb'){ this.player.addItem('bomb',3); }
else { this.player.addItem(s.id,1); }
this.audio.sfx('pickup'); this.particles.pickup(this.player.x,this.player.y);
this.updateHUD(); this.renderShop();
document.getElementById('shopDesc').textContent=`Purchased ${def.name}! ${def.desc}`;
// at least 5 distinct items requirement met via stock
}
};
grid.appendChild(div);
});
}
updateHUD(){
// hearts: each heart =2 points visual
let c=document.getElementById('hearts'); c.innerHTML='';
for(let i=0;i<this.player.maxHearts;i++){
let hpForHeart= Math.max(0, Math.min(2, this.player.hp - i*2));
let el=document.createElement('div'); el.className='heart';
if(this.player.hurtT>0 && hpForHeart>0) el.classList.add('hit');
if(hpForHeart>=2) el.textContent='♥';
else if(hpForHeart>=1) el.textContent='♡'; // use half visual diff
else el.textContent='·';
el.style.color= hpForHeart>0 ? (this.player.hurtT>0?'#ff8b8b':'#ff5a5a') : '#2a2f2b';
el.title=`${hpForHeart}/2`;
c.appendChild(el);
}
document.getElementById('coinCount').textContent=this.player.money;
document.getElementById('staminaBar').style.width=this.player.stamina+'%';
// weapon icons
document.getElementById('meleeIcon').textContent= WEAPONS[this.player.weapon].icon||'⚔';
document.getElementById('meleeIcon').style.color=WEAPONS[this.player.weapon].color;
document.getElementById('meleeIcon').classList.toggle('active', this.player.attackT<=0);
document.getElementById('rangedIcon').classList.toggle('active', this.player.hasBow && this.player.bowCD<=0);
this.updateInvHUD();
// progress
let prog = (this.player.maxHearts-3) + (this.player.weapon!=='rust'?1:0) + (this.player.weapon==='frost'?1:0) + (this.player.hasBow?1:0) + (Object.keys(this.player.inventory).length/6);
prog = Math.min(1, prog/5)*100;
document.getElementById('progressFill').style.width=prog+'%';
// location
let zone=this.zoneAt(this.player.x,this.player.y); if(zone) this.currentZone=zone.name;
document.getElementById('locName').textContent=this.currentZone + (this.player.hasBoots?' • SWIFT':'') + (this.player.hasCharm?' • WIND':'');
}
updateInvHUD(){
let bar=document.getElementById('invBar'); bar.innerHTML='';
this.player.invOrder.forEach((id,idx)=>{
let def=ITEMS_DEF[id]; if(!def) return;
let cnt=this.player.inventory[id]||0; if(cnt<=0) return;
let div=document.createElement('div'); div.className='invSlot'+(idx===this.player.activeIdx?' active':'');
div.innerHTML=`${def.icon}${cnt>1?`<span class="cnt">${cnt}</span>`:''}`;
div.title=`${def.name}: ${def.desc}`;
bar.appendChild(div);
});
if(this.player.invOrder.length===0){
let d=document.createElement('div'); d.className='invSlot'; d.textContent='—'; d.style.opacity='.5'; bar.appendChild(d);
}
}
gameOverCheck(){
if(this.player.hp<=0){
this.showDialog(`<b style="color:#ff6b6b">You faded...</b><br>Your ember dims. Returning to last shrine echo.<br><br>Tip: Buy Sun Vials, dodge while stamina high. Frostbrand chills enemies.`);
this.player.hp=this.player.maxHearts*2; this.player.x=5.5; this.player.y=5.5; this.player.stamina=100;
}
}
}
// ── function: drawPlayer ──
function drawPlayer(ctx, player, cam, t){
let px=(player.x-cam.x)*32, py=(player.y-cam.y)*32;
let bob = Math.sin(t*0.25 + (player.hasBoots? t*0.1:0))* (player.hasBoots? 2.5:1.5) * (Math.hypot(player.vx,player.vy)>0?1:0);
let hurt = player.hurtT>0 ? (player.hurtT%4<2?0.6:1) : 1;
ctx.globalAlpha=hurt;
// shadow
ctx.fillStyle='rgba(0,0,0,0.4)'; ctx.beginPath(); ctx.ellipse(px+14, py+23, 9,4,0,0,Math.PI*2); ctx.fill();
// body based on equipment
let bodyColor = player.hasCharm? '#2a3d33' : '#2b3a2f';
if(player.hurtT>0) bodyColor='#5a1e1e';
ctx.fillStyle=bodyColor; ctx.fillRect(px+8, py+4+bob, 12,12);
// weapon visualization dynamically changes
let wep=WEAPONS[player.weapon];
ctx.save(); ctx.translate(px+14, py+12+bob);
let swing = player.attackT>0 ? Math.sin((1-player.attackT/ (wep.speed*60+10))*Math.PI)* -1.2 : 0;
ctx.rotate( swing + (player.facing[0]>0?0.2:-0.2));
ctx.fillStyle=wep.color; ctx.fillRect(6, -2, wep.range*0.45, 4);
if(player.weapon==='frost'){ ctx.fillStyle='#b6e9ff'; ctx.fillRect(10,-1,14,2); }
if(player.weapon==='rust'){ ctx.fillStyle='#553a2a'; ctx.fillRect(0,-1,6,3); }
ctx.restore();
// head
ctx.fillStyle='#d8cfb0'; ctx.fillRect(px+10, py+2+bob, 8,6);
// eyes facing
ctx.fillStyle='#0a0a0a'; let fx=player.facing[0], fy=player.facing[1];
ctx.fillRect(px+11+fx*2, py+4+bob+fy, 2,2); ctx.fillRect(px+15+fx*2, py+4+bob+fy, 2,2);
// stamina aura if boosted
if(player.boostTime>0){ ctx.strokeStyle='#7ae0a6'; ctx.lineWidth=1; ctx.globalAlpha=0.6; ctx.beginPath(); ctx.arc(px+14, py+12, 16+Math.sin(t*0.3)*2,0,Math.PI*2); ctx.stroke(); ctx.globalAlpha=hurt; }
// boots particle trail
if(player.hasBoots){ ctx.fillStyle= player.hasCharm?'rgba(122,224,166,0.25)':'rgba(200,200,200,0.15)'; ctx.fillRect(px+6, py+18+bob, 4,3); ctx.fillRect(px+18, py+18+bob,4,3); }
ctx.globalAlpha=1;
// weapon type label above head
if(t%60<30){ ctx.fillStyle=wep.color; ctx.font='700 9px JetBrains Mono'; ctx.fillText(wep.label.toUpperCase(), px+2, py-2+bob); }
}
// ── function: drawWorld ──
function drawWorld(ctx, world, cam, t){
let startX=Math.floor(cam.x), endX=Math.floor(cam.x+30), startY=Math.floor(cam.y), endY=Math.floor(cam.y+19);
for(let y=startY;y<endY;y++){
for(let x=startX;x<endX;x++){
if(x<0||y<0||x>=world.w||y>=world.h) continue;
let tile=world.tiles[y][x];
let sx=(x-cam.x)*32, sy=(y-cam.y)*32;
ctx.fillStyle=TILE_COLOR[tile]||'#111';
ctx.fillRect(sx,sy,32,32);
// details
if(tile===TILE.GRASS){
if((x+y)%7===0){ ctx.fillStyle='rgba(122,224,166,0.15)'; ctx.fillRect(sx+6,sy+8,2,12); }
if((x*y)%13===2){ ctx.fillStyle='#2e4a33'; ctx.beginPath(); ctx.arc(sx+16,sy+20,3,0,Math.PI*2); ctx.fill(); }
} else if(tile===TILE.TREE){
ctx.fillStyle='#0e1a10'; ctx.fillRect(sx+6,sy+2,20,24);
ctx.fillStyle='#1b4d2b'; ctx.beginPath(); ctx.arc(sx+16,sy+10,12,0,Math.PI*2); ctx.fill();
} else if(tile===TILE.WATER){
ctx.fillStyle=`rgba(20,${70+Math.sin(t*0.05+x)*15|0},120,0.8)`; ctx.fillRect(sx,sy,32,32);
ctx.fillStyle='rgba(255,255,255,0.15)'; ctx.fillRect(sx+Math.sin(t*0.02+x)*8+8, sy+4, 12,2);
} else if(tile===TILE.CRACK){
ctx.fillStyle='#5a4630'; ctx.fillRect(sx,sy,32,32);
ctx.strokeStyle='#f2c94c'; ctx.lineWidth=1.5; ctx.beginPath(); ctx.moveTo(sx+6,sy+6); ctx.lineTo(sx+26,sy+26); ctx.moveTo(sx+26,sy+6); ctx.lineTo(sx+6,sy+26); ctx.stroke();
ctx.fillStyle='#f2c94c'; ctx.font='10px monospace'; ctx.fillText('!',sx+14,sy+20);
} else if(tile===TILE.STONE){
ctx.fillStyle='#2f3336'; ctx.fillRect(sx,sy,32,32);
ctx.strokeStyle='#43484c'; ctx.strokeRect(sx+1,sy+1,30,30);
}
}
}
// objects
for(let o of world.objects){
if(o.type==='shop'){
let sx=(o.x-cam.x)*32, sy=(o.y-cam.y)*32;
ctx.fillStyle='#3a2e1a'; ctx.fillRect(sx,sy,40,36);
ctx.fillStyle='#f2c94c'; ctx.fillRect(sx+4,sy+2,32,6);
ctx.fillStyle='#8a6a3a'; ctx.fillRect(sx+14,sy+18,12,18);
ctx.fillStyle='#fff'; ctx.font='16px serif'; ctx.fillText('◰',sx+10,sy+16);
}
}
for(let s of world.shrines){
let sx=(s.x-cam.x)*32, sy=(s.y-cam.y)*32;
let pulse=Math.sin(t*0.08)*2;
ctx.fillStyle=s.used?'#7ae0a6':'#f2c94c'; ctx.shadowColor=ctx.fillStyle; ctx.shadowBlur=12;
ctx.beginPath(); ctx.arc(sx+16,sy+16,10+pulse,0,Math.PI*2); ctx.fill(); ctx.shadowBlur=0;
ctx.fillStyle='#000'; ctx.font='12px monospace'; ctx.fillText('⛩',sx+9,sy+21);
}
for(let ch of world.chests){
if(ch.opened) continue;
let sx=(ch.x-cam.x)*32, sy=(ch.y-cam.y)*32;
ctx.fillStyle= ch.locked?'#4a3a2a':'#6b4e2a'; ctx.fillRect(sx+4,sy+10,24,14);
ctx.fillStyle= ch.locked?'#ff5a5a':'#f2c94c'; ctx.fillRect(sx+12,sy+14,8,4);
if(ch.locked){ ctx.fillStyle='#fff'; ctx.font='10px monospace'; ctx.fillText('🔒',sx+10,sy+9); }
}
for(let sw of world.switches){
if(sw.activated) continue;
let sx=(sw.x-cam.x)*32, sy=(sw.y-cam.y)*32;
ctx.fillStyle='#7ae0a6'; ctx.fillRect(sx+10,sy+10,12,12); ctx.fillStyle='#000'; ctx.fillText('◉',sx+12,sy+20);
}
for(let pk of world.pickups){
if(pk.taken) continue;
let sx=(pk.x-cam.x)*32, sy=(pk.y-cam.y)*32;
ctx.shadowColor='#f2c94c'; ctx.shadowBlur=8;
if(pk.type==='coin'){ ctx.fillStyle='#f2c94c'; ctx.beginPath(); ctx.arc(sx,sy,6,0,Math.PI*2); ctx.fill(); ctx.fillStyle='#000'; ctx.font='8px monospace'; ctx.fillText('◍',sx-4,sy+3); }
else if(pk.type==='item'){ let def=ITEMS_DEF[pk.id]; ctx.fillStyle='#1e3326'; ctx.fillRect(sx-10,sy-10,20,20); ctx.fillStyle='#fff'; ctx.font='14px serif'; ctx.fillText(def.icon,sx-7,sy+5); }
ctx.shadowBlur=0;
}
}
// ── function: drawEnemies ──
function drawEnemies(ctx, world, cam, t, player, particles){
for(let e of world.enemies){
if(e.hp<=0) continue;
let sx=(e.x-cam.x)*32, sy=(e.y-cam.y)*32;
// simple AI movement
let dx=player.x-e.x, dy=player.y-e.y; let d=Math.hypot(dx,dy);
if(d<7 && d>0.6){
let sp= e.type==='brute'?0.018:0.032; if(e.chill) sp*=0.5;
e.x+=dx/d*sp; e.y+=dy/d*sp;
e.state='chase';
} else e.state='idle';
// draw
let col = e.type==='brute'?'#6b2e2e':'#3a5a3f';
if(e.hurtT>0) col='#ff8b8b';
ctx.fillStyle=col; ctx.beginPath(); ctx.arc(sx, sy, e.type==='brute'?12:8,0,Math.PI*2); ctx.fill();
// eye
ctx.fillStyle='#f2c94c'; ctx.beginPath(); ctx.arc(sx+dx/d*2, sy+dy/d*2,2,0,Math.PI*2); ctx.fill();
// hp bar
ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(sx-10,sy-16,20,4);
ctx.fillStyle='#ff5a5a'; ctx.fillRect(sx-10,sy-16,20*(e.hp/e.max),4);
if(e.chill){ ctx.strokeStyle='#7ec8e3'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(sx,sy, e.type==='brute'?14:10,0,Math.PI*2); ctx.stroke(); }
}
}
// ── function: combatUpdate ──
function combatUpdate(game){
let p=game.player, w=game.world;
if(game.pendingMelee){
game.pendingMelee=false;
let wep=WEAPONS[p.weapon];
let range=wep.range/32;
let ax=p.x+0.35+p.facing[0]*range*0.6;
let ay=p.y+0.35+p.facing[1]*range*0.6;
for(let e of w.enemies){
if(e.hp<=0) continue;
if(dist(e.x,e.y,ax,ay) < range*0.7 +0.6){
e.hp-=wep.dmg;
e.hurtT=12;
if(wep.chill){ e.chill=180; }
game.particles.impact(e.x,e.y,wep.color);
game.audio.sfx('hit');
if(e.hp<=0){
for(let k=0;k<3;k++) w.pickups.push({x:e.x+rand(-0.4,0.4), y:e.y+rand(-0.4,0.4), type:'coin', val: (rand(2,6)|0), taken:false});
game.particles.emit(e.x,e.y,16,'#ff9b7a',2.6);
p.money+=2;
}
}
}
}
for(let i=game.arrows.length-1;i>=0;i--){
let a=game.arrows[i]; a.update(w);
let hit=false;
for(let e of w.enemies){
if(e.hp<=0) continue;
if(dist(e.x,e.y,a.x,a.y)<0.6){
e.hp-=2; e.hurtT=10; game.particles.impact(e.x,e.y,"#a0d995"); game.audio.sfx('hit'); hit=true;
if(e.hp<=0){ p.money+=3; }
break;
}
}
if(hit || a.life<=0){ game.arrows.splice(i,1); }
}
for(let pk of w.pickups){
if(pk.taken) continue;
if(dist(pk.x,pk.y,p.x+0.35,p.y+0.35)<0.9){
pk.taken=true;
if(pk.type==='coin'){ p.money+=pk.val; game.particles.pickup(pk.x,pk.y); game.audio.sfx('pickup'); game.showCenter("+"+pk.val+" amber",30); }
else if(pk.type==='item'){ p.addItem(pk.id,1); game.particles.pickup(pk.x,pk.y); game.audio.sfx('levelup'); game.showCenter(ITEMS_DEF[pk.id].name+" found!",70); }
game.updateHUD();
}
}
for(let e of w.enemies){
if(e.hp<=0) continue;
if(dist(e.x,e.y,p.x+0.35,p.y+0.35)<0.7){
if(p.hurtT<=0){ p.takeDamage(1); game.audio.sfx('hurt'); game.particles.impact(p.x,p.y,"#ff6b6b"); game.updateHUD(); game.gameOverCheck(); }
}
if(e.chill>0) e.chill--;
}
}
// ── function: checkVictory ──
function checkVictory(game){
if(game.player.maxHearts>=5 && game.player.weapon==='frost' && game.player.hasBow && !game._won){
game._won=true;
game.showDialog(`<b style="color:#f2c94c">Ember Grove Restored</b><br>You carry 5 heart vessels (10 resilience), Frostbrand's chill, and the Hunter's Bow from mid-hollow. All 6 relic types found.<br><br>Your gear visibly changed, your steps lightened with boots, your dash leaves wind.<br>Rest at final shrine to seal echo.<br><br><i>Designed for ~1 hour loop — exploration, combat, shop economy, save echoes, SFX & particles.</i>`);
game.particles.emit(game.player.x,game.player.y,40,"#f2c94c",4);
game.audio.sfx('levelup');
}
}
// ── main ──
const game = new GameCore();
game.input.bind();
let last=0;
let running=false;
function loop(ts){
if(!running) return;
requestAnimationFrame(loop);
let dt = Math.min(32, ts-last); last=ts;
if(game.dialogOpen || game.shopOpen || game.paused){
} else {
game.t++;
game.player.update(game.input, game.world, game.particles, game.audio, game);
game.cam.x = clamp(game.player.x-15, 0, WORLD_W-30);
game.cam.y = clamp(game.player.y-9.5, 0, WORLD_H-19);
combatUpdate(game);
game.particles.update();
checkVictory(game);
if(game.messageT>0){ game.messageT--; if(game.messageT<=0) document.getElementById('centerMsg').classList.add('hidden'); }
}
let ctx=game.ctx;
ctx.fillStyle='#0a120c'; ctx.fillRect(0,0,960,600);
drawWorld(ctx, game.world, game.cam, game.t);
ctx.fillStyle='#d7ffb0';
for(let a of game.arrows){ let sx=(a.x-game.cam.x)*32, sy=(a.y-game.cam.y)*32; ctx.beginPath(); ctx.arc(sx,sy,3,0,Math.PI*2); ctx.fill(); }
drawEnemies(ctx, game.world, game.cam, game.t, game.player, game.particles);
drawPlayer(ctx, game.player, game.cam, game.t);
game.particles.draw(ctx, game.cam);
if(game.player.hasBow){
ctx.strokeStyle='rgba(255,255,255,0.25)'; ctx.beginPath();
let mx=game.input.mouse.x, my=game.input.mouse.y;
ctx.moveTo(mx-6,my); ctx.lineTo(mx+6,my); ctx.moveTo(mx,my-6); ctx.lineTo(mx,my+6); ctx.stroke();
}
if(game.t%8===0) game.updateHUD();
}
function startGame(withLoad){
document.getElementById('titleScreen').classList.add('fade');
setTimeout(()=>{ document.getElementById('titleScreen').style.display='none'; },800);
if(withLoad) game.loadGame();
game.updateHUD(); game.updateInvHUD();
running=true; requestAnimationFrame(loop);
game.audio.init(); game.audio.sfx('save');
game.showCenter("Grove awakens... Amber = currency, ⛩ = save, cracked rocks need bombs",140);
}
document.getElementById('startBtn').onclick=()=>startGame(false);
document.getElementById('contBtn').onclick=()=>startGame(true);
if(localStorage.getItem('emberGroveSave')){ document.getElementById('contBtn').classList.remove('hidden'); }
document.addEventListener('keydown', (e)=>{
if(e.code==='KeyE'){
if(game.dialogOpen){ game.closeDialog(); return; }
if(game.shopOpen){ return; }
game.interact();
}
if(e.code==='KeyQ'){
if(game.dialogOpen) game.closeDialog();
if(game.shopOpen) game.closeShop();
}
if(e.code==='KeyI'){
if(game.player.invOrder.length>0){
game.player.activeIdx=(game.player.activeIdx+1)%game.player.invOrder.length;
game.updateInvHUD(); game.audio.sfx('step');
}
}
if(e.code==='KeyP'){ game.paused=!game.paused; game.showCenter(game.paused?"Paused":"Resumed",40); }
});
document.getElementById('shopClose').onclick=()=>game.closeShop();
document.getElementById('saveBtn').onclick=()=>{
for(let s of game.world.shrines){
if(Math.hypot(s.x-game.player.x, s.y-game.player.y)<2.2){ game.saveGame(s.id); game.audio.sfx('save'); return; }
}
game.showDialog("No shrine nearby. Find glowing ⛩ stones to echo your progress. Safe checkpoints.");
};
game.updateHUD();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.1","provider":"openrouter","persona":"composer","sessionId":"a305c7b7-f888-499f-b414-e1689eca220d","tokensIn":473815,"tokensOut":23357,"tokensTotal":497172,"turns":24,"toolCalls":23,"failedToolCalls":2,"timestamp":"2026-07-18T20:58:55.837Z"} -->