RPG
Qwen 3.6 27B Fable Fusion 711 Uncensored Heretic NM DAU NEO MAX MTP GGUF · full

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 73,358 bytes · SHA-256 75b0ea71dca1
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Realm of Echoes - Action RPG</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
overflow: hidden;
font-family: 'Courier New', monospace;
}
#gameCanvas {
border: 2px solid #333;
image-rendering: pixelated;
}
</style>
</head>
<body>
<canvas id="gameCanvas" width="960" height="640"></canvas>
<script>
// ============================================================
// REALM OF ECHOES - Complete Action-Adventure RPG
// ============================================================
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const TILE_SIZE = 32;
const WORLD_W = 100;
const WORLD_H = 80;
// ============================================================
// AUDIO SYSTEM (Web Audio API - all synthesized)
// ============================================================
const AudioCtx = window.AudioContext || window.webkitAudioContext;
let audioCtx = null;
function initAudio() {
if (!audioCtx) audioCtx = new AudioCtx();
}
function playTone(freq, duration, type = 'square', vol = 0.15, slide = 0) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
if (slide) osc.frequency.linearRampToValueAtTime(freq + slide, audioCtx.currentTime + duration);
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
function playNoise(duration, vol = 0.1, filterFreq = 3000) {
if (!audioCtx) return;
const bufferSize = audioCtx.sampleRate * duration;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const source = audioCtx.createBufferSource();
source.buffer = buffer;
const gain = audioCtx.createGain();
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = filterFreq;
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(audioCtx.destination);
source.start();
}
const SFX = {
walk: () => {
playNoise(0.06, 0.04, 800);
},
attack: () => {
playNoise(0.12, 0.12, 2000);
playTone(200, 0.1, 'sawtooth', 0.08, -100);
},
attack2: () => {
playNoise(0.15, 0.15, 2500);
playTone(250, 0.12, 'sawtooth', 0.1, -150);
},
attack3: () => {
playNoise(0.2, 0.18, 3500);
playTone(300, 0.15, 'sawtooth', 0.12, -200);
},
attackBow: () => {
playTone(400, 0.15, 'triangle', 0.1, 200);
playNoise(0.08, 0.08, 1500);
},
hit: () => {
playTone(150, 0.15, 'square', 0.12, -80);
playNoise(0.08, 0.06, 500);
},
enemyHit: () => {
playTone(200, 0.1, 'square', 0.1, -100);
},
enemyDie: () => {
playTone(300, 0.3, 'sawtooth', 0.1, -200);
playNoise(0.15, 0.08, 1000);
},
pickup: () => {
playTone(523, 0.08, 'sine', 0.1);
setTimeout(() => playTone(659, 0.08, 'sine', 0.1), 60);
setTimeout(() => playTone(784, 0.12, 'sine', 0.12), 120);
},
coin: () => {
playTone(880, 0.06, 'square', 0.08);
setTimeout(() => playTone(1175, 0.1, 'square', 0.1), 50);
},
heal: () => {
playTone(440, 0.15, 'sine', 0.1);
setTimeout(() => playTone(554, 0.15, 'sine', 0.1), 100);
setTimeout(() => playTone(660, 0.2, 'sine', 0.12), 200);
},
shopOpen: () => {
playTone(330, 0.1, 'sine', 0.08);
setTimeout(() => playTone(392, 0.1, 'sine', 0.08), 80);
setTimeout(() => playTone(440, 0.15, 'sine', 0.1), 160);
},
shopBuy: () => {
playTone(523, 0.1, 'sine', 0.1);
setTimeout(() => playTone(659, 0.1, 'sine', 0.1), 80);
setTimeout(() => playTone(784, 0.15, 'sine', 0.12), 160);
setTimeout(() => playTone(1047, 0.2, 'sine', 0.1), 240);
},
save: () => {
playTone(262, 0.15, 'sine', 0.1);
setTimeout(() => playTone(330, 0.15, 'sine', 0.1), 120);
setTimeout(() => playTone(392, 0.15, 'sine', 0.1), 240);
setTimeout(() => playTone(523, 0.3, 'sine', 0.12), 360);
},
door: () => {
playTone(200, 0.2, 'square', 0.08, -50);
},
weaponUp: () => {
playTone(440, 0.15, 'square', 0.1);
setTimeout(() => playTone(554, 0.15, 'square', 0.1), 100);
setTimeout(() => playTone(660, 0.15, 'square', 0.1), 200);
setTimeout(() => playTone(880, 0.3, 'square', 0.12), 300);
},
damage: () => {
playTone(120, 0.2, 'sawtooth', 0.12, -50);
playNoise(0.1, 0.08, 400);
},
bossWarning: () => {
playTone(150, 0.3, 'sawtooth', 0.15);
setTimeout(() => playTone(140, 0.3, 'sawtooth', 0.15), 250);
setTimeout(() => playTone(130, 0.4, 'sawtooth', 0.18), 500);
}
};
// ============================================================
// PARTICLE SYSTEM
// ============================================================
let particles = [];
function spawnParticle(x, y, color, count = 5, spread = 2, life = 30, size = 2) {
for (let i = 0; i < count; i++) {
particles.push({
x, y,
vx: (Math.random() - 0.5) * spread,
vy: (Math.random() - 0.5) * spread - 1,
color,
life: life + Math.random() * 15,
maxLife: life + 15,
size: size + Math.random() * size
});
}
}
function spawnImpactParticles(x, y, type) {
if (type === 'melee') {
spawnParticle(x, y, '#ffaa00', 8, 3, 20, 3);
spawnParticle(x, y, '#ff4400', 4, 2, 15, 2);
} else if (type === 'bow') {
spawnParticle(x, y, '#00ccff', 6, 2, 15, 2);
} else if (type === 'gold') {
spawnParticle(x, y, '#ffd700', 10, 3, 25, 2);
} else if (type === 'heal') {
spawnParticle(x, y, '#44ff44', 8, 2, 30, 2);
} else if (type === 'save') {
spawnParticle(x, y, '#cc88ff', 15, 3, 40, 3);
} else if (type === 'weapon') {
spawnParticle(x, y, '#ffffff', 12, 4, 35, 3);
spawnParticle(x, y, '#ffaa00', 8, 3, 30, 2);
}
}
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(camX, camY) {
for (const p of particles) {
const alpha = p.life / p.maxLife;
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.fillRect(p.x - camX, p.y - camY, p.size, p.size);
}
ctx.globalAlpha = 1;
}
// ============================================================
// MAP SYSTEM
// ============================================================
// Tile types: 0=grass, 1=wall, 2=water, 3=stone, 4=wood, 5=door, 6=sand, 7=forest, 8=snow
const map = [];
const mapDecorations = [];
function generateMap() {
// Initialize with grass
for (let y = 0; y < WORLD_H; y++) {
map[y] = [];
for (let x = 0; x < WORLD_W; x++) {
map[y][x] = 0;
}
}
// Border walls
for (let x = 0; x < WORLD_W; x++) {
map[0][x] = 1;
map[WORLD_H - 1][x] = 1;
}
for (let y = 0; y < WORLD_H; y++) {
map[y][0] = 1;
map[y][WORLD_W - 1] = 1;
}
// Village area (center-ish)
buildVillage(45, 35);
// Forest region (north)
buildForest(20, 5, 30, 15);
// Swamp area (west)
buildSwamp(5, 30, 18, 15);
// Mountain/stone path to dungeons (east)
buildMountainPath(55, 35);
// Cave dungeon (far east)
buildCaveDungeon(75, 30);
// Ice cave (far north-east)
buildIceCave(65, 10);
// Secret area south
buildSecretArea(40, 60);
// Boss arena (deep cave)
buildBossArena(88, 40);
// Add decorative elements
addDecorations();
}
function buildVillage(cx, cy) {
// Clear area
for (let y = cy - 6; y < cy + 6; y++) {
for (let x = cx - 8; x < cx + 8; x++) {
if (inBounds(x, y)) map[y][x] = 3;
}
}
// Houses
buildHouse(cx - 5, cy - 3);
buildHouse(cx + 4, cy - 3);
buildShopHouse(cx + 3, cy + 3);
// Save point
buildSavePoint(cx - 5, cy + 3);
// Path from village
for (let x = cx + 8; x < 55; x++) {
if (inBounds(x, cy)) map[cy][x] = 6;
}
for (let x = cx - 8; x > 20; x--) {
if (inBounds(x, cy)) map[cy][x] = 0;
}
for (let y = cy - 6; y > 18; y--) {
if (inBounds(cx, y)) map[y][cx] = 7;
}
}
function buildHouse(x, y) {
for (let dy = -2; dy <= 0; dy++) {
for (let dx = -2; dx <= 2; dx++) {
if (inBounds(x + dx, y + dy)) {
if (dy === -2 || dx === -2 || dx === 2) {
map[y + dy][x + dx] = 4;
} else if (dy === 0 && (dx === -1 || dx === 0 || dx === 1)) {
map[y + dy][x + dx] = 4;
} else {
map[y + dy][x + dx] = 3;
}
}
}
}
}
function buildShopHouse(x, y) {
for (let dy = -2; dy <= 0; dy++) {
for (let dx = -2; dx <= 2; dx++) {
if (inBounds(x + dx, y + dy)) {
if (dy === -2 || dx === -2 || dx === 2) {
map[y + dy][x + dx] = 4;
} else if (dy === 0 && (dx === -1 || dx === 0 || dx === 1)) {
map[y + dy][x + dx] = 4;
} else {
map[y + dy][x + dx] = 3;
}
}
}
}
// Shop entrance
map[y][x] = 5;
}
function buildSavePoint(x, y) {
// Fountain/save crystal
map[y][x] = 3;
mapDecorations.push({ type: 'saveCrystal', x, y });
}
function buildForest(x, y, w, h) {
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
if (inBounds(x + dx, y + dy) && Math.random() > 0.4) {
map[y + dy][x + dx] = 7;
}
}
}
// Clear path through forest
for (let dx = 2; dx < w - 2; dx++) {
if (inBounds(x + dx, y + h / 2 | 0)) {
map[y + (h / 2 | 0)][x + dx] = 0;
}
}
}
function buildSwamp(x, y, w, h) {
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
if (inBounds(x + dx, y + dy) && Math.random() > 0.5) {
map[y + dy][x + dx] = 2;
} else if (inBounds(x + dx, y + dy)) {
map[y + dy][x + dx] = 0;
}
}
}
}
function buildMountainPath(x, y) {
for (let dx = 0; dx < 25; dx++) {
const ty = y + Math.floor(Math.random() * 3) - 1;
if (inBounds(x + dx, ty)) {
map[ty][x + dx] = 3;
}
if (inBounds(x + dx, ty + 1)) map[ty + 1][x + dx] = 1;
}
}
function buildCaveDungeon(x, y) {
// Outer walls
for (let dy = -8; dy <= 8; dy++) {
for (let dx = -10; dx <= 5; dx++) {
if (inBounds(x + dx, y + dy)) {
if (Math.abs(dx) === 10 || Math.abs(dy) === 8) {
map[y + dy][x + dx] = 1;
} else if (Math.random() > 0.85) {
map[y + dy][x + dx] = 1;
} else {
map[y + dy][x + dx] = 3;
}
}
}
}
// Door entrance
map[y][x - 10] = 5;
}
function buildIceCave(x, y) {
for (let dy = -6; dy <= 6; dy++) {
for (let dx = -8; dx <= 8; dx++) {
if (inBounds(x + dx, y + dy)) {
if (Math.abs(dx) === 8 || Math.abs(dy) === 6) {
map[y + dy][x + dx] = 1;
} else if (Math.random() > 0.9) {
map[y + dy][x + dx] = 1;
} else {
map[y + dy][x + dx] = 8;
}
}
}
}
// Path from mountain
for (let dx = 55; dx < x - 8; dx++) {
if (inBounds(dx, 18)) map[18][dx] = 8;
}
}
function buildSecretArea(x, y) {
for (let dy = -5; dy <= 5; dy++) {
for (let dx = -7; dx <= 7; dx++) {
if (inBounds(x + dx, y + dy)) {
if (Math.abs(dx) === 7 || Math.abs(dy) === 5) {
map[y + dy][x + dx] = 1;
} else {
map[y + dy][x + dx] = 3;
}
}
}
}
// Hidden door
map[y + 4][x - 3] = 5;
}
function buildBossArena(x, y) {
for (let dy = -8; dy <= 8; dy++) {
for (let dx = -10; dx <= 10; dx++) {
if (inBounds(x + dx, y + dy)) {
if (Math.abs(dx) === 10 || Math.abs(dy) === 8) {
map[y + dy][x + dx] = 1;
} else {
map[y + dy][x + dx] = 3;
}
}
}
}
// Door
map[y][x - 10] = 5;
// Boss summoning stone
mapDecorations.push({ type: 'bossStone', x: x, y: y });
}
function addDecorations() {
// Trees in forests
for (let y = 0; y < WORLD_H; y++) {
for (let x = 0; x < WORLD_W; x++) {
if (map[y][x] === 7 && Math.random() > 0.7) {
mapDecorations.push({ type: 'tree', x, y });
}
}
}
// Flowers on grass
for (let y = 0; y < WORLD_H; y++) {
for (let x = 0; x < WORLD_W; x++) {
if (map[y][x] === 0 && Math.random() > 0.95) {
mapDecorations.push({ type: 'flower', x, y, color: ['#ff66aa', '#ffcc00', '#66ccff'][Math.floor(Math.random() * 3)] });
}
}
}
// Rocks
for (let y = 0; y < WORLD_H; y++) {
for (let x = 0; x < WORLD_W; x++) {
if ((map[y][x] === 0 || map[y][x] === 3 || map[y][x] === 8) && Math.random() > 0.97) {
mapDecorations.push({ type: 'rock', x, y });
}
}
}
}
function inBounds(x, y) {
return x >= 0 && x < WORLD_W && y >= 0 && y < WORLD_H;
}
function isWalkable(x, y) {
if (!inBounds(x, y)) return false;
const t = map[y][x];
return t !== 1 && t !== 2; // Not wall or water
}
function getTileColor(tile) {
switch (tile) {
case 0: return ['#3a7d2c', '#458932', '#3d852f'][Math.floor(Math.random() * 3)];
case 1: return ['#555566'];
case 2: return ['#2255aa'];
case 3: return ['#888888'];
case 4: return ['#664422'];
case 5: return ['#885522'];
case 6: return ['#c2a84a'];
case 7: return ['#2d6b1f'];
case 8: return ['#ddeeff'];
default: return '#3a7d2c';
}
}
// ============================================================
// WORLD ITEMS, ENEMIES, AND INTERACTABLES
// ============================================================
let worldItems = [];
let enemies = [];
let doors = [];
let interactables = [];
function spawnWorld() {
worldItems = [];
enemies = [];
doors = [];
interactables = [];
// Find all doors
for (let y = 0; y < WORLD_H; y++) {
for (let x = 0; x < WORLD_W; x++) {
if (map[y][x] === 5) {
doors.push({ x, y, open: false });
}
}
}
// Interactables
// Shop NPC
interactables.push({ type: 'shopkeeper', x: 48, y: 38, name: 'Merchant Alder' });
// Blacksmith (weapon upgrade quest)
interactables.push({ type: 'blacksmith', x: 40, y: 32, name: 'Blacksmith Irona' });
// World items - carefully placed for progression
// Health container 1 - in forest after clearing enemies
worldItems.push({ type: 'healthContainer', x: 33, y: 10, collected: false });
// Weapon upgrade 1 (Iron Sword) - in swamp area
worldItems.push({ type: 'weaponIron', x: 10, y: 38, collected: false });
// Health container 2 - in cave dungeon
worldItems.push({ type: 'healthContainer', x: 78, y: 32, collected: false });
// Ranged weapon (Bow) - mid-game, in ice cave
worldItems.push({ type: 'rangedBow', x: 68, y: 13, collected: false });
// Weapon upgrade 2 (Steel Blade) - secret area
worldItems.push({ type: 'weaponSteel', x: 43, y: 62, collected: false });
// Health container 3 - near boss arena entrance
worldItems.push({ type: 'healthContainer', x: 83, y: 38, collected: false });
// Consumables and accessories
worldItems.push({ type: 'healthPotion', x: 35, y: 15, collected: false, qty: 2 });
worldItems.push({ type: 'healthPotion', x: 80, y: 28, collected: false, qty: 2 });
worldItems.push({ type: 'speedBoots', x: 62, y: 16, collected: false });
worldItems.push({ type: 'shieldRing', x: 70, y: 20, collected: false });
// Coin pickups scattered throughout
for (let i = 0; i < 40; i++) {
let x, y;
do {
x = 5 + Math.floor(Math.random() * 90);
y = 5 + Math.floor(Math.random() * 70);
} while (!isWalkable(x, y));
worldItems.push({ type: 'coin', x, y, collected: false, amount: 5 + Math.floor(Math.random() * 10) });
}
// Bigger coin pickups
for (let i = 0; i < 10; i++) {
let x, y;
do {
x = 5 + Math.floor(Math.random() * 90);
y = 5 + Math.floor(Math.random() * 70);
} while (!isWalkable(x, y));
worldItems.push({ type: 'coin', x, y, collected: false, amount: 25 + Math.floor(Math.random() * 20) });
}
// Enemies by area
// Forest enemies
for (let i = 0; i < 8; i++) {
let x, y;
do { x = 22 + Math.floor(Math.random() * 24); y = 7 + Math.floor(Math.random() * 11); }
while (!isWalkable(x, y));
enemies.push(createEnemy('slime', x, y, 10 + Math.random() * 5));
}
for (let i = 0; i < 4; i++) {
let x, y;
do { x = 22 + Math.floor(Math.random() * 24); y = 7 + Math.floor(Math.random() * 11); }
while (!isWalkable(x, y));
enemies.push(createEnemy('wolf', x, y, 18 + Math.random() * 5));
}
// Swamp enemies
for (let i = 0; i < 6; i++) {
let x, y;
do { x = 7 + Math.floor(Math.random() * 13); y = 32 + Math.floor(Math.random() * 11); }
while (!isWalkable(x, y));
enemies.push(createEnemy('slime', x, y, 12 + Math.random() * 5));
}
for (let i = 0; i < 4; i++) {
let x, y;
do { x = 7 + Math.floor(Math.random() * 13); y = 32 + Math.floor(Math.random() * 11); }
while (!isWalkable(x, y));
enemies.push(createEnemy('skeleton', x, y, 20 + Math.random() * 5));
}
// Mountain enemies
for (let i = 0; i < 6; i++) {
let x, y;
do { x = 56 + Math.floor(Math.random() * 15); y = 33 + Math.floor(Math.random() * 5); }
while (!isWalkable(x, y));
enemies.push(createEnemy('wolf', x, y, 20 + Math.random() * 8));
}
for (let i = 0; i < 4; i++) {
let x, y;
do { x = 56 + Math.floor(Math.random() * 15); y = 33 + Math.floor(Math.random() * 5); }
while (!isWalkable(x, y));
enemies.push(createEnemy('goblin', x, y, 15 + Math.random() * 5));
}
// Cave dungeon enemies
for (let i = 0; i < 10; i++) {
let x, y;
do { x = 67 + Math.floor(Math.random() * 18); y = 24 + Math.floor(Math.random() * 16); }
while (!isWalkable(x, y));
enemies.push(createEnemy('skeleton', x, y, 25 + Math.random() * 10));
}
for (let i = 0; i < 5; i++) {
let x, y;
do { x = 67 + Math.floor(Math.random() * 18); y = 24 + Math.floor(Math.random() * 16); }
while (!isWalkable(x, y));
enemies.push(createEnemy('goblin', x, y, 22 + Math.random() * 8));
}
// Mini boss guarding boss entrance
enemies.push(createEnemy('bossGuard', 85, 38, 80));
// Ice cave enemies
for (let i = 0; i < 8; i++) {
let x, y;
do { x = 58 + Math.floor(Math.random() * 16); y = 10 + Math.floor(Math.random() * 10); }
while (!isWalkable(x, y));
enemies.push(createEnemy('iceSlime', x, y, 20 + Math.random() * 8));
}
for (let i = 0; i < 4; i++) {
let x, y;
do { x = 58 + Math.floor(Math.random() * 16); y = 10 + Math.floor(Math.random() * 10); }
while (!isWalkable(x, y));
enemies.push(createEnemy('frostWolf', x, y, 30 + Math.random() * 10));
}
// Secret area enemies
for (let i = 0; i < 6; i++) {
let x, y;
do { x = 34 + Math.floor(Math.random() * 12); y = 56 + Math.floor(Math.random() * 10); }
while (!isWalkable(x, y));
enemies.push(createEnemy('goblin', x, y, 28 + Math.random() * 8));
}
}
function createEnemy(type, x, y, hp) {
const stats = {
slime: { hp, speed: 0.4, damage: 3, color: '#44aa44', size: 16, ai: 'chase' },
wolf: { hp, speed: 1.2, damage: 5, color: '#666666', size: 18, ai: 'aggressive' },
skeleton: { hp, speed: 0.8, damage: 6, color: '#ddddcc', size: 18, ai: 'chase' },
goblin: { hp, speed: 1.0, damage: 4, color: '#66aa44', size: 16, ai: 'aggressive' },
iceSlime: { hp, speed: 0.5, damage: 5, color: '#44ccff', size: 16, ai: 'chase' },
frostWolf: { hp, speed: 1.3, damage: 7, color: '#88bbff', size: 20, ai: 'aggressive' },
bossGuard: { hp: 80, speed: 0.9, damage: 10, color: '#cc4444', size: 28, ai: 'aggressive' }
};
const s = stats[type] || stats.slime;
return {
type, x: x * TILE_SIZE + TILE_SIZE / 2, y: y * TILE_SIZE + TILE_SIZE / 2,
hp, maxHp: hp,
speed: s.speed,
damage: s.damage,
color: s.color,
size: s.size,
ai: s.ai || 'chase',
state: 'idle',
animTimer: 0,
attackCooldown: 0,
knockbackX: 0,
knockbackY: 0,
flashTimer: 0
};
}
// ============================================================
// PLAYER
// ============================================================
const player = {
x: 45 * TILE_SIZE + TILE_SIZE / 2,
y: 37 * TILE_SIZE + TILE_SIZE / 2,
speed: 2.5,
hp: 6,
maxHp: 6,
hpContainers: 3,
maxContainers: 5,
money: 20,
// Equipment
weapon: 'bronze', // bronze -> iron -> steel
weaponLevel: 0,
hasBow: false,
bowAmmo: 0,
// Accessories
speedBoots: false,
shieldRing: false,
// Inventory (consumables)
healthPotions: 0,
// Combat state
attacking: false,
attackTimer: 0,
attackCooldown: 0,
facing: 'down',
hitTimer: 0,
invincible: 0,
// Animation
animTimer: 0,
walkFrame: 0,
// Camera
camX: 0,
camY: 0
};
// ============================================================
// SHOP SYSTEM
// ============================================================
const shopItems = [
{ name: 'Health Potion', type: 'healthPotion', price: 15, desc: 'Restores 4 HP' },
{ name: 'Bow Arrow (5)', type: 'arrows', price: 10, desc: '+5 arrows for bow' },
{ name: 'Minor Heal', type: 'quickHeal', price: 25, desc: 'Instantly heal 6 HP' }
];
let shopOpen = false;
let shopSelection = 0;
let gameMessage = '';
let messageTimer = 0;
let paused = false;
function openShop() {
shopOpen = true;
shopSelection = 0;
paused = true;
SFX.shopOpen();
}
function closeShop() {
shopOpen = false;
paused = false;
}
function buyItem(index) {
const item = shopItems[index];
if (player.money >= item.price) {
player.money -= item.price;
SFX.shopBuy();
if (item.type === 'healthPotion') {
player.healthPotions++;
showMessage('Bought Health Potion!');
} else if (item.type === 'arrows') {
player.bowAmmo += 5;
showMessage('Bought 5 Arrows!');
} else if (item.type === 'quickHeal') {
player.hp = Math.min(player.hp + 6, player.maxHp);
spawnImpactParticles(player.x, player.y, 'heal');
showMessage('Healed 6 HP!');
}
} else {
showMessage('Not enough gold!');
}
}
function showMessage(text) {
gameMessage = text;
messageTimer = 120;
}
// ============================================================
// SAVE SYSTEM
// ============================================================
const SAVE_KEY = 'realmOfEchoes_save';
function saveGame() {
const saveData = {
player: { ...player },
enemies: enemies.map(e => ({ ...e })),
worldItems: worldItems.map(i => ({ ...i })),
doors: doors.map(d => ({ ...d })),
timestamp: Date.now()
};
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
showMessage('Game Saved!');
SFX.save();
}
function loadGame() {
const data = localStorage.getItem(SAVE_KEY);
if (data) {
try {
const save = JSON.parse(data);
Object.assign(player, save.player);
enemies = save.enemies || [];
worldItems = save.worldItems || [];
doors = save.doors || [];
showMessage('Game Loaded!');
return true;
} catch (e) {
console.error('Failed to load save:', e);
return false;
}
}
return false;
}
function clearSave() {
localStorage.removeItem(SAVE_KEY);
showMessage('Save Cleared!');
}
// ============================================================
// INPUT HANDLING
// ============================================================
const keys = {};
document.addEventListener('keydown', (e) => {
keys[e.key.toLowerCase()] = true;
keys[e.code] = true;
initAudio();
if (shopOpen) {
if (e.key === 'Escape') { closeShop(); return; }
if (e.key === 'ArrowUp' || e.key === 'w') { shopSelection = Math.max(0, shopSelection - 1); }
if (e.key === 'ArrowDown' || e.key === 's') { shopSelection = Math.min(shopItems.length - 1, shopSelection + 1); }
if (e.key === 'Enter' || e.key === 'e') { buyItem(shopSelection); }
return;
}
if (e.key === 'Escape') {
showPauseMenu();
return;
}
if (e.key.toLowerCase() === 'f' && !paused) {
handleInteraction();
}
if (e.key.toLowerCase() === 'u' && !paused) {
useHealthPotion();
}
if (e.key === '1') {
// Switch to melee
if (player.hasBow) {
player.weapon = player.weaponLevel === 0 ? 'bronze' : player.weaponLevel === 1 ? 'iron' : 'steel';
}
}
if (e.key === '2' && player.hasBow) {
player.weapon = 'bow';
}
// Attack with Z / Space / Click area
if ((e.key.toLowerCase() === 'z' || e.key === 'Space') && !paused) {
performAttack();
e.preventDefault();
}
// Auto-save with S (only near save crystals)
if (e.key.toLowerCase() === 's' && !paused && !shopOpen) {
// S is also movement, save handled via F at crystals
}
});
document.addEventListener('keyup', (e) => {
keys[e.key.toLowerCase()] = false;
keys[e.code] = false;
});
let mouseDown = false;
canvas.addEventListener('mousedown', (e) => {
mouseDown = true;
initAudio();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
if (shopOpen) {
const itemH = 40;
const startY = 280;
for (let i = 0; i < shopItems.length; i++) {
if (my >= startY + i * itemH && my < startY + (i + 1) * itemH && mx >= 150 && mx < 750) {
shopSelection = i;
buyItem(i);
break;
}
}
if (my > 460 && mx > 300 && mx < 460) { closeShop(); }
return;
}
if (!paused) {
// Move towards click direction and attack
const wx = mx + player.camX;
const wy = my + player.camY;
const dx = wx - player.x;
const dy = wy - player.y;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
player.facing = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
}
performAttack();
}
});
canvas.addEventListener('mouseup', () => { mouseDown = false; });
// ============================================================
// GAME STATE MANAGEMENT
// ============================================================
let gameState = 'title'; // title, playing, pauseMenu, gameover, victory
let titleSelection = 0;
function showPauseMenu() {
if (gameState === 'playing') {
gameState = 'pauseMenu';
} else if (gameState === 'pauseMenu') {
gameState = 'playing';
}
}
function startNewGame() {
generateMap();
spawnWorld();
gameState = 'playing';
showMessage('Welcome to the Realm of Echoes!');
}
// ============================================================
// PLAYER UPDATE
// ============================================================
let walkTimer = 0;
function updatePlayer() {
if (paused || gameState !== 'playing') return;
const speed = player.speed * (player.speedBoots ? 1.3 : 1);
// Movement
let dx = 0, dy = 0;
if (keys['arrowup'] || keys['w']) { dy = -speed; player.facing = 'up'; }
if (keys['arrowdown'] || keys['s']) { dy = speed; player.facing = 'down'; }
if (keys['arrowleft'] || keys['a']) { dx = -speed; player.facing = 'left'; }
if (keys['arrowright'] || keys['d']) { dx = speed; player.facing = 'right'; }
if (dx !== 0 || dy !== 0) {
walkTimer++;
if (walkTimer > 10) {
walkTimer = 0;
SFX.walk();
player.walkFrame = (player.walkFrame + 1) % 4;
}
// Diagonal normalization
if (dx !== 0 && dy !== 0) {
dx *= 0.707;
dy *= 0.707;
}
// Try moving X
if (dx !== 0) {
const nx = player.x + dx;
const tileX = Math.floor(nx / TILE_SIZE);
const tileY = Math.floor(player.y / TILE_SIZE);
if (isWalkable(tileX, tileY)) {
player.x = nx;
}
}
// Try moving Y
if (dy !== 0) {
const ny = player.y + dy;
const tileX = Math.floor(player.x / TILE_SIZE);
const tileY = Math.floor(ny / TILE_SIZE);
if (isWalkable(tileX, tileY)) {
player.y = ny;
}
}
} else {
player.walkFrame = 0;
}
// Attack timer
if (player.attackTimer > 0) player.attackTimer--;
if (player.attackCooldown > 0) player.attackCooldown--;
if (player.hitTimer > 0) player.hitTimer--;
if (player.invincible > 0) player.invincible--;
// Animation
player.animTimer += 0.1;
// Camera follow
player.camX = player.x - canvas.width / 2;
player.camY = player.y - canvas.height / 2;
// Clamp camera
player.camX = Math.max(0, Math.min(player.camX, WORLD_W * TILE_SIZE - canvas.width));
player.camY = Math.max(0, Math.min(player.camY, WORLD_H * TILE_SIZE - canvas.height));
// Check world items
checkItemPickup();
// Check enemy combat
checkEnemyCombat();
// Message timer
if (messageTimer > 0) messageTimer--;
}
function performAttack() {
if (player.attackCooldown > 0 || paused) return;
const weaponStats = getWeaponStats();
player.attacking = true;
player.attackTimer = weaponStats.attackTime;
player.attackCooldown = weaponStats.cooldown;
if (weaponStats.type === 'melee') {
SFX[weaponStats.sfx] || SFX.attack();
const range = weaponStats.range;
const damage = weaponStats.damage;
const knockback = weaponStats.knockback;
// Attack box based on facing
const ax = player.x + (player.facing === 'right' ? range : player.facing === 'left' ? -range : 0);
const ay = player.y + (player.facing === 'down' ? range : player.facing === 'up' ? -range : 0);
// Spawn hit particles
spawnImpactParticles(ax, ay, 'melee');
// Check enemies - hit box centered on attack point with generous radius
for (const enemy of enemies) {
if (enemy.hp <= 0) continue;
const dist = Math.hypot(enemy.x - ax, enemy.y - ay);
if (dist < range + enemy.size / 2) {
let dmg = damage;
if (weaponStats.pierce && Math.random() < weaponStats.pierceChance) {
dmg *= 1.5;
}
damageEnemy(enemy, dmg, knockback);
}
}
} else if (weaponStats.type === 'ranged') {
SFX.attackBow();
if (player.bowAmmo > 0) {
player.bowAmmo--;
fireArrow(weaponStats);
} else {
showMessage('No arrows! Visit the shop.');
}
}
}
function fireArrow(stats) {
// Simple arrow: instant hit toward facing direction
const dirX = player.facing === 'right' ? 1 : player.facing === 'left' ? -1 : 0;
const dirY = player.facing === 'down' ? 1 : player.facing === 'up' ? -1 : 0;
// Check along line
let bx = player.x;
let by = player.y;
const step = 8;
for (let i = 0; i < stats.range / step; i++) {
bx += dirX * step;
by += dirY * step;
// Visual trail
spawnParticle(bx, by, '#ffff88', 1, 0.5, 10, 2);
for (const enemy of enemies) {
if (enemy.hp <= 0) continue;
const dist = Math.hypot(enemy.x - bx, enemy.y - by);
if (dist < enemy.size) {
spawnImpactParticles(bx, by, 'bow');
damageEnemy(enemy, stats.damage, 3);
return;
}
}
}
}
function getWeaponStats() {
if (player.weapon === 'bow') {
return { type: 'ranged', damage: 8, range: 120, cooldown: 25, attackTime: 10, sfx: 'attackBow' };
}
switch (player.weapon) {
case 'bronze':
return { type: 'melee', damage: 4, range: 30, cooldown: 20, attackTime: 8, knockback: 4, sfx: 'attack' };
case 'iron':
return { type: 'melee', damage: 7, range: 35, cooldown: 18, attackTime: 10, knockback: 6, sfx: 'attack2' };
case 'steel':
return { type: 'melee', damage: 11, range: 40, cooldown: 16, attackTime: 12, knockback: 8, pierce: true, pierceChance: 0.3, sfx: 'attack3' };
default:
return { type: 'melee', damage: 4, range: 30, cooldown: 20, attackTime: 8, knockback: 4 };
}
}
function damageEnemy(enemy, damage, knockback) {
SFX.enemyHit();
enemy.hp -= damage;
enemy.flashTimer = 8;
enemy.state = 'hurt';
// Knockback
const dx = enemy.x - player.x;
const dy = enemy.y - player.y;
const dist = Math.hypot(dx, dy) || 1;
enemy.knockbackX = (dx / dist) * knockback;
enemy.knockbackY = (dy / dist) * knockback;
// Damage number
spawnParticle(enemy.x, enemy.y - 20, '#ffff00', 1, 0, 20, 4);
if (enemy.hp <= 0) {
SFX.enemyDie();
spawnImpactParticles(enemy.x, enemy.y, 'gold');
// Drop coins
const coinAmount = 5 + Math.floor(Math.random() * 10);
player.money += coinAmount;
// Small chance for health potion drop
if (Math.random() < 0.1) {
worldItems.push({ type: 'healthPotion', x: enemy.x / TILE_SIZE | 0, y: enemy.y / TILE_SIZE | 0, collected: false });
}
}
}
function damagePlayer(amount) {
if (player.invincible > 0) return;
let dmg = amount;
if (player.shieldRing) dmg = Math.floor(dmg * 0.75);
player.hp -= dmg;
player.hitTimer = 15;
player.invincible = 30;
SFX.damage();
spawnImpactParticles(player.x, player.y, 'melee');
if (player.hp <= 0) {
player.hp = 0;
gameState = 'gameover';
}
}
function handleInteraction() {
const px = player.x / TILE_SIZE | 0;
const py = player.y / TILE_SIZE | 0;
// Check save crystals
for (const dec of mapDecorations) {
if (dec.type === 'saveCrystal' && Math.abs(dec.x - px) <= 1 && Math.abs(dec.y - py) <= 1) {
spawnImpactParticles(dec.x * TILE_SIZE + 16, dec.y * TILE_SIZE + 16, 'save');
saveGame();
return;
}
}
// Check NPCs
for (const npc of interactables) {
const dist = Math.hypot(npc.x * TILE_SIZE + 16 - player.x, npc.y * TILE_SIZE + 16 - player.y);
if (dist < 48) {
if (npc.type === 'shopkeeper') {
openShop();
return;
}
if (npc.type === 'blacksmith') {
handleBlacksmithDialogue();
return;
}
}
}
// Check doors
for (const door of doors) {
const dist = Math.hypot(door.x * TILE_SIZE + 16 - player.x, door.y * TILE_SIZE + 16 - player.y);
if (dist < 48) {
SFX.door();
door.open = !door.open;
return;
}
}
}
function handleBlacksmithDialogue() {
SFX.shopOpen();
if (player.weaponLevel === 0) {
showMessage('Blacksmith: "Find the Iron Sword in the swamp to upgrade!"');
} else if (player.weaponLevel === 1) {
showMessage('Blacksmith: "The Steel Blade awaits in the hidden ruins!"');
} else {
showMessage('Blacksmith: "Your blade is legendary. No need for more upgrades!"');
}
}
function useHealthPotion() {
if (player.healthPotions > 0 && player.hp < player.maxHp) {
player.healthPotions--;
const heal = Math.min(4, player.maxHp - player.hp);
player.hp += heal;
SFX.heal();
spawnImpactParticles(player.x, player.y, 'heal');
showMessage(`Healed ${heal} HP! (${player.healthPotions} left)`);
}
}
function checkItemPickup() {
const px = player.x / TILE_SIZE | 0;
const py = player.y / TILE_SIZE | 0;
for (const item of worldItems) {
if (item.collected) continue;
if (item.x === px && item.y === py) {
item.collected = true;
const ix = item.x * TILE_SIZE + 16;
const iy = item.y * TILE_SIZE + 16;
switch (item.type) {
case 'coin':
player.money += item.amount || 10;
SFX.coin();
spawnImpactParticles(ix, iy, 'gold');
showMessage(`+${item.amount} Gold`);
break;
case 'healthPotion':
player.healthPotions += (item.qty || 1);
SFX.pickup();
spawnImpactParticles(ix, iy, 'heal');
showMessage('Health Potion acquired!');
break;
case 'healthContainer':
if (player.hpContainers < player.maxContainers) {
player.hpContainers++;
player.maxHp = player.hpContainers * 2;
player.hp = player.maxHp;
SFX.heal();
spawnImpactParticles(ix, iy, 'heal');
showMessage(`Health increased! (${player.hpContainers} containers)`);
} else {
showMessage('Already at max health containers!');
}
break;
case 'weaponIron':
player.weapon = 'iron';
player.weaponLevel = 1;
SFX.weaponUp();
spawnImpactParticles(ix, iy, 'weapon');
showMessage('Iron Sword acquired! (+damage, +range)');
break;
case 'weaponSteel':
player.weapon = 'steel';
player.weaponLevel = 2;
SFX.weaponUp();
spawnImpactParticles(ix, iy, 'weapon');
showMessage('Steel Blade acquired! (Mighty damage, piercing!)');
break;
case 'rangedBow':
player.hasBow = true;
player.bowAmmo = 10;
SFX.weaponUp();
spawnImpactParticles(ix, iy, 'weapon');
showMessage('Bow discovered! [2] to equip, [1] for sword. 10 arrows included.');
break;
case 'speedBoots':
player.speedBoots = true;
SFX.pickup();
spawnImpactParticles(ix, iy, 'weapon');
showMessage('Speed Boots! (+30% movement)');
break;
case 'shieldRing':
player.shieldRing = true;
SFX.pickup();
spawnImpactParticles(ix, iy, 'weapon');
showMessage('Shield Ring! (-25% damage taken)');
break;
}
}
}
}
function checkEnemyCombat() {
for (const enemy of enemies) {
if (enemy.hp <= 0) continue;
// Player hit enemy by bumping (very minor)
const dist = Math.hypot(enemy.x - player.x, enemy.y - player.y);
if (dist < enemy.size + 10 && dist > enemy.size + 5) {
// Check if we're overlapping
const overlap = (enemy.size + 10) - dist;
if (overlap > 0) {
const dx = enemy.x - player.x;
const dy = enemy.y - player.y;
const d = Math.hypot(dx, dy) || 1;
player.x -= (dx / d) * overlap;
player.y -= (dy / d) * overlap;
}
}
}
}
// ============================================================
// ENEMY UPDATE
// ============================================================
function updateEnemies() {
if (paused || gameState !== 'playing') return;
for (const enemy of enemies) {
if (enemy.hp <= 0) continue;
const distToPlayer = Math.hypot(player.x - enemy.x, player.y - enemy.y);
const animTimer = enemy.animTimer += 0.1;
// Knockback decay
enemy.knockbackX *= 0.8;
enemy.knockbackY *= 0.8;
if (enemy.flashTimer > 0) enemy.flashTimer--;
if (enemy.attackCooldown > 0) enemy.attackCooldown--;
// AI state
if (enemy.state === 'hurt') {
enemy.x += enemy.knockbackX;
enemy.y += enemy.knockbackY;
if (enemy.knockbackX < 0.1 && enemy.knockbackY < 0.1 && enemy.knockbackX > -0.1 && enemy.knockbackY > -0.1) {
enemy.state = 'idle';
}
} else if (distToPlayer < 200) {
// Chase / aggressive behavior
let dx = player.x - enemy.x;
let dy = player.y - enemy.y;
const d = Math.hypot(dx, dy) || 1;
dx /= d;
dy /= d;
let moveSpeed = enemy.speed;
if (enemy.ai === 'aggressive' && distToPlayer > 30) {
moveSpeed = enemy.speed * 1.2;
} else if (enemy.ai === 'chase') {
moveSpeed = enemy.speed * (distToPlayer > 100 ? 1.2 : 1.0);
}
const nx = enemy.x + dx * moveSpeed;
const ny = enemy.y + dy * moveSpeed;
if (isWalkable(Math.floor(nx / TILE_SIZE), Math.floor(enemy.y / TILE_SIZE))) {
enemy.x = nx;
}
if (isWalkable(Math.floor(enemy.x / TILE_SIZE), Math.floor(ny / TILE_SIZE))) {
enemy.y = ny;
}
// Attack player if close enough
if (distToPlayer < enemy.size + 12 && enemy.attackCooldown <= 0) {
enemy.attackCooldown = 40;
damagePlayer(enemy.damage);
}
} else {
// Wander/idle
if (Math.random() < 0.01) {
enemy.wanderDirX = (Math.random() - 0.5) * 2;
enemy.wanderDirY = (Math.random() - 0.5) * 2;
}
const wdx = enemy.wanderDirX || 0;
const wdy = enemy.wanderDirY || 0;
const w = Math.hypot(wdx, wdy) || 1;
const nx = enemy.x + (wdx / w) * enemy.speed * 0.3;
const ny = enemy.y + (wdy / w) * enemy.speed * 0.3;
if (isWalkable(Math.floor(nx / TILE_SIZE), Math.floor(enemy.y / TILE_SIZE))) {
enemy.x = nx;
}
if (isWalkable(Math.floor(enemy.x / TILE_SIZE), Math.floor(ny / TILE_SIZE))) {
enemy.y = ny;
}
enemy.state = 'idle';
}
// Keep in bounds
enemy.x = Math.max(16, Math.min(WORLD_W * TILE_SIZE - 16, enemy.x));
enemy.y = Math.max(16, Math.min(WORLD_H * TILE_SIZE - 16, enemy.y));
// Boss check
if (enemy.type === 'bossGuard' && enemy.hp <= 0 && !enemy.defeatedAnnounced) {
enemy.defeatedAnnounced = true;
SFX.bossWarning();
showMessage('The path to the Shadow Lord is open...');
// Open boss door
for (const door of doors) {
if (door.x === 78 && door.y === 38) door.open = true;
}
}
}
}
// ============================================================
// RENDERING
// ============================================================
function render() {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gameState === 'title') {
renderTitle();
return;
}
const camX = player.camX;
const camY = player.camY;
// Visible tile range
const startTileX = Math.floor(camX / TILE_SIZE);
const startTileY = Math.floor(camY / TILE_SIZE);
const endTileX = startTileX + Math.ceil(canvas.width / TILE_SIZE) + 1;
const endTileY = startTileY + Math.ceil(canvas.height / TILE_SIZE) + 1;
// Draw map
for (let y = startTileY; y < endTileY; y++) {
for (let x = startTileX; x < endTileX; x++) {
if (!inBounds(x, y)) continue;
const screenX = x * TILE_SIZE - camX;
const screenY = y * TILE_SIZE - camY;
const tile = map[y][x];
// Base tile color
ctx.fillStyle = getTileColor(tile);
ctx.fillRect(screenX, screenY, TILE_SIZE, TILE_SIZE);
// Add variation
if (tile === 0 || tile === 7) {
ctx.fillStyle = 'rgba(0,0,0,0.1)';
if ((x + y) % 3 === 0) ctx.fillRect(screenX + 8, screenY + 8, 4, 4);
}
// Tile details
if (tile === 2) {
ctx.fillStyle = 'rgba(100,180,255,0.3)';
ctx.fillRect(screenX + 2, screenY + 2 + (Math.sin(Date.now() / 500 + x) * 3) | 0, 28, 4);
}
if (tile === 8) {
ctx.fillStyle = 'rgba(200,230,255,0.4)';
if ((x * 7 + y * 3) % 5 === 0) ctx.fillRect(screenX + 10, screenY + 10, 3, 3);
}
if (tile === 5) {
// Door frame
ctx.fillStyle = '#443322';
ctx.fillRect(screenX + 4, screenY + 4, 24, 24);
}
}
}
// Draw doors overlay
for (const door of doors) {
const sx = door.x * TILE_SIZE - camX;
const sy = door.y * TILE_SIZE - camY;
if (!door.open) {
ctx.fillStyle = '#8B4513';
ctx.fillRect(sx + 6, sy + 2, 20, 28);
ctx.fillStyle = '#FFD700';
ctx.fillRect(sx + 14, sy + 14, 4, 4); // Knob
ctx.fillStyle = '#654321';
ctx.fillRect(sx + 10, sy + 6, 2, 20);
ctx.fillRect(sx + 20, sy + 6, 2, 20);
} else {
// Open door (passable, visual indicator)
ctx.fillStyle = '#3a2211';
ctx.fillRect(sx + 2, sy + 2, 6, 28);
ctx.fillRect(sx + 24, sy + 2, 6, 28);
}
}
// Draw decorations
for (const dec of mapDecorations) {
const sx = dec.x * TILE_SIZE - camX;
const sy = dec.y * TILE_SIZE - camY;
if (sx < -40 || sx > canvas.width + 40 || sy < -40 || sy > canvas.height + 40) continue;
if (dec.type === 'tree') {
// Trunk
ctx.fillStyle = '#5C4033';
ctx.fillRect(sx + 12, sy + 16, 8, 16);
// Canopy
ctx.fillStyle = '#228B22';
ctx.beginPath();
ctx.arc(sx + 16, sy + 12, 14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#32CD32';
ctx.beginPath();
ctx.arc(sx + 14, sy + 8, 10, 0, Math.PI * 2);
ctx.fill();
} else if (dec.type === 'flower') {
ctx.fillStyle = '#228B22';
ctx.fillRect(sx + 15, sy + 18, 2, 14);
ctx.fillStyle = dec.color || '#ff66aa';
ctx.beginPath();
ctx.arc(sx + 16, sy + 16, 4, 0, Math.PI * 2);
ctx.fill();
} else if (dec.type === 'rock') {
ctx.fillStyle = '#666';
ctx.beginPath();
ctx.arc(sx + 16, sy + 20, 8, Math.PI, 0);
ctx.fill();
ctx.fillStyle = '#888';
ctx.beginPath();
ctx.arc(sx + 14, sy + 18, 5, Math.PI, 0);
ctx.fill();
} else if (dec.type === 'saveCrystal') {
// Glowing crystal
const pulse = Math.sin(Date.now() / 300) * 0.3 + 0.7;
ctx.fillStyle = `rgba(180, 120, 255, ${pulse})`;
ctx.beginPath();
ctx.moveTo(sx + 16, sy + 4);
ctx.lineTo(sx + 24, sy + 16);
ctx.lineTo(sx + 16, sy + 28);
ctx.lineTo(sx + 8, sy + 16);
ctx.closePath();
ctx.fill();
// Glow
ctx.fillStyle = `rgba(180, 120, 255, ${pulse * 0.3})`;
ctx.beginPath();
ctx.arc(sx + 16, sy + 16, 20, 0, Math.PI * 2);
ctx.fill();
// Label
ctx.fillStyle = '#cc88ff';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('SAVE', sx + 16, sy - 2);
} else if (dec.type === 'bossStone') {
ctx.fillStyle = '#333';
ctx.fillRect(sx + 6, sy + 12, 20, 20);
ctx.fillStyle = '#cc0000';
ctx.fillRect(sx + 12, sy + 16, 8, 8);
ctx.fillStyle = '#ff4444';
ctx.font = '8px monospace';
ctx.textAlign = 'center';
ctx.fillText('BOSS', sx + 16, sy + 6);
}
}
// Draw world items
for (const item of worldItems) {
if (item.collected) continue;
const sx = item.x * TILE_SIZE - camX + 16;
const sy = item.y * TILE_SIZE - camY + 16;
if (sx < -20 || sx > canvas.width + 20 || sy < -20 || sy > canvas.height + 20) continue;
const bob = Math.sin(Date.now() / 400 + item.x) * 2;
if (item.type === 'coin') {
ctx.fillStyle = '#ffd700';
ctx.beginPath();
ctx.arc(sx, sy + bob, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffaa00';
ctx.font = '6px monospace';
ctx.textAlign = 'center';
ctx.fillText('$', sx, sy + bob + 2);
} else if (item.type === 'healthPotion') {
ctx.fillStyle = '#ff4444';
ctx.fillRect(sx - 4, sy - 6 + bob, 8, 10);
ctx.fillStyle = '#ff8888';
ctx.fillRect(sx - 2, sy - 8 + bob, 4, 3);
ctx.fillStyle = '#ffffff';
ctx.font = '7px monospace';
ctx.textAlign = 'center';
ctx.fillText('+', sx, sy + 2 + bob);
} else if (item.type === 'healthContainer') {
ctx.fillStyle = '#ff4444';
ctx.font = '20px monospace';
ctx.textAlign = 'center';
ctx.fillText('♥', sx, sy + 7 + bob);
ctx.fillStyle = `rgba(255, 100, 100, ${0.3 + Math.sin(Date.now() / 200) * 0.2})`;
ctx.beginPath();
ctx.arc(sx, sy + bob, 18, 0, Math.PI * 2);
ctx.fill();
} else if (item.type === 'weaponIron' || item.type === 'weaponSteel') {
ctx.fillStyle = item.type === 'weaponIron' ? '#aaaaaa' : '#ccddff';
ctx.fillRect(sx - 2, sy - 10 + bob, 4, 14);
ctx.fillStyle = '#664422';
ctx.fillRect(sx - 4, sy + 4 + bob, 8, 4);
ctx.fillStyle = '#ffffff';
ctx.font = '7px monospace';
ctx.textAlign = 'center';
ctx.fillText(item.type === 'weaponIron' ? 'IRON' : 'STEEL', sx, sy - 14 + bob);
} else if (item.type === 'rangedBow') {
ctx.strokeStyle = '#44aaff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(sx, sy + bob, 8, -Math.PI * 0.3, Math.PI * 0.3 + Math.PI);
ctx.stroke();
ctx.fillStyle = '#44aaff';
ctx.font = '7px monospace';
ctx.textAlign = 'center';
ctx.fillText('BOW', sx, sy - 14 + bob);
} else if (item.type === 'speedBoots') {
ctx.fillStyle = '#ff8800';
ctx.fillRect(sx - 6, sy - 3 + bob, 12, 8);
ctx.fillStyle = '#ffff00';
ctx.font = '6px monospace';
ctx.textAlign = 'center';
ctx.fillText('BOOTS', sx, sy + 6 + bob);
} else if (item.type === 'shieldRing') {
ctx.strokeStyle = '#44ff44';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(sx, sy + bob, 6, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = '#44ff44';
ctx.font = '6px monospace';
ctx.textAlign = 'center';
ctx.fillText('RING', sx, sy + 12 + bob);
}
}
// Draw NPCs
for (const npc of interactables) {
const sx = npc.x * TILE_SIZE - camX;
const sy = npc.y * TILE_SIZE - camY;
if (sx < -40 || sx > canvas.width + 40 || sy < -40 || sy > canvas.height + 40) continue;
if (npc.type === 'shopkeeper') {
drawCharacter(sx + 16, sy + 16, '#cc6633', '#663311', 'shopkeeper');
} else if (npc.type === 'blacksmith') {
drawCharacter(sx + 16, sy + 16, '#555555', '#333333', 'blacksmith');
}
}
// Draw enemies
for (const enemy of enemies) {
if (enemy.hp <= 0) continue;
const sx = enemy.x - camX;
const sy = enemy.y - camY;
if (sx < -40 || sx > canvas.width + 40 || sy < -40 || sy > canvas.height + 40) continue;
// Body
ctx.fillStyle = enemy.flashTimer > 0 ? '#ffffff' : enemy.color;
const bobAmt = Math.sin(enemy.animTimer * 3) * 2;
if (enemy.type === 'slime' || enemy.type === 'iceSlime') {
// Slime shape
ctx.beginPath();
ctx.ellipse(sx, sy + bobAmt, enemy.size / 2, enemy.size / 2.5, 0, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = '#000';
ctx.fillRect(sx - 4, sy - 4 + bobAmt, 3, 3);
ctx.fillRect(sx + 2, sy - 4 + bobAmt, 3, 3);
} else if (enemy.type === 'wolf' || enemy.type === 'frostWolf') {
// Wolf shape
ctx.beginPath();
ctx.ellipse(sx, sy + bobAmt, enemy.size / 2, enemy.size / 3, 0, 0, Math.PI * 2);
ctx.fill();
// Head
const dx = player.x - enemy.x > 0 ? 1 : -1;
ctx.beginPath();
ctx.arc(sx + dx * 8, sy - 4 + bobAmt, 6, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = '#ff0000';
ctx.fillRect(sx + dx * 6, sy - 6 + bobAmt, 2, 2);
} else if (enemy.type === 'skeleton') {
// Skeleton
ctx.fillStyle = enemy.flashTimer > 0 ? '#fff' : '#ddd';
ctx.fillRect(sx - 4, sy - 8 + bobAmt, 8, 16);
ctx.beginPath();
ctx.arc(sx, sy - 12 + bobAmt, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.fillRect(sx - 2, sy - 13 + bobAmt, 1, 1);
ctx.fillRect(sx + 1, sy - 13 + bobAmt, 1, 1);
} else if (enemy.type === 'goblin') {
ctx.fillStyle = enemy.flashTimer > 0 ? '#fff' : '#558833';
ctx.fillRect(sx - 5, sy - 6 + bobAmt, 10, 12);
ctx.beginPath();
ctx.arc(sx, sy - 10 + bobAmt, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ff0000';
ctx.fillRect(sx - 2, sy - 11 + bobAmt, 1, 1);
ctx.fillRect(sx + 1, sy - 11 + bobAmt, 1, 1);
} else if (enemy.type === 'bossGuard') {
// Big menacing enemy
ctx.fillStyle = enemy.flashTimer > 0 ? '#fff' : '#882222';
ctx.fillRect(sx - 12, sy - 12 + bobAmt, 24, 24);
ctx.fillStyle = '#cc0000';
ctx.fillRect(sx - 16, sy - 16 + bobAmt, 32, 4);
// Eyes
ctx.fillStyle = '#ffff00';
ctx.fillRect(sx - 6, sy - 8 + bobAmt, 4, 4);
ctx.fillRect(sx + 2, sy - 8 + bobAmt, 4, 4);
}
// Health bar
if (enemy.hp < enemy.maxHp) {
const barW = 24;
const barH = 3;
const ratio = enemy.hp / enemy.maxHp;
ctx.fillStyle = '#330000';
ctx.fillRect(sx - barW / 2, sy - enemy.size - 6, barW, barH);
ctx.fillStyle = ratio > 0.5 ? '#44ff44' : ratio > 0.25 ? '#ffaa00' : '#ff4444';
ctx.fillRect(sx - barW / 2, sy - enemy.size - 6, barW * ratio, barH);
}
}
// Draw player
drawPlayer(camX, camY);
// Draw particles
drawParticles(camX, camY);
// Draw HUD
drawHUD();
// Draw message
if (gameMessage && messageTimer > 0) {
const alpha = Math.min(1, messageTimer / 30);
ctx.fillStyle = `rgba(0, 0, 0, ${alpha * 0.8})`;
ctx.fillRect(canvas.width / 2 - 200, canvas.height - 50, 400, 30);
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.font = '14px monospace';
ctx.textAlign = 'center';
ctx.fillText(gameMessage, canvas.width / 2, canvas.height - 30);
}
// Draw pause/game over/victory overlays
if (gameState === 'pauseMenu') renderPauseMenu();
if (gameState === 'gameover') renderGameOver();
if (gameState === 'victory') renderVictory();
// Draw shop overlay
if (shopOpen) renderShop();
}
function drawCharacter(x, y, bodyColor, darkColor, type) {
const bob = Math.sin(Date.now() / 500) * 1;
// Body
ctx.fillStyle = bodyColor;
ctx.fillRect(x - 6, y - 8 + bob, 12, 16);
// Head
ctx.fillStyle = '#ffcc88';
ctx.beginPath();
ctx.arc(x, y - 12 + bob, 6, 0, Math.PI * 2);
ctx.fill();
// Hair/hat
if (type === 'shopkeeper') {
ctx.fillStyle = darkColor;
ctx.fillRect(x - 7, y - 18 + bob, 14, 4);
ctx.fillRect(x - 7, y - 18 + bob, 4, 8);
} else if (type === 'blacksmith') {
ctx.fillStyle = '#333';
ctx.fillRect(x - 7, y - 16 + bob, 14, 3);
ctx.fillStyle = '#666';
ctx.fillRect(x - 5, y - 18 + bob, 10, 3);
}
// Eyes
ctx.fillStyle = '#000';
ctx.fillRect(x - 2, y - 13 + bob, 1, 1);
ctx.fillRect(x + 1, y - 13 + bob, 1, 1);
// Label
ctx.fillStyle = '#ffff00';
ctx.font = '8px monospace';
ctx.textAlign = 'center';
ctx.fillText('!', x, y - 22 + bob);
}
function drawPlayer(camX, camY) {
const sx = player.x - camX;
const sy = player.y - camY;
// Blink when invincible
if (player.invincible > 0 && Math.floor(player.invincible / 3) % 2 === 0) return;
const bob = Math.sin(player.animTimer * 4) * (player.walkFrame > 0 ? 2 : 0);
const isAttacking = player.attackTimer > 0;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(sx, sy + 12, 10, 4, 0, 0, Math.PI * 2);
ctx.fill();
// Body color based on equipment
const bodyColor = player.speedBoots ? '#4488cc' : '#44aa66';
const accentColor = player.shieldRing ? '#44aaff' : '#66aa66';
// Legs (animated)
ctx.fillStyle = '#334455';
if (player.walkFrame > 0) {
const legOffset = Math.sin(player.walkFrame * Math.PI / 2) * 4;
ctx.fillRect(sx - 4, sy + 6 + bob, 3, 8);
ctx.fillRect(sx + 1, sy + 6 + bob, 3, 8);
ctx.fillRect(sx - 4 - legOffset, sy + 10 + bob, 4, 4);
ctx.fillRect(sx + 1 + legOffset, sy + 10 + bob, 4, 4);
} else {
ctx.fillRect(sx - 4, sy + 6 + bob, 3, 8);
ctx.fillRect(sx + 1, sy + 6 + bob, 3, 8);
}
// Body
ctx.fillStyle = bodyColor;
ctx.fillRect(sx - 7, sy - 4 + bob, 14, 12);
// Shield ring visual
if (player.shieldRing) {
ctx.strokeStyle = `rgba(68, 170, 255, ${0.5 + Math.sin(Date.now() / 200) * 0.3})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(sx, sy + bob, 18, 0, Math.PI * 2);
ctx.stroke();
}
// Head
ctx.fillStyle = '#ffcc88';
ctx.beginPath();
ctx.arc(sx, sy - 10 + bob, 6, 0, Math.PI * 2);
ctx.fill();
// Eyes based on facing
ctx.fillStyle = '#000';
if (player.facing === 'left') {
ctx.fillRect(sx - 3, sy - 11 + bob, 1, 1);
ctx.fillRect(sx - 1, sy - 11 + bob, 1, 1);
} else if (player.facing === 'right') {
ctx.fillRect(sx + 1, sy - 11 + bob, 1, 1);
ctx.fillRect(sx + 3, sy - 11 + bob, 1, 1);
} else if (player.facing === 'up') {
ctx.fillRect(sx - 2, sy - 12 + bob, 1, 1);
ctx.fillRect(sx + 1, sy - 12 + bob, 1, 1);
} else {
ctx.fillRect(sx - 2, sy - 11 + bob, 1, 1);
ctx.fillRect(sx + 1, sy - 11 + bob, 1, 1);
}
// Weapon
if (player.weapon === 'bow') {
// Draw bow
ctx.strokeStyle = '#8B4513';
ctx.lineWidth = 2;
if (player.facing === 'right') {
ctx.beginPath();
ctx.arc(sx + 8, sy + bob, 10, -Math.PI * 0.4, Math.PI * 0.4 + Math.PI);
ctx.stroke();
} else if (player.facing === 'left') {
ctx.beginPath();
ctx.arc(sx - 8, sy + bob, 10, Math.PI * 1.6, -Math.PI * 0.4 + Math.PI);
ctx.stroke();
} else if (player.facing === 'down') {
ctx.beginPath();
ctx.arc(sx, sy + 8 + bob, 10, Math.PI * 0.6, Math.PI * 1.4 + Math.PI);
ctx.stroke();
} else {
ctx.beginPath();
ctx.arc(sx, sy - 8 + bob, 10, -Math.PI * 0.6, Math.PI * 0.4);
ctx.stroke();
}
} else {
// Sword based on level
const swordColor = player.weapon === 'bronze' ? '#cd7f32' : player.weapon === 'iron' ? '#aaaaaa' : '#ccddff';
const swordLength = player.weapon === 'bronze' ? 14 : player.weapon === 'iron' ? 16 : 18;
if (isAttacking) {
// Attack animation - swing
const swingProgress = 1 - (player.attackTimer / getWeaponStats().attackTime);
let swingAngle = 0;
if (player.facing === 'right') swingAngle = -Math.PI / 2 + swingProgress * Math.PI;
else if (player.facing === 'left') swingAngle = Math.PI / 2 - swingProgress * Math.PI;
else if (player.facing === 'down') swingAngle = Math.PI - swingProgress * Math.PI;
else swingAngle = swingProgress * Math.PI;
ctx.save();
ctx.translate(sx, sy + bob);
ctx.rotate(swingAngle);
ctx.fillStyle = swordColor;
ctx.fillRect(0, -1, swordLength, 3);
ctx.fillStyle = '#664422';
ctx.fillRect(-4, -2, 4, 5);
ctx.restore();
} else {
// Idle weapon
ctx.fillStyle = swordColor;
if (player.facing === 'right') {
ctx.fillRect(sx + 6, sy - 2 + bob, swordLength, 3);
ctx.fillStyle = '#664422';
ctx.fillRect(sx + 4, sy - 3 + bob, 4, 5);
} else if (player.facing === 'left') {
ctx.fillRect(sx - 6 - swordLength, sy - 2 + bob, swordLength, 3);
ctx.fillStyle = '#664422';
ctx.fillRect(sx - 8, sy - 3 + bob, 4, 5);
} else if (player.facing === 'down') {
ctx.fillRect(sx + 4, sy + 2 + bob, 3, swordLength);
ctx.fillStyle = '#664422';
ctx.fillRect(sx + 3, sy + swordLength + bob, 5, 4);
} else {
ctx.fillRect(sx - 4, sy - 12 + bob - swordLength, 3, swordLength);
ctx.fillStyle = '#664422';
ctx.fillRect(sx - 5, sy - 12 + bob - swordLength, 5, 4);
}
}
}
// Damage flash
if (player.hitTimer > 0) {
ctx.fillStyle = 'rgba(255, 0, 0, 0.4)';
ctx.beginPath();
ctx.arc(sx, sy + bob, 16, 0, Math.PI * 2);
ctx.fill();
}
}
function drawHUD() {
// Background panel
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(8, 8, 260, 65);
ctx.strokeStyle = '#555';
ctx.strokeRect(8, 8, 260, 65);
// Health hearts (each heart = 2 HP, shown as containers)
let hx = 18;
for (let i = 0; i < player.hpContainers; i++) {
const heartY = 22;
const hpInContainer = player.hp - i * 2;
// Container frame
ctx.fillStyle = '#333';
ctx.fillRect(hx - 2, heartY - 10, 20, 16);
if (hpInContainer >= 2) {
// Full heart
drawHeart(hx + 8, heartY - 2, '#ff4444');
} else if (hpInContainer >= 1) {
// Half heart
ctx.fillStyle = '#ff4444';
drawHeart(hx + 8, heartY - 2, '#ff4444');
ctx.fillStyle = '#333';
ctx.fillRect(hx + 10, heartY - 10, 8, 16);
}
hx += 22;
}
// Gold
ctx.fillStyle = '#ffd700';
ctx.font = '14px monospace';
ctx.textAlign = 'left';
ctx.fillText(`💰 ${player.money}`, 18, 50);
// Weapon info
const weaponName = player.weapon === 'bronze' ? 'Bronze' : player.weapon === 'iron' ? 'Iron' : player.weapon === 'steel' ? 'Steel' : 'Bow';
const weaponIcon = player.weapon === 'bow' ? '🏹' : '⚔️';
ctx.fillStyle = '#aaaaaa';
ctx.font = '12px monospace';
ctx.fillText(`${weaponIcon} ${weaponName}`, 18, 68);
if (player.weapon === 'bow') {
ctx.fillStyle = '#44aaff';
ctx.fillText(`(${player.bowAmmo} arrows)`, 110, 68);
}
// Accessories indicator
let ax = 130;
if (player.speedBoots) {
ctx.fillStyle = '#ff8800';
ctx.font = '12px monospace';
ctx.fillText('👟', ax, 50);
ax += 16;
}
if (player.shieldRing) {
ctx.fillStyle = '#44ff44';
ctx.font = '12px monospace';
ctx.fillText('💍', ax, 50);
ax += 16;
}
// Health potions
if (player.healthPotions > 0) {
ctx.fillStyle = '#ff4444';
ctx.font = '12px monospace';
ctx.fillText(`🧪 x${player.healthPotions}`, ax, 50);
}
// Right side - minimap-ish area indicator
const px = player.x / TILE_SIZE | 0;
const py = player.y / TILE_SIZE | 0;
let areaName = 'Village';
if (px < 25 && py < 20) areaName = 'Dark Forest';
else if (px < 20 && py > 25 && py < 45) areaName = 'Murky Swamp';
else if (px > 55 && px < 65 && py > 30 && py < 40) areaName = 'Mountain Path';
else if (px > 65 && px < 95 && py > 22 && py < 42) areaName = 'Shadow Cave';
else if (px > 55 && px < 80 && py < 22) areaName = 'Frost Cave';
else if (px > 33 && px < 50 && py > 55 && py < 66) areaName = 'Hidden Ruins';
else if (px > 85) areaName = 'Boss Arena';
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(canvas.width - 180, 8, 172, 28);
ctx.fillStyle = '#cccccc';
ctx.font = '12px monospace';
ctx.textAlign = 'right';
ctx.fillText(`Location: ${areaName}`, canvas.width - 16, 26);
// Controls hint
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(canvas.width - 200, canvas.height - 35, 192, 28);
ctx.fillStyle = '#888';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText('WASD/Arrows: Move | Z/Space: Attack | F: Interact | U: Heal | Esc: Menu', canvas.width - 16, canvas.height - 16);
}
function drawHeart(x, y, color) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, y - 4);
ctx.bezierCurveTo(x - 7, y - 12, x - 12, y - 2, x, y + 4);
ctx.bezierCurveTo(x + 12, y - 2, x + 7, y - 12, x, y - 4);
ctx.fill();
}
function renderTitle() {
// Animated background
const t = Date.now() / 1000;
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Stars
ctx.fillStyle = '#ffffff';
for (let i = 0; i < 100; i++) {
const x = ((i * 137.5) % canvas.width);
const y = ((i * 97.3 + t * 10) % canvas.height);
const size = (Math.sin(t + i) * 0.5 + 0.5) * 2;
ctx.fillRect(x, y, size, size);
}
// Title
ctx.fillStyle = '#ffcc00';
ctx.font = '48px monospace';
ctx.textAlign = 'center';
ctx.fillText('REALM OF ECHOES', canvas.width / 2, 180);
ctx.fillStyle = '#aaaaaa';
ctx.font = '16px monospace';
ctx.fillText('An Action-Adventure RPG', canvas.width / 2, 220);
// Menu options
const options = ['New Game', 'Load Game', 'Controls'];
for (let i = 0; i < options.length; i++) {
const y = 300 + i * 40;
if (i === titleSelection) {
ctx.fillStyle = '#ffcc00';
ctx.fillRect(canvas.width / 2 - 120, y - 14, 240, 28);
ctx.fillStyle = '#000';
} else {
ctx.fillStyle = '#888';
}
ctx.font = '18px monospace';
ctx.fillText(`> ${options[i]} <`, canvas.width / 2, y + 4);
}
// Instructions for title
ctx.fillStyle = '#666';
ctx.font = '11px monospace';
ctx.fillText('Arrow Keys/WASD to select, Enter to confirm', canvas.width / 2, 460);
// Features
ctx.fillStyle = '#555';
ctx.font = '10px monospace';
const features = [
'⚔️ Dynamic combat with weapon upgrades',
'🗺️ Explore multiple regions',
'🛒 In-game shop & economy',
'💾 Save your progress at crystals',
'🏹 Discover ranged combat mid-journey',
'🎯 Balanced progression for ~1 hour gameplay'
];
for (let i = 0; i < features.length; i++) {
ctx.fillText(features[i], canvas.width / 2, 490 + i * 16);
}
}
function renderPauseMenu() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ffcc00';
ctx.font = '32px monospace';
ctx.textAlign = 'center';
ctx.fillText('PAUSED', canvas.width / 2, 180);
const options = ['Resume', 'Save Game', 'Clear Save', 'Quit to Title'];
for (let i = 0; i < options.length; i++) {
const y = 240 + i * 40;
ctx.fillStyle = '#888';
ctx.font = '18px monospace';
ctx.fillText(`[ ${i + 1 }] ${options[i]}`, canvas.width / 2, y);
}
ctx.fillStyle = '#666';
ctx.font = '12px monospace';
ctx.fillText('Press ESC or corresponding number', canvas.width / 2, 440);
// Handle pause menu input
document.onkeydown = function(e) {
if (gameState !== 'pauseMenu') return;
if (e.key === 'Escape') { gameState = 'playing'; }
else if (e.key === '1') { gameState = 'playing'; }
else if (e.key === '2') { saveGame(); }
else if (e.key === '3') { clearSave(); }
else if (e.key === '4') { gameState = 'title'; titleSelection = 0; }
};
}
function renderGameOver() {
ctx.fillStyle = 'rgba(60, 0, 0, 0.85)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ff4444';
ctx.font = '40px monospace';
ctx.textAlign = 'center';
ctx.fillText('YOU HAVE FALLEN', canvas.width / 2, 220);
ctx.fillStyle = '#cccccc';
ctx.font = '16px monospace';
ctx.fillText(`Gold collected: ${player.money}`, canvas.width / 2, 280);
ctx.fillText(`Weapons mastered: ${player.weaponLevel + 1}/3`, canvas.width / 2, 310);
ctx.fillStyle = '#ffcc00';
ctx.font = '18px monospace';
ctx.fillText('[ R ] Retry from last save', canvas.width / 2, 380);
ctx.fillText('[ T ] Return to title', canvas.width / 2, 420);
document.onkeydown = function(e) {
if (gameState !== 'gameover') return;
if (e.key.toLowerCase() === 'r') {
const loaded = loadGame();
if (loaded) gameState = 'playing';
else { gameState = 'title'; }
} else if (e.key.toLowerCase() === 't') {
gameState = 'title';
titleSelection = 0;
}
};
}
function renderVictory() {
ctx.fillStyle = 'rgba(0, 20, 40, 0.85)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const t = Date.now() / 1000;
// Victory particles
for (let i = 0; i < 5; i++) {
const x = (Math.sin(t * 2 + i * 1.5) * 300 + canvas.width / 2);
const y = (Math.cos(t * 1.5 + i * 2) * 150 + canvas.height / 2);
ctx.fillStyle = ['#ffcc00', '#ff4444', '#44ff44', '#44aaff'][i % 4];
ctx.beginPath();
ctx.arc(x, y, 3 + Math.sin(t * 3 + i) * 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#ffcc00';
ctx.font = '40px monospace';
ctx.textAlign = 'center';
ctx.fillText('VICTORY!', canvas.width / 2, 180);
ctx.fillStyle = '#44ff44';
ctx.font = '20px monospace';
ctx.fillText('The Shadow Lord has been vanquished!', canvas.width / 2, 230);
ctx.fillStyle = '#cccccc';
ctx.font = '14px monospace';
ctx.fillText(`Gold: ${player.money} | Weapons: ${player.weaponLevel + 1}/3 | Health: ${player.hpContainers}/5`, canvas.width / 2, 290);
ctx.fillStyle = '#ffcc00';
ctx.font = '18px monospace';
ctx.fillText('[ T ] Return to title', canvas.width / 2, 380);
document.onkeydown = function(e) {
if (gameState !== 'victory') return;
if (e.key.toLowerCase() === 't') {
gameState = 'title';
titleSelection = 0;
}
};
}
function renderShop() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Shop background
ctx.fillStyle = '#2a1a0a';
ctx.fillRect(100, 80, 760, 480);
ctx.strokeStyle = '#8B4513';
ctx.lineWidth = 3;
ctx.strokeRect(100, 80, 760, 480);
// Title
ctx.fillStyle = '#ffcc00';
ctx.font = '28px monospace';
ctx.textAlign = 'center';
ctx.fillText('Merchant Alder\'s Shop', canvas.width / 2, 125);
ctx.fillStyle = '#aaa';
ctx.font = '14px monospace';
ctx.fillText(`Your Gold: 💰 ${player.money}`, canvas.width / 2, 155);
// Items
for (let i = 0; i < shopItems.length; i++) {
const item = shopItems[i];
const y = 190 + i * 50;
const canBuy = player.money >= item.price;
if (i === shopSelection) {
ctx.fillStyle = 'rgba(255, 204, 0, 0.2)';
ctx.fillRect(120, y - 15, 720, 40);
ctx.strokeStyle = '#ffcc00';
ctx.lineWidth = 1;
ctx.strokeRect(120, y - 15, 720, 40);
}
ctx.fillStyle = canBuy ? '#ffffff' : '#666';
ctx.font = '16px monospace';
ctx.textAlign = 'left';
ctx.fillText(item.name, 140, y + 5);
ctx.fillStyle = '#aaa';
ctx.font = '12px monospace';
ctx.fillText(item.desc, 140, y + 22);
ctx.fillStyle = canBuy ? '#ffd700' : '#663300';
ctx.font = '14px monospace';
ctx.textAlign = 'right';
ctx.fillText(`${item.price} 💰`, 820, y + 12);
}
// Close button
ctx.fillStyle = 'rgba(100, 100, 100, 0.5)';
ctx.fillRect(canvas.width / 2 - 80, 460, 160, 35);
ctx.fillStyle = '#fff';
ctx.font = '14px monospace';
ctx.textAlign = 'center';
ctx.fillText('ESC - Close', canvas.width / 2, 482);
ctx.fillStyle = '#888';
ctx.font = '10px monospace';
ctx.fillText('Arrows/WASD to select, Enter/E to buy', canvas.width / 2, 520);
}
// ============================================================
// TITLE MENU INPUT
// ============================================================
function handleTitleInput() {
if (gameState !== 'title') return;
const lastKey = titleLastKey;
titleLastKey = '';
if ((keys['arrowup'] || keys['w']) && Date.now() - titleKeyTime > 150) {
titleSelection = Math.max(0, titleSelection - 1);
titleKeyTime = Date.now();
}
if ((keys['arrowdown'] || keys['s']) && Date.now() - titleKeyTime > 150) {
titleSelection = Math.min(2, titleSelection + 1);
titleKeyTime = Date.now();
}
if (keys['Enter'] || keys['e']) {
initAudio();
if (titleSelection === 0) {
startNewGame();
} else if (titleSelection === 1) {
if (loadGame()) {
gameState = 'playing';
} else {
showMessage('No save found!');
setTimeout(() => { gameMessage = ''; }, 1500);
}
} else if (titleSelection === 2) {
gameState = 'controls';
}
}
}
let titleKeyTime = 0;
let titleLastKey = '';
// ============================================================
// CONTROLS SCREEN
// ============================================================
function renderControls() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ffcc00';
ctx.font = '28px monospace';
ctx.textAlign = 'center';
ctx.fillText('CONTROLS', canvas.width / 2, 80);
const controls = [
['Movement', 'WASD / Arrow Keys'],
['Attack', 'Z / Space / Mouse Click'],
['Interact', 'F'],
['Use Health Potion', 'U'],
['Switch to Sword', '1'],
['Switch to Bow', '2 (when owned)'],
['Pause / Menu', 'Escape'],
['', ''],
['Health System', 'Each ♥ = 2 HP. Start with 3, find 2 more!'],
['Weapons', 'Bronze Sword → Iron Sword → Steel Blade'],
['Tips', 'Talk to NPCs with F. Save at glowing crystals.'],
['', ''],
['Objective', 'Explore the realm, grow stronger, defeat the Shadow Lord!']
];
ctx.fillStyle = '#cccccc';
ctx.font = '14px monospace';
for (let i = 0; i < controls.length; i++) {
const y = 130 + i * 26;
if (controls[i][0]) {
ctx.fillStyle = controls[i][0].includes('Objective') ? '#ffcc00' : '#88aaff';
ctx.textAlign = 'left';
ctx.fillText(controls[i][0] + ':', canvas.width / 2 - 180, y);
ctx.fillStyle = '#cccccc';
ctx.textAlign = 'left';
ctx.fillText(controls[i][1], canvas.width / 2 + 10, y);
}
}
ctx.fillStyle = '#888';
ctx.font = '14px monospace';
ctx.textAlign = 'center';
ctx.fillText('Press ESC to return', canvas.width / 2, 550);
document.onkeydown = function(e) {
if (gameState !== 'controls') return;
if (e.key === 'Escape') {
gameState = 'title';
titleSelection = 2;
}
};
}
// ============================================================
// MAIN GAME LOOP
// ============================================================
let lastTime = 0;
function gameLoop(timestamp) {
const dt = timestamp - lastTime;
lastTime = timestamp;
// Handle title screen input
if (gameState === 'title') {
handleTitleInput();
renderTitle();
} else if (gameState === 'controls') {
renderControls();
} else {
updatePlayer();
updateEnemies();
updateParticles();
// Check boss arena win condition (all enemies dead in boss arena area)
const bossEnemiesAlive = enemies.filter(e => e.hp > 0 && e.x / TILE_SIZE > 85 && e.y / TILE_SIZE > 32 && e.y / TILE_SIZE < 46).length;
if (bossEnemiesAlive === 0 && !player.victoryAchieved && player.x / TILE_SIZE > 85) {
player.victoryAchieved = true;
gameState = 'victory';
}
render();
}
requestAnimationFrame(gameLoop);
}
// ============================================================
// INITIALIZATION
// ============================================================
document.addEventListener('keydown', (e) => {
if (gameState === 'title' && e.key === 'Enter') {
initAudio();
}
});
canvas.addEventListener('click', () => {
initAudio();
});
// Start game loop
requestAnimationFrame(gameLoop);
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.6-27B-Fable-Fusion-711-Uncensored-Heretic-NM-DAU-NEO-MAX-MTP-GGUF","provider":"brigid","persona":"full","sessionId":"475b4a13-a134-4503-8f03-ea8bedd89aa1","tokensIn":1721913,"tokensOut":45536,"tokensTotal":1767449,"cost":0,"turns":37,"toolCalls":36,"failedToolCalls":0,"timestamp":"2026-08-06T02:36:53.397Z"} -->