RPG
Deepseek v4 Pro · middle

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 53,409 bytes · SHA-256 49169fb60073
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shard of the Verdant Blade</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;overflow:hidden;font-family:'Segoe UI',sans-serif;}
canvas{display:block;image-rendering:pixelated;border:2px solid #333;border-radius:4px;}
#ui-overlay{position:absolute;pointer-events:none;}
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
// ────────────────────────────────────────
// SHARD OF THE VERDANT BLADE - Action RPG
// ────────────────────────────────────────
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
// Responsive sizing
const W = 800, H = 600;
canvas.width = W; canvas.height = H;
const TILE = 32;
const COLS = 25, ROWS = 19; // visible tiles
// ── AUDIO ENGINE (Web Audio API) ──
let audioCtx = null;
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
function playTone(freq, dur, type='square', vol=0.08, slide=0) {
if (!audioCtx) return;
const t = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
if (slide) osc.frequency.linearRampToValueAtTime(freq + slide, t + dur);
gain.gain.setValueAtTime(vol, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + dur);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(t); osc.stop(t + dur);
}
function sfxStep() { playTone(80, 0.06, 'sine', 0.03); }
function sfxSwing() { playTone(200, 0.12, 'sawtooth', 0.07, -80); }
function sfxHit() { playTone(60, 0.15, 'square', 0.1); playTone(40, 0.2, 'triangle', 0.08); }
function sfxPickup() { playTone(600, 0.08, 'sine', 0.06); setTimeout(()=>playTone(900, 0.1, 'sine', 0.06), 80); }
function sfxCoin() { playTone(1200, 0.05, 'square', 0.04); setTimeout(()=>playTone(1400, 0.06, 'square', 0.04), 50); }
function sfxShop() { playTone(400, 0.1, 'triangle', 0.06); setTimeout(()=>playTone(500, 0.12, 'triangle', 0.06), 100); }
function sfxSave() { playTone(300, 0.2, 'sine', 0.07); setTimeout(()=>playTone(500, 0.3, 'sine', 0.07), 150); }
function sfxBow() { playTone(300, 0.08, 'triangle', 0.05); playTone(150, 0.2, 'sine', 0.04); }
function sfxUpgrade() { playTone(440, 0.1, 'sine', 0.06); setTimeout(()=>playTone(660, 0.1, 'sine', 0.06), 100); setTimeout(()=>playTone(880, 0.15, 'sine', 0.07), 200); }
function sfxDamage() { playTone(50, 0.3, 'sawtooth', 0.12); }
// ── PARTICLE SYSTEM ──
let particles = [];
function spawnParticles(x, y, count, color, speed=2, life=20) {
for (let i = 0; i < count; i++) {
const a = Math.random() * Math.PI * 2;
const s = speed * (0.5 + Math.random());
particles.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s, life, maxLife: life, color });
}
}
// ── INPUT ──
const keys = {};
window.addEventListener('keydown', e => {
keys[e.key.toLowerCase()] = true;
if (e.key.toLowerCase() === 'e') keys['interact'] = true;
if (e.key.toLowerCase() === 'i') { STATE.inventoryOpen = !STATE.inventoryOpen; e.preventDefault(); return; }
if (e.key.toLowerCase() === 'f') keys['attack'] = true;
e.preventDefault();
});
window.addEventListener('keyup', e => {
keys[e.key.toLowerCase()] = false;
if (e.key.toLowerCase() === 'e') keys['interact'] = false;
if (e.key.toLowerCase() === 'f') keys['attack'] = false;
e.preventDefault();
});
canvas.addEventListener('click', () => initAudio());
// ── GAME STATE ──
const STATE = {
player: {
x: 5 * TILE + TILE / 2, y: 8 * TILE + TILE / 2,
vx: 0, vy: 0,
hp: 3, maxHp: 5,
weapon: 0, // 0=rusty sword, 1=broadsword, 2=dual blades
hasRanged: false, // discovered mid-game
rangedAmmo: 10, maxAmmo: 20,
gold: 0,
inventory: [], // [{id, name, desc, type}] type: 'consumable','accessory','key'
accessories: [], // equipped accessories (max 2)
speed: 2.5,
attackCooldown: 0,
attackDuration: 0,
facing: 0, // 0=down, 1=up, 2=left, 3=right
invincible: 0,
animFrame: 0,
stepTimer: 0,
currentRoom: 0,
},
rooms: [], // populated below
enemies: [],
projectiles: [],
pickups: [], // ground items / coins
savePoints: [],
shopOpen: false,
dialogText: '',
dialogTimer: 0,
gameTime: 0,
totalKills: 0,
totalGoldEarned: 0,
upgradesFound: 0,
rangedFound: false,
gameComplete: false,
inventoryOpen: false,
};
// ── MAP DEFINITIONS ──
// Legend: 0=void, 1=floor, 2=wall, 3=water, 4=door, 5=chest, 6=shop, 7=save
// Rooms are 20x15 tiles
function makeRoom(data) {
// data: { tiles: 2D array, enemies: [{x,y,type}], pickups: [{x,y,type}], exits: {dir:roomIdx}, name }
return data;
}
const ROOM_DEFS = [];
// Room 0: Village (start)
ROOM_DEFS.push({
tiles: (()=>{
const t = [];
for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
if (x===0||x===19||y===0||y===14) t[y][x]=2;
else t[y][x]=1;
}}
// Buildings
for (let x=2;x<=5;x++) for (let y=2;y<=4;y++) t[y][x]=2;
t[2][3]=1; t[2][4]=1; // doorways
for (let x=8;x<=11;x++) for (let y=3;y<=5;y++) t[y][x]=2;
t[3][9]=1; t[5][8]=1;
// Water well
t[10][14]=3; t[10][15]=3; // water
t[9][14]=2; t[11][14]=2;
// Shop stall
t[5][13]=6; t[5][14]=6;
t[4][13]=2; t[6][13]=2; t[4][14]=2; t[6][14]=2;
// Save point
t[16][7]=7;
t[15][7]=2; t[17][7]=2; t[16][6]=2; t[16][8]=2;
// Path to exit (right)
for (let x=17;x<20;x++) t[13][x]=1;
// Also open a path at row 7 for return
t[7][0]=1; t[7][1]=1;
return t;
})(),
enemies: [],
pickups: [{x:3*TILE+TILE/2, y:10*TILE+TILE/2, type:'coin', amount:10},
{x:16*TILE+TILE/2, y:10*TILE+TILE/2, type:'coin', amount:5}],
exits: { right: 1 },
name: 'Village',
});
// Room 1: Forest Path
ROOM_DEFS.push({
tiles: (()=>{
const t = [];
for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
if (x===0||x===19||y===0||y===14) {
if ((x===0&&y===7)||(x===19&&y===7)||(y===0&&x===10)||(y===14&&x===10)) t[y][x]=1;
else t[y][x]=2;
}
else t[y][x]=1;
}}
// Trees/obstacles
for (let i=0;i<8;i++) {
const tx=3+Math.floor(Math.random()*14), ty=3+Math.floor(Math.random()*9);
t[ty][tx]=2; t[ty][tx+1]=2; t[ty+1][tx]=2;
}
// Exit paths
t[7][0]=1; t[7][19]=1;
t[13][10]=1; t[14][10]=1;
return t;
})(),
enemies: [
{x:6*TILE+TILE/2, y:5*TILE+TILE/2, type:'slime'},
{x:12*TILE+TILE/2, y:8*TILE+TILE/2, type:'slime'},
{x:15*TILE+TILE/2, y:4*TILE+TILE/2, type:'slime'},
],
pickups: [{x:8*TILE+TILE/2, y:10*TILE+TILE/2, type:'coin', amount:8},
{x:14*TILE+TILE/2, y:11*TILE+TILE/2, type:'health_potion'}],
exits: { left: 0, right: 2, down: 3 },
name: 'Forest Path',
});
// Room 2: Forest Clearing (Weapon Upgrade 1)
ROOM_DEFS.push({
tiles: (()=>{
const t = [];
for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
if (x===0||x===19||y===0||y===14) t[y][x]=2;
else t[y][x]=1;
}}
t[7][0]=1;
// Central pedestal
t[6][9]=2; t[6][10]=2; t[7][9]=5; t[7][10]=2; t[8][9]=2; t[8][10]=2;
// Obstacles
for (let i=0;i<5;i++) {
t[3+i][4]=2; t[3+i][15]=2;
}
return t;
})(),
enemies: [
{x:5*TILE+TILE/2, y:6*TILE+TILE/2, type:'skeleton'},
{x:14*TILE+TILE/2, y:6*TILE+TILE/2, type:'skeleton'},
],
pickups: [{x:7*TILE+TILE/2, y:9*TILE+TILE/2, type:'broadsword_upgrade'}],
exits: { left: 1 },
name: 'Forest Clearing',
});
// Room 3: Cave Entrance
ROOM_DEFS.push({
tiles: (()=>{
const t = [];
for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
if (x===0||x===19||y===0||y===14) {
if ((y===0&&x===10)||(y===14&&x===10)) t[y][x]=1;
else t[y][x]=2;
}
else t[y][x]=1;
}}
// Rocky formations
for (let i=0;i<6;i++) {
t[4][2+i*3]=2; t[4][3+i*3]=2;
t[10][3+i*3]=2; t[10][4+i*3]=2;
}
// Exit paths
t[0][10]=1; // up to Forest Path
t[14][10]=1; // down to Cave Depths
// Save point
t[12][2]=7; t[12][3]=2;
return t;
})(),
enemies: [
{x:5*TILE+TILE/2, y:6*TILE+TILE/2, type:'slime'},
{x:9*TILE+TILE/2, y:10*TILE+TILE/2, type:'skeleton'},
{x:14*TILE+TILE/2, y:7*TILE+TILE/2, type:'slime'},
{x:11*TILE+TILE/2, y:4*TILE+TILE/2, type:'skeleton'},
],
pickups: [{x:5*TILE+TILE/2, y:11*TILE+TILE/2, type:'coin', amount:15},
{x:16*TILE+TILE/2, y:5*TILE+TILE/2, type:'shield_accessory'}],
exits: { up: 1, down: 4 },
name: 'Cave Entrance',
});
// Room 4: Cave Depths (Ranged weapon discovery - mid game)
ROOM_DEFS.push({
tiles: (()=>{
const t = [];
for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
if (x===0||x===19||y===0||y===14) t[y][x]=2;
else t[y][x]=1;
}}
t[0][10]=1; t[14][10]=1;
// Glowing shrine in center
t[7][9]=5; t[7][10]=5; t[7][8]=2; t[7][11]=2;
t[6][9]=2; t[6][10]=2; t[8][9]=2; t[8][10]=2;
// Water pools
for (let x=2;x<=5;x++) { t[3][x]=3; t[4][x]=3; }
for (let x=14;x<=17;x++) { t[10][x]=3; t[11][x]=3; }
return t;
})(),
enemies: [
{x:4*TILE+TILE/2, y:7*TILE+TILE/2, type:'skeleton'},
{x:15*TILE+TILE/2, y:5*TILE+TILE/2, type:'skeleton'},
{x:10*TILE+TILE/2, y:12*TILE+TILE/2, type:'ghost'},
{x:8*TILE+TILE/2, y:4*TILE+TILE/2, type:'ghost'},
],
pickups: [{x:7*TILE+TILE/2, y:9*TILE+TILE/2, type:'ranged_bow'}],
exits: { up: 3, down: 5 },
name: 'Cave Depths',
});
// Room 5: Deep Cave (Weapon Upgrade 2)
ROOM_DEFS.push({
tiles: (()=>{
const t = [];
for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
if (x===0||x===19||y===0||y===14) t[y][x]=2;
else t[y][x]=1;
}}
t[0][10]=1; t[14][10]=1;
// Lava cracks
for (let i=0;i<10;i++) {
const lx=2+Math.floor(Math.random()*16), ly=3+Math.floor(Math.random()*9);
t[ly][lx]=3; if (ly+1<14) t[ly+1][lx]=3;
}
// Altar with upgrade
t[3][16]=5; t[3][17]=2; t[4][16]=2; t[2][16]=2;
// Save point
t[1][3]=7;
return t;
})(),
enemies: [
{x:6*TILE+TILE/2, y:5*TILE+TILE/2, type:'ghost'},
{x:13*TILE+TILE/2, y:8*TILE+TILE/2, type:'ghost'},
{x:8*TILE+TILE/2, y:10*TILE+TILE/2, type:'skeleton'},
{x:16*TILE+TILE/2, y:4*TILE+TILE/2, type:'ghost'},
],
pickups: [{x:3*TILE+TILE/2, y:16*TILE+TILE/2, type:'dualblades_upgrade'},
{x:12*TILE+TILE/2, y:3*TILE+TILE/2, type:'coin', amount:25},
{x:8*TILE+TILE/2, y:6*TILE+TILE/2, type:'damage_amulet'}],
exits: { up: 4, down: 6 },
name: 'Deep Cave',
});
// Room 6: Boss Chamber
ROOM_DEFS.push({
tiles: (()=>{
const t = [];
for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
if (x===0||x===19||y===0||y===14) t[y][x]=2;
else t[y][x]=1;
}}
t[0][10]=1; // entrance only
// Pillars
t[3][3]=2; t[3][16]=2; t[11][3]=2; t[11][16]=2;
t[7][9]=2; t[7][10]=2;
return t;
})(),
enemies: [
{x:10*TILE+TILE/2, y:7*TILE+TILE/2, type:'boss'},
],
pickups: [{x:10*TILE+TILE/2, y:12*TILE+TILE/2, type:'heart_container'}],
exits: { up: 5 },
name: 'Boss Chamber',
});
// Initialize rooms
STATE.rooms = ROOM_DEFS.map(def => ({
tiles: def.tiles,
enemies: def.enemies.map(e => ({
x: e.x, y: e.y,
type: e.type,
hp: e.type==='slime'?2:e.type==='skeleton'?4:e.type==='ghost'?3:15,
maxHp: e.type==='slime'?2:e.type==='skeleton'?4:e.type==='ghost'?3:15,
speed: e.type==='slime'?0.8:e.type==='skeleton'?1.2:e.type==='ghost'?1.5:0.7,
damage: e.type==='boss'?2:1,
cooldown: 0,
animTimer: 0,
alive: true,
})),
pickups: def.pickups.map(p => ({...p, alive: true})),
exits: def.exits,
name: def.name,
}));
STATE.savePoints = [
{ room: 0, x: 16*TILE+TILE/2, y: 7*TILE+TILE/2, used: false },
{ room: 3, x: 12*TILE+TILE/2, y: 2*TILE+TILE/2, used: false },
{ room: 5, x: 1*TILE+TILE/2, y: 3*TILE+TILE/2, used: false },
];
// ── HELPER FUNCTIONS ──
function getRoom() { return STATE.rooms[STATE.player.currentRoom]; }
function tileAt(room, tx, ty) {
if (tx < 0 || tx >= 20 || ty < 0 || ty >= 15) return 2; // walls at edges
return room.tiles[ty][tx];
}
function isSolid(tile) { return tile === 2 || tile === 4; }
function isSolidPos(room, px, py) {
const tx = Math.floor(px / TILE), ty = Math.floor(py / TILE);
if (tx < 0 || tx >= 20 || ty < 0 || ty >= 15) return true;
return isSolid(room.tiles[ty][tx]);
}
// ── WEAPON DEFINITIONS ──
const WEAPONS = [
{ name: 'Rusty Sword', damage: 1, speed: 25, range: 28, color: '#aab', desc: 'A worn blade.' },
{ name: 'Broadsword', damage: 2, speed: 40, range: 34, color: '#c94', desc: 'Heavy, devastating swings.' },
{ name: 'Dual Blades', damage: 1, speed: 14, range: 24, color: '#5cf', desc: 'Furious flurry of strikes.' },
];
// ── ACCESSORY DEFINITIONS ──
const ACCESSORIES = {
shield_accessory: { name: 'Iron Shield', desc: 'Reduces damage taken by 1.', effect: 'shield' },
damage_amulet: { name: 'Berserker Amulet', desc: '+1 damage to all attacks.', effect: 'dmgUp' },
speed_boots: { name: 'Wind Boots', desc: 'Movement speed increased.', effect: 'speedUp' },
life_ring: { name: 'Vitality Ring', desc: 'Slow HP regeneration.', effect: 'regen' },
};
// ── COLLISION DETECTION ──
function dist(a, b) {
const dx = a.x - b.x, dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
function tryMove(entity, dx, dy, room) {
const nx = entity.x + dx, ny = entity.y + dy;
const r = 10; // collision radius
// Check tile collisions at corners
if (isSolidPos(room, nx-r, ny-r) || isSolidPos(room, nx+r, ny-r) ||
isSolidPos(room, nx-r, ny+r) || isSolidPos(room, nx+r, ny+r)) return false;
// Check other entities (enemies block each other)
if (entity !== STATE.player) {
for (const e of room.enemies) {
if (e === entity || !e.alive) continue;
if (dist({x:nx,y:ny}, e) < 18) return false;
}
if (dist({x:nx,y:ny}, STATE.player) < 16) return false;
}
entity.x = nx;
entity.y = ny;
return true;
}
// ── SAVE / LOAD ──
function saveGame(slot) {
const data = {
player: {
x: STATE.player.x, y: STATE.player.y,
hp: STATE.player.hp, maxHp: STATE.player.maxHp,
weapon: STATE.player.weapon, hasRanged: STATE.player.hasRanged,
rangedAmmo: STATE.player.rangedAmmo, gold: STATE.player.gold,
inventory: STATE.player.inventory, accessories: STATE.player.accessories,
currentRoom: STATE.player.currentRoom,
},
totalKills: STATE.totalKills, totalGoldEarned: STATE.totalGoldEarned,
upgradesFound: STATE.upgradesFound, rangedFound: STATE.rangedFound,
savePoints: STATE.savePoints,
rooms: STATE.rooms.map(r => ({
enemies: r.enemies.map(e => ({...e})),
pickups: r.pickups.map(p => ({...p})),
})),
};
localStorage.setItem('rpg_save_' + slot, JSON.stringify(data));
STATE.dialogText = 'Progress saved!';
STATE.dialogTimer = 90;
sfxSave();
}
function loadGame(slot) {
const raw = localStorage.getItem('rpg_save_' + slot);
if (!raw) return false;
try {
const data = JSON.parse(raw);
Object.assign(STATE.player, data.player);
STATE.totalKills = data.totalKills;
STATE.totalGoldEarned = data.totalGoldEarned;
STATE.upgradesFound = data.upgradesFound;
STATE.rangedFound = data.rangedFound;
STATE.savePoints = data.savePoints;
for (let i = 0; i < STATE.rooms.length; i++) {
if (data.rooms[i]) {
STATE.rooms[i].enemies = data.rooms[i].enemies;
STATE.rooms[i].pickups = data.rooms[i].pickups;
}
}
STATE.dialogText = 'Game loaded!';
STATE.dialogTimer = 90;
sfxSave();
return true;
} catch(e) { return false; }
}
// ── SHOP ITEMS ──
const SHOP_ITEMS = [
{ id: 'health_potion', name: 'Health Potion', desc: 'Restores 2 HP.', cost: 15, type: 'consumable' },
{ id: 'speed_boots', name: 'Wind Boots', desc: 'Move 30% faster.', cost: 40, type: 'accessory', effect: 'speedUp' },
{ id: 'life_ring', name: 'Vitality Ring', desc: 'Slow HP regen.', cost: 50, type: 'accessory', effect: 'regen' },
{ id: 'arrows', name: 'Arrow Bundle', desc: '+10 arrows.', cost: 20, type: 'ammo' },
];
// ── UPDATE ──
function update() {
const p = STATE.player;
const room = getRoom();
// Dialog timer
if (STATE.dialogTimer > 0) STATE.dialogTimer--;
// Player movement
let mx = 0, my = 0;
if (keys['w'] || keys['arrowup']) my = -1;
if (keys['s'] || keys['arrowdown']) my = 1;
if (keys['a'] || keys['arrowleft']) mx = -1;
if (keys['d'] || keys['arrowright']) mx = 1;
if (mx !== 0 || my !== 0) {
const len = Math.sqrt(mx*mx+my*my);
mx /= len; my /= len;
const spd = p.speed * (p.accessories.some(a=>a.effect==='speedUp')?1.3:1);
// Check for room transitions before movement
const exits = room.exits;
const ctx2 = Math.floor(p.x / TILE), cty = Math.floor(p.y / TILE);
if (ctx2 <= 0 && mx < 0 && exits.left !== undefined) { p.currentRoom = exits.left; p.x = 19*TILE+TILE/2-2; sfxStep(); }
else if (ctx2 >= 19 && mx > 0 && exits.right !== undefined) { p.currentRoom = exits.right; p.x = TILE/2+2; sfxStep(); }
else if (cty <= 0 && my < 0 && exits.up !== undefined) { p.currentRoom = exits.up; p.y = 14*TILE+TILE/2-2; sfxStep(); }
else if (cty >= 14 && my > 0 && exits.down !== undefined) { p.currentRoom = exits.down; p.y = TILE/2+2; sfxStep(); }
else {
const movedX = tryMove(p, mx * spd, 0, room);
const movedY = tryMove(p, 0, my * spd, room);
if (movedX || movedY) {
p.stepTimer++;
if (p.stepTimer % 15 === 0) sfxStep();
}
}
if (Math.abs(mx) > Math.abs(my)) p.facing = mx > 0 ? 3 : 2;
else p.facing = my > 0 ? 0 : 1;
}
// Attack
if (p.attackCooldown > 0) p.attackCooldown--;
if (p.attackDuration > 0) p.attackDuration--;
if (keys['attack'] && p.attackCooldown <= 0) {
meleeAttack();
}
// Ranged attack (right-click or shift+F)
if (keys['shift'] && keys['attack'] && p.hasRanged && p.rangedAmmo > 0 && p.attackCooldown <= 0) {
rangedAttack();
}
// Interact
if (keys['interact']) {
keys['interact'] = false;
// Check shop
const stx = Math.floor(p.x/TILE), sty = Math.floor(p.y/TILE);
if (sty < 15 && stx < 20 && sty >= 0 && stx >= 0 && room.tiles[sty][stx] === 6) {
STATE.shopOpen = !STATE.shopOpen;
if (STATE.shopOpen) sfxShop();
}
// Check save points
for (const sp of STATE.savePoints) {
if (sp.room === p.currentRoom && dist(p, sp) < 40) {
saveGame(0);
sp.used = true;
}
}
// Check chests / pickups
for (const pk of room.pickups) {
if (!pk.alive) continue;
if (dist(p, pk) < 30) {
collectPickup(pk);
}
}
}
// Invincibility timer
if (p.invincible > 0) p.invincible--;
// Regen
if (p.accessories.some(a=>a.effect==='regen') && p.hp < p.maxHp && STATE.gameTime % 180 === 0) {
p.hp = Math.min(p.maxHp, p.hp + 1);
}
// Update enemies
for (const enemy of room.enemies) {
if (!enemy.alive) continue;
enemy.animTimer++;
if (enemy.cooldown > 0) enemy.cooldown--;
// AI: move toward player
const dx = p.x - enemy.x, dy = p.y - enemy.y;
const d = Math.sqrt(dx*dx+dy*dy);
if (d > 0 && d < 300) {
const spd = enemy.speed;
tryMove(enemy, (dx/d)*spd, (dy/d)*spd, room);
}
// Attack player
if (d < 24 && enemy.cooldown <= 0 && p.invincible <= 0) {
let dmg = enemy.damage;
if (p.accessories.some(a=>a.effect==='shield')) dmg = Math.max(1, dmg-1);
p.hp -= dmg;
p.invincible = 40;
sfxDamage();
spawnParticles(p.x, p.y, 8, '#f44', 3, 25);
if (p.hp <= 0) {
p.hp = 0;
playerDeath();
}
enemy.cooldown = 60;
}
}
// Update projectiles
for (let i = STATE.projectiles.length-1; i >= 0; i--) {
const proj = STATE.projectiles[i];
proj.x += proj.vx; proj.y += proj.vy;
proj.life--;
if (proj.life <= 0) { STATE.projectiles.splice(i,1); continue; }
// Check wall collision
if (isSolidPos(STATE.rooms[proj.room], proj.x, proj.y)) {
spawnParticles(proj.x, proj.y, 5, '#ff0', 2, 12);
STATE.projectiles.splice(i,1);
continue;
}
// Check enemy hit
for (const enemy of STATE.rooms[proj.room].enemies) {
if (!enemy.alive) continue;
if (dist(proj, enemy) < 14) {
let dmg = WEAPONS[p.weapon].damage + (p.accessories.some(a=>a.effect==='dmgUp')?1:0);
enemy.hp -= dmg;
enemy.cooldown = 15;
spawnParticles(enemy.x, enemy.y, 6, '#ff8', 3, 15);
sfxHit();
if (enemy.hp <= 0) {
killEnemy(enemy);
}
STATE.projectiles.splice(i,1);
break;
}
}
}
// Update particles
for (let i = particles.length-1; i >= 0; i--) {
const pt = particles[i];
pt.x += pt.vx; pt.y += pt.vy;
pt.life--;
if (pt.life <= 0) particles.splice(i,1);
}
STATE.gameTime++;
}
function meleeAttack() {
const p = STATE.player;
const w = WEAPONS[p.weapon];
p.attackCooldown = w.speed;
p.attackDuration = 10;
sfxSwing();
// Attack hitbox in front of player
let ax = p.x, ay = p.y;
const range = w.range;
if (p.facing === 0) ay += range;
if (p.facing === 1) ay -= range;
if (p.facing === 2) ax -= range;
if (p.facing === 3) ax += range;
spawnParticles(ax, ay, 4, w.color, 2, 10);
const room = STATE.rooms[p.currentRoom];
for (const enemy of room.enemies) {
if (!enemy.alive) continue;
if (dist({x:ax,y:ay}, enemy) < w.range) {
let dmg = w.damage + (p.accessories.some(a=>a.effect==='dmgUp')?1:0);
enemy.hp -= dmg;
enemy.cooldown = 10;
spawnParticles(enemy.x, enemy.y, 8, '#fff', 3, 18);
sfxHit();
if (enemy.hp <= 0) killEnemy(enemy);
}
}
}
function rangedAttack() {
const p = STATE.player;
p.rangedAmmo--;
p.attackCooldown = 20;
sfxBow();
let vx = 0, vy = 0;
const spd = 5;
if (p.facing === 0) vy = spd;
if (p.facing === 1) vy = -spd;
if (p.facing === 2) vx = -spd;
if (p.facing === 3) vx = spd;
STATE.projectiles.push({
x: p.x, y: p.y,
vx, vy,
room: p.currentRoom,
life: 40,
});
}
function killEnemy(enemy) {
enemy.alive = false;
STATE.totalKills++;
const goldDrop = enemy.type==='boss'?50:enemy.type==='ghost'?12:6;
STATE.player.gold += goldDrop;
STATE.totalGoldEarned += goldDrop;
spawnParticles(enemy.x, enemy.y, enemy.type==='boss'?40:15, enemy.type==='boss'?'#f80':'#ff0', enemy.type==='boss'?8:4, enemy.type==='boss'?60:30);
sfxCoin();
if (enemy.type === 'boss') {
STATE.dialogText = 'THE OVERLORD IS DEFEATED! Collect the Heart Container!';
STATE.dialogTimer = 180;
sfxUpgrade();
}
// Drop pickup sometimes
if (Math.random() < 0.3 || enemy.type==='boss') {
const drops = ['coin', 'coin', 'health_potion'];
const dropType = drops[Math.floor(Math.random()*drops.length)];
const amount = dropType==='coin'?(5+Math.floor(Math.random()*10)):1;
STATE.rooms[STATE.player.currentRoom].pickups.push({
x: enemy.x, y: enemy.y, type: dropType, amount, alive: true
});
}
}
function collectPickup(pk) {
const p = STATE.player;
pk.alive = false;
if (pk.type === 'coin') {
p.gold += pk.amount;
STATE.totalGoldEarned += pk.amount;
sfxCoin();
spawnParticles(pk.x, pk.y, 8, '#ff0', 2, 15);
STATE.dialogText = `+${pk.amount} Gold`;
STATE.dialogTimer = 45;
} else if (pk.type === 'health_potion') {
p.inventory.push({ id: 'health_potion', name: 'Health Potion', desc: 'Restores 2 HP.', type: 'consumable' });
sfxPickup();
spawnParticles(pk.x, pk.y, 10, '#f44', 2, 18);
STATE.dialogText = 'Found: Health Potion!';
STATE.dialogTimer = 60;
} else if (pk.type === 'broadsword_upgrade') {
if (p.weapon < 1) {
p.weapon = 1;
STATE.upgradesFound++;
sfxUpgrade();
spawnParticles(pk.x, pk.y, 20, '#fa0', 5, 35);
STATE.dialogText = 'Upgrade: Broadsword! Heavy, devastating swings.';
STATE.dialogTimer = 90;
}
} else if (pk.type === 'dualblades_upgrade') {
if (p.weapon < 2) {
p.weapon = 2;
STATE.upgradesFound++;
sfxUpgrade();
spawnParticles(pk.x, pk.y, 20, '#5cf', 5, 35);
STATE.dialogText = 'Upgrade: Dual Blades! Furious flurry of strikes.';
STATE.dialogTimer = 90;
}
} else if (pk.type === 'ranged_bow') {
p.hasRanged = true;
p.rangedAmmo = 10;
STATE.rangedFound = true;
sfxUpgrade();
spawnParticles(pk.x, pk.y, 25, '#0f0', 6, 40);
STATE.dialogText = 'Discovered: Hunter\'s Bow! Press Shift+F to fire arrows.';
STATE.dialogTimer = 120;
} else if (pk.type === 'shield_accessory') {
p.inventory.push({ id: 'shield_accessory', name: 'Iron Shield', desc: 'Reduces damage taken.', type: 'accessory', effect: 'shield' });
sfxPickup();
spawnParticles(pk.x, pk.y, 12, '#88f', 3, 20);
STATE.dialogText = 'Found: Iron Shield!';
STATE.dialogTimer = 60;
} else if (pk.type === 'damage_amulet') {
p.inventory.push({ id: 'damage_amulet', name: 'Berserker Amulet', desc: '+1 damage to attacks.', type: 'accessory', effect: 'dmgUp' });
sfxPickup();
spawnParticles(pk.x, pk.y, 12, '#f80', 3, 20);
STATE.dialogText = 'Found: Berserker Amulet!';
STATE.dialogTimer = 60;
} else if (pk.type === 'heart_container') {
if (p.maxHp < 5) {
p.maxHp = 5;
p.hp = Math.min(p.maxHp, p.hp + 2);
}
sfxUpgrade();
spawnParticles(pk.x, pk.y, 30, '#f0f', 6, 45);
STATE.dialogText = '❤ HEART CONTAINER! Max HP: 5 — QUEST COMPLETE! ❤';
STATE.dialogTimer = 200;
STATE.gameComplete = true;
}
}
function playerDeath() {
// Respawn at last save or start
const lastSave = STATE.savePoints.find(sp => sp.used);
if (lastSave) {
STATE.player.currentRoom = lastSave.room;
STATE.player.x = lastSave.x;
STATE.player.y = lastSave.y;
} else {
STATE.player.currentRoom = 0;
STATE.player.x = 5*TILE+TILE/2;
STATE.player.y = 8*TILE+TILE/2;
}
STATE.player.hp = Math.max(1, STATE.player.maxHp - 2);
STATE.player.invincible = 120;
STATE.dialogText = 'You died... Respawning...';
STATE.dialogTimer = 90;
spawnParticles(STATE.player.x, STATE.player.y, 20, '#f00', 4, 40);
}
// ── RENDER ──
function drawTile(tile, x, y) {
const px = x * TILE, py = y * TILE;
switch(tile) {
case 0: ctx.fillStyle = '#000'; ctx.fillRect(px, py, TILE, TILE); break;
case 1: // floor
ctx.fillStyle = (x+y)%2===0?'#2a2a1a':'#252515';
ctx.fillRect(px, py, TILE, TILE);
ctx.fillStyle = '#333322';
ctx.fillRect(px+1, py+1, TILE-2, TILE-2);
break;
case 2: // wall
ctx.fillStyle = '#445';
ctx.fillRect(px, py, TILE, TILE);
ctx.fillStyle = '#556';
ctx.fillRect(px+2, py+2, TILE-4, TILE-4);
ctx.fillStyle = '#667';
ctx.fillRect(px+4, py+4, TILE-8, TILE-8);
break;
case 3: // water/lava
ctx.fillStyle = (STATE.gameTime%60<30)?'#336':'#448';
ctx.fillRect(px, py, TILE, TILE);
break;
case 5: // chest/altar
ctx.fillStyle = '#1a1a2a'; ctx.fillRect(px, py, TILE, TILE);
ctx.fillStyle = '#da4'; ctx.fillRect(px+4, py+8, TILE-8, TILE-12);
ctx.fillStyle = '#fc6'; ctx.fillRect(px+8, py+12, TILE-16, 4);
break;
case 6: // shop
ctx.fillStyle = '#1a1a2a'; ctx.fillRect(px, py, TILE, TILE);
ctx.fillStyle = '#ca8'; ctx.fillRect(px+4, py+12, TILE-8, 8);
ctx.fillStyle = '#da9'; ctx.fillRect(px+8, py+6, TILE-16, 6);
break;
case 7: // save point
ctx.fillStyle = '#1a1a2a'; ctx.fillRect(px, py, TILE, TILE);
ctx.fillStyle = '#adf'; ctx.fillRect(px+6, py+2, TILE-12, TILE-4);
ctx.fillStyle = '#fff'; ctx.fillRect(px+10, py+4, 4, TILE-8);
ctx.fillStyle = '#8cf';
ctx.beginPath(); ctx.arc(px+TILE/2, py+TILE/2, 8, 0, Math.PI*2); ctx.fill();
break;
default: ctx.fillStyle = '#111'; ctx.fillRect(px, py, TILE, TILE);
}
}
function drawPlayer() {
const p = STATE.player;
if (p.invincible > 0 && Math.floor(p.invincible/4)%2===0) return; // flicker
const camX = p.x - W/2, camY = p.y - H/2;
const sx = p.x - camX, sy = p.y - camY;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.beginPath(); ctx.ellipse(sx, sy+4, 8, 4, 0, 0, Math.PI*2); ctx.fill();
// Body
ctx.fillStyle = '#4a7';
ctx.beginPath(); ctx.arc(sx, sy, 9, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = '#285'; ctx.lineWidth = 2; ctx.stroke();
// Eyes
ctx.fillStyle = '#fff';
let ex = sx, ey = sy-2;
if (p.facing===2) ex-=3; if (p.facing===3) ex+=3;
if (p.facing===1) ey-=2; if (p.facing===0) ey+=2;
ctx.beginPath(); ctx.arc(ex-2, ey, 2.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(ex+3, ey, 2.5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(ex-1.5, ey, 1.2, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(ex+3.5, ey, 1.2, 0, Math.PI*2); ctx.fill();
// Weapon
if (p.attackDuration > 0) {
const w = WEAPONS[p.weapon];
ctx.strokeStyle = w.color;
ctx.lineWidth = 4;
ctx.beginPath();
const swingProgress = 1 - (p.attackDuration / 10);
const wx = sx, wy = sy;
let ex2 = wx, ey2 = wy;
const rng = w.range * 0.6;
if (p.facing === 0) { ex2 = wx; ey2 = wy + rng; }
if (p.facing === 1) { ex2 = wx; ey2 = wy - rng; }
if (p.facing === 2) { ex2 = wx - rng; ey2 = wy; }
if (p.facing === 3) { ex2 = wx + rng; ey2 = wy; }
ctx.moveTo(wx, wy); ctx.lineTo(ex2, ey2); ctx.stroke();
// Glow
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(wx, wy); ctx.lineTo(ex2, ey2); ctx.stroke();
} else {
// Weapon on back/side
const w = WEAPONS[p.weapon];
ctx.fillStyle = w.color;
let wx2 = sx, wy2 = sy;
if (p.facing === 0) { wx2 = sx + 8; wy2 = sy + 2; }
if (p.facing === 1) { wx2 = sx - 8; wy2 = sy - 2; }
if (p.facing === 2) { wx2 = sx - 2; wy2 = sy - 8; }
if (p.facing === 3) { wx2 = sx + 2; wy2 = sy + 8; }
ctx.fillRect(wx2-2, wy2-8, 4, 14);
ctx.fillStyle = '#fff'; ctx.fillRect(wx2-1, wy2-7, 2, 4);
}
// Bow on back
if (p.hasRanged) {
ctx.strokeStyle = '#a64'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(sx+6, sy-6, 8, -Math.PI*0.6, Math.PI*0.6);
ctx.stroke();
ctx.strokeStyle = '#863'; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo(sx+6-7, sy-9); ctx.lineTo(sx+6+7, sy-9); ctx.stroke();
}
}
function drawEnemy(enemy, camX, camY) {
if (!enemy.alive) return;
const sx = enemy.x - camX, sy = enemy.y - camY;
if (enemy.type === 'slime') {
const bob = Math.sin(enemy.animTimer*0.1)*3;
ctx.fillStyle = '#3c3';
ctx.beginPath(); ctx.ellipse(sx, sy+bob, 10, 7+Math.sin(enemy.animTimer*0.15)*2, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#5e5'; ctx.beginPath(); ctx.ellipse(sx, sy-3+bob, 4, 3, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(sx-2, sy-4+bob, 1.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx+3, sy-4+bob, 1.5, 0, Math.PI*2); ctx.fill();
// HP bar
const hpPct = enemy.hp/enemy.maxHp;
ctx.fillStyle = '#400'; ctx.fillRect(sx-8, sy-14, 16, 3);
ctx.fillStyle = '#f00'; ctx.fillRect(sx-8, sy-14, 16*hpPct, 3);
} else if (enemy.type === 'skeleton') {
ctx.fillStyle = '#ddd';
ctx.fillRect(sx-5, sy-8, 10, 16);
ctx.fillStyle = '#fff'; ctx.fillRect(sx-3, sy-10, 6, 8); // skull
ctx.fillStyle = '#000'; ctx.fillRect(sx-2, sy-8, 2, 2); ctx.fillRect(sx+1, sy-8, 2, 2); // eyes
ctx.strokeStyle = '#999'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(sx-5, sy+4); ctx.lineTo(sx-8, sy+12); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx+5, sy+4); ctx.lineTo(sx+8, sy+12); ctx.stroke();
const hpPct = enemy.hp/enemy.maxHp;
ctx.fillStyle = '#400'; ctx.fillRect(sx-8, sy-16, 16, 3);
ctx.fillStyle = '#f00'; ctx.fillRect(sx-8, sy-16, 16*hpPct, 3);
} else if (enemy.type === 'ghost') {
const bob = Math.sin(enemy.animTimer*0.08)*4;
ctx.fillStyle = 'rgba(180,160,220,0.7)';
ctx.beginPath(); ctx.arc(sx, sy+bob, 9, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx, sy-5+bob, 6, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.beginPath(); ctx.arc(sx-2, sy-6+bob, 2, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx+3, sy-6+bob, 2, 0, Math.PI*2); ctx.fill();
const hpPct = enemy.hp/enemy.maxHp;
ctx.fillStyle = 'rgba(40,0,40,0.6)'; ctx.fillRect(sx-8, sy-15+bob, 16, 3);
ctx.fillStyle = '#a0f'; ctx.fillRect(sx-8, sy-15+bob, 16*hpPct, 3);
} else if (enemy.type === 'boss') {
const bob = Math.sin(enemy.animTimer*0.05)*2;
ctx.fillStyle = '#600';
ctx.beginPath(); ctx.arc(sx, sy+bob, 18, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#800';
ctx.beginPath(); ctx.arc(sx, sy-4+bob, 12, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#f00';
ctx.beginPath(); ctx.arc(sx-5, sy-6+bob, 3, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(sx+5, sy-6+bob, 3, 0, Math.PI*2); ctx.fill();
// Horns
ctx.fillStyle = '#400';
ctx.beginPath(); ctx.moveTo(sx-6, sy-12+bob); ctx.lineTo(sx-10, sy-22+bob); ctx.lineTo(sx-3, sy-12+bob); ctx.fill();
ctx.beginPath(); ctx.moveTo(sx+6, sy-12+bob); ctx.lineTo(sx+10, sy-22+bob); ctx.lineTo(sx+3, sy-12+bob); ctx.fill();
// Health bar
const hpPct = enemy.hp/enemy.maxHp;
ctx.fillStyle = '#400'; ctx.fillRect(sx-20, sy-26+bob, 40, 5);
ctx.fillStyle = '#f00'; ctx.fillRect(sx-20, sy-26+bob, 40*hpPct, 5);
ctx.fillStyle = '#fff';
ctx.font = '8px monospace'; ctx.fillText('OVERLORD', sx-18, sy-29+bob);
}
}
function drawHUD() {
// Health hearts
const p = STATE.player;
const heartsX = 16, heartsY = 16;
for (let i = 0; i < Math.ceil(p.maxHp/2); i++) {
const hx = heartsX + i * 28;
const full = Math.min(2, Math.max(0, p.hp - i*2));
// Heart shape
ctx.fillStyle = '#333';
drawHeart(hx, heartsY, 14);
if (full === 2) {
ctx.fillStyle = '#f22';
drawHeart(hx, heartsY, 14);
} else if (full === 1) {
// Half heart
ctx.save();
ctx.beginPath();
ctx.rect(hx-6, heartsY-8, 6, 16);
ctx.clip();
ctx.fillStyle = '#f22';
drawHeart(hx, heartsY, 14);
ctx.restore();
}
}
// Weapon name
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px monospace';
ctx.fillText(WEAPONS[p.weapon].name, 16, 48);
// Progression
ctx.fillStyle = '#aaa';
ctx.font = '9px monospace';
const upgText = `Upgrades: ${STATE.upgradesFound}/2`;
const rangedText = STATE.rangedFound ? ' | Bow: ✓' : ' | Bow: ?';
ctx.fillText(upgText + rangedText, 16, 62);
// Ammo
if (p.hasRanged) {
ctx.fillText(`Arrows: ${p.rangedAmmo}/${p.maxAmmo}`, 16, 76);
}
// Gold
ctx.fillText(`Gold: ${p.gold}`, W - 120, 20);
// Room name
ctx.fillStyle = '#aaa';
ctx.font = '10px monospace';
ctx.fillText(getRoom().name, W - 150, H - 10);
// Accessories
if (p.accessories.length > 0) {
ctx.fillStyle = '#dd8';
ctx.font = '9px monospace';
ctx.fillText('Equipped: ' + p.accessories.map(a=>a.name).join(', '), 16, H - 10);
}
// Dialog
if (STATE.dialogTimer > 0) {
const alpha = Math.min(1, STATE.dialogTimer / 20);
ctx.fillStyle = `rgba(0,0,0,${alpha*0.7})`;
const dw = ctx.measureText(STATE.dialogText).width + 40;
ctx.fillRect(W/2-dw/2, H-70, dw, 30);
ctx.fillStyle = `rgba(255,255,255,${alpha})`;
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.fillText(STATE.dialogText, W/2, H-50);
ctx.textAlign = 'start';
}
// Controls hint
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '8px monospace';
ctx.fillText('WASD:Move | F:Attack | Shift+F:Bow | E:Interact | I:Inventory', W/2-170, H-4);
}
function drawHeart(x, y, s) {
ctx.beginPath();
const r = s * 0.35;
// Left circle
ctx.arc(x - r*0.6, y - r*0.4, r, Math.PI, 0, false);
// Right circle
ctx.arc(x + r*0.6, y - r*0.4, r, Math.PI, 0, false);
// Bottom point
ctx.lineTo(x, y + s*0.45);
ctx.closePath();
ctx.fill();
}
function drawInventory() {
if (!STATE.inventoryOpen) return;
const p = STATE.player;
// Backdrop
ctx.fillStyle = 'rgba(0,0,0,0.85)';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#da4';
ctx.font = 'bold 16px monospace';
ctx.textAlign = 'center';
ctx.fillText('INVENTORY', W/2, 40);
ctx.textAlign = 'start';
ctx.fillStyle = '#fff';
ctx.font = '12px monospace';
let iy = 70;
ctx.fillText(`Gold: ${p.gold}`, 100, iy); iy += 25;
ctx.fillText(`Weapon: ${WEAPONS[p.weapon].name} (${WEAPONS[p.weapon].desc})`, 100, iy); iy += 25;
if (p.hasRanged) ctx.fillText(`Ranged: Hunter's Bow (Arrows: ${p.rangedAmmo})`, 100, iy);
else ctx.fillText('Ranged: None discovered', 100, iy);
iy += 30;
ctx.fillStyle = '#da4';
ctx.fillText('Items:', 100, iy); iy += 22;
if (p.inventory.length === 0) {
ctx.fillStyle = '#888';
ctx.fillText(' (empty)', 100, iy);
} else {
for (let i = 0; i < p.inventory.length; i++) {
const item = p.inventory[i];
ctx.fillStyle = item.type==='consumable'?'#f88':item.type==='accessory'?'#adf':'#fff';
ctx.fillText(`${i+1}. ${item.name} - ${item.desc}`, 100, iy);
iy += 20;
// Use/equip hint
if (item.type === 'consumable') {
ctx.fillStyle = '#888';
ctx.fillText(` Press ${i+1} to use`, 100, iy);
iy += 18;
} else if (item.type === 'accessory') {
const equipped = p.accessories.some(a => a.id === item.id);
ctx.fillStyle = equipped ? '#8f8' : '#888';
ctx.fillText(` Press ${i+1} to ${equipped?'unequip':'equip'}`, 100, iy);
iy += 18;
}
}
}
iy += 15;
ctx.fillStyle = '#888';
ctx.font = '10px monospace';
ctx.fillText('Press I to close | Press 1-5 to use/equip items', 100, iy);
}
function drawShop() {
if (!STATE.shopOpen) return;
ctx.fillStyle = 'rgba(0,0,0,0.85)';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#da4';
ctx.font = 'bold 16px monospace';
ctx.textAlign = 'center';
ctx.fillText('SHOP', W/2, 40);
ctx.fillStyle = '#fff';
ctx.font = '11px monospace';
ctx.fillText(`Your Gold: ${STATE.player.gold}`, W/2, 60);
ctx.textAlign = 'start';
let iy = 90;
for (let i = 0; i < SHOP_ITEMS.length; i++) {
const item = SHOP_ITEMS[i];
const affordable = STATE.player.gold >= item.cost;
ctx.fillStyle = affordable ? '#fff' : '#666';
ctx.fillText(`${i+1}. ${item.name} - ${item.desc} [${item.cost}g]`, 120, iy);
if (affordable) {
ctx.fillStyle = '#8f8';
ctx.fillText(` Press ${i+1} to buy`, 120, iy+16);
}
iy += 34;
}
iy += 10;
ctx.fillStyle = '#888';
ctx.fillText('Press E to close shop', 120, iy);
}
function handleShopBuy(index) {
if (index < 0 || index >= SHOP_ITEMS.length) return;
const item = SHOP_ITEMS[index];
const p = STATE.player;
if (p.gold < item.cost) return;
p.gold -= item.cost;
sfxCoin();
if (item.type === 'consumable') {
p.inventory.push({ id: item.id, name: item.name, desc: item.desc, type: 'consumable' });
STATE.dialogText = `Bought: ${item.name}!`;
} else if (item.type === 'accessory') {
// Check if already owned
if (!p.inventory.some(inv=>inv.id===item.id) && !p.accessories.some(a=>a.id===item.id)) {
p.inventory.push({ id: item.id, name: item.name, desc: item.desc, type: 'accessory', effect: item.effect });
STATE.dialogText = `Bought: ${item.name}!`;
} else {
p.gold += item.cost; // refund
STATE.dialogText = 'You already have this accessory!';
}
} else if (item.type === 'ammo') {
if (!p.hasRanged) {
p.gold += item.cost; // refund
STATE.dialogText = 'You need to find a bow first!';
} else {
p.rangedAmmo = Math.min(p.maxAmmo, p.rangedAmmo + 10);
STATE.dialogText = 'Bought: 10 Arrows!';
}
}
STATE.dialogTimer = 60;
}
function handleInventoryUse(index) {
const p = STATE.player;
if (index < 0 || index >= p.inventory.length) return;
const item = p.inventory[index];
if (item.type === 'consumable') {
if (item.id === 'health_potion') {
if (p.hp >= p.maxHp) {
STATE.dialogText = 'HP is already full!';
STATE.dialogTimer = 30;
return;
}
p.hp = Math.min(p.maxHp, p.hp + 2);
p.inventory.splice(index, 1);
sfxPickup();
spawnParticles(p.x, p.y, 12, '#f44', 3, 20);
STATE.dialogText = 'Used Health Potion! +2 HP';
STATE.dialogTimer = 45;
}
} else if (item.type === 'accessory') {
const equippedIdx = p.accessories.findIndex(a => a.id === item.id);
if (equippedIdx >= 0) {
p.accessories.splice(equippedIdx, 1);
STATE.dialogText = `Unequipped: ${item.name}`;
} else {
if (p.accessories.length >= 2) {
STATE.dialogText = 'Max 2 accessories equipped! Unequip one first.';
} else {
p.accessories.push({...item});
STATE.dialogText = `Equipped: ${item.name}!`;
sfxPickup();
}
}
STATE.dialogTimer = 45;
}
}
// Handle number keys for inventory/shop
window.addEventListener('keydown', e => {
const num = parseInt(e.key);
if (isNaN(num) || num < 1 || num > 5) return;
if (STATE.shopOpen) {
handleShopBuy(num - 1);
return;
}
if (STATE.inventoryOpen) {
handleInventoryUse(num - 1);
return;
}
});
function drawParticles(camX, camY) {
for (const pt of particles) {
const alpha = pt.life / pt.maxLife;
const sx = pt.x - camX, sy = pt.y - camY;
ctx.fillStyle = pt.color.replace(')', `,${alpha})`).replace('rgb', 'rgba');
if (pt.color.startsWith('#')) {
const r = parseInt(pt.color.slice(1,3), 16);
const g = parseInt(pt.color.slice(3,5), 16);
const b = parseInt(pt.color.slice(5,7), 16);
ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
}
const size = 2 + (1-alpha)*3;
ctx.beginPath(); ctx.arc(sx, sy, size, 0, Math.PI*2); ctx.fill();
}
}
function drawProjectiles(camX, camY) {
for (const proj of STATE.projectiles) {
const sx = proj.x - camX, sy = proj.y - camY;
ctx.fillStyle = '#ff0';
ctx.beginPath(); ctx.arc(sx, sy, 3, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(sx, sy, 1.5, 0, Math.PI*2); ctx.fill();
// Trail
ctx.fillStyle = 'rgba(255,255,200,0.4)';
ctx.beginPath(); ctx.arc(sx-proj.vx*0.5, sy-proj.vy*0.5, 2, 0, Math.PI*2); ctx.fill();
}
}
function drawPickups(camX, camY) {
const room = getRoom();
for (const pk of room.pickups) {
if (!pk.alive) continue;
const sx = pk.x - camX, sy = pk.y - camY;
if (pk.type === 'coin') {
ctx.fillStyle = '#fc0';
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.1)*2, 5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#ff0';
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.1)*2, 2.5, 0, Math.PI*2); ctx.fill();
} else if (pk.type === 'health_potion') {
ctx.fillStyle = '#f44';
ctx.fillRect(sx-4, sy-6+Math.sin(STATE.gameTime*0.08)*2, 8, 12);
ctx.fillStyle = '#fff';
ctx.fillRect(sx-2, sy-3+Math.sin(STATE.gameTime*0.08)*2, 4, 4);
} else if (pk.type.includes('upgrade')) {
const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
ctx.fillStyle = `rgba(255,200,50,${glow})`;
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 8, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fc0';
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 5, 0, Math.PI*2); ctx.fill();
} else if (pk.type === 'ranged_bow') {
const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
ctx.fillStyle = `rgba(100,255,100,${glow})`;
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 8, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = '#0f0'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*2, 12, -0.6, 2.5); ctx.stroke();
} else if (pk.type === 'shield_accessory' || pk.type === 'damage_amulet') {
ctx.fillStyle = pk.type==='shield_accessory'?'#88f':'#f80';
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.07)*2, 6, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.07)*2, 3, 0, Math.PI*2); ctx.fill();
} else if (pk.type === 'heart_container') {
const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
ctx.fillStyle = `rgba(255,100,255,${glow})`;
drawHeart(sx, sy+Math.sin(STATE.gameTime*0.05)*2, 12);
}
}
}
function drawSavePoints(camX, camY) {
for (const sp of STATE.savePoints) {
if (sp.room !== STATE.player.currentRoom) continue;
const sx = sp.x - camX, sy = sp.y - camY;
ctx.fillStyle = sp.used ? '#8cf' : '#adf';
ctx.beginPath(); ctx.arc(sx, sy, 10+Math.sin(STATE.gameTime*0.04)*2, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = '8px monospace';
ctx.textAlign = 'center';
ctx.fillText(sp.used?'S':'Save', sx, sy+3);
ctx.textAlign = 'start';
}
}
function render() {
ctx.clearRect(0, 0, W, H);
const p = STATE.player;
const room = getRoom();
const camX = p.x - W/2, camY = p.y - H/2;
ctx.save();
ctx.translate(-camX, -camY);
// Draw all tiles
for (let ty = 0; ty < 15; ty++) {
for (let tx = 0; tx < 20; tx++) {
drawTile(room.tiles[ty][tx], tx, ty);
}
}
// Draw pickups
for (const pk of room.pickups) {
if (!pk.alive) continue;
const sx = pk.x, sy = pk.y;
if (pk.type === 'coin') {
ctx.fillStyle = '#fc0';
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.1)*2, 5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#ff0';
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.1)*2, 2.5, 0, Math.PI*2); ctx.fill();
} else if (pk.type === 'health_potion') {
ctx.fillStyle = '#f44';
ctx.fillRect(sx-4, sy-6+Math.sin(STATE.gameTime*0.08)*2, 8, 12);
ctx.fillStyle = '#fff'; ctx.fillRect(sx-2, sy-3+Math.sin(STATE.gameTime*0.08)*2, 4, 4);
} else if (pk.type.includes('upgrade')) {
const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
ctx.fillStyle = `rgba(255,200,50,${glow})`;
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 8, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fc0';
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 5, 0, Math.PI*2); ctx.fill();
} else if (pk.type === 'ranged_bow') {
const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
ctx.fillStyle = `rgba(100,255,100,${glow})`;
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 8, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = '#0f0'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*2, 12, -0.6, 2.5); ctx.stroke();
} else if (pk.type === 'shield_accessory' || pk.type === 'damage_amulet') {
ctx.fillStyle = pk.type==='shield_accessory'?'#88f':'#f80';
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.07)*2, 6, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.07)*2, 3, 0, Math.PI*2); ctx.fill();
} else if (pk.type === 'heart_container') {
const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
ctx.fillStyle = `rgba(255,100,255,${glow})`;
ctx.save();
ctx.translate(sx, sy+Math.sin(STATE.gameTime*0.05)*2);
drawHeart(0, 0, 12);
ctx.restore();
}
}
// Draw save points
for (const sp of STATE.savePoints) {
if (sp.room !== STATE.player.currentRoom) continue;
const sx = sp.x, sy = sp.y;
ctx.fillStyle = sp.used ? '#8cf' : '#adf';
ctx.beginPath(); ctx.arc(sx, sy, 10+Math.sin(STATE.gameTime*0.04)*2, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = '8px monospace';
ctx.textAlign = 'center';
ctx.fillText(sp.used?'S':'Save', sx, sy+3);
ctx.textAlign = 'start';
}
// Draw enemies
for (const enemy of room.enemies) {
drawEnemy(enemy, 0, 0);
}
// Draw projectiles
for (const proj of STATE.projectiles) {
if (proj.room !== STATE.player.currentRoom) continue;
ctx.fillStyle = '#ff0';
ctx.beginPath(); ctx.arc(proj.x, proj.y, 3, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(proj.x, proj.y, 1.5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,200,0.4)';
ctx.beginPath(); ctx.arc(proj.x-proj.vx*0.5, proj.y-proj.vy*0.5, 2, 0, Math.PI*2); ctx.fill();
}
// Draw player
drawPlayerWorld();
// Draw particles
for (const pt of particles) {
const alpha = pt.life / pt.maxLife;
let r=255,g=255,b=255;
if (pt.color.startsWith('#')) {
r = parseInt(pt.color.slice(1,3), 16);
g = parseInt(pt.color.slice(3,5), 16);
b = parseInt(pt.color.slice(5,7), 16);
}
ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
const size = 2 + (1-alpha)*3;
ctx.beginPath(); ctx.arc(pt.x, pt.y, size, 0, Math.PI*2); ctx.fill();
}
ctx.restore();
// HUD (screen space)
drawHUD();
drawInventory();
drawShop();
// Victory overlay
if (STATE.gameComplete) {
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#fc0';
ctx.font = 'bold 28px monospace';
ctx.textAlign = 'center';
ctx.fillText('QUEST COMPLETE!', W/2, H/2-20);
ctx.fillStyle = '#fff';
ctx.font = '14px monospace';
ctx.fillText('You defeated the Overlord and claimed the Heart Container!', W/2, H/2+20);
ctx.fillText(`Kills: ${STATE.totalKills} | Gold Earned: ${STATE.totalGoldEarned} | Upgrades: ${STATE.upgradesFound}`, W/2, H/2+45);
ctx.fillText('Thanks for playing! Refresh to start a new adventure.', W/2, H/2+70);
ctx.textAlign = 'start';
}
}
function drawPlayerWorld() {
const p = STATE.player;
if (p.invincible > 0 && Math.floor(p.invincible/4)%2===0) return;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.beginPath(); ctx.ellipse(p.x, p.y+4, 8, 4, 0, 0, Math.PI*2); ctx.fill();
// Body
ctx.fillStyle = '#4a7';
ctx.beginPath(); ctx.arc(p.x, p.y, 9, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = '#285'; ctx.lineWidth = 2; ctx.stroke();
// Eyes
ctx.fillStyle = '#fff';
let ex = p.x, ey = p.y-2;
if (p.facing===2) ex-=3; if (p.facing===3) ex+=3;
if (p.facing===1) ey-=2; if (p.facing===0) ey+=2;
ctx.beginPath(); ctx.arc(ex-2, ey, 2.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(ex+3, ey, 2.5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(ex-1.5, ey, 1.2, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(ex+3.5, ey, 1.2, 0, Math.PI*2); ctx.fill();
// Weapon
const w = WEAPONS[p.weapon];
if (p.attackDuration > 0) {
ctx.strokeStyle = w.color;
ctx.lineWidth = 4;
ctx.beginPath();
const wx = p.x, wy = p.y;
let ex2 = wx, ey2 = wy;
const rng = w.range * 0.6;
if (p.facing === 0) { ex2 = wx; ey2 = wy + rng; }
if (p.facing === 1) { ex2 = wx; ey2 = wy - rng; }
if (p.facing === 2) { ex2 = wx - rng; ey2 = wy; }
if (p.facing === 3) { ex2 = wx + rng; ey2 = wy; }
ctx.moveTo(wx, wy); ctx.lineTo(ex2, ey2); ctx.stroke();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(wx, wy); ctx.lineTo(ex2, ey2); ctx.stroke();
} else {
let wx2 = p.x, wy2 = p.y;
if (p.facing === 0) { wx2 = p.x + 8; wy2 = p.y + 2; }
if (p.facing === 1) { wx2 = p.x - 8; wy2 = p.y - 2; }
if (p.facing === 2) { wx2 = p.x - 2; wy2 = p.y - 8; }
if (p.facing === 3) { wx2 = p.x + 2; wy2 = p.y + 8; }
ctx.fillStyle = w.color;
ctx.fillRect(wx2-2, wy2-8, 4, 14);
ctx.fillStyle = '#fff'; ctx.fillRect(wx2-1, wy2-7, 2, 4);
}
// Bow on back
if (p.hasRanged) {
ctx.strokeStyle = '#a64'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(p.x+6, p.y-6, 8, -Math.PI*0.6, Math.PI*0.6);
ctx.stroke();
ctx.strokeStyle = '#863'; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo(p.x+6-7, p.y-9); ctx.lineTo(p.x+6+7, p.y-9); ctx.stroke();
}
}
// ── GAME LOOP ──
function gameLoop() {
update();
render();
requestAnimationFrame(gameLoop);
}
// ── START ──
initAudio();
// Try to load save
const hasSave = loadGame(0);
if (!hasSave) {
STATE.dialogText = 'Welcome, adventurer! WASD to move, F to attack, E to interact.';
STATE.dialogTimer = 150;
}
gameLoop();
console.log('Shard of the Verdant Blade - Ready');
console.log('Rooms:', STATE.rooms.map(r=>r.name).join(' → '));
console.log('Save points:', STATE.savePoints.length);
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4-pro","provider":"openrouter","persona":"middle","sessionId":"17502ab4-4004-47fb-96af-fcd6b049881e","tokensIn":1170778,"tokensOut":42294,"tokensTotal":1213072,"turns":33,"toolCalls":32,"failedToolCalls":0,"timestamp":"2026-07-18T18:21:14.845Z"} -->