RPG
Qwen 3.6 27B MTP GGUF IQ4 NL · typescript

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 114,673 bytes · SHA-256 57fd819a5a29
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Realm of Shadows - Action RPG</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
overflow: hidden;
font-family: 'Courier New', monospace;
}
#app {
position: relative;
width: 800px;
height: 600px;
}
#game-canvas {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
#overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
display: none;
justify-content: center;
align-items: center;
flex-direction: column;
background: rgba(0,0,0,0.85);
color: #fff;
z-index: 10;
}
#overlay.active { display: flex; }
#overlay h1 { font-size: 36px; margin-bottom: 10px; color: #e8c547; text-shadow: 2px 2px 4px #000; }
#overlay p { font-size: 16px; margin: 6px 0; color: #ccc; }
#overlay .btn {
margin-top: 20px; padding: 12px 32px;
background: #3a7c3a; color: #fff; border: 2px solid #5cb85c;
font-family: inherit; font-size: 18px; cursor: pointer;
transition: background 0.2s;
}
#overlay .btn:hover { background: #5cb85c; }
#inventory-panel {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: rgba(20, 15, 30, 0.95);
border: 2px solid #6a5acd;
padding: 15px;
display: none;
color: #fff;
min-width: 400px;
z-index: 20;
border-radius: 4px;
}
#inventory-panel.active { display: block; }
#inventory-panel h2 { text-align: center; color: #e8c547; margin-bottom: 10px; font-size: 20px; }
#inventory-grid {
display: grid;
grid-template-columns: repeat(6, 60px);
gap: 6px;
justify-content: center;
margin-bottom: 10px;
}
.inv-slot {
width: 60px; height: 60px;
background: rgba(100, 100, 150, 0.3);
border: 1px solid #444;
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
cursor: pointer;
position: relative;
}
.inv-slot:hover { border-color: #aaa; background: rgba(100, 100, 150, 0.5); }
.inv-slot .count {
position: absolute; bottom: 1px; right: 3px;
font-size: 11px; color: #aaa;
}
#shop-panel {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: rgba(20, 15, 30, 0.95);
border: 2px solid #d4a44c;
padding: 15px;
display: none;
color: #fff;
min-width: 420px;
z-index: 20;
border-radius: 4px;
}
#shop-panel.active { display: block; }
#shop-panel h2 { text-align: center; color: #e8c547; margin-bottom: 10px; font-size: 20px; }
.shop-item {
display: flex; justify-content: space-between; align-items: center;
padding: 8px; margin: 4px 0;
background: rgba(100, 100, 150, 0.2);
border: 1px solid #555; cursor: pointer;
}
.shop-item:hover { border-color: #aaa; background: rgba(100, 100, 150, 0.4); }
.shop-item .name { font-size: 14px; }
.shop-item .price { color: #e8c547; font-size: 14px; }
.shop-item .desc { font-size: 11px; color: #999; }
#dialog-box {
position: absolute;
bottom: 20px; left: 50%;
transform: translateX(-50%);
background: rgba(20, 15, 30, 0.92);
border: 2px solid #6a5acd;
padding: 12px 18px;
display: none;
color: #fff;
max-width: 500px;
z-index: 15;
border-radius: 4px;
font-size: 14px;
line-height: 1.5;
}
#dialog-box.active { display: block; }
#notification {
position: absolute;
top: 80px; left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: #e8c547;
padding: 8px 20px;
display: none;
z-index: 25;
border-radius: 4px;
font-size: 16px;
white-space: nowrap;
pointer-events: none;
}
</style>
</head>
<body>
<div id="app">
<canvas id="game-canvas" width="800" height="600"></canvas>
<div id="overlay"></div>
<div id="inventory-panel">
<h2>Inventory</h2>
<div id="inventory-grid"></div>
<p style="text-align:center;font-size:12px;color:#888;">Press I to close</p>
</div>
<div id="shop-panel">
<h2>Shop</h2>
<div id="shop-list"></div>
<p style="text-align:center;font-size:12px;color:#888;margin-top:8px;">Press ESC or E to close</p>
</div>
<div id="dialog-box"></div>
<div id="notification"></div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./engine":"src/engine.ts"},"src/map.ts":{"./types":"src/types.ts"},"src/input.ts":{"./types":"src/types.ts"},"src/player.ts":{"./types":"src/types.ts","./input":"src/input.ts","./map":"src/map.ts","./audio":"src/audio.ts"},"src/enemies.ts":{"./types":"src/types.ts","./map":"src/map.ts"},"src/effects.ts":{"./types":"src/types.ts"},"src/renderer.ts":{"./types":"src/types.ts","./map":"src/map.ts","./player":"src/player.ts","./effects":"src/effects.ts"},"src/engine.ts":{"./types":"src/types.ts","./map":"src/map.ts","./input":"src/input.ts","./player":"src/player.ts","./enemies":"src/enemies.ts","./effects":"src/effects.ts","./renderer":"src/renderer.ts","./audio":"src/audio.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const engine_1 = require("./engine");
const canvas = document.getElementById('game-canvas');
const engine = new engine_1.GameEngine(canvas);
// Expose for HTML onclick handlers
window.gameEngine = engine;
engine.start();
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SHOP_CATALOG = exports.ITEMS = exports.WEAPONS = exports.EnemyType = exports.Tile = exports.Dir = exports.INV_SLOTS = exports.HP_PER_CONTAINER = exports.MAX_HP_CONTAINERS_MAX = exports.MAX_HP_CONTAINERS_START = exports.PLAYER_HP = exports.PLAYER_SPEED = exports.SCREEN_H = exports.SCREEN_W = exports.ROWS = exports.COLS = exports.TILE = void 0;
exports.TILE = 32;
exports.COLS = 25;
exports.ROWS = 19;
exports.SCREEN_W = 800;
exports.SCREEN_H = 600;
exports.PLAYER_SPEED = 2.5;
exports.PLAYER_HP = 6;
exports.MAX_HP_CONTAINERS_START = 3;
exports.MAX_HP_CONTAINERS_MAX = 5;
exports.HP_PER_CONTAINER = 2;
exports.INV_SLOTS = 18;
var Dir;
(function (Dir) {
Dir[Dir["UP"] = 0] = "UP";
Dir[Dir["DOWN"] = 1] = "DOWN";
Dir[Dir["LEFT"] = 2] = "LEFT";
Dir[Dir["RIGHT"] = 3] = "RIGHT";
})(Dir || (exports.Dir = Dir = {}));
var Tile;
(function (Tile) {
Tile[Tile["EMPTY"] = 0] = "EMPTY";
Tile[Tile["WALL"] = 1] = "WALL";
Tile[Tile["WATER"] = 2] = "WATER";
Tile[Tile["GRASS"] = 3] = "GRASS";
Tile[Tile["FLOOR"] = 4] = "FLOOR";
Tile[Tile["CHEST"] = 5] = "CHEST";
Tile[Tile["DOOR"] = 6] = "DOOR";
Tile[Tile["NPC"] = 7] = "NPC";
Tile[Tile["SAVE_POINT"] = 8] = "SAVE_POINT";
Tile[Tile["ITEM"] = 9] = "ITEM";
Tile[Tile["BRIDGE"] = 10] = "BRIDGE";
Tile[Tile["PUZZLE_SWITCH"] = 11] = "PUZZLE_SWITCH";
Tile[Tile["GATE"] = 12] = "GATE";
Tile[Tile["TREE"] = 13] = "TREE";
Tile[Tile["ROCK"] = 14] = "ROCK";
})(Tile || (exports.Tile = Tile = {}));
var EnemyType;
(function (EnemyType) {
EnemyType["SLIME"] = "slime";
EnemyType["WOLF"] = "wolf";
EnemyType["BAT"] = "bat";
EnemyType["SKEL"] = "skeleton";
EnemyType["GOBLIN"] = "goblin";
EnemyType["DRAGON"] = "dragon";
})(EnemyType || (exports.EnemyType = EnemyType = {}));
exports.WEAPONS = {
rusty_sword: { id: 'rusty_sword', name: 'Rusty Sword', damage: 3, range: 38, cooldown: 20, color: '#aaa', desc: 'A worn but serviceable blade.', type: 'melee' },
iron_blade: { id: 'iron_blade', name: 'Iron Blade', damage: 5, range: 42, cooldown: 16, color: '#8af', desc: 'Sharpened and sturdy. Better damage.', type: 'melee' },
flame_sword: { id: 'flame_sword', name: 'Flame Sword', damage: 7, range: 48, cooldown: 14, color: '#f60', desc: 'Infused with fire. High damage, faster.', type: 'melee' },
shortbow: { id: 'shortbow', name: 'Shortbow', damage: 4, range: 180, cooldown: 24, color: '#a86', desc: 'A basic bow. Fires arrows at range.', type: 'ranged', ammo: 'arrow' },
};
exports.ITEMS = {
// Consumables
herb: { id: 'herb', name: 'Herb', icon: '🌿', desc: 'Restores 2 HP.', price: 20, type: 'consumable', effect: 'heal', stackable: true },
big_herb: { id: 'big_herb', name: 'Big Herb', icon: '🌺', desc: 'Restores 4 HP.', price: 50, type: 'consumable', effect: 'heal_big', stackable: true },
mushroom: { id: 'mushroom', name: 'Mushroom', icon: '🍄', desc: 'Grants temporary shield.', price: 40, type: 'consumable', effect: 'shield' },
energy_drink: { id: 'energy_drink', name: 'Energy Drink', icon: '⚡', desc: 'Boosts speed for a short time.', price: 60, type: 'consumable', effect: 'speed' },
// Accessories
shield_ring: { id: 'shield_ring', name: 'Shield Ring', icon: '💍', desc: 'Reduces damage taken by 1.', price: 100, type: 'accessory' },
gold_amulet: { id: 'gold_amulet', name: 'Gold Amulet', icon: '📿', desc: 'Enemies drop more gold.', price: 120, type: 'accessory' },
// Upgrades
heart_container: { id: 'heart_container', name: 'Heart Container', icon: '❤️', desc: 'Increases max HP by 2.', price: 0, type: 'upgrade' },
// Materials
arrow: { id: 'arrow', name: 'Arrow', icon: '➤', desc: 'Ammunition for the bow.', price: 5, type: 'material', stackable: true },
iron_ingot: { id: 'iron_ingot', name: 'Iron Ingot', icon: '⬜', desc: 'Used for weapon upgrades.', price: 30, type: 'material' },
gem: { id: 'gem', name: 'Gem', icon: '💎', desc: 'A precious gem. Valuable.', price: 80, type: 'material' },
};
exports.SHOP_CATALOG = {
village_shop: ['herb', 'big_herb', 'mushroom', 'energy_drink', 'arrow'],
town_shop: ['herb', 'big_herb', 'mushroom', 'energy_drink', 'shield_ring', 'gold_amulet', 'arrow', 'iron_ingot'],
hidden_shop: ['big_herb', 'mushroom', 'energy_drink', 'gem', 'iron_ingot'],
};
};
// ── module: src/map.ts ──
__mods["src/map.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ENEMY_DEFS = exports.AREAS = void 0;
const types_1 = require("./types");
function row(str) {
return str.split('').map(c => {
switch (c) {
case ' ': return types_1.Tile.EMPTY;
case '#': return types_1.Tile.WALL;
case '~': return types_1.Tile.WATER;
case '.': return types_1.Tile.FLOOR;
case '=': return types_1.Tile.BRIDGE;
case 'D': return types_1.Tile.DOOR;
case 'G': return types_1.Tile.GATE;
case 'T': return types_1.Tile.TREE;
case 'R': return types_1.Tile.ROCK;
case 'S': return types_1.Tile.SAVE_POINT;
default: return types_1.Tile.EMPTY;
}
});
}
exports.AREAS = {
village: {
id: 'village',
name: 'Village of Dawn',
bg: '#1a3a1a',
ground: '#2a5a2a',
map: [
row('#########################'),
row('# #'),
row('# T . . . T #'),
row('# . . # # . . . S #'),
row('# # # . . #'),
row('# # # . . . #'),
row('# . . . . . . . #'),
row('# D D D . . #'),
row('# T . . #'),
row('# . . T . . #'),
row('# . . . . . . . . #'),
row('# . . #'),
row('# . . . . . . . #'),
row('# . . . . . . . #'),
row('# . . . . . . . . #'),
row('# . . . . . . . . . #'),
row('# . . . . . . . . . . #'),
row('# . . #'),
row('#########################'),
],
enemies: [
{ x: 8 * types_1.TILE, y: 6 * types_1.TILE, type: 'slime' },
{ x: 18 * types_1.TILE, y: 10 * types_1.TILE, type: 'slime' },
{ x: 5 * types_1.TILE, y: 13 * types_1.TILE, type: 'slime' },
],
chests: [
{ x: 16 * types_1.TILE, y: 5 * types_1.TILE, contents: { id: 'herb', name: 'Herb', icon: '🌿', desc: 'Restores 2 HP.', price: 20, type: 'consumable', effect: 'heal', stackable: true }, opened: false },
],
npcs: [
{ x: 19 * types_1.TILE, y: 8 * types_1.TILE, name: 'Elder', dialog: 'Welcome, adventurer. The Shadow Caverns to the east hold dangers and treasures. May you find what you seek.', isShop: false },
{ x: 3 * types_1.TILE, y: 7 * types_1.TILE, name: 'Merchant', dialog: 'Welcome to my shop! I have herbs and supplies. Press E to browse.', isShop: true, shopItems: ['herb', 'big_herb', 'mushroom', 'energy_drink', 'arrow'] },
],
savePoints: [{ x: 20 * types_1.TILE, y: 7 * types_1.TILE }],
connections: [
{ dx: 1, dy: 0, targetArea: 'forest', targetDx: -1, targetDy: 0 },
],
itemsOnGround: [
{ x: 10 * types_1.TILE, y: 3 * types_1.TILE, item: 'herb', collected: false },
],
visited: false,
},
forest: {
id: 'forest',
name: 'Darkwood Forest',
bg: '#0a2a0a',
ground: '#1a4a1a',
map: [
row('#########################'),
row('# T T T #'),
row('# . T . . . T #'),
row('# . . T . . . . . #'),
row('# T . . . . . #'),
row('# T T . . . . T #'),
row('# . T . . . . . . #'),
row('# . . T . . D #'),
row('# . . . . T . . . . #'),
row('# . . . . . #'),
row('# T . T . . T #'),
row('# . T . . . . . . #'),
row('# . . . . T . . . #'),
row('# . . . . . . . T #'),
row('# . . . . . . . . . #'),
row('# . . . . D . . . . #'),
row('# T . . . . . . . . T #'),
row('# . . . . . . . . #'),
row('#########################'),
],
enemies: [
{ x: 12 * types_1.TILE, y: 4 * types_1.TILE, type: 'wolf' },
{ x: 6 * types_1.TILE, y: 10 * types_1.TILE, type: 'wolf' },
{ x: 16 * types_1.TILE, y: 8 * types_1.TILE, type: 'bat' },
{ x: 10 * types_1.TILE, y: 13 * types_1.TILE, type: 'bat' },
{ x: 3 * types_1.TILE, y: 6 * types_1.TILE, type: 'slime' },
],
chests: [
{ x: 18 * types_1.TILE, y: 3 * types_1.TILE, contents: { id: 'iron_ingot', name: 'Iron Ingot', icon: '⬜', desc: 'Used for weapon upgrades.', price: 30, type: 'material' }, opened: false },
{ x: 4 * types_1.TILE, y: 14 * types_1.TILE, contents: { id: 'heart_container', name: 'Heart Container', icon: '❤️', desc: 'Increases max HP by 2.', price: 0, type: 'upgrade' }, opened: false },
],
npcs: [
{ x: 10 * types_1.TILE, y: 6 * types_1.TILE, name: 'Hermit', dialog: 'The cave beyond leads to ruins. Beware — a skeleton guards the way. Press E to trade.', isShop: false },
],
savePoints: [{ x: 14 * types_1.TILE, y: 5 * types_1.TILE }],
connections: [
{ dx: -1, dy: 0, targetArea: 'village', targetDx: 1, targetDy: 0 },
{ dx: 1, dy: 0, targetArea: 'cave', targetDx: -1, targetDy: 0 },
],
itemsOnGround: [
{ x: 8 * types_1.TILE, y: 11 * types_1.TILE, item: 'mushroom', collected: false },
{ x: 15 * types_1.TILE, y: 14 * types_1.TILE, item: 'arrow', collected: false },
],
visited: false,
},
cave: {
id: 'cave',
name: 'Shadow Cavern',
bg: '#1a1a2a',
ground: '#2a2a3a',
map: [
row('#########################'),
row('# . . . . . . . . . . . #'),
row('# . R . . . . . . . #'),
row('# . . . . . . . . . #'),
row('# . . . . . . . . . #'),
row('# . R . . . . R . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . D D D D D . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . S . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . R . . . . . R . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . D . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('#########################'),
],
enemies: [
{ x: 14 * types_1.TILE, y: 3 * types_1.TILE, type: 'skeleton' },
{ x: 5 * types_1.TILE, y: 12 * types_1.TILE, type: 'bat' },
{ x: 16 * types_1.TILE, y: 14 * types_1.TILE, type: 'skeleton' },
{ x: 8 * types_1.TILE, y: 10 * types_1.TILE, type: 'goblin' },
],
chests: [
{ x: 3 * types_1.TILE, y: 4 * types_1.TILE, contents: { id: 'big_herb', name: 'Big Herb', icon: '🌺', desc: 'Restores 4 HP.', price: 50, type: 'consumable', effect: 'heal_big', stackable: true }, opened: false },
{ x: 20 * types_1.TILE, y: 10 * types_1.TILE, contents: { id: 'energy_drink', name: 'Energy Drink', icon: '⚡', desc: 'Boosts speed for a short time.', price: 60, type: 'consumable', effect: 'speed' }, opened: false },
],
npcs: [
{ x: 11 * types_1.TILE, y: 8 * types_1.TILE, name: 'Cave Trader', dialog: 'Supplies for the journey ahead. Press E to trade.', isShop: true, shopItems: ['herb', 'big_herb', 'mushroom', 'energy_drink', 'arrow'] },
],
savePoints: [{ x: 13 * types_1.TILE, y: 9 * types_1.TILE }],
connections: [
{ dx: -1, dy: 0, targetArea: 'forest', targetDx: 1, targetDy: 0 },
{ dx: 1, dy: 0, targetArea: 'ruins', targetDx: -1, targetDy: 0 },
],
itemsOnGround: [
{ x: 18 * types_1.TILE, y: 6 * types_1.TILE, item: 'arrow', collected: false },
{ x: 10 * types_1.TILE, y: 14 * types_1.TILE, item: 'gem', collected: false },
],
visited: false,
},
ruins: {
id: 'ruins',
name: 'Ancient Ruins',
bg: '#2a2a1a',
ground: '#3a3a2a',
map: [
row('#########################'),
row('# # . . . . # . . . . #'),
row('# # . R . . # . . . . #'),
row('# # . . . . # . . . . #'),
row('# # . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . D D . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . S . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . D . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# # . . . . # . . . . #'),
row('# # . . . . # . . . . #'),
row('#########################'),
],
enemies: [
{ x: 8 * types_1.TILE, y: 4 * types_1.TILE, type: 'goblin' },
{ x: 16 * types_1.TILE, y: 3 * types_1.TILE, type: 'skeleton' },
{ x: 4 * types_1.TILE, y: 10 * types_1.TILE, type: 'goblin' },
{ x: 12 * types_1.TILE, y: 14 * types_1.TILE, type: 'dragon' },
],
chests: [
{ x: 10 * types_1.TILE, y: 3 * types_1.TILE, contents: { id: 'shield_ring', name: 'Shield Ring', icon: '💍', desc: 'Reduces damage taken by 1.', price: 100, type: 'accessory' }, opened: false },
{ x: 5 * types_1.TILE, y: 16 * types_1.TILE, contents: { id: 'heart_container', name: 'Heart Container', icon: '❤️', desc: 'Increases max HP by 2.', price: 0, type: 'upgrade' }, opened: false },
],
npcs: [
{ x: 10 * types_1.TILE, y: 7 * types_1.TILE, name: 'Ruins Keeper', dialog: "The final trial awaits in the Dragon's Lair. Beyond the southern door lies glory... or doom.", isShop: false },
],
savePoints: [{ x: 12 * types_1.TILE, y: 9 * types_1.TILE }],
connections: [
{ dx: -1, dy: 0, targetArea: 'cave', targetDx: 1, targetDy: 0 },
{ dx: 0, dy: 1, targetArea: 'dragon_lair', targetDx: 0, targetDy: -1 },
],
itemsOnGround: [
{ x: 15 * types_1.TILE, y: 8 * types_1.TILE, item: 'arrow', collected: false },
{ x: 7 * types_1.TILE, y: 13 * types_1.TILE, item: 'big_herb', collected: false },
],
visited: false,
},
dragon_lair: {
id: 'dragon_lair',
name: "Dragon's Lair",
bg: '#3a1a1a',
ground: '#4a2a2a',
map: [
row('#########################'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . # . . . . . # #'),
row('# . . # . . . . . # #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . S . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . # . . . . . # #'),
row('# . . # . . . . . # #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('# . . . . . . . . . . . #'),
row('#########################'),
],
enemies: [
{ x: 4 * types_1.TILE, y: 3 * types_1.TILE, type: 'skeleton' },
{ x: 18 * types_1.TILE, y: 3 * types_1.TILE, type: 'skeleton' },
{ x: 12 * types_1.TILE, y: 5 * types_1.TILE, type: 'dragon' },
{ x: 8 * types_1.TILE, y: 13 * types_1.TILE, type: 'goblin' },
{ x: 16 * types_1.TILE, y: 14 * types_1.TILE, type: 'goblin' },
],
chests: [
{ x: 3 * types_1.TILE, y: 10 * types_1.TILE, contents: { id: 'gold_amulet', name: 'Gold Amulet', icon: '📿', desc: 'Enemies drop more gold.', price: 120, type: 'accessory' }, opened: false },
{ x: 20 * types_1.TILE, y: 10 * types_1.TILE, contents: { id: 'heart_container', name: 'Heart Container', icon: '❤️', desc: 'Increases max HP by 2.', price: 0, type: 'upgrade' }, opened: false },
],
npcs: [],
savePoints: [{ x: 12 * types_1.TILE, y: 7 * types_1.TILE }],
connections: [
{ dx: 0, dy: -1, targetArea: 'ruins', targetDx: 0, targetDy: 1 },
],
itemsOnGround: [
{ x: 7 * types_1.TILE, y: 11 * types_1.TILE, item: 'big_herb', collected: false },
],
visited: false,
},
};
exports.ENEMY_DEFS = {
slime: { hp: 4, maxHp: 4, damage: 1, speed: 1, range: 30, money: 5, attackDelay: 40, aggroRange: 80 },
wolf: { hp: 6, maxHp: 6, damage: 2, speed: 2, range: 34, money: 10, attackDelay: 35, aggroRange: 120 },
bat: { hp: 3, maxHp: 3, damage: 1, speed: 3, range: 28, money: 8, attackDelay: 50, aggroRange: 100 },
skeleton: { hp: 8, maxHp: 8, damage: 3, speed: 1.5, range: 36, money: 15, attackDelay: 30, aggroRange: 140 },
goblin: { hp: 10, maxHp: 10, damage: 3, speed: 2, range: 34, money: 20, attackDelay: 28, aggroRange: 130 },
dragon: { hp: 25, maxHp: 25, damage: 5, speed: 1.2, range: 40, money: 50, attackDelay: 35, aggroRange: 160 },
};
};
// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.InputSystem = void 0;
const types_1 = require("./types");
class InputSystem {
constructor() {
this.input = {
keys: {},
justPressedKeys: {},
mouse: { x: types_1.SCREEN_W / 2, y: types_1.SCREEN_H / 2 },
mouseDown: false,
};
window.addEventListener('keydown', (e) => {
if (!this.input.keys[e.key]) {
this.input.justPressedKeys[e.key] = true;
}
this.input.keys[e.key] = true;
// Prevent scrolling
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
this.input.keys[e.key] = false;
});
window.addEventListener('mousemove', (e) => {
const canvas = document.getElementById('game-canvas');
if (canvas) {
const rect = canvas.getBoundingClientRect();
this.input.mouse.x = (e.clientX - rect.left) * (canvas.width / rect.width);
this.input.mouse.y = (e.clientY - rect.top) * (canvas.height / rect.height);
}
});
window.addEventListener('mousedown', () => { this.input.mouseDown = true; });
window.addEventListener('mouseup', () => { this.input.mouseDown = false; });
}
update() {
this.input.justPressedKeys = {};
}
isDown(key) {
return !!this.input.keys[key];
}
justPressed(key) {
return !!this.input.justPressedKeys[key];
}
getMoveDir() {
if (this.input.keys['ArrowUp'] || this.input.keys['w'] || this.input.keys['W'])
return types_1.Dir.UP;
if (this.input.keys['ArrowDown'] || this.input.keys['s'] || this.input.keys['S'])
return types_1.Dir.DOWN;
if (this.input.keys['ArrowLeft'] || this.input.keys['a'] || this.input.keys['A'])
return types_1.Dir.LEFT;
if (this.input.keys['ArrowRight'] || this.input.keys['d'] || this.input.keys['D'])
return types_1.Dir.RIGHT;
return null;
}
attack() {
return this.justPressed(' ') || this.justPressed('z') || this.justPressed('Z');
}
interact() {
return this.justPressed('e') || this.justPressed('E');
}
useItem() {
return this.justPressed('q') || this.justPressed('Q');
}
openInventory() {
return this.justPressed('i') || this.justPressed('I');
}
cancel() {
return this.justPressed('Escape');
}
}
exports.InputSystem = InputSystem;
};
// ── module: src/player.ts ──
__mods["src/player.ts"] = function (exports, require, module) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.Player = void 0;
const types_1 = require("./types");
const Audio = __importStar(require("./audio"));
class Player {
constructor() {
this.state = {
x: 8 * types_1.TILE,
y: 9 * types_1.TILE,
dir: types_1.Dir.DOWN,
hp: types_1.PLAYER_HP,
maxHp: types_1.MAX_HP_CONTAINERS_START * types_1.HP_PER_CONTAINER,
hpContainers: types_1.MAX_HP_CONTAINERS_START,
money: 0,
inventory: new Array(types_1.INV_SLOTS).fill(null),
weapons: { melee: 'rusty_sword', ranged: false, rangedWeapon: null },
attackCooldown: 0,
hitFlash: 0,
invincible: 0,
animTimer: 0,
isMoving: false,
areaId: 'village',
rangedDiscovered: false,
shieldActive: false,
shieldTimer: 0,
};
}
get currentSpeed() {
// Speed boost from energy drink could be tracked in game state
return types_1.PLAYER_SPEED;
}
get weaponRange() {
const w = this.state.weapons.melee;
const defs = { rusty_sword: 38, iron_blade: 42, flame_sword: 48 };
return defs[w] || 38;
}
get weaponDamage() {
const w = this.state.weapons.melee;
const defs = { rusty_sword: 3, iron_blade: 5, flame_sword: 7 };
return defs[w] || 3;
}
get weaponColor() {
const w = this.state.weapons.melee;
const defs = { rusty_sword: '#aaa', iron_blade: '#8af', flame_sword: '#f60' };
return defs[w] || '#aaa';
}
get weaponCooldown() {
const w = this.state.weapons.melee;
const defs = { rusty_sword: 20, iron_blade: 16, flame_sword: 14 };
return defs[w] || 20;
}
get weaponName() {
const w = this.state.weapons.melee;
const defs = { rusty_sword: 'Rusty Sword', iron_blade: 'Iron Blade', flame_sword: 'Flame Sword' };
return defs[w] || 'Rusty Sword';
}
canAttack() {
return this.state.attackCooldown <= 0;
}
attack(dir) {
this.state.attackCooldown = this.weaponCooldown;
this.state.dir = dir;
Audio.playAttack();
const cx = this.state.x + types_1.TILE / 2;
const cy = this.state.y + types_1.TILE / 2;
const range = this.weaponRange;
let hits = [];
// Create an arc of hitboxes in front of the player
const segments = 5;
const angle = Math.PI / 3;
let baseAngle = 0;
if (dir === types_1.Dir.RIGHT)
baseAngle = 0;
if (dir === types_1.Dir.DOWN)
baseAngle = Math.PI / 2;
if (dir === types_1.Dir.LEFT)
baseAngle = Math.PI;
if (dir === types_1.Dir.UP)
baseAngle = -Math.PI / 2;
for (let i = 0; i < segments; i++) {
const a = baseAngle - angle / 2 + (angle / (segments - 1)) * i;
for (let r = 10; r < range; r += 8) {
const px = cx + Math.cos(a) * r;
const py = cy + Math.sin(a) * r;
hits.push({ x: px, y: py });
}
}
return hits;
}
rangedAttack() {
if (!this.state.weapons.rangedWeapon)
return null;
Audio.playBowShoot();
const cx = this.state.x + types_1.TILE / 2;
const cy = this.state.y + types_1.TILE / 2;
let dx = 0, dy = -1;
if (this.state.dir === types_1.Dir.RIGHT) {
dx = 1;
dy = 0;
}
if (this.state.dir === types_1.Dir.DOWN) {
dx = 0;
dy = 1;
}
if (this.state.dir === types_1.Dir.LEFT) {
dx = -1;
dy = 0;
}
return { x: cx + dx * 20, y: cy + dy * 20 };
}
takeDamage(amount) {
if (this.state.invincible > 0)
return 0;
// Shield ring check
if (this.hasItem('shield_ring'))
amount = Math.max(0, amount - 1);
// Shield from mushroom
if (this.state.shieldActive) {
this.state.shieldActive = false;
this.state.shieldTimer = 0;
return 0;
}
this.state.hp = Math.max(0, this.state.hp - amount);
this.state.hitFlash = 10;
this.state.invincible = 30;
Audio.playHit();
return amount;
}
hasItem(id) {
return this.state.inventory.includes(id);
}
hasItemWithCount(id) {
return this.state.inventory.filter(i => i === id).length;
}
addItem(id) {
const itemDef = window.ITEMS_DEF || {};
const def = itemDef[id];
if (def && def.stackable) {
// Find existing stack
for (let i = 0; i < this.state.inventory.length; i++) {
if (this.state.inventory[i] === id) {
// Already have it, stack logic handled by game
return false;
}
}
}
const slot = this.state.inventory.indexOf(null);
if (slot === -1)
return false;
this.state.inventory[slot] = id;
Audio.playPickup();
return true;
}
removeItem(id) {
const slot = this.state.inventory.indexOf(id);
if (slot === -1)
return false;
this.state.inventory[slot] = null;
return true;
}
useConsumable(id) {
const slot = this.state.inventory.indexOf(id);
if (slot === -1)
return false;
switch (id) {
case 'herb':
this.state.hp = Math.min(this.state.maxHp, this.state.hp + 2);
break;
case 'big_herb':
this.state.hp = Math.min(this.state.maxHp, this.state.hp + 4);
break;
case 'mushroom':
this.state.shieldActive = true;
this.state.shieldTimer = 300;
break;
case 'energy_drink':
// Speed boost handled in game state
break;
}
this.state.inventory[slot] = null;
Audio.playPickup();
return true;
}
addHpContainers() {
if (this.state.hpContainers < 5) {
this.state.hpContainers++;
this.state.maxHp = this.state.hpContainers * types_1.HP_PER_CONTAINER;
this.state.hp = this.state.maxHp;
}
}
updateArea(newArea, x, y) {
this.state.areaId = newArea;
this.state.x = x;
this.state.y = y;
}
update(input, gameSpeed) {
const dir = input.getMoveDir();
if (dir !== null) {
this.state.dir = dir;
this.state.isMoving = true;
this.state.animTimer++;
}
else {
this.state.isMoving = false;
}
let spd = this.currentSpeed * gameSpeed;
let nx = this.state.x;
let ny = this.state.y;
if (dir === types_1.Dir.UP)
ny -= spd;
if (dir === types_1.Dir.DOWN)
ny += spd;
if (dir === types_1.Dir.LEFT)
nx -= spd;
if (dir === types_1.Dir.RIGHT)
nx += spd;
this.state.x = nx;
this.state.y = ny;
if (this.state.attackCooldown > 0)
this.state.attackCooldown--;
if (this.state.hitFlash > 0)
this.state.hitFlash--;
if (this.state.invincible > 0)
this.state.invincible--;
if (this.state.shieldActive) {
this.state.shieldTimer--;
if (this.state.shieldTimer <= 0)
this.state.shieldActive = false;
}
if (!this.state.isMoving)
this.state.animTimer = 0;
}
}
exports.Player = Player;
};
// ── module: src/audio.ts ──
__mods["src/audio.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.playAttack = playAttack;
exports.playBowShoot = playBowShoot;
exports.playHit = playHit;
exports.playPickup = playPickup;
exports.playDamage = playDamage;
exports.playStep = playStep;
exports.playDeath = playDeath;
exports.playEnemyDeath = playEnemyDeath;
exports.playShop = playShop;
exports.playPurchase = playPurchase;
exports.playSave = playSave;
exports.playUpgrade = playUpgrade;
exports.playHeal = playHeal;
exports.playDialog = playDialog;
let audioCtx = null;
function getCtx() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
return audioCtx;
}
function playTone(freq, dur, type = 'square', vol = 0.1) {
try {
const ctx = getCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime);
gain.gain.setValueAtTime(vol, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + dur);
}
catch (e) { }
}
function playAttack() {
playTone(200, 0.15, 'sawtooth', 0.12);
setTimeout(() => playTone(150, 0.1, 'sawtooth', 0.08), 50);
}
function playBowShoot() {
playTone(400, 0.2, 'triangle', 0.1);
setTimeout(() => playTone(300, 0.15, 'triangle', 0.06), 30);
}
function playHit() {
playTone(100, 0.2, 'sawtooth', 0.15);
playTone(80, 0.15, 'square', 0.1);
}
function playPickup() {
playTone(520, 0.1, 'square', 0.08);
setTimeout(() => playTone(660, 0.1, 'square', 0.08), 60);
setTimeout(() => playTone(880, 0.15, 'square', 0.08), 120);
}
function playDamage() {
playTone(150, 0.2, 'sawtooth', 0.15);
setTimeout(() => playTone(100, 0.15, 'square', 0.1), 50);
}
function playStep() {
playTone(60 + Math.random() * 20, 0.05, 'sine', 0.03);
}
function playDeath() {
playTone(300, 0.3, 'sawtooth', 0.12);
setTimeout(() => playTone(200, 0.3, 'sawtooth', 0.1), 100);
setTimeout(() => playTone(100, 0.4, 'sawtooth', 0.08), 200);
}
function playEnemyDeath() {
playTone(250, 0.1, 'square', 0.1);
setTimeout(() => playTone(180, 0.15, 'square', 0.08), 50);
setTimeout(() => playTone(100, 0.2, 'square', 0.06), 100);
}
function playShop() {
playTone(440, 0.1, 'square', 0.06);
setTimeout(() => playTone(550, 0.1, 'square', 0.06), 80);
}
function playPurchase() {
playTone(520, 0.1, 'square', 0.08);
setTimeout(() => playTone(660, 0.1, 'square', 0.08), 80);
setTimeout(() => playTone(880, 0.15, 'square', 0.06), 160);
}
function playSave() {
playTone(330, 0.15, 'sine', 0.08);
setTimeout(() => playTone(440, 0.15, 'sine', 0.08), 150);
setTimeout(() => playTone(550, 0.2, 'sine', 0.08), 300);
}
function playUpgrade() {
playTone(440, 0.1, 'square', 0.1);
setTimeout(() => playTone(550, 0.1, 'square', 0.1), 100);
setTimeout(() => playTone(660, 0.1, 'square', 0.1), 200);
setTimeout(() => playTone(880, 0.2, 'square', 0.08), 300);
}
function playHeal() {
playTone(400, 0.1, 'sine', 0.06);
setTimeout(() => playTone(500, 0.1, 'sine', 0.06), 80);
setTimeout(() => playTone(600, 0.15, 'sine', 0.06), 160);
}
function playDialog() {
playTone(330, 0.08, 'square', 0.04);
}
};
// ── module: src/enemies.ts ──
__mods["src/enemies.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.EnemyManager = void 0;
const types_1 = require("./types");
const map_1 = require("./map");
class EnemyManager {
constructor() {
this.enemies = new Map();
this.spawnTimer = 0;
}
getEnemiesInArea(areaId) {
const result = [];
for (const [, e] of this.enemies) {
if (e.areaId === areaId && e.alive) {
result.push(e);
}
}
return result;
}
spawnEnemy(areaId, type, x, y) {
const def = map_1.ENEMY_DEFS[type];
if (!def)
return null;
const key = `${areaId}_${type}_${Math.floor(x / types_1.TILE)}_${Math.floor(y / types_1.TILE)}`;
if (this.enemies.has(key))
return null;
const enemy = {
type: type,
x, y,
hp: def.hp || 4,
maxHp: def.maxHp || 4,
damage: def.damage || 1,
speed: def.speed || 1,
range: def.range || 30,
money: def.money || 5,
attackCooldown: 0,
attackDelay: def.attackDelay || 40,
hitFlash: 0,
state: 'idle',
hurtTimer: 0,
animTimer: 0,
dir: types_1.Dir.DOWN,
aggroRange: def.aggroRange || 100,
alive: true,
areaId,
};
this.enemies.set(key, enemy);
return enemy;
}
initAreaEnemies(areaId) {
const area = map_1.AREAS[areaId];
if (!area)
return;
for (const spawn of area.enemies) {
this.spawnEnemy(areaId, spawn.type, spawn.x, spawn.y);
}
}
findEnemyNear(x, y, range) {
let best = null;
let bestDist = range;
for (const [, e] of this.enemies) {
if (!e.alive)
continue;
const dx = (e.x + types_1.TILE / 2) - x;
const dy = (e.y + types_1.TILE / 2) - y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < bestDist) {
bestDist = dist;
best = e;
}
}
return best;
}
damageEnemy(enemy, damage) {
if (!enemy.alive)
return false;
enemy.hp -= damage;
enemy.hitFlash = 8;
enemy.hurtTimer = 12;
enemy.state = 'hurt';
if (enemy.hp <= 0) {
enemy.alive = false;
enemy.state = 'dead';
return true;
}
return false;
}
update(enemies, playerX, playerY, deltaTime) {
for (const e of enemies) {
if (!e.alive)
continue;
if (e.hitFlash > 0)
e.hitFlash--;
if (e.hurtTimer > 0) {
e.hurtTimer--;
if (e.hurtTimer <= 0)
e.state = 'idle';
continue;
}
if (e.attackCooldown > 0)
e.attackCooldown--;
const dx = (playerX + types_1.TILE / 2) - (e.x + types_1.TILE / 2);
const dy = (playerY + types_1.TILE / 2) - (e.y + types_1.TILE / 2);
const dist = Math.sqrt(dx * dx + dy * dy);
// Determine direction toward player
if (Math.abs(dx) > Math.abs(dy)) {
e.dir = dx > 0 ? types_1.Dir.RIGHT : types_1.Dir.LEFT;
}
else {
e.dir = dy > 0 ? types_1.Dir.DOWN : types_1.Dir.UP;
}
if (dist < e.aggroRange && e.state === 'idle') {
e.state = 'chase';
}
if (dist < e.range + 16 && e.attackCooldown <= 0 && e.state !== 'hurt') {
e.state = 'attack';
e.attackCooldown = e.attackDelay;
// Return damage info
}
if (e.state === 'chase') {
const spd = e.speed;
const nx = e.x + (dx / dist) * spd;
const ny = e.y + (dy / dist) * spd;
// Simple collision check against other enemies
let blocked = false;
for (const other of enemies) {
if (other === e || !other.alive)
continue;
const odx = nx - other.x;
const ody = ny - other.y;
if (Math.sqrt(odx * odx + ody * ody) < types_1.TILE * 0.6) {
blocked = true;
break;
}
}
if (!blocked) {
e.x = nx;
e.y = ny;
}
}
// Return to idle if far from player
if (dist > e.aggroRange * 1.5 && e.state === 'chase') {
e.state = 'idle';
}
}
}
getAttackDamage(enemy) {
if (enemy.state === 'attack' && enemy.attackCooldown === enemy.attackDelay) {
return enemy.damage;
}
return null;
}
respawnArea(areaId) {
// Remove dead enemies in this area
const toRemove = [];
for (const [key, e] of this.enemies) {
if (e.areaId === areaId && !e.alive) {
toRemove.push(key);
}
}
for (const key of toRemove) {
this.enemies.delete(key);
}
// Respawn
const area = map_1.AREAS[areaId];
if (area) {
for (const spawn of area.enemies) {
this.spawnEnemy(areaId, spawn.type, spawn.x, spawn.y);
}
}
}
}
exports.EnemyManager = EnemyManager;
};
// ── module: src/effects.ts ──
__mods["src/effects.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Effects = void 0;
class Effects {
constructor() {
this.particles = [];
this.texts = [];
}
emit(x, y, count, color, speed = 3, size = 3, life = 25) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = speed * (0.3 + Math.random() * 0.7);
this.particles.push({
x, y,
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd,
life, maxLife: life,
color,
size: size * (0.5 + Math.random() * 0.5),
});
}
}
emitBurst(x, y, color, count = 8) {
for (let i = 0; i < count; i++) {
const angle = (Math.PI * 2 / count) * i + Math.random() * 0.3;
const spd = 2 + Math.random() * 2;
this.particles.push({
x, y,
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd,
life: 15 + Math.random() * 10, maxLife: 25,
color,
size: 2 + Math.random() * 2,
});
}
}
emitSlash(x, y, color) {
// Arc slash particles
for (let i = 0; i < 12; i++) {
const t = i / 11;
const angle = -Math.PI / 4 + t * Math.PI / 2;
const r = 15 + t * 20;
this.particles.push({
x: x + Math.cos(angle) * r * 0.3,
y: y + Math.sin(angle) * r * 0.3,
vx: Math.cos(angle) * (2 + Math.random()),
vy: Math.sin(angle) * (2 + Math.random()),
life: 8 + Math.random() * 5, maxLife: 13,
color,
size: 1.5 + Math.random() * 1.5,
});
}
}
emitHit(x, y) {
this.emit(x, y, 5, '#ff0', 2, 2, 12);
this.emit(x, y, 3, '#f80', 1.5, 2, 10);
}
emitDeath(x, y) {
this.emitBurst(x, y, '#f80', 15);
this.emitBurst(x, y, '#ff0', 8);
this.emitBurst(x, y, '#f00', 5);
}
emitPickup(x, y) {
this.emit(x, y, 10, '#0f0', 2, 2, 20);
this.emit(x, y, 5, '#ff0', 1.5, 2, 18);
}
emitCoin(x, y) {
this.emit(x, y, 6, '#fd0', 2.5, 2, 15);
}
emitSave(x, y) {
this.emit(x, y, 20, '#88f', 2, 3, 30);
this.emit(x, y, 15, '#fff', 1.5, 2, 25);
}
emitHeart(x, y) {
this.emit(x, y, 12, '#f44', 2, 3, 25);
this.emit(x, y, 8, '#f88', 1.5, 2, 20);
}
emitUpgrade(x, y) {
this.emit(x, y, 25, '#ff0', 3, 3, 30);
this.emit(x, y, 15, '#f80', 2.5, 2, 25);
this.emit(x, y, 10, '#fff', 2, 2, 20);
}
addText(x, y, text, color = '#fff') {
this.texts.push({
x, y,
text,
color,
life: 40,
vy: -1.5,
});
}
addDamageText(x, y, damage, color = '#ff0') {
this.texts.push({
x: x + (Math.random() - 0.5) * 10,
y: y - 5,
text: `-${damage}`,
color,
life: 35,
vy: -1.8,
});
}
update() {
for (const p of this.particles) {
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.96;
p.vy *= 0.96;
p.life--;
}
this.particles = this.particles.filter(p => p.life > 0);
for (const t of this.texts) {
t.y += t.vy;
t.life--;
}
this.texts = this.texts.filter(t => t.life > 0);
}
}
exports.Effects = Effects;
};
// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Renderer = void 0;
const types_1 = require("./types");
const map_1 = require("./map");
class Renderer {
constructor(canvas, effects) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.effects = effects;
}
drawGame(player, enemies, projectiles) {
const ctx = this.ctx;
const area = map_1.AREAS[player.state.areaId];
if (!area)
return;
// Camera follows player
const camX = Math.max(0, Math.min(player.state.x - types_1.SCREEN_W / 2 + types_1.TILE / 2, area.map[0].length * types_1.TILE - types_1.SCREEN_W));
const camY = Math.max(0, Math.min(player.state.y - types_1.SCREEN_H / 2 + types_1.TILE / 2, area.map.length * types_1.TILE - types_1.SCREEN_H));
ctx.save();
ctx.translate(-camX, -camY);
// Draw ground
const startCol = Math.max(0, Math.floor(camX / types_1.TILE));
const endCol = Math.min(area.map[0].length, Math.ceil((camX + types_1.SCREEN_W) / types_1.TILE) + 1);
const startRow = Math.max(0, Math.floor(camY / types_1.TILE));
const endRow = Math.min(area.map.length, Math.ceil((camY + types_1.SCREEN_H) / types_1.TILE) + 1);
for (let r = startRow; r < endRow; r++) {
for (let c = startCol; c < endCol; c++) {
const tile = area.map[r][c];
const x = c * types_1.TILE;
const y = r * types_1.TILE;
// Ground color
ctx.fillStyle = area.ground;
ctx.fillRect(x, y, types_1.TILE, types_1.TILE);
// Grid lines (subtle)
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
ctx.strokeRect(x, y, types_1.TILE, types_1.TILE);
// Tile-specific rendering
switch (tile) {
case types_1.Tile.WALL:
ctx.fillStyle = '#555';
ctx.fillRect(x, y, types_1.TILE, types_1.TILE);
ctx.fillStyle = '#666';
ctx.fillRect(x + 2, y + 2, types_1.TILE - 4, types_1.TILE - 4);
// Brick pattern
ctx.strokeStyle = '#444';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y + types_1.TILE / 2);
ctx.lineTo(x + types_1.TILE, y + types_1.TILE / 2);
ctx.moveTo(x + types_1.TILE / 2, y);
ctx.lineTo(x + types_1.TILE / 2, y + types_1.TILE / 2);
ctx.moveTo(x + types_1.TILE / 4, y + types_1.TILE / 2);
ctx.lineTo(x + types_1.TILE / 4, y + types_1.TILE);
ctx.moveTo(x + types_1.TILE * 3 / 4, y + types_1.TILE / 2);
ctx.lineTo(x + types_1.TILE * 3 / 4, y + types_1.TILE);
ctx.stroke();
break;
case types_1.Tile.WATER:
ctx.fillStyle = '#2244aa';
ctx.fillRect(x, y, types_1.TILE, types_1.TILE);
// Water shimmer
const shimmer = Math.sin(Date.now() / 500 + c * 0.5 + r * 0.3) * 15 + 30;
ctx.fillStyle = `rgba(${shimmer + 50}, ${shimmer + 100}, 255, 0.4)`;
ctx.fillRect(x + 2, y + 2, types_1.TILE - 4, types_1.TILE - 4);
break;
case types_1.Tile.GRASS:
ctx.fillStyle = '#3a7a3a';
ctx.fillRect(x + 4, y + 4, 3, 8);
ctx.fillRect(x + 14, y + 10, 3, 6);
break;
case types_1.Tile.FLOOR:
ctx.fillStyle = '#4a4a3a';
ctx.fillRect(x, y, types_1.TILE, types_1.TILE);
ctx.fillStyle = '#525242';
ctx.fillRect(x + 1, y + 1, types_1.TILE - 2, types_1.TILE - 2);
break;
case types_1.Tile.BRIDGE:
ctx.fillStyle = '#6a4a2a';
ctx.fillRect(x, y, types_1.TILE, types_1.TILE);
ctx.strokeStyle = '#8a6a4a';
ctx.lineWidth = 1;
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(x + 4 + i * 8, y);
ctx.lineTo(x + 4 + i * 8, y + types_1.TILE);
ctx.stroke();
}
break;
case types_1.Tile.DOOR:
ctx.fillStyle = '#8a5a2a';
ctx.fillRect(x + 4, y, types_1.TILE - 8, types_1.TILE);
ctx.fillStyle = '#aa7a3a';
ctx.fillRect(x + 6, y + 2, types_1.TILE - 12, types_1.TILE - 4);
ctx.fillStyle = '#fd0';
ctx.beginPath();
ctx.arc(x + types_1.TILE - 10, y + types_1.TILE / 2, 2, 0, Math.PI * 2);
ctx.fill();
break;
case types_1.Tile.GATE:
ctx.fillStyle = '#666';
ctx.fillRect(x, y, types_1.TILE, types_1.TILE);
ctx.fillStyle = '#888';
for (let i = 0; i < 5; i++) {
ctx.fillRect(x + 2 + i * 6, y + 2, 4, types_1.TILE - 4);
}
break;
case types_1.Tile.SAVE_POINT:
// Save crystal
const glow = Math.sin(Date.now() / 300) * 0.3 + 0.7;
ctx.fillStyle = `rgba(100, 150, 255, ${glow * 0.3})`;
ctx.beginPath();
ctx.arc(x + types_1.TILE / 2, y + types_1.TILE / 2, types_1.TILE * 0.8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#88f';
ctx.beginPath();
ctx.moveTo(x + types_1.TILE / 2, y + 2);
ctx.lineTo(x + types_1.TILE - 4, y + types_1.TILE / 2);
ctx.lineTo(x + types_1.TILE / 2, y + types_1.TILE - 2);
ctx.lineTo(x + 4, y + types_1.TILE / 2);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#aaf';
ctx.beginPath();
ctx.moveTo(x + types_1.TILE / 2, y + 6);
ctx.lineTo(x + types_1.TILE - 8, y + types_1.TILE / 2);
ctx.lineTo(x + types_1.TILE / 2, y + types_1.TILE - 6);
ctx.lineTo(x + 8, y + types_1.TILE / 2);
ctx.closePath();
ctx.fill();
break;
case types_1.Tile.TREE:
ctx.fillStyle = '#5a3a1a';
ctx.fillRect(x + 12, y + 16, 8, 16);
ctx.fillStyle = '#2a6a2a';
ctx.beginPath();
ctx.arc(x + types_1.TILE / 2, y + 12, 14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#3a8a3a';
ctx.beginPath();
ctx.arc(x + types_1.TILE / 2 - 3, y + 10, 10, 0, Math.PI * 2);
ctx.fill();
break;
case types_1.Tile.ROCK:
ctx.fillStyle = '#777';
ctx.beginPath();
ctx.arc(x + types_1.TILE / 2, y + types_1.TILE / 2 + 2, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#888';
ctx.beginPath();
ctx.arc(x + types_1.TILE / 2 - 3, y + types_1.TILE / 2 - 2, 8, 0, Math.PI * 2);
ctx.fill();
break;
case types_1.Tile.PUZZLE_SWITCH:
ctx.fillStyle = '#a0a';
ctx.fillRect(x + 4, y + 4, types_1.TILE - 8, types_1.TILE - 8);
ctx.fillStyle = '#c0c';
ctx.beginPath();
ctx.arc(x + types_1.TILE / 2, y + types_1.TILE / 2, 6, 0, Math.PI * 2);
ctx.fill();
break;
}
}
}
// Ambient particles per area
const t = Date.now() / 1000;
for (let i = 0; i < 8; i++) {
const px = ((t * 20 + i * 100) % (area.map[0].length * types_1.TILE));
const py = ((t * 15 + i * 73) % (area.map.length * types_1.TILE)) + Math.sin(t + i) * 10;
ctx.fillStyle = area.id === 'village' ? 'rgba(100, 255, 100, 0.15)' :
area.id === 'forest' ? 'rgba(50, 150, 50, 0.12)' :
area.id === 'cave' ? 'rgba(150, 150, 255, 0.1)' :
area.id === 'ruins' ? 'rgba(200, 180, 100, 0.1)' :
'rgba(255, 100, 50, 0.12)';
ctx.beginPath();
ctx.arc(px, py, 2, 0, Math.PI * 2);
ctx.fill();
}
// Draw ground items
if (area.itemsOnGround) {
for (const gi of area.itemsOnGround) {
if (gi.collected)
continue;
const bobY = Math.sin(Date.now() / 400) * 3;
ctx.fillStyle = 'rgba(255, 255, 100, 0.2)';
ctx.beginPath();
ctx.arc(gi.x + types_1.TILE / 2, gi.y + types_1.TILE / 2 + bobY, 14, 0, Math.PI * 2);
ctx.fill();
ctx.font = '20px serif';
ctx.textAlign = 'center';
ctx.fillText('📦', gi.x + types_1.TILE / 2, gi.y + types_1.TILE / 2 + bobY + 7);
}
}
// Draw chests
for (const chest of area.chests) {
if (chest.opened)
continue;
ctx.fillStyle = '#8a5a2a';
ctx.fillRect(chest.x + 4, chest.y + 8, types_1.TILE - 8, types_1.TILE - 12);
ctx.fillStyle = '#aa7a3a';
ctx.fillRect(chest.x + 2, chest.y + 6, types_1.TILE - 4, 6);
ctx.fillStyle = '#fd0';
ctx.fillRect(chest.x + types_1.TILE / 2 - 3, chest.y + 12, 6, 4);
}
// Draw NPCs
for (const npc of area.npcs) {
this.drawNPC(npc.x, npc.y, npc.name);
}
// Draw projectiles
for (const p of projectiles) {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath();
ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2);
ctx.fill();
}
// Draw enemies
for (const e of enemies) {
if (!e.alive)
continue;
this.drawEnemy(e);
}
// Draw player
this.drawPlayer(player.state, player, camX, camY);
// Draw particles
for (const p of this.effects.particles) {
const alpha = p.life / p.maxLife;
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * alpha, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// Draw floating texts
for (const t of this.effects.texts) {
const alpha = t.life / 40;
ctx.globalAlpha = alpha;
ctx.font = 'bold 14px Courier New';
ctx.textAlign = 'center';
ctx.fillStyle = '#000';
ctx.fillText(t.text, t.x + 1, t.y + 1);
ctx.fillStyle = t.color;
ctx.fillText(t.text, t.x, t.y);
}
ctx.globalAlpha = 1;
ctx.restore();
// Draw area name
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(types_1.SCREEN_W / 2 - 100, 4, 200, 20);
ctx.fillStyle = '#e8c547';
ctx.font = '14px Courier New';
ctx.textAlign = 'center';
ctx.fillText(area.name, types_1.SCREEN_W / 2, 18);
}
drawPlayer(state, player, camX, camY) {
const ctx = this.ctx;
const cx = state.x + types_1.TILE / 2;
const cy = state.y + types_1.TILE / 2;
const flash = state.hitFlash > 0 && state.hitFlash % 2 === 0;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.beginPath();
ctx.ellipse(cx, cy + 14, 10, 4, 0, 0, Math.PI * 2);
ctx.fill();
// Shield effect
if (state.shieldActive) {
const glow = Math.sin(Date.now() / 200) * 0.3 + 0.5;
ctx.strokeStyle = `rgba(100, 200, 255, ${glow})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, 18, 0, Math.PI * 2);
ctx.stroke();
}
// Invincibility flicker
if (state.invincible > 0 && Math.floor(state.invincible / 3) % 2 === 0)
return;
// Body
const bobY = state.isMoving ? Math.sin(state.animTimer * 0.3) * 2 : 0;
const bodyY = cy - 6 + bobY;
// Clothing
ctx.fillStyle = flash ? '#f44' : '#4488aa';
ctx.fillRect(cx - 8, bodyY - 2, 16, 14);
// Head
ctx.fillStyle = flash ? '#f44' : '#fca';
ctx.beginPath();
ctx.arc(cx, bodyY - 6, 8, 0, Math.PI * 2);
ctx.fill();
// Hair
ctx.fillStyle = '#864';
ctx.beginPath();
ctx.arc(cx, bodyY - 10, 7, Math.PI, Math.PI * 2);
ctx.fill();
// Eyes based on direction
ctx.fillStyle = '#333';
let ex = 0, ey = -6;
if (state.dir === types_1.Dir.LEFT)
ex = -2;
if (state.dir === types_1.Dir.RIGHT)
ex = 2;
if (state.dir === types_1.Dir.UP)
ey = -8;
if (state.dir === types_1.Dir.DOWN)
ey = -5;
ctx.fillRect(cx - 3 + ex, bodyY + ey, 2, 2);
ctx.fillRect(cx + 1 + ex, bodyY + ey, 2, 2);
// Weapon
const wColor = this.getWeaponColor(state.weapons.melee);
ctx.strokeStyle = '#864';
ctx.lineWidth = 3;
let sx = cx, sy = bodyY, ex2 = cx, ey2 = bodyY;
switch (state.dir) {
case types_1.Dir.RIGHT:
sx = cx + 5;
sy = bodyY + 2;
ex2 = cx + player.weaponRange;
ey2 = bodyY + 2;
break;
case types_1.Dir.LEFT:
sx = cx - 5;
sy = bodyY + 2;
ex2 = cx - player.weaponRange;
ey2 = bodyY + 2;
break;
case types_1.Dir.UP:
sx = cx;
sy = bodyY - 4;
ex2 = cx;
ey2 = bodyY - player.weaponRange;
break;
case types_1.Dir.DOWN:
sx = cx;
sy = bodyY + 8;
ex2 = cx;
ey2 = bodyY + player.weaponRange;
break;
}
// Only show weapon swing during attack cooldown
const isSwinging = state.attackCooldown > this.getWeaponCooldown(state.weapons.melee) * 0.5;
if (isSwinging) {
ctx.strokeStyle = wColor;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex2, ey2);
ctx.stroke();
// Slash trail
ctx.strokeStyle = `rgba(255, 255, 255, 0.4)`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex2, ey2);
ctx.stroke();
}
else {
// Resting weapon position
ctx.strokeStyle = wColor;
ctx.lineWidth = 2;
ctx.beginPath();
let rx = cx + (state.dir === types_1.Dir.LEFT ? -10 : state.dir === types_1.Dir.RIGHT ? 10 : 0);
let ry = bodyY + (state.dir === types_1.Dir.UP ? -10 : state.dir === types_1.Dir.DOWN ? 10 : 0);
ctx.moveTo(sx, sy);
ctx.lineTo(rx, ry);
ctx.stroke();
}
// Ranged weapon indicator
if (state.weapons.rangedWeapon) {
ctx.fillStyle = '#a86';
ctx.fillRect(cx - 3, bodyY - 10, 6, 4);
}
}
drawEnemy(e) {
const ctx = this.ctx;
const cx = e.x + types_1.TILE / 2;
const cy = e.y + types_1.TILE / 2;
const flash = e.hitFlash > 0;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.beginPath();
ctx.ellipse(cx, cy + 12, 10, 4, 0, 0, Math.PI * 2);
ctx.fill();
switch (e.type) {
case 'slime':
const slimeSquish = Math.sin(e.animTimer * 0.1) * 2;
ctx.fillStyle = flash ? '#fff' : '#4a4';
ctx.beginPath();
ctx.ellipse(cx, cy + 2 + slimeSquish, 12 - slimeSquish, 10 + slimeSquish, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(cx - 4, cy - 2, 3, 0, Math.PI * 2);
ctx.arc(cx + 4, cy - 2, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(cx - 3, cy - 2, 1.5, 0, Math.PI * 2);
ctx.arc(cx + 5, cy - 2, 1.5, 0, Math.PI * 2);
ctx.fill();
break;
case 'wolf':
ctx.fillStyle = flash ? '#fff' : '#665';
ctx.beginPath();
ctx.ellipse(cx, cy, 14, 10, 0, 0, Math.PI * 2);
ctx.fill();
// Head
ctx.beginPath();
ctx.arc(cx + (e.dir === types_1.Dir.LEFT ? -10 : 10), cy - 4, 8, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = '#f00';
ctx.beginPath();
ctx.arc(cx + (e.dir === types_1.Dir.LEFT ? -14 : 14), cy - 6, 2, 0, Math.PI * 2);
ctx.fill();
// Ears
ctx.fillStyle = flash ? '#fff' : '#665';
ctx.beginPath();
ctx.moveTo(cx + (e.dir === types_1.Dir.LEFT ? -12 : 8), cy - 12);
ctx.lineTo(cx + (e.dir === types_1.Dir.LEFT ? -16 : 14), cy - 18);
ctx.lineTo(cx + (e.dir === types_1.Dir.LEFT ? -6 : 12), cy - 12);
ctx.fill();
break;
case 'bat':
const wingFlap = Math.sin(Date.now() / 100) * 6;
ctx.fillStyle = flash ? '#fff' : '#636';
// Body
ctx.beginPath();
ctx.ellipse(cx, cy, 6, 5, 0, 0, Math.PI * 2);
ctx.fill();
// Wings
ctx.beginPath();
ctx.moveTo(cx - 6, cy);
ctx.lineTo(cx - 16, cy - 8 + wingFlap);
ctx.lineTo(cx - 8, cy + 4);
ctx.fill();
ctx.beginPath();
ctx.moveTo(cx + 6, cy);
ctx.lineTo(cx + 16, cy - 8 + wingFlap);
ctx.lineTo(cx + 8, cy + 4);
ctx.fill();
// Eyes
ctx.fillStyle = '#f00';
ctx.beginPath();
ctx.arc(cx - 2, cy - 1, 1.5, 0, Math.PI * 2);
ctx.arc(cx + 2, cy - 1, 1.5, 0, Math.PI * 2);
ctx.fill();
break;
case 'skeleton':
ctx.fillStyle = flash ? '#fff' : '#ddd';
// Body
ctx.fillRect(cx - 5, cy - 8, 10, 16);
// Head (skull)
ctx.beginPath();
ctx.arc(cx, cy - 12, 8, 0, Math.PI * 2);
ctx.fill();
// Eye sockets
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(cx - 3, cy - 13, 2.5, 0, Math.PI * 2);
ctx.arc(cx + 3, cy - 13, 2.5, 0, Math.PI * 2);
ctx.fill();
// Eye glow
ctx.fillStyle = '#f80';
ctx.beginPath();
ctx.arc(cx - 3, cy - 13, 1, 0, Math.PI * 2);
ctx.arc(cx + 3, cy - 13, 1, 0, Math.PI * 2);
ctx.fill();
// Jaw
ctx.fillStyle = flash ? '#fff' : '#ccc';
ctx.fillRect(cx - 4, cy - 8, 8, 4);
// Arms
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx - 5, cy - 2);
ctx.lineTo(cx - 14, cy + 4);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx + 5, cy - 2);
ctx.lineTo(cx + 14, cy + 4);
ctx.stroke();
// Bone weapon
ctx.strokeStyle = '#ee8';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx + 14, cy + 4);
ctx.lineTo(cx + 22, cy + 12);
ctx.stroke();
break;
case 'goblin':
ctx.fillStyle = flash ? '#fff' : '#6a3';
// Body
ctx.fillRect(cx - 6, cy - 6, 12, 14);
// Head
ctx.beginPath();
ctx.arc(cx, cy - 10, 8, 0, Math.PI * 2);
ctx.fill();
// Ears
ctx.beginPath();
ctx.moveTo(cx - 8, cy - 12);
ctx.lineTo(cx - 16, cy - 18);
ctx.lineTo(cx - 8, cy - 6);
ctx.fill();
ctx.beginPath();
ctx.moveTo(cx + 8, cy - 12);
ctx.lineTo(cx + 16, cy - 18);
ctx.lineTo(cx + 8, cy - 6);
ctx.fill();
// Eyes
ctx.fillStyle = '#f00';
ctx.beginPath();
ctx.arc(cx - 3, cy - 11, 2, 0, Math.PI * 2);
ctx.arc(cx + 3, cy - 11, 2, 0, Math.PI * 2);
ctx.fill();
// Sword
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx + 8, cy + 2);
ctx.lineTo(cx + 18, cy - 6);
ctx.stroke();
break;
case 'dragon':
const breathe = Math.sin(Date.now() / 300) * 3;
ctx.fillStyle = flash ? '#fff' : '#a33';
// Body
ctx.beginPath();
ctx.ellipse(cx, cy, 18, 14, 0, 0, Math.PI * 2);
ctx.fill();
// Wings
ctx.fillStyle = flash ? '#fcc' : '#822';
ctx.beginPath();
ctx.moveTo(cx - 14, cy - 4);
ctx.lineTo(cx - 28, cy - 16 + breathe);
ctx.lineTo(cx - 14, cy + 4);
ctx.fill();
ctx.beginPath();
ctx.moveTo(cx + 14, cy - 4);
ctx.lineTo(cx + 28, cy - 16 + breathe);
ctx.lineTo(cx + 14, cy + 4);
ctx.fill();
// Head
ctx.fillStyle = flash ? '#fff' : '#b44';
ctx.beginPath();
ctx.arc(cx, cy - 12, 10, 0, Math.PI * 2);
ctx.fill();
// Horns
ctx.fillStyle = '#864';
ctx.beginPath();
ctx.moveTo(cx - 6, cy - 18);
ctx.lineTo(cx - 10, cy - 26);
ctx.lineTo(cx - 2, cy - 18);
ctx.fill();
ctx.beginPath();
ctx.moveTo(cx + 6, cy - 18);
ctx.lineTo(cx + 10, cy - 26);
ctx.lineTo(cx + 2, cy - 18);
ctx.fill();
// Eyes
ctx.fillStyle = '#ff0';
ctx.beginPath();
ctx.arc(cx - 4, cy - 13, 3, 0, Math.PI * 2);
ctx.arc(cx + 4, cy - 13, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#f00';
ctx.beginPath();
ctx.arc(cx - 4, cy - 13, 1.5, 0, Math.PI * 2);
ctx.arc(cx + 4, cy - 13, 1.5, 0, Math.PI * 2);
ctx.fill();
// Fire breath
if (e.state === 'attack') {
ctx.fillStyle = 'rgba(255, 100, 0, 0.6)';
ctx.beginPath();
ctx.arc(cx + (e.dir === types_1.Dir.LEFT ? -20 : 20), cy - 10, 6 + Math.random() * 4, 0, Math.PI * 2);
ctx.fill();
}
break;
}
// HP bar
if (e.hp < e.maxHp) {
const barW = 28;
const barH = 4;
const barX = cx - barW / 2;
const barY = cy - 24;
ctx.fillStyle = '#400';
ctx.fillRect(barX, barY, barW, barH);
ctx.fillStyle = '#f00';
ctx.fillRect(barX, barY, barW * (e.hp / e.maxHp), barH);
}
}
drawNPC(x, y, name) {
const ctx = this.ctx;
const cx = x + types_1.TILE / 2;
const cy = y + types_1.TILE / 2;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.beginPath();
ctx.ellipse(cx, cy + 14, 10, 4, 0, 0, Math.PI * 2);
ctx.fill();
// Body
ctx.fillStyle = '#c8a';
ctx.fillRect(cx - 8, cy - 4, 16, 14);
// Head
ctx.fillStyle = '#fca';
ctx.beginPath();
ctx.arc(cx, cy - 10, 8, 0, Math.PI * 2);
ctx.fill();
// Hat
ctx.fillStyle = '#636';
ctx.fillRect(cx - 10, cy - 18, 20, 6);
ctx.fillRect(cx - 6, cy - 24, 12, 6);
// Eyes
ctx.fillStyle = '#333';
ctx.fillRect(cx - 3, cy - 11, 2, 2);
ctx.fillRect(cx + 1, cy - 11, 2, 2);
// Exclamation mark
const bobY = Math.sin(Date.now() / 500) * 3;
ctx.fillStyle = '#fd0';
ctx.font = 'bold 16px Courier New';
ctx.textAlign = 'center';
ctx.fillText('!', cx, cy - 28 + bobY);
// Name
ctx.fillStyle = '#fff';
ctx.font = '10px Courier New';
ctx.fillText(name, cx, cy + 20);
}
getWeaponColor(weaponId) {
const colors = { rusty_sword: '#aaa', iron_blade: '#8af', flame_sword: '#f60' };
return colors[weaponId] || '#aaa';
}
getWeaponCooldown(weaponId) {
const c = { rusty_sword: 20, iron_blade: 16, flame_sword: 14 };
return c[weaponId] || 20;
}
drawHUD(player) {
const ctx = this.ctx;
const state = player.state;
// HP Hearts
const heartW = 24;
const heartH = 22;
const totalHearts = state.hpContainers * 2;
const startX = 15;
const startY = 8;
for (let i = 0; i < totalHearts; i++) {
const row = Math.floor(i / 5);
const col = i % 5;
const hx = startX + col * (heartW + 4);
const hy = startY + row * (heartH + 2);
const hpIdx = i * 2;
const remaining = state.hp - hpIdx;
if (remaining <= 0) {
// Empty heart
ctx.fillStyle = '#400';
this.drawHeart(ctx, hx, hy, heartW, heartH);
}
else if (remaining >= 2) {
// Full heart
ctx.fillStyle = '#f44';
this.drawHeart(ctx, hx, hy, heartW, heartH);
// Highlight
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.arc(hx + 6, hy + 6, 4, 0, Math.PI * 2);
ctx.fill();
}
else {
// Half heart
ctx.fillStyle = '#f44';
ctx.fillRect(hx, hy, heartW / 2, heartH);
ctx.fillStyle = '#400';
ctx.fillRect(hx + heartW / 2, hy, heartW / 2, heartH);
// Shape the half
ctx.save();
ctx.beginPath();
ctx.rect(hx, hy, heartW / 2, heartH);
ctx.clip();
this.drawHeart(ctx, hx, hy, heartW, heartH);
ctx.restore();
}
}
// Money
ctx.fillStyle = '#fd0';
ctx.font = 'bold 14px Courier New';
ctx.textAlign = 'left';
ctx.fillText(`💰 ${state.money}`, 15, 50);
// Weapon name
ctx.fillStyle = '#ccc';
ctx.font = '12px Courier New';
ctx.fillText(`⚔️ ${player.weaponName}`, 15, 68);
// Ranged indicator
if (state.weapons.rangedWeapon) {
const arrows = player.hasItemWithCount('arrow');
ctx.fillStyle = '#a86';
ctx.fillText(`🏹 ${arrows}`, 15, 84);
}
// Shield indicator
if (state.shieldActive) {
ctx.fillStyle = '#8cf';
ctx.fillText(`🛡️ ${Math.ceil(state.shieldTimer / 60)}s`, 15, state.weapons.rangedWeapon ? 100 : 84);
}
// Minimap
this.drawMinimap(state);
}
drawMinimap(state) {
const ctx = this.ctx;
const mmX = types_1.SCREEN_W - 120;
const mmY = 8;
const mmW = 100;
const mmH = 16;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(mmX - 4, mmY - 2, mmW + 8, mmH + 4);
const area = map_1.AREAS[state.areaId];
if (!area)
return;
// Show current area as colored block
ctx.fillStyle = '#5a5';
ctx.fillRect(mmX, mmY, mmW, mmH);
// Player dot
const px = mmX + (state.x / (area.map[0].length * types_1.TILE)) * mmW;
const py = mmY + (state.y / (area.map.length * types_1.TILE)) * mmH;
ctx.fillStyle = '#ff0';
ctx.fillRect(px - 2, py - 2, 4, 4);
}
drawHeart(ctx, x, y, w, h) {
ctx.beginPath();
ctx.moveTo(x + w / 2, y + h);
ctx.bezierCurveTo(x, y + h * 0.6, x, y, x + w / 2, y);
ctx.bezierCurveTo(x + w, y, x + w, y + h * 0.6, x + w / 2, y + h);
ctx.fill();
}
}
exports.Renderer = Renderer;
};
// ── module: src/engine.ts ──
__mods["src/engine.ts"] = function (exports, require, module) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.GameEngine = void 0;
const types_1 = require("./types");
const map_1 = require("./map");
const input_1 = require("./input");
const player_1 = require("./player");
const enemies_1 = require("./enemies");
const effects_1 = require("./effects");
const renderer_1 = require("./renderer");
const Audio = __importStar(require("./audio"));
class GameEngine {
constructor(canvas) {
this.projectiles = [];
// UI state
this.showInventory = false;
this.showShop = false;
this.dialogText = '';
this.dialogTimer = 0;
this.notificationText = '';
this.notificationTimer = 0;
this.menuState = 'title'; // title, playing, gameover, win
this.speedBoostTimer = 0;
this.stepTimer = 0;
this.input = new input_1.InputSystem();
this.effects = new effects_1.Effects();
this.player = new player_1.Player();
this.enemies = new enemies_1.EnemyManager();
this.renderer = new renderer_1.Renderer(canvas, this.effects);
// Initialize enemies for starting area
this.enemies.initAreaEnemies('village');
}
start() {
this.menuState = 'title';
this.showTitleScreen();
this.loop();
}
showTitleScreen() {
const overlay = document.getElementById('overlay');
overlay.className = 'active';
const hasSave = !!localStorage.getItem('realm_of_shadows_save');
overlay.innerHTML = `
<h1>⚔️ Realm of Shadows ⚔️</h1>
<p style="font-size:18px;color:#e8c547;margin:10px 0;">An Action-Adventure RPG</p>
<p>WASD / Arrow Keys — Move</p>
<p>Space / Z — Attack</p>
<p>X — Ranged Attack (when discovered)</p>
<p>E — Interact / Shop</p>
<p>Q — Use Consumable Item</p>
<p>I — Open Inventory</p>
<br>
<p style="color:#aaa;font-size:13px;">Explore 5 areas. Defeat enemies. Find treasures.</p>
<p style="color:#aaa;font-size:13px;">Upgrade your weapon and grow stronger.</p>
<button class="btn" onclick="gameEngine.startGame()">Begin Adventure</button>
${hasSave ? '<button class="btn" style="margin-left:10px;background:#555;border-color:#777;" onclick="gameEngine.loadAndPlay()">Load Game</button>' : ''}
`;
}
startGame() {
document.getElementById('overlay').className = '';
this.menuState = 'playing';
this.player = new player_1.Player();
this.enemies = new enemies_1.EnemyManager();
this.enemies.initAreaEnemies('village');
this.projectiles = [];
this.speedBoostTimer = 0;
// Mark village as visited
map_1.AREAS['village'].visited = true;
Audio.playPickup();
}
loadAndPlay() {
document.getElementById('overlay').className = '';
this.menuState = 'playing';
this.player = new player_1.Player();
this.enemies = new enemies_1.EnemyManager();
this.projectiles = [];
this.speedBoostTimer = 0;
if (this.loadGame()) {
// Init enemies for visited areas
for (const area of Object.values(map_1.AREAS)) {
if (area.visited)
this.enemies.initAreaEnemies(area.id);
}
}
else {
this.startGame();
}
}
loop() {
requestAnimationFrame(() => this.loop());
if (this.menuState === 'playing') {
this.update();
}
this.render();
this.input.update();
}
update() {
const p = this.player;
const input = this.input;
// Speed boost
if (this.speedBoostTimer > 0) {
this.speedBoostTimer--;
}
const gameSpeed = this.speedBoostTimer > 0 ? 1.6 : 1;
// Handle menu inputs
if (this.showInventory) {
if (input.cancel()) {
this.showInventory = false;
document.getElementById('inventory-panel').className = '';
}
return;
}
if (this.showShop) {
if (input.cancel() || input.interact()) {
this.showShop = false;
document.getElementById('shop-panel').className = '';
}
return;
}
if (this.dialogTimer > 0) {
this.dialogTimer--;
if (this.dialogTimer <= 0) {
document.getElementById('dialog-box').className = '';
}
return;
}
if (this.notificationTimer > 0) {
this.notificationTimer--;
if (this.notificationTimer <= 0) {
document.getElementById('notification').className = '';
}
}
// Open inventory
if (input.openInventory()) {
this.showInventory = !this.showInventory;
document.getElementById('inventory-panel').className = this.showInventory ? 'active' : '';
if (this.showInventory)
this.renderInventory();
return;
}
// Player movement with collision
const area = map_1.AREAS[p.state.areaId];
if (!area)
return;
const oldX = p.state.x;
const oldY = p.state.y;
p.update(input, gameSpeed);
// Collision with walls/trees/rocks/water
const newPos = this.checkCollision(p.state.x, p.state.y, area);
if (!newPos) {
p.state.x = oldX;
p.state.y = oldY;
}
else {
p.state.x = newPos.x;
p.state.y = newPos.y;
}
// Step sounds
if (p.state.isMoving) {
this.stepTimer++;
if (this.stepTimer % 15 === 0)
Audio.playStep();
}
else {
this.stepTimer = 0;
}
// Area transitions
this.checkAreaTransition(p, input);
// Attack
if ((input.attack() || p.state._pendingAttack) && p.canAttack()) {
p.state._pendingAttack = false;
const hits = p.attack(p.state.dir);
const cx = p.state.x + types_1.TILE / 2;
const cy = p.state.y + types_1.TILE / 2;
this.effects.emitSlash(cx, cy, p.weaponColor);
// Check hits on enemies
const areaEnemies = this.enemies.getEnemiesInArea(p.state.areaId);
for (const e of areaEnemies) {
if (!e.alive)
continue;
for (const hit of hits) {
const ex = e.x + types_1.TILE / 2;
const ey = e.y + types_1.TILE / 2;
const dx = hit.x - ex;
const dy = hit.y - ey;
if (Math.sqrt(dx * dx + dy * dy) < types_1.TILE * 0.8) {
const killed = this.enemies.damageEnemy(e, p.weaponDamage);
this.effects.emitHit(ex, ey);
this.effects.addDamageText(ex, ey - 15, p.weaponDamage, p.state.weapons.melee === 'flame_sword' ? '#f60' : '#ff0');
if (killed) {
let money = e.money;
if (p.hasItem('gold_amulet'))
money = Math.floor(money * 1.5);
p.state.money += money;
this.effects.emitDeath(ex, ey);
this.effects.emitCoin(ex, ey - 10);
this.effects.addText(ex, ey - 25, `+${money}💰`, '#fd0');
p.state.hitFlash = 0; // No self-damage feedback
Audio.playEnemyDeath();
}
break;
}
}
}
}
// Ranged attack
if ((input.justPressed('x') || input.justPressed('X')) && p.canAttack()) {
if (p.state.weapons.rangedWeapon) {
if (p.hasItemWithCount('arrow') > 0) {
p.state.attackCooldown = 24;
const pos = p.rangedAttack();
if (pos) {
let dx = 0, dy = -1;
if (p.state.dir === types_1.Dir.RIGHT) {
dx = 1;
dy = 0;
}
if (p.state.dir === types_1.Dir.DOWN) {
dx = 0;
dy = 1;
}
if (p.state.dir === types_1.Dir.LEFT) {
dx = -1;
dy = 0;
}
this.projectiles.push({
x: pos.x, y: pos.y,
vx: dx * 6, vy: dy * 6,
damage: 4,
life: 40,
color: '#da4',
owner: 'player',
type: 'arrow',
});
p.removeItem('arrow');
}
}
else {
this.showNotification('No arrows!');
}
}
}
// Enemy AI
const areaEnemies = this.enemies.getEnemiesInArea(p.state.areaId);
this.enemies.update(areaEnemies, p.state.x, p.state.y, 1);
// Enemy damage to player
for (const e of areaEnemies) {
if (!e.alive)
continue;
if (e.state === 'attack' || e.state === 'chase') {
const dx = (p.state.x + types_1.TILE / 2) - (e.x + types_1.TILE / 2);
const dy = (p.state.y + types_1.TILE / 2) - (e.y + types_1.TILE / 2);
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < e.range + 12 && p.state.attackCooldown > this.player.weaponCooldown * 0.5) {
// Enemy in attack range
const dmg = e.state === 'attack' ? this.enemies.getAttackDamage(e) : null;
if (dmg && dmg > 0) {
const taken = p.takeDamage(dmg);
if (taken > 0) {
this.effects.emitHit(p.state.x + types_1.TILE / 2, p.state.y + types_1.TILE / 2);
this.effects.addDamageText(p.state.x + types_1.TILE / 2, p.state.y, taken, '#f44');
Audio.playDamage();
}
}
}
// Contact damage during chase
if (e.state === 'chase' && dist < 16) {
const taken = p.takeDamage(1);
if (taken > 0) {
this.effects.emitHit(p.state.x + types_1.TILE / 2, p.state.y + types_1.TILE / 2);
Audio.playDamage();
}
}
}
}
// Update projectiles
for (const proj of this.projectiles) {
proj.x += proj.vx;
proj.y += proj.vy;
proj.life--;
// Check collision with enemies
if (proj.owner === 'player') {
for (const e of areaEnemies) {
if (!e.alive)
continue;
const ex = e.x + types_1.TILE / 2;
const ey = e.y + types_1.TILE / 2;
const dx = proj.x - ex;
const dy = proj.y - ey;
if (Math.sqrt(dx * dx + dy * dy) < types_1.TILE * 0.6) {
const killed = this.enemies.damageEnemy(e, proj.damage);
this.effects.emitHit(proj.x, proj.y);
this.effects.addDamageText(ex, ey - 15, proj.damage);
if (killed) {
let money = e.money;
if (p.hasItem('gold_amulet'))
money = Math.floor(money * 1.5);
p.state.money += money;
this.effects.emitDeath(ex, ey);
this.effects.addText(ex, ey - 25, `+${money}💰`, '#fd0');
Audio.playEnemyDeath();
}
proj.life = 0;
break;
}
}
}
}
this.projectiles = this.projectiles.filter(p => p.life > 0);
// Interactions
if (input.interact()) {
this.handleInteraction(p, area);
}
// Use item
if (input.useItem()) {
this.tryUseItem();
}
// Update effects
this.effects.update();
// Check player death
if (p.state.hp <= 0) {
this.menuState = 'gameover';
this.showGameOver();
Audio.playDeath();
return;
}
// Check dragon defeat (victory condition)
if (p.state.areaId === 'dragon_lair') {
const dragons = areaEnemies.filter(e => e.type === 'dragon' && e.alive);
if (dragons.length === 0) {
// Check if dragon was ever here (was alive before)
const allDragons = this.enemies.getEnemiesInArea('dragon_lair');
const deadDragons = allDragons.filter(e => e.type === 'dragon' && !e.alive);
if (deadDragons.length > 0) {
this.menuState = 'win';
this.showVictory();
Audio.playUpgrade();
return;
}
}
}
// Respawn enemies in other areas
// (only respawn in the current area if all enemies are dead after a delay)
}
checkCollision(nx, ny, area) {
// Check all four corners of player bounding box
const margin = 6;
const checks = [
{ x: nx + margin, y: ny + margin },
{ x: nx + types_1.TILE - margin, y: ny + margin },
{ x: nx + margin, y: ny + types_1.TILE - margin },
{ x: nx + types_1.TILE - margin, y: ny + types_1.TILE - margin },
];
for (const c of checks) {
const col = Math.floor(c.x / types_1.TILE);
const row = Math.floor(c.y / types_1.TILE);
if (col < 0 || col >= area.map[0].length || row < 0 || row >= area.map.length) {
return null;
}
const tile = area.map[row][col];
if (tile === types_1.Tile.WALL || tile === types_1.Tile.TREE || tile === types_1.Tile.ROCK || tile === types_1.Tile.WATER || tile === types_1.Tile.GATE) {
return null;
}
}
return { x: nx, y: ny };
}
checkAreaTransition(p, input) {
const area = map_1.AREAS[p.state.areaId];
if (!area)
return;
const cols = area.map[0].length;
const rows = area.map.length;
// Tile-relative: col 0/row 0 is outer wall, col 1/row 1 is first walkable
const col = Math.floor(p.state.x / types_1.TILE);
const row = Math.floor(p.state.y / types_1.TILE);
for (const conn of area.connections) {
// West edge: player reached leftmost walkable column (col 1) and moving west
if (conn.dx === -1 && col <= 1) {
this.transitionArea(p, conn.targetArea, (cols - 2) * types_1.TILE, Math.floor(rows / 2) * types_1.TILE);
return;
}
// East edge: player reached rightmost walkable column and moving east
if (conn.dx === 1 && col >= cols - 2) {
this.transitionArea(p, conn.targetArea, 2 * types_1.TILE, Math.floor(rows / 2) * types_1.TILE);
return;
}
// North edge: player reached topmost walkable row and moving north
if (conn.dy === -1 && row <= 1) {
this.transitionArea(p, conn.targetArea, Math.floor(cols / 2) * types_1.TILE, 2 * types_1.TILE);
return;
}
// South edge: player reached bottommost walkable row and moving south
if (conn.dy === 1 && row >= rows - 2) {
this.transitionArea(p, conn.targetArea, Math.floor(cols / 2) * types_1.TILE, (rows - 2) * types_1.TILE);
return;
}
}
}
transitionArea(p, targetId, x, y) {
const area = map_1.AREAS[targetId];
if (!area)
return;
if (!area.visited) {
area.visited = true;
this.enemies.initAreaEnemies(targetId);
// Mid-game ranged discovery
if (!p.state.rangedDiscovered) {
// After visiting 3rd area (ruins or cave), discover ranged weapon
const visitedCount = Object.values(map_1.AREAS).filter(a => a.visited).length;
if (visitedCount >= 3) {
p.state.rangedDiscovered = true;
p.state.weapons.ranged = true;
p.state.weapons.rangedWeapon = 'shortbow';
// Give some arrows
for (let i = 0; i < 5; i++)
p.addItem('arrow');
this.showNotification('🏹 Discovered: Shortbow! (X to fire)');
Audio.playUpgrade();
}
}
}
p.updateArea(targetId, x, y);
this.showNotification(`Entered: ${area.name}`);
}
handleInteraction(p, area) {
const cx = p.state.x + types_1.TILE / 2;
const cy = p.state.y + types_1.TILE / 2;
// NPCs
for (const npc of area.npcs) {
const dx = cx - (npc.x + types_1.TILE / 2);
const dy = cy - (npc.y + types_1.TILE / 2);
if (Math.sqrt(dx * dx + dy * dy) < types_1.TILE * 1.2) {
if (npc.isShop) {
this.showShop = true;
this.renderShop(npc);
Audio.playShop();
}
else {
this.showDialog(npc.dialog);
Audio.playDialog();
}
return;
}
}
// Chests
for (const chest of area.chests) {
if (chest.opened)
continue;
const dx = cx - (chest.x + types_1.TILE / 2);
const dy = cy - (chest.y + types_1.TILE / 2);
if (Math.sqrt(dx * dx + dy * dy) < types_1.TILE * 1.2 && chest.contents) {
chest.opened = true;
this.openChest(p, chest, cx, cy);
return;
}
}
// Ground items
if (area.itemsOnGround) {
for (const gi of area.itemsOnGround) {
if (gi.collected)
continue;
const dx = cx - (gi.x + types_1.TILE / 2);
const dy = cy - (gi.y + types_1.TILE / 2);
if (Math.sqrt(dx * dx + dy * dy) < types_1.TILE * 1.2) {
gi.collected = true;
const itemDef = types_1.ITEMS[gi.item];
if (itemDef) {
if (itemDef.type === 'consumable' || itemDef.type === 'material') {
p.addItem(gi.item);
}
else if (itemDef.type === 'accessory') {
if (p.addItem(gi.item)) {
this.showNotification(`Found: ${itemDef.name}!`);
Audio.playPickup();
}
}
else if (itemDef.type === 'upgrade') {
this.applyUpgrade(gi.item, p, gi.x + types_1.TILE / 2, gi.y + types_1.TILE / 2);
}
}
this.effects.emitPickup(gi.x + types_1.TILE / 2, gi.y + types_1.TILE / 2);
return;
}
}
}
// Save points
for (const sp of area.savePoints) {
const dx = cx - (sp.x + types_1.TILE / 2);
const dy = cy - (sp.y + types_1.TILE / 2);
if (Math.sqrt(dx * dx + dy * dy) < types_1.TILE * 1.2) {
this.saveGame(sp);
return;
}
}
}
openChest(p, chest, cx, cy) {
const item = chest.contents;
if (!item)
return;
if (item.type === 'upgrade') {
this.applyUpgrade(item.id, p, cx, cy);
}
else if (item.type === 'accessory') {
if (p.addItem(item.id)) {
this.showNotification(`Found: ${item.name}!`);
Audio.playUpgrade();
}
else {
this.showNotification('Inventory full!');
}
}
else {
p.addItem(item.id);
this.showNotification(`Found: ${item.name}!`);
Audio.playPickup();
}
this.effects.emitPickup(cx, cy);
}
applyUpgrade(id, p, x, y) {
switch (id) {
case 'heart_container':
if (p.state.hpContainers < types_1.MAX_HP_CONTAINERS_MAX) {
p.addHpContainers();
this.effects.emitHeart(x, y);
this.showNotification('❤️ Heart Container! Max HP increased!');
Audio.playUpgrade();
}
else {
this.showNotification('Already at max HP!');
}
break;
case 'iron_ingot':
// Weapon upgrade
if (p.state.weapons.melee === 'rusty_sword') {
p.state.weapons.melee = 'iron_blade';
this.effects.emitUpgrade(x, y);
this.showNotification('⚔️ Iron Blade! Weapon upgraded!');
Audio.playUpgrade();
}
else if (p.state.weapons.melee === 'iron_blade') {
p.state.weapons.melee = 'flame_sword';
this.effects.emitUpgrade(x, y);
this.showNotification('🔥 Flame Sword! Weapon upgraded!');
Audio.playUpgrade();
}
else {
p.addItem(id);
}
break;
default:
p.addItem(id);
}
}
tryUseItem() {
const p = this.player;
// Find first consumable in inventory
const consumables = ['herb', 'big_herb', 'mushroom', 'energy_drink'];
for (const id of consumables) {
const slot = p.state.inventory.indexOf(id);
if (slot !== -1) {
const def = types_1.ITEMS[id];
if (p.state.hp >= p.state.maxHp && (id === 'herb' || id === 'big_herb')) {
this.showNotification('Health is full!');
return;
}
if (p.useConsumable(id)) {
this.showNotification(`Used: ${def.name}`);
if (id === 'herb' || id === 'big_herb') {
Audio.playHeal();
this.effects.emit(p.state.x + types_1.TILE / 2, p.state.y + types_1.TILE / 2, 8, '#4f4', 2, 2, 20);
}
else if (id === 'energy_drink') {
this.speedBoostTimer = 300;
Audio.playPickup();
this.effects.emit(p.state.x + types_1.TILE / 2, p.state.y + types_1.TILE / 2, 10, '#ff0', 3, 2, 25);
}
else if (id === 'mushroom') {
Audio.playPickup();
this.effects.emit(p.state.x + types_1.TILE / 2, p.state.y + types_1.TILE / 2, 12, '#8cf', 2, 3, 30);
}
return;
}
}
}
this.showNotification('No usable items!');
}
renderInventory() {
const grid = document.getElementById('inventory-grid');
grid.innerHTML = '';
const p = this.player;
for (let i = 0; i < types_1.INV_SLOTS; i++) {
const slot = document.createElement('div');
slot.className = 'inv-slot';
const itemId = p.state.inventory[i];
if (itemId) {
const def = types_1.ITEMS[itemId];
if (def) {
slot.textContent = def.icon;
slot.title = `${def.name}: ${def.desc}`;
// Count for stackable items
const count = p.state.inventory.filter((v, j) => v === itemId).length;
if (count > 1 && def.stackable) {
const countEl = document.createElement('span');
countEl.className = 'count';
countEl.textContent = `${count}`;
slot.appendChild(countEl);
}
// Click to use consumables
if (def.type === 'consumable') {
slot.addEventListener('click', () => {
this.tryUseItem();
});
}
}
}
grid.appendChild(slot);
}
}
renderShop(npc) {
const list = document.getElementById('shop-list');
const panel = document.getElementById('shop-panel');
list.innerHTML = '';
panel.className = 'active';
const shopItems = npc.shopItems || ['herb'];
for (const itemId of shopItems) {
const def = types_1.ITEMS[itemId];
if (!def)
continue;
const el = document.createElement('div');
el.className = 'shop-item';
el.innerHTML = `
<div>
<span class="name">${def.icon} ${def.name}</span>
<span class="desc"> — ${def.desc}</span>
</div>
<span class="price">💰 ${def.price}</span>
`;
el.addEventListener('click', () => {
if (this.player.state.money >= def.price) {
this.player.state.money -= def.price;
if (def.type === 'upgrade') {
this.applyUpgrade(itemId, this.player, this.player.state.x + types_1.TILE / 2, this.player.state.y + types_1.TILE / 2);
}
else if (def.type === 'material' && itemId === 'iron_ingot') {
// Iron ingot from shop triggers weapon upgrade
this.applyUpgrade('iron_ingot', this.player, this.player.state.x + types_1.TILE / 2, this.player.state.y + types_1.TILE / 2);
}
else {
this.player.addItem(itemId);
}
Audio.playPurchase();
this.renderShop(npc); // Refresh
}
else {
this.showNotification('Not enough gold!');
}
});
list.appendChild(el);
}
}
showDialog(text) {
const box = document.getElementById('dialog-box');
box.textContent = text;
box.className = 'active';
this.dialogText = text;
this.dialogTimer = 180; // ~3 seconds
}
showNotification(text) {
const el = document.getElementById('notification');
el.textContent = text;
el.className = 'active';
this.notificationText = text;
this.notificationTimer = 90;
}
saveGame(savePoint) {
const p = this.player.state;
const saveData = {
player: {
x: p.x, y: p.y,
dir: p.dir,
hp: p.hp, maxHp: p.maxHp,
hpContainers: p.hpContainers,
money: p.money,
inventory: [...p.inventory],
weapons: { ...p.weapons },
areaId: p.areaId,
rangedDiscovered: p.rangedDiscovered,
},
};
try {
localStorage.setItem('realm_of_shadows_save', JSON.stringify(saveData));
this.effects.emitSave(savePoint.x + types_1.TILE / 2, savePoint.y + types_1.TILE / 2);
this.showNotification('✨ Game Saved!');
Audio.playSave();
}
catch (e) {
this.showNotification('Save failed (localStorage unavailable)');
}
}
loadGame() {
try {
const data = localStorage.getItem('realm_of_shadows_save');
if (!data)
return false;
const save = JSON.parse(data);
const p = this.player.state;
p.x = save.player.x;
p.y = save.player.y;
p.dir = save.player.dir;
p.hp = save.player.hp;
p.maxHp = save.player.maxHp;
p.hpContainers = save.player.hpContainers;
p.money = save.player.money;
p.inventory = save.player.inventory;
p.weapons = save.player.weapons;
p.areaId = save.player.areaId;
p.rangedDiscovered = save.player.rangedDiscovered;
// Restore area visited status
for (const area of Object.values(map_1.AREAS)) {
if (area.visited)
this.enemies.initAreaEnemies(area.id);
}
this.enemies.initAreaEnemies(p.areaId);
this.showNotification('📂 Game Loaded!');
return true;
}
catch (e) {
return false;
}
}
showGameOver() {
const overlay = document.getElementById('overlay');
overlay.className = 'active';
overlay.innerHTML = `
<h1 style="color:#f44;">💀 You Have Fallen 💀</h1>
<p>The shadows claim another soul...</p>
<p>Gold earned: 💰 ${this.player.state.money}</p>
<p>Area: ${map_1.AREAS[this.player.state.areaId]?.name || 'Unknown'}</p>
<p>Weapon: ${this.player.weaponName}</p>
<button class="btn" onclick="gameEngine.showTitleScreen()">Return to Title</button>
<button class="btn" style="margin-left:10px;background:#a33;border-color:#c55;" onclick="gameEngine.startGame()">Try Again</button>
`;
}
showVictory() {
const overlay = document.getElementById('overlay');
overlay.className = 'active';
overlay.innerHTML = `
<h1 style="color:#fd0;">🐉 Victory! The Dragon Falls! 🐉</h1>
<p style="font-size:16px;color:#fff;margin:10px 0;">You have slain the great dragon and saved the realm!</p>
<p>Gold earned: 💰 ${this.player.state.money}</p>
<p>Weapon: ${this.player.weaponName}</p>
<p>Max HP: ${this.player.state.maxHp} (${this.player.state.hpContainers} containers)</p>
<p>Items collected: ${this.player.state.inventory.filter(i => i !== null).length}</p>
<button class="btn" onclick="gameEngine.showTitleScreen()">Return to Title</button>
<button class="btn" style="margin-left:10px;background:#5a5;border-color:#7c7;" onclick="gameEngine.startGame()">Play Again</button>
`;
}
render() {
const canvas = this.renderer.canvas;
const ctx = this.renderer.ctx;
// Clear
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, types_1.SCREEN_W, types_1.SCREEN_H);
if (this.menuState === 'title')
return;
// Draw game
this.renderer.drawGame(this.player, this.enemies.getEnemiesInArea(this.player.state.areaId), this.projectiles);
// Draw HUD
this.renderer.drawHUD(this.player);
// Controls hint
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '11px Courier New';
ctx.textAlign = 'right';
ctx.fillText('Space:Attack E:Interact I:Inventory Q:Use', types_1.SCREEN_W - 10, types_1.SCREEN_H - 8);
// Save/Load hints
ctx.textAlign = 'left';
if (this.player.state.areaId && map_1.AREAS[this.player.state.areaId]) {
const area = map_1.AREAS[this.player.state.areaId];
if (area.savePoints && area.savePoints.length > 0) {
const dx = this.player.state.x - area.savePoints[0].x;
const dy = this.player.state.y - area.savePoints[0].y;
if (Math.sqrt(dx * dx + dy * dy) < types_1.TILE * 2) {
ctx.fillStyle = 'rgba(100,150,255,0.6)';
ctx.fillText('E: Save Game', 10, types_1.SCREEN_H - 8);
}
}
}
}
}
exports.GameEngine = GameEngine;
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.6-27B-MTP-GGUF-IQ4_NL","provider":"brigid","persona":"typescript","sessionId":"79f68c06-679c-4063-a161-5d6ce5227bc3","tokensIn":334844,"tokensOut":6516,"tokensTotal":341360,"cost":0,"turns":7,"toolCalls":6,"failedToolCalls":0,"timestamp":"2026-08-04T15:14:09.438Z"} -->