RPG
longcat 2.0 · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 60,897 bytes · SHA-256 8e68070f256c
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Realm of Shadows - Action RPG</title>
<style>
/* ── css: main-styles ── */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0f;
overflow: hidden;
font-family: 'Courier New', monospace;
color: #e0e0e0;
user-select: none;
}
canvas#gameCanvas {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
#ui-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#hud {
position: absolute;
top: 10px;
left: 10px;
pointer-events: none;
z-index: 10;
}
#minimap {
position: absolute;
top: 10px;
right: 10px;
border: 2px solid #444;
border-radius: 4px;
pointer-events: none;
z-index: 10;
}
#inventory-panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(10, 10, 20, 0.95);
border: 2px solid #666;
border-radius: 8px;
padding: 20px;
pointer-events: auto;
z-index: 100;
min-width: 400px;
display: none;
}
#inventory-panel.active {
display: block;
}
#shop-panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(10, 10, 20, 0.95);
border: 2px solid #c0a040;
border-radius: 8px;
padding: 20px;
pointer-events: auto;
z-index: 100;
min-width: 450px;
display: none;
}
#shop-panel.active {
display: block;
}
#message-log {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
z-index: 10;
text-align: center;
width: 500px;
}
#save-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 50;
font-size: 24px;
color: #4af;
opacity: 0;
transition: opacity 0.3s;
text-shadow: 0 0 10px #4af;
}
#save-indicator.active {
opacity: 1;
}
#interaction-prompt {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
z-index: 10;
font-size: 14px;
color: #ff0;
opacity: 0;
transition: opacity 0.2s;
text-shadow: 0 0 4px #000;
}
#interaction-prompt.active {
opacity: 1;
}
.item-slot {
display: inline-block;
width: 48px;
height: 48px;
border: 1px solid #555;
border-radius: 4px;
margin: 2px;
background: rgba(30, 30, 50, 0.8);
text-align: center;
line-height: 48px;
font-size: 20px;
cursor: pointer;
position: relative;
vertical-align: middle;
}
.item-slot:hover {
border-color: #aaa;
background: rgba(50, 50, 80, 0.8);
}
.item-slot .count {
position: absolute;
bottom: 2px;
right: 4px;
font-size: 10px;
color: #ff0;
}
.shop-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: 1px solid #444;
border-radius: 4px;
margin: 4px 0;
cursor: pointer;
background: rgba(30, 30, 50, 0.6);
}
.shop-item:hover {
border-color: #c0a040;
background: rgba(50, 50, 30, 0.6);
}
#game-over {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
pointer-events: auto;
z-index: 200;
}
#game-over.active {
display: flex;
}
h2 {
margin-bottom: 10px;
color: #fff;
}
button {
padding: 10px 24px;
background: #333;
border: 1px solid #666;
color: #fff;
font-family: inherit;
font-size: 14px;
cursor: pointer;
border-radius: 4px;
margin: 5px;
}
button:hover {
background: #555;
}
.health-heart {
display: inline-block;
font-size: 22px;
margin: 0 2px;
}
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="hud" style="position:absolute;top:0;left:0;width:0;height:0;overflow:hidden;">
<div id="health-display"></div>
<div id="money-display" style="margin-top:4px;color:#fd0;font-size:14px;"></div>
<div id="weapon-display" style="margin-top:2px;font-size:12px;color:#aaa;"></div>
</div>
<canvas id="minimap" width="160" height="160"></canvas>
<div id="inventory-panel">
<h2>Inventory [I]</h2>
<div id="inventory-slots" style="margin:10px 0;"></div>
<div id="equipment-slots" style="margin:10px 0;font-size:12px;color:#aaa;"></div>
<p style="font-size:11px;color:#888;">Click item to use/equip. [I] to close.</p>
</div>
<div id="shop-panel">
<h2 style="color:#c0a040;">Item Shop [E to close]</h2>
<div id="shop-items" style="margin:10px 0;"></div>
<p id="shop-balance" style="font-size:12px;margin-top:8px;"></p>
</div>
<div id="message-log"></div>
<div id="save-indicator">💾 Game Saved</div>
<div id="interaction-prompt">[E] to interact</div>
<div id="game-over">
<h2 style="color:#f44;">You Have Fallen</h2>
<p style="margin-bottom:20px;color:#aaa;">The shadows consume another soul...</p>
<button id="respawn-btn">Respawn at Last Save</button>
</div>
<div id="start-screen" style="position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;align-items:center;justify-content:center;flex-direction:column;z-index:300;pointer-events:auto;">
<h1 style="color:#c0a040;font-size:36px;margin-bottom:10px;text-shadow:0 0 20px #c0a040;">⚔️ Realm of Shadows</h1>
<p style="color:#888;margin-bottom:20px;font-size:14px;">An Action-Adventure RPG</p>
<div style="color:#aaa;font-size:12px;text-align:center;margin-bottom:20px;line-height:1.8;">
<p>WASD - Move | Mouse - Aim | Click - Attack</p>
<p>I - Inventory | E - Interact/Shop | Space - Roll</p>
<p style="margin-top:8px;color:#666;">Explore • Fight • Collect • Upgrade • Survive</p>
</div>
<button id="start-btn" style="padding:15px 40px;font-size:18px;">Begin Adventure</button>
</div>
<script>
'use strict';
// ── class: AudioEngine ──
// ============================================================
// AUDIO ENGINE - Synthesized sound effects via Web Audio API
// ============================================================
class AudioEngine {
constructor() {
this.ctx = null;
this.enabled = true;
this.initialized = false;
}
init() {
if (this.initialized) return;
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.initialized = true;
} catch(e) {
this.enabled = false;
}
}
play(type) {
if (!this.enabled || !this.ctx) return;
if (this.ctx.state === 'suspended') this.ctx.resume();
switch(type) {
case 'walk': this._walk(); break;
case 'attack': this._attack(); break;
case 'hit': this._hit(); break;
case 'enemy_hit': this._enemyHit(); break;
case 'pickup': this._pickup(); break;
case 'coin': this._coin(); break;
case 'hurt': this._hurt(); break;
case 'save': this._save(); break;
case 'shop': this._shop(); break;
case 'upgrade': this._upgrade(); break;
case 'roll': this._roll(); break;
case 'shoot': this._shoot(); break;
case 'heal': this._heal(); break;
case 'save_point': this._savePoint(); break;
case 'levelup': this._levelup(); break;
case 'death': this._death(); break;
}
}
_tone(freq, duration, type='square', vol=0.15) {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
gain.gain.setValueAtTime(vol, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + duration);
}
_noise(duration, vol=0.1) {
const bufferSize = this.ctx.sampleRate * duration;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const source = this.ctx.createBufferSource();
source.buffer = buffer;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(vol, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
source.connect(gain);
gain.connect(this.ctx.destination);
source.start();
}
_walk() { this._noise(0.05, 0.03); }
_attack() { this._tone(200, 0.1, 'sawtooth', 0.12); this._noise(0.06, 0.08); }
_hit() { this._tone(150, 0.15, 'square', 0.15); this._noise(0.1, 0.1); }
_enemyHit() { this._tone(300, 0.08, 'square', 0.1); this._noise(0.05, 0.06); }
_pickup() { this._tone(600, 0.1, 'sine', 0.1); setTimeout(() => this._tone(800, 0.1, 'sine', 0.08), 80); }
_coin() { this._tone(1200, 0.05, 'sine', 0.08); setTimeout(() => this._tone(1600, 0.08, 'sine', 0.06), 40); }
_hurt() { this._tone(100, 0.3, 'sawtooth', 0.2); this._noise(0.15, 0.12); }
_save() { this._tone(440, 0.15, 'sine', 0.1); setTimeout(() => this._tone(660, 0.15, 'sine', 0.1), 100); setTimeout(() => this._tone(880, 0.2, 'sine', 0.08), 200); }
_shop() { this._tone(500, 0.08, 'triangle', 0.1); setTimeout(() => this._tone(700, 0.08, 'triangle', 0.08), 60); setTimeout(() => this._tone(900, 0.12, 'triangle', 0.06), 120); }
_upgrade() { this._tone(400, 0.1, 'sine', 0.12); setTimeout(() => this._tone(600, 0.1, 'sine', 0.1), 80); setTimeout(() => this._tone(800, 0.15, 'sine', 0.1), 160); setTimeout(() => this._tone(1000, 0.2, 'sine', 0.08), 240); }
_roll() { this._noise(0.15, 0.06); this._tone(300, 0.1, 'triangle', 0.04); }
_shoot() { this._tone(900, 0.12, 'sawtooth', 0.08); this._noise(0.08, 0.06); }
_heal() { this._tone(523, 0.15, 'sine', 0.1); setTimeout(() => this._tone(659, 0.15, 'sine', 0.1), 100); setTimeout(() => this._tone(784, 0.2, 'sine', 0.08), 200); this._noise(0.1, 0.02); }
_savePoint() { this._tone(440, 0.2, 'sine', 0.12); setTimeout(() => this._tone(880, 0.3, 'sine', 0.1), 150); }
_levelup() { this._tone(523, 0.1, 'sine', 0.1); setTimeout(() => this._tone(659, 0.1, 'sine', 0.1), 80); setTimeout(() => this._tone(784, 0.1, 'sine', 0.1), 160); setTimeout(() => this._tone(1047, 0.2, 'sine', 0.12), 240); }
_death() { this._tone(200, 0.5, 'sawtooth', 0.15); setTimeout(() => this._tone(150, 0.5, 'sawtooth', 0.12), 200); setTimeout(() => this._tone(100, 0.8, 'sawtooth', 0.1), 400); }
}
// ── const: audio ──
const audio = new AudioEngine();
// ── class: Particle ──
// ============================================================
// PARTICLE SYSTEM
// ============================================================
class Particle {
constructor(x, y, vx, vy, life, color, size, gravity=0) {
this.x = x; this.y = y;
this.vx = vx; this.vy = vy;
this.life = life; this.maxLife = life;
this.color = color; this.size = size;
this.gravity = gravity;
this.alpha = 1;
}
update(dt) {
this.vx *= 0.98;
this.vy *= 0.98;
this.vy += this.gravity * dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
this.life -= dt;
this.alpha = Math.max(0, this.life / this.maxLife);
}
get dead() { return this.life <= 0; }
}
// ── class: ParticleSystem ──
class ParticleSystem {
constructor() { this.particles = []; }
emit(x, y, count, opts={}) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = (opts.speed || 100) * (0.5 + Math.random());
const vx = Math.cos(angle) * speed + (opts.vx || 0);
const vy = Math.sin(angle) * speed + (opts.vy || 0);
const life = (opts.life || 0.5) * (0.7 + Math.random() * 0.6);
const color = opts.colors ? opts.colors[Math.floor(Math.random() * opts.colors.length)] : (opts.color || '#fff');
const size = (opts.size || 3) * (0.7 + Math.random() * 0.6);
this.particles.push(new Particle(x, y, vx, vy, life, color, size, opts.gravity || 0));
}
}
update(dt) {
for (let i = this.particles.length - 1; i >= 0; i--) {
this.particles[i].update(dt);
if (this.particles[i].dead) this.particles.splice(i, 1);
}
}
render(ctx, cam) {
for (const p of this.particles) {
ctx.globalAlpha = p.alpha;
ctx.fillStyle = p.color;
const s = p.size * p.alpha;
ctx.fillRect(p.x - cam.x - s/2, p.y - cam.y - s/2, s, s);
}
ctx.globalAlpha = 1;
}
}
// ── const: particles ──
const particles = new ParticleSystem();
// ── const: TILE ──
// ============================================================
// GAME CONSTANTS
// ============================================================
const TILE = 32;
// ── const: MAP_W ──
const MAP_W = 50;
// ── const: MAP_H ──
const MAP_H = 40;
// ── const: CANVAS_W ──
const CANVAS_W = 960;
// ── const: CANVAS_H ──
const CANVAS_H = 640;
// ── const: TILE_EMPTY ──
const TILE_EMPTY = 0;
// ── const: TILE_WALL ──
const TILE_WALL = 1;
// ── const: TILE_DOOR ──
const TILE_DOOR = 2;
// ── const: TILE_SAVE ──
const TILE_SAVE = 3;
// ── const: TILE_SHOP ──
const TILE_SHOP = 4;
// ── const: TILE_CHEST ──
const TILE_CHEST = 5;
// ── const: TILE_WATER ──
const TILE_WATER = 6;
// ── const: TILE_GRASS ──
const TILE_GRASS = 7;
// ── const: SOLID_TILES ──
// Tiles that block movement
const SOLID_TILES = new Set([TILE_WALL, TILE_WATER]);
// ── const: WEAPONS ──
const WEAPONS = {
dagger: { name: 'Rusty Dagger', damage: 1, range: 36, arc: 1.2, cooldown: 350, type: 'melee', color: '#888', width: 3 },
sword: { name: 'Steel Sword', damage: 2, range: 48, arc: 1.4, cooldown: 400, type: 'melee', color: '#ccc', width: 4 },
flamesword: { name: 'Flame Blade', damage: 3, range: 52, arc: 1.5, cooldown: 380, type: 'melee', color: '#f80', width: 5, special: 'burn' },
bow: { name: 'Hunter Bow', damage: 2, range: 300, cooldown: 600, type: 'ranged', color: '#8b4513', speed: 400 }
};
// ── const: ITEMS ──
const ITEMS = {
health_potion: { name: 'Health Potion', type: 'consumable', effect: 'heal', value: 2, icon: '🧪', desc: 'Restores 2 HP' },
super_potion: { name: 'Super Potion', type: 'consumable', effect: 'heal', value: 4, icon: '💊', desc: 'Restores 4 HP' },
shield_charm: { name: 'Shield Charm', type: 'accessory', effect: 'defense', value: 1, icon: '🛡️', desc: '+1 Defense' },
speed_boots: { name: 'Swift Boots', type: 'accessory', effect: 'speed', value: 1.3, icon: '👢', desc: '+30% Speed' },
lucky_coin: { name: 'Lucky Coin', type: 'accessory', effect: 'luck', value: 1.5, icon: '🍀', desc: '+50% Gold' },
bomb: { name: 'Bomb', type: 'consumable', effect: 'damage', value: 5, icon: '💣', desc: '5 dmg to nearby enemies' }
};
// ── const: SHOP_ITEMS ──
const SHOP_ITEMS = [
{ id: 'health_potion', price: 15 },
{ id: 'super_potion', price: 30 },
{ id: 'bomb', price: 25 },
{ id: 'shield_charm', price: 50 },
{ id: 'speed_boots', price: 60 }
];
// ── const: ENEMY_TYPES ──
const ENEMY_TYPES = {
slime: { name: 'Slime', hp: 3, damage: 1, speed: 30, color: '#4a4', size: 12, xp: 5, gold: 3, behavior: 'chase' },
skeleton: { name: 'Skeleton', hp: 5, damage: 2, speed: 45, color: '#ddd', size: 14, xp: 10, gold: 5, behavior: 'chase' },
bat: { name: 'Bat', hp: 2, damage: 1, speed: 70, color: '#633', size: 10, xp: 4, gold: 2, behavior: 'erratic' },
knight: { name: 'Dark Knight', hp: 10, damage: 3, speed: 35, color: '#333', size: 16, xp: 20, gold: 10, behavior: 'chase' },
wizard: { name: 'Shadow Wizard', hp: 6, damage: 2, speed: 25, color: '#609', size: 14, xp: 15, gold: 8, behavior: 'ranged' },
boss: { name: 'Shadow Lord', hp: 40, damage: 4, speed: 40, color: '#900', size: 24, xp: 100, gold: 50, behavior: 'boss' }
};
// ── let: spawnTile ──
let spawnTile = { x: 2, y: 2 };
// ── let: savePoints ──
let savePoints = [];
// ── let: shopPos ──
let shopPos = { x: 0, y: 0 };
// ── let: weapon1Room ──
let weapon1Room = { x: 0, y: 0 };
// ── let: weapon2Room ──
let weapon2Room = { x: 0, y: 0 };
// ── let: rangedRoom ──
let rangedRoom = { x: 0, y: 0 };
// ── let: bossRoom ──
let bossRoom = { x: 0, y: 0, room: null };
// ── class: Enemy ──
class Enemy {
constructor(x, y, type) {
const def = ENEMY_TYPES[type];
this.x = x;
this.y = y;
this.type = type;
this.name = def.name;
this.maxHp = def.hp;
this.hp = def.hp;
this.damage = def.damage;
this.speed = def.speed;
this.color = def.color;
this.size = def.size;
this.behavior = def.behavior;
this.xp = def.xp;
this.gold = def.gold;
this.dir = { x: 0, y: 0 };
this.attackCd = 0;
this.knockback = { x: 0, y: 0 };
this.burnTimer = 0;
this.burnDmg = 0;
this.stunTimer = 0;
this.alerted = false;
this.erraticTimer = 0;
this.erraticDir = Math.random() * Math.PI * 2;
}
update(dt, game) {
if (this.stunTimer > 0) {
this.stunTimer -= dt;
this.x += this.knockback.x * dt;
this.y += this.knockback.y * dt;
this.knockback.x *= 0.9;
this.knockback.y *= 0.9;
return;
}
if (this.burnTimer > 0) {
this.burnTimer -= dt;
if (Math.floor(this.burnTimer * 4) !== Math.floor((this.burnTimer + dt) * 4)) {
this.hp -= this.burnDmg;
particles.emit(this.x, this.y, 3, { speed: 30, colors: ['#f80', '#ff0'], life: 0.3, size: 2 });
}
}
if (this.attackCd > 0) this.attackCd -= dt;
const dx = game.player.x - this.x;
const dy = game.player.y - this.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 250) this.alerted = true;
if (!this.alerted) return;
let moveX = 0, moveY = 0;
if (this.behavior === 'chase') {
if (dist > 1) { moveX = dx / dist; moveY = dy / dist; }
} else if (this.behavior === 'erratic') {
this.erraticTimer -= dt;
if (this.erraticTimer <= 0) {
this.erraticTimer = 0.5 + Math.random();
this.erraticDir = Math.random() * Math.PI * 2;
}
if (dist < 120) {
moveX = dx / dist; moveY = dy / dist;
} else {
moveX = Math.cos(this.erraticDir);
moveY = Math.sin(this.erraticDir);
}
} else if (this.behavior === 'ranged') {
if (dist > 100) {
moveX = dx / dist * 0.5; moveY = dy / dist * 0.5;
} else if (dist < 60) {
moveX = -dx / dist; moveY = -dy / dist;
}
if (dist < 200 && this.attackCd <= 0) {
this.attackCd = 2.0;
const angle = Math.atan2(dy, dx);
game.projectiles.push({
x: this.x, y: this.y,
vx: Math.cos(angle) * 200,
vy: Math.sin(angle) * 200,
damage: this.damage,
life: 1.5,
fromPlayer: false,
color: '#a0f'
});
audio.play('shoot');
}
} else if (this.behavior === 'boss') {
if (dist > 60) { moveX = dx / dist; moveY = dy / dist; }
if (this.attackCd <= 0 && dist < 250) {
this.attackCd = 1.5;
const angle = Math.atan2(dy, dx);
for (let i = -1; i <= 1; i++) {
const a = angle + i * 0.3;
game.projectiles.push({
x: this.x, y: this.y,
vx: Math.cos(a) * 180,
vy: Math.sin(a) * 180,
damage: this.damage,
life: 2.0,
fromPlayer: false,
color: '#f0f'
});
}
audio.play('shoot');
}
}
const spd = this.speed;
this.x += moveX * spd * dt;
this.y += moveY * spd * dt;
const tx = Math.floor(this.x / TILE);
const ty = Math.floor(this.y / TILE);
if (tx >= 0 && tx < MAP_W && ty >= 0 && ty < MAP_H && SOLID_TILES.has(game.map[ty][tx])) {
this.x -= moveX * spd * dt * 2;
this.y -= moveY * spd * dt * 2;
}
if (dist < this.size + game.player.size + 5 && this.attackCd <= 0 && this.behavior !== 'ranged' && this.behavior !== 'boss') {
this.attackCd = 1.0;
game.player.takeDamage(this.damage);
} else if (this.behavior === 'boss' && dist < this.size + game.player.size + 5 && this.attackCd <= 0.5) {
game.player.takeDamage(this.damage);
this.attackCd = 1.2;
}
}
takeDamage(amount, fromX, fromY, special) {
this.hp -= amount;
audio.play('hit');
const angle = Math.atan2(this.y - fromY, this.x - fromX);
this.knockback.x = Math.cos(angle) * 150;
this.knockback.y = Math.sin(angle) * 150;
this.stunTimer = 0.1;
this.alerted = true;
particles.emit(this.x, this.y, 6, { speed: 70, colors: ['#fff', this.color], life: 0.3, size: 3 });
if (special === 'burn') {
this.burnTimer = 3.0;
this.burnDmg = 1;
}
return this.hp <= 0;
}
render(ctx, cam) {
const sx = this.x - cam.x;
const sy = this.y - cam.y;
if (sx < -50 || sx > CANVAS_W + 50 || sy < -50 || sy > CANVAS_H + 50) return;
ctx.save();
ctx.translate(sx, sy);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, this.size - 2, this.size * 0.7, 4, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = this.color;
if (this.type === 'slime') {
ctx.beginPath();
ctx.ellipse(0, 0, this.size, this.size * 0.8, 0, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === 'bat') {
ctx.fillRect(-this.size, -4, this.size * 2, 8);
const wing = Math.sin(Date.now() * 0.01) * 4;
ctx.fillRect(-this.size - 4, -8 + wing, 8, 12);
ctx.fillRect(this.size - 4, -8 - wing, 8, 12);
} else {
ctx.beginPath();
ctx.arc(0, 0, this.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#f00';
ctx.fillRect(-4, -4, 3, 3);
ctx.fillRect(2, -4, 3, 3);
if (this.hp < this.maxHp) {
ctx.fillStyle = '#300';
ctx.fillRect(-this.size, -this.size - 8, this.size * 2, 4);
ctx.fillStyle = '#f00';
ctx.fillRect(-this.size, -this.size - 8, this.size * 2 * (this.hp / this.maxHp), 4);
}
if (this.burnTimer > 0) {
ctx.fillStyle = 'rgba(255,100,0,0.3)';
ctx.beginPath();
ctx.arc(0, 0, this.size + 3, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
// ── class: Player ──
class Player {
constructor(x, y) {
this.x = x;
this.y = y;
this.size = 14;
this.speed = 120;
this.maxHp = 3;
this.hp = 3;
this.facing = { x: 1, y: 0 };
this.weapon = 'dagger';
this.inventory = [];
this.gold = 0;
this.xp = 0;
this.level = 1;
this.attackCooldown = 0;
this.invincible = 0;
this.defense = 0;
this.hasRanged = false;
this.weaponUpgrades = [];
this.walkFrame = 0;
this.walkTimer = 0;
this.isMoving = false;
this.attacking = false;
this.attackAnim = 0;
this.rolling = false;
this.rollTimer = 0;
this.rollCd = 0;
this.rollDir = { x: 0, y: 0 };
this.equipped = [];
}
get wepData() { return WEAPONS[this.weapon]; }
get damage() { return this.wepData.damage; }
get effectiveSpeed() {
let s = this.speed;
if (this.equipped.includes('speed_boots')) s *= 1.3;
return s;
}
update(dt, input, game) {
if (this.attackCooldown > 0) this.attackCooldown -= dt * 1000;
if (this.invincible > 0) this.invincible -= dt;
if (this.rollCd > 0) this.rollCd -= dt;
if (this.attacking) {
this.attackAnim -= dt;
if (this.attackAnim <= 0) this.attacking = false;
}
if (this.rolling) {
this.rollTimer -= dt;
this.x += this.rollDir.x * 300 * dt;
this.y += this.rollDir.y * 300 * dt;
if (this.rollTimer <= 0) this.rolling = false;
this._collision(game);
return;
}
let dx = 0, dy = 0;
if (input.keys['w'] || input.keys['arrowup']) dy = -1;
if (input.keys['s'] || input.keys['arrowdown']) dy = 1;
if (input.keys['a'] || input.keys['arrowleft']) dx = -1;
if (input.keys['d'] || input.keys['arrowright']) dx = 1;
this.isMoving = dx !== 0 || dy !== 0;
if (this.isMoving) {
const len = Math.sqrt(dx * dx + dy * dy);
dx /= len; dy /= len;
this.facing = { x: dx, y: dy };
const spd = this.effectiveSpeed;
this.x += dx * spd * dt;
this.y += dy * spd * dt;
this._collision(game);
this.walkTimer += dt;
if (this.walkTimer > 0.18) {
this.walkTimer = 0;
this.walkFrame = (this.walkFrame + 1) % 4;
if (Math.random() < 0.2) audio.play('walk');
}
}
if (input.mouseDown && this.attackCooldown <= 0) {
this.attack(game, input);
}
if (input.keys[' '] && this.rollCd <= 0 && this.isMoving) {
this.rolling = true;
this.rollTimer = 0.25;
this.rollCd = 0.8;
this.invincible = Math.max(this.invincible, 0.3);
this.rollDir = { x: dx || this.facing.x, y: dy || this.facing.y };
audio.play('roll');
particles.emit(this.x, this.y, 5, { speed: 50, colors: ['#aaa', '#888'], life: 0.3, size: 3 });
}
}
attack(game, input) {
this.attacking = true;
this.attackAnim = 0.15;
audio.play(this.wepData.type === 'ranged' ? 'shoot' : 'attack');
if (this.wepData.type === 'ranged') {
const angle = Math.atan2(input.mouseY - (this.y - game.cam.y), input.mouseX - (this.x - game.cam.x));
game.projectiles.push({
x: this.x, y: this.y,
vx: Math.cos(angle) * this.wepData.speed,
vy: Math.sin(angle) * this.wepData.speed,
damage: this.damage,
life: this.wepData.range / this.wepData.speed,
fromPlayer: true,
color: '#ff0'
});
this.attackCooldown = this.wepData.cooldown;
} else {
const angle = Math.atan2(this.facing.y, this.facing.x);
game.meleeHits.push({
x: this.x + Math.cos(angle) * 20,
y: this.y + Math.sin(angle) * 20,
angle,
arc: this.wepData.arc,
range: this.wepData.range,
damage: this.damage,
life: 0.15,
hit: new Set(),
special: this.wepData.special || null
});
particles.emit(
this.x + Math.cos(angle) * 25,
this.y + Math.sin(angle) * 25,
4, { speed: 60, color: this.wepData.color, life: 0.2, size: 3 }
);
this.attackCooldown = this.wepData.cooldown;
}
}
takeDamage(amount) {
if (this.invincible > 0) return false;
const dmg = Math.max(1, amount - this.defense);
this.hp -= dmg;
this.invincible = 1.0;
audio.play('hurt');
particles.emit(this.x, this.y, 8, { speed: 80, colors: ['#f00', '#f44'], life: 0.4, size: 3 });
return true;
}
heal(amount) {
this.hp = Math.min(this.maxHp, this.hp + amount);
particles.emit(this.x, this.y, 10, { speed: 40, colors: ['#0f0', '#4f4', '#fff'], life: 0.5, size: 3, gravity: -20 });
audio.play('heal');
}
addGold(amount) {
let g = amount;
if (this.equipped.includes('lucky_coin')) g = Math.floor(g * 1.5);
this.gold += g;
audio.play('coin');
}
_collision(game) {
const r = this.size;
const checks = [
{ x: this.x - r, y: this.y - r },
{ x: this.x + r, y: this.y - r },
{ x: this.x - r, y: this.y + r },
{ x: this.x + r, y: this.y + r }
];
for (const c of checks) {
const tx = Math.floor(c.x / TILE);
const ty = Math.floor(c.y / TILE);
if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) { this._pushBack(); return; }
if (SOLID_TILES.has(game.map[ty][tx])) { this._pushBack(); return; }
}
}
_pushBack() {
this.x -= this.facing.x * 4;
this.y -= this.facing.y * 4;
}
render(ctx, cam) {
const sx = this.x - cam.x;
const sy = this.y - cam.y;
if (this.invincible > 0 && Math.floor(this.invincible * 10) % 2 === 0) return;
ctx.save();
ctx.translate(sx, sy);
const bobOffset = this.isMoving ? Math.sin(this.walkFrame * Math.PI / 2) * 2 : 0;
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, this.size - 2, this.size * 0.7, 4, 0, 0, Math.PI * 2);
ctx.fill();
let bodyColor = '#4488cc';
if (this.weapon === 'flamesword') bodyColor = '#cc6644';
else if (this.weapon === 'bow') bodyColor = '#668844';
ctx.fillStyle = bodyColor;
ctx.fillRect(-6, -8 + bobOffset, 12, 14);
ctx.fillStyle = '#fca';
ctx.beginPath();
ctx.arc(0, -10 + bobOffset, 6, 0, Math.PI * 2);
ctx.fill();
const ex = this.facing.x * 3;
const ey = this.facing.y * 2 - 10 + bobOffset;
ctx.fillStyle = '#000';
ctx.fillRect(ex - 2, ey - 1, 2, 2);
ctx.fillRect(ex + 1, ey - 1, 2, 2);
const wAngle = Math.atan2(this.facing.y, this.facing.x);
ctx.save();
ctx.rotate(wAngle);
if (this.attacking) {
ctx.rotate(Math.sin((0.15 - this.attackAnim) / 0.15 * Math.PI * 0.7) * 0.6);
}
if (this.weapon === 'bow') {
ctx.strokeStyle = '#8b4513';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(12, 0, 10, -1, 1);
ctx.stroke();
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(12, -10);
ctx.lineTo(12, 10);
ctx.stroke();
} else {
const wData = this.wepData;
ctx.strokeStyle = wData.color;
ctx.lineWidth = wData.width;
ctx.lineCap = 'round';
const wLen = this.weapon === 'dagger' ? 14 : (this.weapon === 'sword' ? 18 : 22);
ctx.beginPath();
ctx.moveTo(8, 0);
ctx.lineTo(8 + wLen, 0);
ctx.stroke();
ctx.strokeStyle = '#aa0';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(7, -3);
ctx.lineTo(7, 3);
ctx.stroke();
if (this.weapon === 'flamesword' && this.attacking) {
ctx.fillStyle = 'rgba(255,100,0,0.6)';
ctx.beginPath();
ctx.arc(8 + wLen, 0, 5, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
ctx.restore();
}
}
// ── const: XP_LEVELS ──
// XP thresholds for leveling. Each level-up increases max HP.
const XP_LEVELS = [0, 30, 80, 150, 250];
// ── const: HP_PER_LEVEL ──
const HP_PER_LEVEL = [3, 3, 4, 4, 5]; // max hp at each level
// ── function: generateMap ──
function generateMap() {
let map, rooms;
let attempts = 0;
do {
map = [];
for (let y = 0; y < MAP_H; y++) { map[y] = []; for (let x = 0; x < MAP_W; x++) map[y][x] = TILE_WALL; }
rooms = [];
for (let i = 0; i < 50; i++) {
const w = 3 + Math.floor(Math.random() * 5);
const h = 3 + Math.floor(Math.random() * 4);
const rx = 1 + Math.floor(Math.random() * (MAP_W - w - 2));
const ry = 1 + Math.floor(Math.random() * (MAP_H - h - 2));
let overlap = false;
for (const r of rooms) { if (rx < r.x + r.w + 1 && rx + w + 1 > r.x && ry < r.y + r.h + 1 && ry + h + 1 > r.y) { overlap = true; break; } }
if (overlap) continue;
rooms.push({ x: rx, y: ry, w, h });
for (let y = ry; y < ry + h; y++) for (let x = rx; x < rx + w; x++) { map[y][x] = TILE_EMPTY; if (Math.random() < 0.03) map[y][x] = TILE_GRASS; }
}
// Connect all rooms
for (let i = 1; i < rooms.length; i++) {
const a = rooms[i-1], b = rooms[i];
let cx = Math.floor(a.x + a.w/2), cy = Math.floor(a.y + a.h/2);
const bx = Math.floor(b.x + b.w/2), by = Math.floor(b.y + b.h/2);
while (cx !== bx) { if (cy >= 0 && cy < MAP_H && cx >= 0 && cx < MAP_W) { map[cy][cx] = TILE_EMPTY; if (cy+1<MAP_H) map[cy+1][cx]=TILE_EMPTY; } cx += cx<bx?1:-1; }
while (cy !== by) { if (cy >= 0 && cy < MAP_H && cx >= 0 && cx < MAP_W) { map[cy][cx] = TILE_EMPTY; if (cx+1<MAP_W) map[cy][cx+1]=TILE_EMPTY; } cy += cy<by?1:-1; }
}
attempts++;
} while (rooms.length < 12 && attempts < 20);
const R = rooms.length;
// Spawn
spawnTile = { x: Math.floor(rooms[0].x + rooms[0].w/2), y: Math.floor(rooms[0].y + rooms[0].h/2) };
// Pick unique room indices for special placements
var used = {};
function pickIdx(preferred, minR) {
if (preferred < R && !used[preferred]) { used[preferred] = true; return preferred; }
for (var i = minR; i < R; i++) { if (!used[i]) { used[i] = true; return i; } }
return Math.min(preferred, R-1);
}
function roomCenter(r) { return { x: Math.floor(r.x+r.w/2), y: Math.floor(r.y+r.h/2) }; }
// Saves: rooms 4, 10, R-3
savePoints = [];
[4, 10, R-3].forEach(function(si) {
si = Math.max(0, Math.min(si, R-1));
var rc = roomCenter(rooms[si]); map[rc.y][rc.x] = TILE_SAVE; savePoints.push({ x: rc.x, y: rc.y, room: rooms[si] });
});
// Shop: room 6
var shopR = rooms[pickIdx(6, 5)]; var sc = roomCenter(shopR); map[sc.y][sc.x] = TILE_SHOP; shopPos = { x: sc.x, y: sc.y };
// Chests in various rooms
for (var ci = 0; ci < 10; ci++) {
var cIdx = 2 + ci * 2;
if (cIdx >= R - 1) break;
var crc = roomCenter(rooms[cIdx]); map[crc.y][crc.x] = TILE_CHEST;
}
// Water
for (var wi = 0; wi < 4; wi++) {
var wr = rooms[3 + Math.floor(Math.random() * Math.max(1, R - 5))];
if (!wr) continue;
var wx = wr.x + 1 + Math.floor(Math.random() * Math.max(1, wr.w - 2));
var wy = wr.y + 1 + Math.floor(Math.random() * Math.max(1, wr.h - 2));
if (wy < MAP_H && wx < MAP_W && map[wy][wx] === TILE_EMPTY) {
map[wy][wx] = TILE_WATER; if (wy+1<MAP_H && map[wy+1][wx]===TILE_EMPTY) map[wy+1][wx]=TILE_WATER;
}
}
// Weapon 1: room 3 (early)
var w1idx = pickIdx(3, 2); var w1rc = roomCenter(rooms[w1idx]);
weapon1Room = { x: w1rc.x * TILE + TILE/2, y: w1rc.y * TILE + TILE/2 };
// Ranged: ~55% through
var rangedIdx = pickIdx(Math.floor(R * 0.55), Math.floor(R * 0.4));
var rangedRC = roomCenter(rooms[rangedIdx]);
rangedRoom = { x: rangedRC.x * TILE + TILE/2, y: rangedRC.y * TILE + TILE/2 };
// Weapon 2: ~75%
var w2idx = pickIdx(Math.floor(R * 0.75), Math.floor(R * 0.6));
var w2rc = roomCenter(rooms[w2idx]);
weapon2Room = { x: w2rc.x * TILE + TILE/2, y: w2rc.y * TILE + TILE/2 };
// Boss: last room
var bossRoomIdx = R - 1;
var bossRC = roomCenter(rooms[bossRoomIdx]);
bossRoom = { x: bossRC.x * TILE + TILE/2, y: bossRC.y * TILE + TILE/2, room: rooms[bossRoomIdx] };
for (var by = rooms[bossRoomIdx].y; by < rooms[bossRoomIdx].y + rooms[bossRoomIdx].h; by++)
for (var bx = rooms[bossRoomIdx].x; bx < rooms[bossRoomIdx].x + rooms[bossRoomIdx].w; bx++)
if (bx>=0&&bx<MAP_W&&by>=0&&by<MAP_H&&map[by][bx]!==TILE_WALL) map[by][bx]=TILE_EMPTY;
return { map, rooms };
}
// ── class: Game ──
class Game {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.canvas.width = CANVAS_W; this.canvas.height = CANVAS_H;
this.ctx = this.canvas.getContext('2d');
this.minimapCanvas = document.getElementById('minimap');
this.minimapCtx = this.minimapCanvas.getContext('2d');
this.cam = { x: 0, y: 0 };
this.player = null;
this.gameOver = false; this.paused = false;
this.showInventory = false; this.showShop = false;
this.bossSpawned = false; this.bossDefeated = false;
this.input = { keys: {}, mouseX: 0, mouseY: 0, mouseDown: false };
this.lastTime = 0; this.saveFlashTimer = 0;
this.reset();
}
reset() {
this.map = []; this.rooms = []; this.enemies = []; this.items = [];
this.projectiles = []; this.meleeHits = []; this.chests = [];
this.interactables = []; this.messages = [];
}
init() {
this.reset();
var gen = generateMap();
this.map = gen.map; this.rooms = gen.rooms;
var px = spawnTile.x * TILE + TILE / 2;
var py = spawnTile.y * TILE + TILE / 2;
this.player = new Player(px, py);
this._calcLevel();
this._spawnEnemies();
this._placeItems();
var self = this;
savePoints.forEach(function(sp) { self.pushI('save', sp.x*TILE+TILE/2, sp.y*TILE+TILE/2); });
if (shopPos.x > 0 && shopPos.y > 0) this.pushI('shop', shopPos.x*TILE+TILE/2, shopPos.y*TILE+TILE/2);
this.pushI('weapon1', weapon1Room.x, weapon1Room.y, 'Steel Sword', 'sword');
this.pushI('weapon2', weapon2Room.x, weapon2Room.y, 'Flame Blade', 'flamesword');
this.pushI('ranged', rangedRoom.x, rangedRoom.y, 'Hunter Bow', 'bow');
if (bossRoom.room && bossRoom.x > 0) this.pushI('boss', bossRoom.x, bossRoom.y);
for (var y = 0; y < MAP_H; y++) for (var x = 0; x < MAP_W; x++) {
if (this.map[y][x] === TILE_CHEST) this.chests.push({ x: x*TILE+TILE/2, y: y*TILE+TILE/2, opened: false, loot: this._rollChest() });
}
this._addMessage('Welcome to the Realm of Shadows...', '#ff0');
this._addMessage('Find weapons, items, and defeat the Shadow Lord!', '#aaa');
}
pushI(type, x, y, label, weaponId) { this.interactables.push({ type: type, x: x, y: y, label: label||'', weaponId: weaponId||'' }); }
_calcLevel() {
var lvl = 1;
for (var i = 0; i < XP_LEVELS.length; i++) if (this.player.xp >= XP_LEVELS[i]) lvl = i + 1;
this.player.level = lvl;
var idx = Math.min(lvl - 1, HP_PER_LEVEL.length - 1);
this.player.maxHp = HP_PER_LEVEL[idx];
this.player.hp = Math.min(this.player.hp, this.player.maxHp);
}
_spawnEnemies() {
for (var i = 1; i < this.rooms.length; i++) {
var r = this.rooms[i], count = 1 + Math.floor(Math.random() * 3);
var types = ['slime'];
if (i > 3) types.push('skeleton');
if (i > 6) types.push('bat');
if (i > 9) types.push('knight');
if (i > 12) types.push('wizard');
for (var j = 0; j < count; j++) {
var ex = (r.x + 1 + Math.floor(Math.random() * Math.max(1,r.w-2))) * TILE + TILE/2;
var ey = (r.y + 1 + Math.floor(Math.random() * Math.max(1,r.h-2))) * TILE + TILE/2;
this.enemies.push(new Enemy(ex, ey, types[Math.floor(Math.random()*types.length)]));
}
}
}
_placeItems() {
var types = ['gold','gold','gold','health_potion','bomb','gold'];
for (var i = 0; i < 25; i++) {
var r = this.rooms[Math.floor(Math.random() * Math.max(1, this.rooms.length))];
if (!r) continue;
var ix = (r.x+1+Math.floor(Math.random()*Math.max(1,r.w-2)))*TILE+TILE/2;
var iy = (r.y+1+Math.floor(Math.random()*Math.max(1,r.h-2)))*TILE+TILE/2;
var t = types[Math.floor(Math.random()*types.length)];
if (t === 'gold') this.items.push({ x: ix, y: iy, type: 'gold', value: 2+Math.floor(Math.random()*5) });
else this.items.push({ x: ix, y: iy, type: t });
}
}
_rollChest() {
var l = [[{type:'gold',value:15}],[{type:'health_potion'},{type:'gold',value:5}],[{type:'super_potion'}],[{type:'shield_charm'}],[{type:'lucky_coin'}],[{type:'bomb'},{type:'bomb'},{type:'gold',value:10}],[{type:'speed_boots'}]];
return l[Math.floor(Math.random()*l.length)];
}
start() { this.init(); this.lastTime = performance.now(); this._loop(); }
_loop() {
var now = performance.now();
var dt = Math.min((now - this.lastTime) / 1000, 0.05);
this.lastTime = now;
if (!this.paused && !this.gameOver) this._update(dt);
this._render();
var self = this;
requestAnimationFrame(function() { self._loop(); });
}
_update(dt) {
this.player.update(dt, this.input, this);
this.cam.x = Math.max(0, Math.min(MAP_W*TILE-CANVAS_W, this.player.x - CANVAS_W/2));
this.cam.y = Math.max(0, Math.min(MAP_H*TILE-CANVAS_H, this.player.y - CANVAS_H/2));
var self = this;
for (var i = this.enemies.length - 1; i >= 0; i--) {
this.enemies[i].update(dt, this);
if (this.enemies[i].hp <= 0) {
var e = this.enemies[i];
this.player.addGold(e.gold); this.player.xp += e.xp;
particles.emit(e.x, e.y, 12, { speed: 80, colors: [e.color,'#fff'], life: 0.6, size: 4 });
self._addMessage('Defeated '+e.name+'! +'+e.xp+' XP, +'+e.gold+'g', '#0f0');
if (e.type === 'boss') { this.bossDefeated = true; self._addMessage('Victory! The Shadow Lord is slain!', '#ff0'); }
var oldLvl = this.player.level;
this._calcLevel();
if (this.player.level > oldLvl) {
self._addMessage('Level Up! Lv'+this.player.level+' Max HP: '+this.player.maxHp, '#f0f');
audio.play('levelup');
particles.emit(this.player.x, this.player.y, 20, { speed: 60, colors: ['#f0f','#ff0','#fff'], life: 0.8, size: 4, gravity: -20 });
}
this.enemies.splice(i, 1);
}
}
for (var i = this.projectiles.length - 1; i >= 0; i--) {
var p = this.projectiles[i];
p.x += p.vx*dt; p.y += p.vy*dt; p.life -= dt;
if (p.life <= 0) { this.projectiles.splice(i,1); continue; }
var tx = Math.floor(p.x/TILE), ty = Math.floor(p.y/TILE);
if (tx>=0&&tx<MAP_W&&ty>=0&&ty<MAP_H&&SOLID_TILES.has(this.map[ty][tx])) {
particles.emit(p.x, p.y, 4, { speed: 40, color: p.color, life: 0.2, size: 2 });
this.projectiles.splice(i,1); continue;
}
if (p.fromPlayer) {
var hit = false;
for (var e of this.enemies) {
var dx=p.x-e.x, dy=p.y-e.y;
if (dx*dx+dy*dy < (e.size+4)*(e.size+4)) { e.takeDamage(p.damage, this.player.x, this.player.y, p.special); particles.emit(p.x,p.y,6,{speed:60,color:'#ff0',life:0.3,size:3}); hit=true; break; }
}
if (hit) { this.projectiles.splice(i,1); continue; }
} else {
var dx=p.x-this.player.x, dy=p.y-this.player.y;
if (dx*dx+dy*dy < (this.player.size+4)*(this.player.size+4)) { this.player.takeDamage(p.damage); particles.emit(p.x,p.y,6,{speed:60,color:'#f0f',life:0.3,size:3}); this.projectiles.splice(i,1); }
}
}
for (var i = this.meleeHits.length - 1; i >= 0; i--) {
var m = this.meleeHits[i]; m.life -= dt;
if (m.life <= 0) { this.meleeHits.splice(i,1); continue; }
for (var e of this.enemies) {
if (m.hit.has(e)) continue;
var dx=e.x-m.x, dy=e.y-m.y, dist=Math.sqrt(dx*dx+dy*dy);
if (dist < m.range + e.size) {
var diff=Math.atan2(dy,dx)-m.angle;
while(diff>Math.PI) diff-=2*Math.PI; while(diff<-Math.PI) diff+=2*Math.PI;
if (Math.abs(diff) < m.arc) { m.hit.add(e); e.takeDamage(m.damage, this.player.x, this.player.y, m.special); }
}
}
}
for (var i = this.items.length - 1; i >= 0; i--) {
var item = this.items[i];
var dx=item.x-this.player.x, dy=item.y-this.player.y;
if (dx*dx+dy*dy < 400) {
if (item.type === 'gold') { this.player.addGold(item.value); self._addMessage('+'+item.value+' gold', '#fd0'); }
else { this.player.inventory.push(item.type); self._addMessage('Found '+ITEMS[item.type].name+'!', '#0ff'); audio.play('pickup'); }
particles.emit(item.x, item.y, 6, { speed: 30, colors: ['#ff0','#ff'], life: 0.4, size: 3, gravity: -30 });
this.items.splice(i, 1);
}
}
particles.update(dt);
if (this.player.hp <= 0 && !this.gameOver) this._playerDeath();
if (this.saveFlashTimer > 0) this.saveFlashTimer -= dt;
}
_playerDeath() { this.gameOver = true; audio.play('death'); document.getElementById('game-over').classList.add('active'); }
respawn() {
this.gameOver = false; document.getElementById('game-over').classList.remove('active');
this.player.hp = this.player.maxHp; this.player.invincible = 2;
this.player.x = spawnTile.x*TILE+TILE/2; this.player.y = spawnTile.y*TILE+TILE/2;
this.enemies = []; this._spawnEnemies();
this._addMessage('Respawned...', '#f80');
}
interact() {
var near = null, nearDist = 1600;
for (var i = 0; i < this.interactables.length; i++) {
var dx = this.interactables[i].x - this.player.x;
var dy = this.interactables[i].y - this.player.y;
if (dx*dx+dy*dy < nearDist) { nearDist = dx*dx+dy*dy; near = this.interactables[i]; }
}
for (var i = 0; i < this.chests.length; i++) {
if (this.chests[i].opened) continue;
var dx = this.chests[i].x - this.player.x;
var dy = this.chests[i].y - this.player.y;
if (dx*dx+dy*dy < 400) { this.openChest(this.chests[i]); return; }
}
if (!near) return;
switch (near.type) {
case 'save': this._saveGame(); break;
case 'shop': this._openShop(); break;
case 'weapon1': case 'weapon2': case 'ranged':
if (!this.player.weaponUpgrades.includes(near.weaponId)) this.player.weaponUpgrades.push(near.weaponId);
this.player.weapon = near.weaponId;
if (near.weaponId === 'bow') this.player.hasRanged = true;
this._addMessage('Acquired '+near.label+'!', '#f0f'); audio.play('upgrade');
particles.emit(near.x, near.y, 15, { speed: 60, colors: ['#f0f','#ff0','#fff'], life: 0.6, size: 4 });
break;
case 'boss':
if (!this.bossSpawned) { this.bossSpawned = true; this.enemies.push(new Enemy(near.x, near.y-50, 'boss')); this._addMessage('The Shadow Lord appears!', '#f00'); }
break;
}
}
openChest(chest) {
if (chest.opened) return;
chest.opened = true; audio.play('pickup');
particles.emit(chest.x, chest.y, 12, { speed: 50, colors: ['#ff0','#fa0'], life: 0.5, size: 4 });
var self = this;
chest.loot.forEach(function(loot) { if (loot.type==='gold') self.player.addGold(loot.value); else { self.player.inventory.push(loot.type); self._addMessage('Found '+ITEMS[loot.type].name+'!','#0ff'); } });
}
_saveGame() {
this.saveFlashTimer = 2;
var el = document.getElementById('save-indicator');
el.classList.add('active');
var self = this;
setTimeout(function() { el.classList.remove('active'); }, 2000);
audio.play('save'); self._addMessage('Game Saved', '#4af');
}
_openShop() { this.showShop = !this.showShop; this.paused = this.showShop; if (this.showShop) this._renderShop(); }
buyItem(itemId, price) {
if (this.player.gold >= price) { this.player.gold -= price; this.player.inventory.push(itemId); audio.play('shop'); this._addMessage('Purchased '+ITEMS[itemId].name+' for '+price+'g', '#c0a040'); this._renderShop(); }
else this._addMessage('Not enough gold!', '#f44');
}
useItem(itemId) {
var item = ITEMS[itemId]; if (!item) return;
if (item.type === 'consumable') {
if (item.effect === 'heal') { this.player.heal(item.value); this._addMessage('Used '+item.name+', +'+item.value+' HP', '#0f0'); }
else if (item.effect === 'damage') {
var count = 0;
for (var e of this.enemies) { var dx=e.x-this.player.x, dy=e.y-this.player.y; if (dx*dx+dy*dy<10000 && e.takeDamage(item.value,this.player.x,this.player.y,null)) count++; }
particles.emit(this.player.x,this.player.y,20,{speed:100,colors:['#f80','#ff0','#f00'],life:0.5,size:5}); audio.play('hit'); this._addMessage('Boom! Hit '+count+' enemies','#f80');
}
var idx = this.player.inventory.indexOf(itemId); if (idx>=0) this.player.inventory.splice(idx,1);
} else if (item.type === 'accessory') {
if (!this.player.equipped.includes(itemId)) { this.player.equipped.push(itemId); if (item.effect==='defense') this.player.defense += item.value; this._addMessage('Equipped '+item.name,'#0ff'); audio.play('upgrade'); }
}
}
_addMessage(text, color) { this.messages.push({ text: text, color: color||'#fff', time: 4 }); }
_renderShop() {
var panel = document.getElementById('shop-panel');
if (!this.showShop) { panel.classList.remove('active'); return; }
panel.classList.add('active');
var container = document.getElementById('shop-items'); container.innerHTML = '';
for (var i = 0; i < SHOP_ITEMS.length; i++) {
var si = SHOP_ITEMS[i], item = ITEMS[si.id];
var div = document.createElement('div'); div.className = 'shop-item';
div.innerHTML = '<span>'+item.icon+' '+item.name+' - '+item.desc+'</span><span style="color:#fd0;">'+si.price+'g</span>';
(function(id, pr) { div.onclick = function() { game.buyItem(id, pr); }; })(si.id, si.price);
container.appendChild(div);
}
document.getElementById('shop-balance').textContent = 'Your gold: '+this.player.gold+'g';
}
renderInventory() {
var panel = document.getElementById('inventory-panel');
if (!this.showInventory) { panel.classList.remove('active'); return; }
panel.classList.add('active');
var container = document.getElementById('inventory-slots'); container.innerHTML = '';
var counts = {};
for (var i = 0; i < this.player.inventory.length; i++) { var id = this.player.inventory[i]; counts[id] = (counts[id]||0)+1; }
for (var id in counts) {
var item = ITEMS[id];
var div = document.createElement('div'); div.className = 'item-slot'; div.title = item.desc;
div.innerHTML = item.icon + '<span class="count">' + counts[id] + '</span>';
(function(itemId) { div.onclick = function() { game.useItem(itemId); game.renderInventory(); }; })(id);
container.appendChild(div);
}
var eqDiv = document.getElementById('equipment-slots');
var eq = 'Equipped: '+(this.player.equipped.length===0?'None':this.player.equipped.map(function(id){return ITEMS[id].name;}).join(', '));
eq += ' | HP: '+this.player.hp+'/'+this.player.maxHp+' | Def: '+this.player.defense;
eqDiv.textContent = eq;
}
_drawHeart(ctx, x, y, size, filled) {
ctx.save(); ctx.translate(x,y); ctx.scale(size/16,size/16);
ctx.fillStyle = filled ? '#f22' : '#400';
ctx.beginPath(); ctx.moveTo(0,-4);
ctx.bezierCurveTo(-8,-16,-16,-4,0,8); ctx.bezierCurveTo(16,-4,8,-16,0,-4); ctx.fill();
if (filled) { ctx.fillStyle='rgba(255,100,100,0.4)'; ctx.beginPath(); ctx.arc(-4,-6,3,0,Math.PI*2); ctx.fill(); }
ctx.restore();
}
_render() {
var ctx = this.ctx; ctx.clearRect(0,0,CANVAS_W,CANVAS_H);
var startX=Math.max(0,Math.floor(this.cam.x/TILE)), startY=Math.max(0,Math.floor(this.cam.y/TILE));
var endX=Math.min(MAP_W,Math.ceil((this.cam.x+CANVAS_W)/TILE)+1), endY=Math.min(MAP_H,Math.ceil((this.cam.y+CANVAS_H)/TILE)+1);
for (var y=startY; y<endY; y++) for (var x=startX; x<endX; x++) {
var tile=this.map[y][x], sx=x*TILE-this.cam.x, sy=y*TILE-this.cam.y;
switch(tile) {
case TILE_EMPTY: ctx.fillStyle='#1a1a2e'; ctx.fillRect(sx,sy,TILE,TILE); break;
case TILE_WALL: ctx.fillStyle='#2a2a3e'; ctx.fillRect(sx,sy,TILE,TILE); ctx.fillStyle='#3a3a4e'; ctx.fillRect(sx+1,sy+1,TILE-2,TILE-2); break;
case TILE_GRASS: ctx.fillStyle='#1a2a1e'; ctx.fillRect(sx,sy,TILE,TILE); ctx.fillStyle='#2a4a2e'; ctx.fillRect(sx+4,sy+4,3,3); ctx.fillRect(sx+14,sy+8,3,3); ctx.fillRect(sx+22,sy+2,3,3); break;
case TILE_WATER: ctx.fillStyle='#1a2a4e'; ctx.fillRect(sx,sy,TILE,TILE); ctx.fillStyle='#2a3a6e'; ctx.fillRect(sx+4,sy+4,TILE-8,3); break;
case TILE_SAVE:
ctx.fillStyle='#1a1a2e'; ctx.fillRect(sx,sy,TILE,TILE); ctx.fillStyle='#4488ff';
ctx.beginPath(); ctx.moveTo(sx+TILE/2,sy+6); ctx.lineTo(sx+TILE-8,sy+TILE/2);
ctx.lineTo(sx+TILE/2,sy+TILE-6); ctx.lineTo(sx+8,sy+TILE/2); ctx.closePath(); ctx.fill();
ctx.fillStyle='#88bbff'; ctx.beginPath(); ctx.arc(sx+TILE/2,sy+TILE/2,4,0,Math.PI*2); ctx.fill(); break;
case TILE_SHOP:
ctx.fillStyle='#2a2a1e'; ctx.fillRect(sx,sy,TILE,TILE);
ctx.fillStyle='#c0a040'; ctx.font='18px serif'; ctx.textAlign='center'; ctx.fillText('$',sx+TILE/2,sy+TILE/2+6); ctx.textAlign='left'; break;
case TILE_CHEST:
ctx.fillStyle='#1a1a2e'; ctx.fillRect(sx,sy,TILE,TILE);
var ch=this.chests.find(function(c){return Math.abs(c.x-(x*TILE+TILE/2))<1 && Math.abs(c.y-(y*TILE+TILE/2))<1;});
if (ch && !ch.opened) { ctx.fillStyle='#8b4513'; ctx.fillRect(sx+6,sy+10,TILE-12,TILE-14); ctx.fillStyle='#c0a040'; ctx.fillRect(sx+6,sy+14,TILE-12,4); ctx.fillStyle='#ff0'; ctx.fillRect(sx+13,sy+12,6,6); }
else { ctx.fillStyle='#5a3010'; ctx.fillRect(sx+6,sy+14,TILE-12,TILE-18); }
break;
}
}
for (var i = 0; i < this.interactables.length; i++) {
var inter = this.interactables[i], sx=inter.x-this.cam.x, sy=inter.y-this.cam.y;
if (sx<-50||sx>CANVAS_W+50||sy<-50||sy>CANVAS_H+50) continue;
if (inter.type==='weapon1'||inter.type==='weapon2'||inter.type==='ranged') {
if (inter.weaponId===this.player.weapon) continue;
ctx.fillStyle='rgba(255,0,255,0.2)'; ctx.beginPath(); ctx.arc(sx,sy,18+Math.sin(Date.now()*0.003)*3,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#808'; ctx.fillRect(sx-10,sy-4,20,8);
ctx.font='16px serif'; ctx.textAlign='center'; ctx.fillStyle='#f0f';
ctx.fillText(inter.type==='ranged'?'///':'++',sx,sy+5); ctx.textAlign='left';
} else if (inter.type==='boss' && !this.bossDefeated) {
ctx.fillStyle='rgba(255,0,0,0.3)'; ctx.beginPath(); ctx.arc(sx,sy,20+Math.sin(Date.now()*0.005)*5,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#f00'; ctx.font='16px serif'; ctx.textAlign='center'; ctx.fillText('XX',sx,sy+5); ctx.textAlign='left';
}
}
for (var i = 0; i < this.items.length; i++) {
var item=this.items[i], sx=item.x-this.cam.x, sy=item.y-this.cam.y;
if (sx<-20||sx>CANVAS_W+20||sy<-20||sy>CANVAS_H+20) continue;
if (item.type==='gold') { ctx.fillStyle='#fd0'; ctx.beginPath(); ctx.arc(sx,sy,5,0,Math.PI*2); ctx.fill(); ctx.fillStyle='#a80'; ctx.beginPath(); ctx.arc(sx,sy,3,0,Math.PI*2); ctx.fill(); }
else { ctx.fillStyle='rgba(0,100,255,0.5)'; ctx.beginPath(); ctx.arc(sx,sy,8,0,Math.PI*2); ctx.fill(); ctx.font='12px serif'; ctx.textAlign='center'; ctx.fillStyle='#fff'; ctx.fillText(ITEMS[item.type].icon,sx,sy+4); ctx.textAlign='left'; }
}
for (var i = 0; i < this.enemies.length; i++) this.enemies[i].render(ctx, this.cam);
if (this.player) this.player.render(ctx, this.cam);
for (var i = 0; i < this.meleeHits.length; i++) {
var m=this.meleeHits[i], sx=m.x-this.cam.x, sy=m.y-this.cam.y;
ctx.strokeStyle='rgba(255,255,255,'+(m.life/0.15)+')'; ctx.lineWidth=3;
ctx.beginPath(); ctx.arc(sx,sy,m.range,m.angle-m.arc,m.angle+m.arc); ctx.stroke();
}
for (var i = 0; i < this.projectiles.length; i++) {
var p=this.projectiles[i], sx=p.x-this.cam.x, sy=p.y-this.cam.y;
ctx.fillStyle=p.color; ctx.beginPath(); ctx.arc(sx,sy,4,0,Math.PI*2); ctx.fill();
ctx.fillStyle=p.color+'44'; ctx.beginPath(); ctx.arc(sx-p.vx*0.02,sy-p.vy*0.02,3,0,Math.PI*2); ctx.fill();
}
particles.render(ctx, this.cam);
// HUD
if (this.player) {
var fullH=Math.floor(this.player.hp/2), half=this.player.hp%2===1, maxH=Math.ceil(this.player.maxHp/2);
for (var i=0; i<maxH; i++) {
var hx=16+i*22, hy=18;
if (i<fullH) this._drawHeart(ctx,hx,hy,14,true);
else if (i===fullH && half) {
ctx.save(); ctx.beginPath(); ctx.rect(hx-8,hy-8,8,16); ctx.clip();
this._drawHeart(ctx,hx,hy,14,true); ctx.restore();
ctx.save(); ctx.globalAlpha=0.4; this._drawHeart(ctx,hx,hy,14,false); ctx.restore();
} else this._drawHeart(ctx,hx,hy,14,false);
}
ctx.fillStyle='#fd0'; ctx.font='bold 16px Courier New'; ctx.textAlign='left';
ctx.fillText('Gold: '+this.player.gold, 16, 50);
ctx.fillStyle='#fff'; ctx.font='13px Courier New';
ctx.fillText('Lv'+this.player.level+' XP:'+this.player.xp+' HP:'+this.player.hp+'/'+this.player.maxHp, 16, 68);
var xpNext=XP_LEVELS[Math.min(this.player.level,XP_LEVELS.length-1)]||999;
var xpPrev=XP_LEVELS[Math.max(0,this.player.level-2)]||0;
var prog=this.player.level<XP_LEVELS.length?(this.player.xp-xpPrev)/(xpNext-xpPrev):1;
ctx.fillStyle='#333'; ctx.fillRect(16,73,120,6);
ctx.fillStyle='#a0f'; ctx.fillRect(16,73,Math.max(0,Math.min(120,prog*120)),6);
ctx.fillStyle='#aaa'; ctx.font='12px Courier New';
ctx.fillText(this.player.wepData.name+' (Atk '+this.player.damage+')', 16, 95);
if (this.player.inventory.length > 0) {
ctx.fillStyle='#888'; ctx.font='10px Courier New';
ctx.fillText('Items: '+this.player.inventory.map(function(id){return ITEMS[id].icon;}).join(''), 16, 112);
}
}
// Interaction prompt
var nearI=null, nearD=1600;
for (var i=0;i<this.interactables.length;i++) {
var dx=this.interactables[i].x-this.player.x, dy=this.interactables[i].y-this.player.y;
if (dx*dx+dy*dy<nearD) { nearD=dx*dx+dy*dy; nearI=this.interactables[i]; }
}
var promptEl=document.getElementById('interaction-prompt');
if (nearI && !this.showShop) {
var txt='[E] ';
if (nearI.type==='save') txt+='Save Game';
else if (nearI.type==='shop') txt+='Open Shop';
else if (nearI.type==='weapon1'||nearI.type==='weapon2'||nearI.type==='ranged') txt+='Pick up '+nearI.label;
else if (nearI.type==='boss') txt+='Boss Arena';
promptEl.textContent=txt; promptEl.classList.add('active');
} else promptEl.classList.remove('active');
this._renderMessages();
if (this.saveFlashTimer > 0) {
ctx.fillStyle='rgba(68,170,255,'+Math.min(1,this.saveFlashTimer)+')';
ctx.font='bold 24px Courier New'; ctx.textAlign='center';
ctx.fillText('Game Saved!',CANVAS_W/2,CANVAS_H/2-50); ctx.textAlign='left';
}
this._renderMinimap();
}
_renderMessages() {
var container=document.getElementById('message-log');
for (var i=this.messages.length-1;i>=0;i--) { this.messages[i].time-=0.016; if(this.messages[i].time<=0) this.messages.splice(i,1); }
container.innerHTML=this.messages.slice(-5).map(function(m){return '<div style="color:'+m.color+';font-size:13px;margin:2px 0;text-shadow:0 0 4px '+m.color+';">'+m.text+'</div>';}).join('');
}
_renderMinimap() {
var ctx=this.minimapCtx, mw=this.minimapCanvas.width, mh=this.minimapCanvas.height;
ctx.fillStyle='#111'; ctx.fillRect(0,0,mw,mh);
var sx=mw/MAP_W, sy=mh/MAP_H;
for (var y=0;y<MAP_H;y++) for (var x=0;x<MAP_W;x++) {
var t=this.map[y][x];
if (t===TILE_WALL) ctx.fillStyle='#333';
else if (t===TILE_EMPTY||t===TILE_GRASS) ctx.fillStyle='#555';
else if (t===TILE_WATER) ctx.fillStyle='#246';
else if (t===TILE_SAVE) ctx.fillStyle='#4af';
else if (t===TILE_SHOP) ctx.fillStyle='#ca4';
else if (t===TILE_CHEST) ctx.fillStyle='#fa0';
else continue;
ctx.fillRect(x*sx,y*sy,sx+0.5,sy+0.5);
}
ctx.fillStyle='#0f0'; ctx.fillRect((this.player.x/TILE)*sx-1,(this.player.y/TILE)*sy-1,3,3);
ctx.fillStyle='#f44';
for (var i=0;i<this.enemies.length;i++) ctx.fillRect((this.enemies[i].x/TILE)*sx-1,(this.enemies[i].y/TILE)*sy-1,2,2);
}
}
// ── var: game ──
var game = new Game();
// ── main ──
document.addEventListener('keydown', function(e) {
var key = e.key.toLowerCase();
game.input.keys[key] = true;
if (key === 'i') { game.showInventory = !game.showInventory; game.paused = game.showInventory; game.renderInventory(); e.preventDefault(); }
if (key === 'e') {
if (game.showShop) { game.showShop = false; game.paused = false; document.getElementById('shop-panel').classList.remove('active'); }
else game.interact();
e.preventDefault();
}
if (key === 'escape') { game.showInventory = false; game.showShop = false; game.paused = false; document.getElementById('inventory-panel').classList.remove('active'); document.getElementById('shop-panel').classList.remove('active'); }
if (key === ' ') e.preventDefault();
});
document.addEventListener('keyup', function(e) { game.input.keys[e.key.toLowerCase()] = false; });
game.canvas.addEventListener('mousemove', function(e) {
var rect = game.canvas.getBoundingClientRect();
game.input.mouseX = (e.clientX - rect.left) * (CANVAS_W / rect.width);
game.input.mouseY = (e.clientY - rect.top) * (CANVAS_H / rect.height);
});
game.canvas.addEventListener('mousedown', function(e) { if (e.button === 0) { audio.init(); game.input.mouseDown = true; } });
game.canvas.addEventListener('mouseup', function(e) { if (e.button === 0) game.input.mouseDown = false; });
game.canvas.addEventListener('contextmenu', function(e) { e.preventDefault(); });
document.getElementById('start-btn').addEventListener('click', function() {
audio.init();
document.getElementById('start-screen').style.display = 'none';
game.start();
});
document.getElementById('respawn-btn').addEventListener('click', function() { game.respawn(); });
function resizeCanvas() {
var maxW = window.innerWidth - 20, maxH = window.innerHeight - 20;
var scale = Math.min(maxW / CANVAS_W, maxH / CANVAS_H);
game.canvas.style.width = (CANVAS_W * scale) + 'px';
game.canvas.style.height = (CANVAS_H * scale) + 'px';
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
</script>
</body>
</html>
<!-- agent-meta {"model":"meituan/longcat-2.0","provider":"openrouter","persona":"composer","sessionId":"8314909c-0f02-4867-8791-29ecea0a71a4","tokensIn":4743640,"tokensOut":97667,"tokensTotal":4841307,"turns":68,"toolCalls":67,"failedToolCalls":7,"timestamp":"2026-07-20T18:06:40.597Z"} -->