← RPG results

RPG

Claude Fable 5 · basic

Initial view of RPG

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 21,849 bytes · SHA-256 b46d6eeea7db
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Emberfall — A Tiny Action-Adventure RPG</title>
<style>
  html,body{margin:0;padding:0;background:#0a0a12;overflow:hidden;font-family:'Courier New',monospace;user-select:none}
  #cv{display:block;image-rendering:pixelated}
  .overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(5,5,15,.82);z-index:10}
  .panel{background:#181828;border:3px solid #6a5a3a;border-radius:8px;padding:24px 32px;color:#e8dcc0;max-width:560px;box-shadow:0 0 40px #000}
  h1{color:#ffcf5a;letter-spacing:3px;margin:0 0 6px;text-shadow:0 0 12px #a06010}
  h2{color:#ffcf5a;margin:0 0 12px}
  .btn{display:inline-block;background:#3a4a2a;border:2px solid #8aa05a;color:#dfffb0;padding:8px 20px;margin:6px 6px 0 0;cursor:pointer;border-radius:4px;font-family:inherit;font-size:15px}
  .btn:hover{background:#4c6236}
  .btn.dis{opacity:.4;cursor:default}
  .shopItem,.invItem{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #333;padding:7px 2px;gap:14px}
  .shopItem small,.invItem small{color:#9a9ab0;display:block}
  .price{color:#ffd35a;white-space:nowrap}
  #msg{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:rgba(10,10,25,.88);border:2px solid #6a5a3a;color:#f0e8d0;padding:10px 22px;border-radius:6px;font-size:16px;z-index:9;display:none;max-width:70vw;text-align:center;pointer-events:none}
  .hidden{display:none!important}
  .key{background:#333;border:1px solid #777;border-radius:3px;padding:1px 6px;color:#ffd;font-size:13px}
  ul{padding-left:18px;line-height:1.7;margin:8px 0}
  .tag{color:#7ad07a;font-size:12px}
</style>
</head>
<body>
<canvas id="cv"></canvas>
<div id="msg"></div>

<div id="title" class="overlay">
  <div class="panel" style="text-align:center">
    <h1>⚔ EMBERFALL ⚔</h1>
    <div style="color:#b0a890;margin-bottom:10px">A one-hour action-adventure</div>
    <div style="text-align:left;font-size:14px;line-height:1.7;border:1px solid #444;padding:10px 14px;border-radius:6px;background:#10101e">
      <span class="key">WASD / Arrows</span> move &nbsp; <span class="key">Space / J</span> sword<br>
      <span class="key">K</span> bow (once found) &nbsp; <span class="key">E</span> interact / talk / open<br>
      <span class="key">I</span> inventory &nbsp; <span class="key">H</span> drink potion<br><br>
      Cut bushes, smash pots, earn gold. Buy gear at the shop.<br>
      Defeat the <b style="color:#8ad06a">Forest Guardian</b> to open the cave.<br>
      Find the key, break the seal, slay the <b style="color:#c07a5a">Stone Golem</b>.<br>
      Pray at glowing statues to <b style="color:#7ad0ff">save</b> your progress.
    </div>
    <button class="btn" id="btnNew">✦ New Game</button>
    <button class="btn hidden" id="btnCont">↻ Continue</button>
  </div>
</div>

<div id="shopUI" class="overlay hidden">
  <div class="panel" style="min-width:440px">
    <h2>🏪 Marla's Trading Post</h2>
    <div id="shopGold" style="color:#ffd35a;margin-bottom:8px"></div>
    <div id="shopList"></div>
    <button class="btn" onclick="closeUI()">Leave (Esc)</button>
  </div>
</div>

<div id="invUI" class="overlay hidden">
  <div class="panel" style="min-width:440px">
    <h2>🎒 Inventory</h2>
    <div id="invList"></div>
    <button class="btn" onclick="closeUI()">Close (I / Esc)</button>
  </div>
</div>

<div id="winUI" class="overlay hidden">
  <div class="panel" style="text-align:center">
    <h1>🏆 VICTORY 🏆</h1>
    <div id="winStats" style="line-height:1.9;margin:10px 0"></div>
    <button class="btn" onclick="closeUI()">Keep Exploring</button>
  </div>
</div>

<script>
'use strict';
/* ============ CONSTANTS / MAP ============ */
const TS=32, MW=70, MH=54;
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
let VW=innerWidth, VH=innerHeight;
function resize(){VW=cv.width=innerWidth;VH=cv.height=innerHeight}
addEventListener('resize',resize);resize();
const SOLID=new Set([2,3,4,6,7,8,10]);
let map=new Uint8Array(MW*MH);
const T=(x,y)=>(x<0||y<0||x>=MW||y>=MH)?3:map[y*MW+x];
const setT=(x,y,v)=>{if(x>=0&&y>=0&&x<MW&&y<MH)map[y*MW+x]=v};
const rectT=(x0,y0,x1,y1,v)=>{for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++)setT(x,y,v)};
const ringT=(x0,y0,x1,y1,v)=>{for(let x=x0;x<=x1;x++){setT(x,y0,v);setT(x,y1,v)}for(let y=y0;y<=y1;y++){setT(x0,y,v);setT(x1,y,v)}};
function mulberry(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}

const SLIME_SP=[[26,47],[30,44],[38,42],[42,47],[46,44],[50,47],[45,34],[38,31],[52,40],[60,38],[28,35]];
const BAT_SP=[[20,21],[28,21],[40,21],[48,21],[56,21],[12,16],[34,20],[63,19]];
const RED_SP=[[16,22],[52,22],[56,17]];
const CHEST_DEF=[{id:'steel',tx:65,ty:47},{id:'charm',tx:26,ty:31},{id:'bow',tx:28,ty:16},{id:'ring',tx:46,ty:16},{id:'key',tx:63,ty:17},{id:'flame',tx:7,ty:8},{id:'coins1',tx:52,ty:4},{id:'coins2',tx:60,ty:44}];
const STATUES=[{tx:20,ty:50},{tx:36,ty:22}];
const SIGN={tx:17,ty:48};
const SHOPKEEPER={tx:10,ty:44};
const GATE_DEF=[{id:'g1',tx:34,ty:27},{id:'g2',tx:63,ty:47},{id:'g3',tx:9,ty:8},{id:'g4',tx:56,ty:14}];
const SWITCH_DEF=[{tx:61,ty:47,gate:'g2'},{tx:13,ty:11,gate:'g3'}];
const BLOCK_DEF=[{tx:58,ty:47},{tx:13,ty:6}];

function genMap(){
  map.fill(0);
  const R=mulberry(1337);
  // cave
  rectT(0,0,MW-1,25,6);
  rectT(4,20,65,23,5);       // main corridor
  rectT(33,20,35,25,5);      // entrance shaft
  ringT(6,3,18,13,6); rectT(7,4,17,12,5); setT(12,13,5); rectT(11,13,13,19,5); // puzzle room 2
  for(let y=4;y<=12;y++)setT(9,y,6); setT(9,8,5); // inner wall + gate hole
  ringT(48,2,66,14,6); rectT(49,3,65,13,5); setT(56,14,5); rectT(55,14,57,19,5); // boss room
  rectT(62,16,64,19,5); // key alcove
  rectT(26,15,30,18,5); setT(28,19,5); // bow alcove
  rectT(44,15,48,18,5); setT(46,19,5); // ring alcove
  // rock band + gate corridor
  rectT(0,26,MW-1,28,3); setT(34,26,1);setT(34,27,1);setT(34,28,1);
  // overworld borders
  for(let y=29;y<MH;y++){setT(0,y,3);setT(MW-1,y,3)}
  rectT(0,MH-1,MW-1,MH-1,3);
  // village + roads
  rectT(5,44,22,50,1);
  rectT(22,46,40,48,1);
  rectT(33,29,35,46,1);
  rectT(7,40,13,43,10);      // shop building
  rectT(17,39,21,42,4);      // pond
  // puzzle room 1 (forest)
  ringT(55,42,67,52,3); rectT(56,43,66,51,0); setT(55,47,0);
  for(let y=43;y<=51;y++)setT(63,y,3); setT(63,47,0);
  // flowers in village
  for(let y=44;y<=52;y++)for(let x=2;x<=23;x++)if(T(x,y)===0&&R()<0.09)setT(x,y,11);
  // trees & bushes
  for(let y=29;y<=52;y++)for(let x=1;x<=68;x++){
    if(T(x,y)!==0)continue;
    if(x<24&&y>=38)continue;
    if(x>=54&&y>=41)continue;
    if(x>=31&&x<=37&&y<=38)continue;
    let nearPath=false;
    for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++)if(T(x+dx,y+dy)===1)nearPath=true;
    if(nearPath)continue;
    const r=R();
    if(r<0.16)setT(x,y,2); else if(r<0.22)setT(x,y,7);
  }
  // pots in cave
  const R2=mulberry(777);
  for(let y=1;y<=25;y++)for(let x=1;x<=68;x++){
    if(T(x,y)!==5)continue;
    if(x>=6&&x<=18&&y<=13)continue;
    if(x>=48&&y<=14)continue;
    if(R2()<0.05)setT(x,y,8);
  }
  // clear important spots
  const clear=[...SLIME_SP,...BAT_SP,...RED_SP,[34,31],[SIGN.tx,SIGN.ty],[SHOPKEEPER.tx,SHOPKEEPER.ty]];
  CHEST_DEF.forEach(c=>{clear.push([c.tx,c.ty],[c.tx,c.ty+1])});
  STATUES.forEach(s=>{clear.push([s.tx,s.ty],[s.tx,s.ty+1])});
  BLOCK_DEF.forEach(b=>clear.push([b.tx,b.ty]));
  SWITCH_DEF.forEach(s=>clear.push([s.tx,s.ty]));
  GATE_DEF.forEach(g=>clear.push([g.tx,g.ty]));
  clear.push([15,47],[16,47]);
  for(const [x,y] of clear){const t=T(x,y);if(SOLID.has(t))setT(x,y,y<26?5:(t===10?10:0))}
}

/* ============ AUDIO ============ */
let AC=null;
function A(){if(!AC)AC=new (window.AudioContext||window.webkitAudioContext)();return AC}
function tone(f,d,type='square',v=0.1,slide=0,delay=0){try{const a=A(),t=a.currentTime+delay,o=a.createOscillator(),g=a.createGain();o.type=type;o.frequency.setValueAtTime(f,t);if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(30,f+slide),t+d);g.gain.setValueAtTime(v,t);g.gain.exponentialRampToValueAtTime(0.001,t+d);o.connect(g).connect(a.destination);o.start(t);o.stop(t+d+0.05)}catch(e){}}
function noise(d,v,delay=0,fl=800){try{const a=A(),t=a.currentTime+delay,b=a.createBuffer(1,a.sampleRate*d|0,a.sampleRate),ch=b.getChannelData(0);for(let i=0;i<ch.length;i++)ch[i]=Math.random()*2-1;const s=a.createBufferSource();s.buffer=b;const f=a.createBiquadFilter();f.type='lowpass';f.frequency.value=fl;const g=a.createGain();g.gain.setValueAtTime(v,t);g.gain.exponentialRampToValueAtTime(0.001,t+d);s.connect(f);f.connect(g);g.connect(a.destination);s.start(t)}catch(e){}}
const SFX={
  step:()=>noise(0.05,0.035,0,380),
  swing:()=>{tone(520,0.09,'sawtooth',0.06,-340);noise(0.07,0.04,0,1400)},
  shoot:()=>{tone(950,0.13,'square',0.05,-620);noise(0.05,0.05,0,2200)},
  hit:()=>{noise(0.07,0.16,0,900);tone(170,0.08,'square',0.1,-70)},
  hurt:()=>{tone(200,0.26,'sawtooth',0.16,-130);noise(0.14,0.1,0,500)},
  pickup:()=>{tone(660,0.08,'square',0.09);tone(990,0.12,'square',0.09,0,0.07)},
  coin:()=>{tone(1046,0.06,'square',0.08);tone(1568,0.13,'square',0.08,0,0.06)},
  buy:()=>[523,659,784,1046].forEach((f,i)=>tone(f,0.1,'square',0.08,0,i*0.06)),
  save:()=>[523,659,784].forEach((f,i)=>tone(f,0.55,'sine',0.07,0,i*0.09)),
  chest:()=>[392,523,659,784,1046].forEach((f,i)=>tone(f,0.14,'square',0.07,0,i*0.09)),
  die:()=>tone(300,0.3,'sawtooth',0.11,-210),
  open:()=>{tone(140,0.35,'square',0.09,70);noise(0.3,0.07,0,320)},
  heart:()=>[659,784,988,1319].forEach((f,i)=>tone(f,0.22,'sine',0.09,0,i*0.1)),
  denied:()=>tone(150,0.15,'square',0.09,-40),
  push:()=>noise(0.13,0.07,0,240),
  potion:()=>{tone(420,0.16,'sine',0.09,220);tone(620,0.2,'sine',0.09,320,0.12)},
};

/* ============ STATE ============ */
let player, flags, chests, gates, blocks, enemies, pickups, projs, parts, floats, spawnPoint, playTime, frame=0, shake=0, hurtFlash=0, running=false, uiOpen=null, victoryTimer=0, stepT=0;
const keys={};

function newWorld(fromSave){
  genMap();
  chests=CHEST_DEF.map(c=>({...c,opened:false}));
  gates=GATE_DEF.map(g=>({...g,open:false}));
  blocks=BLOCK_DEF.map(b=>({...b}));
  pickups=[];projs=[];parts=[];floats=[];
  if(!fromSave){
    flags={mini:false,boss:false,g2:false,g3:false,victory:false,caveHint:false};
    player={x:15.5*TS,y:47*TS,w:18,h:20,dir:'down',hp:3,maxhp:3,coins:15,weapon:0,hasBow:false,arrows:0,
      inv:{potion:1,key:0},acc:{boots:false,ring:false,amulet:false,charm:false},
      atkCd:0,swing:0,bowCd:0,bowAnim:0,invuln:0,pushT:0,moving:false};
    spawnPoint={x:15.5*TS,y:47*TS};
    playTime=0;
  }
  spawnEnemies();
}
function spawnEnemies(){
  enemies=[];
  SLIME_SP.forEach(([x,y])=>enemies.push(mkEnemy('slime',x,y)));
  BAT_SP.forEach(([x,y])=>enemies.push(mkEnemy('bat',x,y)));
  RED_SP.forEach(([x,y])=>enemies.push(mkEnemy('red',x,y)));
  if(!flags.mini)enemies.push(mkEnemy('guardian',34,31));
  if(!flags.boss)enemies.push(mkEnemy('golem',57,8));
}
function mkEnemy(type,tx,ty){
  const base={x:tx*TS+16,y:ty*TS+16,type,t:Math.random()*100,flash:0,kx:0,ky:0,state:0,st:0,dx:0,dy:0};
  const cfg={
    slime:{hp:3,dmg:1,spd:0.9,w:20,h:16,coins:[2,4]},
    bat:{hp:2,dmg:1,spd:1.6,w:18,h:14,coins:[3,5]},
    red:{hp:6,dmg:2,spd:1.0,w:24,h:18,coins:[6,9]},
    guardian:{hp:18,dmg:1,spd:1.1,w:30,h:30,coins:[25,25]},
    golem:{hp:42,dmg:2,spd:0.8,w:36,h:36,coins:[60,60]},
  }[type];
  return Object.assign(base,cfg,{maxhp:cfg.hp});
}

/* ============ COLLISION ============ */
function tileSolid(px,py){
  const tx=Math.floor(px/TS),ty=Math.floor(py/TS);
  if(SOLID.has(T(tx,ty)))return true;
  for(const g of gates)if(!g.open&&g.tx===tx&&g.ty===ty)return true;
  for(const b of blocks)if(b.tx===tx&&b.ty===ty)return true;
  for(const c of chests)if(c.tx===tx&&c.ty===ty)return true;
  return false;
}
function rectSolid(x,y,w,h){
  return tileSolid(x,y)||tileSolid(x+w,y)||tileSolid(x,y+h)||tileSolid(x+w,y+h)||tileSolid(x+w/2,y)||tileSolid(x+w/2,y+h);
}
function moveEnt(e,dx,dy,isPlayer){
  let hitBlock=null;
  if(dx){
    if(!rectSolid(e.x-e.w/2+dx,e.y-e.h/2,e.w,e.h))e.x+=dx;
    else if(isPlayer)hitBlock=findBlock(e.x-e.w/2+dx,e.y-e.h/2,e.w,e.h);
  }
  if(dy){
    if(!rectSolid(e.x-e.w/2,e.y-e.h/2+dy,e.w,e.h))e.y+=dy;
    else if(isPlayer&&!hitBlock)hitBlock=findBlock(e.x-e.w/2,e.y-e.h/2+dy,e.w,e.h);
  }
  return hitBlock;
}
function findBlock(x,y,w,h){
  for(const b of blocks){
    const bx=b.tx*TS,by=b.ty*TS;
    if(x<bx+TS&&x+w>bx&&y<by+TS&&y+h>by)return b;
  }
  return null;
}

/* ============ MESSAGES / UI ============ */
let msgTimer=0;
const msgEl=document.getElementById('msg');
function msg(t,dur=200){msgEl.textContent=t;msgEl.style.display='block';msgTimer=dur}
function closeUI(){
  document.getElementById('shopUI').classList.add('hidden');
  document.getElementById('invUI').classList.add('hidden');
  document.getElementById('winUI').classList.add('hidden');
  uiOpen=null;
}
const SHOP_ITEMS=[
  {id:'potion',name:'Health Potion',price:25,desc:'Restores 2 HP — press H to drink'},
  {id:'arrows',name:'Bundle of Arrows ×10',price:15,desc:'Ammunition for a bow'},
  {id:'boots',name:'Swift Boots',price:50,desc:'+30% movement speed',once:true},
  {id:'amulet',name:'Guard Amulet',price:70,desc:'35% chance to block all damage',once:true},
];
function openShop(){
  uiOpen='shop';
  document.getElementById('shopGold').textContent='Your gold: '+player.coins+' 🪙';
  const L=document.getElementById('shopList');L.innerHTML='';
  SHOP_ITEMS.forEach((it,i)=>{
    const owned=it.once&&player.acc[it.id];
    const d=document.createElement('div');d.className='shopItem';
    d.innerHTML=`<div><b>${it.name}</b><small>${it.desc}</small></div><div class="price">${owned?'<span class="tag">OWNED</span>':it.price+'g'}</div>`;
    if(!owned){const b=document.createElement('button');b.className='btn'+(player.coins<it.price?' dis':'');b.textContent='Buy';
      b.onclick=()=>buyItem(i);d.appendChild(b);}
    L.appendChild(d);
  });
  document.getElementById('shopUI').classList.remove('hidden');
}
function buyItem(i){
  const it=SHOP_ITEMS[i];
  if(player.coins<it.price){SFX.denied();msg("Not enough gold!");return}
  if(it.once&&player.acc[it.id])return;
  player.coins-=it.price;SFX.buy();
  if(it.id==='potion')player.inv.potion++;
  else if(it.id==='arrows')player.arrows+=10;
  else if(it.id==='boots'){player.acc.boots=true;msg("Swift Boots equipped! You feel lighter.")}
  else if(it.id==='amulet'){player.acc.amulet=true;msg("Guard Amulet equipped! It hums softly.")}
  burst(player.x,player.y-20,'#ffd35a',10,2);
  openShop();
}
function openInv(){
  uiOpen='inv';
  const p=player,L=document.getElementById('invList');
  const wname=['Rusty Sword','Steel Sword ★','Flame Blade ★★'][p.weapon];
  let h=`<div class="invItem"><div><b>⚔ ${wname}</b><small>Damage ${[1,2,3][p.weapon]+(p.acc.ring?1:0)} · Range ${['short','medium','long'][p.weapon]}${p.weapon===2?' · burns foes':''}</small></div><span class="tag">EQUIPPED</span></div>`;
  if(p.hasBow)h+=`<div class="invItem"><div><b>🏹 Hunter's Bow</b><small>Ranged, 2 dmg · Arrows: ${p.arrows}</small></div><span class="tag">K to fire</span></div>`;
  h+=`<div class="invItem"><div><b>🧪 Health Potion ×${p.inv.potion}</b><small>Restores 2 HP</small></div>${p.inv.potion>0?'<button class="btn" onclick="usePotion();openInv()">Use</button>':''}</div>`;
  const accs=[['boots','👢 Swift Boots','+30% move speed'],['ring','💍 Power Ring','+1 sword damage'],['amulet','🔮 Guard Amulet','35% chance to block damage'],['charm','🍀 Swift Charm','Attack speed up']];
  for(const [k,n,d] of accs)if(p.acc[k])h+=`<div class="invItem"><div><b>${n}</b><small>${d}</small></div><span class="tag">EQUIPPED</span></div>`;
  if(p.inv.key>0)h+=`<div class="invItem"><div><b>🗝 Ancient Key</b><small>Opens the sealed door deep in the cave</small></div></div>`;
  L.innerHTML=h;
  document.getElementById('invUI').classList.remove('hidden');
}
function usePotion(){
  if(player.inv.potion<=0){SFX.denied();return}
  if(player.hp>=player.maxhp){msg("Health is already full.");SFX.denied();return}
  player.inv.potion--;player.hp=Math.min(player.maxhp,player.hp+2);
  SFX.potion();burst(player.x,player.y,'#ff6a8a',14,2.2);
  msg("You drink the potion. +2 HP");
}

/* ============ SAVE ============ */
function saveGame(){
  spawnPoint={x:player.x,y:player.y};
  const data={p:{x:player.x,y:player.y,hp:player.hp,maxhp:player.maxhp,coins:player.coins,weapon:player.weapon,hasBow:player.hasBow,arrows:player.arrows,inv:player.inv,acc:player.acc},
    flags,opened:chests.filter(c=>c.opened).map(c=>c.id),gateOpen:gates.filter(g=>g.open).map(g=>g.id),
    blocks:blocks.map(b=>[b.tx,b.ty]),spawn:spawnPoint,time:playTime};
  localStorage.setItem('emberfall_save',JSON.stringify(data));
  player.hp=player.maxhp;
  SFX.save();burst(player.x,player.y,'#7ad0ff',24,2.5);
  msg("✦ Game saved. Your wounds heal. ✦");
}
function loadGame(){
  const raw=localStorage.getItem('emberfall_save');if(!raw)return false;
  try{
    const d=JSON.parse(raw);
    flags=d.flags;
    newWorld(true);
    Object.assign(flags,d.flags);
    player={x:d.p.x,y:d.p.y,w:18,h:20,dir:'down',hp:d.p.hp,maxhp:d.p.maxhp,coins:d.p.coins,weapon:d.p.weapon,hasBow:d.p.hasBow,arrows:d.p.arrows,
      inv:d.p.inv,acc:d.p.acc,atkCd:0,swing:0,bowCd:0,bowAnim:0,invuln:0,pushT:0,moving:false};
    chests.forEach(c=>{if(d.opened.includes(c.id))c.opened=true});
    gates.forEach(g=>{if(d.gateOpen.includes(g.id))g.open=true});
    d.blocks.forEach((b,i)=>{if(blocks[i]){blocks[i].tx=b[0];blocks[i].ty=b[1]}});
    spawnPoint=d.spawn;playTime=d.time||0;
    spawnEnemies();
    return true;
  }catch(e){return false}
}

/* ============ PARTICLES / FLOATERS ============ */
function burst(x,y,col,n,sp){
  for(let i=0;i<n;i++){
    const a=Math.random()*Math.PI*2,s=(0.4+Math.random())*sp;
    parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-0.5,life:20+Math.random()*20,col,r:1.5+Math.random()*2});
  }
}
function floatTxt(x,y,txt,col){floats.push({x,y,txt,col,life:50})}

/* ============ COMBAT ============ */
const DIRA={down:Math.PI/2,up:-Math.PI/2,left:Math.PI,right:0};
function melee(){
  const p=player;
  if(p.atkCd>0||uiOpen)return;
  p.atkCd=(p.acc.charm?[22,20,17]:[30,26,22])[p.weapon];
  p.swing=14;SFX.swing();
  const dmg=[1,2,3][p.weapon]+(p.acc.ring?1:0);
  const range=[38,46,54][p.weapon];
  const fa=DIRA[p.dir];
  for(const e of enemies){
    const dx=e.x-p.x,dy=e.y-p.y,d=Math.hypot(dx,dy);
    if(d<range+e.w/2){
      let ad=Math.abs(((Math.atan2(dy,dx)-fa+Math.PI*3)%(Math.PI*2))-Math.PI);
      if(ad<1.25)hurtEnemy(e,dmg,fa);
    }
  }
  // cut bushes / pots
  for(const dist of [22,44]){
    const cx=p.x+Math.cos(fa)*dist,cy=p.y+Math.sin(fa)*dist;
    const tx=Math.floor(cx/TS),ty=Math.floor(cy/TS),t=T(tx,ty);
    if(t===7||t===8){
      setT(tx,ty,t===7?0:5);
      SFX.hit();burst(tx*TS+16,ty*TS+16,t===7?'#5a9a3a':'#b08a5a',10,2);
      const r=Math.random();
      if(r<0.4)pickups.push({x:tx*TS+16,y:ty*TS+16,type:'coin',v:2+Math.floor(Math.random()*3)});
      else if(r<0.5)pickups.push({x:tx*TS+16,y:ty*TS+16,type:'heart'});
      else if(r<0.62&&p.hasBow)pickups.push({x:tx*TS+16,y:ty*TS+16,type:'arrows',v:4});
    }
  }
  if(p.weapon===2)for(let i=0;i<5;i++)parts.push({x:p.x+Math.cos(fa)*range*0.7,y:p.y+Math.sin(fa)*range*0.7,vx:(Math.random()-0.5)*2,vy:-1-Math.random(),life:18,col:Math.random()<0.5?'#ff8a2a':'#ffd35a',r:2.5});
}
function shootBow(){
  const p=player;
  if(!p.hasBow||uiOpen)return;
  if(p.bowCd>0)return;
  if(p.arrows<=0){msg("Out of arrows! Buy more at the shop.");SFX.denied();p.bowCd=20;return}
  p.bowCd=32;p.bowAnim=14;p.arrows--;SFX.shoot();
  const fa=DIRA[p.dir];
  projs.push({x:p.x,y:p.y-4,vx:Math.cos(fa)*7,vy:Math.sin(fa)*7,life:110,friendly:true,dmg:2,ang:fa});
}
function hurtEnemy(e,dmg,fromAng){
  e.hp-=dmg;e.flash=8;
  e.kx=Math.cos(fromAng)*4;e.ky=Math.sin(fromAng)*4;
  SFX.hit();shake=4;
  burst(e.x,e.y,'#ffd0a0',8,2.4);
  floatTxt(e.x,e.y-14,'-'+dmg,'#ffe07a');
  if(player.weapon===2&&e.hp>0){e.hp-=0; burst(e.x,e.y,'#ff7a2a',4,1.5)}
  if(e.hp<=0)killEnemy(e);
}
function killEnemy(e){
  SFX.die();burst(e.x,e.y,'#c0c0d0',16,3);
  const n=e.coins[0]+Math.floor(Math.random()*(e.coins[1]-e.coins[0]+1));
  for(let i=0;i<Math.min(n,8);i++)pickups.push({x:e.x+(Math.random()-0.5)*20,y:e.y+(Math.random()-0.5)*20,type:'coin',v:Math.ceil(n/Math.min(n,8))});
  if(Math.random()<0.14)pickups.push({x:e.x,y:e.y,type:'heart'});
  if(player.hasBow&&Math.random()<0.22)pickups.push({x:e.x,y:e.y,type:'arrows',v:3});
  if(e.type==='guardian'){
    flags.mini=true;
    const g=gates.find(g=>g.id==='g1');g.open=true;SFX.open();
    pickups.push({x:e.x,y:e.y-10,type:'crystal'});
    msg("The Forest Guardian falls! The cave gate rumbles open to the north...",380);
    shake=14;
  }
  if(e.type==='golem'){
    flags.boss=true;
    pickups.push({x:e.x,y:e.y-10,type:'crystal'});
    msg("The Stone Golem crumbles to dust!",300);
    shake=18;victoryTimer=190;
  }
  enemies=enemies.filter(x=>x!==e);
}
function damagePlayer(d,fx,fy){
  const p=player;
  if(p.invuln>0)return;
  if(p.acc.amulet&&Math.random()<0.35){
    floatTxt(p.x,p.y-20,'BLOCKED','#7ad0ff');burst(p.x,p.y,'#7ad0ff',10,2);
    tone(880,0.1,'triangle',0.1);p.invuln=40;return;
  }
  p.hp-=d;p.invuln=75;hurtFlash=16;shake=8;
  SFX.hurt();burst(p.x,p.y,'#ff4a4a',14,3);
  floatTxt(p.x,p.y-22,'-'+d,'#ff6a6a');
  const
<!-- agent-meta {"model":"anthropic/claude-fable-5","provider":"openrouter","persona":"basic","sessionId":"b4a747cc-cc0a-4781-be25-eaa634bdca75","tokensIn":1174,"tokensOut":32768,"tokensTotal":33942,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-07-22T20:12:56.243Z"} -->