RPG
seed 2.0 code · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 91,505 bytes · SHA-256 8f505ac1fb80
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chronicle of the Lost Star - Action RPG</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: 'Courier New', monospace;
overflow: hidden;
}
#gameContainer {
position: relative;
border: 4px solid #4a4a6a;
box-shadow: 0 0 30px rgba(100, 100, 200, 0.5);
}
#gameCanvas {
display: block;
image-rendering: pixelated;
background: #16213e;
}
#uiLayer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
#uiLayer > * { pointer-events: auto; }
/* HUD Elements */
#hud {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.hearts { display: flex; gap: 4px; }
.heart {
width: 28px;
height: 28px;
position: relative;
}
.heart-full { filter: drop-shadow(0 0 4px #ff4444); }
.coin-display {
background: rgba(0,0,0,0.7);
padding: 8px 16px;
border-radius: 8px;
color: #ffd700;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
}
.area-name {
position: absolute;
top: 50px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
padding: 10px 30px;
border-radius: 4px;
color: #fff;
font-size: 18px;
opacity: 0;
transition: opacity 0.5s;
}
.area-name.show { opacity: 1; }
/* Inventory Panel */
#inventoryPanel, #shopPanel, #savePanel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(20, 20, 40, 0.95);
border: 3px solid #6a6a9a;
border-radius: 12px;
padding: 24px;
display: none;
min-width: 400px;
max-height: 500px;
overflow-y: auto;
}
#inventoryPanel.show, #shopPanel.show, #savePanel.show { display: block; }
.panel-title {
color: #aaf;
font-size: 24px;
margin-bottom: 16px;
text-align: center;
border-bottom: 2px solid #4a4a6a;
padding-bottom: 10px;
}
.inv-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.inv-slot {
width: 70px;
height: 70px;
background: rgba(60, 60, 100, 0.5);
border: 2px solid #5a5a8a;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.inv-slot:hover {
border-color: #88f;
background: rgba(80, 80, 140, 0.6);
}
.inv-slot.selected {
border-color: #ff0;
box-shadow: 0 0 10px #ff0;
}
.item-icon { font-size: 28px; }
.item-count {
position: absolute;
bottom: 4px;
right: 6px;
font-size: 12px;
color: #fff;
font-weight: bold;
}
.item-details {
margin-top: 16px;
padding: 12px;
background: rgba(40, 40, 80, 0.6);
border-radius: 6px;
color: #ccc;
font-size: 14px;
min-height: 60px;
}
.use-btn {
margin-top: 10px;
padding: 10px 24px;
background: #4a4a8a;
border: 2px solid #6a6aaa;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-family: inherit;
font-size: 14px;
}
.use-btn:hover { background: #5a5a9a; }
.use-btn:disabled {
background: #333;
border-color: #444;
color: #666;
cursor: not-allowed;
}
/* Shop specific */
.shop-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: rgba(50, 50, 100, 0.4);
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
border: 2px solid transparent;
}
.shop-item:hover {
border-color: #88f;
background: rgba(70, 70, 130, 0.5);
}
.shop-item-info { display: flex; align-items: center; gap: 12px; }
.shop-price { color: #ffd700; font-weight: bold; }
/* Controls hint */
.controls-hint {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(0,0,0,0.7);
padding: 8px 14px;
border-radius: 6px;
color: #aaa;
font-size: 12px;
}
/* Dialog box */
#dialogBox {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(20, 20, 50, 0.95);
border: 3px solid #88a;
border-radius: 8px;
padding: 16px 24px;
color: #fff;
max-width: 600px;
display: none;
}
#dialogBox.show { display: block; }
.dialog-speaker {
color: #aaf;
font-weight: bold;
margin-bottom: 6px;
}
.dialog-continue {
color: #888;
font-size: 12px;
margin-top: 8px;
text-align: right;
}
/* Weapon display */
#weaponDisplay {
position: absolute;
bottom: 10px;
right: 10px;
background: rgba(0,0,0,0.7);
padding: 10px 16px;
border-radius: 6px;
color: #fff;
font-size: 13px;
}
.weapon-icon { font-size: 20px; margin-right: 8px; }
/* Notification */
#notification {
position: absolute;
top: 120px;
left: 50%;
transform: translateX(-50%);
background: rgba(20, 100, 50, 0.9);
padding: 10px 24px;
border-radius: 6px;
color: #fff;
opacity: 0;
transition: opacity 0.3s;
white-space: nowrap;
}
#notification.show { opacity: 1; }
/* Start screen */
#startScreen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, #0f0f23 0%, #1a1a3e 50%, #0f0f23 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
}
#startScreen.hidden { display: none; }
.game-title {
font-size: 48px;
color: #ccf;
text-shadow: 0 0 20px #88f, 0 0 40px #448;
margin-bottom: 10px;
letter-spacing: 4px;
}
.game-subtitle {
color: #88a;
margin-bottom: 50px;
font-size: 18px;
}
.menu-btn {
padding: 16px 48px;
margin: 8px;
background: rgba(60, 60, 120, 0.6);
border: 2px solid #88a;
border-radius: 8px;
color: #fff;
font-size: 18px;
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
}
.menu-btn:hover {
background: rgba(80, 80, 160, 0.8);
border-color: #aaf;
box-shadow: 0 0 15px rgba(136, 136, 255, 0.4);
}
/* Death screen */
#deathScreen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(50, 10, 10, 0.9);
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
}
#deathScreen.show { display: flex; }
/* Minimap */
#minimap {
position: absolute;
top: 50px;
right: 10px;
width: 120px;
height: 120px;
background: rgba(0,0,0,0.6);
border: 2px solid #4a4a6a;
border-radius: 4px;
}
</style>
</head>
<body>
<div id="gameContainer">
<canvas id="gameCanvas" width="800" height="600"></canvas>
<div id="uiLayer">
<!-- Start Screen -->
<div id="startScreen">
<div class="game-title">⚔ CHRONICLE ⚔</div>
<div class="game-subtitle">of the Lost Star</div>
<button class="menu-btn" onclick="game.startNewGame()">New Game</button>
<button class="menu-btn" onclick="game.loadGame()">Continue</button>
<button class="menu-btn" onclick="game.showControls()">Controls</button>
<div id="controlsInfo" style="display:none; margin-top:30px; color:#aaa; text-align:center; line-height:2;">
WASD / Arrows - Move<br>
SPACE - Attack / Swing Weapon<br>
E - Interact (Shop, Save Points, Chests)<br>
Q - Fire Ranged Weapon (once unlocked)<br>
I - Inventory<br>
1,2,3,4 - Quick Items<br>
ESC - Close Menus / Pause
</div>
</div>
<!-- Death Screen -->
<div id="deathScreen">
<div class="game-title" style="color:#f44;">YOU DIED</div>
<div class="game-subtitle">The darkness claims another soul...</div>
<button class="menu-btn" onclick="game.respawn()">Return to Last Save</button>
<button class="menu-btn" onclick="game.startNewGame()">Start Over</button>
</div>
<!-- HUD -->
<div id="hud">
<div class="hearts" id="heartsContainer"></div>
<div class="coin-display">
<span style="font-size:20px;">🪙</span>
<span id="coinCount">0</span>
</div>
</div>
<div class="area-name" id="areaName"></div>
<div id="notification"></div>
<!-- Minimap -->
<canvas id="minimap" width="120" height="120"></canvas>
<!-- Weapon Display -->
<div id="weaponDisplay">
<span class="weapon-icon" id="weaponIcon">🗡️</span>
<span id="weaponName">Wooden Sword</span>
</div>
<div class="controls-hint">
WASD: Move | SPACE: Attack | E: Interact | I: Inventory
</div>
<!-- Dialog Box -->
<div id="dialogBox">
<div class="dialog-speaker" id="dialogSpeaker"></div>
<div id="dialogText"></div>
<div class="dialog-continue">Press SPACE to continue...</div>
</div>
<!-- Inventory Panel -->
<div id="inventoryPanel">
<div class="panel-title">📦 Inventory</div>
<div class="inv-grid" id="invGrid"></div>
<div class="item-details" id="itemDetails">
Select an item to view details.
</div>
<button class="use-btn" id="useItemBtn" disabled onclick="game.useSelectedItem()">Use Item</button>
<button class="use-btn" style="margin-left:10px;" onclick="game.closeInventory()">Close (ESC)</button>
</div>
<!-- Shop Panel -->
<div id="shopPanel">
<div class="panel-title">🏪 Merchant's Shop</div>
<div id="shopItems"></div>
<div style="margin-top:16px; color:#ffd700;">
Your Gold: <span id="shopGold">0</span> 🪙
</div>
<button class="use-btn" style="margin-top:16px;" onclick="game.closeShop()">Leave Shop</button>
</div>
<!-- Save Panel -->
<div id="savePanel">
<div class="panel-title">💎 Save Point</div>
<p style="color:#aaa; margin-bottom:16px; text-align:center;">Rest at this ancient shrine to save your progress.</p>
<p style="color:#8f8; margin-bottom:20px; text-align:center;">Your health will be fully restored.</p>
<button class="menu-btn" onclick="game.saveGame()">Save & Rest</button>
<button class="menu-btn" onclick="game.closeSavePanel()">Cancel</button>
</div>
</div>
</div>
<script>
// ==================== AUDIO SYSTEM ====================
class AudioManager {
constructor() {
this.ctx = null;
this.enabled = true;
}
init() {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
}
play(type) {
if (!this.enabled || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
const now = this.ctx.currentTime;
switch(type) {
case 'attack':
osc.frequency.setValueAtTime(300, now);
osc.frequency.exponentialRampToValueAtTime(100, now + 0.1);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
osc.start(now);
osc.stop(now + 0.1);
break;
case 'hit':
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(200, now);
osc.frequency.exponentialRampToValueAtTime(50, now + 0.15);
gain.gain.setValueAtTime(0.25, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
osc.start(now);
osc.stop(now + 0.15);
break;
case 'pickup':
osc.type = 'sine';
osc.frequency.setValueAtTime(523, now);
osc.frequency.setValueAtTime(659, now + 0.05);
osc.frequency.setValueAtTime(784, now + 0.1);
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2);
osc.start(now);
osc.stop(now + 0.2);
break;
case 'step':
osc.type = 'triangle';
osc.frequency.setValueAtTime(80 + Math.random() * 40, now);
gain.gain.setValueAtTime(0.03, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.05);
osc.start(now);
osc.stop(now + 0.05);
break;
case 'door':
osc.type = 'square';
osc.frequency.setValueAtTime(200, now);
osc.frequency.exponentialRampToValueAtTime(100, now + 0.2);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.3);
osc.start(now);
osc.stop(now + 0.3);
break;
case 'save':
osc.type = 'sine';
osc.frequency.setValueAtTime(400, now);
osc.frequency.setValueAtTime(500, now + 0.1);
osc.frequency.setValueAtTime(600, now + 0.2);
osc.frequency.setValueAtTime(800, now + 0.3);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.6);
osc.start(now);
osc.stop(now + 0.6);
break;
case 'hurt':
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(150, now);
osc.frequency.exponentialRampToValueAtTime(30, now + 0.2);
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2);
osc.start(now);
osc.stop(now + 0.2);
break;
case 'shoot':
osc.type = 'square';
osc.frequency.setValueAtTime(600, now);
osc.frequency.exponentialRampToValueAtTime(200, now + 0.1);
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
osc.start(now);
osc.stop(now + 0.1);
break;
case 'chest':
osc.type = 'sine';
osc.frequency.setValueAtTime(300, now);
osc.frequency.setValueAtTime(450, now + 0.1);
osc.frequency.setValueAtTime(600, now + 0.2);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.4);
osc.start(now);
osc.stop(now + 0.4);
break;
case 'upgrade':
osc.type = 'sine';
for (let i = 0; i < 5; i++) {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.connect(g);
g.connect(this.ctx.destination);
o.frequency.setValueAtTime(400 + i * 100, now + i * 0.1);
g.gain.setValueAtTime(0.15, now + i * 0.1);
g.gain.exponentialRampToValueAtTime(0.01, now + i * 0.1 + 0.15);
o.start(now + i * 0.1);
o.stop(now + i * 0.1 + 0.15);
}
return;
}
}
}
// ==================== PARTICLE SYSTEM ====================
class Particle {
constructor(x, y, type) {
this.x = x;
this.y = y;
this.type = type;
this.life = 1;
this.decay = 0.02;
this.vx = (Math.random() - 0.5) * 4;
this.vy = (Math.random() - 0.5) * 4 - 1;
this.size = 3 + Math.random() * 4;
switch(type) {
case 'hit':
this.color = '#ff4';
this.decay = 0.08;
this.size = 2 + Math.random() * 5;
break;
case 'coin':
this.color = '#fd0';
this.decay = 0.04;
this.vy = -Math.random() * 3 - 1;
break;
case 'heal':
this.color = '#4f4';
this.decay = 0.03;
this.vy = -Math.random() * 2;
break;
case 'spark':
this.color = '#fff';
this.decay = 0.1;
this.vx *= 2;
this.vy *= 2;
break;
case 'magic':
this.color = '#a6f';
this.decay = 0.025;
this.vy = -0.5 - Math.random();
this.size = 4 + Math.random() * 6;
break;
case 'dust':
this.color = '#a86';
this.decay = 0.015;
this.vx *= 0.5;
this.vy = -0.3;
this.size = 2 + Math.random() * 3;
break;
}
}
update() {
this.x += this.vx;
this.y += this.vy;
this.life -= this.decay;
this.vx *= 0.98;
this.vy *= 0.98;
}
draw(ctx) {
ctx.globalAlpha = this.life;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * this.life, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
// ==================== MAIN GAME ====================
class Game {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.ctx = this.canvas.getContext('2d');
this.minimapCanvas = document.getElementById('minimap');
this.minimapCtx = this.minimapCanvas.getContext('2d');
this.audio = new AudioManager();
this.keys = {};
this.lastTime = 0;
this.gameState = 'menu'; // menu, playing, paused, dialog, inventory, shop, save, dead
this.setupInput();
}
init() {
// World definition
this.world = {
currentArea: 'village',
areas: this.createAreas(),
camera: { x: 0, y: 0 }
};
// Player
this.player = {
x: 400,
y: 400,
width: 24,
height: 32,
speed: 140,
facing: 'down',
animFrame: 0,
animTimer: 0,
moving: false,
// Stats
health: 6, // 3 hearts × 2 HP each
maxHealth: 6, // Will increase to 10 (5 hearts)
healthContainers: 3,
// Combat
weapon: 0, // 0=wooden, 1=iron, 2=master
attackCooldown: 0,
attacking: false,
attackTimer: 0,
attackAngle: 0,
// Ranged (unlocked mid-game)
hasRanged: false,
rangedCooldown: 0,
gold: 0,
invincible: 0,
// Inventory
inventory: [],
selectedItem: null
};
// Entities
this.enemies = [];
this.projectiles = [];
this.particles = [];
this.items = [];
this.interactables = [];
// Dialog
this.dialogQueue = [];
this.currentDialog = null;
// Progression tracking
this.progression = {
boss1Defeated: false,
boss2Defeated: false,
foundRangedWeapon: false,
weaponUpgrades: 0,
chestsOpened: [],
npcsTalked: []
};
this.loadArea(this.world.currentArea);
}
createAreas() {
return {
village: {
name: "Starfall Village",
width: 1200,
height: 1000,
bgColor: '#2d4a2d',
tiles: [],
exits: [
{ x: 550, y: 0, w: 100, h: 20, to: 'forest', spawnX: 400, spawnY: 550 },
{ x: 0, y: 450, w: 20, h: 100, to: 'shop', spawnX: 650, spawnY: 300 },
],
entities: () => [
{ type: 'npc', x: 300, y: 300, id: 'elder', name: 'Village Elder', icon: '👴', dialog: [
"Welcome, young warrior, to Starfall Village.",
"A dark star fell from the sky not long ago...",
"It shattered into pieces, and monsters began to appear.",
"Recover the star fragments in the forest and cave.",
"Take this - it might help you on your journey.",
"{GIVE:POTION}"
]},
{ type: 'npc', x: 700, y: 350, id: 'weaponsmith', name: 'Weaponsmith', icon: '🔨', dialog: [
"Hey there! I'm the local blacksmith.",
"Bring me 100 gold and a Metal Shard, and I'll upgrade your sword!",
"{SHOW_UPGRADE}"
]},
{ type: 'savepoint', x: 400, y: 300, id: 'village_save' },
{ type: 'chest', x: 900, y: 250, id: 'village_chest_1', content: { type: 'gold', amount: 30 } },
{ type: 'sign', x: 560, y: 60, text: "← North →\nWhispering Forest" },
{ type: 'pot', x: 150, y: 200, dropChance: 0.3 },
{ type: 'pot', x: 180, y: 210, dropChance: 0.3 },
],
enemies: () => [
// Low-level enemies near forest edge
{ type: 'slime', x: 800, y: 150 },
{ type: 'slime', x: 950, y: 200 },
],
decorations: () => [
// Houses
{ x: 100, y: 150, w: 120, h: 100, color: '#8b4513', type: 'house' },
{ x: 600, y: 200, w: 100, h: 90, color: '#8b4513', type: 'house' },
{ x: 250, y: 500, w: 140, h: 110, color: '#654321', type: 'house' },
// Trees
{ x: 50, y: 50, w: 40, h: 50, color: '#1a5a1a', type: 'tree' },
{ x: 1100, y: 80, w: 40, h: 50, color: '#1a5a1a', type: 'tree' },
{ x: 1000, y: 700, w: 40, h: 50, color: '#1a5a1a', type: 'tree' },
{ x: 50, y: 750, w: 40, h: 50, color: '#1a5a1a', type: 'tree' },
// Path
{ x: 550, y: 20, w: 100, h: 600, color: '#8b7355', type: 'path' },
]
},
forest: {
name: "Whispering Forest",
width: 1400,
height: 1200,
bgColor: '#1e3a1e',
exits: [
{ x: 350, y: 580, w: 100, h: 20, to: 'village', spawnX: 560, spawnY: 80 },
{ x: 650, y: 0, w: 100, h: 20, to: 'deepforest', spawnX: 400, spawnY: 550 },
{ x: 1380, y: 500, w: 20, h: 100, to: 'cave', spawnX: 80, spawnY: 300 },
],
entities: () => [
{ type: 'chest', x: 200, y: 200, id: 'forest_chest_1', content: { type: 'gold', amount: 50 } },
{ type: 'chest', x: 1100, y: 150, id: 'forest_chest_2', content: { type: 'item', item: 'ANTIDOTE' } },
{ type: 'chest', x: 800, y: 900, id: 'forest_chest_3', content: { type: 'item', item: 'METAL_SHARD' } },
{ type: 'savepoint', x: 700, y: 600, id: 'forest_save' },
{ type: 'sign', x: 380, y: 540, text: "South to\nStarfall Village" },
{ type: 'npc', x: 1200, y: 400, id: 'hermit', name: 'Forest Hermit', icon: '🧙', dialog: [
"You seek the star fragment, don't you?",
"Deep in this forest, a great slime guards one piece.",
"And in the cave to the east, you'll find another.",
"But beware - the cave is dark and dangerous.",
"Here, take this herb to help you on your way.",
"{GIVE:HERB}"
]},
],
enemies: () => {
const e = [
{ type: 'slime', x: 300, y: 150 },
{ type: 'slime', x: 500, y: 300 },
{ type: 'slime', x: 200, y: 400 },
{ type: 'wolf', x: 800, y: 200 },
{ type: 'wolf', x: 1000, y: 350 },
{ type: 'slime', x: 600, y: 800 },
{ type: 'wolf', x: 900, y: 700 },
];
// Add miniboss if not defeated
if (!this.progression.boss1Defeated) {
e.push({ type: 'bigslime', x: 700, y: 150, isBoss: true });
}
return e;
},
decorations: () => {
const d = [];
// Lots of trees
for (let i = 0; i < 40; i++) {
let x = Math.random() * 1300 + 50;
let y = Math.random() * 1100 + 50;
// Avoid paths and clearings
if ((x > 600 && x < 800) || (x > 300 && x < 500 && y > 500 && y < 650)) continue;
d.push({ x, y, w: 45 + Math.random() * 20, h: 55 + Math.random() * 20, color: '#0d2d0d', type: 'tree' });
}
// Main path
d.push({ x: 650, y: 20, w: 100, h: 1200, color: '#5a4a35', type: 'path' });
d.push({ x: 300, y: 500, w: 500, h: 100, color: '#5a4a35', type: 'path' });
return d;
}
},
deepforest: {
name: "Ancient Grove",
width: 1000,
height: 900,
bgColor: '#0f2a0f',
exits: [
{ x: 350, y: 580, w: 100, h: 20, to: 'forest', spawnX: 680, spawnY: 80 },
],
entities: () => {
const ent = [
{ type: 'savepoint', x: 500, y: 500, id: 'grove_save' },
{ type: 'sign', x: 380, y: 540, text: "Back to Forest" },
];
// Ranged weapon chest - only appears after first boss (mid-game)
if (this.progression.boss1Defeated && !this.progression.foundRangedWeapon) {
ent.push({ type: 'chest', x: 500, y: 200, id: 'ranged_chest', content: { type: 'ranged' }, special: true });
}
if (!this.progression.boss2Defeated) {
ent.push({ type: 'npc', x: 200, y: 300, id: 'spirit', name: 'Forest Spirit', icon: '👻', dialog: [
"The guardian of this grove has been corrupted by the dark star's power...",
"Only by defeating it can you obtain the second fragment.",
"After defeating the Big Slime, a magical weapon appeared in the grove...",
"Perhaps it will aid you."
]});
}
return ent;
},
enemies: () => {
const e = [
{ type: 'wolf', x: 300, y: 150 },
{ type: 'wolf', x: 700, y: 200 },
{ type: 'slime', x: 800, y: 400 },
];
if (!this.progression.boss2Defeated) {
e.push({ type: 'guardian', x: 500, y: 120, isBoss: true });
}
return e;
},
decorations: () => [
{ x: 450, y: 400, w: 100, h: 30, color: '#5a4a35', type: 'path' },
...(() => {
const d = [];
for (let i = 0; i < 25; i++) {
let x = Math.random() * 900 + 50;
let y = Math.random() * 800 + 50;
if (x > 400 && x < 600 && y > 100 && y < 550) continue;
d.push({ x, y, w: 50, h: 60, color: '#081a08', type: 'tree' });
}
return d;
})()
]
},
cave: {
name: "Darkstone Cave",
width: 1000,
height: 800,
bgColor: '#2a2535',
exits: [
{ x: 0, y: 250, w: 20, h: 100, to: 'forest', spawnX: 1250, spawnY: 530 },
],
entities: () => [
{ type: 'savepoint', x: 200, y: 200, id: 'cave_save' },
{ type: 'chest', x: 800, y: 600, id: 'cave_chest_1', content: { type: 'gold', amount: 80 } },
{ type: 'chest', x: 200, y: 600, id: 'cave_chest_2', content: { type: 'item', item: 'HEART_CONTAINER' } },
{ type: 'chest', x: 700, y: 200, id: 'cave_chest_3', content: { type: 'item', item: 'METAL_SHARD' } },
{ type: 'sign', x: 60, y: 220, text: "Exit to Forest" },
],
enemies: () => [
{ type: 'bat', x: 400, y: 200 },
{ type: 'bat', x: 500, y: 350 },
{ type: 'bat', x: 300, y: 500 },
{ type: 'skeleton', x: 600, y: 300 },
{ type: 'skeleton', x: 800, y: 450 },
{ type: 'bat', x: 700, y: 600 },
],
decorations: () => [
{ x: 400, y: 300, w: 200, h: 200, color: '#1a1520', type: 'rock' },
{ x: 100, y: 400, w: 80, h: 100, color: '#3a3545', type: 'rock' },
{ x: 850, y: 100, w: 100, h: 80, color: '#3a3545', type: 'rock' },
]
},
shop: {
name: "Merchant's Shop",
width: 800,
height: 600,
bgColor: '#4a3a2a',
exits: [
{ x: 680, y: 250, w: 20, h: 100, to: 'village', spawnX: 60, spawnY: 480 },
],
entities: () => [
{ type: 'npc', x: 350, y: 280, id: 'merchant', name: 'Merchant', icon: '💰', dialog: [
"Welcome to my shop! Have a look at my wares.",
"{OPEN_SHOP}"
]},
{ type: 'sign', x: 660, y: 220, text: "Back to Village" },
],
enemies: () => [],
decorations: () => [
{ x: 100, y: 100, w: 200, h: 150, color: '#654321', type: 'counter' },
{ x: 500, y: 100, w: 120, h: 60, color: '#5a3a1a', type: 'shelf' },
{ x: 500, y: 200, w: 120, h: 60, color: '#5a3a1a', type: 'shelf' },
{ x: 100, y: 400, w: 200, h: 80, color: '#3a2a1a', type: 'rug' },
]
}
};
}
shopItems = [
{ id: 'POTION', name: 'Health Potion', icon: '🧪', price: 30, desc: 'Restores 4 HP (2 full hearts)' },
{ id: 'HERB', name: 'Healing Herb', icon: '🌿', price: 15, desc: 'Restores 2 HP (1 heart)' },
{ id: 'ANTIDOTE', name: 'Antidote', icon: '💊', price: 20, desc: 'Cures poison (not that you need it yet...)' },
{ id: 'METAL_SHARD', name: 'Metal Shard', icon: '💎', price: 50, desc: 'Used for weapon upgrades' },
{ id: 'BOMB', name: 'Bomb', icon: '💣', price: 40, desc: 'Explosive. Useful for... things.' },
];
itemData = {
POTION: { name: 'Health Potion', icon: '🧪', desc: 'Restores 4 HP (2 full hearts).', consumable: true, effect: 'heal', value: 4 },
HERB: { name: 'Healing Herb', icon: '🌿', desc: 'Restores 2 HP (1 heart).', consumable: true, effect: 'heal', value: 2 },
ANTIDOTE: { name: 'Antidote', icon: '💊', desc: 'Cures poison.', consumable: true, effect: 'cure', value: 0 },
METAL_SHARD: { name: 'Metal Shard', icon: '💎', desc: 'A rare shard used for weapon upgrades.', consumable: false },
HEART_CONTAINER: { name: 'Heart Container', icon: '❤️', desc: 'Permanently increases maximum health by 2 HP (1 heart).', consumable: true, effect: 'container', value: 2 },
BOMB: { name: 'Bomb', icon: '💣', desc: 'Explosive weapon. Place and run!', consumable: true, effect: 'bomb', value: 0 },
STAR_FRAGMENT: { name: 'Star Fragment', icon: '⭐', desc: 'A fragment of the fallen star. Collect them all!', consumable: false, key: true },
};
weaponData = [
{ name: 'Wooden Sword', icon: '🗡️', damage: 2, range: 50, speed: 1.0, color: '#8b4513' },
{ name: 'Iron Sword', icon: '⚔️', damage: 4, range: 55, speed: 1.0, color: '#aaa' },
{ name: 'Master Sword', icon: '✨', damage: 7, range: 65, speed: 1.2, color: '#4af' },
];
enemyData = {
slime: { name: 'Slime', icon: '🟢', health: 4, damage: 1, speed: 40, size: 24, gold: 5, exp: 0 },
wolf: { name: 'Wolf', icon: '🐺', health: 6, damage: 2, speed: 70, size: 28, gold: 10, exp: 0 },
bat: { name: 'Bat', icon: '🦇', health: 3, damage: 1, speed: 90, size: 20, gold: 6, exp: 0 },
skeleton: { name: 'Skeleton', icon: '💀', health: 8, damage: 2, speed: 50, size: 28, gold: 15, exp: 0 },
bigslime: { name: 'Gelidus', icon: '👾', health: 30, damage: 2, speed: 35, size: 48, gold: 100, exp: 0, boss: true },
guardian: { name: 'Forest Guardian', icon: '🌲', health: 50, damage: 3, speed: 45, size: 56, gold: 150, exp: 0, boss: true },
};
loadArea(areaId) {
const area = this.world.areas[areaId];
if (!area) return;
this.world.currentArea = areaId;
this.interactables = [];
this.enemies = [];
this.items = [];
this.projectiles = [];
// Load entities
area.entities().forEach(e => this.spawnEntity(e));
area.enemies().forEach(e => this.spawnEnemy(e));
// Show area name
this.showAreaName(area.name);
}
spawnEntity(e) {
// Check if chest was already opened
if (e.type === 'chest' && this.progression.chestsOpened.includes(e.id)) {
return;
}
const entity = {
...e,
width: e.type === 'npc' ? 32 : (e.type === 'chest' ? 36 : 40),
height: e.type === 'npc' ? 32 : (e.type === 'chest' ? 32 : 40),
opened: false,
animTimer: 0
};
this.interactables.push(entity);
}
spawnEnemy(e) {
const data = this.enemyData[e.type];
if (!data) return;
this.enemies.push({
...e,
...data,
currentHealth: data.health,
width: data.size,
height: data.size,
vx: 0,
vy: 0,
state: 'idle',
stateTimer: 0,
attackCooldown: 0,
hitTimer: 0,
animFrame: 0,
animTimer: 0,
startX: e.x,
startY: e.y
});
}
showAreaName(name) {
const el = document.getElementById('areaName');
el.textContent = name;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 3000);
}
setupInput() {
window.addEventListener('keydown', (e) => {
this.keys[e.code] = true;
if (e.code === 'Escape') {
this.handleEscape();
}
if (e.code === 'KeyI' && this.gameState === 'playing') {
this.openInventory();
}
if ((e.code === 'Digit1' || e.code === 'Digit2' || e.code === 'Digit3' || e.code === 'Digit4') && this.gameState === 'playing') {
this.useQuickSlot(parseInt(e.code.slice(-1)) - 1);
}
if (e.code === 'Space') {
e.preventDefault();
this.handleSpace();
}
if (e.code === 'KeyE' && this.gameState === 'playing') {
this.tryInteract();
}
if (e.code === 'KeyQ' && this.gameState === 'playing' && this.player.hasRanged) {
this.fireRanged();
}
});
window.addEventListener('keyup', (e) => {
this.keys[e.code] = false;
});
// Click outside to close panels
document.getElementById('inventoryPanel').addEventListener('click', (e) => {
if (e.target.classList.contains('inv-slot')) {
const idx = parseInt(e.target.dataset.index);
this.selectInventorySlot(idx);
}
});
document.getElementById('shopItems').addEventListener('click', (e) => {
const item = e.target.closest('.shop-item');
if (item) {
const idx = parseInt(item.dataset.index);
this.tryBuyItem(idx);
}
});
}
handleEscape() {
if (this.gameState === 'inventory') {
this.closeInventory();
} else if (this.gameState === 'shop') {
this.closeShop();
} else if (this.gameState === 'save') {
this.closeSavePanel();
} else if (this.gameState === 'dialog') {
// Advance dialog instead of closing
this.advanceDialog();
}
}
handleSpace() {
if (this.gameState === 'dialog') {
this.advanceDialog();
} else if (this.gameState === 'playing') {
this.tryAttack();
}
}
// ==================== GAME FLOW ====================
startNewGame() {
this.audio.init();
document.getElementById('startScreen').classList.add('hidden');
this.init();
this.gameState = 'playing';
// Give initial potion
this.addToInventory('HERB', 1);
this.showNotification("Welcome to Starfall Village!");
this.updateHUD();
this.gameLoop(0);
}
loadGame() {
const save = localStorage.getItem('lostStarSave');
if (!save) {
alert('No save data found!');
return;
}
try {
const data = JSON.parse(save);
this.audio.init();
document.getElementById('startScreen').classList.add('hidden');
// Restore state
this.player = data.player;
this.progression = data.progression;
this.world.currentArea = data.currentArea;
this.interactables = [];
this.enemies = [];
this.items = [];
this.projectiles = [];
this.particles = [];
const area = this.world.areas[this.world.currentArea];
area.entities().forEach(e => this.spawnEntity(e));
area.enemies().forEach(e => this.spawnEnemy(e));
this.player.x = data.playerX || 400;
this.player.y = data.playerY || 400;
this.gameState = 'playing';
this.showNotification("Game loaded!");
this.updateHUD();
this.showAreaName(area.name);
this.gameLoop(0);
} catch(e) {
alert('Save data corrupted!');
console.error(e);
}
}
saveGame() {
// Heal player
this.player.health = this.player.maxHealth;
this.updateHUD();
const saveData = {
player: this.player,
progression: this.progression,
currentArea: this.world.currentArea,
playerX: this.player.x,
playerY: this.player.y,
timestamp: Date.now()
};
localStorage.setItem('lostStarSave', JSON.stringify(saveData));
this.audio.play('save');
this.spawnParticles(this.player.x, this.player.y, 'magic', 15);
this.closeSavePanel();
this.showNotification("Progress saved! Health restored.");
}
respawn() {
document.getElementById('deathScreen').classList.remove('show');
this.loadGame();
}
showControls() {
const el = document.getElementById('controlsInfo');
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
// ==================== MAIN LOOP ====================
gameLoop(timestamp) {
const dt = Math.min((timestamp - this.lastTime) / 1000, 0.1);
this.lastTime = timestamp;
if (this.gameState === 'playing') {
this.update(dt);
}
this.render();
this.renderMinimap();
requestAnimationFrame(t => this.gameLoop(t));
}
update(dt) {
// Player movement
this.updatePlayer(dt);
// Combat timers
if (this.player.attackCooldown > 0) this.player.attackCooldown -= dt;
if (this.player.attacking) {
this.player.attackTimer -= dt;
if (this.player.attackTimer <= 0) {
this.player.attacking = false;
}
}
if (this.player.rangedCooldown > 0) this.player.rangedCooldown -= dt;
if (this.player.invincible > 0) this.player.invincible -= dt;
// Enemies
this.updateEnemies(dt);
// Projectiles
this.updateProjectiles(dt);
// Particles
this.particles = this.particles.filter(p => {
p.update();
return p.life > 0;
});
// Check area transitions
this.checkAreaTransitions();
// Check items on ground
this.checkItemPickups();
// Update entity animations
this.interactables.forEach(e => {
e.animTimer += dt;
});
// Step sounds
if (this.player.moving && this.player.animTimer > 0.2) {
this.player.animTimer = 0;
this.audio.play('step');
}
}
updatePlayer(dt) {
const p = this.player;
let dx = 0, dy = 0;
if (this.keys['KeyW'] || this.keys['ArrowUp']) dy -= 1;
if (this.keys['KeyS'] || this.keys['ArrowDown']) dy += 1;
if (this.keys['KeyA'] || this.keys['ArrowLeft']) dx -= 1;
if (this.keys['KeyD'] || this.keys['ArrowRight']) dx += 1;
// Normalize diagonal
if (dx !== 0 && dy !== 0) {
dx *= 0.707;
dy *= 0.707;
}
p.moving = dx !== 0 || dy !== 0;
if (p.moving) {
if (Math.abs(dx) > Math.abs(dy)) {
p.facing = dx > 0 ? 'right' : 'left';
} else {
p.facing = dy > 0 ? 'down' : 'up';
}
p.animTimer += dt;
}
// Try move
const newX = p.x + dx * p.speed * dt;
const newY = p.y + dy * p.speed * dt;
if (!this.checkCollision(newX, p.y, p.width, p.height)) {
p.x = newX;
}
if (!this.checkCollision(p.x, newY, p.width, p.height)) {
p.y = newY;
}
// Clamp to area
const area = this.world.areas[this.world.currentArea];
p.x = Math.max(p.width/2, Math.min(area.width - p.width/2, p.x));
p.y = Math.max(p.height/2, Math.min(area.height - p.height/2, p.y));
// Animation
if (p.moving) {
p.animFrame = Math.floor(p.animTimer * 8) % 4;
} else {
p.animFrame = 0;
}
}
checkCollision(x, y, w, h) {
const area = this.world.areas[this.world.currentArea];
const halfW = w / 2;
const halfH = h / 2;
// Check decorations that block
for (const dec of area.decorations()) {
if (dec.type === 'tree' || dec.type === 'rock' || dec.type === 'house' || dec.type === 'counter' || dec.type === 'shelf') {
if (x + halfW > dec.x && x - halfW < dec.x + dec.w &&
y + halfH > dec.y && y - halfH < dec.y + dec.h) {
return true;
}
}
}
// Check interactables except signs
for (const ent of this.interactables) {
if (ent.type === 'sign' || ent.type === 'savepoint') continue;
if (x + halfW > ent.x - ent.width/2 && x - halfW < ent.x + ent.width/2 &&
y + halfH > ent.y - ent.height/2 && y - halfH < ent.y + ent.height/2) {
return true;
}
}
// Check living enemies
for (const enemy of this.enemies) {
if (x + halfW > enemy.x - enemy.width/2 && x - halfW < enemy.x + enemy.width/2 &&
y + halfH > enemy.y - enemy.height/2 && y - halfH < enemy.y + enemy.height/2) {
return true;
}
}
return false;
}
checkAreaTransitions() {
const area = this.world.areas[this.world.currentArea];
const p = this.player;
for (const exit of area.exits) {
if (p.x > exit.x && p.x < exit.x + exit.w &&
p.y > exit.y && p.y < exit.y + exit.h) {
this.audio.play('door');
this.loadArea(exit.to);
this.player.x = exit.spawnX;
this.player.y = exit.spawnY;
return;
}
}
}
checkItemPickups() {
const p = this.player;
this.items = this.items.filter(item => {
const dist = Math.hypot(item.x - p.x, item.y - p.y);
if (dist < 30) {
if (item.gold) {
this.player.gold += item.gold;
this.showNotification(`+${item.gold} Gold!`);
this.audio.play('pickup');
this.spawnParticles(item.x, item.y, 'coin', 8);
} else if (item.itemId) {
this.addToInventory(item.itemId, 1);
const data = this.itemData[item.itemId];
this.showNotification(`Got ${data.name}!`);
this.audio.play('pickup');
this.spawnParticles(item.x, item.y, 'spark', 6);
}
this.updateHUD();
return false;
}
item.animTimer = (item.animTimer || 0) + 0.016;
item.floatY = Math.sin(item.animTimer * 3) * 3;
return true;
});
}
// ==================== COMBAT ====================
tryAttack() {
if (this.player.attackCooldown > 0) return;
const weapon = this.weaponData[this.player.weapon];
this.player.attacking = true;
this.player.attackTimer = 0.3 / weapon.speed;
this.player.attackCooldown = 0.4 / weapon.speed;
this.audio.play('attack');
// Set attack angle based on facing
const facingAngles = { down: Math.PI/2, up: -Math.PI/2, left: Math.PI, right: 0 };
this.player.attackAngle = facingAngles[this.player.facing];
// Check enemy hits
const p = this.player;
const attackX = p.x + Math.cos(this.player.attackAngle) * 30;
const attackY = p.y + Math.sin(this.player.attackAngle) * 30;
this.enemies.forEach(enemy => {
const dist = Math.hypot(enemy.x - attackX, enemy.y - attackY);
if (dist < weapon.range) {
this.damageEnemy(enemy, weapon.damage);
}
});
// Break pots
this.interactables = this.interactables.filter(ent => {
if (ent.type === 'pot') {
const dist = Math.hypot(ent.x - attackX, ent.y - attackY);
if (dist < weapon.range) {
this.spawnParticles(ent.x, ent.y, 'dust', 6);
// Drop
if (Math.random() < (ent.dropChance || 0.3)) {
this.items.push({ x: ent.x, y: ent.y, gold: 5, animTimer: 0 });
}
return false;
}
}
return true;
});
}
fireRanged() {
if (this.player.rangedCooldown > 0) return;
this.player.rangedCooldown = 0.8;
this.audio.play('shoot');
const speed = 300;
const facingAngles = { down: Math.PI/2, up: -Math.PI/2, left: Math.PI, right: 0 };
const angle = facingAngles[this.player.facing];
this.projectiles.push({
x: this.player.x,
y: this.player.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
damage: 3,
fromPlayer: true,
lifetime: 2.0,
size: 8,
color: '#4af'
});
}
damageEnemy(enemy, damage) {
if (enemy.hitTimer > 0) return;
enemy.currentHealth -= damage;
enemy.hitTimer = 0.4;
this.audio.play('hit');
this.spawnParticles(enemy.x, enemy.y, 'hit', 8);
this.spawnDamageNumber(enemy.x, enemy.y - enemy.height/2, damage, '#ff4');
// Knockback
const angle = Math.atan2(enemy.y - this.player.y, enemy.x - this.player.x);
enemy.vx += Math.cos(angle) * 80;
enemy.vy += Math.sin(angle) * 80;
if (enemy.currentHealth <= 0) {
this.killEnemy(enemy);
}
}
killEnemy(enemy) {
// Drops
this.items.push({ x: enemy.x, y: enemy.y, gold: enemy.gold, animTimer: 0 });
// Boss drops special items
if (enemy.isBoss) {
if (enemy.type === 'bigslime') {
this.progression.boss1Defeated = true;
this.items.push({ x: enemy.x, y: enemy.y + 30, itemId: 'STAR_FRAGMENT', animTimer: 0 });
this.showNotification("Gelidus has been defeated!");
} else if (enemy.type === 'guardian') {
this.progression.boss2Defeated = true;
this.items.push({ x: enemy.x, y: enemy.y + 30, itemId: 'STAR_FRAGMENT', animTimer: 0 });
this.items.push({ x: enemy.x + 30, y: enemy.y, itemId: 'HEART_CONTAINER', animTimer: 0 });
this.showNotification("Forest Guardian has been defeated!");
}
}
this.spawnParticles(enemy.x, enemy.y, enemy.boss ? 'magic' : 'spark', enemy.boss ? 20 : 10);
}
updateEnemies(dt) {
this.enemies = this.enemies.filter(e => e.currentHealth > 0);
this.enemies.forEach(enemy => {
if (enemy.hitTimer > 0) enemy.hitTimer -= dt;
if (enemy.attackCooldown > 0) enemy.attackCooldown -= dt;
enemy.animTimer += dt;
enemy.animFrame = Math.floor(enemy.animTimer * 6) % 4;
// AI
const distToPlayer = Math.hypot(enemy.x - this.player.x, enemy.y - this.player.y);
// Simple state machine
enemy.stateTimer -= dt;
if (distToPlayer < 180) {
// Chase
const angle = Math.atan2(this.player.y - enemy.y, this.player.x - enemy.x);
enemy.vx = Math.cos(angle) * enemy.speed;
enemy.vy = Math.sin(angle) * enemy.speed;
// Attack if close
if (distToPlayer < 35 && enemy.attackCooldown <= 0) {
if (this.player.invincible <= 0) {
this.damagePlayer(enemy.damage);
}
enemy.attackCooldown = 1.2;
}
} else if (enemy.stateTimer <= 0) {
// Wander
enemy.stateTimer = 1 + Math.random() * 2;
if (Math.random() < 0.6 && Math.hypot(enemy.x - enemy.startX, enemy.y - enemy.startY) < 100) {
const angle = Math.random() * Math.PI * 2;
enemy.vx = Math.cos(angle) * enemy.speed * 0.5;
enemy.vy = Math.sin(angle) * enemy.speed * 0.5;
} else {
// Return to start
const angle = Math.atan2(enemy.startY - enemy.y, enemy.startX - enemy.x);
enemy.vx = Math.cos(angle) * enemy.speed * 0.5;
enemy.vy = Math.sin(angle) * enemy.speed * 0.5;
}
}
// Apply velocity with friction
const newX = enemy.x + enemy.vx * dt;
const newY = enemy.y + enemy.vy * dt;
if (!this.checkCollision(newX, enemy.y, enemy.width, enemy.height)) {
enemy.x = newX;
} else {
enemy.vx *= -0.5;
}
if (!this.checkCollision(enemy.x, newY, enemy.width, enemy.height)) {
enemy.y = newY;
} else {
enemy.vy *= -0.5;
}
enemy.vx *= 0.9;
enemy.vy *= 0.9;
// Clamp
const area = this.world.areas[this.world.currentArea];
enemy.x = Math.max(enemy.width, Math.min(area.width - enemy.width, enemy.x));
enemy.y = Math.max(enemy.height, Math.min(area.height - enemy.height, enemy.y));
});
}
updateProjectiles(dt) {
this.projectiles = this.projectiles.filter(proj => {
proj.x += proj.vx * dt;
proj.y += proj.vy * dt;
proj.lifetime -= dt;
if (proj.lifetime <= 0) return false;
const area = this.world.areas[this.world.currentArea];
if (proj.x < 0 || proj.x > area.width || proj.y < 0 || proj.y > area.height) {
return false;
}
if (proj.fromPlayer) {
for (const enemy of this.enemies) {
const dist = Math.hypot(enemy.x - proj.x, enemy.y - proj.y);
if (dist < enemy.width/2 + proj.size) {
this.damageEnemy(enemy, proj.damage);
return false;
}
}
}
return true;
});
}
damagePlayer(damage) {
this.player.health -= damage;
this.player.invincible = 1.0;
this.audio.play('hurt');
this.spawnParticles(this.player.x, this.player.y, 'hit', 10);
this.spawnDamageNumber(this.player.x, this.player.y - 20, damage, '#f44');
this.updateHUD();
if (this.player.health <= 0) {
this.gameState = 'dead';
document.getElementById('deathScreen').classList.add('show');
}
}
// ==================== INTERACTION ====================
tryInteract() {
const p = this.player;
for (const ent of this.interactables) {
const dist = Math.hypot(ent.x - p.x, ent.y - p.y);
if (dist < 50) {
this.interactWith(ent);
return;
}
}
}
interactWith(ent) {
switch(ent.type) {
case 'npc':
this.startDialog(ent);
break;
case 'chest':
this.openChest(ent);
break;
case 'savepoint':
this.openSavePanel();
break;
case 'sign':
this.showNotification(ent.text);
break;
}
}
startDialog(npc) {
// Check if NPC has special dialog
if (!this.progression.npcsTalked.includes(npc.id)) {
this.progression.npcsTalked.push(npc.id);
}
this.dialogQueue = [...npc.dialog];
this.currentNPC = npc;
this.advanceDialog();
}
advanceDialog() {
if (this.dialogQueue.length === 0) {
this.closeDialog();
return;
}
const line = this.dialogQueue.shift();
// Special commands
if (line.startsWith('{GIVE:')) {
const itemId = line.slice(6, -1);
this.addToInventory(itemId, 1);
const data = this.itemData[itemId];
this.showNotification(`Received ${data.name}!`);
this.audio.play('pickup');
this.advanceDialog();
return;
}
if (line === '{OPEN_SHOP}') {
this.closeDialog();
this.openShop();
return;
}
if (line === '{SHOW_UPGRADE}') {
this.closeDialog();
this.showUpgradeOption();
return;
}
document.getElementById('dialogSpeaker').textContent = this.currentNPC ? this.currentNPC.name : '';
document.getElementById('dialogText').textContent = line;
document.getElementById('dialogBox').classList.add('show');
this.gameState = 'dialog';
}
closeDialog() {
document.getElementById('dialogBox').classList.remove('show');
this.gameState = 'playing';
this.currentNPC = null;
}
openChest(chest) {
if (chest.opened || this.progression.chestsOpened.includes(chest.id)) return;
chest.opened = true;
this.progression.chestsOpened.push(chest.id);
this.audio.play('chest');
const content = chest.content;
if (content.type === 'gold') {
this.player.gold += content.amount;
this.showNotification(`Found ${content.amount} Gold!`);
this.spawnParticles(chest.x, chest.y, 'coin', 12);
} else if (content.type === 'item') {
if (content.item === 'HEART_CONTAINER') {
// Special: immediately apply
this.player.healthContainers++;
this.player.maxHealth += 2;
this.player.health += 2;
this.showNotification("Heart Container acquired!\nMax health increased!");
this.audio.play('upgrade');
this.spawnParticles(chest.x, chest.y, 'heal', 20);
} else {
this.addToInventory(content.item, 1);
const data = this.itemData[content.item];
this.showNotification(`Found ${data.name}!`);
}
} else if (content.type === 'ranged') {
this.player.hasRanged = true;
this.progression.foundRangedWeapon = true;
this.showNotification("Found the STAR BOW!\nPress Q to shoot!");
this.audio.play('upgrade');
this.spawnParticles(chest.x, chest.y, 'magic', 25);
}
this.updateHUD();
}
showUpgradeOption() {
// Check eligibility
const canUpgrade = (this.player.weapon < 2) &&
(this.getItemCount('METAL_SHARD') >= 1) &&
(this.player.gold >= (this.player.weapon === 0 ? 100 : 200));
const cost = this.player.weapon === 0 ? 100 : 200;
const nextWeapon = this.weaponData[this.player.weapon + 1];
if (this.player.weapon >= 2) {
this.showNotification("Your weapon is already fully upgraded!");
} else if (!canUpgrade) {
const haveShards = this.getItemCount('METAL_SHARD');
this.showNotification(`Need: ${cost} Gold + 1 Metal Shard\nYou have: ${this.player.gold} Gold + ${haveShards} Shard(s)`);
} else {
// Do upgrade
this.removeItem('METAL_SHARD', 1);
this.player.gold -= cost;
this.player.weapon++;
this.progression.weaponUpgrades++;
this.audio.play('upgrade');
this.spawnParticles(this.player.x, this.player.y, 'spark', 20);
this.showNotification(`Weapon upgraded to ${nextWeapon.name}!`);
this.updateHUD();
this.updateWeaponDisplay();
}
}
// ==================== INVENTORY ====================
addToInventory(itemId, count) {
const existing = this.player.inventory.find(i => i.id === itemId);
if (existing) {
existing.count += count;
} else {
this.player.inventory.push({ id: itemId, count });
}
}
getItemCount(itemId) {
const item = this.player.inventory.find(i => i.id === itemId);
return item ? item.count : 0;
}
removeItem(itemId, count) {
const item = this.player.inventory.find(i => i.id === itemId);
if (item) {
item.count -= count;
if (item.count <= 0) {
this.player.inventory = this.player.inventory.filter(i => i.id !== itemId);
}
}
}
openInventory() {
this.gameState = 'inventory';
this.player.selectedItem = null;
this.renderInventory();
document.getElementById('inventoryPanel').classList.add('show');
}
closeInventory() {
this.gameState = 'playing';
document.getElementById('inventoryPanel').classList.remove('show');
}
renderInventory() {
const grid = document.getElementById('invGrid');
grid.innerHTML = '';
// 12 slots (3 rows × 4 cols)
for (let i = 0; i < 12; i++) {
const slot = document.createElement('div');
slot.className = 'inv-slot';
slot.dataset.index = i;
if (i < this.player.inventory.length) {
const invItem = this.player.inventory[i];
const data = this.itemData[invItem.id];
slot.innerHTML = `
<span class="item-icon">${data.icon}</span>
${invItem.count > 1 ? `<span class="item-count">×${invItem.count}</span>` : ''}
`;
if (this.player.selectedItem === i) {
slot.classList.add('selected');
}
}
grid.appendChild(slot);
}
}
selectInventorySlot(idx) {
if (idx >= this.player.inventory.length) return;
this.player.selectedItem = idx;
this.renderInventory();
const invItem = this.player.inventory[idx];
const data = this.itemData[invItem.id];
document.getElementById('itemDetails').innerHTML = `
<div style="font-size:18px; margin-bottom:6px; color:#fff;">
${data.icon} ${data.name} ${invItem.count > 1 ? `(×${invItem.count})` : ''}
</div>
<div>${data.desc}</div>
`;
const btn = document.getElementById('useItemBtn');
btn.disabled = !data.consumable;
}
useSelectedItem() {
if (this.player.selectedItem === null) return;
const invItem = this.player.inventory[this.player.selectedItem];
if (!invItem) return;
const data = this.itemData[invItem.id];
if (!data.consumable) return;
// Apply effect
if (data.effect === 'heal') {
const healAmount = Math.min(data.value, this.player.maxHealth - this.player.health);
if (healAmount <= 0) {
this.showNotification("Already at full health!");
return;
}
this.player.health += healAmount;
this.spawnParticles(this.player.x, this.player.y, 'heal', 10);
this.audio.play('pickup');
this.showNotification(`Healed ${healAmount} HP!`);
} else if (data.effect === 'container') {
// Shouldn't happen in inventory - applied on pickup
this.player.healthContainers++;
this.player.maxHealth += 2;
this.player.health += 2;
this.audio.play('upgrade');
this.showNotification("Max health increased!");
}
this.removeItem(invItem.id, 1);
this.updateHUD();
this.renderInventory();
// Refresh selection
if (this.player.selectedItem >= this.player.inventory.length) {
this.player.selectedItem = null;
document.getElementById('itemDetails').textContent = 'Select an item to view details.';
document.getElementById('useItemBtn').disabled = true;
} else {
this.selectInventorySlot(this.player.selectedItem);
}
this.closeInventory();
this.gameState = 'playing';
}
useQuickSlot(slot) {
// Quick slots map to first 4 inventory items
if (slot < this.player.inventory.length) {
const invItem = this.player.inventory[slot];
const data = this.itemData[invItem.id];
if (data.consumable) {
this.player.selectedItem = slot;
this.useSelectedItem();
}
}
}
// ==================== SHOP ====================
openShop() {
this.gameState = 'shop';
this.renderShop();
document.getElementById('shopPanel').classList.add('show');
}
closeShop() {
this.gameState = 'playing';
document.getElementById('shopPanel').classList.remove('show');
}
renderShop() {
const container = document.getElementById('shopItems');
container.innerHTML = '';
document.getElementById('shopGold').textContent = this.player.gold;
this.shopItems.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'shop-item';
div.dataset.index = idx;
const canBuy = this.player.gold >= item.price;
div.innerHTML = `
<div class="shop-item-info">
<span style="font-size:24px; margin-right:12px;">${item.icon}</span>
<div>
<div style="color:#fff; font-weight:bold;">${item.name}</div>
<div style="color:#888; font-size:12px;">${item.desc}</div>
</div>
</div>
<div class="shop-price" style="${canBuy ? '' : 'color:#666;'}">${item.price} 🪙</div>
`;
container.appendChild(div);
});
}
tryBuyItem(idx) {
const item = this.shopItems[idx];
if (this.player.gold >= item.price) {
this.player.gold -= item.price;
this.addToInventory(item.id, 1);
this.audio.play('pickup');
this.showNotification(`Purchased ${item.name}!`);
this.updateHUD();
this.renderShop();
} else {
this.showNotification("Not enough gold!");
}
}
// ==================== SAVE PANEL ====================
openSavePanel() {
this.gameState = 'save';
document.getElementById('savePanel').classList.add('show');
}
closeSavePanel() {
this.gameState = 'playing';
document.getElementById('savePanel').classList.remove('show');
}
// ==================== UI UPDATES ====================
updateHUD() {
// Hearts
const heartsContainer = document.getElementById('heartsContainer');
heartsContainer.innerHTML = '';
for (let i = 0; i < this.player.healthContainers; i++) {
const heartHealth = Math.max(0, Math.min(2, this.player.health - i * 2));
const heartDiv = document.createElement('div');
heartDiv.className = 'heart';
// 0 = empty, 1 = half, 2 = full
let heartChar = '🖤';
if (heartHealth >= 2) heartChar = '❤️';
else if (heartHealth >= 1) heartChar = '💔';
heartDiv.innerHTML = `<span style="font-size:26px;${heartHealth >= 2 ? 'filter:drop-shadow(0 0 4px #f44);' : ''}">${heartChar}</span>`;
heartsContainer.appendChild(heartDiv);
}
// Empty slots (show progression)
const maxContainers = 5;
for (let i = this.player.healthContainers; i < maxContainers; i++) {
const heartDiv = document.createElement('div');
heartDiv.className = 'heart';
heartDiv.innerHTML = `<span style="font-size:26px;opacity:0.3;">🖤</span>`;
heartsContainer.appendChild(heartDiv);
}
// Gold
document.getElementById('coinCount').textContent = this.player.gold;
}
updateWeaponDisplay() {
const weapon = this.weaponData[this.player.weapon];
document.getElementById('weaponIcon').textContent = weapon.icon;
document.getElementById('weaponName').textContent = weapon.name + (this.player.hasRanged ? ' + 🏹' : '');
}
showNotification(text) {
const el = document.getElementById('notification');
el.innerHTML = text.replace(/\n/g, '<br>');
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 3000);
}
spawnParticles(x, y, type, count) {
for (let i = 0; i < count; i++) {
this.particles.push(new Particle(x, y, type));
}
}
spawnDamageNumber(x, y, value, color) {
// Simple text particle
const p = new Particle(x, y - 10, 'spark');
p.color = color;
p.text = '-' + value;
p.vy = -2;
p.decay = 0.015;
p.size = 0;
this.particles.push(p);
}
// ==================== RENDERING ====================
render() {
const ctx = this.ctx;
const area = this.world.areas[this.world.currentArea];
// Camera follows player
const camX = this.player.x - this.canvas.width / 2;
const camY = this.player.y - this.canvas.height / 2;
this.world.camera.x = Math.max(0, Math.min(area.width - this.canvas.width, camX));
this.world.camera.y = Math.max(0, Math.min(area.height - this.canvas.height, camY));
ctx.save();
ctx.translate(-this.world.camera.x, -this.world.camera.y);
// Background
ctx.fillStyle = area.bgColor;
ctx.fillRect(0, 0, area.width, area.height);
// Grid pattern
ctx.fillStyle = 'rgba(255,255,255,0.02)';
for (let x = 0; x < area.width; x += 32) {
for (let y = 0; y < area.height; y += 32) {
if ((x + y) % 64 === 0) {
ctx.fillRect(x, y, 32, 32);
}
}
}
// Exits
area.exits.forEach(exit => {
ctx.fillStyle = 'rgba(100, 200, 255, 0.3)';
ctx.fillRect(exit.x, exit.y, exit.w, exit.h);
ctx.strokeStyle = '#4af';
ctx.lineWidth = 2;
ctx.strokeRect(exit.x, exit.y, exit.w, exit.h);
});
// Decorations (behind player)
area.decorations().forEach(dec => {
if (dec.type === 'path' || dec.type === 'rug') {
ctx.fillStyle = dec.color;
if (dec.type === 'rug') {
ctx.globalAlpha = 0.6;
}
ctx.fillRect(dec.x, dec.y, dec.w, dec.h);
ctx.globalAlpha = 1;
}
});
// Items on ground
this.items.forEach(item => {
ctx.save();
ctx.translate(item.x, item.y + (item.floatY || 0));
ctx.shadowColor = item.gold ? '#fd0' : '#fff';
ctx.shadowBlur = 10;
ctx.font = '20px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (item.gold) {
ctx.fillText('🪙', 0, 0);
} else if (item.itemId) {
const data = this.itemData[item.itemId];
ctx.fillText(data.icon, 0, 0);
}
ctx.restore();
});
// Interactables (render some behind, some in front order matters)
const behindPlayer = [];
const inFrontPlayer = [];
this.interactables.forEach(ent => {
if (ent.y < this.player.y) {
behindPlayer.push(ent);
} else {
inFrontPlayer.push(ent);
}
});
behindPlayer.forEach(ent => this.renderInteractable(ctx, ent));
// Enemies
const enemiesBehind = [];
const enemiesFront = [];
this.enemies.forEach(e => {
if (e.y < this.player.y) enemiesBehind.push(e);
else enemiesFront.push(e);
});
enemiesBehind.forEach(e => this.renderEnemy(ctx, e));
// Player
this.renderPlayer(ctx);
enemiesFront.forEach(e => this.renderEnemy(ctx, e));
inFrontPlayer.forEach(ent => this.renderInteractable(ctx, ent));
// Decorations in front
area.decorations().forEach(dec => {
if (dec.type === 'tree' || dec.type === 'rock' || dec.type === 'house' || dec.type === 'counter' || dec.type === 'shelf') {
// Only draw those that should be in front based on Y vs player...
// Actually draw all with Y-based ordering consideration but trees are tall
const baseY = dec.y + dec.h;
if (baseY > this.player.y) {
this.renderDecoration(ctx, dec);
}
}
});
// Actually re-draw tall decorations properly with bottom-edge Y sorting
area.decorations().forEach(dec => {
if (dec.type === 'tree') {
const bottomY = dec.y + dec.h;
if (bottomY >= this.player.y - 10) {
this.renderDecoration(ctx, dec);
}
}
});
// Attack arc
if (this.player.attacking) {
const p = this.player;
const weapon = this.weaponData[p.weapon];
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.attackAngle);
// Weapon swipe
ctx.globalAlpha = 0.6;
ctx.fillStyle = weapon.color;
ctx.beginPath();
ctx.moveTo(15, -15);
ctx.lineTo(weapon.range, -8);
ctx.lineTo(weapon.range, 8);
ctx.lineTo(15, 15);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// Projectiles
this.projectiles.forEach(proj => {
ctx.save();
ctx.translate(proj.x, proj.y);
ctx.shadowColor = proj.color;
ctx.shadowBlur = 10;
ctx.fillStyle = proj.color;
ctx.beginPath();
ctx.arc(0, 0, proj.size, 0, Math.PI * 2);
ctx.fill();
// Trail
ctx.globalAlpha = 0.4;
for (let i = 1; i <= 3; i++) {
ctx.beginPath();
ctx.arc(-proj.vx * 0.02 * i, -proj.vy * 0.02 * i, proj.size * (1 - i * 0.2), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
});
// Particles
this.particles.forEach(p => {
if (p.text) {
ctx.save();
ctx.globalAlpha = p.life;
ctx.font = 'bold 16px sans-serif';
ctx.fillStyle = p.color;
ctx.textAlign = 'center';
ctx.fillText(p.text, p.x, p.y);
ctx.restore();
} else {
p.draw(ctx);
}
});
ctx.restore();
// Screen flash when invincible
if (this.player.invincible > 0) {
const flash = Math.sin(this.player.invincible * 20) > 0;
if (flash) {
ctx.fillStyle = 'rgba(255, 50, 50, 0.2)';
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
}
}
renderPlayer(ctx) {
const p = this.player;
ctx.save();
ctx.translate(p.x, p.y);
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, p.height/2 - 4, 12, 6, 0, 0, Math.PI * 2);
ctx.fill();
// Body
const bobY = p.moving ? Math.sin(p.animFrame * Math.PI / 2) * 2 : 0;
ctx.fillStyle = '#4488ff';
ctx.fillRect(-10, -8 + bobY, 20, 24);
// Head
ctx.fillStyle = '#ffcc99';
ctx.beginPath();
ctx.arc(0, -14 + bobY, 10, 0, Math.PI * 2);
ctx.fill();
// Hair
ctx.fillStyle = '#8b4513';
ctx.beginPath();
ctx.arc(0, -18 + bobY, 10, Math.PI, 0);
ctx.fill();
// Eyes based on facing
ctx.fillStyle = '#000';
if (p.facing === 'left') {
ctx.fillRect(-6, -15 + bobY, 3, 3);
} else if (p.facing === 'right') {
ctx.fillRect(3, -15 + bobY, 3, 3);
} else {
ctx.fillRect(-4, -15 + bobY, 2, 3);
ctx.fillRect(2, -15 + bobY, 2, 3);
}
// Weapon at side
const weapon = this.weaponData[p.weapon];
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
if (!p.attacking) {
ctx.fillText(weapon.icon, 14, 4 + bobY);
}
// Ranged indicator
if (p.hasRanged) {
ctx.font = '10px sans-serif';
ctx.fillText('🏹', -16, 8 + bobY);
}
ctx.restore();
}
renderEnemy(ctx, enemy) {
ctx.save();
ctx.translate(enemy.x, enemy.y);
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, enemy.height/2 - 2, enemy.width/2 - 4, 6, 0, 0, Math.PI * 2);
ctx.fill();
// Hit flash
if (enemy.hitTimer > 0) {
ctx.globalAlpha = 0.5 + Math.sin(enemy.hitTimer * 30) * 0.5;
}
const bobY = Math.sin(enemy.animFrame * Math.PI / 2) * 2;
// Boss indicator
if (enemy.boss) {
ctx.font = `${enemy.width - 4}px sans-serif`;
} else {
ctx.font = `${enemy.width}px sans-serif`;
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(enemy.icon, 0, bobY);
// Health bar
if (enemy.currentHealth < enemy.health) {
const barW = enemy.width;
const barH = 4;
const pct = enemy.currentHealth / enemy.health;
ctx.fillStyle = '#333';
ctx.fillRect(-barW/2, -enemy.height/2 - 10, barW, barH);
ctx.fillStyle = enemy.boss ? '#f4f' : '#f44';
ctx.fillRect(-barW/2, -enemy.height/2 - 10, barW * pct, barH);
}
ctx.restore();
}
renderInteractable(ctx, ent) {
ctx.save();
ctx.translate(ent.x, ent.y);
const bobY = Math.sin(ent.animTimer * 2) * 2;
// Glow for important things
if (ent.type === 'savepoint') {
ctx.shadowColor = '#4af';
ctx.shadowBlur = 15 + Math.sin(ent.animTimer * 3) * 5;
} else if (ent.type === 'chest' && !ent.opened) {
ctx.shadowColor = '#fd0';
ctx.shadowBlur = 10;
} else if (ent.special) {
ctx.shadowColor = '#f4f';
ctx.shadowBlur = 20;
}
ctx.font = '28px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
switch(ent.type) {
case 'npc':
ctx.fillText(ent.icon, 0, bobY);
// Name tag
ctx.font = '11px sans-serif';
ctx.fillStyle = '#fff';
ctx.shadowBlur = 0;
ctx.fillText(ent.name, 0, -24);
break;
case 'chest':
ctx.fillText(ent.opened ? '📭' : '📦', 0, 0);
break;
case 'savepoint':
ctx.fillText('💎', 0, bobY);
break;
case 'sign':
ctx.fillText('🪧', 0, 0);
break;
case 'pot':
ctx.font = '20px sans-serif';
ctx.fillText('🏺', 0, 0);
break;
}
ctx.restore();
}
renderDecoration(ctx, dec) {
ctx.save();
switch(dec.type) {
case 'tree':
// Trunk
ctx.fillStyle = '#5a3a1a';
ctx.fillRect(dec.x + dec.w/2 - 6, dec.y + dec.h - 15, 12, 20);
// Foliage
ctx.fillStyle = dec.color;
ctx.beginPath();
ctx.moveTo(dec.x + dec.w/2, dec.y);
ctx.lineTo(dec.x, dec.y + dec.h - 10);
ctx.lineTo(dec.x + dec.w, dec.y + dec.h - 10);
ctx.closePath();
ctx.fill();
// Round top
ctx.beginPath();
ctx.arc(dec.x + dec.w/2, dec.y + dec.h * 0.35, dec.w * 0.45, 0, Math.PI * 2);
ctx.fill();
break;
case 'rock':
ctx.fillStyle = dec.color;
ctx.beginPath();
ctx.moveTo(dec.x + dec.w * 0.1, dec.y + dec.h);
ctx.lineTo(dec.x, dec.y + dec.h * 0.6);
ctx.lineTo(dec.x + dec.w * 0.3, dec.y);
ctx.lineTo(dec.x + dec.w * 0.7, dec.y + dec.h * 0.1);
ctx.lineTo(dec.x + dec.w, dec.y + dec.h * 0.5);
ctx.lineTo(dec.x + dec.w * 0.9, dec.y + dec.h);
ctx.closePath();
ctx.fill();
// Highlight
ctx.fillStyle = 'rgba(255,255,255,0.1)';
ctx.beginPath();
ctx.arc(dec.x + dec.w * 0.4, dec.y + dec.h * 0.3, dec.w * 0.2, 0, Math.PI * 2);
ctx.fill();
break;
case 'house':
// Base
ctx.fillStyle = dec.color;
ctx.fillRect(dec.x, dec.y + dec.h * 0.35, dec.w, dec.h * 0.65);
// Roof
ctx.fillStyle = '#6b2a00';
ctx.beginPath();
ctx.moveTo(dec.x - 10, dec.y + dec.h * 0.35);
ctx.lineTo(dec.x + dec.w/2, dec.y - 10);
ctx.lineTo(dec.x + dec.w + 10, dec.y + dec.h * 0.35);
ctx.closePath();
ctx.fill();
// Door
ctx.fillStyle = '#3a1a00';
ctx.fillRect(dec.x + dec.w/2 - 12, dec.y + dec.h - 35, 24, 35);
// Doorknob
ctx.fillStyle = '#da1';
ctx.beginPath();
ctx.arc(dec.x + dec.w/2 + 6, dec.y + dec.h - 18, 3, 0, Math.PI * 2);
ctx.fill();
// Window
ctx.fillStyle = '#88ccff';
ctx.fillRect(dec.x + 15, dec.y + dec.h * 0.5, 20, 20);
ctx.fillRect(dec.x + dec.w - 35, dec.y + dec.h * 0.5, 20, 20);
// Cross
ctx.strokeStyle = '#5a3a1a';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(dec.x + 25, dec.y + dec.h * 0.5);
ctx.lineTo(dec.x + 25, dec.y + dec.h * 0.5 + 20);
ctx.moveTo(dec.x + 15, dec.y + dec.h * 0.5 + 10);
ctx.lineTo(dec.x + 35, dec.y + dec.h * 0.5 + 10);
ctx.stroke();
break;
case 'counter':
ctx.fillStyle = dec.color;
ctx.fillRect(dec.x, dec.y, dec.w, dec.h);
ctx.fillStyle = '#7a5a3a';
ctx.fillRect(dec.x, dec.y, dec.w, 15);
// Items on counter
ctx.font = '24px sans-serif';
ctx.fillText('🧪', dec.x + 40, dec.y + 50);
ctx.fillText('🌿', dec.x + 90, dec.y + 55);
ctx.fillText('💎', dec.x + 140, dec.y + 48);
break;
case 'shelf':
ctx.fillStyle = dec.color;
ctx.fillRect(dec.x, dec.y, dec.w, dec.h);
ctx.fillStyle = '#4a2a0a';
ctx.fillRect(dec.x, dec.y, dec.w, 8);
ctx.fillRect(dec.x, dec.y + dec.h - 8, dec.w, 8);
break;
}
ctx.restore();
}
renderMinimap() {
const mctx = this.minimapCtx;
const area = this.world.areas[this.world.currentArea];
const scale = 120 / Math.max(area.width, area.height);
mctx.fillStyle = 'rgba(0,0,0,0.8)';
mctx.fillRect(0, 0, 120, 120);
// Background color
mctx.fillStyle = area.bgColor;
mctx.globalAlpha = 0.5;
mctx.fillRect(0, 0, area.width * scale, area.height * scale);
mctx.globalAlpha = 1;
// Decorations (blocking)
area.decorations().forEach(dec => {
if (dec.type === 'tree' || dec.type === 'rock' || dec.type === 'house') {
mctx.fillStyle = dec.type === 'tree' ? '#0a2a0a' : '#333';
mctx.fillRect(dec.x * scale, dec.y * scale, Math.max(2, dec.w * scale), Math.max(2, dec.h * scale));
}
});
// Exits
area.exits.forEach(exit => {
mctx.fillStyle = '#4af';
mctx.fillRect(exit.x * scale, exit.y * scale, Math.max(3, exit.w * scale), Math.max(3, exit.h * scale));
});
// Interactables
this.interactables.forEach(ent => {
if (ent.type === 'savepoint') {
mctx.fillStyle = '#4af';
} else if (ent.type === 'chest' && !ent.opened) {
mctx.fillStyle = '#fd0';
} else if (ent.type === 'npc') {
mctx.fillStyle = '#fa4';
} else {
return;
}
mctx.beginPath();
mctx.arc(ent.x * scale, ent.y * scale, 3, 0, Math.PI * 2);
mctx.fill();
});
// Enemies
this.enemies.forEach(enemy => {
mctx.fillStyle = enemy.boss ? '#f4f' : '#f44';
const size = enemy.boss ? 4 : 2;
mctx.beginPath();
mctx.arc(enemy.x * scale, enemy.y * scale, size, 0, Math.PI * 2);
mctx.fill();
});
// Player
mctx.fillStyle = '#4f4';
mctx.beginPath();
mctx.arc(this.player.x * scale, this.player.y * scale, 4, 0, Math.PI * 2);
mctx.fill();
// Viewport
mctx.strokeStyle = 'rgba(255,255,255,0.5)';
mctx.lineWidth = 1;
mctx.strokeRect(
this.world.camera.x * scale,
this.world.camera.y * scale,
this.canvas.width * scale,
this.canvas.height * scale
);
}
}
const game = new Game();
// Start screen music / context wakeup
document.addEventListener('click', () => {
if (!game.audio.ctx) {
game.audio.init();
}
}, { once: true });
</script>
</body>
</html>
<!-- agent-meta {"model":"bytedance-seed/seed-2.0-code","provider":"openrouter","persona":"full","sessionId":"176f28cc-c72d-43eb-b423-dd9a82581232","tokensIn":445867,"tokensOut":30789,"tokensTotal":476656,"cost":0.3153005,"turns":14,"toolCalls":13,"failedToolCalls":0,"timestamp":"2026-08-15T21:06:27.885Z"} -->