RPG
longcat 2.0 · 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 63,182 bytes · SHA-256 32523851a126
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Heart of the Ruins — Action Adventure RPG</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0f;
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;
cursor: crosshair;
}
#ui-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
}
#inventory-panel {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: rgba(15, 15, 25, 0.95);
border: 2px solid #555;
border-radius: 8px;
padding: 20px;
pointer-events: all;
display: none;
min-width: 400px;
z-index: 100;
}
#inventory-panel h2 { color: #ffd700; margin-bottom: 10px; font-size: 16px; }
.inv-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
.inv-slot {
width: 56px; height: 56px;
background: rgba(30,30,50,0.8);
border: 1px solid #444;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
transition: border-color 0.2s;
}
.inv-slot:hover { border-color: #ffd700; }
.inv-slot canvas { width: 40px; height: 40px; }
.inv-slot .count {
position: absolute;
bottom: 2px; right: 4px;
color: #fff; font-size: 10px;
text-shadow: 1px 1px 1px #000;
}
#shop-panel {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: rgba(15, 15, 25, 0.95);
border: 2px solid #ffd700;
border-radius: 8px;
padding: 20px;
pointer-events: all;
display: none;
min-width: 420px;
z-index: 100;
}
#shop-panel h2 { color: #ffd700; margin-bottom: 10px; }
#shop-panel .shop-items { display: flex; flex-direction: column; gap: 6px; }
.shop-item {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(40,40,60,0.8);
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #555;
cursor: pointer;
transition: border-color 0.2s;
}
.shop-item:hover { border-color: #ffd700; }
.shop-item .name { color: #ddd; font-size: 12px; }
.shop-item .price { color: #ffd700; font-size: 12px; }
#dialogue-box {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
background: rgba(10, 10, 20, 0.9);
border: 1px solid #666;
border-radius: 6px;
padding: 12px 20px;
color: #ddd;
font-size: 13px;
max-width: 500px;
display: none;
z-index: 90;
text-align: center;
}
#controls-hint {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
color: rgba(255,255,255,0.4);
font-size: 11px;
}
.hud-btn {
pointer-events: all;
background: rgba(30,30,50,0.8);
border: 1px solid #555;
color: #ddd;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 11px;
}
.hud-btn:hover { border-color: #ffd700; color: #ffd700; }
#minimap {
position: absolute;
top: 8px; right: 8px;
width: 120px; height: 120px;
background: rgba(10,10,20,0.7);
border: 1px solid #444;
border-radius: 4px;
}
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="ui-overlay">
<canvas id="minimap"></canvas>
<div id="inventory-panel">
<h2>⚔ INVENTORY — Press E to close</h2>
<div class="inv-grid" id="inv-grid"></div>
<div id="inv-desc" style="color:#aaa;font-size:11px;margin-top:8px;"></div>
</div>
<div id="shop-panel">
<h2>🏪 Item Shop — Gold: <span id="shop-gold">0</span></h2>
<div class="shop-items" id="shop-items"></div>
<div style="margin-top:10px;text-align:right;">
<button class="hud-btn" onclick="game.closeShop()">Close (F)</button>
</div>
</div>
<div id="dialogue-box"></div>
<div id="controls-hint">WASD Move | SPACE Attack | SHIFT Ranged | E Inventory | F Interact | M Minimap</div>
</div>
<script>
// ============================================================
// HEART OF THE RUINS — Action Adventure RPG
// A single-file HTML5 action-adventure experience
// ============================================================
// ===== CONSTANTS =====
const W = 800, H = 600;
const TILE = 32;
const GRAVITY = 0;
const WEAPONS = {
rustySword: { name: 'Rusty Sword', dmg: 1, range: 50, cooldown: 380, arc: Math.PI/2, color: '#aaa', type: 'melee' },
steelBlade: { name: 'Steel Blade', dmg: 2, range: 65, cooldown: 360, arc: Math.PI/2.2, color: '#ccf', type: 'melee' },
flameSword: { name: 'Flame Sword', dmg: 2, range: 70, cooldown: 400, arc: Math.PI/1.5, color: '#f80', type: 'melee', flame: true },
hunterBow: { name: "Hunter's Bow", dmg: 1, range: 250, cooldown: 500, speed: 7, color: '#8f8', type: 'ranged' }
};
// ===== AUDIO SYSTEM =====
class AudioSystem {
constructor() {
this.ctx = null;
this.initialized = false;
}
init() {
if (this.initialized) return;
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.initialized = true;
} catch(e) {}
}
play(freq, duration, type = 'square', vol = 0.12) {
if (!this.ctx) this.init();
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(vol, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
osc.start(this.ctx.currentTime);
osc.stop(this.ctx.currentTime + duration);
}
hit() { this.play(200, 0.08, 'square', 0.15); this.play(120, 0.1, 'sawtooth', 0.08); }
swing() { this.play(400, 0.06, 'triangle', 0.08); this.play(600, 0.04, 'sine', 0.05); }
pickup() { this.play(800, 0.08, 'sine', 0.1); setTimeout(() => this.play(1000, 0.08, 'sine', 0.08), 60); }
coin() { this.play(1200, 0.05, 'sine', 0.08); setTimeout(() => this.play(1600, 0.06, 'sine', 0.06), 40); }
damage() { this.play(100, 0.15, 'sawtooth', 0.15); this.play(80, 0.2, 'square', 0.1); }
walk() { this.play(80 + Math.random()*20, 0.04, 'sine', 0.03); }
shop() { this.play(500, 0.1, 'sine', 0.08); setTimeout(() => this.play(700, 0.1, 'sine', 0.08), 80); setTimeout(() => this.play(900, 0.12, 'sine', 0.08), 160); }
save() { this.play(440, 0.15, 'sine', 0.1); setTimeout(() => this.play(550, 0.15, 'sine', 0.1), 120); setTimeout(() => this.play(660, 0.2, 'sine', 0.1), 240); }
explosion() { this.play(60, 0.3, 'sawtooth', 0.15); this.play(40, 0.4, 'square', 0.1); }
bowshot() { this.play(900, 0.06, 'triangle', 0.06); this.play(700, 0.08, 'sawtooth', 0.04); }
levelup() {
[400,500,600,800].forEach((f,i) => setTimeout(() => this.play(f, 0.15, 'sine', 0.1), i*100));
}
interact() { this.play(600, 0.06, 'sine', 0.06); }
}
// ===== PARTICLE SYSTEM =====
class ParticleSystem {
constructor() { this.particles = []; }
emit(x, y, count, color, life = 30, speed = 2, size = 3) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = (Math.random() * 0.7 + 0.3) * speed;
this.particles.push({
x, y,
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd - 0.5,
life: life + Math.random() * life * 0.5,
maxLife: life,
color,
size: size + Math.random() * size * 0.5
});
}
}
update() {
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i];
p.x += p.vx;
p.y += p.vy;
p.vy += 0.05;
p.life--;
if (p.life <= 0) this.particles.splice(i, 1);
}
}
draw(ctx, camX, camY) {
for (const p of this.particles) {
const alpha = p.life / p.maxLife;
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.fillRect(p.x - camX - p.size/2, p.y - camY - p.size/2, p.size, p.size);
}
ctx.globalAlpha = 1;
}
}
// ===== INPUT =====
class Input {
constructor() {
this.keys = {};
this.justPressed = {};
window.addEventListener('keydown', e => {
if (!this.keys[e.key.toLowerCase()] && !this.keys[e.code]) {
this.justPressed[e.key.toLowerCase()] = true;
this.justPressed[e.code] = true;
}
this.keys[e.key.toLowerCase()] = true;
this.keys[e.code] = true;
if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(e.key.toLowerCase())) e.preventDefault();
});
window.addEventListener('keyup', e => {
this.keys[e.key.toLowerCase()] = false;
this.keys[e.code] = false;
});
}
isDown(key) { return !!this.keys[key]; }
wasPressed(key) {
if (this.justPressed[key]) { this.justPressed[key] = false; return true; }
return false;
}
clearFrame() { this.justPressed = {}; }
}
// ===== CAMERA =====
class Camera {
constructor(w, h) {
this.x = 0; this.y = 0;
this.w = w; this.h = h;
this.smoothing = 0.08;
}
follow(targetX, targetY, worldW, worldH) {
const tx = targetX - this.w / 2;
const ty = targetY - this.h / 2;
this.x += (tx - this.x) * this.smoothing;
this.y += (ty - this.y) * this.smoothing;
this.x = Math.max(0, Math.min(this.x, worldW - this.w));
this.y = Math.max(0, Math.min(this.y, worldH - this.h));
}
}
// ===== WORLD MAP =====
// Tile-based world: each area is a connected region. The world is one large grid.
// Tile types: 0=grass, 1=wall/rock, 2=water, 3=path, 4=tree, 5=bridge, 6=door, 7=chest, 8=save, 9=shop, 10=stairs
const WORLD_W = 96; // tiles
const WORLD_H = 64; // tiles
function generateWorld() {
const tiles = new Uint8Array(WORLD_W * WORLD_H);
const meta = {}; // tile metadata: chests, doors, etc.
// Fill with grass
for (let i = 0; i < tiles.length; i++) tiles[i] = 0;
// Helper
const set = (x, y, t) => { if (x >= 0 && x < WORLD_W && y >= 0 && y < WORLD_H) tiles[y * WORLD_W + x] = t; };
const get = (x, y) => (x >= 0 && x < WORLD_W && y >= 0 && y < WORLD_H) ? tiles[y * WORLD_W + x] : 1;
// Border walls
for (let x = 0; x < WORLD_W; x++) { set(x, 0, 1); set(x, WORLD_H-1, 1); }
for (let y = 0; y < WORLD_H; y++) { set(0, y, 1); set(WORLD_W-1, y, 1); }
// Define regions (each roughly 20x16 tiles)
// Region layout:
// [Meadow] [Forest] [Mountain]
// [Village] [Plains] [Darkwood]
// [Caves] [Ruins] [Castle]
// Meadow (0,0) to (31,21) — starting area, safe
// Add some trees/bushes as decoration
for (let i = 0; i < 30; i++) {
const tx = 3 + Math.floor(Math.random()*26);
const ty = 3 + Math.floor(Math.random()*16);
set(tx, ty, 4);
}
// Meadow pond
for (let x = 20; x < 26; x++) for (let y = 4; y < 8; y++) set(x, y, 2);
set(20, 5, 0); set(21, 5, 0); set(20, 6, 0); // small shore
// Forest (32,0) to (63,21) — enemies, first weapon upgrade
for (let i = 0; i < 50; i++) {
const tx = 34 + Math.floor(Math.random()*27);
const ty = 2 + Math.floor(Math.random()*18);
set(tx, ty, 4);
}
// Clearing in forest
for (let x = 44; x < 52; x++) for (let y = 8; y < 14; y++) set(x, y, 0);
// Steel blade in clearing
set(47, 10, 7); meta[`47,10`] = { type: 'chest', item: 'steelBlade', label: 'Steel Blade' };
// Mountain (64,0) to (95,21) — impassable cliffs + cave entrance
for (let x = 66; x < 94; x++) for (let y = 2; y < 20; y++) set(x, y, 1);
// Path around mountain
for (let y = 22; y < 28; y++) set(78, y, 3);
for (let x = 64; x < 94; x++) set(x, 22, 3);
// Cave entrance
set(78, 20, 6); meta[`78,20`] = { type: 'door', label: 'Cave Entrance', target: 'cave' };
// Village (0,22) to (31,43) — shop, save point, NPC
for (let x = 2; x < 30; x++) for (let y = 24; y < 42; y++) set(x, y, 3);
// Shop building
for (let x = 5; x < 12; x++) for (let y = 26; y < 32; y++) set(x, y, 0);
set(8, 32, 9); meta[`8,32`] = { type: 'shop' };
// Save point
set(15, 35, 8); meta[`15,35`] = { type: 'save' };
// NPC
set(20, 30, 7); meta[`20,30`] = { type: 'chest', item: 'npc_quest', label: 'Village Elder: Bring me 3 Dark Essences for a reward!' };
// Village walls (partial)
for (let x = 2; x < 30; x++) { set(x, 24, 1); set(x, 41, 1); }
for (let y = 24; y < 42; y++) { set(2, y, 1); set(29, y, 1); }
// Village gates
set(15, 24, 0); set(16, 24, 0);
set(15, 41, 0); set(16, 41, 0);
set(2, 32, 0); set(2, 33, 0);
set(29, 32, 0); set(29, 33, 0);
// Plains (32,22) to (63,43) — open area with scattered enemies
for (let x = 33; x < 63; x++) for (let y = 23; y < 42; y++) set(x, y, 0);
// Some rocks
for (let i = 0; i < 15; i++) {
const tx = 34 + Math.floor(Math.random()*27);
const ty = 24 + Math.floor(Math.random()*17);
set(tx, ty, 1);
}
// Mid point - ranged weapon discovery here
set(47, 32, 7); meta[`47,32`] = { type: 'chest', item: 'hunterBow', label: "Hunter's Bow", midgame: true };
// Health upgrade
set(38, 28, 7); meta[`38,28`] = { type: 'chest', item: 'heart', label: 'Heart Shard' };
// Darkwood (64,22) to (95,43) — dense forest, harder enemies
for (let x = 65; x < 95; x++) for (let y = 23; y < 42; y++) set(x, y, 0);
for (let i = 0; i < 60; i++) {
const tx = 66 + Math.floor(Math.random()*27);
const ty = 24 + Math.floor(Math.random()*17);
set(tx, ty, 4);
}
// Path through
for (let y = 30; y < 36; y++) set(78, y, 0);
for (let x = 70; x < 88; x++) set(x, 38, 0);
// Second weapon upgrade
set(85, 35, 7); meta[`85,35`] = { type: 'chest', item: 'flameSword', label: 'Flame Sword' };
// More health
set(70, 26, 7); meta[`70,26`] = { type: 'chest', item: 'heart', label: 'Heart Shard' };
// Caves (0,44) to (31,63) — underground area
for (let x = 2; x < 30; x++) for (let y = 44; y < 63; y++) set(x, y, 1);
// Cave rooms
for (let x = 4; x < 14; x++) for (let y = 46; y < 54; y++) set(x, y, 0);
for (let x = 14; x < 28; x++) for (let y = 46; y < 54; y++) set(x, y, 0);
for (let x = 4; x < 28; x++) for (let y = 54; y < 61; y++) set(x, y, 0);
// Save point in cave
set(10, 50, 8); meta[`10,50`] = { type: 'save' };
// Gold cache
set(22, 57, 7); meta[`22,57`] = { type: 'chest', item: 'gold50', label: 'Gold Cache (50g)' };
// Ruins (32,44) to (63,63) — ancient temple area
for (let x = 33; x < 62; x++) for (let y = 45; y < 62; y++) set(x, y, 3);
// Pillar ruins
for (let x = 36; x < 60; x += 4) for (let y = 48; y < 60; y += 4) set(x, y, 1);
set(37, 49, 1); set(38, 49, 1); set(37, 50, 1);
// Chest with accessory
set(55, 55, 7); meta[`55,55`] = { type: 'chest', item: 'boots', label: 'Swift Boots' };
set(40, 57, 7); meta[`40,57`] = { type: 'chest', item: 'potion', label: 'Health Potion' };
// Save
set(58, 48, 8); meta[`58,48`] = { type: 'save' };
// Castle (64,44) to (95,63) — final area
for (let x = 65; x < 94; x++) for (let y = 45; y < 62; y++) set(x, y, 1);
// Castle interior
for (let x = 67; x < 92; x++) for (let y = 47; y < 60; y++) set(x, y, 3);
// Throne room
for (let x = 75; x < 85; x++) for (let y = 48; y < 54; y++) set(x, y, 0);
// Boss trigger
set(80, 50, 7); meta[`80,50`] = { type: 'boss', label: 'Shadow Lord' };
// Castle gates
set(67, 53, 0); set(68, 53, 0);
// Connecting paths between regions
// Meadow-Plains connection
for (let x = 30; x < 33; x++) for (let y = 30; y < 35; y++) set(x, y, 0);
// Fix some connectivity - ensure paths exist between adjacent regions
// Village to Cave
for (let y = 42; y < 46; y++) set(10, y, 0);
set(10, 43, 0); set(11, 43, 0);
// Plains to Ruins
for (let y = 42; y < 46; y++) set(47, y, 0);
// Village-Plains gate already open via shared area
// Plains-Darkwood shared border
for (let y = 30; y < 36; y++) set(63, y, 0);
return { tiles, meta };
}
// ===== PLAYER =====
class Player {
constructor(x, y) {
this.x = x; this.y = y;
this.w = 20; this.h = 20;
this.vx = 0; this.vy = 0;
this.speed = 2.2;
this.maxHp = 3; // Start with 3 HP (1.5 hearts)
this.targetMaxHp = 5; // Must reach 5 HP (2.5 hearts)
this.hp = 3;
this.facing = 0; // angle in radians
this.attackCooldown = 0;
this.weapon = 'rustySword';
this.inventory = [];
this.gold = 0;
this.attackAnim = 0;
this.invincible = 0;
this.essences = 0;
this.steps = 0;
this.walkTimer = 0;
// Visual
this.flashTimer = 0;
// Ranged ammo
this.arrows = 20;
this.hasRanged = false;
this.buffs = { speed: 0, damage: 0, shield: 0 };
}
get currentWeapon() { return WEAPONS[this.weapon]; }
get heartCount() { return Math.ceil(this.maxHp / 2); }
update(dt, input, world) {
// Movement
let dx = 0, dy = 0;
if (input.isDown('w') || input.isDown('arrowup')) dy -= 1;
if (input.isDown('s') || input.isDown('arrowdown')) dy += 1;
if (input.isDown('a') || input.isDown('arrowleft')) dx -= 1;
if (input.isDown('d') || input.isDown('arrowright')) dx += 1;
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707; }
const spd = this.speed + (this.buffs.speed > 0 ? 0.8 : 0);
const newX = this.x + dx * spd;
const newY = this.y + dy * spd;
// Collision with tiles
if (!this.collides(newX, this.y, world)) this.x = newX;
if (!this.collides(this.x, newY, world)) this.y = newY;
// Walk sound
if (dx !== 0 || dy !== 0) {
this.walkTimer += dt;
if (this.walkTimer > 200) { this.walkTimer = 0; audio.walk(); }
}
// Facing direction (towards mouse or movement)
if (game && game.mouse) {
const centerX = this.x - camera.x + this.w/2;
const centerY = this.y - camera.y + this.h/2;
this.facing = Math.atan2(game.mouse.y - centerY, game.mouse.x - centerX);
}
// Attack cooldown
if (this.attackCooldown > 0) this.attackCooldown -= dt;
if (this.invincible > 0) this.invincible -= dt;
if (this.flashTimer > 0) this.flashTimer -= dt;
if (this.attackAnim > 0) this.attackAnim -= dt;
if (this.buffs.shield > 0) this.buffs.shield -= dt;
if (this.buffs.speed > 0) this.buffs.speed -= dt;
// Dash regen
if (this.attackAnim < 0) this.attackAnim = 0;
}
collides(x, y, world) {
const margin = 2;
const points = [
[x + margin, y + margin],
[x + this.w - margin, y + margin],
[x + margin, y + this.h - margin],
[x + this.w - margin, y + this.h - margin]
];
for (const [px, py] of points) {
const tx = Math.floor(px / TILE);
const ty = Math.floor(py / TILE);
const tile = world.getTile(tx, ty);
if (tile === 1 || tile === 2 || tile === 4) return true;
}
return false;
}
takeDamage(amount) {
if (this.invincible > 0) return;
if (this.buffs.shield > 0) { this.buffs.shield = 0; this.invincible = 500; return; }
this.hp -= amount;
this.invincible = 800;
this.flashTimer = 800;
audio.damage();
particles.emit(this.x + this.w/2, this.y + this.h/2, 8, '#f44', 20, 2);
if (this.hp <= 0) {
this.hp = 0;
game.onPlayerDeath();
}
}
heal(amount) {
this.hp = Math.min(this.maxHp, this.hp + amount);
particles.emit(this.x + this.w/2, this.y + this.h/2, 10, '#4f4', 25, 1.5);
audio.pickup();
}
upgradeMaxHp() {
if (this.maxHp < this.targetMaxHp) {
this.maxHp += 2;
this.hp = Math.min(this.hp + 2, this.maxHp);
audio.levelup();
game.showDialogue('❤ Max HP increased! You feel stronger.');
}
}
draw(ctx, camX, camY) {
const sx = this.x - camX;
const sy = this.y - camY;
// Invincibility flash
if (this.flashTimer > 0 && Math.floor(this.flashTimer / 80) % 2 === 0) return;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(sx + this.w/2, sy + this.h + 2, this.w/2, 4, 0, 0, Math.PI*2);
ctx.fill();
// Body
ctx.fillStyle = '#4488cc';
ctx.fillRect(sx + 4, sy + 2, this.w - 8, this.h - 2);
// Armor highlight
ctx.fillStyle = '#55aaee';
ctx.fillRect(sx + 6, sy + 2, this.w - 12, 6);
// Eyes based on facing
const eyeOffX = Math.cos(this.facing) * 3;
const eyeOffY = Math.sin(this.facing) * 2;
ctx.fillStyle = '#fff';
ctx.fillRect(sx + 7 + eyeOffX, sy + 6 + eyeOffY, 4, 4);
ctx.fillRect(sx + 13 + eyeOffX, sy + 6 + eyeOffY, 4, 4);
ctx.fillStyle = '#000';
ctx.fillRect(sx + 8 + eyeOffX + Math.cos(this.facing), sy + 7 + eyeOffY, 2, 2);
ctx.fillRect(sx + 14 + eyeOffX + Math.cos(this.facing), sy + 7 + eyeOffY, 2, 2);
// Weapon visualization
const wpn = this.currentWeapon;
const wAngle = this.facing;
const wLen = this.w + (this.attackAnim > 0 ? 8 : 2);
const wx = sx + this.w/2 + Math.cos(wAngle) * wLen;
const wy = sy + this.h/2 + Math.sin(wAngle) * wLen;
ctx.strokeStyle = wpn.color;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(sx + this.w/2, sy + this.h/2);
ctx.lineTo(wx, wy);
ctx.stroke();
// Blade
if (wpn.type === 'melee') {
ctx.fillStyle = wpn.color;
ctx.beginPath();
ctx.arc(wx, wy, 4, 0, Math.PI*2);
ctx.fill();
if (wpn.flame) {
ctx.fillStyle = '#ff0';
ctx.beginPath();
ctx.arc(wx + Math.cos(wAngle)*3, wy + Math.sin(wAngle)*3, 3, 0, Math.PI*2);
ctx.fill();
}
}
// Shield buff
if (this.buffs.shield > 0) {
ctx.strokeStyle = '#4ff';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.arc(sx + this.w/2, sy + this.h/2, this.w, 0, Math.PI*2);
ctx.stroke();
ctx.globalAlpha = 1;
}
}
}
// ===== ENEMY =====
class Enemy {
constructor(x, y, type) {
this.x = x; this.y = y;
this.type = type;
this.flashTimer = 0;
this.attackCooldown = 0;
this.knockbackX = 0; this.knockbackY = 0;
switch(type) {
case 'slime':
this.w = 18; this.h = 18; this.maxHp = 3; this.speed = 0.6;
this.color = '#4a4'; this.contactDmg = 0.5; this.goldDrop = [2,5];
this.xpWorth = 1;
break;
case 'bat':
this.w = 16; this.h = 16; this.maxHp = 2; this.speed = 1.8;
this.color = '#848'; this.contactDmg = 0.5; this.goldDrop = [1,3];
this.xpWorth = 1; this.flying = true;
break;
case 'skeleton':
this.w = 20; this.h = 22; this.maxHp = 5; this.speed = 0.9;
this.color = '#ddd'; this.contactDmg = 1; this.goldDrop = [4,8];
this.xpWorth = 2;
break;
case 'knight':
this.w = 24; this.h = 24; this.maxHp = 8; this.speed = 0.7;
this.color = '#888'; this.contactDmg = 1.5; this.goldDrop = [8,15];
this.xpWorth = 3;
break;
case 'darkmage':
this.w = 20; this.h = 22; this.maxHp = 6; this.speed = 0.5;
this.color = '#808'; this.contactDmg = 1; this.ranged = true;
this.goldDrop = [6,12]; this.xpWorth = 3;
break;
case 'boss':
this.w = 40; this.h = 40; this.maxHp = 30; this.speed = 0.6;
this.color = '#202'; this.contactDmg = 2; this.ranged = true;
this.goldDrop = [50,50]; this.xpWorth = 20;
this.isBoss = true;
break;
}
this.hp = this.maxHp;
}
update(dt, player, world) {
this.attackCooldown -= dt;
if (this.flashTimer > 0) this.flashTimer -= dt;
// Knockback
if (Math.abs(this.knockbackX) > 0.1 || Math.abs(this.knockbackY) > 0.1) {
this.x += this.knockbackX;
this.y += this.knockbackY;
this.knockbackX *= 0.8;
this.knockbackY *= 0.8;
return;
}
const dx = player.x - this.x;
const dy = player.y - this.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < 300) {
const nx = dx / dist;
const ny = dy / dist;
const newX = this.x + nx * this.speed;
const newY = this.y + ny * this.speed;
if (!this.flying) {
if (!this.collides(newX, this.y, world)) this.x = newX;
if (!this.collides(this.x, newY, world)) this.y = newY;
} else {
this.x = newX; this.y = newY;
}
// Contact damage
if (dist < 25 && this.attackCooldown <= 0) {
player.takeDamage(this.contactDmg);
this.attackCooldown = 800;
}
}
}
collides(x, y, world) {
const points = [[x, y], [x+this.w, y], [x, y+this.h], [x+this.w, y+this.h]];
for (const [px, py] of points) {
const tx = Math.floor(px / TILE);
const ty = Math.floor(py / TILE);
const tile = world.getTile(tx, ty);
if (tile === 1 || tile === 2 || tile === 4) return true;
}
return false;
}
takeDamage(amount, fromX, fromY) {
this.hp -= amount;
this.flashTimer = 150;
const angle = Math.atan2(this.y - fromY, this.x - fromX);
this.knockbackX = Math.cos(angle) * 4;
this.knockbackY = Math.sin(angle) * 4;
audio.hit();
particles.emit(this.x + this.w/2, this.y + this.h/2, 6, this.color, 15, 2);
if (this.hp <= 0) {
this.die();
return true;
}
return false;
}
die() {
audio.explosion();
particles.emit(this.x + this.w/2, this.y + this.h/2, 15, this.color, 25, 3);
// Drop gold
const goldAmt = this.goldDrop[0] + Math.floor(Math.random() * (this.goldDrop[1] - this.goldDrop[0] + 1));
for (let i = 0; i < Math.min(goldAmt, 5); i++) {
game.items.push(new ItemPickup(
this.x + Math.random() * this.w,
this.y + Math.random() * this.h,
'coin', goldAmt <= 5 ? goldAmt : Math.ceil(goldAmt / 5)
));
}
// Darkwood enemies drop essences
if (this.type === 'knight' || this.type === 'darkmage') {
if (Math.random() < 0.4) {
game.items.push(new ItemPickup(this.x + this.w/2, this.y + this.h/2, 'essence', 1));
}
}
if (this.isBoss) game.onBossDefeated();
}
draw(ctx, camX, camY) {
const sx = this.x - camX;
const sy = this.y - camY;
if (this.flashTimer > 0) {
ctx.globalAlpha = 0.7;
ctx.fillStyle = '#fff';
} else {
ctx.fillStyle = this.color;
}
if (this.type === 'slime') {
ctx.beginPath();
ctx.ellipse(sx + this.w/2, sy + this.h/2, this.w/2, this.h/2, 0, 0, Math.PI*2);
ctx.fill();
// Eyes
ctx.fillStyle = '#fff';
ctx.fillRect(sx + 4, sy + 6, 4, 4);
ctx.fillRect(sx + 10, sy + 6, 4, 4);
ctx.fillStyle = '#000';
ctx.fillRect(sx + 5, sy + 7, 2, 2);
ctx.fillRect(sx + 11, sy + 7, 2, 2);
} else if (this.type === 'bat') {
ctx.beginPath();
ctx.ellipse(sx + this.w/2, sy + this.h/2, this.w/2, this.h/3, 0, 0, Math.PI*2);
ctx.fill();
// Wings
const wing = Math.sin(Date.now() * 0.01) * 4;
ctx.fillRect(sx - 4, sy + 4 + wing, 6, 3);
ctx.fillRect(sx + this.w - 2, sy + 4 - wing, 6, 3);
// Eyes
ctx.fillStyle = '#f00';
ctx.fillRect(sx + 5, sy + 6, 3, 3);
ctx.fillRect(sx + 9, sy + 6, 3, 3);
} else {
// General enemy body
ctx.fillRect(sx + 2, sy, this.w - 4, this.h);
// Head
ctx.fillRect(sx + 4, sy - 2, this.w - 8, 6);
// Eyes
ctx.fillStyle = this.flashTimer > 0 ? '#fff' : '#f00';
ctx.fillRect(sx + 5, sy + 2, 4, 4);
ctx.fillRect(sx + this.w - 9, sy + 2, 4, 4);
}
ctx.globalAlpha = 1;
// HP bar
if (this.hp < this.maxHp) {
const barW = this.w;
ctx.fillStyle = '#600';
ctx.fillRect(sx, sy - 8, barW, 4);
ctx.fillStyle = '#f44';
ctx.fillRect(sx, sy - 8, barW * (this.hp / this.maxHp), 4);
}
// Boss indicator
if (this.isBoss) {
ctx.strokeStyle = '#f0f';
ctx.lineWidth = 2;
ctx.strokeRect(sx - 4, sy - 4, this.w + 8, this.h + 8);
}
}
}
// ===== PROJECTILE (ranged) =====
class Projectile {
constructor(x, y, angle, speed, dmg, owner) {
this.x = x; this.y = y;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.dmg = dmg;
this.owner = owner;
this.life = 120;
this.size = 5;
}
update(dt, world) {
this.x += this.vx;
this.y += this.vy;
this.life--;
// Check tile collision
const tx = Math.floor(this.x / TILE);
const ty = Math.floor(this.y / TILE);
const tile = world.getTile(tx, ty);
if (tile === 1 || tile === 2 || tile === 4) this.life = 0;
}
draw(ctx, camX, camY) {
const sx = this.x - camX;
const sy = this.y - camY;
ctx.fillStyle = '#ff8';
ctx.beginPath();
ctx.arc(sx, sy, this.size/2, 0, Math.PI*2);
ctx.fill();
// Trail
ctx.fillStyle = 'rgba(255,200,50,0.5)';
ctx.beginPath();
ctx.arc(sx - this.vx*2, sy - this.vy*2, 2, 0, Math.PI*2);
ctx.fill();
}
}
// ===== ITEM PICKUP =====
class ItemPickup {
constructor(x, y, type, amount) {
this.x = x; this.y = y;
this.type = type;
this.amount = amount;
this.bobOffset = Math.random() * Math.PI * 2;
this.life = 99999;
this.collected = false;
}
update(dt) {
this.life--;
// Bob animation
this.bobOffset += 0.05;
}
draw(ctx, camX, camY) {
const sx = this.x - camX;
const sy = this.y - camY + Math.sin(this.bobOffset) * 3;
switch(this.type) {
case 'coin':
ctx.fillStyle = '#ffd700';
ctx.beginPath();
ctx.arc(sx + 6, sy + 6, 5, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#aa8800';
ctx.font = '7px monospace';
ctx.fillText('$', sx + 3, sy + 9);
break;
case 'heart':
ctx.fillStyle = '#f44';
// Heart shape
ctx.beginPath();
ctx.moveTo(sx + 7, sy + 4);
ctx.bezierCurveTo(sx + 7, sy + 2, sx + 4, sy + 1, sx + 4, sy + 4);
ctx.bezierCurveTo(sx + 4, sy + 7, sx + 7, sy + 9, sx + 7, sy + 10);
ctx.bezierCurveTo(sx + 7, sy + 9, sx + 10, sy + 7, sx + 10, sy + 4);
ctx.bezierCurveTo(sx + 10, sy + 1, sx + 7, sy + 2, sx + 7, sy + 4);
ctx.fill();
break;
case 'potion':
ctx.fillStyle = '#f44';
ctx.fillRect(sx + 2, sy + 4, 8, 8);
ctx.fillStyle = '#8f8';
ctx.fillRect(sx + 4, sy + 2, 4, 4);
break;
case 'essence':
ctx.fillStyle = '#a0f';
ctx.beginPath();
ctx.arc(sx + 6, sy + 6, 5, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#f0f';
ctx.beginPath();
ctx.arc(sx + 6, sy + 6, 3, 0, Math.PI*2);
ctx.fill();
break;
default:
ctx.fillStyle = '#fff';
ctx.fillRect(sx + 1, sy + 1, 10, 10);
}
}
}
// ===== GAME =====
class Game {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.canvas.width = W;
this.canvas.height = H;
this.ctx = this.canvas.getContext('2d');
this.minimap = document.getElementById('minimap');
this.mctx = this.minimap.getContext('2d');
// Generate world
const worldData = generateWorld();
this.worldTiles = worldData.tiles;
this.worldMeta = worldData.meta;
// World utility
this.getTile = (x, y) => {
if (x < 0 || x >= WORLD_W || y < 0 || y >= WORLD_H) return 1;
return this.worldTiles[y * WORLD_W + x];
};
// Player
this.player = new Player(8 * TILE, 8 * TILE);
// Entities
this.enemies = [];
this.items = [];
this.projectiles = [];
// Camera
window.camera = new Camera(W, H);
camera = window.camera;
// Input
window.input = new Input();
input = window.input;
// Audio & Particles
window.audio = new AudioSystem();
audio = window.audio;
window.particles = new ParticleSystem();
particles = window.particles;
// Mouse
this.mouse = { x: W/2, y: H/2 };
this.canvas.addEventListener('mousemove', e => {
const rect = this.canvas.getBoundingClientRect();
this.mouse.x = (e.clientX - rect.left) * (W / rect.width);
this.mouse.y = (e.clientY - rect.top) * (H / rect.height);
});
// UI state
this.showInventory = false;
this.showShop = false;
this.dialogue = '';
this.dialogueTimer = 0;
this.gameOver = false;
this.gameWon = false;
this.paused = false;
this.hasSaved = false;
// Spawn enemies
this.spawnEnemies();
// Shop items
this.shopData = [
{ name: 'Health Potion (heals 2HP)', price: 15, action: () => { this.player.heal(2); } },
{ name: 'Speed Elixir (temp boost)', price: 20, action: () => { this.player.buffs.speed = 8000; } },
{ name: 'Shield Charm (1 free hit)', price: 25, action: () => { this.player.buffs.shield = 99999; } },
{ name: 'Quiver (+10 arrows)', price: 10, action: () => { this.player.arrows += 10; } },
{ name: 'Heart Shard (+2 Max HP)', price: 50, action: () => { this.player.upgradeMaxHp(); } },
];
// Track progress
this.midgameTriggered = false;
this.weaponsFound = new Set(['rustySword']);
// Timing
this.lastTime = 0;
this.accumulator = 0;
this.fixedDt = 16;
// Bind inventory close listener
document.addEventListener('keydown', e => {
if (e.key.toLowerCase() === 'e') {
if (this.showShop) return;
this.showInventory = !this.showInventory;
}
if (e.key.toLowerCase() === 'escape') {
this.showInventory = false;
this.showShop = false;
}
});
// Start
this.showDialogue('Welcome to the Ruins. Find chests, defeat enemies, and grow stronger. WASD to move, SPACE to attack.');
}
spawnEnemies() {
// Forest region (32,0) to (63,21)
for (let i = 0; i < 6; i++) {
const x = (34 + Math.random() * 27) * TILE;
const y = (3 + Math.random() * 17) * TILE;
this.enemies.push(new Enemy(x, y, 'slime'));
}
for (let i = 0; i < 3; i++) {
const x = (34 + Math.random() * 27) * TILE;
const y = (3 + Math.random() * 17) * TILE;
this.enemies.push(new Enemy(x, y, 'bat'));
}
// Plains region (32,22) to (63,43)
for (let i = 0; i < 4; i++) {
const x = (34 + Math.random() * 27) * TILE;
const y = (24 + Math.random() * 17) * TILE;
this.enemies.push(new Enemy(x, y, 'skeleton'));
}
for (let i = 0; i < 3; i++) {
const x = (34 + Math.random() * 27) * TILE;
const y = (24 + Math.random() * 17) * TILE;
this.enemies.push(new Enemy(x, y, 'bat'));
}
// Darkwood region (64,22) to (95,43)
for (let i = 0; i < 5; i++) {
const x = (66 + Math.random() * 27) * TILE;
const y = (24 + Math.random() * 17) * TILE;
this.enemies.push(new Enemy(x, y, 'knight'));
}
for (let i = 0; i < 3; i++) {
const x = (66 + Math.random() * 27) * TILE;
const y = (24 + Math.random() * 17) * TILE;
this.enemies.push(new Enemy(x, y, 'darkmage'));
}
// Caves (0,44) to (31,63)
for (let i = 0; i < 5; i++) {
const x = (4 + Math.random() * 24) * TILE;
const y = (47 + Math.random() * 13) * TILE;
this.enemies.push(new Enemy(x, y, 'skeleton'));
}
for (let i = 0; i < 3; i++) {
const x = (4 + Math.random() * 24) * TILE;
const y = (47 + Math.random() * 13) * TILE;
this.enemies.push(new Enemy(x, y, 'bat'));
}
// Ruins (32,44) to (63,63)
for (let i = 0; i < 4; i++) {
const x = (34 + Math.random() * 26) * TILE;
const y = (46 + Math.random() * 14) * TILE;
this.enemies.push(new Enemy(x, y, 'knight'));
}
for (let i = 0; i < 3; i++) {
const x = (34 + Math.random() * 26) * TILE;
const y = (46 + Math.random() * 14) * TILE;
this.enemies.push(new Enemy(x, y, 'darkmage'));
}
// Castle (64,44) to (95,63)
for (let i = 0; i < 4; i++) {
const x = (68 + Math.random() * 23) * TILE;
const y = (48 + Math.random() * 11) * TILE;
this.enemies.push(new Enemy(x, y, 'knight'));
}
for (let i = 0; i < 2; i++) {
const x = (68 + Math.random() * 23) * TILE;
const y = (48 + Math.random() * 11) * TILE;
this.enemies.push(new Enemy(x, y, 'darkmage'));
}
}
spawnBoss() {
this.enemies.push(new Enemy(80 * TILE, 50 * TILE, 'boss'));
this.showDialogue('The Shadow Lord awakens! Defeat him to escape the Ruins!');
}
update(dt) {
if (this.gameOver || this.gameWon) return;
if (this.paused) return;
if (this.showInventory || this.showShop) return;
// Dialogue timer
if (this.dialogueTimer > 0) {
this.dialogueTimer -= dt;
if (this.dialogueTimer <= 0) this.dialogue = '';
}
// Player update
this.player.update(dt, input, this);
// Check for mid-game progression
if (!this.midgameTriggered && this.weaponsFound.size >= 2) {
this.midgameTriggered = true;
this.showDialogue('You feel the wind shift. New paths open before you...');
}
// Player attack
if ((input.isDown(' ') || input.isDown('Space')) && this.player.attackCooldown <= 0) {
this.playerAttack();
}
// Ranged attack
this.player.rangedCooldown = (this.player.rangedCooldown || 0);
if (this.player.rangedCooldown > 0) this.player.rangedCooldown -= dt;
if (input.isDown('shift') && this.player.hasRanged && this.player.arrows > 0 && this.player.rangedCooldown <= 0) {
this.rangedAttack();
}
// Camera
camera.follow(this.player.x + this.player.w/2, this.player.y + this.player.h/2, WORLD_W * TILE, WORLD_H * TILE);
// Update enemies (only those near camera)
const camLeft = camera.x - 100, camRight = camera.x + W + 100;
const camTop = camera.y - 100, camBottom = camera.y + H + 100;
for (let i = this.enemies.length - 1; i >= 0; i--) {
const e = this.enemies[i];
if (e.x + e.w > camLeft && e.x < camRight && e.y + e.h > camTop && e.y < camBottom) {
e.update(dt, this.player, this);
}
if (e.hp <= 0) {
this.enemies.splice(i, 1);
}
}
// Update items
for (let i = this.items.length - 1; i >= 0; i--) {
this.items[i].update(dt);
if (this.items[i].life <= 0) { this.items.splice(i, 1); continue; }
// Collect
const dx = this.player.x + this.player.w/2 - this.items[i].x;
const dy = this.player.y + this.player.h/2 - this.items[i].y;
if (Math.sqrt(dx*dx + dy*dy) < 25) {
this.collectItem(this.items[i]);
this.items.splice(i, 1);
}
}
// Update projectiles
for (let i = this.projectiles.length - 1; i >= 0; i--) {
const p = this.projectiles[i];
p.update(dt, this);
if (p.life <= 0) { this.projectiles.splice(i, 1); continue; }
// Hit enemies
for (let j = this.enemies.length - 1; j >= 0; j--) {
const e = this.enemies[j];
if (p.x > e.x && p.x < e.x + e.w && p.y > e.y && p.y < e.y + e.h) {
const killed = e.takeDamage(p.dmg, p.x, p.y);
this.projectiles.splice(i, 1);
break;
}
}
}
// Particles
particles.update();
// Check interactions (tile in front of player)
this.checkTileInteractions();
// Clear input
input.clearFrame();
}
playerAttack() {
const wpn = this.player.currentWeapon;
this.player.attackCooldown = wpn.cooldown;
this.player.attackAnim = 200;
audio.swing();
// Melee attack
if (wpn.type === 'melee') {
const ax = this.player.x + this.player.w/2 + Math.cos(this.player.facing) * wpn.range * 0.5;
const ay = this.player.y + this.player.h/2 + Math.sin(this.player.facing) * wpn.range * 0.5;
for (const enemy of this.enemies) {
const ex = enemy.x + enemy.w/2;
const ey = enemy.y + enemy.h/2;
const dx = ex - ax;
const dy = ey - ay;
const dist = Math.sqrt(dx*dx + dy*dy);
const angle = Math.atan2(dy, dx);
const angleDiff = Math.abs(angle - this.player.facing);
if (dist < wpn.range && (angleDiff < wpn.arc || angleDiff > Math.PI*2 - wpn.arc)) {
const killed = enemy.takeDamage(wpn.dmg + this.player.buffs.damage, ax, ay);
if (wpn.flame) {
particles.emit(ex, ey, 5, '#f80', 15, 1);
}
}
}
// Slash visual
particles.emit(ax, ay, 3, wpn.color, 10, 2);
}
}
rangedAttack() {
const wpn = WEAPONS.hunterBow;
this.player.rangedCooldown = wpn.cooldown;
this.player.arrows--;
audio.bowshot();
const bx = this.player.x + this.player.w/2;
const by = this.player.y + this.player.h/2;
this.projectiles.push(new Projectile(bx, by, this.player.facing, wpn.speed, wpn.dmg, 'player'));
particles.emit(bx, by, 3, '#ff8', 8, 2);
}
collectItem(item) {
audio.pickup();
switch(item.type) {
case 'coin':
this.player.gold += item.amount;
audio.coin();
break;
case 'heart':
this.player.heal(2);
break;
case 'potion':
this.player.heal(3);
break;
case 'essence':
this.player.essences++;
this.showDialogue(`Dark Essence collected! (${this.player.essences}/3)`);
break;
}
}
checkTileInteractions() {
const px = Math.floor((this.player.x + this.player.w/2) / TILE);
const py = Math.floor((this.player.y + this.player.h/2) / TILE);
// Check surrounding tiles
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const key = `${px+dx},${py+dy}`;
const meta = this.worldMeta[key];
if (!meta) continue;
if (meta.type === 'save' && input.wasPressed('f')) {
this.saveGame();
return;
}
if (meta.type === 'shop' && input.wasPressed('f')) {
this.openShop();
return;
}
if (meta.type === 'chest' && input.wasPressed('f')) {
this.openChest(key, meta);
return;
}
if (meta.type === 'boss' && input.wasPressed('f')) {
this.triggerBoss();
return;
}
}
}
}
openChest(key, meta) {
if (meta.opened) {
this.showDialogue('This chest is empty.');
return;
}
meta.opened = true;
audio.shop();
particles.emit(
parseInt(key.split(',')[0]) * TILE + TILE/2,
parseInt(key.split(',')[1]) * TILE + TILE/2,
15, '#ffd700', 30, 2
);
switch(meta.item) {
case 'steelBlade':
this.player.weapon = 'steelBlade';
this.weaponsFound.add('steelBlade');
this.showDialogue('⚔ Found Steel Blade! More damage, longer reach.');
break;
case 'flameSword':
this.player.weapon = 'flameSword';
this.weaponsFound.add('flameSword');
this.showDialogue('🔥 Found Flame Sword! Wide arc, fire damage!');
break;
case 'hunterBow':
this.player.hasRanged = true;
this.player.arrows += 20;
this.showDialogue('🏹 Found Hunter\'s Bow! Press SHIFT to fire arrows at range.');
break;
case 'heart':
this.player.upgradeMaxHp();
break;
case 'boots':
this.player.speed = 3.0;
this.showDialogue('👢 Found Swift Boots! Movement speed increased.');
this.player.inventory.push({ name: 'Swift Boots', type: 'accessory' });
break;
case 'potion':
this.player.heal(3);
this.showDialogue('🧪 Found Health Potion! HP restored.');
break;
case 'gold50':
this.player.gold += 50;
audio.coin();
this.showDialogue('💰 Found 50 Gold!');
break;
case 'npc_quest':
if (this.player.essences >= 3) {
this.player.essences -= 3;
this.player.gold += 40;
this.player.upgradeMaxHp();
meta.item = 'npc_done';
this.showDialogue('The Elder thanks you! +40 Gold, +2 Max HP!');
} else {
this.showDialogue(`The Elder asks for 3 Dark Essences. You have ${this.player.essences}/3. Defeat knights and mages in Darkwood and Ruins to find them.`);
}
break;
case 'npc_done':
this.showDialogue('Thanks again, adventurer!');
break;
}
}
openShop() {
this.showShop = true;
audio.interact();
this.renderShop();
}
closeShop() {
this.showShop = false;
}
renderShop() {
const panel = document.getElementById('shop-panel');
const goldEl = document.getElementById('shop-gold');
const itemsEl = document.getElementById('shop-items');
goldEl.textContent = this.player.gold;
panel.style.display = 'block';
itemsEl.innerHTML = '';
this.shopData.forEach((item, i) => {
const div = document.createElement('div');
div.className = 'shop-item';
div.innerHTML = `<span class="name">${item.name}</span><span class="price">${item.price}g</span>`;
div.onclick = () => {
if (this.player.gold >= item.price) {
this.player.gold -= item.price;
item.action();
audio.shop();
this.renderShop();
} else {
this.showDialogue('Not enough gold!');
}
};
itemsEl.appendChild(div);
});
}
saveGame() {
this.hasSaved = true;
audio.save();
const saveData = {
playerX: this.player.x,
playerY: this.player.y,
hp: this.player.hp,
maxHp: this.player.maxHp,
gold: this.player.gold,
weapon: this.player.weapon,
hasRanged: this.player.hasRanged,
arrows: this.player.arrows,
essences: this.player.essences,
speed: this.player.speed,
inventory: this.player.inventory,
weaponsFound: [...this.weaponsFound],
openedChests: Object.keys(this.worldMeta).filter(k => this.worldMeta[k].opened),
timestamp: Date.now()
};
localStorage.setItem('ruins_save', JSON.stringify(saveData));
this.showDialogue('💾 Game saved! Your progress has been recorded.');
// Visual effect
particles.emit(this.player.x + this.player.w/2, this.player.y, 20, '#4ff', 40, 2);
}
loadGame() {
const data = localStorage.getItem('ruins_save');
if (!data) return false;
try {
const save = JSON.parse(data);
this.player.x = save.playerX;
this.player.y = save.playerY;
this.player.hp = save.hp;
this.player.maxHp = save.maxHp;
this.player.gold = save.gold;
this.player.weapon = save.weapon;
this.player.hasRanged = save.hasRanged;
this.player.arrows = save.arrows;
this.player.essences = save.essences;
this.player.speed = save.speed;
this.player.inventory = save.inventory || [];
this.weaponsFound = new Set(save.weaponsFound || ['rustySword']);
// Restore opened chests
if (save.openedChests) {
save.openedChests.forEach(k => {
if (this.worldMeta[k]) this.worldMeta[k].opened = true;
});
}
this.showDialogue('💾 Save loaded! Welcome back, adventurer.');
return true;
} catch(e) {
return false;
}
}
triggerBoss() {
// Check if player has flame sword (final weapon) or enough upgrades
if (this.player.weapon !== 'flameSword' && this.player.maxHp < 5) {
this.showDialogue('A powerful evil stirs within... You are not ready yet. Seek the Flame Sword and more health.');
return;
}
this.spawnBoss();
// Mark so it doesn't trigger again
this.worldMeta['80,50'].triggered = true;
}
onBossDefeated() {
this.gameWon = true;
audio.levelup();
particles.emit(this.player.x, this.player.y, 50, '#ffd700', 60, 4);
this.showDialogue('🎉 VICTORY! The Shadow Lord has been vanquished! You have escaped the Ruins! Explore freely or press R to restart.');
}
onPlayerDeath() {
this.gameOver = true;
audio.damage();
this.showDialogue('💀 You have fallen... Press R to restart from your last save.');
}
showDialogue(text) {
this.dialogue = text;
this.dialogueTimer = 4000;
document.getElementById('dialogue-box').textContent = text;
document.getElementById('dialogue-box').style.display = 'block';
}
render() {
const ctx = this.ctx;
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, W, H);
this.drawTiles(ctx);
this.drawItems(ctx);
this.drawEnemies(ctx);
this.drawProjectiles(ctx);
this.player.draw(ctx, camera.x, camera.y);
particles.draw(ctx, camera.x, camera.y);
this.drawHUD(ctx);
if (this.gameWon) this.drawVictoryScreen(ctx);
if (this.gameOver) this.drawDeathScreen(ctx);
}
drawTiles(ctx) {
const startX = Math.max(0, Math.floor(camera.x / TILE));
const startY = Math.max(0, Math.floor(camera.y / TILE));
const endX = Math.min(WORLD_W, Math.ceil((camera.x + W) / TILE) + 1);
const endY = Math.min(WORLD_H, Math.ceil((camera.y + H) / TILE) + 1);
for (let y = startY; y < endY; y++) {
for (let x = startX; x < endX; x++) {
const tile = this.worldTiles[y * WORLD_W + x];
const sx = x * TILE - camera.x;
const sy = y * TILE - camera.y;
switch(tile) {
case 0: // grass
ctx.fillStyle = ((x+y)%2 === 0) ? '#2d5a2d' : '#285028';
ctx.fillRect(sx, sy, TILE, TILE);
break;
case 1: // wall/rock
ctx.fillStyle = '#444';
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = '#555';
ctx.fillRect(sx+2, sy+2, TILE-4, TILE-4);
break;
case 2: // water
ctx.fillStyle = '#226';
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = '#338';
ctx.fillRect(sx+4, sy+4, TILE-8, TILE-8);
break;
case 3: // path
ctx.fillStyle = '#5a4a3a';
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = '#6a5a4a';
ctx.fillRect(sx+4, sy+4, TILE-8, TILE-8);
break;
case 4: // tree
ctx.fillStyle = '#2d5a2d';
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = '#1a3a1a';
ctx.fillRect(sx+4, sy+8, TILE-8, TILE-8);
ctx.fillStyle = '#2a6a2a';
ctx.beginPath();
ctx.arc(sx+TILE/2, sy+8, 10, 0, Math.PI*2);
ctx.fill();
break;
case 5: // bridge
ctx.fillStyle = '#654';
ctx.fillRect(sx, sy, TILE, TILE);
break;
case 6: // door
ctx.fillStyle = '#864';
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = '#a86';
ctx.fillRect(sx+4, sy+4, TILE-8, TILE-8);
break;
case 7: // chest
ctx.fillStyle = ((x+y)%2 === 0) ? '#2d5a2d' : '#285028';
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = '#a86';
ctx.fillRect(sx+6, sy+8, TILE-12, TILE-12);
ctx.fillStyle = '#ffd700';
ctx.fillRect(sx+12, sy+12, TILE-24, TILE-24);
// Opened check
const key = `${x},${y}`;
if (this.worldMeta[key] && this.worldMeta[key].opened) {
ctx.fillStyle = '#553';
ctx.fillRect(sx+6, sy+8, TILE-12, TILE-12);
}
break;
case 8: // save point
ctx.fillStyle = ((x+y)%2 === 0) ? '#2d5a2d' : '#285028';
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = '#4ff';
ctx.beginPath();
ctx.arc(sx+TILE/2, sy+TILE/2, 8, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(sx+TILE/2, sy+TILE/2, 4, 0, Math.PI*2);
ctx.fill();
// Pulsing ring
const pulse = Math.sin(Date.now() * 0.005) * 3;
ctx.strokeStyle = 'rgba(68,255,255,0.4)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(sx+TILE/2, sy+TILE/2, 10 + pulse, 0, Math.PI*2);
ctx.stroke();
break;
case 9: // shop
ctx.fillStyle = '#654';
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = '#ffd700';
ctx.font = '10px monospace';
ctx.fillText('$$$', sx+8, sy+20);
break;
case 10: // stairs
ctx.fillStyle = '#555';
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = '#777';
ctx.fillRect(sx+4, sy+4, TILE-8, TILE-8);
break;
}
}
}
}
drawItems(ctx) {
for (const item of this.items) {
if (item.x > camera.x - 20 && item.x < camera.x + W + 20 &&
item.y > camera.y - 20 && item.y < camera.y + H + 20) {
item.draw(ctx, camera.x, camera.y);
}
}
}
drawEnemies(ctx) {
for (const enemy of this.enemies) {
if (enemy.x + enemy.w > camera.x - 50 && enemy.x < camera.x + W + 50 &&
enemy.y + enemy.h > camera.y - 50 && enemy.y < camera.y + H + 50) {
enemy.draw(ctx, camera.x, camera.y);
}
}
}
drawProjectiles(ctx) {
for (const p of this.projectiles) {
p.draw(ctx, camera.x, camera.y);
}
}
drawHUD(ctx) {
// Heath hearts
const heartSize = 18;
const startX = 12;
const startY = 12;
for (let i = 0; i < Math.ceil(this.player.maxHp / 2); i++) {
const hx = startX + i * (heartSize + 4);
const hy = startY;
const hpInHeart = Math.min(2, Math.max(0, this.player.hp - i * 2));
// Heart background
ctx.fillStyle = '#400';
this.drawHeart(ctx, hx, hy, heartSize);
// Heart fill (based on HP in this heart)
if (hpInHeart > 0) {
ctx.fillStyle = hpInHeart >= 2 ? '#f44' : '#f88';
ctx.save();
ctx.beginPath();
ctx.rect(hx, hy + heartSize * (1 - hpInHeart/2), heartSize, heartSize * hpInHeart/2);
ctx.clip();
this.drawHeart(ctx, hx, hy, heartSize);
ctx.restore();
}
// Heart outline
ctx.strokeStyle = '#a22';
ctx.lineWidth = 1;
this.drawHeart(ctx, hx, hy, heartSize, true);
}
// Gold
ctx.fillStyle = '#ffd700';
ctx.font = 'bold 14px monospace';
ctx.fillText(`💰 ${this.player.gold}`, 12, 50);
// Weapon
ctx.fillStyle = '#aaa';
ctx.font = '11px monospace';
ctx.fillText(`⚔ ${this.player.currentWeapon.name}`, 12, 68);
// Arrows
if (this.player.hasRanged) {
ctx.fillStyle = '#8f8';
ctx.fillText(`🏹 ${this.player.arrows}`, 12, 84);
}
// Essences
if (this.player.essences > 0) {
ctx.fillStyle = '#a0f';
ctx.fillText(`✦ ${this.player.essences}/3`, 12, 100);
}
// Buffs indicator
if (this.player.buffs.speed > 0) {
ctx.fillStyle = '#0ff';
ctx.fillText('⚡ Speed', 12, 116);
}
// Mini-map indicator dots for chests/unopened
// (drawn on minimap canvas separately)
// Attack cooldown indicator
if (this.player.attackCooldown > 0) {
const pct = this.player.attackCooldown / this.player.currentWeapon.cooldown;
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.fillRect(W - 60, H - 20, 50, 8);
ctx.fillStyle = 'rgba(255,200,50,0.6)';
ctx.fillRect(W - 60, H - 20, 50 * (1 - pct), 8);
}
// Interaction hint
const px = Math.floor((this.player.x + this.player.w/2) / TILE);
const py = Math.floor((this.player.y + this.player.h/2) / TILE);
let nearInteractable = false;
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const key = `${px+dx},${py+dy}`;
const meta = this.worldMeta[key];
if (meta && (meta.type === 'chest' || meta.type === 'save' || meta.type === 'shop' || meta.type === 'boss')) {
if (meta.type === 'boss' && meta.triggered) continue;
if (meta.type === 'chest' && meta.opened) continue;
nearInteractable = true;
}
}
}
if (nearInteractable) {
ctx.fillStyle = '#ffd700';
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.fillText('[F] Interact', W/2, H - 30);
ctx.textAlign = 'left';
}
}
drawHeart(ctx, x, y, size, stroke = false) {
const s = size / 12;
ctx.beginPath();
ctx.moveTo(x + 6*s, y + 4*s);
ctx.bezierCurveTo(x + 6*s, y + 2*s, x + 2*s, y + 0*s, x + 2*s, y + 3*s);
ctx.bezierCurveTo(x + 2*s, y + 7*s, x + 6*s, y + 10*s, x + 6*s, y + 11*s);
ctx.bezierCurveTo(x + 6*s, y + 10*s, x + 10*s, y + 7*s, x + 10*s, y + 3*s);
ctx.bezierCurveTo(x + 10*s, y + 0*s, x + 6*s, y + 2*s, x + 6*s, y + 4*s);
if (stroke) {
ctx.stroke();
} else {
ctx.fill();
}
}
drawVictoryScreen(ctx) {
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#ffd700';
ctx.font = 'bold 32px monospace';
ctx.textAlign = 'center';
ctx.fillText('VICTORY!', W/2, H/2 - 40);
ctx.fillStyle = '#ddd';
ctx.font = '14px monospace';
ctx.fillText('The Shadow Lord has been defeated!', W/2, H/2);
ctx.fillText(`Gold: ${this.player.gold} | Weapon: ${this.player.currentWeapon.name}`, W/2, H/2 + 25);
ctx.fillText('Press R to play again', W/2, H/2 + 55);
ctx.textAlign = 'left';
}
drawDeathScreen(ctx) {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#f44';
ctx.font = 'bold 32px monospace';
ctx.textAlign = 'center';
ctx.fillText('YOU DIED', W/2, H/2 - 20);
ctx.fillStyle = '#aaa';
ctx.font = '14px monospace';
ctx.fillText('Press R to restart from last save', W/2, H/2 + 20);
ctx.textAlign = 'left';
}
drawMinimap() {
const ctx = this.mctx;
const mmW = 120, mmH = 120;
const scaleX = mmW / WORLD_W;
const scaleY = mmH / WORLD_H;
ctx.fillStyle = '#0a0a15';
ctx.fillRect(0, 0, mmW, mmH);
// Draw simplified tiles
for (let y = 0; y < WORLD_H; y += 2) {
for (let x = 0; x < WORLD_W; x += 2) {
const tile = this.worldTiles[y * WORLD_W + x];
let color = null;
switch(tile) {
case 1: color = '#444'; break;
case 2: color = '#226'; break;
case 3: color = '#654'; break;
case 4: color = '#262'; break;
case 7: color = this.worldMeta[`${x},${y}`]?.opened ? '#333' : '#ffd700'; break;
case 8: color = '#4ff'; break;
case 9: color = '#f80'; break;
default: color = null;
}
if (color) {
ctx.fillStyle = color;
ctx.fillRect(x * scaleX, y * scaleY, scaleX * 2 + 1, scaleY * 2 + 1);
}
}
}
// Player position
ctx.fillStyle = '#4f4';
ctx.fillRect(
(this.player.x / TILE) * scaleX - 2,
(this.player.y / TILE) * scaleY - 2,
4, 4
);
// Boss
ctx.fillStyle = '#f0f';
ctx.fillRect(80 * scaleX - 2, 50 * scaleY - 2, 4, 4);
}
renderInventoryUI() {
const panel = document.getElementById('inventory-panel');
const grid = document.getElementById('inv-grid');
if (!this.showInventory) {
panel.style.display = 'none';
return;
}
panel.style.display = 'block';
grid.innerHTML = '';
// Show equipped weapon
const wpnSlot = document.createElement('div');
wpnSlot.className = 'inv-slot';
wpnSlot.innerHTML = `<canvas width="40" height="40" id="wpn-canvas"></canvas><div style="position:absolute;top:2px;left:2px;font:9px monospace;color:#fff;">E</div>`;
grid.appendChild(wpnSlot);
setTimeout(() => {
const c = document.getElementById('wpn-canvas');
if (c) {
const cx = c.getContext('2d');
cx.fillStyle = this.player.currentWeapon.color;
cx.fillRect(8, 18, 24, 4);
cx.beginPath();
cx.arc(32, 20, 4, 0, Math.PI*2);
cx.fill();
}
}, 10);
// Show bow if owned
if (this.player.hasRanged) {
const bowSlot = document.createElement('div');
bowSlot.className = 'inv-slot';
bowSlot.innerHTML = `<canvas width="40" height="40" id="bow-canvas"></canvas><div style="position:absolute;top:2px;left:2px;font:9px monospace;color:#fff;">R</div><div class="count">${this.player.arrows}</div>`;
grid.appendChild(bowSlot);
setTimeout(() => {
const c = document.getElementById('bow-canvas');
if (c) {
const cx = c.getContext('2d');
cx.strokeStyle = '#8f8';
cx.lineWidth = 2;
cx.beginPath();
cx.arc(20, 20, 12, -0.8, 0.8);
cx.stroke();
cx.fillStyle = '#fff';
cx.fillRect(20, 18, 15, 4);
}
}, 10);
}
// Show inventory items
const itemCounts = {};
for (const item of this.player.inventory) {
itemCounts[item.name] = (itemCounts[item.name] || 0) + 1;
}
// Also add consumables from game state
if (this.player.essences > 0) itemCounts['Dark Essence'] = this.player.essences;
for (const [name, count] of Object.entries(itemCounts)) {
const slot = document.createElement('div');
slot.className = 'inv-slot';
slot.innerHTML = `<div style="color:#fff;font:10px monospace;text-align:center;">${name.substring(0,6)}</div><div class="count">${count}</div>`;
grid.appendChild(slot);
}
// Fill empty slots
const totalSlots = 10;
const usedSlots = grid.children.length;
for (let i = usedSlots; i < totalSlots; i++) {
const slot = document.createElement('div');
slot.className = 'inv-slot';
slot.style.opacity = '0.3';
grid.appendChild(slot);
}
// Description
document.getElementById('inv-desc').textContent =
`Max HP: ${this.player.maxHp}/${this.player.targetMaxHp} | Speed: ${this.player.speed.toFixed(1)} | Attack: ${this.player.currentWeapon.dmg} | Gold: ${this.player.gold}`;
}
restart() {
localStorage.removeItem('ruins_save');
location.reload();
}
}
// ===== MAIN LOOP =====
let game;
function init() {
game = new Game();
// Try to load save
game.loadGame();
// Restart handler
document.addEventListener('keydown', e => {
if (e.key.toLowerCase() === 'r') {
if (game.gameOver || game.gameWon) game.restart();
}
});
// Close shop on escape
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
game.showShop = false;
document.getElementById('shop-panel').style.display = 'none';
}
});
// Start game loop
requestAnimationFrame(gameLoop);
}
function gameLoop(timestamp) {
if (!game.lastTime) game.lastTime = timestamp;
const delta = timestamp - game.lastTime;
game.lastTime = timestamp;
// Fixed timestep
game.accumulator += delta;
while (game.accumulator >= game.fixedDt) {
game.update(game.fixedDt);
game.accumulator -= game.fixedDt;
}
// Render
game.render();
game.drawMinimap();
game.renderInventoryUI();
// Hide dialogue when timer expires
if (game.dialogueTimer <= 0) {
document.getElementById('dialogue-box').style.display = 'none';
}
requestAnimationFrame(gameLoop);
}
// Start
window.addEventListener('load', init);
</script>
</body>
</html>
<!-- agent-meta {"model":"meituan/longcat-2.0","provider":"openrouter","persona":"full","sessionId":"40e861dd-e999-4b82-becc-6ffc4e94fdc2","tokensIn":622719,"tokensOut":28751,"tokensTotal":651470,"turns":20,"toolCalls":19,"failedToolCalls":0,"timestamp":"2026-07-20T18:22:55.595Z"} -->