RPG
Deepseek v4 Pro · 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 50,070 bytes · SHA-256 b4aa087cccf9
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EmberQuest - Action RPG</title>
<style>
/* ── css: layout ── */
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#111; overflow:hidden; font-family:'Segoe UI',system-ui,sans-serif; }
#gameCanvas { display:block; position:absolute; top:0; left:0; width:100%; height:100%; }
#ui-layer { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; }
#ui-layer > * { pointer-events:auto; }
#hud { position:absolute; top:0; left:0; right:0; padding:10px 16px; display:flex; align-items:center; gap:14px; z-index:10; }
#health-bar { display:flex; gap:3px; }
.heart { width:22px; height:22px; position:relative; }
.heart img, .heart svg { width:100%; height:100%; }
#weapon-display { background:rgba(0,0,0,0.7); color:#ffcc00; padding:5px 12px; border-radius:6px; font-size:13px; font-weight:bold; border:1px solid rgba(255,204,0,0.4); }
#coin-display { background:rgba(0,0,0,0.7); color:#ffd700; padding:5px 12px; border-radius:6px; font-size:13px; font-weight:bold; border:1px solid rgba(255,215,0,0.4); display:flex; align-items:center; gap:5px; }
#minimap { margin-left:auto; width:100px; height:70px; background:rgba(0,0,0,0.6); border:1px solid rgba(255,255,255,0.3); border-radius:4px; }
.overlay { position:absolute; inset:0; z-index:100; display:flex; align-items:center; justify-content:center; }
.overlay.hidden { display:none; }
.overlay-bg { position:absolute; inset:0; background:rgba(0,0,0,0.75); }
.overlay-content { position:relative; background:#1a1a2e; border:2px solid #c9a96e; border-radius:12px; padding:24px 28px; min-width:340px; max-width:500px; color:#eee; z-index:1; }
.overlay-content h2 { color:#ffcc00; margin-bottom:14px; font-size:20px; text-align:center; }
#inv-grid, #shop-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin-bottom:12px; }
.inv-slot, .shop-slot { background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.2); border-radius:8px; padding:10px 6px; text-align:center; cursor:pointer; transition:all 0.15s; font-size:12px; min-height:60px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; }
.inv-slot:hover, .shop-slot:hover { background:rgba(255,255,255,0.14); border-color:#ffcc00; }
.inv-slot .item-icon { font-size:26px; }
.inv-slot .item-name { font-size:10px; color:#ccc; }
.shop-slot .item-price { font-size:10px; color:#ffd700; }
#inv-desc { background:rgba(0,0,0,0.3); padding:8px 12px; border-radius:6px; font-size:12px; color:#aaa; margin-bottom:10px; min-height:32px; }
#shop-coins { text-align:center; color:#ffd700; margin-bottom:10px; font-size:14px; }
.overlay-content button { display:block; margin:8px auto 0; padding:8px 24px; background:#c9a96e; color:#1a1a2e; border:none; border-radius:6px; font-weight:bold; cursor:pointer; font-size:14px; }
.overlay-content button:hover { background:#dfb97e; }
#dialog-box { position:absolute; bottom:60px; left:50%; transform:translateX(-50%); background:rgba(0,0,0,0.85); color:#fff; padding:14px 22px; border-radius:8px; border:1px solid rgba(255,255,255,0.3); z-index:90; max-width:500px; text-align:center; }
#dialog-box.hidden { display:none; }
#dialog-hint { display:block; font-size:11px; color:#888; margin-top:6px; }
#death-screen .overlay-content h2 { color:#e04040; }
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="ui-layer">
<div id="hud">
<div id="health-bar"></div>
<div id="weapon-display"></div>
<div id="coin-display"></div>
<div id="minimap"></div>
</div>
<div id="inventory-screen" class="overlay hidden">
<div class="overlay-bg"></div>
<div class="overlay-content">
<h2>Inventory</h2>
<div id="inv-grid"></div>
<div id="inv-desc"></div>
<button id="inv-close">Close [I]</button>
</div>
</div>
<div id="shop-screen" class="overlay hidden">
<div class="overlay-bg"></div>
<div class="overlay-content">
<h2>Shop</h2>
<div id="shop-grid"></div>
<div id="shop-coins"></div>
<button id="shop-close">Close [E]</button>
</div>
</div>
<div id="save-screen" class="overlay hidden">
<div class="overlay-bg"></div>
<div class="overlay-content">
<h2>Save Point</h2>
<p id="save-message"></p>
<button id="save-btn">Save Game</button>
<button id="save-close">Close [E]</button>
</div>
</div>
<div id="dialog-box" class="hidden">
<p id="dialog-text"></p>
<span id="dialog-hint">Press E to continue</span>
</div>
<div id="death-screen" class="overlay hidden">
<div class="overlay-bg"></div>
<h2>You Died</h2><p>Your journey has ended... for now.</p><button id="death-load">Load Last Save</button><button id="death-new">New Game</button>
</div>
</div>
<script>
'use strict';
// ── const: TILE ──
// ── Game Configuration ──
const TILE = 32;
// ── const: MAP_W ──
const MAP_W = 60;
// ── const: MAP_H ──
const MAP_H = 40;
// ── const: COLORS ──
const COLORS = {
grass: '#3a7d32', grass2: '#4a8d42', path: '#b8945c', wall: '#6b6b6b',
water: '#2855a0', water2: '#3065b8', tree: '#1e5c1e', trunk: '#8b5a2b',
roof: '#8b3a3a', house: '#c4946a', door: '#5a3020', stone: '#7a7a7a',
cave: '#3a3a3a', bridge: '#a08060', chest: '#d4a040', savepoint: '#5ab0ff',
shopMat: '#e8c860',
};
// ── const: WEAPONS ──
const WEAPONS = {
sword: { name:'Wooden Sword', dmg:2, speed:380, range:36, color:'#c9b896', desc:'A simple wooden blade.' },
iron: { name:'Iron Broadsword',dmg:4, speed:500, range:44, color:'#b0b8c0', desc:'Heavier, hits harder.' },
flame: { name:'Flame Blade', dmg:5, speed:380, range:40, color:'#ff6600', desc:'Wreathed in fire. Burns foes.' },
bow: { name:'Hunter Bow', dmg:3, speed:600, range:200,color:'#8b6b3a', desc:'Fires arrows from afar.', projectile:true },
};
// ── const: ITEMS ──
const ITEMS = {
potion: { name:'Health Potion', icon:'🧪', desc:'Restores 2 health points.', type:'consumable', price:15, effect:'heal', value:2 },
shield: { name:'Iron Shield', icon:'🛡️', desc:'Blocks 1 hit when equipped.', type:'accessory', price:30, effect:'block' },
boots: { name:'Swift Boots', icon:'👢', desc:'+15% movement speed.', type:'accessory', price:25, effect:'speed' },
charm: { name:'Lucky Charm', icon:'🍀', desc:'Enemies drop more coins.', type:'accessory', price:20, effect:'luck' },
ring: { name:'Power Ring', icon:'💍', desc:'+1 melee damage.', type:'accessory', price:35, effect:'power' },
bomb: { name:'Bomb', icon:'💣', desc:'Damages nearby enemies.', type:'consumable', price:12, effect:'bomb', value:4 },
hcontainer:{ name:'Heart Container',icon:'❤️', desc:'Permanently +2 max HP.', type:'consumable', price:0, effect:'maxhp', value:2 },
};
// ── const: ENEMIES ──
const ENEMIES = {
slime: { name:'Slime', hp:3, dmg:1, speed:1.2, size:14, color:'#5dc95d', coins:[1,3], xp:10 },
bat: { name:'Bat', hp:2, dmg:1, speed:2.5, size:10, color:'#8b5a8b', coins:[1,2], xp:8 },
skel: { name:'Skeleton',hp:6, dmg:2, speed:1.5, size:16,color:'#d4c8b8', coins:[2,5], xp:20 },
golem: { name:'Golem', hp:10,dmg:3, speed:0.8, size:22,color:'#8b7355', coins:[4,8], xp:35 },
boss: { name:'Shadow Knight',hp:20,dmg:4,speed:1.8,size:24,color:'#3a1030',coins:[15,30],xp:100 },
};
// ── function: buildMap ──
// ── Tile Map (60x40) ── Legend: 0=grass 1=grass2 2=path 3=wall 4=water 5=tree 6=house 7=bridge 8=stone 9=cave
function buildMap() {
const m = [];
for (let y=0; y<MAP_H; y++) { m[y]=[]; for (let x=0; x<MAP_W; x++) m[y][x]=0; }
// River
for (let x=28; x<34; x++) for (let y=0; y<MAP_H; y++) m[y][x]=4;
for (let y=15; y<26; y++) for (let x=28; x<34; x++) m[y][x]=0; // gap = bridge area
// Bridge
for (let x=29; x<33; x++) { m[19][x]=7; m[20][x]=7; m[21][x]=7; }
// Paths
function path(x1,y1,x2,y2) {
let cx=x1, cy=y1;
while (cx!==x2 || cy!==y2) {
if (cx!==x2) cx+=Math.sign(x2-cx); else cy+=Math.sign(y2-cy);
if (m[cy]&&m[cy][cx]===0) m[cy][cx]=2;
// widen
if (m[cy]&&m[cy][Math.min(cx+1,MAP_W-1)]===0) m[cy][Math.min(cx+1,MAP_W-1)]=2;
if (m[cy]&&m[cy][Math.max(cx-1,0)]===0) m[cy][Math.max(cx-1,0)]=2;
}
}
path(8,33,22,21); // village to bridge
path(22,21,45,24); // bridge to castle area
path(22,15,22,8); // bridge north to cave
path(8,33,8,30); // village center
// Village houses (left area)
for (let dy=0; dy<4; dy++) for (let dx=0; dx<5; dx++) { m[30+dy][4+dx]=6; }
for (let dy=0; dy<3; dy++) for (let dx=0; dx<4; dx++) { m[27+dy][12+dx]=6; }
// Shop (special house)
for (let dy=0; dy<4; dy++) for (let dx=0; dx<5; dx++) { m[30+dy][18+dx]=6; }
// Trees (forest top-left)
for (let i=0; i<180; i++) {
const tx=2+Math.floor(Math.random()*26), ty=1+Math.floor(Math.random()*20);
if (m[ty][tx]===0 && !(tx>=4&&tx<=20&&ty>=15&&ty<=22)) m[ty][tx]=5;
}
// Trees right side
for (let i=0; i<60; i++) {
const tx=35+Math.floor(Math.random()*22), ty=1+Math.floor(Math.random()*20);
if (m[ty][tx]===0) m[ty][tx]=5;
}
// Castle walls
for (let dy=0; dy<8; dy++) for (let dx=0; dx<8; dx++) {
if (dy===0||dy===7||dx===0||dx===7) m[22+dy][45+dx]=3;
else m[22+dy][45+dx]=8;
}
m[26][48]=2; m[26][49]=2; // castle entrance
// Cave area
for (let dy=0; dy<5; dy++) for (let dx=0; dx<5; dx++) { m[5+dy][18+dx]=9; }
m[7][20]=2; // cave entrance path connect
// Stone ruins near castle
for (let dy=0; dy<3; dy++) for (let dx=0; dx<4; dx++) { m[28+dy][40+dx]=8; }
// Scatter grass variants
for (let y=0; y<MAP_H; y++) for (let x=0; x<MAP_W; x++) {
if (m[y][x]===0 && Math.random()<0.3) m[y][x]=1;
}
return m;
}
// ── const: TILE_MAP ──
const TILE_MAP = buildMap();
// ── class: SoundEngine ──
// ── Sound Engine (Web Audio synthesis) ──
class SoundEngine {
constructor() {
this.ctx = null;
this.enabled = true;
}
init() {
try { this.ctx = new (window.AudioContext||window.webkitAudioContext)(); } catch(e) { this.enabled=false; }
}
play(freq, type, duration, vol=0.12, slide=0) {
if (!this.enabled || !this.ctx) return;
if (this.ctx.state==='suspended') this.ctx.resume();
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
if (slide) osc.frequency.linearRampToValueAtTime(freq+slide, t+duration);
gain.gain.setValueAtTime(vol, t);
gain.gain.exponentialRampToValueAtTime(0.001, t+duration);
osc.connect(gain); gain.connect(this.ctx.destination);
osc.start(t); osc.stop(t+duration);
}
sfx(name) {
switch(name) {
case 'step': this.play(80,'square',0.08,0.03); break;
case 'swing': this.play(200,'sawtooth',0.15,0.06,-80); break;
case 'hit': this.play(60,'square',0.18,0.1); break;
case 'kill': this.play(300,'triangle',0.2,0.15,200); break;
case 'pickup': this.play(600,'sine',0.12,0.1); setTimeout(()=>this.play(900,'sine',0.1,0.1),60); break;
case 'coin': this.play(1000,'sine',0.08,0.08); break;
case 'buy': this.play(500,'triangle',0.1,0.12,200); break;
case 'save': this.play(400,'sine',0.15,0.2,300); break;
case 'hurt': this.play(40,'sawtooth',0.25,0.2); break;
case 'bow': this.play(150,'triangle',0.1,0.2,400); break;
case 'bomb': this.play(25,'sawtooth',0.3,0.4,-20); break;
case 'heal': this.play(400,'sine',0.1,0.15); setTimeout(()=>this.play(600,'sine',0.1,0.15),100); break;
case 'upgrade': this.play(300,'triangle',0.15,0.3,500); break;
}
}
footstep() { this.sfx('step'); }
}
// ── const: SFX ──
const SFX = new SoundEngine();
// ── class: Particle ──
// ── Particle System ──
class Particle {
constructor(x,y,vx,vy,life,color,size=3) {
this.x=x; this.y=y; this.vx=vx; this.vy=vy; this.life=life; this.maxLife=life;
this.color=color; this.size=size;
}
update(dt) { this.x+=this.vx*dt; this.y+=this.vy*dt; this.life-=dt; }
alive() { return this.life>0; }
alpha() { return Math.max(0,this.life/this.maxLife); }
}
// ── class: ParticleSystem ──
class ParticleSystem {
constructor() { this.particles=[]; }
emit(x,y,count,color,spread=60,life=0.5,size=3) {
for (let i=0; i<count; i++) {
const angle=Math.random()*Math.PI*2, speed=spread*(0.5+Math.random());
this.particles.push(new Particle(x,y,Math.cos(angle)*speed,Math.sin(angle)*speed,life*(0.5+Math.random()),color,size*(0.5+Math.random())));
}
}
update(dt) { this.particles=this.particles.filter(p=>{p.update(dt);return p.alive();}); }
render(ctx,cam) {
for (const p of this.particles) {
ctx.globalAlpha=p.alpha();
ctx.fillStyle=p.color;
ctx.fillRect(p.x-cam.x-p.size/2,p.y-cam.y-p.size/2,p.size,p.size);
}
ctx.globalAlpha=1;
}
}
// ── const: PART ──
const PART = new ParticleSystem();
// ── class: Player ──
// ── Player ──
class Player {
constructor() {
this.x = 8*TILE + TILE/2;
this.y = 31*TILE + TILE/2;
this.w = 20; this.h = 20;
this.vx = 0; this.vy = 0;
this.speed = 150;
this.facing = {x:0,y:1};
this.hp = 3; this.maxHp = 3;
this.weapon = 'sword';
this._prevWeapon = 'sword';
this.hasBow = false;
this.inventory = [];
this.coins = 0;
this.accessories = [];
this.attackCooldown = 0;
this.invincible = 0;
this.alive = true;
this.animTimer = 0;
this.footstepTimer = 0;
this.attackFlash = 0;
this.aimX = 0; this.aimY = 1;
}
get weaponData() { return WEAPONS[this.weapon]; }
get attackDmg() {
let d=this.weaponData.dmg;
if (this.accessories.includes('ring')) d+=1;
return d;
}
get moveSpeed() {
let s=this.speed;
if (this.accessories.includes('boots')) s*=1.15;
return s;
}
get hasShield() { return this.accessories.includes('shield'); }
get hasLucky() { return this.accessories.includes('charm'); }
addItem(id, qty=1) {
const existing = this.inventory.find(i=>i.id===id);
if (existing) { existing.qty+=qty; } else { this.inventory.push({id,qty}); }
}
removeItem(id, qty=1) {
const existing = this.inventory.find(i=>i.id===id);
if (!existing) return false;
existing.qty -= qty;
if (existing.qty <= 0) this.inventory = this.inventory.filter(i=>i.id!==id);
return true;
}
hasItem(id) { const e=this.inventory.find(i=>i.id===id); return e?e.qty:0; }
useItem(id) {
const item = ITEMS[id];
if (!item) return false;
if (item.type==='consumable') {
if (!this.removeItem(id)) return false;
if (item.effect==='heal') { this.hp=Math.min(this.maxHp,this.hp+item.value); SFX.sfx('heal'); }
if (item.effect==='maxhp') { this.maxHp+=item.value; this.hp+=item.value; SFX.sfx('upgrade'); }
if (item.effect==='bomb') { SFX.sfx('bomb'); return 'bomb'; }
return true;
}
if (item.type==='accessory') {
if (this.accessories.includes(id)) { this.accessories=this.accessories.filter(a=>a!==id); }
else { this.accessories.push(id); }
SFX.sfx('pickup');
return true;
}
return false;
}
takeDamage(dmg) {
if (this.invincible>0) return false;
if (this.hasShield) { this.accessories=this.accessories.filter(a=>a!=='shield'); SFX.sfx('hit'); this.invincible=0.3; return false; }
this.hp -= dmg;
this.invincible = 0.5;
SFX.sfx('hurt');
PART.emit(this.x,this.y,8,'#ff4444',80,0.4,4);
if (this.hp <= 0) { this.hp=0; this.alive=false; }
return true;
}
update(dt, map, enemies) {
if (!this.alive) return;
this.attackCooldown = Math.max(0, this.attackCooldown-dt);
this.invincible = Math.max(0, this.invincible-dt);
this.attackFlash = Math.max(0, this.attackFlash-dt);
let mx=0, my=0;
if (KEYS['KeyW']||KEYS['ArrowUp']) my-=1;
if (KEYS['KeyS']||KEYS['ArrowDown']) my+=1;
if (KEYS['KeyA']||KEYS['ArrowLeft']) mx-=1;
if (KEYS['KeyD']||KEYS['ArrowRight']) mx+=1;
if (mx!==0||my!==0) {
const len=Math.sqrt(mx*mx+my*my);
mx/=len; my/=len;
this.facing={x:mx,y:my};
this.footstepTimer-=dt;
if (this.footstepTimer<=0) { this.footstepTimer=0.35; SFX.footstep(); }
}
let nx=this.x+mx*this.moveSpeed*dt;
let ny=this.y+my*this.moveSpeed*dt;
if (!this.collidesTile(nx,this.y,map)) this.x=nx;
if (!this.collidesTile(this.x,ny,map)) this.y=ny;
this.x=Math.max(this.w/2,Math.min(MAP_W*TILE-this.w/2,this.x));
this.y=Math.max(this.h/2,Math.min(MAP_H*TILE-this.h/2,this.y));
this.animTimer+=dt;
}
collidesTile(px,py,map) {
const cx = Math.floor(px/TILE), cy=Math.floor(py/TILE);
for (let dy=-1;dy<=1;dy++) for (let dx=-1;dx<=1;dx++) {
const tx=cx+dx, ty=cy+dy;
if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) return true;
const t=map[ty][tx];
if (t===3||t===4||t===5) return true;
}
return false;
}
render(ctx, cam) {
if (!this.alive) return;
const sx=this.x-cam.x, sy=this.y-cam.y;
const flash = this.invincible>0 && Math.floor(this.invincible*20)%2===0;
// Shadow
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(sx,sy+8,9,3,0,0,Math.PI*2); ctx.fill();
// Body
ctx.fillStyle = flash?'#ff8888':'#e8c890';
ctx.beginPath();
ctx.arc(sx, sy-2, 10, 0, Math.PI*2);
ctx.fill();
ctx.strokeStyle='#8b6b3a'; ctx.lineWidth=1.5; ctx.stroke();
// Eyes - face movement direction
ctx.fillStyle='#222';
const ex=this.facing.x*3, ey=this.facing.y*3;
ctx.beginPath(); ctx.arc(sx+ex-3, sy+ey-5, 2, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx+ex+3, sy+ey-5, 2, 0, Math.PI*2); ctx.fill();
// Weapon on back or in hand
const angle = Math.atan2(this.facing.y, this.facing.x);
if (this.attackFlash>0) {
// Swing arc
const swingAngle = angle + Math.sin(this.attackFlash*30)*1.2;
const wx=sx+Math.cos(swingAngle)*18, wy=sy+Math.sin(swingAngle)*18;
ctx.strokeStyle=this.weaponData.color;
ctx.lineWidth=3; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(sx+Math.cos(angle)*5,sy+Math.sin(angle)*5); ctx.lineTo(wx,wy); ctx.stroke();
// Arc trail
ctx.strokeStyle='rgba(255,255,200,0.5)';
ctx.lineWidth=1;
ctx.beginPath(); ctx.arc(sx,sy,18,angle-0.8,angle+0.8); ctx.stroke();
} else {
// Small weapon held at side
ctx.save();
ctx.translate(sx-this.facing.x*2, sy+this.facing.y*4);
ctx.rotate(angle+Math.PI/2);
ctx.fillStyle=this.weaponData.color;
ctx.fillRect(-2,-2,4,14);
// Handle
ctx.fillStyle='#8b5a2b'; ctx.fillRect(-2,10,4,5);
ctx.restore();
}
// Bow on back if owned
if (this.hasBow && this.weapon!=='bow') {
ctx.strokeStyle='#8b6b3a'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(sx+this.facing.x*2, sy-this.facing.y*8, 8, angle-1.8, angle+0.3);
ctx.stroke();
// Bowstring
ctx.strokeStyle='#ddd'; ctx.lineWidth=0.7;
const bx=sx+this.facing.x*2, by=sy-this.facing.y*8;
ctx.beginPath(); ctx.moveTo(bx+Math.cos(angle-1.8)*8, by+Math.sin(angle-1.8)*8);
ctx.lineTo(bx+Math.cos(angle+0.3)*8, by+Math.sin(angle+0.3)*8); ctx.stroke();
}
// Drawn bow when equipped
if (this.weapon==='bow') {
ctx.save();
ctx.translate(sx, sy-2);
ctx.rotate(angle);
ctx.strokeStyle='#8b6b3a'; ctx.lineWidth=2.5;
ctx.beginPath(); ctx.arc(0,0,12, -1.2, 1.2); ctx.stroke();
ctx.strokeStyle='#ddd'; ctx.lineWidth=0.8;
ctx.beginPath(); ctx.moveTo(Math.cos(-1.2)*12,Math.sin(-1.2)*12); ctx.lineTo(Math.cos(1.2)*12,Math.sin(1.2)*12); ctx.stroke();
ctx.fillStyle='#c94'; ctx.beginPath(); ctx.arc(Math.cos(0)*4,Math.sin(0)*4, 2.5,0,Math.PI*2); ctx.fill();
ctx.restore();
}
// Shield visual
if (this.hasShield) {
ctx.fillStyle='#8899bb';
ctx.beginPath(); ctx.arc(sx+this.facing.x*6+this.facing.y*5, sy+this.facing.y*3-this.facing.x*5, 7, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle='#667'; ctx.lineWidth=1.2; ctx.stroke();
}
}
attack(enemies, targetX, targetY) {
if (this.attackCooldown>0 || !this.alive) return null;
const wd=this.weaponData;
// Determine direction
let dirX=this.facing.x, dirY=this.facing.y;
if (targetX!==undefined && targetY!==undefined) {
const ddx=targetX-this.x, ddy=targetY-this.y;
const dlen=Math.sqrt(ddx*ddx+ddy*ddy);
if (dlen>0.1) { dirX=ddx/dlen; dirY=ddy/dlen; }
}
if (wd.projectile) {
this.attackCooldown=wd.speed/1000;
SFX.sfx('bow');
return {type:'projectile', x:this.x+dirX*16, y:this.y+dirY*16,
vx:dirX*350, vy:dirY*350, dmg:this.attackDmg, life:wd.range/350, color:wd.color};
}
this.attackCooldown=wd.speed/1000;
this.attackFlash=0.15;
SFX.sfx('swing');
const cx=this.x+dirX*wd.range*0.5, cy=this.y+dirY*wd.range*0.5;
PART.emit(cx,cy,5,wd.color,40,0.2,3);
const hits=[];
for (const e of enemies) {
if (!e.alive) continue;
const dx=e.x-cx, dy=e.y-cy;
if (Math.sqrt(dx*dx+dy*dy)<wd.range*0.6) hits.push(e);
}
return {type:'melee', hits, dmg:this.attackDmg};
}
}
// ── class: Enemy ──
// ── Enemy ──
class Enemy {
constructor(x, y, type) {
const def = ENEMIES[type];
this.x = x; this.y = y;
this.type = type;
this.name = def.name;
this.hp = def.hp;
this.maxHp = def.hp;
this.dmg = def.dmg;
this.speed = def.speed * (40 + Math.random()*20);
this.size = def.size;
this.color = def.color;
this.coinRange = def.coins;
this.xp = def.xp;
this.alive = true;
this.attackCooldown = 0;
this.hitFlash = 0;
this.wanderAngle = Math.random()*Math.PI*2;
this.wanderTimer = 0;
this.patrolX = x; this.patrolY = y;
this.aggro = false;
this.aggroRange = 150;
}
update(dt, player, map) {
if (!this.alive) return;
this.attackCooldown = Math.max(0, this.attackCooldown-dt);
this.hitFlash = Math.max(0, this.hitFlash-dt);
const dx = player.x - this.x, dy = player.y - this.y;
const dist = Math.sqrt(dx*dx+dy*dy);
this.aggro = dist < this.aggroRange && player.alive;
let mx=0, my=0;
if (this.aggro) {
mx = dx/dist; my = dy/dist;
if (dist < 28 && this.attackCooldown<=0) {
this.attackCooldown = 1.2;
player.takeDamage(this.dmg);
}
} else {
this.wanderTimer -= dt;
if (this.wanderTimer <= 0) {
this.wanderTimer = 1+Math.random()*2;
this.wanderAngle += (Math.random()-0.5)*Math.PI;
}
mx = Math.cos(this.wanderAngle);
my = Math.sin(this.wanderAngle);
}
const nx = this.x + mx*this.speed*dt;
const ny = this.y + my*this.speed*dt;
if (!this.collidesTile(nx, this.y, map)) this.x = nx;
else this.wanderAngle += Math.PI;
if (!this.collidesTile(this.x, ny, map)) this.y = ny;
else this.wanderAngle += Math.PI;
this.x = Math.max(this.size, Math.min(MAP_W*TILE-this.size, this.x));
this.y = Math.max(this.size, Math.min(MAP_H*TILE-this.size, this.y));
}
collidesTile(px, py, map) {
const cx=Math.floor(px/TILE), cy=Math.floor(py/TILE);
for (let dy=-1;dy<=1;dy++) for (let dx=-1;dx<=1;dx++) {
const tx=cx+dx, ty=cy+dy;
if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) return true;
const t=map[ty][tx];
if (t===3||t===4||t===5) return true;
}
return false;
}
takeDamage(dmg) {
this.hp -= dmg;
this.hitFlash = 0.12;
PART.emit(this.x, this.y, 4, '#ffffff', 30, 0.2, 2);
if (this.hp <= 0) {
this.alive = false;
SFX.sfx('kill');
const [min,max]=this.coinRange;
const coins = min + Math.floor(Math.random()*(max-min+1));
PART.emit(this.x, this.y, 10, '#ffd700', 50, 0.5, 3);
return {coins, xp:this.xp};
}
SFX.sfx('hit');
return null;
}
render(ctx, cam) {
if (!this.alive) return;
const sx=this.x-cam.x, sy=this.y-cam.y;
const flash = this.hitFlash>0;
ctx.fillStyle = flash ? '#ffffff' : this.color;
if (this.type==='slime') {
ctx.beginPath(); ctx.ellipse(sx,sy,this.size,this.size*0.7,0,0,Math.PI*2); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.beginPath(); ctx.arc(sx-3,sy-2,3,0,Math.PI*2); ctx.fill();
} else if (this.type==='bat') {
ctx.beginPath(); ctx.arc(sx,sy,this.size,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(sx-3,sy-3,2,0,Math.PI*2); ctx.fill();
} else if (this.type==='skel') {
ctx.fillRect(sx-6,sy-10,12,20);
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(sx,sy-8,7,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#222'; ctx.beginPath(); ctx.arc(sx-2,sy-9,1.5,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx+2,sy-9,1.5,0,Math.PI*2); ctx.fill();
} else if (this.type==='golem') {
ctx.fillRect(sx-10,sy-12,20,24);
ctx.fillStyle='#aaa'; ctx.beginPath(); ctx.arc(sx,sy-10,9,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#f44'; ctx.beginPath(); ctx.arc(sx-3,sy-12,2,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx+3,sy-12,2,0,Math.PI*2); ctx.fill();
} else if (this.type==='boss') {
ctx.fillRect(sx-12,sy-14,24,28);
ctx.fillStyle='#5a2050'; ctx.beginPath(); ctx.arc(sx,sy-12,11,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#f00'; ctx.beginPath(); ctx.arc(sx-3,sy-14,2.5,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx+3,sy-14,2.5,0,Math.PI*2); ctx.fill();
}
// HP bar
if (this.aggro || this.hp<this.maxHp) {
const bw=this.size*2, bh=3;
ctx.fillStyle='#400'; ctx.fillRect(sx-bw/2,sy-this.size-8,bw,bh);
ctx.fillStyle='#f44'; ctx.fillRect(sx-bw/2,sy-this.size-8,bw*(this.hp/this.maxHp),bh);
}
}
}
// ── class: Projectile ──
// ── Projectile ──
class Projectile {
constructor(x,y,vx,vy,dmg,life,color) {
this.x=x; this.y=y; this.vx=vx; this.vy=vy; this.dmg=dmg; this.life=life; this.color=color; this.alive=true;
}
update(dt,map,enemies) {
this.x+=this.vx*dt; this.y+=this.vy*dt; this.life-=dt;
if (this.life<=0) { this.alive=false; return; }
const cx=Math.floor(this.x/TILE),cy=Math.floor(this.y/TILE);
if (cx<0||cy<0||cx>=MAP_W||cy>=MAP_H) { this.alive=false; return; }
const t=map[cy][cx];
if (t===3||t===4||t===5) { this.alive=false; PART.emit(this.x,this.y,4,'#ffcc00',30,0.2,2); return; }
for (const e of enemies) {
if (!e.alive) continue;
const dx=e.x-this.x, dy=e.y-this.y;
if (Math.sqrt(dx*dx+dy*dy)<e.size+4) {
e.takeDamage(this.dmg);
this.alive=false;
PART.emit(this.x,this.y,5,'#ffcc00',40,0.2,2);
return;
}
}
}
render(ctx,cam) {
if (!this.alive) return;
ctx.fillStyle=this.color;
ctx.beginPath(); ctx.arc(this.x-cam.x,this.y-cam.y,3,0,Math.PI*2); ctx.fill();
}
}
// ── class: WorldObject ──
// ── World Objects ──
class WorldObject {
constructor(x,y,type,data) {
this.x=x; this.y=y; this.type=type; this.data=data; this.interacted=false;
}
}
// ── function: createWorldObjects ──
function createWorldObjects() {
const objs=[];
// Save points
objs.push(new WorldObject(8*TILE,29*TILE,'save',{name:'Village Save'}));
objs.push(new WorldObject(22*TILE,14*TILE,'save',{name:'Forest Edge Save'}));
objs.push(new WorldObject(37*TILE,22*TILE,'save',{name:'Castle Approach Save'}));
// Shop NPC
objs.push(new WorldObject(15*TILE,30*TILE,'shop',{greeting:'Welcome traveler! Buy somethin?'}));
// Chests
objs.push(new WorldObject(14*TILE,24*TILE,'chest',{contents:[{id:'potion',qty:2},{id:'coins',qty:10}]}));
objs.push(new WorldObject(10*TILE,8*TILE,'chest',{contents:[{id:'iron',qty:1},{id:'potion',qty:1}]})); // Weapon upgrade 1
objs.push(new WorldObject(19*TILE,10*TILE,'chest',{contents:[{id:'shield',qty:1},{id:'coins',qty:15}]}));
objs.push(new WorldObject(22*TILE,6*TILE,'chest',{contents:[{id:'flame',qty:1},{id:'bow',qty:1},{id:'hcontainer',qty:1}]})); // Upgrade 2 + ranged + heart
objs.push(new WorldObject(44*TILE,26*TILE,'chest',{contents:[{id:'bomb',qty:2},{id:'coins',qty:20}]}));
objs.push(new WorldObject(48*TILE,23*TILE,'chest',{contents:[{id:'ring',qty:1},{id:'hcontainer',qty:1}]})); // final heart container
objs.push(new WorldObject(40*TILE,28*TILE,'chest',{contents:[{id:'boots',qty:1},{id:'charm',qty:1}]}));
return objs;
}
// ── class: Camera ──
// ── Camera ──
class Camera {
constructor(canvas) { this.x=0; this.y=0; this.w=canvas.width; this.h=canvas.height; }
follow(px,py,mapW,mapH) {
this.x = px - this.w/2;
this.y = py - this.h/2;
this.x = Math.max(0, Math.min(mapW*TILE - this.w, this.x));
this.y = Math.max(0, Math.min(mapH*TILE - this.h, this.y));
}
}
// ── class: Game ──
// ── Game ──
class Game {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.cam = new Camera(canvas);
this.resize();
this.player = new Player();
this.enemies = [];
this.projectiles = [];
this.worldObjects = createWorldObjects();
this.chestsOpened = [];
this.dialogText = null;
this.dialogTimer = 0;
this.showInventory = false;
this.showShop = false;
this.showSave = false;
this.showDeath = false;
this.currentSaveObj = null;
this.lastTime = performance.now();
this.enemyRespawnTimers = {};
this.totalKills = 0;
this.gameProgression = 0;
this.keysDown = {};
this.mouseWorld = {x:0,y:0};
this.mouseCanvas = {x:0,y:0};
this._mmCanvas = null;
this._mmCtx = null;
this._mmSetup = false;
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
this.cam.w = this.canvas.width;
this.cam.h = this.canvas.height;
}
spawnEnemies() {
const spawns = [
{x:12*TILE,y:18*TILE,type:'slime'},{x:16*TILE,y:12*TILE,type:'slime'},{x:8*TILE,y:8*TILE,type:'slime'},
{x:20*TILE,y:16*TILE,type:'bat'},{x:6*TILE,y:14*TILE,type:'bat'},{x:24*TILE,y:10*TILE,type:'slime'},
{x:20*TILE,y:7*TILE,type:'skel'},{x:17*TILE,y:5*TILE,type:'skel'},{x:22*TILE,y:4*TILE,type:'bat'},
{x:30*TILE,y:18*TILE,type:'slime'},{x:32*TILE,y:22*TILE,type:'bat'},
{x:40*TILE,y:24*TILE,type:'skel'},{x:43*TILE,y:22*TILE,type:'golem'},{x:38*TILE,y:25*TILE,type:'skel'},
{x:47*TILE,y:25*TILE,type:'golem'},
{x:50*TILE,y:15*TILE,type:'slime'},{x:54*TILE,y:12*TILE,type:'bat'},
{x:48*TILE,y:25*TILE,type:'boss'},
];
for (const s of spawns) {
this.enemies.push(new Enemy(s.x+TILE/2, s.y+TILE/2, s.type));
}
}
getEnemyAt(wx,wy) {
for (const e of this.enemies) {
if (!e.alive) continue;
const dx=e.x-wx, dy=e.y-wy;
if (Math.sqrt(dx*dx+dy*dy)<e.size+6) return e;
}
return null;
}
getObjectAt(wx,wy) {
for (const obj of this.worldObjects) {
if (obj.interacted) continue;
const dx=obj.x-wx, dy=obj.y-wy;
if (Math.sqrt(dx*dx+dy*dy)<28) return obj;
}
return null;
}
openChest(obj) {
if (obj.interacted) return;
obj.interacted = true;
this.chestsOpened.push(obj);
SFX.sfx('pickup');
const msgs = [];
for (const item of obj.data.contents) {
if (item.id==='coins') { this.player.coins+=item.qty; msgs.push(`+${item.qty} Gold`); continue; }
if (item.id==='iron' || item.id==='flame') {
this.player.weapon = item.id;
this.player._prevWeapon = item.id;
this.gameProgression = Math.max(this.gameProgression, item.id==='flame'?2:1);
msgs.push(`Found: ${WEAPONS[item.id].name}!`);
PART.emit(obj.x,obj.y,15,'#ffcc00',70,0.6,4);
SFX.sfx('upgrade');
continue;
}
if (item.id==='bow') {
this.player.hasBow = true;
this.gameProgression = 2;
msgs.push('Found: Hunter Bow! (Press R to use)');
PART.emit(obj.x,obj.y,15,'#ffaa00',70,0.6,4);
SFX.sfx('upgrade');
continue;
}
this.player.addItem(item.id, item.qty);
msgs.push(`+${item.qty}x ${ITEMS[item.id].name}`);
}
this.showDialog(msgs.join(' | '));
}
interactWithShop() {
this.showShop = true;
SFX.sfx('coin');
this.updateShopUI();
}
interactWithSave(obj) {
this.currentSaveObj = obj;
this.showSave = true;
document.getElementById('save-message').textContent = `Save at: ${obj.data.name}`;
}
showDialog(text) {
this.dialogText = text;
this.dialogTimer = 4;
document.getElementById('dialog-text').textContent = text;
document.getElementById('dialog-box').classList.remove('hidden');
}
hideDialog() {
this.dialogText = null;
this.dialogTimer = 0;
document.getElementById('dialog-box').classList.add('hidden');
}
saveGame() {
const data = {
x: this.player.x, y: this.player.y,
hp: this.player.hp, maxHp: this.player.maxHp,
weapon: this.player.weapon, hasBow: this.player.hasBow,
coins: this.player.coins, inventory: this.player.inventory,
accessories: this.player.accessories,
chestsOpened: this.worldObjects.filter(o=>o.interacted&&o.type==='chest').map(o=>`${o.x},${o.y}`),
progression: this.gameProgression, totalKills: this.totalKills,
};
localStorage.setItem('emberquest_save', JSON.stringify(data));
SFX.sfx('save');
this.showDialog('Game saved!');
}
loadGame() {
try {
const raw = localStorage.getItem('emberquest_save');
if (!raw) return false;
const data = JSON.parse(raw);
if (data.hp <= 0) { localStorage.removeItem('emberquest_save'); return false; }
this.player.x = data.x; this.player.y = data.y;
this.player.hp = data.hp; this.player.maxHp = data.maxHp;
this.player.weapon = data.weapon; this.player._prevWeapon = data.weapon;
this.player.hasBow = !!data.hasBow;
this.player.coins = data.coins||0; this.player.inventory = data.inventory||[];
this.player.accessories = data.accessories||[];
this.gameProgression = data.progression||0;
this.totalKills = data.totalKills||0;
this.player.alive = true;
const openedSet = new Set(data.chestsOpened||[]);
for (const obj of this.worldObjects) {
if (obj.type==='chest' && openedSet.has(`${obj.x},${obj.y}`)) obj.interacted=true;
}
this.showDeath = false;
this.showDialog('Game loaded!');
return true;
} catch(e) { localStorage.removeItem('emberquest_save'); return false; }
}
updateShopUI() {
const grid = document.getElementById('shop-grid');
const shopItems = ['potion','shield','boots','charm','ring','bomb'];
grid.innerHTML = shopItems.map(id=>{
const item=ITEMS[id];
return `<div class="shop-slot" data-id="${id}"><span class="item-icon">${item.icon}</span><span class="item-name">${item.name}</span><span class="item-price">${item.price}g</span></div>`;
}).join('');
document.getElementById('shop-coins').textContent = `Your Gold: ${this.player.coins}`;
grid.querySelectorAll('.shop-slot').forEach(el=>{
el.addEventListener('click',()=>this.buyItem(el.dataset.id));
});
}
buyItem(id) {
const item=ITEMS[id];
if (this.player.coins < item.price) { this.showDialog('Not enough gold!'); return; }
this.player.coins -= item.price;
this.player.addItem(id);
SFX.sfx('buy');
this.showDialog(`Bought ${item.name}!`);
this.updateShopUI();
this.updateHUD();
}
updateHUD() {
const hb = document.getElementById('health-bar');
let html = '';
for (let i=0; i<Math.floor(this.player.maxHp/2); i++) {
const fill = Math.min(2, Math.max(0, this.player.hp - i*2));
html += `<svg class="heart" viewBox="0 0 24 24"><defs><linearGradient id="hg${i}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#e04040"/><stop offset="100%" stop-color="#333"/></linearGradient></defs><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="${fill>=2?'#e04040':(fill>=1?`url(#hg${i})`:'#333')}" stroke="#882020" stroke-width="1"/></svg>`;
}
if (this.player.maxHp % 2 === 1) {
const lastHp = this.player.hp - Math.floor(this.player.hp/2)*2;
html += `<svg class="heart" viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="${lastHp>=1?'#e04040':'#333'}" stroke="#882020" stroke-width="1"/></svg>`;
}
hb.innerHTML = html;
document.getElementById('weapon-display').textContent = this.player.weaponData.name;
document.getElementById('coin-display').textContent = `🪙 ${this.player.coins}`;
}
updateInventoryUI() {
const grid = document.getElementById('inv-grid');
const items = this.player.inventory;
grid.innerHTML = items.map(inv=>{
const item=ITEMS[inv.id];
return `<div class="inv-slot" data-id="${inv.id}"><span class="item-icon">${item.icon}</span><span class="item-name">${item.name} x${inv.qty}</span></div>`;
}).join('');
if (items.length===0) grid.innerHTML='<div style="grid-column:1/-1;text-align:center;color:#888;">Empty</div>';
grid.querySelectorAll('.inv-slot').forEach(el=>{
el.addEventListener('click',()=>{
const id=el.dataset.id;
const item=ITEMS[id];
if (item.type==='consumable') {
const result=this.player.useItem(id);
if (result==='bomb') {
PART.emit(this.player.x,this.player.y,20,'#ff8800',100,0.5,5);
for (const e of this.enemies) {
if (!e.alive) continue;
const dx=e.x-this.player.x, dy=e.y-this.player.y;
if (Math.sqrt(dx*dx+dy*dy)<80) e.takeDamage(4);
}
}
} else if (item.type==='accessory') {
this.player.useItem(id);
}
this.updateInventoryUI();
this.updateHUD();
});
});
document.getElementById('inv-desc').textContent = 'Click an item to use/equip it.';
}
renderMap() {
const ctx=this.ctx, cam=this.cam;
const startTX = Math.floor(cam.x/TILE)-1;
const startTY = Math.floor(cam.y/TILE)-1;
const endTX = Math.ceil((cam.x+cam.w)/TILE)+1;
const endTY = Math.ceil((cam.y+cam.h)/TILE)+1;
for (let ty=startTY; ty<=endTY; ty++) {
for (let tx=startTX; tx<=endTX; tx++) {
if (tx<0||ty<0||tx>=MAP_W||ty>=MAP_H) continue;
const t=TILE_MAP[ty][tx];
const sx=tx*TILE-cam.x, sy=ty*TILE-cam.y;
if (t===0||t===1) { ctx.fillStyle=t===0?COLORS.grass:COLORS.grass2; ctx.fillRect(sx,sy,TILE,TILE); }
else if (t===2) { ctx.fillStyle=COLORS.path; ctx.fillRect(sx,sy,TILE,TILE); }
else if (t===3) { ctx.fillStyle=COLORS.wall; ctx.fillRect(sx,sy,TILE,TILE); ctx.strokeStyle='#555';ctx.lineWidth=0.5;ctx.strokeRect(sx,sy,TILE,TILE); }
else if (t===4) { ctx.fillStyle=COLORS.water; ctx.fillRect(sx,sy,TILE,TILE); }
else if (t===5) { ctx.fillStyle=COLORS.grass; ctx.fillRect(sx,sy,TILE,TILE); ctx.fillStyle=COLORS.tree; ctx.beginPath();ctx.arc(sx+TILE/2,sy+TILE/2,TILE/2-2,0,Math.PI*2);ctx.fill(); ctx.fillStyle=COLORS.trunk;ctx.fillRect(sx+TILE/2-2,sy+TILE/2,4,TILE/2); }
else if (t===6) { ctx.fillStyle=COLORS.house; ctx.fillRect(sx,sy,TILE,TILE); }
else if (t===7) { ctx.fillStyle=COLORS.bridge; ctx.fillRect(sx,sy,TILE,TILE); ctx.strokeStyle='#8a7050';ctx.strokeRect(sx,sy,TILE,TILE); }
else if (t===8) { ctx.fillStyle=COLORS.stone; ctx.fillRect(sx,sy,TILE,TILE); }
else if (t===9) { ctx.fillStyle=COLORS.cave; ctx.fillRect(sx,sy,TILE,TILE); }
}
}
}
renderWorldObjects() {
const ctx=this.ctx, cam=this.cam;
for (const obj of this.worldObjects) {
const sx=obj.x-cam.x, sy=obj.y-cam.y;
if (sx<-20||sy<-20||sx>cam.w+20||sy>cam.h+20) continue;
if (obj.type==='save') {
ctx.fillStyle=COLORS.savepoint;
ctx.beginPath(); ctx.arc(sx,sy,10,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#fff'; ctx.font='10px sans-serif'; ctx.textAlign='center';
ctx.fillText('💾',sx,sy+4);
} else if (obj.type==='chest') {
const color = obj.interacted ? '#886630' : COLORS.chest;
ctx.fillStyle=color;
ctx.fillRect(sx-8,sy-6,16,14);
ctx.fillStyle=obj.interacted?'#665020':'#b89030';
ctx.fillRect(sx-7,sy-4,14,5);
ctx.fillStyle='#ffd700'; ctx.fillRect(sx-2,sy-3,4,3);
} else if (obj.type==='shop') {
ctx.fillStyle=COLORS.shopMat;
ctx.beginPath(); ctx.arc(sx,sy,12,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#000'; ctx.font='14px sans-serif'; ctx.textAlign='center';
ctx.fillText('💰',sx,sy+5);
}
}
}
renderMinimap() {
if (!this._mmSetup) {
const mm = document.getElementById('minimap');
if (!mm) return;
const canvas = document.createElement('canvas');
canvas.width=100; canvas.height=70;
canvas.style.width='100px'; canvas.style.height='70px';
mm.innerHTML='';
mm.appendChild(canvas);
this._mmCanvas = canvas;
this._mmCtx = canvas.getContext('2d');
this._mmSetup = true;
}
const ctx = this._mmCtx;
if (!ctx) return;
ctx.fillStyle='#1a3a1a'; ctx.fillRect(0,0,100,70);
const sx=100/MAP_W, sy=70/MAP_H;
for (let y=0; y<MAP_H; y++) for (let x=0; x<MAP_W; x++) {
const t=TILE_MAP[y][x];
if (t===2) { ctx.fillStyle='#b8945c'; ctx.fillRect(x*sx,y*sy,sx,sy); }
else if (t===3) { ctx.fillStyle='#666'; ctx.fillRect(x*sx,y*sy,sx,sy); }
else if (t===4) { ctx.fillStyle='#2855a0'; ctx.fillRect(x*sx,y*sy,sx,sy); }
}
ctx.fillStyle='#ff4444';
ctx.fillRect(this.player.x/TILE*sx-1, this.player.y/TILE*sy-1, 2, 2);
}
update(timestamp) {
const dt = Math.min((timestamp-this.lastTime)/1000, 0.1);
this.lastTime = timestamp;
if (this.dialogTimer>0) {
this.dialogTimer-=dt;
if (this.dialogTimer<=0) this.hideDialog();
}
this.player.update(dt, TILE_MAP, this.enemies);
for (const e of this.enemies) e.update(dt, this.player, TILE_MAP);
for (const p of this.projectiles) p.update(dt, TILE_MAP, this.enemies);
this.projectiles = this.projectiles.filter(p=>p.alive);
PART.update(dt);
for (const e of this.enemies) {
if (!e.alive && e.type!=='boss') {
const key=`${e.type}_${Math.floor(e.x/100)}_${Math.floor(e.y/100)}`;
if (!this.enemyRespawnTimers[key]) this.enemyRespawnTimers[key]=0;
this.enemyRespawnTimers[key]+=dt;
if (this.enemyRespawnTimers[key]>25) {
const def=ENEMIES[e.type];
e.hp=def.hp; e.alive=true; e.x=e.patrolX; e.y=e.patrolY;
this.enemyRespawnTimers[key]=0;
}
}
}
this.cam.follow(this.player.x, this.player.y, MAP_W, MAP_H);
}
render() {
const ctx=this.ctx;
ctx.clearRect(0,0,this.canvas.width,this.canvas.height);
this.renderMap();
this.renderWorldObjects();
for (const p of this.projectiles) p.render(ctx, this.cam);
for (const e of this.enemies) e.render(ctx, this.cam);
this.player.render(ctx, this.cam);
PART.render(ctx, this.cam);
const nearObj = this.getObjectAt(this.player.x, this.player.y);
if (nearObj && !this.dialogText) {
const sx=nearObj.x-this.cam.x, sy=nearObj.y-this.cam.y-20;
ctx.fillStyle='#fff'; ctx.font='12px sans-serif'; ctx.textAlign='center';
if (nearObj.type==='chest' && !nearObj.interacted) ctx.fillText('[E] Open Chest', sx, sy);
else if (nearObj.type==='save') ctx.fillText('[E] Save Point', sx, sy);
else if (nearObj.type==='shop') ctx.fillText('[E] Talk to Merchant', sx, sy);
}
}
handleClick(cx, cy) {
const wx = cx + this.cam.x, wy = cy + this.cam.y;
const result = this.player.attack(this.enemies, wx, wy);
if (!result) return;
if (result.type==='melee') {
for (const e of result.hits) {
const drop = e.takeDamage(result.dmg);
if (drop) {
this.player.coins += drop.coins;
this.totalKills++;
SFX.sfx('coin');
}
}
} else if (result.type==='projectile') {
this.projectiles.push(new Projectile(result.x,result.y,result.vx,result.vy,result.dmg,result.life,result.color));
}
}
handleInteract() {
const obj = this.getObjectAt(this.player.x, this.player.y);
if (!obj) return;
if (obj.type==='chest' && !obj.interacted) this.openChest(obj);
else if (obj.type==='shop') this.interactWithShop();
else if (obj.type==='save') this.interactWithSave(obj);
}
}
// ── main ──
// ── Entry Point ──
const KEYS = {};
function resumeAudio() {
if (SFX.ctx && SFX.ctx.state==='suspended') SFX.ctx.resume();
}
window.addEventListener('keydown', e => { KEYS[e.code]=true; resumeAudio();
if (e.code==='KeyI' && GAME) { GAME.showInventory=!GAME.showInventory;
document.getElementById('inventory-screen').classList.toggle('hidden',!GAME.showInventory);
if (GAME.showInventory) GAME.updateInventoryUI();
if (GAME.showShop) { GAME.showShop=false; document.getElementById('shop-screen').classList.add('hidden'); }
if (GAME.showSave) { GAME.showSave=false; document.getElementById('save-screen').classList.add('hidden'); }
}
if (e.code==='KeyE' && GAME && !GAME.showInventory && !GAME.showShop) {
if (GAME.showSave) { GAME.saveGame(); GAME.showSave=false; document.getElementById('save-screen').classList.add('hidden'); }
else GAME.handleInteract();
}
if (e.code==='KeyR' && GAME && GAME.player.hasBow && GAME.player.weapon!=='bow') {
GAME.player._prevWeapon = GAME.player.weapon;
GAME.player.weapon='bow';
} else if (e.code==='Digit1' && GAME && GAME.player.weapon==='bow') {
GAME.player.weapon=GAME.player._prevWeapon||'sword';
}
if (e.code==='Escape') {
if (GAME.showInventory) { GAME.showInventory=false; document.getElementById('inventory-screen').classList.add('hidden'); }
if (GAME.showShop) { GAME.showShop=false; document.getElementById('shop-screen').classList.add('hidden'); }
if (GAME.showSave) { GAME.showSave=false; document.getElementById('save-screen').classList.add('hidden'); }
}
});
window.addEventListener('keyup', e => { KEYS[e.code]=false; });
window.addEventListener('resize', () => { if (GAME) GAME.resize(); });
const canvas = document.getElementById('gameCanvas');
const GAME = new Game(canvas);
SFX.init();
// Spawn initial enemies
GAME.spawnEnemies();
function startFresh() {
GAME.player = new Player();
GAME.enemies = [];
GAME.worldObjects = createWorldObjects();
GAME.spawnEnemies();
GAME.showDeath = false;
GAME.gameProgression = 0;
GAME.totalKills = 0;
GAME.chestsOpened = [];
GAME.projectiles = [];
document.getElementById('death-screen').classList.add('hidden');
GAME.showDialog('New adventure begins! WASD move, click attack, E interact, I inventory.');
}
// Button handlers
document.getElementById('inv-close').addEventListener('click', ()=>{
GAME.showInventory=false; document.getElementById('inventory-screen').classList.add('hidden');
});
document.getElementById('shop-close').addEventListener('click', ()=>{
GAME.showShop=false; document.getElementById('shop-screen').classList.add('hidden');
});
document.getElementById('save-btn').addEventListener('click', ()=>{
GAME.saveGame(); GAME.showSave=false; document.getElementById('save-screen').classList.add('hidden');
});
document.getElementById('save-close').addEventListener('click', ()=>{
GAME.showSave=false; document.getElementById('save-screen').classList.add('hidden');
});
document.getElementById('death-load').addEventListener('click', ()=>{
if (GAME.loadGame()) {
document.getElementById('death-screen').classList.add('hidden');
GAME.updateHUD();
} else {
GAME.showDialog('No save found! Start a new game instead.');
}
});
document.getElementById('death-new').addEventListener('click', startFresh);
// Click to attack
canvas.addEventListener('click', (e)=>{
resumeAudio();
if (GAME.showInventory||GAME.showShop||GAME.showSave) return;
const rect=canvas.getBoundingClientRect();
GAME.handleClick(e.clientX-rect.left, e.clientY-rect.top);
});
// Mouse tracking for ranged direction
canvas.addEventListener('mousemove', (e)=>{
const rect=canvas.getBoundingClientRect();
GAME.mouseCanvas={x:e.clientX-rect.left, y:e.clientY-rect.top};
GAME.mouseWorld={x:GAME.mouseCanvas.x+GAME.cam.x, y:GAME.mouseCanvas.y+GAME.cam.y};
});
// Clear any corrupted save
try {
const raw = localStorage.getItem('emberquest_save');
if (raw) {
const data = JSON.parse(raw);
if (data.hp <= 0) localStorage.removeItem('emberquest_save');
}
} catch(e) { localStorage.removeItem('emberquest_save'); }
// Try auto-load
const hasSave = GAME.loadGame();
if (!hasSave) {
GAME.showDialog('Welcome to EmberQuest! WASD to move, click to attack, E to interact, I for inventory. Find the merchant 💰 in the village!');
}
// Game loop
function gameLoop(timestamp) {
if (!GAME.player.alive && !GAME.showDeath) {
GAME.showDeath=true;
document.getElementById('death-screen').classList.remove('hidden');
}
if (!GAME.showDeath) {
GAME.update(timestamp);
}
GAME.render();
GAME.updateHUD();
GAME.renderMinimap();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4-pro","provider":"openrouter","persona":"composer","sessionId":"f8b66565-da3f-4d34-b11a-0d1c99562c7a","tokensIn":1622160,"tokensOut":53996,"tokensTotal":1676156,"turns":42,"toolCalls":42,"failedToolCalls":2,"timestamp":"2026-07-18T20:06:21.206Z"} -->