RPG
Qwen 3.6 35b a3b · composer

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 63,606 bytes · SHA-256 f7df9197819d
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Realm of Echoes</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a12; overflow: hidden; font-family: 'Courier New', monospace; }
#gameCanvas { display: block; image-rendering: pixelated; }
#hud {
position: fixed; top: 10px; left: 10px; right: 10px;
display: flex; justify-content: space-between; align-items: center;
padding: 8px 16px; background: rgba(0,0,0,0.7);
border: 2px solid #333; border-radius: 8px; color: #fff; z-index: 10;
}
#hearts { font-size: 24px; letter-spacing: 4px; }
#money-display { font-size: 18px; color: #ffd700; }
#weapon-display { font-size: 14px; color: #aaa; }
#inventory-toggle, #save-toggle {
padding: 6px 12px; background: rgba(255,255,255,0.1);
border: 1px solid #555; border-radius: 4px; color: #fff;
cursor: pointer; font-family: inherit; font-size: 13px;
}
#inventory-toggle:hover, #save-toggle:hover { background: rgba(255,255,255,0.2); }
.hidden { display: none !important; }
#inventory-panel {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(10,10,18,0.95); border: 2px solid #555;
border-radius: 12px; padding: 24px; color: #fff; z-index: 20;
min-width: 400px; max-width: 500px;
}
#inventory-panel h3 { margin-bottom: 16px; text-align: center; color: #ffd700; }
#inventory-grid {
display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px;
margin-bottom: 16px;
}
.inv-item {
width: 64px; height: 64px; background: rgba(255,255,255,0.05);
border: 2px solid #444; border-radius: 8px; display: flex;
align-items: center; justify-content: center; font-size: 28px;
cursor: pointer; position: relative;
}
.inv-item:hover { border-color: #ffd700; background: rgba(255,215,0,0.1); }
.inv-item .count {
position: absolute; bottom: 2px; right: 4px; font-size: 11px;
color: #aaa; background: rgba(0,0,0,0.6); padding: 1px 4px; border-radius: 3px;
}
#shop-panel {
border-top: 1px solid #444; padding-top: 16px; margin-top: 16px;
}
#shop-panel h3 { color: #4fc3f7; margin-bottom: 12px; }
#shop-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
margin-bottom: 12px;
}
.shop-item {
padding: 8px; background: rgba(255,255,255,0.05);
border: 2px solid #444; border-radius: 8px; text-align: center;
cursor: pointer; color: #fff; font-family: inherit;
}
.shop-item:hover { border-color: #4fc3f7; }
.shop-item .price { color: #ffd700; font-size: 12px; }
.shop-item .name { font-size: 13px; margin-bottom: 4px; }
#shop-close {
width: 100%; padding: 8px; background: #333; border: 1px solid #555;
color: #fff; border-radius: 4px; cursor: pointer; font-family: inherit;
}
#shop-close:hover { background: #444; }
#dialog-box {
position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%);
background: rgba(10,10,18,0.95); border: 2px solid #555;
border-radius: 12px; padding: 20px 30px; color: #fff; z-index: 20;
min-width: 400px; max-width: 600px; text-align: center;
}
#dialog-box p { font-size: 16px; margin-bottom: 12px; line-height: 1.5; }
#dialog-continue {
padding: 8px 24px; background: #ffd700; border: none;
border-radius: 4px; cursor: pointer; font-family: inherit; font-weight: bold;
}
#dialog-continue:hover { background: #ffed4a; }
#game-over, #victory-screen {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(10,10,18,0.97); border: 2px solid #555;
border-radius: 16px; padding: 40px; color: #fff; z-index: 30;
text-align: center; min-width: 350px;
}
#game-over h2 { color: #ff4444; font-size: 36px; margin-bottom: 16px; }
#victory-screen h2 { color: #ffd700; font-size: 36px; margin-bottom: 16px; }
#game-over p, #victory-screen p { font-size: 16px; margin-bottom: 24px; color: #aaa; }
#restart-btn, #victory-restart {
padding: 12px 32px; background: #ffd700; border: none;
border-radius: 6px; cursor: pointer; font-family: inherit;
font-size: 16px; font-weight: bold;
}
#restart-btn:hover, #victory-restart:hover { background: #ffed4a; }
#controls-hint {
position: fixed; bottom: 10px; left: 50%; transform: translateX(-50%);
color: #555; font-size: 12px; z-index: 10;
}
/* ── css: animations ── */
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
@keyframes glow {
0%, 100% { text-shadow: 0 0 4px currentColor; }
50% { text-shadow: 0 0 12px currentColor, 0 0 20px currentColor; }
}
#hearts span { display: inline-block; animation: pulse 2s ease-in-out infinite; }
#hearts span:nth-child(odd) { animation-delay: 0.5s; }
#money-display { animation: glow 3s ease-in-out infinite; color: #ffd700; }
#hud { animation: glow 4s ease-in-out infinite; }
.inv-item { transition: all 0.2s ease; }
.inv-item:hover { transform: scale(1.1); }
.shop-item { transition: all 0.2s ease; }
.shop-item:hover { transform: scale(1.05); }
#dialog-box { animation: pulse 0.3s ease-out; }
#game-over, #victory-screen { animation: pulse 0.5s ease-out; }
/* ── css: hud ── */
#hud {
position: fixed; top: 10px; left: 10px; right: 10px;
display: flex; justify-content: space-between; align-items: center;
padding: 8px 16px; background: rgba(0,0,0,0.7);
border: 2px solid #333; border-radius: 8px; color: #fff; z-index: 10;
}
#hearts { font-size: 24px; letter-spacing: 4px; }
#money-display { font-size: 18px; color: #ffd700; }
#weapon-display { font-size: 14px; color: #aaa; }
#progress-bar { width: 120px; height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; overflow: hidden; }
#progress-fill { width: 0%; height: 100%; background: linear-gradient(90deg, #4fc3f7, #ffd700); border-radius: 4px; transition: width 0.5s; }
#objective { font-size: 11px; color: #888; max-width: 150px; text-align: center; }
#inventory-toggle, #save-toggle {
padding: 6px 12px; background: rgba(255,255,255,0.1);
border: 1px solid #555; border-radius: 4px; color: #fff;
cursor: pointer; font-family: inherit; font-size: 13px;
}
#inventory-toggle:hover, #save-toggle:hover { background: rgba(255,255,255,0.2); }
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="hud">
<div id="hearts"></div>
<div id="money-display">💰 0</div>
<div id="weapon-display">⚔️ Iron Sword</div>
<div id="inventory-toggle">🎒 Inventory [I]</div>
<div id="save-toggle">💾 Save [S]</div>
</div><div id="progress-bar" style="width:120px;height:8px;background:rgba(255,255,255,0.1);border-radius:4px;overflow:hidden;">
<div id="progress-fill" style="width:0%;height:100%;background:linear-gradient(90deg,#4fc3f7,#ffd700);border-radius:4px;transition:width 0.5s;"></div>
</div>
<div id="objective" style="font-size:11px;color:#888;">Find the Echo Blade</div>
<div id="inventory-panel" class="hidden">
<h3>Inventory</h3>
<div id="inventory-grid"></div>
<div id="shop-panel" class="hidden">
<h3>Item Shop</h3>
<div id="shop-grid"></div>
<button id="shop-close">Close</button>
</div>
</div>
<div id="dialog-box" class="hidden">
<p id="dialog-text"></p>
<button id="dialog-continue">Continue</button>
</div>
<div id="game-over" class="hidden">
<h2>Game Over</h2>
<p id="death-msg"></p>
<button id="restart-btn">Restart</button>
</div>
<div id="victory-screen" class="hidden">
<h2>Victory!</h2>
<p id="victory-msg"></p>
<button id="victory-restart">Play Again</button>
</div>
<div id="controls-hint">WASD/Arrows: Move | Click/Space: Attack | I: Inventory | S: Save</div>
<script>
'use strict';
// ── var: TILE ──
// ===== CONSTANTS =====
var TILE = 32;
// ── var: CANVAS_W ──
var CANVAS_W = 960;
// ── var: CANVAS_H ──
var CANVAS_H = 640;
// ── var: PLAYER_SPEED ──
var PLAYER_SPEED = 3;
// ── var: ENEMY_BASE_SPEED ──
var ENEMY_BASE_SPEED = 1.2;
// ── var: ATTACK_COOLDOWN ──
var ATTACK_COOLDOWN = 300;
// ── var: RANGED_COOLDOWN ──
var RANGED_COOLDOWN = 600;
// ── var: MAX_HEALTH ──
var MAX_HEALTH = 5;
// ── var: INITIAL_HEALTH ──
var INITIAL_HEALTH = 3;
// ── var: PROGRESSION_GOAL ──
var PROGRESSION_GOAL = 5;
// ── var: ROOMS ──
var ROOMS = {
"start": {
w: 20, h: 15, name: "Starting Hollow",
bg: "#1a1a2e", walls: [
[0,0,20,1],[0,0,1,15],[19,0,1,15],[0,14,20,1],
[5,4,2,3],[8,8,3,2],[12,5,2,4],[15,10,3,2]
], doors: [{x:19,y:7,w:1,h:1,target:"forest_edge",dx:1,dy:0}],
enemies: [], items: [{type:"health_pickup",x:6,y:5},{type:"coin",x:10,y:9}],
savePoint: false, shopNPC: false, boss: false
},
"forest_edge": {
w: 22, h: 16, name: "Whispering Forest",
bg: "#0f1a0f", walls: [
[0,0,22,1],[0,0,1,16],[21,0,1,16],[0,15,22,1],
[3,3,2,5],[7,6,4,2],[12,3,2,6],[16,8,3,3],[18,12,3,2]
], doors: [{x:0,y:7,w:1,h:1,target:"start",dx:-1,dy:0},{x:21,y:8,w:1,h:1,target:"ruins",dx:-1,dy:0}],
enemies: [{type:"slime",count:3},{type:"bat",count:2}],
items: [{type:"coin",x:5,y:4},{type:"coin",x:14,y:5},{type:"health_pickup",x:18,y:13}],
savePoint: false, shopNPC: false, boss: false
},
"ruins": {
w: 24, h: 17, name: "Ancient Ruins",
bg: "#1a1428", walls: [
[0,0,24,1],[0,0,1,17],[23,0,1,17],[0,16,24,1],
[4,3,3,3],[9,2,2,5],[14,5,4,2],[19,9,3,4],[7,10,3,3],[16,13,4,2]
], doors: [{x:0,y:8,w:1,h:1,target:"forest_edge",dx:-1,dy:0},{x:23,y:9,w:1,h:1,target:"cave",dx:-1,dy:0}],
enemies: [{type:"skeleton",count:3},{type:"bat",count:2}],
items: [{type:"coin",x:6,y:4},{type:"weapon_upgrade",x:16,y:6},{type:"health_pickup",x:10,y:12}],
savePoint: true, shopNPC: false, boss: false
},
"cave": {
w: 20, h: 15, name: "Dark Cavern",
bg: "#0a0a1a", walls: [
[0,0,20,1],[0,0,1,15],[19,0,1,15],[0,14,20,1],
[3,4,4,2],[9,3,2,4],[13,7,3,3],[6,10,3,2],[16,11,2,2]
], doors: [{x:0,y:7,w:1,h:1,target:"ruins",dx:-1,dy:0},{x:19,y:7,w:1,h:1,target:"swamp",dx:1,dy:0}],
enemies: [{type:"spider",count:3},{type:"skeleton",count:2}],
items: [{type:"coin",x:5,y:5},{type:"coin",x:12,y:4},{type:"accessory",x:17,y:12}],
savePoint: false, shopNPC: false, boss: false
},
"swamp": {
w: 22, h: 16, name: "Murky Swamp",
bg: "#0a1a0a", walls: [
[0,0,22,1],[0,0,1,16],[21,0,1,16],[0,15,22,1],
[2,4,3,3],[7,3,2,5],[12,6,4,2],[17,9,3,3],[5,11,3,2],[15,12,4,2]
], doors: [{x:0,y:8,w:1,h:1,target:"cave",dx:-1,dy:0},{x:21,y:8,w:1,h:1,target:"boss_chamber",dx:-1,dy:0}],
enemies: [{type:"slime",count:2},{type:"spider",count:3},{type:"bat",count:2}],
items: [{type:"coin",x:4,y:5},{type:"coin",x:10,y:4},{type:"ranged_weapon",x:18,y:13}],
savePoint: false, shopNPC: false, boss: false
},
"shop": {
w: 16, h: 12, name: "Merchant's Rest",
bg: "#1a1a10", walls: [
[0,0,16,1],[0,0,1,12],[15,0,1,12],[0,11,16,1]
], doors: [{x:7,y:0,w:2,h:1,target:"crossroads",dx:0,dy:1}],
enemies: [], items: [],
savePoint: true, shopNPC: true, boss: false
},
"crossroads": {
w: 18, h: 14, name: "Crossroads",
bg: "#141420", walls: [
[0,0,18,1],[0,0,1,14],[17,0,1,14],[0,13,18,1],
[5,3,2,4],[11,6,2,3],[8,10,3,2]
], doors: [{x:8,y:0,w:2,h:1,target:"shop",dx:0,dy:1},{x:0,y:7,w:1,h:1,target:"forest_edge",dx:-1,dy:0},{x:17,y:6,w:1,h:1,target:"cave",dx:1,dy:0}],
enemies: [{type:"skeleton",count:2},{type:"spider",count:1}],
items: [{type:"coin",x:3,y:4},{type:"health_pickup",x:14,y:11}],
savePoint: false, shopNPC: false, boss: false
},
"boss_chamber": {
w: 20, h: 16, name: "The Echo Throne",
bg: "#1a0a0a", walls: [
[0,0,20,1],[0,0,1,16],[19,0,1,16],[0,15,20,1],
[3,3,2,4],[15,3,2,4],[8,6,4,3],[10,10,2,3],[5,12,3,2],[12,12,3,2]
], doors: [{x:9,y:0,w:2,h:1,target:"swamp",dx:0,dy:1}],
enemies: [{type:"boss",count:1}],
items: [{type:"final_upgrade",x:10,y:7}],
savePoint: false, shopNPC: false, boss: true
},
"victory_room": {
w: 14, h: 10, name: "The Heart of Echoes",
bg: "#0a0a2a", walls: [
[0,0,14,1],[0,0,1,10],[13,0,1,10],[0,9,14,1]
], doors: [],
enemies: [], items: [{type:"victory",x:7,y:4}],
savePoint: true, shopNPC: false, boss: false
}
};
// ── var: ENEMY_DATA ──
var ENEMY_DATA = {
slime: { hp: 3, damage: 1, speed: 0.8, color: "#44cc44", size: 20, xp: 10, coinDrop: 5, name: "Slime" },
bat: { hp: 2, damage: 1, speed: 2, color: "#8844aa", size: 16, xp: 8, coinDrop: 3, name: "Bat" },
skeleton: { hp: 5, damage: 2, speed: 1.2, color: "#ccccaa", size: 22, xp: 15, coinDrop: 8, name: "Skeleton" },
spider: { hp: 4, damage: 2, speed: 1.8, color: "#aa4444", size: 18, xp: 12, coinDrop: 6, name: "Spider" },
boss: { hp: 30, damage: 3, speed: 1.5, color: "#ff4400", size: 36, xp: 100, coinDrop: 50, name: "Echo Lord" }
};
// ── var: SHOP_ITEMS ──
var SHOP_ITEMS = [
{ id: "health_vial", name: "Health Vial", icon: "❤️", price: 15, desc: "Restore 2 HP", type: "consumable" },
{ id: "stamina_tonic", name: "Stamina Tonic", icon: "⚡", price: 20, desc: "Speed boost", type: "consumable" },
{ id: "iron_amulet", name: "Iron Amulet", icon: "🔰", price: 30, desc: "+1 max HP", type: "accessory" },
{ id: "gold_ring", name: "Gold Ring", icon: "💍", price: 40, desc: "Find more coins", type: "accessory" },
{ id: "shadow_cloak", name: "Shadow Cloak", icon: "🧥", price: 50, desc: "Brief invisibility", type: "consumable" },
{ id: "phoenix_feather", name: "Phoenix Feather", icon: "🪶", price: 60, desc: "Auto-revive once", type: "accessory" }
];
// ── var: ACCESSORY_IDS ──
var ACCESSORY_IDS = ["iron_amulet", "gold_ring", "shadow_cloak", "phoenix_feather"];
// ── const: AudioCtx ──
// ===== AUDIO SYSTEM =====
const AudioCtx = window.AudioContext || window.webkitAudioContext;
// ── let: audioCtx ──
let audioCtx;
// ── function: initAudio ──
function initAudio() {
if (!audioCtx) audioCtx = new AudioCtx();
}
// ── function: playSound ──
function playSound(type) {
if (!audioCtx) return;
try {
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
switch(type) {
case 'walk':
osc.type = 'sine'; osc.frequency.setValueAtTime(80, now);
gain.gain.setValueAtTime(0.03, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
osc.start(now); osc.stop(now + 0.05);
break;
case 'attack':
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(300, now);
osc.frequency.exponentialRampToValueAtTime(100, now + 0.15);
gain.gain.setValueAtTime(0.12, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
osc.start(now); osc.stop(now + 0.15);
break;
case 'ranged_attack':
osc.type = 'sine'; osc.frequency.setValueAtTime(600, now);
osc.frequency.exponentialRampToValueAtTime(200, now + 0.2);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
osc.start(now); osc.stop(now + 0.2);
break;
case 'hit':
osc.type = 'square'; osc.frequency.setValueAtTime(200, now);
osc.frequency.exponentialRampToValueAtTime(50, now + 0.1);
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
osc.start(now); osc.stop(now + 0.1);
break;
case 'damage':
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(150, now);
osc.frequency.exponentialRampToValueAtTime(40, now + 0.2);
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
osc.start(now); osc.stop(now + 0.2);
break;
case 'pickup':
osc.type = 'sine'; osc.frequency.setValueAtTime(400, now);
osc.frequency.exponentialRampToValueAtTime(800, now + 0.1);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
osc.start(now); osc.stop(now + 0.15);
break;
case 'coin':
osc.type = 'sine'; osc.frequency.setValueAtTime(800, now);
osc.frequency.setValueAtTime(1200, now + 0.05);
gain.gain.setValueAtTime(0.08, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
osc.start(now); osc.stop(now + 0.1);
break;
case 'levelup':
osc.type = 'sine';
[523, 659, 784, 1047].forEach((f, i) => {
osc.frequency.setValueAtTime(f, now + i * 0.1);
});
gain.gain.setValueAtTime(0.12, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
osc.start(now); osc.stop(now + 0.5);
break;
case 'shop':
osc.type = 'triangle'; osc.frequency.setValueAtTime(500, now);
osc.frequency.setValueAtTime(700, now + 0.1);
osc.frequency.setValueAtTime(500, now + 0.2);
gain.gain.setValueAtTime(0.08, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
osc.start(now); osc.stop(now + 0.3);
break;
case 'save':
osc.type = 'sine';
[440, 554, 659].forEach((f, i) => {
osc.frequency.setValueAtTime(f, now + i * 0.15);
});
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
osc.start(now); osc.stop(now + 0.5);
break;
case 'death':
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(300, now);
osc.frequency.exponentialRampToValueAtTime(30, now + 0.8);
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.8);
osc.start(now); osc.stop(now + 0.8);
break;
case 'victory':
osc.type = 'sine';
[523, 659, 784, 1047, 784, 1047].forEach((f, i) => {
osc.frequency.setValueAtTime(f, now + i * 0.12);
});
gain.gain.setValueAtTime(0.12, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
osc.start(now); osc.stop(now + 1.0);
break;
case 'door':
osc.type = 'triangle'; osc.frequency.setValueAtTime(200, now);
osc.frequency.exponentialRampToValueAtTime(400, now + 0.15);
gain.gain.setValueAtTime(0.06, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
osc.start(now); osc.stop(now + 0.2);
break;
case 'enemy_die':
osc.type = 'square'; osc.frequency.setValueAtTime(250, now);
osc.frequency.exponentialRampToValueAtTime(50, now + 0.3);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
osc.start(now); osc.stop(now + 0.3);
break;
}
} catch(e) {}
}
// ── let: particles ──
// ===== PARTICLE SYSTEM =====
let particles = [];
// ── function: spawnParticles ──
function spawnParticles(x, y, color, count, spread, life, size) {
for (let i = 0; i < count; i++) {
particles.push({
x, y,
vx: (Math.random() - 0.5) * spread,
vy: (Math.random() - 0.5) * spread - 1,
life: life || 30,
maxLife: life || 30,
color: color || '#ffd700',
size: size || 3
});
}
}
// ── function: updateParticles ──
function updateParticles() {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.vy += 0.05;
p.life--;
if (p.life <= 0) particles.splice(i, 1);
}
}
// ── function: drawParticles ──
function drawParticles(ctx, camX, camY) {
for (const p of particles) {
const alpha = p.life / p.maxLife;
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x - camX, p.y - camY, p.size * alpha, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// ── function: createPlayer ──
// ===== PLAYER =====
function createPlayer(x, y) {
return {
x, y, w: 24, h: 24,
health: INITIAL_HEALTH,
maxHealth: INITIAL_HEALTH,
speed: PLAYER_SPEED,
weapon: { name: "Iron Sword", damage: 2, range: 36, speed: ATTACK_COOLDOWN, type: "melee", icon: "⚔️" },
weaponLevel: 0,
hasRanged: false,
rangedCooldown: 0,
attackCooldown: 0,
inventory: [],
money: 0,
accessories: [],
hasPhoenixFeather: false,
facing: 'down',
animFrame: 0,
animTimer: 0,
isAttacking: false,
attackTimer: 0,
attackDir: { x: 0, y: 0 },
damageFlash: 0,
isMoving: false,
walkTimer: 0,
hpGoalReached: false,
gameWon: false,
invincibleTimer: 0,
staminaBoost: 0,
shadowCloakTimer: 0
};
}
// ── function: getAttackHitbox ──
function getAttackHitbox(player) {
const w = player.weapon;
let hx = player.x + player.w / 2;
let hy = player.y + player.h / 2;
const dir = player.attackDir;
hx += dir.x * (w.range / 2);
hy += dir.y * (w.range / 2);
return { x: hx - w.range / 2, y: hy - w.range / 2, w: w.range, h: w.range };
}
// ── function: getRangedHitbox ──
function getRangedHitbox(player) {
return {
x: player.x + player.w / 2 + player.attackDir.x * 20 - 6,
y: player.y + player.h / 2 + player.attackDir.y * 20 - 6,
w: 12, h: 12
};
}
// ── function: playerTakeDamage ──
function playerTakeDamage(dmg, player) {
if (player.invincibleTimer > 0) return;
player.health -= dmg;
player.damageFlash = 15;
player.invincibleTimer = 30;
playSound('damage');
if (player.health <= 0) {
if (player.hasPhoenixFeather) {
player.hasPhoenixFeather = false;
player.health = 1;
player.invincibleTimer = 60;
playSound('levelup');
spawnParticles(player.x + player.w/2, player.y + player.h/2, '#ff69b4', 20, 6, 40, 4);
} else {
playSound('death');
}
}
}
// ── function: healPlayer ──
function healPlayer(amount, player) {
const oldHealth = player.health;
const oldMax = player.maxHealth;
player.maxHealth += amount;
player.health = Math.min(player.health + amount, player.maxHealth);
if (player.health > oldHealth) {
playSound('pickup');
spawnParticles(player.x + player.w/2, player.y + player.h/2, '#44ff44', 10, 4, 25, 3);
}
if (player.health >= PROGRESSION_GOAL && !player.hpGoalReached) {
player.hpGoalReached = true;
playSound('levelup');
}
}
// ── function: addItem ──
function addItem(player, item) {
if (player.inventory.length >= 20) return false;
player.inventory.push(item);
if (item.type === 'accessory' && ACCESSORY_IDS.includes(item.id)) {
player.accessories.push(item.id);
if (item.id === 'iron_amulet') {
player.maxHealth += 1;
player.health = Math.min(player.health + 1, player.maxHealth);
}
if (item.id === 'gold_ring') player.goldBonus = (player.goldBonus || 0) + 1;
if (item.id === 'phoenix_feather') player.hasPhoenixFeather = true;
}
return true;
}
// ── function: createEnemy ──
// ===== ENEMIES =====
function createEnemy(type, x, y) {
const data = ENEMY_DATA[type];
const roomScale = getRoomProgression() * 0.3;
return {
type, x, y, w: data.size, h: data.size,
hp: data.hp + Math.floor(roomScale),
maxHp: data.hp + Math.floor(roomScale),
damage: data.damage,
speed: data.speed + roomScale * 0.2,
color: data.color,
xp: data.xp,
coinDrop: data.coinDrop + Math.floor(Math.random() * 3),
name: data.name,
state: 'idle',
stateTimer: 0,
aggroRange: 120,
attackRange: 30,
attackCooldown: 0,
knockback: { x: 0, y: 0 },
animFrame: 0,
animTimer: 0,
hitFlash: 0,
alive: true
};
}
// ── function: getRoomProgression ──
function getRoomProgression() {
const roomsVisited = game.roomsVisited.size;
const totalRooms = Object.keys(ROOMS).length;
return roomsVisited / totalRooms;
}
// ── function: updateEnemy ──
function updateEnemy(enemy, player, room) {
if (!enemy.alive) return;
if (enemy.hitFlash > 0) enemy.hitFlash--;
if (enemy.attackCooldown > 0) enemy.attackCooldown--;
if (enemy.knockback.x !== 0 || enemy.knockback.y !== 0) {
enemy.x += enemy.knockback.x;
enemy.y += enemy.knockback.y;
enemy.knockback.x *= 0.8;
enemy.knockback.y *= 0.8;
if (Math.abs(enemy.knockback.x) < 0.1) enemy.knockback.x = 0;
if (Math.abs(enemy.knockback.y) < 0.1) enemy.knockback.y = 0;
return;
}
enemy.stateTimer++;
const dx = player.x + player.w/2 - (enemy.x + enemy.w/2);
const dy = player.y + player.h/2 - (enemy.y + enemy.h/2);
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < enemy.aggroRange) {
enemy.state = 'chase';
} else {
if (enemy.stateTimer > 120) {
enemy.state = 'idle';
enemy.stateTimer = 0;
}
}
if (enemy.state === 'chase') {
const angle = Math.atan2(dy, dx);
let nx = enemy.x + Math.cos(angle) * enemy.speed;
let ny = enemy.y + Math.sin(angle) * enemy.speed;
if (!collidesWithWall(nx, ny, enemy.w, enemy.h, room)) {
enemy.x = nx;
enemy.y = ny;
}
if (dist < enemy.attackRange && enemy.attackCooldown <= 0) {
playerTakeDamage(enemy.damage, player);
enemy.attackCooldown = 60;
spawnParticles(player.x + player.w/2, player.y + player.h/2, '#ff4444', 5, 3, 15, 2);
}
}
enemy.animTimer++;
if (enemy.animTimer > 15) {
enemy.animTimer = 0;
enemy.animFrame = (enemy.animFrame + 1) % 4;
}
}
// ── function: damageEnemy ──
function damageEnemy(enemy, dmg, player) {
enemy.hp -= dmg;
enemy.hitFlash = 8;
playSound('hit');
spawnParticles(enemy.x + enemy.w/2, enemy.y + enemy.h/2, '#ffffff', 8, 4, 20, 3);
if (enemy.hp <= 0) {
enemy.alive = false;
playSound('enemy_die');
spawnParticles(enemy.x + enemy.w/2, enemy.y + enemy.h/2, enemy.color, 15, 6, 30, 4);
player.money += enemy.coinDrop;
// Drop coin
game.roomItems.push({
type: 'coin', x: enemy.x, y: enemy.y, collected: false, icon: '🪙', value: enemy.coinDrop
});
}
}
// ── function: collidesWithWall ──
// ===== COLLISION =====
function collidesWithWall(x, y, w, h, room) {
for (const wall of room.walls) {
const wx = wall[0] * TILE, wy = wall[1] * TILE;
const ww = wall[2] * TILE, wh = wall[3] * TILE;
if (x < wx + ww && x + w > wx && y < wy + wh && y + h > wy) return true;
}
return false;
}
// ── function: rectsOverlap ──
function rectsOverlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
// ── function: getRoomById ──
// ===== WORLD MANAGEMENT =====
function getRoomById(id) {
const r = ROOMS[id];
return {
...r,
enemies: r.enemies.map(e => createEnemy(e.type, e.x * TILE + 16, e.y * TILE + 16)),
items: r.items.map(i => ({...i, collected: false, icon: getItemIcon(i.type), value: getItemValue(i.type), bobPhase: Math.random() * Math.PI * 2}))
};
}
// ── function: getItemIcon ──
function getItemIcon(type) {
const icons = { health_pickup: '❤️', coin: '🪙', weapon_upgrade: '🗡️', ranged_weapon: '🏹', accessory: '💎', final_upgrade: '👑', victory: '⭐' };
return icons[type] || '📦';
}
// ── function: getItemValue ──
function getItemValue(type) {
if (type === 'coin') return 5;
return 0;
}
// ── function: drawRoom ──
function drawRoom(ctx, room, camX, camY) {
// Background with gradient
const grad = ctx.createRadialGradient(CANVAS_W/2, CANVAS_H/2, 50, CANVAS_W/2, CANVAS_H/2, CANVAS_W/2);
grad.addColorStop(0, room.bg);
grad.addColorStop(1, shadeColor(room.bg, -30));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
// Floor tiles with pattern
for (let y = 0; y < room.h; y++) {
for (let x = 0; x < room.w; x++) {
const sx = x * TILE - camX;
const sy = y * TILE - camY;
if (sx < -TILE || sx > CANVAS_W || sy < -TILE || sy > CANVAS_H) continue;
const pattern = (x + y) % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.04)';
ctx.fillStyle = pattern;
ctx.fillRect(sx + 1, sy + 1, TILE - 2, TILE - 2);
}
}
// Ambient light circles
ctx.fillStyle = 'rgba(255,255,200,0.02)';
for (let i = 0; i < 3; i++) {
const lx = (room.w * TILE / 4 * (i + 1)) - camX;
const ly = (room.h * TILE / 3) - camY;
ctx.beginPath();
ctx.arc(lx, ly, 80, 0, Math.PI * 2);
ctx.fill();
}
// Walls with 3D effect
for (const wall of room.walls) {
const wx = wall[0] * TILE - camX;
const wy = wall[1] * TILE - camY;
const ww = wall[2] * TILE;
const wh = wall[3] * TILE;
// Wall body
ctx.fillStyle = '#2a2a3e';
ctx.fillRect(wx, wy, ww, wh);
// Top highlight
ctx.fillStyle = 'rgba(150,150,200,0.15)';
if (wall[3] > 1) ctx.fillRect(wx, wy, ww, 3);
if (wall[2] > 1) ctx.fillRect(wx, wy, 3, wh);
// Bottom shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
if (wall[3] > 1) ctx.fillRect(wx, wy + wh - 2, ww, 2);
if (wall[2] > 1) ctx.fillRect(wx + ww - 2, wy, 2, wh);
// Brick pattern
ctx.strokeStyle = 'rgba(80,80,120,0.3)';
ctx.lineWidth = 0.5;
for (let bx = 0; bx < ww; bx += 16) {
ctx.beginPath();
ctx.moveTo(wx + bx, wy);
ctx.lineTo(wx + bx, wy + wh);
ctx.stroke();
}
for (let by = 0; by < wh; by += 8) {
ctx.beginPath();
ctx.moveTo(wx, wy + by);
ctx.lineTo(wx + ww, wy + by);
ctx.stroke();
}
}
// Doors with glow
for (const door of room.doors) {
const dx = door.x * TILE - camX;
const dy = door.y * TILE - camY;
const dw = door.w * TILE;
const dh = door.h * TILE;
// Glow
ctx.fillStyle = 'rgba(255,215,0,0.1)';
ctx.fillRect(dx - 4, dy - 4, dw + 8, dh + 8);
// Door
ctx.fillStyle = '#4a3a2a';
ctx.fillRect(dx, dy, dw, dh);
// Door frame
ctx.strokeStyle = '#6a5a3a';
ctx.lineWidth = 2;
ctx.strokeRect(dx, dy, dw, dh);
// Arrow indicator
ctx.fillStyle = '#ffd700';
ctx.font = '18px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const arrows = { up: '↑', down: '↓', left: '←', right: '→' };
let arrow = '→';
if (door.dx < 0) arrow = '←';
else if (door.dx > 0) arrow = '→';
else if (door.dy < 0) arrow = '↑';
else if (door.dy > 0) arrow = '↓';
ctx.fillText(arrow, dx + dw/2, dy + dh/2);
}
// Save point with glow
if (room.savePoint) {
const sx = 10 * TILE - camX;
const sy = 7 * TILE - camY;
const glow = 0.15 + Math.sin(Date.now() / 500) * 0.05;
ctx.fillStyle = `rgba(100,100,255,${glow})`;
ctx.beginPath();
ctx.arc(sx + TILE/2, sy + TILE/2, 24, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = `rgba(100,100,255,${glow + 0.1})`;
ctx.beginPath();
ctx.arc(sx + TILE/2, sy + TILE/2, 16, 0, Math.PI * 2);
ctx.fill();
ctx.font = '24px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('💾', sx + TILE/2, sy + TILE/2);
}
// Shop NPC
if (room.shopNPC) {
const nx = 7 * TILE - camX;
const ny = 5 * TILE - camY;
const bob = Math.sin(Date.now() / 400) * 2;
// Glow
ctx.fillStyle = 'rgba(255,215,0,0.1)';
ctx.beginPath();
ctx.arc(nx + TILE/2, ny + TILE/2 + bob, 24, 0, Math.PI * 2);
ctx.fill();
ctx.font = '28px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('🧙', nx + TILE/2, ny + TILE/2 + bob);
ctx.fillStyle = '#ffd700';
ctx.font = '11px Courier New';
ctx.fillText('Shop', nx + TILE/2, ny + TILE/2 + 20);
}
}
// ── function: drawEnemies ──
function drawEnemies(ctx, enemies, camX, camY) {
for (const e of enemies) {
if (!e.alive) continue;
const ex = e.x - camX;
const ey = e.y - camY;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(ex + e.w/2, ey + e.h + 2, e.w/2, 4, 0, 0, Math.PI * 2);
ctx.fill();
// Body
const flash = e.hitFlash > 0;
ctx.fillStyle = flash ? '#ffffff' : e.color;
if (e.type === 'slime') {
const bounce = Math.sin(e.animFrame * Math.PI / 2) * 3;
ctx.beginPath();
ctx.ellipse(ex + e.w/2, ey + e.h/2 + bounce, e.w/2, e.h/2 - bounce, 0, 0, Math.PI * 2);
ctx.fill();
// Slime eyes
ctx.fillStyle = '#222';
ctx.beginPath();
ctx.arc(ex + e.w/2 - 4, ey + e.h/2 - 2 + bounce, 2, 0, Math.PI * 2);
ctx.arc(ex + e.w/2 + 4, ey + e.h/2 - 2 + bounce, 2, 0, Math.PI * 2);
ctx.fill();
} else if (e.type === 'bat') {
const wingAngle = Math.sin(e.animFrame * Math.PI / 2) * 0.5;
ctx.save();
ctx.translate(ex + e.w/2, ey + e.h/2);
// Wings
ctx.fillStyle = flash ? '#ffffff' : 'rgba(136,68,170,0.6)';
ctx.beginPath();
ctx.ellipse(-10, 0, 12, 6, wingAngle, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(10, 0, 12, 6, -wingAngle, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.fillStyle = flash ? '#ffffff' : e.color;
ctx.beginPath();
ctx.arc(ex + e.w/2, ey + e.h/2, e.w/2, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = '#ff0000';
ctx.beginPath();
ctx.arc(ex + e.w/2 - 3, ey + e.h/2 - 2, 2, 0, Math.PI * 2);
ctx.arc(ex + e.w/2 + 3, ey + e.h/2 - 2, 2, 0, Math.PI * 2);
ctx.fill();
} else if (e.type === 'boss') {
const pulse = 1 + Math.sin(Date.now() / 200) * 0.05;
ctx.save();
ctx.translate(ex + e.w/2, ey + e.h/2);
ctx.scale(pulse, pulse);
// Aura
ctx.fillStyle = 'rgba(255,68,0,0.15)';
ctx.beginPath();
ctx.arc(0, 0, e.w/2 + 10, 0, Math.PI * 2);
ctx.fill();
// Body
ctx.fillStyle = flash ? '#ffffff' : e.color;
ctx.beginPath();
ctx.arc(0, 0, e.w/2, 0, Math.PI * 2);
ctx.fill();
// Crown
ctx.fillStyle = '#ffd700';
ctx.beginPath();
ctx.moveTo(-15, -e.w/2);
ctx.lineTo(-10, -e.w/2 - 12);
ctx.lineTo(-5, -e.w/2 - 4);
ctx.lineTo(0, -e.w/2 - 14);
ctx.lineTo(5, -e.w/2 - 4);
ctx.lineTo(10, -e.w/2 - 12);
ctx.lineTo(15, -e.w/2);
ctx.closePath();
ctx.fill();
// Eyes
ctx.fillStyle = '#ffff00';
ctx.beginPath();
ctx.arc(-6, -4, 3, 0, Math.PI * 2);
ctx.arc(6, -4, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else {
// Skeleton / Spider
ctx.beginPath();
ctx.arc(ex + e.w/2, ey + e.h/2, e.w/2, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = flash ? '#ff0000' : '#222';
ctx.beginPath();
ctx.arc(ex + e.w/2 - 5, ey + e.h/2 - 3, 3, 0, Math.PI * 2);
ctx.arc(ex + e.w/2 + 5, ey + e.h/2 - 3, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = flash ? '#ff0000' : '#fff';
ctx.beginPath();
ctx.arc(ex + e.w/2 - 5, ey + e.h/2 - 3, 1.5, 0, Math.PI * 2);
ctx.arc(ex + e.w/2 + 5, ey + e.h/2 - 3, 1.5, 0, Math.PI * 2);
ctx.fill();
}
// HP bar
if (e.hp < e.maxHp) {
const barW = e.w;
const barH = 4;
const barX = ex;
const barY = ey - 8;
ctx.fillStyle = '#333';
ctx.fillRect(barX, barY, barW, barH);
ctx.fillStyle = e.hp / e.maxHp > 0.5 ? '#44ff44' : (e.hp / e.maxHp > 0.25 ? '#ffaa00' : '#ff4444');
ctx.fillRect(barX, barY, barW * (e.hp / e.maxHp), barH);
}
}
}
// ── function: drawPlayer ──
function drawPlayer(ctx, player, camX, camY) {
const px = player.x - camX;
const py = player.y - camY;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(px + player.w/2, py + player.h + 2, player.w/2, 4, 0, 0, Math.PI * 2);
ctx.fill();
// Invincibility blink
if (player.invincibleTimer > 0 && Math.floor(player.invincibleTimer / 3) % 2 === 0) return;
// Shadow cloak effect
if (player.shadowCloakTimer > 0) {
ctx.globalAlpha = 0.4 + Math.sin(Date.now() / 100) * 0.1;
}
// Body with gradient
const bodyGrad = ctx.createRadialGradient(px + player.w/2, py + player.h/2, 2, px + player.w/2, py + player.h/2, player.w/2);
let bodyColor1 = '#4488ff';
let bodyColor2 = '#2255cc';
if (player.damageFlash > 0) { bodyColor1 = '#ff6644'; bodyColor2 = '#cc3322'; }
bodyGrad.addColorStop(0, bodyColor1);
bodyGrad.addColorStop(1, bodyColor2);
ctx.fillStyle = bodyGrad;
ctx.beginPath();
ctx.arc(px + player.w/2, py + player.h/2, player.w/2, 0, Math.PI * 2);
ctx.fill();
// Border
ctx.strokeStyle = '#1a3388';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px + player.w/2, py + player.h/2, player.w/2, 0, Math.PI * 2);
ctx.stroke();
// Eyes
ctx.fillStyle = '#fff';
const eyeOffX = player.facing === 'left' ? -3 : (player.facing === 'right' ? 3 : 0);
const eyeOffY = player.facing === 'up' ? -3 : (player.facing === 'down' ? 3 : 0);
ctx.beginPath();
ctx.arc(px + player.w/2 - 4 + eyeOffX, py + player.h/2 - 2 + eyeOffY, 3, 0, Math.PI * 2);
ctx.arc(px + player.w/2 + 4 + eyeOffX, py + player.h/2 - 2 + eyeOffY, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#222';
ctx.beginPath();
ctx.arc(px + player.w/2 - 4 + eyeOffX * 1.3, py + player.h/2 - 2 + eyeOffY * 1.3, 1.5, 0, Math.PI * 2);
ctx.arc(px + player.w/2 + 4 + eyeOffX * 1.3, py + player.h/2 - 2 + eyeOffY * 1.3, 1.5, 0, Math.PI * 2);
ctx.fill();
// Weapon visual
if (player.isAttacking) {
ctx.save();
ctx.translate(px + player.w/2, py + player.h/2);
const angle = Math.atan2(player.attackDir.y, player.attackDir.x);
ctx.rotate(angle);
const w = player.weapon;
// Attack arc
ctx.strokeStyle = w.type === 'melee' ? '#aaa' : '#4fc3f7';
ctx.lineWidth = 3;
ctx.globalAlpha = player.attackTimer / 12;
ctx.beginPath();
ctx.arc(0, 0, w.range, -0.6, 0.6);
ctx.stroke();
// Weapon blade
ctx.fillStyle = w.type === 'melee' ? (w.name === 'Echo Blade' ? '#ffd700' : '#cccccc') : '#4fc3f7';
ctx.fillRect(8, -2, w.range - 8, 4);
// Glow
ctx.fillStyle = w.type === 'melee' ? (w.name === 'Echo Blade' ? 'rgba(255,215,0,0.3)' : 'rgba(255,255,255,0.3)') : 'rgba(79,195,247,0.3)';
ctx.fillRect(8, -4, w.range - 8, 8);
ctx.restore();
ctx.globalAlpha = 1;
} else {
// Carry weapon
const angle = Math.atan2(player.attackDir.y, player.attackDir.x);
ctx.save();
ctx.translate(px + player.w/2, py + player.h/2);
ctx.rotate(angle);
ctx.fillStyle = '#aaa';
ctx.fillRect(10, -1, 14, 2);
ctx.restore();
}
ctx.globalAlpha = 1;
}
// ── function: drawRoomItems ──
function drawRoomItems(ctx, items, camX, camY) {
for (const item of items) {
if (item.collected) continue;
const ix = item.x * TILE - camX;
const iy = item.y * TILE - camY;
const bob = Math.sin(Date.now() / 300 + (item.bobPhase || 0)) * 3;
const glow = 0.1 + Math.sin(Date.now() / 400 + (item.bobPhase || 0)) * 0.05;
// Glow
ctx.fillStyle = `rgba(255,215,0,${glow})`;
ctx.beginPath();
ctx.arc(ix + TILE/2, iy + TILE/2 + bob, 18, 0, Math.PI * 2);
ctx.fill();
ctx.font = '20px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(item.icon, ix + TILE/2, iy + TILE/2 + bob);
}
}
// ── function: drawRangedProjectiles ──
function drawRangedProjectiles(ctx, projectiles, camX, camY) {
for (const p of projectiles) {
const px = p.x - camX;
const py = p.y - camY;
// Trail
ctx.fillStyle = 'rgba(79,195,247,0.3)';
ctx.beginPath();
ctx.arc(px - p.vx * 0.5, py - p.vy * 0.5, 8, 0, Math.PI * 2);
ctx.fill();
// Core
ctx.fillStyle = '#4fc3f7';
ctx.beginPath();
ctx.arc(px, py, 5, 0, Math.PI * 2);
ctx.fill();
// Bright center
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(px, py, 2, 0, Math.PI * 2);
ctx.fill();
}
}
// ── function: drawHearts ──
// ===== HUD & UI =====
function drawHearts(health, maxHealth) {
const el = document.getElementById('hearts');
let html = '';
for (let i = 0; i < maxHealth; i++) {
if (i < Math.floor(health)) {
html += '<span style="color:#ff4444;text-shadow:0 0 6px #ff4444">❤️</span>';
} else if (i < health) {
html += '<span style="color:#ff8888">🧡</span>';
} else {
html += '<span style="color:#444">🤍</span>';
}
}
el.innerHTML = html;
}
// ── function: updateHUD ──
function updateHUD(player) {
document.getElementById('money-display').textContent = `💰 ${player.money}`;
document.getElementById('weapon-display').textContent = `${player.weapon.icon} ${player.weapon.name}`;
drawHearts(player.health, player.maxHealth);
updateProgress(player);
}
// ── function: updateInventoryUI ──
function updateInventoryUI(player) {
const grid = document.getElementById('inventory-grid');
let html = '';
for (let i = 0; i < 20; i++) {
const item = player.inventory[i];
if (item) {
const isAccessory = ACCESSORY_IDS.includes(item.id);
html += `<div class="inv-item" data-index="${i}" title="${item.name || item.type}">${item.icon}${item.count > 1 ? `<span class="count">${item.count}</span>` : ''}</div>`;
} else {
html += '<div class="inv-item"></div>';
}
}
grid.innerHTML = html;
grid.querySelectorAll('.inv-item[data-index]').forEach(el => {
el.addEventListener('click', () => useItem(parseInt(el.dataset.index), player));
});
}
// ── function: useItem ──
function useItem(index, player) {
const item = player.inventory[index];
if (!item) return;
if (item.type === 'consumable') {
playSound('pickup');
if (item.id === 'health_vial') {
healPlayer(2, player);
} else if (item.id === 'stamina_tonic') {
player.staminaBoost = 180;
spawnParticles(player.x + player.w/2, player.y + player.h/2, '#ffff00', 10, 4, 20, 3);
} else if (item.id === 'shadow_cloak') {
player.shadowCloakTimer = 180;
spawnParticles(player.x + player.w/2, player.y + player.h/2, '#8844aa', 10, 4, 20, 3);
}
item.count--;
if (item.count <= 0) player.inventory.splice(index, 1);
updateInventoryUI(player);
updateHUD(player);
} else if (item.type === 'accessory') {
if (item.equipped) {
player.accessories = player.accessories.filter(a => a !== item.id);
if (item.id === 'iron_amulet') {
player.maxHealth = Math.max(INITIAL_HEALTH, player.maxHealth - 1);
player.health = Math.min(player.health, player.maxHealth);
}
if (item.id === 'gold_ring') player.goldBonus = (player.goldBonus || 0) - 1;
if (item.id === 'phoenix_feather') player.hasPhoenixFeather = false;
item.equipped = false;
} else {
player.accessories.push(item.id);
if (item.id === 'iron_amulet') {
player.maxHealth += 1;
player.health = Math.min(player.health + 1, player.maxHealth);
}
if (item.id === 'gold_ring') player.goldBonus = (player.goldBonus || 0) + 1;
if (item.id === 'phoenix_feather') player.hasPhoenixFeather = true;
item.equipped = true;
}
updateInventoryUI(player);
updateHUD(player);
}
}
// ── function: updateShopUI ──
function updateShopUI() {
const grid = document.getElementById('shop-grid');
let html = '';
for (const item of SHOP_ITEMS) {
const canAfford = window.game && window.game.player && window.game.player.money >= item.price;
html += `<div class="shop-item" data-id="${item.id}" style="${canAfford ? '' : 'opacity:0.4;'}">
<div class="name">${item.icon} ${item.name}</div>
<div class="price">💰 ${item.price}</div>
<div style="font-size:11px;color:#888">${item.desc}</div>
</div>`;
}
grid.innerHTML = html;
grid.querySelectorAll('.shop-item').forEach(el => {
el.addEventListener('click', () => buyItem(el.dataset.id));
});
}
// ── function: buyItem ──
function buyItem(id) {
const shopItem = SHOP_ITEMS.find(s => s.id === id);
if (!shopItem) return;
if (!window.game || window.game.player.money < shopItem.price) {
showDialog("Not enough money!", 1500);
return;
}
window.game.player.money -= shopItem.price;
const newItem = { ...shopItem, type: shopItem.type, count: 1, equipped: false };
if (shopItem.type === 'consumable') {
const existing = window.game.player.inventory.find(i => i.id === id && i.type === 'consumable');
if (existing) {
existing.count++;
} else {
window.game.player.inventory.push(newItem);
}
} else {
window.game.player.inventory.push(newItem);
}
playSound('shop');
updateHUD(window.game.player);
updateShopUI();
updateInventoryUI(window.game.player);
showDialog(`Bought ${shopItem.name}!`, 1200);
}
// ── function: showDialog ──
function showDialog(text, duration) {
const box = document.getElementById('dialog-box');
document.getElementById('dialog-text').textContent = text;
box.classList.remove('hidden');
if (dialogTimeout) clearTimeout(dialogTimeout);
if (duration) {
dialogTimeout = setTimeout(() => box.classList.add('hidden'), duration);
}
}
// ── function: showGameOver ──
function showGameOver() {
document.getElementById('game-over').classList.remove('hidden');
document.getElementById('death-msg').textContent = `You fell in ${window.game.roomsVisited.size} rooms. Money: ${window.game.player.money}`;
}
// ── function: showVictory ──
function showVictory() {
document.getElementById('victory-screen').classList.remove('hidden');
document.getElementById('victory-msg').textContent = `You conquered the Echo Lord! Final HP: ${window.game.player.health}/${window.game.player.maxHealth}. Money: ${window.game.player.money}`;
}
// ── function: saveGame ──
function saveGame() {
const saveData = {
player: {
x: window.game.player.x, y: window.game.player.y,
health: window.game.player.health, maxHealth: window.game.player.maxHealth,
money: window.game.player.money,
weapon: window.game.player.weapon, weaponLevel: window.game.player.weaponLevel,
hasRanged: window.game.player.hasRanged,
inventory: window.game.player.inventory,
accessories: window.game.player.accessories,
hasPhoenixFeather: window.game.player.hasPhoenixFeather,
goldBonus: window.game.player.goldBonus,
staminaBoost: window.game.player.staminaBoost,
shadowCloakTimer: window.game.player.shadowCloakTimer,
hpGoalReached: window.game.player.hpGoalReached
},
currentRoom: window.game.currentRoomId,
roomsVisited: [...window.game.roomsVisited],
roomItems: {},
bossDefeated: window.game.bossDefeated
};
for (const [id, room] of Object.entries(window.game.rooms)) {
saveData.roomItems[id] = room.items.map(i => ({...i}));
}
localStorage.setItem('realm_of_echoes_save', JSON.stringify(saveData));
playSound('save');
showDialog('Game saved at this location!', 1500);
}
// ── function: loadGame ──
function loadGame() {
const data = localStorage.getItem('realm_of_echoes_save');
if (!data) return false;
try {
const save = JSON.parse(data);
window.game.player = { ...createPlayer(160, 128), ...save.player };
window.game.currentRoomId = save.currentRoom;
window.game.roomsVisited = new Set(save.roomsVisited);
window.game.bossDefeated = save.bossDefeated || false;
for (const [id, items] of Object.entries(save.roomItems)) {
window.game.rooms[id].items = items;
}
return true;
} catch(e) { return false; }
}
// ── function: hasSave ──
function hasSave() {
return !!localStorage.getItem('realm_of_echoes_save');
}
// ── function: updateGame ──
// ===== GAME LOGIC =====
function updateGame(game) {
const player = game.player;
const room = game.rooms[game.currentRoomId];
if (!room) return;
// Player position
const px = player.x + player.w / 2;
const py = player.y + player.h / 2;
// Movement
player.isMoving = false;
let dx = 0, dy = 0;
if (window.keys['KeyW'] || window.keys['ArrowUp']) { dy = -1; player.facing = 'up'; }
if (window.keys['KeyS'] || window.keys['ArrowDown']) { dy = 1; player.facing = 'down'; }
if (window.keys['KeyA'] || window.keys['ArrowLeft']) { dx = -1; player.facing = 'left'; }
if (window.keys['KeyD'] || window.keys['ArrowRight']) { dx = 1; player.facing = 'right'; }
if (dx !== 0 || dy !== 0) {
player.isMoving = true;
const spd = player.staminaBoost > 0 ? player.speed * 1.5 : player.speed;
let nx = player.x + dx * spd;
let ny = player.y + dy * spd;
if (!collidesWithWall(nx, player.y, player.w, player.h, room)) player.x = nx;
if (!collidesWithWall(player.x, ny, player.w, player.h, room)) player.y = ny;
player.walkTimer++;
if (player.walkTimer % 15 === 0) playSound('walk');
}
// Timers
if (player.attackCooldown > 0) player.attackCooldown--;
if (player.rangedCooldown > 0) player.rangedCooldown--;
if (player.damageFlash > 0) player.damageFlash--;
if (player.invincibleTimer > 0) player.invincibleTimer--;
if (player.staminaBoost > 0) player.staminaBoost--;
if (player.shadowCloakTimer > 0) player.shadowCloakTimer--;
// Attack
if (player.isAttacking) {
player.attackTimer--;
if (player.attackTimer <= 0) player.isAttacking = false;
}
// Check door transitions
for (const door of room.doors) {
const doorRect = { x: door.x * TILE, y: door.y * TILE, w: door.w * TILE, h: door.h * TILE };
if (rectsOverlap(
{ x: player.x, y: player.y, w: player.w, h: player.h },
doorRect
)) {
playSound('door');
game.currentRoomId = door.target;
const targetRoom = game.rooms[door.target];
for (const td of targetRoom.doors) {
if (td.target === game.currentRoomId) {
player.x = td.x * TILE + (td.dx > 0 ? 1 : td.dx < 0 ? TILE - player.w : TILE/2 - player.w/2);
player.y = td.y * TILE + (td.dy > 0 ? 1 : td.dy < 0 ? TILE - player.h : TILE/2 - player.h/2);
}
}
game.roomsVisited.add(game.currentRoomId);
break;
}
}
// Update enemies
for (const enemy of room.enemies) {
updateEnemy(enemy, player, room);
}
// Attack collision
if (player.isAttacking) {
const hitbox = getAttackHitbox(player);
for (const enemy of room.enemies) {
if (!enemy.alive) continue;
if (rectsOverlap(hitbox, { x: enemy.x, y: enemy.y, w: enemy.w, h: enemy.h })) {
damageEnemy(enemy, player.weapon.damage, player);
const angle = Math.atan2(enemy.y - player.y, enemy.x - player.x);
enemy.knockback.x = Math.cos(angle) * 8;
enemy.knockback.y = Math.sin(angle) * 8;
}
}
}
// Ranged projectiles
for (let i = game.projectiles.length - 1; i >= 0; i--) {
const p = game.projectiles[i];
p.x += p.vx;
p.y += p.vy;
p.life--;
if (p.life <= 0) { game.projectiles.splice(i, 1); continue; }
for (const enemy of room.enemies) {
if (!enemy.alive) continue;
const ex = enemy.x + enemy.w/2;
const ey = enemy.y + enemy.h/2;
const dist = Math.sqrt((p.x - ex)**2 + (p.y - ey)**2);
if (dist < enemy.w/2 + 5) {
damageEnemy(enemy, player.weapon.damage, player);
spawnParticles(p.x, p.y, '#4fc3f7', 6, 3, 15, 2);
game.projectiles.splice(i, 1);
break;
}
}
}
// Item collection
for (const item of room.items) {
if (item.collected) continue;
const ix = item.x * TILE + TILE/2;
const iy = item.y * TILE + TILE/2;
const dist = Math.sqrt((ix - px)**2 + (iy - py)**2);
if (dist < 28) {
collectItem(item, player, game);
}
}
// Save point interaction
if (room.savePoint) {
const sx = 10 * TILE + TILE/2;
const sy = 7 * TILE + TILE/2;
const dist = Math.sqrt((sx - px)**2 + (sy - py)**2);
if (dist < 30 && window.keys['KeyE']) {
saveGame();
}
}
// Shop NPC interaction
if (room.shopNPC) {
const nx = 7 * TILE + TILE/2;
const ny = 5 * TILE + TILE/2;
const dist = Math.sqrt((nx - px)**2 + (ny - py)**2);
if (dist < 30 && window.keys['KeyE']) {
openShop();
}
}
// Victory check
if (game.bossDefeated && !player.gameWon) {
player.gameWon = true;
setTimeout(() => showVictory(), 500);
}
// Death check
if (player.health <= 0 && !player.hasPhoenixFeather) {
showGameOver();
}
updateParticles();
}
// ── function: collectItem ──
function collectItem(item, player, game) {
item.collected = true;
if (item.type === 'coin') {
const bonus = player.goldBonus || 0;
player.money += item.value + bonus;
playSound('coin');
spawnParticles(item.x * TILE + TILE/2, item.y * TILE + TILE/2, '#ffd700', 8, 4, 20, 3);
} else if (item.type === 'health_pickup') {
healPlayer(1, player);
spawnParticles(item.x * TILE + TILE/2, item.y * TILE + TILE/2, '#ff4444', 10, 4, 25, 3);
} else if (item.type === 'weapon_upgrade') {
if (player.weaponLevel === 0) {
player.weapon = { name: "Steel Blade", damage: 4, range: 40, speed: ATTACK_COOLDOWN, type: "melee", icon: "🗡️" };
player.weaponLevel = 1;
playSound('levelup');
spawnParticles(item.x * TILE + TILE/2, item.y * TILE + TILE/2, '#ff8800', 20, 6, 40, 4);
showDialog("Upgraded to Steel Blade! +2 damage, +4 range", 2000);
}
} else if (item.type === 'ranged_weapon') {
player.hasRanged = true;
playSound('levelup');
spawnParticles(item.x * TILE + TILE/2, item.y * TILE + TILE/2, '#4fc3f7', 20, 6, 40, 4);
showDialog("Found the Echo Bow! Press R to shoot", 2000);
} else if (item.type === 'final_upgrade') {
if (player.weaponLevel === 1) {
player.weapon = { name: "Echo Blade", damage: 7, range: 50, speed: ATTACK_COOLDOWN, type: "melee", icon: "👑" };
player.weaponLevel = 2;
player.hasRanged = true;
playSound('levelup');
spawnParticles(item.x * TILE + TILE/2, item.y * TILE + TILE/2, '#ffd700', 30, 8, 50, 5);
showDialog("Ultimate weapon acquired! +7 damage! Press R to shoot", 2500);
setTimeout(() => {
game.rooms['boss_chamber'].doors.push({x: 10, y: 15, w: 2, h: 1, target: "victory_room", dx: 0, dy: 1});
}, 500);
}
} else if (item.type === 'accessory') {
const accItem = { id: 'iron_amulet', name: 'Iron Amulet', icon: '🔰', type: 'accessory', count: 1, equipped: true };
addItem(player, accItem);
playSound('pickup');
spawnParticles(item.x * TILE + TILE/2, item.y * TILE + TILE/2, '#aaa', 10, 4, 25, 3);
showDialog("Found Iron Amulet! +1 Max HP", 1500);
} else if (item.type === 'victory') {
game.bossDefeated = true;
playSound('victory');
showVictory();
}
}
// ── function: openShop ──
function openShop() {
document.getElementById('shop-panel').classList.remove('hidden');
updateShopUI();
playSound('shop');
}
// ── function: closeShop ──
function closeShop() {
document.getElementById('shop-panel').classList.add('hidden');
}
// ── function: render ──
// ===== RENDERING =====
function render(game) {
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const room = game.rooms[game.currentRoomId];
if (!room) return;
// Camera
const camX = game.player.x + game.player.w / 2 - CANVAS_W / 2;
const camY = game.player.y + game.player.h / 2 - CANVAS_H / 2;
// Clamp camera
const maxCamX = room.w * TILE - CANVAS_W;
const maxCamY = room.h * TILE - CANVAS_H;
const finalCamX = Math.max(0, Math.min(camX, maxCamX));
const finalCamY = Math.max(0, Math.min(camY, maxCamY));
// Draw
drawRoom(ctx, room, finalCamX, finalCamY);
drawRoomItems(ctx, room.items, finalCamX, finalCamY);
drawEnemies(ctx, room.enemies, finalCamX, finalCamY);
drawPlayer(ctx, game.player, finalCamX, finalCamY);
drawRangedProjectiles(ctx, game.projectiles, finalCamX, finalCamY);
drawParticles(ctx, finalCamX, finalCamY);
// Room name
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '14px Courier New';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(room.name, CANVAS_W / 2, 8);
// Interaction hint
const px = game.player.x + game.player.w/2;
const py = game.player.y + game.player.h/2;
if (room.savePoint) {
const sx = 10 * TILE + TILE/2, sy = 7 * TILE + TILE/2;
if (Math.sqrt((sx-px)**2 + (sy-py)**2) < 40) {
ctx.fillStyle = 'rgba(100,150,255,0.8)';
ctx.font = '12px Courier New';
ctx.fillText('[E] Save', CANVAS_W/2, CANVAS_H - 30);
}
}
if (room.shopNPC) {
const nx = 7 * TILE + TILE/2, ny = 5 * TILE + TILE/2;
if (Math.sqrt((nx-px)**2 + (ny-py)**2) < 40) {
ctx.fillStyle = 'rgba(255,215,0,0.8)';
ctx.font = '12px Courier New';
ctx.fillText('[E] Shop', CANVAS_W/2, CANVAS_H - 30);
}
}
// Ranged hint
if (game.player.hasRanged && game.player.rangedCooldown <= 0) {
ctx.fillStyle = 'rgba(79,195,247,0.6)';
ctx.font = '12px Courier New';
ctx.fillText('[R] Shoot Bow', CANVAS_W/2, CANVAS_H - 12);
}
}
// ── function: shadeColor ──
function shadeColor(color, percent) {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = Math.max(0, Math.min(255, (num >> 16) + amt));
const G = Math.max(0, Math.min(255, (num >> 8 & 0x00FF) + amt));
const B = Math.max(0, Math.min(255, (num & 0x0000FF) + amt));
return '#' + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1);
}
// ── function: updateProgress ──
function updateProgress(player) {
const roomsVisited = player.roomsVisited || (window.game && window.game.roomsVisited.size) || 1;
const totalRooms = Object.keys(ROOMS).length;
const progress = Math.min(100, Math.round((roomsVisited / totalRooms) * 100));
const fill = document.getElementById('progress-fill');
if (fill) fill.style.width = progress + '%';
// Update objective text
const obj = document.getElementById('objective');
if (obj) {
if (player.weaponLevel === 0) obj.textContent = 'Find the Steel Blade';
else if (player.weaponLevel === 1) obj.textContent = 'Find the Echo Blade';
else if (player.hasRanged) obj.textContent = 'Defeat the Echo Lord';
else obj.textContent = 'Find the Echo Bow';
}
}
// ── main ──
// ===== MAIN =====
window.keys = {};
window.game = null;
let animFrame;
let inventoryOpen = false;
let dialogTimeout = null;
function initGame() {
try {
window.game = {
player: createPlayer(288, 224),
currentRoomId: 'start',
rooms: {},
roomsVisited: new Set(['start']),
projectiles: [],
bossDefeated: false,
paused: false
};
for (const [id, r] of Object.entries(ROOMS)) {
window.game.rooms[id] = getRoomById(id);
}
if (hasSave()) {
if (loadGame()) {
showDialog('Welcome back, adventurer!', 2000);
}
}
updateHUD(window.game.player);
updateInventoryUI(window.game.player);
updateShopUI();
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('mousedown', onMouseDown);
document.getElementById('inventory-toggle').addEventListener('click', toggleInventory);
document.getElementById('save-toggle').addEventListener('click', () => {
if (window.game.rooms[window.game.currentRoomId].savePoint) {
saveGame();
} else {
showDialog('No save point here!', 1500);
}
});
document.getElementById('shop-close').addEventListener('click', () => {
closeShop();
inventoryOpen = false;
document.getElementById('inventory-panel').classList.add('hidden');
});
document.getElementById('restart-btn').addEventListener('click', () => {
document.getElementById('game-over').classList.add('hidden');
localStorage.removeItem('realm_of_echoes_save');
initGame();
});
document.getElementById('victory-restart').addEventListener('click', () => {
document.getElementById('victory-screen').classList.add('hidden');
localStorage.removeItem('realm_of_echoes_save');
initGame();
});
document.getElementById('dialog-continue').addEventListener('click', () => {
document.getElementById('dialog-box').classList.add('hidden');
});
const canvas = document.getElementById('gameCanvas');
canvas.width = CANVAS_W;
canvas.height = CANVAS_H;
canvas.style.width = CANVAS_W + 'px';
canvas.style.height = CANVAS_H + 'px';
gameLoop();
} catch(e) {
console.error('Init error:', e);
}
}
function onKeyDown(e) {
window.keys[e.code] = true;
initAudio();
if (e.code === 'KeyI' && !document.getElementById('inventory-panel').classList.contains('hidden')) {
inventoryOpen = false;
document.getElementById('inventory-panel').classList.add('hidden');
}
if (e.code === 'KeyI') {
inventoryOpen = !inventoryOpen;
document.getElementById('inventory-panel').classList.toggle('hidden', !inventoryOpen);
updateInventoryUI(window.game.player);
}
if (e.code === 'KeyR' && window.game && window.game.player.hasRanged && window.game.player.rangedCooldown <= 0) {
shootRanged(window.game);
}
}
function onKeyUp(e) {
window.keys[e.code] = false;
}
function onMouseDown(e) {
initAudio();
if (!window.game || window.game.paused) return;
if (inventoryOpen) return;
if (window.game.player.attackCooldown <= 0) {
window.game.player.isAttacking = true;
window.game.player.attackTimer = 12;
window.game.player.attackCooldown = window.game.player.weapon.speed;
window.game.player.attackDir = getAttackDirection(window.game.player);
playSound('attack');
spawnParticles(
window.game.player.x + window.game.player.w/2 + window.game.player.attackDir.x * 20,
window.game.player.y + window.game.player.h/2 + window.game.player.attackDir.y * 20,
'#ffffff', 5, 3, 15, 2
);
}
}
function getAttackDirection(player) {
if (window.keys['KeyW'] || window.keys['ArrowUp']) return { x: 0, y: -1 };
if (window.keys['KeyS'] || window.keys['ArrowDown']) return { x: 0, y: 1 };
if (window.keys['KeyA'] || window.keys['ArrowLeft']) return { x: -1, y: 0 };
if (window.keys['KeyD'] || window.keys['ArrowRight']) return { x: 1, y: 0 };
return { x: 0, y: 1 };
}
function shootRanged(g) {
const player = g.player;
const dir = getAttackDirection(player);
g.projectiles.push({
x: player.x + player.w/2,
y: player.y + player.h/2,
vx: dir.x * 6,
vy: dir.y * 6,
life: 60
});
player.rangedCooldown = RANGED_COOLDOWN;
playSound('ranged_attack');
spawnParticles(player.x + player.w/2, player.y + player.h/2, '#4fc3f7', 5, 3, 15, 2);
}
function toggleInventory() {
inventoryOpen = !inventoryOpen;
document.getElementById('inventory-panel').classList.toggle('hidden', !inventoryOpen);
if (inventoryOpen) updateInventoryUI(window.game.player);
}
function gameLoop() {
if (!window.game) return;
updateGame(window.game);
render(window.game);
updateHUD(window.game.player);
animFrame = requestAnimationFrame(gameLoop);
}
initGame();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.6-35b-a3b","provider":"lmstudio","persona":"composer","sessionId":"8a989806-5e16-478f-a187-29318beaa537","tokensIn":3439297,"tokensOut":60939,"tokensTotal":3500236,"turns":69,"toolCalls":75,"failedToolCalls":1,"timestamp":"2026-07-21T00:05:16.294Z"} -->