406 lines
12 KiB
JavaScript
406 lines
12 KiB
JavaScript
// === APP BOOTSTRAP ===
|
|
|
|
const canvas = document.getElementById('game');
|
|
const ctx = canvas.getContext('2d');
|
|
const debugCanvas = document.getElementById('debug-canvas');
|
|
const debugCtx = debugCanvas.getContext('2d');
|
|
const menuOverlay = document.getElementById('menu-overlay');
|
|
const debugOverlay = document.getElementById('debug-overlay');
|
|
|
|
// Expose canvas contexts and DOM elements to window for other modules
|
|
window.ctx = ctx;
|
|
window.debugCtx = debugCtx;
|
|
window.menuOverlay = menuOverlay;
|
|
|
|
// Debug mode
|
|
const debugMode = new URLSearchParams(window.location.search).has('debug');
|
|
let debugVisible = false;
|
|
|
|
// Screen state
|
|
let currentScreen = SCREEN_TITLE;
|
|
let transitionProgress = 1; // 1 = fully visible, 0 = fully faded
|
|
let transitioning = false;
|
|
let targetScreen = null;
|
|
|
|
// Game loop timing
|
|
let lastTime = 0;
|
|
let animFrameId = null;
|
|
|
|
// === SCREEN MANAGEMENT ===
|
|
|
|
function switchScreen(screen) {
|
|
if (transitioning) return;
|
|
transitioning = true;
|
|
targetScreen = screen;
|
|
transitionProgress = 1;
|
|
}
|
|
|
|
function updateTransition(dt) {
|
|
if (!transitioning) return;
|
|
transitionProgress -= dt / TRANSITION_DURATION;
|
|
if (transitionProgress <= 0) {
|
|
transitionProgress = 1;
|
|
transitioning = false;
|
|
currentScreen = targetScreen;
|
|
targetScreen = null;
|
|
onScreenChange(currentScreen);
|
|
}
|
|
}
|
|
|
|
function onScreenChange(screen) {
|
|
menuOverlay.innerHTML = '';
|
|
menuOverlay.classList.remove('active');
|
|
inputSystem.clearKeys();
|
|
|
|
switch (screen) {
|
|
case SCREEN_TITLE:
|
|
renderTitleScreen(menuOverlay);
|
|
menuOverlay.classList.add('active');
|
|
break;
|
|
case SCREEN_DUNGEON_SELECT:
|
|
renderDungeonSelect(menuOverlay, saveData, onDungeonSelected);
|
|
menuOverlay.classList.add('active');
|
|
break;
|
|
case SCREEN_PLAYING:
|
|
if (!gameState.currentDungeon) return;
|
|
loadDungeonRoom(gameState.currentDungeon, gameState.currentRoomIdx);
|
|
break;
|
|
case SCREEN_DEAD:
|
|
renderDeathScreen(menuOverlay, gameState);
|
|
menuOverlay.classList.add('active');
|
|
break;
|
|
case SCREEN_DUNGEON_COMPLETE:
|
|
renderDungeonCompleteScreen(menuOverlay, gameState);
|
|
menuOverlay.classList.add('active');
|
|
break;
|
|
case SCREEN_GAME_COMPLETE:
|
|
renderGameCompleteScreen(menuOverlay, gameState);
|
|
menuOverlay.classList.add('active');
|
|
break;
|
|
case SCREEN_INVENTORY:
|
|
renderInventoryOverlay(menuOverlay, gameState);
|
|
menuOverlay.classList.add('active');
|
|
break;
|
|
}
|
|
}
|
|
|
|
function onDungeonSelected(dungeonIdx) {
|
|
gameState.currentDungeon = dungeonIdx;
|
|
gameState.currentRoomIdx = 0;
|
|
gameState.dungeonRooms = generateDungeon(DUNGEONS[dungeonIdx]);
|
|
gameState.player.hp = gameState.player.maxHp;
|
|
gameState.player.invincibleTimer = 1000;
|
|
switchScreen(SCREEN_PLAYING);
|
|
}
|
|
|
|
// === DEBUG ===
|
|
|
|
function toggleDebug() {
|
|
debugVisible = !debugVisible;
|
|
debugCanvas.classList.toggle('visible', debugVisible);
|
|
debugOverlay.classList.toggle('visible', debugVisible);
|
|
}
|
|
|
|
function renderDebug() {
|
|
if (!debugVisible) return;
|
|
debugCtx.clearRect(0, 0, CANVAS_W, CANVAS_H);
|
|
debugCtx.fillStyle = '#0F0';
|
|
debugCtx.font = '10px monospace';
|
|
|
|
let y = 10;
|
|
const lines = [];
|
|
lines.push(`Screen: ${currentScreen}`);
|
|
lines.push(`Dungeon: ${gameState.currentDungeon !== null ? DUNGEONS[gameState.currentDungeon].name : 'none'}`);
|
|
lines.push(`Room: ${gameState.currentRoomIdx}/${gameState.dungeonRooms ? gameState.dungeonRooms.length - 1 : 0}`);
|
|
lines.push(`Player HP: ${gameState.player.hp}/${gameState.player.maxHp}`);
|
|
lines.push(`Weapons: ${gameState.player.weapons.map(w => w.name).join(', ')}`);
|
|
lines.push(`Active: ${gameState.player.activeWeapon ? gameState.player.activeWeapon.name : 'none'}`);
|
|
lines.push(`Enemies: ${gameState.enemies.length}`);
|
|
lines.push(`Particles: ${gameState.particles ? gameState.particles.length : 0}`);
|
|
|
|
if (gameState.currentDungeon !== null && gameState.dungeonRooms && gameState.currentRoom < gameState.dungeonRooms.length) {
|
|
const room = gameState.currentRoom;
|
|
lines.push(`Room type: ${room.type}`);
|
|
lines.push(`Room enemies: ${room.enemies.length}`);
|
|
lines.push(`Exit open: ${gameState.exitOpen}`);
|
|
}
|
|
|
|
for (const line of lines) {
|
|
debugCtx.fillText(line, 5, y);
|
|
y += 12;
|
|
}
|
|
|
|
// Draw collision boxes on debug canvas
|
|
if (currentScreen === SCREEN_PLAYING && gameState.layout) {
|
|
debugCtx.strokeStyle = 'rgba(0,255,0,0.3)';
|
|
debugCtx.lineWidth = 0.5;
|
|
for (let y2 = 0; y2 < ROWS; y2++) {
|
|
for (let x = 0; x < COLS; x++) {
|
|
if (gameState.layout[y2][x] === TILE_TYPES.FLOOR) {
|
|
debugCtx.strokeRect(x * TILE, y2 * TILE, TILE, TILE);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// === CONSOLE API ===
|
|
|
|
window._genDungeon = function (id) {
|
|
const dungeon = DUNGEONS[id];
|
|
if (!dungeon) { console.error(`Dungeon ${id} not found`); return null; }
|
|
const rooms = generateDungeon(dungeon);
|
|
console.log(`Generated ${rooms.length} rooms for "${dungeon.name}" (seed: ${dungeon.seed})`);
|
|
console.table(rooms.map((r, i) => ({
|
|
idx: i, type: r.type, enemies: r.enemies.length, puzzles: r.puzzles ? r.puzzles.length : 0,
|
|
items: r.chests ? r.chests.length : 0, hasBoss: r.boss !== undefined
|
|
})));
|
|
return rooms;
|
|
};
|
|
|
|
window.spawnEnemy = function (type) {
|
|
if (currentScreen !== SCREEN_PLAYING) { console.error('Not in game'); return; }
|
|
const def = ENEMY_TYPES.find(e => e.name === type || e.id === type);
|
|
if (!def) { console.error(`Enemy type "${type}" not found`); return; }
|
|
const enemy = createEnemyInstance(def, gameState.player.x + 2, gameState.player.y);
|
|
gameState.enemies.push(enemy);
|
|
console.log(`Spawned ${def.name} at (${enemy.x}, ${enemy.y})`);
|
|
};
|
|
|
|
window.giveWeapon = function (name) {
|
|
if (currentScreen !== SCREEN_PLAYING) { console.error('Not in game'); return; }
|
|
const def = WEAPONS.find(w => w.name === name);
|
|
if (!def) { console.error(`Weapon "${name}" not found`); return; }
|
|
if (!gameState.player.weapons.find(w => w.name === def.name)) {
|
|
gameState.player.weapons.push({ ...def });
|
|
console.log(`Added ${def.name} to inventory`);
|
|
} else {
|
|
console.log(`${def.name} already in inventory`);
|
|
}
|
|
};
|
|
|
|
window.teleportToRoom = function (idx) {
|
|
if (currentScreen !== SCREEN_PLAYING || !gameState.dungeonRooms) {
|
|
console.error('Not in game');
|
|
return;
|
|
}
|
|
if (idx < 0 || idx >= gameState.dungeonRooms.length) {
|
|
console.error(`Room index ${idx} out of range [0, ${gameState.dungeonRooms.length - 1}]`);
|
|
return;
|
|
}
|
|
gameState.currentRoomIdx = idx;
|
|
loadDungeonRoom(gameState.currentDungeon, idx);
|
|
console.log(`Teleported to room ${idx}`);
|
|
};
|
|
|
|
window.dumpState = function () {
|
|
console.log('=== Game State ===');
|
|
console.log('Screen:', currentScreen);
|
|
console.log('Dungeon:', gameState.currentDungeon !== null ? DUNGEONS[gameState.currentDungeon].name : 'none');
|
|
console.log('Room:', gameState.currentRoomIdx);
|
|
console.log('Player:', JSON.stringify(gameState.player));
|
|
console.log('Enemies:', gameState.enemies.length);
|
|
console.log('Particles:', gameState.particles ? gameState.particles.length : 0);
|
|
};
|
|
|
|
// Keyboard shortcut for debug
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.code === 'F1') { e.preventDefault(); toggleDebug(); }
|
|
if (e.code === 'F2' && currentScreen === SCREEN_PLAYING) {
|
|
e.preventDefault();
|
|
if (gameState.exitOpen || gameState.currentRoomIdx >= (gameState.dungeonRooms ? gameState.dungeonRooms.length - 1 : 0)) {
|
|
if (gameState.currentRoomIdx >= (gameState.dungeonRooms ? gameState.dungeonRooms.length - 1 : 0)) {
|
|
completeDungeon();
|
|
}
|
|
} else {
|
|
gameState.exitOpen = true;
|
|
showMessage('Debug: Door opened');
|
|
tryExit();
|
|
}
|
|
}
|
|
if (e.code === 'F3' && currentScreen === SCREEN_PLAYING) {
|
|
e.preventDefault();
|
|
gameState.player.invincible = true;
|
|
console.log('God mode enabled');
|
|
}
|
|
if (e.code === 'F4' && currentScreen === SCREEN_PLAYING) {
|
|
e.preventDefault();
|
|
spawnEnemy('goblin');
|
|
}
|
|
});
|
|
|
|
// === MAIN GAME LOOP ===
|
|
|
|
function gameLoop(timestamp) {
|
|
const dt = Math.min(timestamp - lastTime, 50); // Cap delta to prevent spiral of death
|
|
lastTime = timestamp;
|
|
|
|
// Update transition
|
|
updateTransition(dt);
|
|
|
|
// Screen-specific updates
|
|
if (currentScreen === SCREEN_PLAYING && !transitioning) {
|
|
updateGame(dt);
|
|
}
|
|
|
|
// Update input
|
|
inputSystem.update(dt);
|
|
|
|
// Render
|
|
render();
|
|
|
|
// Debug overlay
|
|
renderDebug();
|
|
|
|
animFrameId = requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
function updateGame(dt) {
|
|
// Update player timers
|
|
if (gameState.player.moveTimer > 0) gameState.player.moveTimer -= dt;
|
|
if (gameState.player.attackTimer > 0) gameState.player.attackTimer -= dt;
|
|
if (gameState.player.attackAnim > 0) gameState.player.attackAnim -= dt;
|
|
if (gameState.player.hitTimer > 0) gameState.player.hitTimer -= dt;
|
|
if (gameState.player.invincibleTimer > 0) gameState.player.invincibleTimer -= dt;
|
|
else if (!gameState.player.invincible) gameState.player.invincibleTimer = 0;
|
|
if (gameState.messageTimer > 0) gameState.messageTimer -= dt;
|
|
|
|
// Update particles
|
|
if (gameState.particles) {
|
|
for (let i = gameState.particles.length - 1; i >= 0; i--) {
|
|
const p = gameState.particles[i];
|
|
p.life -= dt;
|
|
if (p.life <= 0) {
|
|
gameState.particles.splice(i, 1);
|
|
continue;
|
|
}
|
|
p.x += p.vx * (dt / 1000);
|
|
p.y += p.vy * (dt / 1000);
|
|
p.vy += (p.gravity || 0) * (dt / 1000);
|
|
p.alpha = (p.life / p.maxLife) * (p.maxAlpha || 1);
|
|
}
|
|
if (gameState.particles.length > MAX_PARTICLES) {
|
|
gameState.particles.splice(0, gameState.particles.length - MAX_PARTICLES);
|
|
}
|
|
}
|
|
|
|
// Handle movement
|
|
if (gameState.player.moveTimer <= 0 && !gameState.player.invincibleTimer) {
|
|
const move = inputSystem.getMove();
|
|
if (move) {
|
|
tryMove(move.dx, move.dy);
|
|
gameState.player.moveTimer = MOVE_COOLDOWN;
|
|
}
|
|
}
|
|
|
|
// Handle actions
|
|
if (inputSystem.getAction('attack') && gameState.player.attackTimer <= 0) {
|
|
tryAttack();
|
|
}
|
|
if (inputSystem.getAction('interact')) {
|
|
tryInteract();
|
|
}
|
|
if (inputSystem.getAction('menu') && currentScreen === SCREEN_PLAYING) {
|
|
switchScreen(SCREEN_INVENTORY);
|
|
}
|
|
if (inputSystem.getAction('back') && currentScreen === SCREEN_INVENTORY) {
|
|
switchScreen(SCREEN_PLAYING);
|
|
}
|
|
|
|
// Update enemies
|
|
updateEnemies(dt);
|
|
|
|
// Check death
|
|
if (gameState.player.hp <= 0 && !gameState.player.invincible) {
|
|
gameState.player.hp = 0;
|
|
switchScreen(SCREEN_DEAD);
|
|
}
|
|
}
|
|
|
|
// === RENDERING ===
|
|
|
|
function render() {
|
|
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
|
|
|
|
// Draw black background
|
|
ctx.fillStyle = '#000';
|
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
|
|
if (currentScreen === SCREEN_PLAYING) {
|
|
renderGame();
|
|
}
|
|
|
|
// Draw transition overlay
|
|
if (transitioning || transitionProgress < 1) {
|
|
ctx.fillStyle = `rgba(0,0,0,${1 - transitionProgress})`;
|
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
}
|
|
}
|
|
|
|
function renderGame() {
|
|
if (!gameState.layout) return;
|
|
const theme = DUNGEONS[gameState.currentDungeon] ? DUNGEONS[gameState.currentDungeon].theme : 'stone';
|
|
const themeData = THEMES[theme] || THEMES.stone;
|
|
|
|
renderFloor(themeData);
|
|
renderTargets();
|
|
renderWalls(themeData);
|
|
renderTorches(themeData);
|
|
renderDoors();
|
|
renderSoftwalls();
|
|
renderLava();
|
|
renderWater();
|
|
renderTraps();
|
|
renderLevers();
|
|
renderSwitches();
|
|
renderRocks();
|
|
renderChests();
|
|
renderItems();
|
|
renderEnemies();
|
|
if (gameState.currentBoss) renderBoss(gameState.currentBoss);
|
|
renderPlayer();
|
|
renderAttackAnim();
|
|
renderParticles();
|
|
|
|
// Hit flash overlay
|
|
if (gameState.player.hitTimer > 0) {
|
|
ctx.fillStyle = COLORS.hitFlash;
|
|
ctx.globalAlpha = gameState.player.hitTimer / HIT_FLASH;
|
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
renderMessages();
|
|
renderHUD();
|
|
}
|
|
|
|
// === INITIALIZATION ===
|
|
|
|
function init() {
|
|
// Initialize subsystems
|
|
initKeyboard();
|
|
initActions();
|
|
initGameState();
|
|
loadSaveData();
|
|
// Expose inputSystem and gameState to window for other modules
|
|
window.inputSystem = inputSystem;
|
|
window.gameState = gameState;
|
|
|
|
// Start with title screen
|
|
currentScreen = SCREEN_TITLE;
|
|
menuOverlay.innerHTML = '';
|
|
renderTitleScreen(menuOverlay);
|
|
menuOverlay.classList.add('active');
|
|
|
|
// Start game loop
|
|
lastTime = performance.now();
|
|
requestAnimationFrame(gameLoop);
|
|
|
|
console.log('Zelda Dungeon Quest initialized');
|
|
console.log('Debug mode:', debugMode ? 'ON (?debug=1)' : 'OFF');
|
|
console.log('Controls: F1=debug, F2=skip room, F3=god mode, F4=spawn enemy');
|
|
console.log('Console API: generateDungeon(id), spawnEnemy(type), giveWeapon(name), teleportToRoom(idx), dumpState()');
|
|
}
|
|
|
|
// Boot!
|
|
init(); |