RPG
thinkingcap qwen3.6 27b · full

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 54,671 bytes · SHA-256 f51b7404d18c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Realm of Echoes - Action Adventure RPG</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; font-family: 'Segoe UI', sans-serif; user-select: none; }
canvas { display: block; image-rendering: pixelated; }
#ui-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
#hud { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; align-items: center; }
.heart { width: 24px; height: 24px; filter: drop-shadow(0 0 3px rgba(255,50,50,0.6)); transition: all 0.3s; }
.heart.empty { opacity: 0.25; filter: none; }
#stats-bar { position: absolute; top: 12px; right: 12px; display: flex; gap: 16px; align-items: center; color: #e8d5b7; font-size: 14px; text-shadow: 0 0 8px rgba(0,0,0,0.8); }
.stat-item { display: flex; align-items: center; gap: 6px; background: rgba(0,0,0,0.5); padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(232,213,183,0.2); }
#weapon-display { position: absolute; bottom: 12px; left: 12px; color: #e8d5b7; font-size: 13px; background: rgba(0,0,0,0.6); padding: 6px 14px; border-radius: 6px; border: 1px solid rgba(232,213,183,0.2); }
#message-log { position: absolute; bottom: 50px; left: 12px; width: 320px; pointer-events: none; }
.msg { color: #e8d5b7; font-size: 13px; padding: 3px 0; text-shadow: 0 0 6px rgba(0,0,0,0.9); opacity: 1; transition: opacity 0.5s; }
#minimap { position: absolute; top: 12px; right: 12px; border: 2px solid rgba(232,213,183,0.4); border-radius: 4px; background: rgba(0,0,0,0.7); }
#inventory-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 520px; max-height: 80vh; background: linear-gradient(135deg, #1a1420, #0f0b14); border: 2px solid rgba(232,213,183,0.3); border-radius: 12px; padding: 20px; display: none; pointer-events: auto; z-index: 100; overflow-y: auto; }
#inventory-panel h2 { color: #e8d5b7; margin-bottom: 16px; font-size: 20px; text-align: center; border-bottom: 1px solid rgba(232,213,183,0.2); padding-bottom: 10px; }
.inv-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin-bottom: 16px; }
.inv-slot { aspect-ratio: 1; background: rgba(255,255,255,0.05); border: 1px solid rgba(232,213,183,0.15); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 24px; cursor: pointer; transition: all 0.2s; position: relative; }
.inv-slot:hover { background: rgba(255,255,255,0.1); border-color: rgba(232,213,183,0.4); }
.inv-slot .count { position: absolute; bottom: 2px; right: 4px; font-size: 10px; color: #e8d5b7; }
#shop-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 480px; background: linear-gradient(135deg, #1a1420, #0f0b14); border: 2px solid rgba(232,213,183,0.3); border-radius: 12px; padding: 20px; display: none; pointer-events: auto; z-index: 100; }
#shop-panel h2 { color: #ffd700; margin-bottom: 16px; font-size: 20px; text-align: center; border-bottom: 1px solid rgba(255,215,0,0.3); padding-bottom: 10px; }
.shop-item { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; margin: 4px 0; background: rgba(255,255,255,0.03); border-radius: 6px; cursor: pointer; transition: all 0.2s; }
.shop-item:hover { background: rgba(255,215,0,0.1); }
.shop-item .name { color: #e8d5b7; font-size: 14px; }
.shop-item .price { color: #ffd700; font-size: 13px; }
#game-over-screen, #title-screen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 200; pointer-events: auto; }
#title-screen { background: radial-gradient(ellipse at center, #1a1430 0%, #050510 100%); }
#game-over-screen { background: rgba(10,5,5,0.92); display: none; }
.title-text { font-size: 48px; color: #e8d5b7; text-shadow: 0 0 30px rgba(232,213,183,0.4), 0 0 60px rgba(100,80,160,0.3); margin-bottom: 8px; letter-spacing: 4px; }
.subtitle { color: #a895c5; font-size: 16px; margin-bottom: 40px; letter-spacing: 2px; }
.btn { padding: 12px 36px; background: linear-gradient(135deg, #3d2b5e, #2a1f45); color: #e8d5b7; border: 1px solid rgba(232,213,183,0.3); border-radius: 8px; font-size: 16px; cursor: pointer; transition: all 0.3s; margin: 4px; letter-spacing: 1px; }
.btn:hover { background: linear-gradient(135deg, #5a3d7e, #3d2b5e); border-color: rgba(232,213,183,0.6); transform: scale(1.05); }
.controls-info { color: #7a6b94; font-size: 12px; margin-top: 30px; text-align: center; line-height: 1.8; }
#damage-flash { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(ellipse at center, transparent 40%, rgba(255,0,0,0.3)); pointer-events: none; opacity: 0; transition: opacity 0.1s; z-index: 50; }
#save-notification { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #7df9ff; font-size: 24px; text-shadow: 0 0 20px rgba(125,249,255,0.6); pointer-events: none; opacity: 0; z-index: 50; transition: opacity 0.5s; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="ui-overlay">
<div id="hud"></div>
<div id="stats-bar">
<div class="stat-item" id="gold-display">🪙 0</div>
<div class="stat-item" id="zone-display">📍 Village</div>
</div>
<div id="weapon-display">⚔️ Rusty Sword (1)</div>
<div id="message-log"></div>
</div>
<div id="inventory-panel">
<h2>🎒 Inventory</h2>
<div class="inv-grid" id="inv-grid"></div>
<div style="color:#7a6b94;font-size:12px;text-align:center;">Press I to close • Click item to use</div>
</div>
<div id="shop-panel">
<h2>🏪 Merchant Shop</h2>
<div id="shop-items"></div>
<div style="color:#7a6b94;font-size:12px;text-align:center;margin-top:12px;">Press E to close • Click item to buy</div>
</div>
<div id="damage-flash"></div>
<div id="save-notification">💾 Game Saved</div>
<div id="title-screen">
<div class="title-text">Realm of Echoes</div>
<div class="subtitle">An Action-Adventure RPG</div>
<button class="btn" onclick="startGame()">Begin Adventure</button>
<button class="btn" id="load-btn" style="display:none;" onclick="loadGame()">Continue Journey</button>
<div class="controls-info">
WASD / Arrow Keys — Move<br>
Space — Attack | E — Interact | I — Inventory<br>
R — Use Ranged Weapon (when unlocked)
</div>
</div>
<div id="game-over-screen">
<div class="title-text" style="font-size:36px;color:#ff4444;">You Have Fallen</div>
<div class="subtitle">The echoes fade...</div>
<button class="btn" onclick="restartGame()">Try Again</button>
<button class="btn" id="load-btn2" style="display:none;" onclick="loadGame()">Load Save</button>
</div>
<script>
// ============================================================
// REALM OF ECHOES - Complete Game Engine
// ============================================================
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
// --- Constants ---
const TILE = 32;
const VIEW_W = Math.ceil(window.innerWidth / TILE) + 2;
const VIEW_H = Math.ceil(window.innerHeight / TILE) + 2;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// --- Audio Engine (Web Audio API) ---
let audioCtx = null;
function initAudio() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
function playSound(type, vol = 0.3) {
if (!audioCtx) return;
const now = audioCtx.currentTime;
const g = audioCtx.createGain();
g.connect(audioCtx.destination);
g.gain.setValueAtTime(vol, now);
if (type === 'walk') {
const o = audioCtx.createOscillator();
o.type = 'sine'; o.frequency.setValueAtTime(80 + Math.random()*30, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
o.connect(g); o.start(now); o.stop(now + 0.06);
} else if (type === 'attack') {
const o = audioCtx.createOscillator();
o.type = 'sawtooth'; o.frequency.setValueAtTime(200, now);
o.frequency.exponentialRampToValueAtTime(80, now + 0.15);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
o.connect(g); o.start(now); o.stop(now + 0.15);
} else if (type === 'hit') {
const buf = audioCtx.createBufferSource();
const b = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.1, audioCtx.sampleRate);
const d = b.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random()*2-1) * Math.exp(-i/d.length*4);
buf.buffer = b; g.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
buf.connect(g); buf.start(now);
} else if (type === 'pickup') {
const o = audioCtx.createOscillator();
o.type = 'sine'; o.frequency.setValueAtTime(400, now);
o.frequency.exponentialRampToValueAtTime(800, now + 0.12);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
o.connect(g); o.start(now); o.stop(now + 0.15);
} else if (type === 'shop') {
const o = audioCtx.createOscillator();
o.type = 'triangle'; o.frequency.setValueAtTime(300, now);
o.frequency.exponentialRampToValueAtTime(600, now + 0.2);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
o.connect(g); o.start(now); o.stop(now + 0.25);
} else if (type === 'save') {
[400,500,600].forEach((f,i) => {
const o = audioCtx.createOscillator();
const gg = audioCtx.createGain();
o.type='sine'; o.frequency.setValueAtTime(f, now+i*0.12);
gg.gain.setValueAtTime(0.15, now+i*0.12);
gg.gain.exponentialRampToValueAtTime(0.001, now+i*0.12+0.2);
o.connect(gg); gg.connect(audioCtx.destination);
o.start(now+i*0.12); o.stop(now+i*0.12+0.2);
});
} else if (type === 'ranged') {
const o = audioCtx.createOscillator();
o.type='sine'; o.frequency.setValueAtTime(600,now);
o.frequency.exponentialRampToValueAtTime(150,now+0.2);
g.gain.exponentialRampToValueAtTime(0.001,now+0.2);
o.connect(g);o.start(now);o.stop(now+0.2);
} else if (type === 'upgrade') {
[500,650,800,950].forEach((f,i)=>{
const o=audioCtx.createOscillator();const gg=audioCtx.createGain();
o.type='sine';o.frequency.setValueAtTime(f,now+i*0.1);
gg.gain.setValueAtTime(0.2,now+i*0.1);
gg.gain.exponentialRampToValueAtTime(0.001,now+i*0.1+0.3);
o.connect(gg);gg.connect(audioCtx.destination);
o.start(now+i*0.1);o.stop(now+i*0.1+0.3);
});
} else if (type === 'death') {
const o=audioCtx.createOscillator();o.type='sawtooth';
o.frequency.setValueAtTime(200,now);o.frequency.exponentialRampToValueAtTime(40,now+1);
g.gain.setValueAtTime(0.3,now);g.gain.exponentialRampToValueAtTime(0.001,now+1);
o.connect(g);o.start(now);o.stop(now+1);
} else if (type === 'heal') {
const o=audioCtx.createOscillator();o.type='sine';
o.frequency.setValueAtTime(350,now);o.frequency.exponentialRampToValueAtTime(700,now+0.3);
g.gain.exponentialRampToValueAtTime(0.001,now+0.35);
o.connect(g);o.start(now);o.stop(now+0.35);
}
}
// --- Particle System ---
const particles = [];
function spawnParticles(x, y, color, count=8, speed=2, life=30) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = speed * (0.5 + Math.random());
particles.push({
x, y, vx: Math.cos(angle)*spd, vy: Math.sin(angle)*spd,
life, maxLife: life, color, size: 2+Math.random()*3
});
}
}
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; // gravity
p.life--;
if (p.life <= 0) particles.splice(i, 1);
}
}
function drawParticles(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;
}
// --- Tile Types ---
const T = {
EMPTY: 0, GRASS: 1, DIRT: 2, WALL: 3, WATER: 4, BRIDGE: 5,
FLOOR: 6, DOOR: 7, SAVE_POINT: 8, SHOP: 9, CHEST: 10,
LAVA: 11, STONE: 12, SAND: 13, SNOW: 14, ICE: 15,
BOSS_DOOR: 16, TRAP: 17, SHRINE: 18
};
const TILE_COLORS = {
[T.EMPTY]: null,
[T.GRASS]: '#2d5a1e',
[T.DIRT]: '#8b6914',
[T.WALL]: '#3a3a4a',
[T.WATER]: '#1a4a7a',
[T.BRIDGE]: '#6b4c2a',
[T.FLOOR]: '#5a5060',
[T.DOOR]: '#8b4513',
[T.SAVE_POINT]: '#4a3a5a',
[T.SHOP]: '#5a5060',
[T.CHEST]: '#5a5060',
[T.LAVA]: '#aa2200',
[T.STONE]: '#4a4a5a',
[T.SAND]: '#c2a848',
[T.SNOW]: '#d8e8f0',
[T.ICE]: '#8ac4e0',
[T.BOSS_DOOR]: '#6a1a1a',
[T.TRAP]: '#5a5060',
[T.SHRINE]: '#3a2a5a'
};
const TILE_SOLID = new Set([T.WALL, T.WATER, T.LAVA]);
// --- World Map Generation ---
function createWorld() {
const W = 80, H = 60;
const map = Array.from({length: H}, () => Array(W).fill(T.GRASS));
// Helper to fill rectangle
function rect(x1,y1,x2,y2,tile) {
for (let y=y1;y<=y2;y++) for(let x=x1;x<=x2;x++) if(y>=0&&y<H&&x>=0&&x<W) map[y][x]=tile;
}
// Zone 1: Village (top-left area, 0-35, 0-28)
rect(0,0,W,H,T.GRASS);
// Village buildings
rect(4,4,9,9,T.WALL); rect(5,5,8,8,T.FLOOR); map[7][6]=T.SHOP;
rect(12,3,17,8,T.WALL); rect(13,4,16,7,T.FLOOR);
rect(20,5,25,10,T.WALL); rect(21,6,24,9,T.FLOOR); map[8][22]=T.SAVE_POINT;
// Paths from village
rect(10,5,11,25,T.DIRT); rect(13,8,13,25,T.DIRT);
rect(15,10,40,11,T.DIRT); rect(30,11,31,25,T.DIRT);
// Zone 2: Forest (middle area)
rect(0,30,W,35,T.GRASS);
// Trees as walls scattered
for(let i=0;i<40;i++){const tx=Math.floor(Math.random()*W),ty=30+Math.floor(Math.random()*18);map[ty][tx]=T.WALL;}
rect(2,32,5,36,T.GRASS); // clear path
map[34][3]=T.CHEST;
// Zone 3: Cave (right side)
rect(40,15,W-1,38,T.STONE);
rect(41,16,W-2,37,T.FLOOR);
// Cave walls pattern
for(let y=16;y<38;y++)for(let x=41;x<W-1;x++){
if(Math.random()<0.15)map[y][x]=T.WALL;
}
rect(42,17,45,20,T.FLOOR); // clear entrance area
map[18][43]=T.SAVE_POINT;
// Zone 4: Underground (bottom)
rect(0,40,W,H,T.STONE);
for(let y=41;y<H-1;y++)for(let x=1;x<W-1;x++){
map[y][x]=T.FLOOR;
if(Math.random()<0.12)map[y][x]=T.WALL;
}
rect(5,42,10,46,T.FLOOR); // clear area
map[44][7]=T.CHEST;
map[20][48]=T.CHEST;
// Zone 5: Lava Cavern (bottom-right)
rect(35,40,W,H,T.STONE);
for(let y=41;y<H-1;y++)for(let x=36;x<W-1;x++){
map[y][x]=T.FLOOR;
if(Math.random()<0.2)map[y][x]=T.LAVA;
if(Math.random()<0.08)map[y][x]=T.WALL;
}
// Bridges over lava
rect(45,43,47,43,T.BRIDGE);
rect(55,46,57,46,T.BRIDGE);
map[44][50]=T.BOSS_DOOR;
// Zone 6: Crystal Peak (top-right) - final area
rect(40,0,W-1,12,T.SNOW);
for(let y=1;y<11;y++)for(let x=41;x<W-1;x++){
if(Math.random()<0.15)map[y][x]=T.WALL;
if(Math.random()<0.03)map[y][x]=T.ICE;
}
map[6][55]=T.SHRINE;
// Connect zones with passages
rect(28,24,32,26,T.DIRT); // forest to cave connector
rect(10,37,14,39,T.FLOOR); // forest to underground
rect(35,37,39,39,T.FLOOR); // cave to lava cavern
return {map, W, H};
}
// --- Game State ---
let game = null;
function createGameState() {
const world = createWorld();
return {
player: {
x: 7*TILE, y: 7*TILE,
hp: 3, maxHp: 6, // 3 hearts * 2 = 6 HP (start with 1.5 hearts visible)
displayHearts: 3, // starts at 3 heart shapes
gold: 0,
weapon: { name: 'Rusty Sword', level: 0, damage: 3, range: TILE*1.2, speed: 400, color: '#aaa' },
hasRangedWeapon: false,
ammo: 0,
inventory: [],
facing: 'down',
attacking: false,
attackTimer: 0,
invincible: 0,
walkFrame: 0,
walkTimer: 0,
speed: 2.5
},
world,
enemies: [],
items: [],
chests: [],
savePoints: [],
shops: [],
zones: ['Village', 'Forest', 'Cave', 'Underground', 'Lava Cavern', 'Crystal Peak'],
currentZone: 0,
gameProgress: 0, // 0-100
midGameReached: false,
bossDefeated: false,
gameOver: false,
paused: false,
inventoryOpen: false,
shopOpen: false,
messages: [],
time: 0,
keys: {},
camera: { x: 0, y: 0 }
};
}
// --- Enemy Types ---
const ENEMY_TYPES = {
slime: { hp: 4, damage: 1, speed: 0.8, color: '#4a2', size: 12, gold: 3, xp: 5 },
bat: { hp: 3, damage: 1, speed: 1.5, color: '#639', size: 10, gold: 2, xp: 3 },
skeleton: { hp: 8, damage: 2, speed: 1, color: '#ccc', size: 14, gold: 5, xp: 10 },
ghost: { hp: 6, damage: 2, speed: 1.2, color: 'rgba(150,200,255,0.7)', size: 13, gold: 4, xp: 8 },
demon: { hp: 12, damage: 3, speed: 1.3, color: '#a22', size: 16, gold: 8, xp: 15 },
boss: { hp: 40, damage: 4, speed: 0.9, color: '#ff0044', size: 24, gold: 50, xp: 100 }
};
function spawnEnemies() {
const g = game;
const enemies = [];
// Village area - weak enemies
for (let i = 0; i < 3; i++) {
enemies.push(createEnemy('slime', 25 + Math.random()*10, 15 + Math.random()*8));
}
// Forest
for (let i = 0; i < 6; i++) {
const type = Math.random() > 0.5 ? 'bat' : 'slime';
enemies.push(createEnemy(type, 2 + Math.random()*35, 31 + Math.random()*14));
}
// Cave
for (let i = 0; i < 8; i++) {
const type = ['skeleton', 'ghost', 'bat'][Math.floor(Math.random()*3)];
enemies.push(createEnemy(type, 42 + Math.random()*(g.world.W-45), 17 + Math.random()*18));
}
// Underground
for (let i = 0; i < 6; i++) {
const type = ['skeleton', 'ghost'][Math.floor(Math.random()*2)];
enemies.push(createEnemy(type, 2 + Math.random()*30, 41 + Math.random()*15));
}
// Lava Cavern - tough enemies
for (let i = 0; i < 8; i++) {
const type = ['demon', 'skeleton'][Math.floor(Math.random()*2)];
enemies.push(createEnemy(type, 37 + Math.random()*(g.world.W-42), 41 + Math.random()*15));
}
// Boss in lava cavern
enemies.push(createEnemy('boss', 50*TILE, 44*TILE));
g.enemies = enemies;
}
function createEnemy(type, x, y) {
const t = ENEMY_TYPES[type];
return { type, x, y, hp: t.hp, maxHp: t.hp, damage: t.damage, speed: t.speed, color: t.color, size: t.size, gold: t.gold, alive: true, hitTimer: 0, attackCooldown: 0, aggroRange: TILE * 5, angle: Math.random()*Math.PI*2 };
}
// --- Items ---
const ITEM_DEFS = {
health_potion: { name: 'Health Potion', icon: '🧪', desc: 'Restores 4 HP', type: 'consumable' },
greater_health_potion: { name: 'Greater Health Potion', icon: '✨', desc: 'Restores 6 HP', type: 'consumable' },
iron_shield: { name: 'Iron Shield', icon: '🛡️', desc: '+1 Max Hearts', type: 'accessory' },
speed_boots: { name: 'Swift Boots', icon: '👢', desc: '+20% Speed', type: 'accessory' },
lucky_charm: { name: 'Lucky Charm', icon: '🍀', desc: '+50% Gold Find', type: 'accessory' },
fire_ring: { name: 'Fire Ring', icon: '💍', desc: '+2 Weapon Damage', type: 'accessory' },
arrow_pack: { name: 'Arrow Pack (10)', icon: '🏹', desc: 'Ranged ammo x10', type: 'ammo' }
};
function spawnItems() {
const g = game;
const items = [];
// Scattered items throughout the world
items.push({ ...ITEM_DEFS.health_potion, x: 6*TILE, y: 24*TILE });
items.push({ ...ITEM_DEFS.health_potion, x: 30*TILE, y: 11*TILE });
items.push({ ...ITEM_DEFS.gold_pickup, x: 8*TILE, y: 5*TILE }, { gold: 5 });
// Forest items
items.push({ ...ITEM_DEFS.health_potion, x: 15*TILE, y: 34*TILE });
items.push({ ...ITEM_DEFS.gold_pickup, x: 20*TILE, y: 36*TILE }, { gold: 8 });
// Cave items
items.push({ ...ITEM_DEFS.health_potion, x: 50*TILE, y: 20*TILE });
items.push({ ...ITEM_DEFS.gold_pickup, x: 45*TILE, y: 30*TILE }, { gold: 10 });
// Underground
items.push({ ...ITEM_DEFS.greater_health_potion, x: 8*TILE, y: 44*TILE });
items.push({ ...ITEM_DEFS.gold_pickup, x: 25*TILE, y: 46*TILE }, { gold: 12 });
// Lava cavern
items.push({ ...ITEM_DEFS.greater_health_potion, x: 40*TILE, y: 43*TILE });
items.push({ ...ITEM_DEFS.gold_pickup, x: 55*TILE, y: 48*TILE }, { gold: 15 });
// Crystal Peak items
items.push({ ...ITEM_DEFS.health_potion, x: 60*TILE, y: 5*TILE });
items.push({ ...ITEM_DEFS.gold_pickup, x: 70*TILE, y: 8*TILE }, { gold: 20 });
g.items = items;
}
// --- Shop Items ---
const SHOP_ITEMS = [
{ id: 'health_potion', price: 10, maxBuy: 99 },
{ id: 'greater_health_potion', price: 25, maxBuy: 99 },
{ id: 'iron_shield', price: 80, maxBuy: 1 },
{ id: 'speed_boots', price: 60, maxBuy: 1 },
{ id: 'lucky_charm', price: 50, maxBuy: 1 },
{ id: 'fire_ring', price: 70, maxBuy: 1 },
{ id: 'arrow_pack', price: 15, maxBuy: 99 }
];
// --- Weapon Upgrades ---
const WEAPON_UPGRADES = [
{ name: 'Rusty Sword', damage: 3, range: TILE*1.2, speed: 400, color: '#aaa' },
{ name: 'Steel Blade', damage: 5, range: TILE*1.5, speed: 350, color: '#8cf' },
{ name: 'Flame Edge', damage: 8, range: TILE*2, speed: 300, color: '#f84' }
];
// --- Core Game Functions ---
function startGame() {
initAudio();
game = createGameState();
spawnEnemies();
spawnItems();
document.getElementById('title-screen').style.display = 'none';
document.getElementById('game-over-screen').style.display = 'none';
addMessage("Welcome to the Realm of Echoes!");
addMessage("Explore, fight, and grow stronger.");
}
function restartGame() {
startGame();
}
function loadGame() {
const data = localStorage.getItem('realmOfEchoes');
if (data) {
initAudio();
game = JSON.parse(data);
document.getElementById('title-screen').style.display = 'none';
document.getElementById('game-over-screen').style.display = 'none';
addMessage("Save loaded. Continue your journey!");
}
}
function saveGame() {
if (!game) return;
localStorage.setItem('realmOfEchoes', JSON.stringify(game));
const notif = document.getElementById('save-notification');
notif.style.opacity = '1';
setTimeout(() => notif.style.opacity = '0', 2000);
}
function addMessage(text) {
if (!game) return;
game.messages.push({ text, time: Date.now() });
const log = document.getElementById('message-log');
const div = document.createElement('div');
div.className = 'msg';
div.textContent = text;
log.appendChild(div);
setTimeout(() => { div.style.opacity = '0'; }, 3000);
setTimeout(() => { if (div.parentNode) div.remove(); }, 3500);
}
// --- Input Handling ---
document.addEventListener('keydown', e => {
if (!game || game.gameOver) return;
const key = e.key.toLowerCase();
game.keys[key] = true;
if (key === 'i') {
game.inventoryOpen = !game.inventoryOpen;
document.getElementById('inventory-panel').style.display = game.inventoryOpen ? 'block' : 'none';
if (game.inventoryOpen) renderInventory();
}
if (key === 'e' && game.shopOpen) {
game.shopOpen = false;
document.getElementById('shop-panel').style.display = 'none';
}
// Prevent scrolling
if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(e.key.toLowerCase()) || e.key === ' ') {
e.preventDefault();
}
});
document.addEventListener('keyup', e => {
if (!game) return;
game.keys[e.key.toLowerCase()] = false;
});
// --- Collision Detection ---
function isSolid(px, py) {
const g = game;
const tx = Math.floor(px / TILE);
const ty = Math.floor(py / TILE);
if (tx < 0 || ty < 0 || tx >= g.world.W || ty >= g.world.H) return true;
return TILE_SOLID.has(g.world.map[ty][tx]);
}
function canMoveTo(x, y, size=10) {
const corners = [
[x-size+2, y-size+2], [x+size-2, y-size+2],
[x-size+2, y+size-2], [x+size-2, y+size-2]
];
return !corners.some(([cx,cy]) => isSolid(cx, cy));
}
// --- Player Update ---
function updatePlayer(dt) {
const p = game.player;
if (p.invincible > 0) p.invincible -= dt;
// Movement
let dx = 0, dy = 0;
if (game.keys['w'] || game.keys['arrowup']) { dy = -1; p.facing = 'up'; }
if (game.keys['s'] || game.keys['arrowdown']) { dy = 1; p.facing = 'down'; }
if (game.keys['a'] || game.keys['arrowleft']) { dx = -1; p.facing = 'left'; }
if (game.keys['d'] || game.keys['arrowright']) { dx = 1; p.facing = 'right'; }
const moving = dx !== 0 || dy !== 0;
if (moving) {
const len = Math.sqrt(dx*dx + dy*dy);
dx /= len; dy /= len;
const nx = p.x + dx * p.speed;
const ny = p.y + dy * p.speed;
if (canMoveTo(nx, p.y)) p.x = nx;
if (canMoveTo(p.x, ny)) p.y = ny;
// Walk animation & sound
p.walkTimer += dt;
if (p.walkTimer > 200) {
p.walkFrame = (p.walkFrame + 1) % 4;
playSound('walk', 0.08);
p.walkTimer = 0;
}
} else {
p.walkFrame = 0;
}
// Attack
if ((game.keys[' '] || game.keys['space']) && !p.attacking) {
performAttack();
}
// Ranged attack
if (game.keys['r'] && p.hasRangedWeapon && p.ammo > 0 && !p.attacking) {
performRangedAttack();
}
if (p.attacking) {
p.attackTimer -= dt;
if (p.attackTimer <= 0) p.attacking = false;
}
// Zone detection
updateZone(p.x, p.y);
// Interact with nearby objects
if (game.keys['e']) {
game.keys['e'] = false;
interact();
}
}
function performAttack() {
const p = game.player;
p.attacking = true;
p.attackTimer = p.weapon.speed;
playSound('attack');
// Attack hitbox based on facing direction
let ax = p.x, ay = p.y;
if (p.facing === 'up') ay -= TILE;
if (p.facing === 'down') ay += TILE;
if (p.facing === 'left') ax -= TILE;
if (p.facing === 'right') ax += TILE;
// Check enemy hits
for (const e of game.enemies) {
if (!e.alive) continue;
const dist = Math.sqrt((e.x-ax)**2 + (e.y-ay)**2);
if (dist < p.weapon.range) {
damageEnemy(e, p.weapon.damage);
}
}
// Attack particles
spawnParticles(ax, ay, p.weapon.color, 6, 3, 15);
}
function performRangedAttack() {
const p = game.player;
if (p.ammo <= 0) return;
p.ammo--;
p.attacking = true;
p.attackTimer = 400;
playSound('ranged');
let ax = p.x, ay = p.y;
const range = TILE * 5;
if (p.facing === 'up') ay -= range/2;
if (p.facing === 'down') ay += range/2;
if (p.facing === 'left') ax -= range/2;
if (p.facing === 'right') ax += range/2;
for (const e of game.enemies) {
if (!e.alive) continue;
const dist = Math.sqrt((e.x-ax)**2 + (e.y-ay)**2);
if (dist < TILE * 1.5) {
damageEnemy(e, p.weapon.damage + 2);
}
}
spawnParticles(ax, ay, '#ff0', 8, 4, 20);
}
function damageEnemy(enemy, dmg) {
enemy.hp -= dmg;
enemy.hitTimer = 15;
playSound('hit');
spawnParticles(enemy.x, enemy.y, '#ff4', 10, 3, 20);
if (enemy.hp <= 0) {
enemy.alive = false;
const goldMult = game.player.inventory.some(i => i.id === 'lucky_charm') ? 1.5 : 1;
const goldDrop = Math.floor(enemy.gold * goldMult);
game.player.gold += goldDrop;
addMessage(`Defeated ${enemy.type}! +${goldDrop} gold`);
spawnParticles(enemy.x, enemy.y, '#ff0', 15, 4, 30);
// Progress tracking
if (enemy.type === 'boss') {
game.bossDefeated = true;
addMessage("🏆 BOSS DEFEATED! The realm is saved!");
playSound('upgrade');
}
updateProgress();
}
}
function damagePlayer(dmg) {
const p = game.player;
if (p.invincible > 0) return;
p.hp -= dmg;
p.invincible = 800;
playSound('hit');
// Flash screen red
const flash = document.getElementById('damage-flash');
flash.style.opacity = '1';
setTimeout(() => flash.style.opacity = '0', 200);
spawnParticles(p.x, p.y, '#f44', 8, 3, 20);
if (p.hp <= 0) {
p.hp = 0;
game.gameOver = true;
playSound('death');
document.getElementById('game-over-screen').style.display = 'flex';
const saved = localStorage.getItem('realmOfEchoes');
if (saved) {
document.getElementById('load-btn2').style.display = '';
}
}
updateHUD();
}
function interact() {
const p = game.player;
// Check nearby tiles for interactive objects
const tx = Math.floor(p.x / TILE);
const ty = Math.floor(p.y / TILE);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = tx + dx, ny = ty + dy;
if (nx < 0 || ny < 0 || nx >= game.world.W || ny >= game.world.H) continue;
const tile = game.world.map[ny][nx];
if (tile === T.SAVE_POINT) {
saveGame();
playSound('save');
addMessage("💾 Game saved!");
spawnParticles(p.x, p.y, '#7df9ff', 20, 3, 40);
game.world.map[ny][nx] = T.FLOOR; // consume save point
} else if (tile === T.SHOP) {
openShop();
} else if (tile === T.CHEST) {
openChest(nx, ny);
} else if (tile === T.SHRINE) {
activateShrine();
} else if (tile === T.BOSS_DOOR && game.gameProgress >= 80) {
addMessage("The boss door is sealed. You need more power!");
}
}
}
}
function openChest(tx, ty) {
const loot = Math.random();
if (loot < 0.3) {
// Weapon upgrade
if (game.player.weapon.level < WEAPON_UPGRADES.length - 1) {
game.player.weapon.level++;
const wu = WEAPON_UPGRADES[game.player.weapon.level];
Object.assign(game.player.weapon, wu);
addMessage(`⚔️ Weapon upgraded to ${wu.name}!`);
playSound('upgrade');
spawnParticles(tx*TILE+TILE/2, ty*TILE+TILE/2, '#ff0', 25, 4, 40);
} else {
game.player.gold += 30;
addMessage("Found 30 gold!");
playSound('pickup');
}
} else if (loot < 0.6) {
// Health upgrade
if (game.player.displayHearts < 5) {
game.player.displayHearts++;
game.player.maxHp = game.player.displayHearts * 2;
game.player.hp = Math.min(game.player.hp + 2, game.player.maxHp);
addMessage("❤️ Max health increased!");
playSound('upgrade');
} else {
game.player.gold += 20;
addMessage("Found 20 gold!");
}
} else if (loot < 0.85) {
// Gold
const amt = 15 + Math.floor(Math.random()*20);
game.player.gold += amt;
addMessage(`Found ${amt} gold!`);
playSound('pickup');
} else {
// Ranged weapon (mid-game)
if (!game.player.hasRangedWeapon && game.gameProgress >= 45) {
game.player.hasRangedWeapon = true;
game.player.ammo += 10;
addMessage("🏹 Discovered a Bow! Press R to shoot.");
playSound('upgrade');
} else {
game.player.gold += 10;
addMessage("Found 10 gold!");
}
}
spawnParticles(tx*TILE+TILE/2, ty*TILE+TILE/2, '#ffd700', 15, 3, 30);
game.world.map[ty][tx] = T.FLOOR;
}
function activateShrine() {
if (game.bossDefeated) {
addMessage("🌟 The shrine glows with ancient power. You are victorious!");
playSound('upgrade');
spawnParticles(game.player.x, game.player.y, '#fff', 30, 5, 50);
game.world.map[Math.floor(game.player.y/TILE)][Math.floor(game.player.x/TILE)] = T.FLOOR;
} else {
addMessage("The shrine is dormant. Defeat the boss to awaken it.");
}
}
function openShop() {
playSound('shop');
game.shopOpen = true;
const panel = document.getElementById('shop-panel');
panel.style.display = 'block';
renderShop();
}
function renderShop() {
const container = document.getElementById('shop-items');
container.innerHTML = '';
for (const si of SHOP_ITEMS) {
const def = ITEM_DEFS[si.id];
if (!def) continue;
const div = document.createElement('div');
div.className = 'shop-item';
const owned = game.player.inventory.some(i => i.id === si.id && def.type !== 'consumable' && def.type !== 'ammo');
div.innerHTML = `<span class="name">${def.icon} ${def.name}</span><span class="price">🪙 ${si.price}${owned ? ' (Owned)' : ''}</span>`;
if (!owned) {
div.onclick = () => buyItem(si);
} else {
div.style.opacity = '0.4';
div.style.cursor = 'default';
}
container.appendChild(div);
}
}
function buyItem(si) {
const def = ITEM_DEFS[si.id];
if (game.player.gold < si.price) {
addMessage("Not enough gold!");
return;
}
game.player.gold -= si.price;
playSound('pickup');
if (def.type === 'consumable') {
const existing = game.player.inventory.find(i => i.id === si.id);
if (existing) existing.count++;
else game.player.inventory.push({ ...def, id: si.id, count: 1 });
} else if (def.type === 'accessory') {
game.player.inventory.push({ ...def, id: si.id, count: 1 });
applyAccessory(si.id);
} else if (def.type === 'ammo') {
game.player.ammo += 10;
}
addMessage(`Bought ${def.name}!`);
spawnParticles(game.player.x, game.player.y, '#ffd700', 12, 3, 25);
updateHUD();
renderShop();
}
function applyAccessory(id) {
const p = game.player;
if (id === 'iron_shield' && p.displayHearts < 5) {
p.displayHearts++;
p.maxHp = p.displayHearts * 2;
addMessage("Iron Shield equipped! +1 Max Heart");
} else if (id === 'speed_boots') {
p.speed += 0.5;
addMessage("Swift Boots equipped! Speed increased!");
} else if (id === 'fire_ring') {
p.weapon.damage += 2;
addMessage("Fire Ring equipped! +2 Damage");
}
}
function renderInventory() {
const grid = document.getElementById('inv-grid');
grid.innerHTML = '';
for (let i = 0; i < 15; i++) {
const slot = document.createElement('div');
slot.className = 'inv-slot';
if (game.player.inventory[i]) {
const item = game.player.inventory[i];
slot.textContent = item.icon || '?';
if (item.count > 1) {
const cnt = document.createElement('span');
cnt.className = 'count';
cnt.textContent = item.count;
slot.appendChild(cnt);
}
slot.title = `${item.name}\n${item.desc}`;
if (item.type === 'consumable') {
slot.onclick = () => useItem(i);
}
}
grid.appendChild(slot);
}
}
function useItem(index) {
const item = game.player.inventory[index];
if (!item || item.type !== 'consumable') return;
playSound('heal');
if (item.id === 'health_potion') {
game.player.hp = Math.min(game.player.hp + 4, game.player.maxHp);
addMessage("Used Health Potion (+4 HP)");
} else if (item.id === 'greater_health_potion') {
game.player.hp = Math.min(game.player.hp + 6, game.player.maxHp);
addMessage("Used Greater Health Potion (+6 HP)");
}
spawnParticles(game.player.x, game.player.y, '#4f4', 10, 2, 25);
item.count--;
if (item.count <= 0) game.player.inventory.splice(index, 1);
renderInventory();
updateHUD();
}
function updateZone(x, y) {
const g = game;
let zone = 0;
if (y > 38 && x < 35) zone = 3; // Underground
else if (y > 38) zone = 4; // Lava Cavern
else if (x >= 40 && y <= 12) zone = 5; // Crystal Peak
else if (x >= 40) zone = 2; // Cave
else if (y > 29) zone = 1; // Forest
if (zone !== g.currentZone) {
g.currentZone = zone;
addMessage(`Entered ${g.zones[zone]}`);
}
}
function updateProgress() {
const aliveCount = game.enemies.filter(e => e.alive).length;
const total = game.enemies.length;
game.gameProgress = Math.min(100, ((total - aliveCount) / total) * 100);
// Mid-game check for ranged weapon hint
if (game.gameProgress >= 45 && !game.midGameReached) {
game.midGameReached = true;
addMessage("You sense a new power awakening...");
}
}
// --- Enemy AI ---
function updateEnemies(dt) {
const p = game.player;
for (const e of game.enemies) {
if (!e.alive) continue;
if (e.hitTimer > 0) e.hitTimer--;
if (e.attackCooldown > 0) e.attackCooldown -= dt;
const dx = p.x - e.x;
const dy = p.y - e.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < e.aggroRange) {
// Chase player
const nx = e.x + (dx/dist) * e.speed;
const ny = e.y + (dy/dist) * e.speed;
if (canMoveTo(nx, ny, e.size)) {
e.x = nx; e.y = ny;
}
// Attack player on contact
if (dist < TILE && e.attackCooldown <= 0) {
damagePlayer(e.damage);
e.attackCooldown = 1200;
}
} else {
// Wander
e.angle += (Math.random()-0.5)*0.3;
const wx = e.x + Math.cos(e.angle)*e.speed*0.3;
const wy = e.y + Math.sin(e.angle)*e.speed*0.3;
if (canMoveTo(wx, wy, e.size)) {
e.x = wx; e.y = wy;
} else {
e.angle += Math.PI;
}
}
}
}
// --- Item Pickup ---
function updateItems() {
const p = game.player;
for (let i = game.items.length-1; i >= 0; i--) {
const item = game.items[i];
const dist = Math.sqrt((p.x-item.x)**2 + (p.y-item.y)**2);
if (dist < TILE) {
if (item.gold) {
p.gold += item.gold;
addMessage(`+${item.gold} gold`);
} else {
const existing = p.inventory.find(it => it.id === item.id);
if (existing) existing.count++;
else p.inventory.push({ ...item, count: 1 });
addMessage(`Picked up ${item.name}`);
}
playSound('pickup');
spawnParticles(item.x, item.y, '#ffd700', 8, 2, 20);
game.items.splice(i, 1);
}
}
}
// --- HUD Update ---
function updateHUD() {
const p = game.player;
// Hearts
const hud = document.getElementById('hud');
hud.innerHTML = '';
for (let i = 0; i < p.displayHearts; i++) {
const heart = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
heart.setAttribute('class', 'heart' + (p.hp <= i*2 ? ' empty' : ''));
heart.setAttribute('viewBox', '0 0 24 24');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('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');
path.setAttribute('fill', '#ff3344');
heart.appendChild(path);
hud.appendChild(heart);
}
// Gold
document.getElementById('gold-display').textContent = `🪙 ${p.gold}`;
// Zone
document.getElementById('zone-display').textContent = `📍 ${game.zones[game.currentZone]}`;
// Weapon
const wd = document.getElementById('weapon-display');
let wtext = `⚔️ ${p.weapon.name} (${p.weapon.damage})`;
if (p.hasRangedWeapon) wtext += ` | 🏹 ${p.ammo}`;
wd.textContent = wtext;
}
// --- Rendering ---
function drawTile(x, y, tileType) {
const px = x * TILE - game.camera.x;
const py = y * TILE - game.camera.y;
if (px < -TILE || py < -TILE || px > canvas.width + TILE || py > canvas.height + TILE) return;
const color = TILE_COLORS[tileType];
if (!color) return;
ctx.fillStyle = color;
ctx.fillRect(px, py, TILE, TILE);
// Tile details
if (tileType === T.GRASS) {
ctx.fillStyle = '#3a7028';
for (let i = 0; i < 3; i++) {
const gx = px + ((x*7+y*13+i*17)%24)+4;
const gy = py + ((x*11+y*5+i*23)%24)+4;
ctx.fillRect(gx, gy, 2, 3);
}
} else if (tileType === T.WATER) {
const wave = Math.sin(game.time * 0.003 + x + y) * 3;
ctx.fillStyle = '#2a6aaa';
ctx.fillRect(px+4, py+12+wave, TILE-8, 4);
} else if (tileType === T.LAVA) {
const glow = Math.sin(game.time*0.005+x+y)*30;
ctx.fillStyle = `rgb(${170+glow},${40+glow/2},0)`;
ctx.fillRect(px, py, TILE, TILE);
} else if (tileType === T.WALL) {
// Wall texture
ctx.fillStyle = 'rgba(0,0,0,0.15)';
ctx.fillRect(px+TILE/2-1, py, 2, TILE);
ctx.fillRect(px, py+TILE/2-1, TILE, 2);
} else if (tileType === T.SAVE_POINT) {
// Glowing crystal
const pulse = Math.sin(game.time*0.005)*0.3+0.7;
ctx.fillStyle = `rgba(125,249,255,${pulse})`;
ctx.beginPath();
ctx.moveTo(px+TILE/2, py+4);
ctx.lineTo(px+TILE-6, py+TILE/2);
ctx.lineTo(px+TILE/2, py+TILE-4);
ctx.lineTo(px+6, py+TILE/2);
ctx.closePath();
ctx.fill();
} else if (tileType === T.SHOP) {
// Shop sign
ctx.fillStyle = '#ffd700';
ctx.font = '16px serif';
ctx.textAlign = 'center';
ctx.fillText('🏪', px+TILE/2, py+TILE-8);
} else if (tileType === T.CHEST) {
// Chest
ctx.fillStyle = '#b87300';
ctx.fillRect(px+6, py+10, TILE-12, TILE-14);
ctx.fillStyle = '#ffd700';
ctx.fillRect(px+TILE/2-3, py+14, 6, 4);
} else if (tileType === T.SHRINE) {
const pulse = Math.sin(game.time*0.004)*0.4+0.6;
ctx.fillStyle = `rgba(255,255,255,${pulse})`;
ctx.beginPath();
ctx.arc(px+TILE/2, py+TILE/2, 10, 0, Math.PI*2);
ctx.fill();
} else if (tileType === T.BOSS_DOOR) {
ctx.fillStyle = '#8b0000';
ctx.fillRect(px+4, py+2, TILE-8, TILE-4);
ctx.fillStyle = '#ff0000';
ctx.font = '14px serif';
ctx.textAlign = 'center';
ctx.fillText('👹', px+TILE/2, py+TILE-6);
} else if (tileType === T.STONE) {
ctx.fillStyle = 'rgba(0,0,0,0.1)';
const sx = ((x*7+y*3)%5)+2;
ctx.fillRect(px+sx, py+((x*3+y*7)%5)+2, 4, 3);
} else if (tileType === T.SNOW) {
ctx.fillStyle = 'rgba(255,255,255,0.3)';
for(let i=0;i<3;i++){
const sx=px+((x*13+y*7+i*19)%28)+2;
const sy=py+((x*11+y*3+i*17)%28)+2;
ctx.fillRect(sx,sy,2,2);
}
} else if (tileType === T.ICE) {
ctx.fillStyle = 'rgba(200,240,255,0.4)';
ctx.fillRect(px+2,py+2,TILE-4,TILE-4);
}
// Grid lines for walls
if (tileType === T.WALL || tileType === T.STONE) {
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.strokeRect(px, py, TILE, TILE);
}
}
function drawPlayer() {
const p = game.player;
const px = p.x - game.camera.x;
const py = p.y - game.camera.y;
// Invincibility flash
if (p.invincible > 0 && Math.floor(p.invincible/100)%2) return;
ctx.save();
ctx.translate(px, py);
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, 14, 10, 4, 0, 0, Math.PI*2);
ctx.fill();
// Body bob animation
const bob = p.walkFrame % 2 === 1 ? -2 : 0;
// Character body
ctx.fillStyle = '#5b8';
ctx.fillRect(-6, -4+bob, 12, 16);
// Head
ctx.fillStyle = '#fdb';
ctx.beginPath();
ctx.arc(0, -8+bob, 7, 0, Math.PI*2);
ctx.fill();
// Eyes based on facing direction
let eyeOffX = 0, eyeOffY = 0;
if (p.facing === 'left') eyeOffX = -3;
if (p.facing === 'right') eyeOffX = 3;
if (p.facing === 'up') eyeOffY = -2;
ctx.fillStyle = '#333';
ctx.fillRect(-3+eyeOffX, -9+bob+eyeOffY, 2, 2);
ctx.fillRect(1+eyeOffX, -9+bob+eyeOffY, 2, 2);
// Weapon visual
const wColor = p.weapon.color;
if (p.attacking) {
// Attack swing animation
let ax=0, ay=0;
if(p.facing==='right')ax=14;if(p.facing==='left')ax=-14;
if(p.facing==='down')ay=14;if(p.facing==='up')ay=-14;
ctx.strokeStyle = wColor;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(0, -2+bob);
ctx.lineTo(ax, ay-2+bob);
ctx.stroke();
// Attack arc effect
ctx.strokeStyle = `${wColor}66`;
ctx.lineWidth = 8;
const startAngle = p.facing==='right'?-0.5:p.facing==='left'?Math.PI-0.5:p.facing==='down'?Math.PI/2:-Math.PI/2;
ctx.beginPath();
ctx.arc(0, bob, TILE*0.6, startAngle-0.8, startAngle+0.8);
ctx.stroke();
} else {
// Idle weapon
let wx=0, wy=0;
if(p.facing==='right')wx=10;if(p.facing==='left')wx=-10;
if(p.facing==='down')wy=10;if(p.facing==='up')wy=-10;
ctx.strokeStyle = wColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(4, 2+bob);
ctx.lineTo(wx+4, wy+2+bob);
ctx.stroke();
}
// Equipment glow effects
if (p.inventory.some(i => i.id === 'iron_shield')) {
ctx.strokeStyle = '#8cf';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(0, bob, 14, 0, Math.PI*2);
ctx.stroke();
}
ctx.restore();
}
function drawEnemy(e) {
if (!e.alive) return;
const px = e.x - game.camera.x;
const py = e.y - game.camera.y;
if (px < -TILE*2 || py < -TILE*2 || px > canvas.width+TILE*2 || py > canvas.height+TILE*2) return;
ctx.save();
ctx.translate(px, py);
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, e.size, e.size*0.7, 3, 0, 0, Math.PI*2);
ctx.fill();
// Hit flash
const color = e.hitTimer > 0 ? '#fff' : e.color;
ctx.fillStyle = color;
if (e.type === 'boss') {
// Boss - larger, menacing shape
ctx.beginPath();
ctx.arc(0, 0, e.size, 0, Math.PI*2);
ctx.fill();
// Eyes
ctx.fillStyle = '#ff0';
ctx.fillRect(-8,-4,5,5);
ctx.fillRect(3,-4,5,5);
// Horns
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.beginPath();ctx.moveTo(-12,-e.size+5);ctx.lineTo(-16,-e.size-8);ctx.stroke();
ctx.beginPath();ctx.moveTo(12,-e.size+5);ctx.lineTo(16,-e.size-8);ctx.stroke();
} else if (e.type === 'slime') {
// Slime - blob shape
const squish = Math.sin(game.time*0.005)*2;
ctx.beginPath();
ctx.ellipse(0, 0, e.size+1, e.size-squish, 0, 0, Math.PI*2);
ctx.fill();
// Eyes
ctx.fillStyle = '#fff';
ctx.fillRect(-4,-3,3,3);ctx.fillRect(1,-3,3,3);
} else if (e.type === 'bat') {
// Bat - wings
const wingFlap = Math.sin(game.time*0.01)*5;
ctx.beginPath();
ctx.arc(0, 0, e.size*0.6, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = color;
ctx.fillRect(-e.size-3+wingFlap, -2, 8, 4);
ctx.fillRect(e.size-5-wingFlap, -2, 8, 4);
} else if (e.type === 'skeleton') {
// Skeleton - bony shape
ctx.fillRect(-6,-10,12,20);
ctx.beginPath();ctx.arc(0,-12,7,0,Math.PI*2);ctx.fill();
ctx.fillStyle = '#333';
ctx.fillRect(-3,-13,2,2);ctx.fillRect(1,-13,2,2);
} else if (e.type === 'ghost') {
// Ghost - translucent
ctx.globalAlpha = 0.7;
ctx.beginPath();
ctx.arc(0, -4, e.size, Math.PI, 0);
ctx.lineTo(e.size, 8);
for(let i=0;i<5;i++){
const gx=e.size-i*(e.size*2/4);
ctx.quadraticCurveTo(gx-e.size/10, 2+Math.sin(game.time*0.003+i)*3, gx-e.size/5, 8);
}
ctx.fill();
ctx.fillStyle = '#fff';
ctx.fillRect(-4,-6,3,3);ctx.fillRect(1,-6,3,3);
} else if (e.type === 'demon') {
// Demon - red with spikes
ctx.beginPath();ctx.arc(0,0,e.size,0,Math.PI*2);ctx.fill();
for(let i=0;i<8;i++){
const a=i*Math.PI/4;
ctx.beginPath();
ctx.moveTo(Math.cos(a)*e.size, Math.sin(a)*e.size);
ctx.lineTo(Math.cos(a)*(e.size+6), Math.sin(a)*(e.size+6));
ctx.strokeStyle=color;ctx.lineWidth=2;ctx.stroke();
}
ctx.fillStyle='#ff0';
ctx.fillRect(-5,-3,4,4);ctx.fillRect(1,-3,4,4);
}
// HP bar for enemies with significant damage taken
if (e.hp < e.maxHp) {
const bw = 24;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(-bw/2, -e.size-8, bw, 4);
ctx.fillStyle = '#f44';
ctx.fillRect(-bw/2, -e.size-8, bw*(e.hp/e.maxHp), 4);
}
ctx.restore();
}
function drawItem(item) {
const px = item.x - game.camera.x;
const py = item.y - game.camera.y;
if (px < -TILE || py < -TILE || px > canvas.width+TILE || py > canvas.height+TILE) return;
const bob = Math.sin(game.time*0.004 + item.x)*3;
ctx.save();
ctx.translate(px, py+bob);
// Glow
ctx.fillStyle = 'rgba(255,215,0,0.2)';
ctx.beginPath();
ctx.arc(0, 0, 14, 0, Math.PI*2);
ctx.fill();
if (item.gold) {
// Gold coin
ctx.fillStyle = '#ffd700';
ctx.beginPath();
ctx.arc(0, 0, 6, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#aa8800';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('$', 0, 4);
} else {
// Item icon
ctx.font = '20px serif';
ctx.textAlign = 'center';
ctx.fillText(item.icon || '?', 0, 7);
}
ctx.restore();
}
// --- Camera ---
function updateCamera() {
const p = game.player;
const targetX = p.x - canvas.width/2;
const targetY = p.y - canvas.height/2;
game.camera.x += (targetX - game.camera.x) * 0.1;
game.camera.y += (targetY - game.camera.y) * 0.1;
// Clamp to world bounds
game.camera.x = Math.max(0, Math.min(game.world.W*TILE - canvas.width, game.camera.x));
game.camera.y = Math.max(0, Math.min(game.world.H*TILE - canvas.height, game.camera.y));
}
// --- Main Game Loop ---
let lastTime = 0;
function gameLoop(timestamp) {
if (!game || game.gameOver) {
requestAnimationFrame(gameLoop);
return;
}
const dt = Math.min(timestamp - lastTime, 50);
lastTime = timestamp;
game.time = timestamp;
// Update
updatePlayer(dt);
updateEnemies(dt);
updateItems();
updateParticles();
updateCamera();
// Render
ctx.fillStyle = '#1a1420';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const camX = game.camera.x;
const camY = game.camera.y;
// Draw visible tiles
const startTX = Math.max(0, Math.floor(camX/TILE)-1);
const endTX = Math.min(game.world.W, Math.ceil((camX+canvas.width)/TILE)+1);
const startTY = Math.max(0, Math.floor(camY/TILE)-1);
const endTY = Math.min(game.world.H, Math.ceil((camY+canvas.height)/TILE)+1);
for (let y = startTY; y < endTY; y++) {
for (let x = startTX; x < endTX; x++) {
drawTile(x, y, game.world.map[y][x]);
}
}
// Draw items
for (const item of game.items) drawItem(item);
// Draw enemies
for (const e of game.enemies) drawEnemy(e);
// Draw player
drawPlayer();
// Draw particles
drawParticles(camX, camY);
// Vignette effect
const gradient = ctx.createRadialGradient(canvas.width/2, canvas.height/2, canvas.width*0.3, canvas.width/2, canvas.height/2, canvas.width*0.7);
gradient.addColorStop(0, 'transparent');
gradient.addColorStop(1, 'rgba(0,0,0,0.4)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Minimap
const mmW = 160, mmH = 120;
const mmX = canvas.width - mmW - 16;
const mmY = canvas.height - mmH - 36;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(mmX-2, mmY-2, mmW+4, mmH+4);
ctx.strokeStyle = 'rgba(232,213,183,0.3)';
ctx.strokeRect(mmX-2, mmY-2, mmW+4, mmH+4);
const scaleX = mmW / (game.world.W * TILE);
const scaleY = mmH / (game.world.H * TILE);
// Draw explored area dots
for (const e of game.enemies) {
if (!e.alive) continue;
ctx.fillStyle = '#f44';
ctx.fillRect(mmX + e.x*scaleX, mmY + e.y*scaleY, 2, 2);
}
// Player dot
ctx.fillStyle = '#4f4';
ctx.beginPath();
ctx.arc(mmX + game.player.x*scaleX, mmY + game.player.y*scaleY, 3, 0, Math.PI*2);
ctx.fill();
// Progress bar (subtle)
const pbW = 120;
const pbX = canvas.width/2 - pbW/2;
const pbY = canvas.height - 24;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(pbX-2, pbY-2, pbW+4, 10);
ctx.fillStyle = '#3a6';
ctx.fillRect(pbX, pbY, pbW*(game.gameProgress/100), 6);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '8px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`Progress: ${Math.floor(game.gameProgress)}%`, canvas.width/2, pbY+5);
requestAnimationFrame(gameLoop);
}
// --- Check for existing save on load ---
window.addEventListener('load', () => {
if (localStorage.getItem('realmOfEchoes')) {
document.getElementById('load-btn').style.display = '';
}
});
// Handle resize
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
});
requestAnimationFrame(gameLoop);
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"full","sessionId":"0082b24b-5e61-45ec-b50c-ce6563287d67","tokensIn":501832,"tokensOut":23096,"tokensTotal":524928,"turns":16,"toolCalls":15,"failedToolCalls":1,"timestamp":"2026-07-18T22:10:46.488Z"} -->