RPG
Gemini 3.7 Flash · full

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 111,085 bytes · SHA-256 85677dfbf644
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Chronicles of the Sunken Spire - Action RPG</title>
<style>
:root {
--bg-dark: #0a0c10;
--panel-bg: rgba(16, 22, 34, 0.92);
--border-gold: #c69b3f;
--border-gold-glow: #e6c66d;
--accent-red: #d32f2f;
--accent-cyan: #26c6da;
--accent-green: #66bb6a;
--text-gold: #f5dfa2;
--text-main: #e0e6ed;
--font-ui: 'Cinzel', 'Trajan Pro', 'Georgia', serif;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
* {
box-sizing: border-box;
user-select: none;
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
body {
background-color: var(--bg-dark);
color: var(--text-main);
font-family: var(--font-sans);
overflow: hidden;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
#game-wrapper {
position: relative;
width: 100%;
height: 100%;
max-width: 1280px;
max-height: 720px;
background: #000;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.9), 0 0 15px rgba(198, 155, 63, 0.2);
overflow: hidden;
}
canvas#gameCanvas {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/* HUD OVERLAY */
#hud {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px 20px;
font-family: var(--font-ui);
}
.hud-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.hud-left {
display: flex;
flex-direction: column;
gap: 8px;
}
.heart-container-bar {
display: flex;
gap: 6px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.8));
align-items: center;
}
.heart-icon {
width: 28px;
height: 28px;
transition: transform 0.15s ease;
}
.heart-icon.pulse {
animation: heartBeat 0.4s ease;
}
@keyframes heartBeat {
0% { transform: scale(1); }
50% { transform: scale(1.35); }
100% { transform: scale(1); }
}
.stamina-bar-wrap {
width: 150px;
height: 10px;
background: rgba(0, 0, 0, 0.6);
border: 1.5px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
overflow: hidden;
}
.stamina-fill {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #2e7d32, #66bb6a);
transition: width 0.08s linear;
}
.hud-stats {
display: flex;
gap: 16px;
margin-top: 4px;
font-size: 14px;
color: var(--text-gold);
text-shadow: 0 1px 3px #000;
}
.stat-badge {
background: rgba(16, 24, 38, 0.85);
border: 1px solid var(--border-gold);
padding: 4px 10px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.hud-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.minimap-card {
width: 130px;
height: 100px;
background: rgba(10, 15, 25, 0.85);
border: 2px solid var(--border-gold);
border-radius: 6px;
position: relative;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
}
.minimap-card canvas {
width: 100%;
height: 100%;
}
.zone-name {
position: absolute;
bottom: 2px;
right: 4px;
font-size: 9px;
color: var(--text-gold);
letter-spacing: 0.5px;
text-shadow: 0 1px 2px #000;
}
.hud-bottom {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.hud-weapons {
display: flex;
gap: 10px;
}
.equip-slot {
width: 54px;
height: 54px;
background: rgba(16, 22, 34, 0.88);
border: 2px solid var(--border-gold);
border-radius: 8px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 10px rgba(0,0,0,0.7);
}
.equip-slot.active {
border-color: #ffd54f;
box-shadow: 0 0 12px rgba(255, 213, 79, 0.6);
}
.equip-slot .key-hint {
position: absolute;
top: 2px;
left: 4px;
font-size: 10px;
color: #a0b0c0;
}
.equip-slot .ammo-badge {
position: absolute;
bottom: 2px;
right: 4px;
font-size: 11px;
color: #fff;
font-weight: bold;
text-shadow: 0 1px 3px #000;
}
.boss-health-bar-container {
position: absolute;
top: 60px;
left: 50%;
transform: translateX(-50%);
width: 450px;
background: rgba(15, 20, 30, 0.9);
border: 2px solid #b71c1c;
border-radius: 6px;
padding: 6px 12px;
box-shadow: 0 0 20px rgba(183, 28, 28, 0.5);
display: none;
flex-direction: column;
gap: 4px;
text-align: center;
}
.boss-health-bar-container.active {
display: flex;
}
.boss-name {
font-size: 14px;
color: #ff8a80;
letter-spacing: 1px;
}
.boss-hp-bg {
width: 100%;
height: 14px;
background: #2b0b0b;
border-radius: 3px;
overflow: hidden;
}
.boss-hp-fill {
width: 100%;
height: 100%;
background: linear-gradient(90deg, #d32f2f, #ff5252);
transition: width 0.15s ease-out;
}
/* MODALS AND OVERLAYS */
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(5, 8, 14, 0.82);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
pointer-events: auto;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s;
}
.modal-overlay.open {
opacity: 1;
visibility: visible;
}
.modal-box {
background: var(--panel-bg);
border: 2px solid var(--border-gold);
border-radius: 12px;
box-shadow: 0 0 30px rgba(0,0,0,0.9), 0 0 15px rgba(198, 155, 63, 0.25);
padding: 24px 28px;
max-width: 600px;
width: 90%;
color: var(--text-main);
font-family: var(--font-sans);
position: relative;
}
.modal-title {
font-family: var(--font-ui);
font-size: 22px;
color: var(--text-gold);
border-bottom: 1px solid rgba(198, 155, 63, 0.3);
padding-bottom: 10px;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.close-btn {
background: transparent;
border: none;
color: #a0b0c0;
font-size: 22px;
cursor: pointer;
line-height: 1;
transition: color 0.15s;
}
.close-btn:hover { color: #fff; }
/* INVENTORY GRID */
.inventory-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.inv-item-card {
background: rgba(26, 35, 52, 0.7);
border: 1px solid rgba(198, 155, 63, 0.4);
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, background-color 0.15s;
position: relative;
}
.inv-item-card:hover {
border-color: var(--border-gold-glow);
background: rgba(36, 48, 70, 0.85);
transform: translateY(-2px);
}
.inv-item-card.equipped {
border-color: #66bb6a;
box-shadow: 0 0 10px rgba(102, 187, 106, 0.35);
}
.inv-item-card .item-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.inv-item-card .item-name {
font-size: 12px;
text-align: center;
font-weight: 500;
}
.inv-item-card .item-count {
position: absolute;
top: 4px;
right: 6px;
font-size: 11px;
color: #ffca28;
font-weight: bold;
}
.item-desc-panel {
background: rgba(10, 14, 22, 0.6);
border-radius: 6px;
padding: 12px 14px;
font-size: 13px;
line-height: 1.4;
min-height: 60px;
border-left: 3px solid var(--border-gold);
color: #c9d5e3;
}
/* SHOP UI */
.shop-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 320px;
overflow-y: auto;
padding-right: 4px;
margin-bottom: 16px;
}
.shop-item-row {
background: rgba(22, 30, 46, 0.8);
border: 1px solid rgba(198, 155, 63, 0.3);
border-radius: 8px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.15s, border-color 0.15s;
}
.shop-item-row:hover {
background: rgba(30, 42, 64, 0.95);
border-color: var(--border-gold);
}
.shop-item-info {
display: flex;
align-items: center;
gap: 12px;
}
.btn-buy {
background: linear-gradient(135deg, #b8860b, #d4af37);
border: 1px solid #ffe082;
color: #1a1200;
font-family: var(--font-ui);
font-weight: bold;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
transition: filter 0.15s, transform 0.1s;
}
.btn-buy:hover {
filter: brightness(1.2);
transform: scale(1.04);
}
.btn-buy:disabled {
background: #3e4450;
border-color: #555e6d;
color: #8892a0;
cursor: not-allowed;
transform: none;
}
/* DIALOGUE BOX */
#dialogue-overlay {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
width: 85%;
max-width: 780px;
background: rgba(14, 19, 29, 0.94);
border: 2px solid var(--border-gold);
border-radius: 10px;
padding: 16px 20px;
z-index: 40;
box-shadow: 0 8px 24px rgba(0,0,0,0.8);
display: none;
pointer-events: auto;
}
#dialogue-overlay.active {
display: flex;
gap: 16px;
animation: dialogSlide 0.2s ease-out;
}
@keyframes dialogSlide {
from { opacity: 0; transform: translate(-50%, 15px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
.dialog-portrait {
width: 64px;
height: 64px;
border: 2px solid var(--border-gold);
border-radius: 8px;
background: #1c2638;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-content {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.dialog-speaker {
font-family: var(--font-ui);
font-size: 15px;
color: var(--text-gold);
margin-bottom: 4px;
}
.dialog-text {
font-size: 14px;
line-height: 1.45;
color: #e2e8f0;
min-height: 38px;
}
.dialog-hint {
align-self: flex-end;
font-size: 11px;
color: #8fa1b8;
font-family: var(--font-ui);
margin-top: 4px;
}
/* BANNER NOTIFICATION */
#banner-notify {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(14, 20, 32, 0.92);
border: 2px solid var(--border-gold-glow);
padding: 10px 24px;
border-radius: 30px;
color: var(--text-gold);
font-family: var(--font-ui);
font-size: 16px;
letter-spacing: 1px;
box-shadow: 0 0 20px rgba(230, 198, 109, 0.4);
pointer-events: none;
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
z-index: 45;
}
#banner-notify.show {
opacity: 1;
transform: translate(-50%, -8px);
}
/* TITLE & GAME OVER SCREENS */
.screen-overlay {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, #172338 0%, #080c14 100%);
z-index: 60;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
}
.screen-title {
font-family: var(--font-ui);
font-size: 38px;
color: #f7e7b4;
text-shadow: 0 0 15px rgba(245, 223, 162, 0.5), 0 4px 8px #000;
margin-bottom: 8px;
letter-spacing: 2px;
}
.screen-subtitle {
font-size: 16px;
color: #94a3b8;
margin-bottom: 30px;
max-width: 480px;
line-height: 1.5;
}
.btn-primary {
background: linear-gradient(135deg, #c69b3f, #e6c66d);
border: 1px solid #fff;
color: #1a1200;
font-family: var(--font-ui);
font-size: 16px;
font-weight: bold;
padding: 12px 32px;
border-radius: 8px;
cursor: pointer;
box-shadow: 0 4px 15px rgba(0,0,0,0.5), 0 0 20px rgba(230, 198, 109, 0.4);
transition: transform 0.15s, filter 0.15s;
margin: 6px;
}
.btn-primary:hover {
transform: translateY(-2px) scale(1.03);
filter: brightness(1.15);
}
.controls-help-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 18px;
background: rgba(10, 15, 24, 0.6);
border: 1px solid rgba(198, 155, 63, 0.3);
padding: 14px 20px;
border-radius: 8px;
margin-bottom: 24px;
font-size: 13px;
text-align: left;
color: #cbd5e1;
}
.controls-help-grid strong {
color: var(--text-gold);
}
/* TOUCH CONTROLS (for mobile / tablet) */
#touch-controls {
position: absolute;
inset: 0;
pointer-events: none;
display: none;
z-index: 30;
}
.touch-stick-zone {
position: absolute;
bottom: 20px;
left: 20px;
width: 130px;
height: 130px;
background: rgba(255, 255, 255, 0.08);
border: 2px dashed rgba(255, 255, 255, 0.2);
border-radius: 50%;
pointer-events: auto;
touch-action: none;
}
.touch-stick-knob {
position: absolute;
top: 40px;
left: 40px;
width: 50px;
height: 50px;
background: rgba(230, 198, 109, 0.5);
border: 2px solid var(--border-gold);
border-radius: 50%;
pointer-events: none;
}
.touch-buttons-zone {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: auto;
}
.touch-btn-row {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.touch-action-btn {
width: 54px;
height: 54px;
border-radius: 50%;
background: rgba(16, 24, 38, 0.85);
border: 2px solid var(--border-gold);
color: var(--text-gold);
font-family: var(--font-ui);
font-size: 14px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
}
.touch-action-btn:active {
background: rgba(230, 198, 109, 0.4);
transform: scale(0.92);
}
@media (hover: none) and (pointer: coarse) {
#touch-controls { display: block; }
}
</style>
</head>
<body>
<div id="game-wrapper">
<!-- Core Rendering Canvas -->
<canvas id="gameCanvas" width="1280" height="720"></canvas>
<!-- HUD Layer -->
<div id="hud">
<div class="hud-top">
<div class="hud-left">
<div class="heart-container-bar" id="heart-bar" title="Health"></div>
<div class="stamina-bar-wrap" title="Stamina / Dash Energy">
<div class="stamina-fill" id="stamina-fill"></div>
</div>
<div class="hud-stats">
<div class="stat-badge" title="Glimmer Gold">
<svg width="14" height="14" viewBox="0 0 24 24" fill="#ffd700"><polygon points="12 2 15 8 22 9 17 14 18 21 12 17 6 21 7 14 2 9 9 8 12 2"/></svg>
<span id="gold-count">0</span>
</div>
<div class="stat-badge" title="Ancient Keys">
<svg width="14" height="14" viewBox="0 0 24 24" fill="#e0e0e0"><path d="M7 14A5 5 0 1 1 12 9a5 5 0 0 1-5 5zm0-8a3 3 0 1 0 3 3 3 3 0 0 0-3-3zm14 9.5l-6-6V7h-2v2.5l5.5 5.5H19v2h2v-1.5z"/></svg>
<span id="key-count">0</span>
</div>
</div>
</div>
<div class="hud-right">
<div class="minimap-card">
<canvas id="minimapCanvas" width="130" height="100"></canvas>
<div class="zone-name" id="zone-display-name">Whispering Grove</div>
</div>
</div>
</div>
<!-- Boss Health Bar (When active) -->
<div class="boss-health-bar-container" id="boss-bar-wrap">
<div class="boss-name" id="boss-name-label">Crypt Warden</div>
<div class="boss-hp-bg">
<div class="boss-hp-fill" id="boss-hp-fill"></div>
</div>
</div>
<div class="hud-bottom">
<div class="hud-weapons">
<div class="equip-slot active" id="slot-sword" title="Melee Sword [LMB / Space]">
<span class="key-hint">LMB</span>
<div id="sword-icon-container"></div>
</div>
<div class="equip-slot" id="slot-bow" style="display:none;" title="Mystic Bow [RMB / F]">
<span class="key-hint">RMB</span>
<div id="bow-icon-container"></div>
<span class="ammo-badge" id="arrow-count">∞</span>
</div>
<div class="equip-slot" id="slot-bomb" title="Bomb / Urn [Q]">
<span class="key-hint">Q</span>
<div id="bomb-icon-container"></div>
<span class="ammo-badge" id="bomb-count">0</span>
</div>
<div class="equip-slot" id="slot-inv" style="cursor:pointer; pointer-events:auto;" onclick="game.toggleInventory()" title="Open Inventory [I / Tab]">
<span class="key-hint">I</span>
<svg width="26" height="26" viewBox="0 0 24 24" fill="#c69b3f"><path d="M20 6h-4V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2zm-10-2h4v2h-4V4zm10 16H4V8h16v12z"/></svg>
</div>
</div>
<div style="color: #94a3b8; font-size: 11px; text-shadow: 0 1px 3px #000; letter-spacing: 0.5px;">
[SPACE/LMB] Attack | [SHIFT] Dash | [E] Interact/Save | [I] Bag
</div>
</div>
</div>
<!-- Notification Banner -->
<div id="banner-notify">Progress Saved at Ancient Shrine</div>
<!-- Dialogue Overlay -->
<div id="dialogue-overlay">
<div class="dialog-portrait" id="dialog-portrait"></div>
<div class="dialog-content">
<div>
<div class="dialog-speaker" id="dialog-speaker">Elder Orin</div>
<div class="dialog-text" id="dialog-text">The Spire has awakened... Take this blade and restore the seals.</div>
</div>
<div class="dialog-hint">Press [E] or [Space] to continue</div>
</div>
</div>
<!-- Inventory Modal -->
<div class="modal-overlay" id="inventory-modal">
<div class="modal-box">
<div class="modal-title">
<span>Adventurer's Satchel</span>
<button class="close-btn" onclick="game.toggleInventory()">×</button>
</div>
<div class="inventory-grid" id="inventory-grid"></div>
<div class="item-desc-panel" id="inv-item-description">
Select an item to view properties, bonuses, and equip status.
</div>
</div>
</div>
<!-- Shop Modal -->
<div class="modal-overlay" id="shop-modal">
<div class="modal-box">
<div class="modal-title">
<span>Outpost Merchant</span>
<button class="close-btn" onclick="game.closeShop()">×</button>
</div>
<div class="shop-list" id="shop-items-container"></div>
<div style="font-size: 13px; color: var(--text-gold); display:flex; justify-content:space-between; align-items:center;">
<span>Your Gold: <strong id="shop-gold-display">0</strong> G</span>
<span style="color:#94a3b8; font-size:11px;">Restocks whenever you return from exploration</span>
</div>
</div>
</div>
<!-- Title / Start Screen -->
<div class="screen-overlay" id="title-screen">
<div class="screen-title">CHRONICLES OF THE SUNKEN SPIRE</div>
<div class="screen-subtitle">
Explore ancient forgotten realms, master blade & bow, conquer guardian bosses, and shatter the seal of the Void Sovereign.
</div>
<div class="controls-help-grid">
<div><strong>WASD / Arrow Keys</strong>: Move Character</div>
<div><strong>Left Click / Space</strong>: Sword Attack</div>
<div><strong>Right Click / F</strong>: Ranged Bow</div>
<div><strong>Space / Shift</strong>: Evasive Dash Roll</div>
<div><strong>E</strong>: Interact / Talk / Save Shrine</div>
<div><strong>Q / 1-3</strong>: Use Bomb & Potions</div>
<div><strong>I / Tab</strong>: Inventory & Equipment</div>
<div><strong>Audio Synth</strong>: Auto-tuned Dynamic Audio</div>
</div>
<button class="btn-primary" id="btn-start-game">BEGIN ADVENTURE</button>
<button class="btn-primary" id="btn-continue-game" style="display:none; background: #263238; border-color:#546e7a; color:#cfd8dc;">CONTINUE CHECKPOINT</button>
</div>
<!-- Game Over Screen -->
<div class="screen-overlay" id="gameover-screen" style="display:none;">
<div class="screen-title" style="color: #ef5350;">YOU HAVE FALLEN</div>
<div class="screen-subtitle">
The void energy overtook your spirit. Awaken at the last consecrated shrine with renewed determination.
</div>
<button class="btn-primary" id="btn-respawn">RESPAWN AT SAVE SHRINE</button>
</div>
<!-- Victory / Game Completed Screen -->
<div class="screen-overlay" id="victory-screen" style="display:none;">
<div class="screen-title" style="color: #ffd54f;">THE SPIRE IS LIBERATED!</div>
<div class="screen-subtitle" id="victory-stats-text">
You conquered all five heart containers, mastered the Stormcaller blade and Mystic bow, and sealed away the Void Sovereign forever!
</div>
<button class="btn-primary" onclick="location.reload()">PLAY AGAIN</button>
</div>
<!-- Touch Controls -->
<div id="touch-controls">
<div class="touch-stick-zone" id="touch-joystick">
<div class="touch-stick-knob" id="touch-knob"></div>
</div>
<div class="touch-buttons-zone">
<div class="touch-btn-row">
<div class="touch-action-btn" id="btn-touch-bow" style="display:none;">BOW</div>
<div class="touch-action-btn" id="btn-touch-bomb">BOMB</div>
<div class="touch-action-btn" id="btn-touch-interact">E</div>
</div>
<div class="touch-btn-row">
<div class="touch-action-btn" id="btn-touch-dash" style="background:#1e3a5f;">DASH</div>
<div class="touch-action-btn" id="btn-touch-attack" style="width:64px; height:64px; background:#6b2020;">ATK</div>
</div>
</div>
</div>
</div>
<script>
/* ==========================================================================
CHRONICLES OF THE SUNKEN SPIRE - ACTION-ADVENTURE ENGINE
========================================================================== */
// --- PROCEDURAL AUDIO SYNTHESIZER (WEB AUDIO API) ---
class SoundEngine {
constructor() {
this.ctx = null;
this.masterGain = null;
this.muted = false;
}
init() {
if (this.ctx) return;
const AudioCtx = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioCtx();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.28;
this.masterGain.connect(this.ctx.destination);
}
ensureState() {
if (!this.ctx) this.init();
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
}
playStep() {
this.ensureState();
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
osc.type = 'triangle';
osc.frequency.setValueAtTime(80 + Math.random() * 30, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(30, this.ctx.currentTime + 0.05);
filter.type = 'lowpass';
filter.frequency.value = 400;
gain.gain.setValueAtTime(0.04, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.05);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.06);
}
playAttack(tier = 1) {
this.ensureState();
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const now = this.ctx.currentTime;
if (tier === 1) {
// Rusty Slash
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(320, now);
osc.frequency.exponentialRampToValueAtTime(80, now + 0.12);
gain.gain.setValueAtTime(0.18, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.12);
} else if (tier === 2) {
// Sunforged Fire Slash
osc.type = 'triangle';
osc.frequency.setValueAtTime(540, now);
osc.frequency.exponentialRampToValueAtTime(140, now + 0.16);
gain.gain.setValueAtTime(0.24, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.16);
} else {
// Stormcaller Lightning Cleave
osc.type = 'square';
osc.frequency.setValueAtTime(680, now);
osc.frequency.exponentialRampToValueAtTime(160, now + 0.22);
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.22);
}
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(now);
osc.stop(now + 0.23);
}
playBow() {
this.ensureState();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(380, now);
osc.frequency.exponentialRampToValueAtTime(840, now + 0.08);
gain.gain.setValueAtTime(0.25, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(now);
osc.stop(now + 0.16);
}
playHit() {
this.ensureState();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(200, now);
osc.frequency.exponentialRampToValueAtTime(50, now + 0.09);
gain.gain.setValueAtTime(0.22, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.09);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(now);
osc.stop(now + 0.1);
}
playHurt() {
this.ensureState();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(140, now);
osc.frequency.exponentialRampToValueAtTime(40, now + 0.18);
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.18);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(now);
osc.stop(now + 0.2);
}
playCoin() {
this.ensureState();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(987, now); // B5
osc.frequency.setValueAtTime(1318, now + 0.07); // E6
gain.gain.setValueAtTime(0.18, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(now);
osc.stop(now + 0.26);
}
playPowerup() {
this.ensureState();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const notes = [440, 554, 659, 880, 1108];
notes.forEach((freq, idx) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
const t = now + idx * 0.06;
gain.gain.setValueAtTime(0.2, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.18);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(t);
osc.stop(t + 0.2);
});
}
playSave() {
this.ensureState();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const chords = [523.25, 659.25, 783.99, 1046.50];
chords.forEach((freq, idx) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.6 + idx * 0.1);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(now);
osc.stop(now + 0.8);
});
}
playDash() {
this.ensureState();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(280, now);
osc.frequency.exponentialRampToValueAtTime(600, now + 0.1);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(now);
osc.stop(now + 0.16);
}
playExplosion() {
this.ensureState();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(120, now);
osc.frequency.exponentialRampToValueAtTime(25, now + 0.35);
gain.gain.setValueAtTime(0.35, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.4);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(now);
osc.stop(now + 0.42);
}
}
const sounds = new SoundEngine();
// --- VECTOR & MATH UTILS ---
const dist = (x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1);
const clamp = (val, min, max) => Math.max(min, Math.min(max, val));
// --- PARTICLE ENGINE ---
class ParticleSystem {
constructor() {
this.particles = [];
}
emit(x, y, count = 5, options = {}) {
for (let i = 0; i < count; i++) {
const angle = options.angle !== undefined ? options.angle + (Math.random() - 0.5) * (options.spread || 0.8) : Math.random() * Math.PI * 2;
const speed = options.speed ? options.speed * (0.5 + Math.random() * 0.8) : 1 + Math.random() * 3;
this.particles.push({
x,
y,
vx: Math.cos(angle) * speed + (options.vx || 0),
vy: Math.sin(angle) * speed + (options.vy || 0),
size: options.size || (2 + Math.random() * 3),
color: options.color || '#ffd54f',
alpha: 1,
life: options.life || (0.3 + Math.random() * 0.4),
maxLife: options.life || (0.3 + Math.random() * 0.4),
shape: options.shape || 'circle'
});
}
}
update(dt) {
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i];
p.x += p.vx * 60 * dt;
p.y += p.vy * 60 * dt;
p.life -= dt;
p.alpha = Math.max(0, p.life / p.maxLife);
if (p.life <= 0) {
this.particles.splice(i, 1);
}
}
}
render(ctx, camera) {
for (const p of this.particles) {
const sx = p.x - camera.x;
const sy = p.y - camera.y;
ctx.save();
ctx.globalAlpha = p.alpha;
ctx.fillStyle = p.color;
if (p.shape === 'circle') {
ctx.beginPath();
ctx.arc(sx, sy, p.size, 0, Math.PI * 2);
ctx.fill();
} else if (p.shape === 'spark') {
ctx.fillRect(sx - p.size, sy - p.size * 0.4, p.size * 2, p.size * 0.8);
} else if (p.shape === 'ring') {
ctx.strokeStyle = p.color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(sx, sy, (1 - p.alpha) * 25, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
}
}
}
// --- ITEM & INVENTORY DATABASE ---
const ITEM_DB = {
// Consumables & Upgrades
potion_heal: {
id: 'potion_heal',
name: 'Elixir of Vitality',
type: 'consumable',
desc: 'Instantly restores 4 points of Health (2 full hearts).',
price: 35,
icon: '🧪',
use: (player) => {
if (player.hp >= player.maxHp) return false;
player.hp = Math.min(player.maxHp, player.hp + 4);
sounds.playPowerup();
game.notify('Restored 4 Health points!');
return true;
}
},
bomb_pack: {
id: 'bomb_pack',
name: 'Satchel of Fire Urns',
type: 'consumable',
desc: 'Explosive throwables that crack sealed walls & devastate armored foes.',
price: 30,
icon: '💣',
use: (player) => {
player.bombs += 3;
sounds.playPowerup();
game.notify('+3 Fire Urns added to Satchel!');
return true;
}
},
heart_container: {
id: 'heart_container',
name: 'Heart Vessel of Valor',
type: 'upgrade',
desc: 'Permanently increases maximum health by 1 full Heart Container (2 HP).',
price: 150,
icon: '💖',
use: (player) => {
if (player.heartContainers >= 5) {
game.notify('Maximum Heart Containers (5) reached!');
return false;
}
player.heartContainers++;
player.maxHp = player.heartContainers * 2;
player.hp = player.maxHp;
sounds.playPowerup();
game.notify(`Heart Capacity Expanded to ${player.heartContainers} Containers!`);
return true;
}
},
// 5 Accessories & Artifacts
swift_boots: {
id: 'swift_boots',
name: 'Swiftwind Boots',
type: 'accessory',
desc: 'Enchanted leather boots. Increases movement speed by 30% and reduces dash cooldown.',
price: 80,
icon: '👢',
bonus: { speedMult: 1.3, dashCooldownMult: 0.7 }
},
amulet_thorns: {
id: 'amulet_thorns',
name: 'Amulet of Thorns',
type: 'accessory',
desc: 'Spiked brass talisman. Reflects 50% melee damage back onto attackers.',
price: 110,
icon: '🧿',
bonus: { reflectDmg: true }
},
gold_ring: {
id: 'gold_ring',
name: 'Ring of the Alchemist',
type: 'accessory',
desc: 'Doubles currency drops from fallen foes and grass, with wider pickup vacuum.',
price: 90,
icon: '💍',
bonus: { goldMult: 2.0, magnetRadius: 90 }
},
iron_buckler: {
id: 'iron_buckler',
name: 'Iron Aegis Shield',
type: 'accessory',
desc: 'Sturdy buckler. Absorbs 1 damage point from all incoming frontal attacks.',
price: 120,
icon: '🛡️',
bonus: { defense: 1 }
},
berserk_talisman: {
id: 'berserk_talisman',
name: 'Bloodstone Talisman',
type: 'accessory',
desc: 'Infuses blade with ferocious fury. Increases weapon damage by +1 when HP is below 50%.',
price: 140,
icon: '🩸',
bonus: { berserk: true }
}
};
// --- WEAPON EVOLUTION TIERS ---
const WEAPONS = {
SWORD: [
{
tier: 1,
name: 'Rusty Broadsword',
damage: 1.0,
range: 48,
arc: Math.PI * 0.75,
speed: 0.28,
color: '#b0bec5',
trailColor: 'rgba(200, 215, 230, 0.4)',
desc: 'An aged iron blade. Basic reach and dependable balance.'
},
{
tier: 2,
name: 'Sunforged Broadsword',
damage: 2.0,
range: 58,
arc: Math.PI * 0.95,
speed: 0.22,
color: '#ff9800',
trailColor: 'rgba(255, 152, 0, 0.55)',
desc: 'Tempered in solar magma. Emits fiery sweeping slashes that ignite brush & monsters.'
},
{
tier: 3,
name: 'Stormcaller Greatblade',
damage: 3.5,
range: 72,
arc: Math.PI * 1.25,
speed: 0.18,
color: '#00e5ff',
trailColor: 'rgba(0, 229, 255, 0.65)',
desc: 'Crackles with celestial lightning. Devastating wide sweep and unleashed energy arcs!'
}
],
BOW: {
name: 'Mystic Quiver Bow',
damage: 1.5,
projectileSpeed: 520,
range: 450,
color: '#64ffda',
desc: 'Uncovered in the Sunken Crypts. Launches crystalline arrows that hit distant switches & snipers.'
}
};
// --- MAP ZONES DEFINITIONS ---
const ZONES = {
VILLAGE: 0,
OVERWORLD: 1,
CRYPT: 2,
SPIRE: 3,
SANCTUM: 4
};
const ZONE_CONFIGS = [
{
id: ZONES.VILLAGE,
name: 'Whispering Grove (Town)',
ambientColor: '#102214',
musicScale: 'peaceful',
width: 28,
height: 20
},
{
id: ZONES.OVERWORLD,
name: 'Verdant Wilds & Ancient Gates',
ambientColor: '#0b1c1b',
musicScale: 'adventure',
width: 36,
height: 26
},
{
id: ZONES.CRYPT,
name: 'Sunken Crypts (Mid-Game)',
ambientColor: '#0d111d',
musicScale: 'dungeon',
width: 34,
height: 24
},
{
id: ZONES.SPIRE,
name: 'Storm Peaks & Spire Ascent',
ambientColor: '#170f26',
musicScale: 'storm',
width: 36,
height: 28
},
{
id: ZONES.SANCTUM,
name: 'Sanctum of the Void Sovereign',
ambientColor: '#1d0a14',
musicScale: 'boss',
width: 30,
height: 22
}
];
// --- WORLD TILEMAP & ENTITY ENGINE ---
const TILE_SIZE = 40;
const TILES = {
GRASS: 0,
DIRT: 1,
STONE_FLOOR: 2,
WALL: 3,
WATER: 4,
DOOR: 5,
CRACKED_WALL: 6,
SHRINE: 7,
SHOP_CARPET: 8,
LAVA: 9,
VOID_FLOOR: 10
};
class World {
constructor(game) {
this.game = game;
this.currentZoneId = ZONES.VILLAGE;
this.zonesData = {};
this.initAllZones();
}
initAllZones() {
ZONE_CONFIGS.forEach(cfg => {
this.zonesData[cfg.id] = this.generateZone(cfg);
});
}
generateZone(cfg) {
const W = cfg.width;
const H = cfg.height;
const map = [];
for (let y = 0; y < H; y++) {
const row = [];
for (let x = 0; x < W; x++) {
// Border walls
if (x === 0 || x === W - 1 || y === 0 || y === H - 1) {
row.push(TILES.WALL);
} else {
if (cfg.id === ZONES.VILLAGE) {
row.push(Math.random() < 0.2 ? TILES.DIRT : TILES.GRASS);
} else if (cfg.id === ZONES.CRYPT || cfg.id === ZONES.SANCTUM) {
row.push(TILES.STONE_FLOOR);
} else if (cfg.id === ZONES.SPIRE) {
row.push(Math.random() < 0.15 ? TILES.LAVA : TILES.STONE_FLOOR);
} else {
row.push(Math.random() < 0.25 ? TILES.DIRT : TILES.GRASS);
}
}
}
map.push(row);
}
const props = [];
const enemies = [];
const npcs = [];
const interactables = [];
// Customize per zone
if (cfg.id === ZONES.VILLAGE) {
// Village Save Shrine
map[7][7] = TILES.SHRINE;
interactables.push({
type: 'shrine',
x: 7 * TILE_SIZE + 20,
y: 7 * TILE_SIZE + 20,
name: 'Grove Haven Shrine'
});
// Shop area
for (let sx = 18; sx <= 22; sx++) {
for (let sy = 5; sy <= 8; sy++) {
map[sy][sx] = TILES.SHOP_CARPET;
}
}
npcs.push({
x: 20 * TILE_SIZE + 20,
y: 6 * TILE_SIZE + 20,
name: 'Merchant Barnaby',
type: 'merchant',
portrait: '🧙♂️',
dialog: "Welcome, brave traveler! I stock vital elixirs, satchels, and heart blessings in exchange for shiny Glimmer gold."
});
// Elder NPC
npcs.push({
x: 10 * TILE_SIZE + 20,
y: 6 * TILE_SIZE + 20,
name: 'Elder Orin',
type: 'npc',
portrait: '👴',
dialog: "The Void Sovereign stirs atop the Sunken Spire. Seek the Sunforged Blade in the Wilds, master the Mystic Bow in the Crypts, and claim 5 Heart Containers to prevail!"
});
// Training Dummy
enemies.push(new Enemy(14 * TILE_SIZE, 12 * TILE_SIZE, 'dummy'));
// Bushes & Grass pots
for (let i = 0; i < 22; i++) {
const rx = 3 + Math.floor(Math.random() * (W - 6));
const ry = 3 + Math.floor(Math.random() * (H - 6));
if (map[ry][rx] === TILES.GRASS) {
props.push({ type: 'bush', x: rx * TILE_SIZE + 20, y: ry * TILE_SIZE + 20, hp: 1 });
}
}
// Exit to Overworld (East Gate)
map[10][W - 1] = TILES.DOOR;
map[11][W - 1] = TILES.DOOR;
interactables.push({
type: 'gate',
x: (W - 1) * TILE_SIZE,
y: 10.5 * TILE_SIZE,
targetZone: ZONES.OVERWORLD,
targetX: 2.5 * TILE_SIZE,
targetY: 10.5 * TILE_SIZE
});
} else if (cfg.id === ZONES.OVERWORLD) {
// Overworld: Gates, Slimes, Goblins, Chests, Cracked Wall for Sunforged Blade (Weapon Upgrade 1)
map[10][0] = TILES.DOOR;
map[11][0] = TILES.DOOR;
interactables.push({
type: 'gate',
x: 0,
y: 10.5 * TILE_SIZE,
targetZone: ZONES.VILLAGE,
targetX: (ZONE_CONFIGS[0].width - 2.5) * TILE_SIZE,
targetY: 10.5 * TILE_SIZE
});
// Exit to Crypts (East)
map[14][W - 1] = TILES.DOOR;
map[15][W - 1] = TILES.DOOR;
interactables.push({
type: 'gate',
x: (W - 1) * TILE_SIZE,
y: 14.5 * TILE_SIZE,
targetZone: ZONES.CRYPT,
targetX: 2.5 * TILE_SIZE,
targetY: 12.5 * TILE_SIZE
});
// Shrine
map[5][18] = TILES.SHRINE;
interactables.push({
type: 'shrine',
x: 18 * TILE_SIZE + 20,
y: 5 * TILE_SIZE + 20,
name: 'Wilds Outpost Shrine'
});
// Internal ruins walls & Cracked wall secret room
for (let y = 16; y <= 22; y++) {
map[y][10] = TILES.WALL;
map[y][18] = TILES.WALL;
}
for (let x = 10; x <= 18; x++) {
map[22][x] = TILES.WALL;
}
map[16][14] = TILES.CRACKED_WALL; // Needs Bomb or heavy hit
// Weapon Upgrade 1 in secret chamber
interactables.push({
type: 'pedestal_weapon1',
x: 14 * TILE_SIZE + 20,
y: 19 * TILE_SIZE + 20,
name: 'Sunforged Broadsword'
});
// Chest with Heart Piece / Accessory
interactables.push({
type: 'chest',
x: 28 * TILE_SIZE + 20,
y: 6 * TILE_SIZE + 20,
item: 'swift_boots',
opened: false
});
// Enemies
for (let i = 0; i < 7; i++) {
enemies.push(new Enemy((6 + Math.random() * 22) * TILE_SIZE, (4 + Math.random() * 16) * TILE_SIZE, 'slime'));
}
for (let i = 0; i < 5; i++) {
enemies.push(new Enemy((12 + Math.random() * 18) * TILE_SIZE, (6 + Math.random() * 14) * TILE_SIZE, 'goblin'));
}
for (let i = 0; i < 20; i++) {
props.push({ type: 'bush', x: (4 + Math.random() * (W - 8)) * TILE_SIZE, y: (3 + Math.random() * (H - 6)) * TILE_SIZE, hp: 1 });
}
} else if (cfg.id === ZONES.CRYPT) {
// Sunken Crypts (Mid-Game Dungeon)
// Entrance from Overworld (West)
map[12][0] = TILES.DOOR;
map[13][0] = TILES.DOOR;
interactables.push({
type: 'gate',
x: 0,
y: 12.5 * TILE_SIZE,
targetZone: ZONES.OVERWORLD,
targetX: (ZONE_CONFIGS[1].width - 2.5) * TILE_SIZE,
targetY: 14.5 * TILE_SIZE
});
// Exit to Spire (North gate locked by Crypt Boss)
map[0][16] = TILES.DOOR;
map[0][17] = TILES.DOOR;
interactables.push({
type: 'gate',
x: 16.5 * TILE_SIZE,
y: 0,
targetZone: ZONES.SPIRE,
targetX: 16.5 * TILE_SIZE,
targetY: (ZONE_CONFIGS[3].height - 2.5) * TILE_SIZE,
requiresCryptBoss: true
});
// Shrine
map[18][6] = TILES.SHRINE;
interactables.push({
type: 'shrine',
x: 6 * TILE_SIZE + 20,
y: 18 * TILE_SIZE + 20,
name: 'Crypt Sanctum Shrine'
});
// Crypt Boss: Crypt Warden (Unlocks Mystic Bow upon defeat!)
enemies.push(new BossCryptWarden(24 * TILE_SIZE, 12 * TILE_SIZE));
// Skeletons & Bat minions
for (let i = 0; i < 6; i++) {
enemies.push(new Enemy((10 + Math.random() * 12) * TILE_SIZE, (6 + Math.random() * 12) * TILE_SIZE, 'skeleton'));
}
for (let i = 0; i < 5; i++) {
enemies.push(new Enemy((16 + Math.random() * 10) * TILE_SIZE, (4 + Math.random() * 14) * TILE_SIZE, 'bat'));
}
// Chest with Amulet of Thorns
interactables.push({
type: 'chest',
x: 8 * TILE_SIZE + 20,
y: 6 * TILE_SIZE + 20,
item: 'amulet_thorns',
opened: false
});
} else if (cfg.id === ZONES.SPIRE) {
// Storm Peaks & Spire Ascent
// Entrance from Crypts (South)
map[H - 1][16] = TILES.DOOR;
map[H - 1][17] = TILES.DOOR;
interactables.push({
type: 'gate',
x: 16.5 * TILE_SIZE,
y: (H - 1) * TILE_SIZE,
targetZone: ZONES.CRYPT,
targetX: 16.5 * TILE_SIZE,
targetY: 2.5 * TILE_SIZE
});
// Exit to Final Sanctum (North)
map[0][16] = TILES.DOOR;
map[0][17] = TILES.DOOR;
interactables.push({
type: 'gate',
x: 16.5 * TILE_SIZE,
y: 0,
targetZone: ZONES.SANCTUM,
targetX: 14.5 * TILE_SIZE,
targetY: (ZONE_CONFIGS[4].height - 2.5) * TILE_SIZE
});
// Shrine
map[20][8] = TILES.SHRINE;
interactables.push({
type: 'shrine',
x: 8 * TILE_SIZE + 20,
y: 20 * TILE_SIZE + 20,
name: 'Storm Peak Summit Shrine'
});
// Stormcaller Greatblade (Weapon Upgrade 2) Pedestal guarded by Elite Knights
interactables.push({
type: 'pedestal_weapon2',
x: 28 * TILE_SIZE + 20,
y: 12 * TILE_SIZE + 20,
name: 'Stormcaller Greatblade'
});
// Switch Puzzle for Mystic Bow (Shoot switch to open gate / bridge)
interactables.push({
type: 'ranged_switch',
x: 22 * TILE_SIZE + 20,
y: 6 * TILE_SIZE + 20,
activated: false
});
// Chest with Bloodstone Talisman or Heart Vessel
interactables.push({
type: 'chest',
x: 8 * TILE_SIZE + 20,
y: 8 * TILE_SIZE + 20,
item: 'berserk_talisman',
opened: false
});
for (let i = 0; i < 5; i++) {
enemies.push(new Enemy((12 + Math.random() * 18) * TILE_SIZE, (8 + Math.random() * 14) * TILE_SIZE, 'knight'));
}
for (let i = 0; i < 5; i++) {
enemies.push(new Enemy((10 + Math.random() * 20) * TILE_SIZE, (6 + Math.random() * 16) * TILE_SIZE, 'bat'));
}
} else if (cfg.id === ZONES.SANCTUM) {
// Sanctum of the Void Sovereign (Final Boss)
map[H - 1][14] = TILES.DOOR;
map[H - 1][15] = TILES.DOOR;
interactables.push({
type: 'gate',
x: 14.5 * TILE_SIZE,
y: (H - 1) * TILE_SIZE,
targetZone: ZONES.SPIRE,
targetX: 16.5 * TILE_SIZE,
targetY: 2.5 * TILE_SIZE
});
// Save Shrine right before the Sovereign
map[18][6] = TILES.SHRINE;
interactables.push({
type: 'shrine',
x: 6 * TILE_SIZE + 20,
y: 18 * TILE_SIZE + 20,
name: 'Sanctum Threshold Shrine'
});
// Final Boss: Void Sovereign
enemies.push(new BossVoidSovereign(15 * TILE_SIZE, 8 * TILE_SIZE));
}
return {
map,
props,
enemies,
npcs,
interactables,
width: W,
height: H,
name: cfg.name
};
}
getCurrentZone() {
return this.zonesData[this.currentZoneId];
}
isSolid(x, y) {
const tx = Math.floor(x / TILE_SIZE);
const ty = Math.floor(y / TILE_SIZE);
const zone = this.getCurrentZone();
if (tx < 0 || tx >= zone.width || ty < 0 || ty >= zone.height) return true;
const tile = zone.map[ty][tx];
return tile === TILES.WALL || tile === TILES.CRACKED_WALL || tile === TILES.WATER;
}
}
// --- PROJECTILE CLASS ---
class Projectile {
constructor(x, y, vx, vy, options = {}) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.damage = options.damage || 1;
this.range = options.range || 400;
this.distTravelled = 0;
this.color = options.color || '#64ffda';
this.isPlayer = options.isPlayer !== undefined ? options.isPlayer : true;
this.radius = options.radius || 4;
this.dead = false;
}
update(dt, world, enemies, player) {
const moveStep = Math.hypot(this.vx, this.vy) * dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
this.distTravelled += moveStep;
if (this.distTravelled >= this.range) {
this.dead = true;
return;
}
// Check wall collision
if (world.isSolid(this.x, this.y)) {
const tx = Math.floor(this.x / TILE_SIZE);
const ty = Math.floor(this.y / TILE_SIZE);
const zone = world.getCurrentZone();
if (zone.map[ty] && zone.map[ty][tx] === TILES.CRACKED_WALL) {
zone.map[ty][tx] = TILES.STONE_FLOOR;
sounds.playExplosion();
game.particles.emit(this.x, this.y, 20, { color: '#8d6e63', size: 4 });
game.notify('Secret passage blasted open!');
}
this.dead = true;
game.particles.emit(this.x, this.y, 4, { color: this.color });
return;
}
// Hit enemies
if (this.isPlayer) {
// Check ranged switches
const zone = world.getCurrentZone();
zone.interactables.forEach(item => {
if (item.type === 'ranged_switch' && !item.activated && dist(this.x, this.y, item.x, item.y) < 24) {
item.activated = true;
this.dead = true;
sounds.playPowerup();
game.notify('Ranged Sigil Activated! Ancient barrier lowered.');
game.particles.emit(item.x, item.y, 25, { color: '#64ffda', size: 4 });
}
});
enemies.forEach(e => {
if (!e.dead && dist(this.x, this.y, e.x, e.y) < e.radius + this.radius) {
e.takeDamage(this.damage, this.vx, this.vy);
this.dead = true;
game.particles.emit(this.x, this.y, 6, { color: this.color });
}
});
} else {
// Enemy projectile hit player
if (dist(this.x, this.y, player.x, player.y) < player.radius + this.radius) {
player.takeDamage(this.damage, this.x, this.y);
this.dead = true;
game.particles.emit(this.x, this.y, 8, { color: '#ff5252' });
}
}
}
render(ctx, camera) {
const sx = this.x - camera.x;
const sy = this.y - camera.y;
ctx.save();
ctx.fillStyle = this.color;
ctx.shadowColor = this.color;
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(sx, sy, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// --- BOMB EXPLOSIVE CLASS ---
class Bomb {
constructor(x, y) {
this.x = x;
this.y = y;
this.timer = 1.4;
this.dead = false;
this.radius = 12;
this.blastRadius = 90;
}
update(dt, world, enemies, player) {
this.timer -= dt;
if (this.timer <= 0) {
this.explode(world, enemies, player);
this.dead = true;
}
}
explode(world, enemies, player) {
sounds.playExplosion();
game.particles.emit(this.x, this.y, 40, { color: '#ff7043', size: 6, speed: 5 });
game.particles.emit(this.x, this.y, 25, { color: '#ffca28', size: 4, speed: 4 });
game.particles.emit(this.x, this.y, 10, { shape: 'ring', color: '#ffab00' });
// Break cracked walls within blast
const zone = world.getCurrentZone();
const cx = Math.floor(this.x / TILE_SIZE);
const cy = Math.floor(this.y / TILE_SIZE);
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const tx = cx + dx;
const ty = cy + dy;
if (zone.map[ty] && zone.map[ty][tx] === TILES.CRACKED_WALL) {
zone.map[ty][tx] = TILES.STONE_FLOOR;
game.notify('Wall blown to rubble!');
}
}
}
// Damage enemies in blast
enemies.forEach(e => {
if (!e.dead && dist(this.x, this.y, e.x, e.y) < this.blastRadius) {
const d = Math.max(1, dist(this.x, this.y, e.x, e.y));
const pushX = (e.x - this.x) / d * 400;
const pushY = (e.y - this.y) / d * 400;
e.takeDamage(4.0, pushX, pushY);
}
});
// Damage props (bushes)
zone.props.forEach(p => {
if (dist(this.x, this.y, p.x, p.y) < this.blastRadius) {
p.hp = 0;
}
});
// Hurt player if standing too close
if (dist(this.x, this.y, player.x, player.y) < this.blastRadius * 0.75) {
player.takeDamage(1, this.x, this.y);
}
}
render(ctx, camera) {
const sx = this.x - camera.x;
const sy = this.y - camera.y;
const pulse = Math.sin(Date.now() * 0.02) * 2;
ctx.save();
ctx.fillStyle = '#212121';
ctx.beginPath();
ctx.arc(sx, sy, this.radius + pulse, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#d32f2f';
ctx.lineWidth = 2;
ctx.stroke();
// Fuse spark
ctx.fillStyle = '#ffeb3b';
ctx.beginPath();
ctx.arc(sx + 6, sy - 8, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// --- PICKUP ENTITY (GOLD, HEARTS, BOMBS) ---
class Pickup {
constructor(x, y, type = 'gold', amount = 1) {
this.x = x;
this.y = y;
this.type = type; // 'gold', 'heart', 'bomb'
this.amount = amount;
this.dead = false;
this.bob = Math.random() * Math.PI * 2;
}
update(dt, player) {
this.bob += dt * 4;
const d = dist(this.x, this.y, player.x, player.y);
const magnetDist = player.getMagnetRadius();
if (d < magnetDist) {
// Pull towards player
const speed = 260 * dt;
this.x += ((player.x - this.x) / d) * speed;
this.y += ((player.y - this.y) / d) * speed;
}
if (d < player.radius + 12) {
this.collect(player);
this.dead = true;
}
}
collect(player) {
if (this.type === 'gold') {
const mult = player.hasEquipped('gold_ring') ? 2 : 1;
const total = this.amount * mult;
player.gold += total;
sounds.playCoin();
game.particles.emit(this.x, this.y, 8, { color: '#ffd54f', size: 3 });
} else if (this.type === 'heart') {
player.hp = Math.min(player.maxHp, player.hp + 2);
sounds.playPowerup();
game.particles.emit(this.x, this.y, 10, { color: '#ff5252', size: 3 });
} else if (this.type === 'bomb') {
player.bombs += 1;
sounds.playPowerup();
game.particles.emit(this.x, this.y, 6, { color: '#ff9800', size: 3 });
}
}
render(ctx, camera) {
const sx = this.x - camera.x;
const sy = this.y - camera.y + Math.sin(this.bob) * 3;
ctx.save();
if (this.type === 'gold') {
ctx.fillStyle = '#ffd700';
ctx.strokeStyle = '#b8860b';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(sx, sy, 6, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else if (this.type === 'heart') {
ctx.fillStyle = '#ef5350';
ctx.beginPath();
ctx.arc(sx - 3, sy - 2, 4, 0, Math.PI * 2);
ctx.arc(sx + 3, sy - 2, 4, 0, Math.PI * 2);
ctx.lineTo(sx, sy + 6);
ctx.fill();
} else if (this.type === 'bomb') {
ctx.fillStyle = '#37474f';
ctx.beginPath();
ctx.arc(sx, sy, 5, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
// --- BASE ENEMY CLASS ---
class Enemy {
constructor(x, y, type = 'slime') {
this.x = x;
this.y = y;
this.type = type;
this.dead = false;
this.vx = 0;
this.vy = 0;
this.radius = 16;
this.flashTimer = 0;
this.attackCooldown = 0;
this.animTimer = Math.random() * 10;
// Type setups
if (type === 'slime') {
this.maxHp = 2.0;
this.hp = 2.0;
this.speed = 55;
this.damage = 1;
this.color = '#4caf50';
this.radius = 14;
} else if (type === 'goblin') {
this.maxHp = 4.0;
this.hp = 4.0;
this.speed = 85;
this.damage = 1;
this.color = '#d84315';
this.radius = 16;
} else if (type === 'skeleton') {
this.maxHp = 5.0;
this.hp = 5.0;
this.speed = 70;
this.damage = 1.5;
this.color = '#e0e0e0';
this.radius = 16;
} else if (type === 'bat') {
this.maxHp = 2.0;
this.hp = 2.0;
this.speed = 110;
this.damage = 1;
this.color = '#7e57c2';
this.radius = 12;
} else if (type === 'knight') {
this.maxHp = 9.0;
this.hp = 9.0;
this.speed = 60;
this.damage = 2;
this.color = '#455a64';
this.radius = 18;
} else if (type === 'dummy') {
this.maxHp = 9999;
this.hp = 9999;
this.speed = 0;
this.damage = 0;
this.color = '#a1887f';
this.radius = 18;
}
}
takeDamage(amount, pushX = 0, pushY = 0) {
if (this.type === 'dummy') {
sounds.playHit();
this.flashTimer = 0.15;
game.particles.emit(this.x, this.y, 8, { color: '#fff', size: 3 });
return;
}
this.hp -= amount;
this.flashTimer = 0.18;
sounds.playHit();
game.particles.emit(this.x, this.y, 10, { color: '#ff1744', size: 3 });
// Knockback
const pDist = Math.hypot(pushX, pushY) || 1;
this.vx += (pushX / pDist) * 160;
this.vy += (pushY / pDist) * 160;
if (this.hp <= 0) {
this.die();
}
}
die() {
this.dead = true;
sounds.playExplosion();
game.particles.emit(this.x, this.y, 25, { color: this.color, size: 5, speed: 4 });
// Drop loot
const dropChance = Math.random();
if (dropChance < 0.7) {
const goldAmt = this.type === 'knight' ? 15 : (this.type === 'goblin' ? 8 : 4);
game.pickups.push(new Pickup(this.x, this.y, 'gold', goldAmt));
}
if (Math.random() < 0.3) {
game.pickups.push(new Pickup(this.x + 8, this.y, 'heart', 1));
}
if (Math.random() < 0.2) {
game.pickups.push(new Pickup(this.x - 8, this.y, 'bomb', 1));
}
}
update(dt, world, player) {
if (this.dead) return;
this.animTimer += dt;
if (this.flashTimer > 0) this.flashTimer -= dt;
if (this.attackCooldown > 0) this.attackCooldown -= dt;
// Apply knockback decay
this.x += this.vx * dt;
this.y += this.vy * dt;
this.vx *= 0.85;
this.vy *= 0.85;
if (this.type === 'dummy') return;
const d = dist(this.x, this.y, player.x, player.y);
const aggroRange = this.type === 'bat' ? 260 : 200;
if (d < aggroRange && d > 10) {
const nx = (player.x - this.x) / d;
const ny = (player.y - this.y) / d;
const nextX = this.x + nx * this.speed * dt;
const nextY = this.y + ny * this.speed * dt;
if (!world.isSolid(nextX, this.y)) this.x = nextX;
if (!world.isSolid(this.x, nextY)) this.y = nextY;
// Skeleton shoots projectile at range
if (this.type === 'skeleton' && d > 80 && this.attackCooldown <= 0) {
this.attackCooldown = 2.2;
game.projectiles.push(new Projectile(this.x, this.y, nx * 240, ny * 240, {
isPlayer: false,
color: '#e0e0e0',
damage: 1,
radius: 5
}));
}
// Melee attack player
if (d < this.radius + player.radius && this.attackCooldown <= 0) {
this.attackCooldown = 0.9;
player.takeDamage(this.damage, this.x, this.y);
// Amulet of thorns retaliation
if (player.hasEquipped('amulet_thorns')) {
this.takeDamage(this.damage * 0.5, -nx * 100, -ny * 100);
}
}
}
}
render(ctx, camera) {
if (this.dead) return;
const sx = this.x - camera.x;
const sy = this.y - camera.y;
ctx.save();
if (this.flashTimer > 0) {
ctx.fillStyle = '#ffffff';
} else {
ctx.fillStyle = this.color;
}
if (this.type === 'slime') {
const squish = Math.sin(this.animTimer * 6) * 3;
ctx.beginPath();
ctx.ellipse(sx, sy, this.radius + squish, this.radius - squish, 0, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(sx - 4, sy - 2, 2, 0, Math.PI * 2);
ctx.arc(sx + 4, sy - 2, 2, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === 'goblin') {
ctx.beginPath();
ctx.arc(sx, sy, this.radius, 0, Math.PI * 2);
ctx.fill();
// Goblin ears
ctx.fillStyle = '#bf360c';
ctx.beginPath();
ctx.moveTo(sx - this.radius, sy);
ctx.lineTo(sx - this.radius - 8, sy - 6);
ctx.lineTo(sx - this.radius + 2, sy + 4);
ctx.moveTo(sx + this.radius, sy);
ctx.lineTo(sx + this.radius + 8, sy - 6);
ctx.lineTo(sx + this.radius - 2, sy + 4);
ctx.fill();
} else if (this.type === 'skeleton') {
ctx.beginPath();
ctx.arc(sx, sy, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#212121';
ctx.fillRect(sx - 6, sy - 4, 3, 5);
ctx.fillRect(sx + 3, sy - 4, 3, 5);
} else if (this.type === 'bat') {
const wing = Math.sin(this.animTimer * 14) * 8;
ctx.beginPath();
ctx.arc(sx, sy, this.radius * 0.7, 0, Math.PI * 2);
ctx.fill();
// Wings
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx - 14, sy - wing);
ctx.lineTo(sx - 6, sy + 4);
ctx.moveTo(sx, sy);
ctx.lineTo(sx + 14, sy - wing);
ctx.lineTo(sx + 6, sy + 4);
ctx.fill();
} else if (this.type === 'knight') {
ctx.beginPath();
ctx.rect(sx - this.radius, sy - this.radius, this.radius * 2, this.radius * 2);
ctx.fill();
ctx.strokeStyle = '#ffd54f';
ctx.lineWidth = 2;
ctx.stroke();
} else if (this.type === 'dummy') {
ctx.beginPath();
ctx.arc(sx, sy, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#5d4037';
ctx.fillRect(sx - 3, sy - this.radius, 6, this.radius * 2);
}
// Health bar overhead for tough enemies
if (this.maxHp > 3 && this.type !== 'dummy' && this.hp < this.maxHp) {
const barW = 28;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(sx - barW / 2, sy - this.radius - 10, barW, 4);
ctx.fillStyle = '#ef5350';
ctx.fillRect(sx - barW / 2, sy - this.radius - 10, barW * (this.hp / this.maxHp), 4);
}
ctx.restore();
}
}
// --- MID-GAME BOSS: CRYPT WARDEN ---
class BossCryptWarden extends Enemy {
constructor(x, y) {
super(x, y, 'boss_crypt');
this.name = 'Crypt Warden Morvath';
this.maxHp = 22.0;
this.hp = 22.0;
this.radius = 28;
this.speed = 65;
this.color = '#7c4dff';
this.phase = 1;
this.slamTimer = 3.0;
this.isBoss = true;
}
update(dt, world, player) {
if (this.dead) return;
super.update(dt, world, player);
// Update Boss UI
game.updateBossBar(this.name, this.hp, this.maxHp);
this.slamTimer -= dt;
if (this.slamTimer <= 0) {
this.slamTimer = 3.2;
// Ground slam radial shockwave
sounds.playExplosion();
game.particles.emit(this.x, this.y, 25, { shape: 'ring', color: '#b388ff' });
for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
game.projectiles.push(new Projectile(this.x, this.y, Math.cos(a) * 200, Math.sin(a) * 200, {
isPlayer: false,
color: '#b388ff',
damage: 1.5,
radius: 6
}));
}
}
}
die() {
super.die();
game.cryptBossDefeated = true;
game.hideBossBar();
sounds.playPowerup();
game.notify('Crypt Warden Defeated! Ranged Mystic Bow Discovered!');
// UNLOCK RANGED BOW (MID-GAME DISCOVERY)
game.player.unlockBow();
game.pickups.push(new Pickup(this.x, this.y, 'heart', 1));
game.pickups.push(new Pickup(this.x + 16, this.y, 'gold', 50));
}
render(ctx, camera) {
if (this.dead) return;
const sx = this.x - camera.x;
const sy = this.y - camera.y;
ctx.save();
ctx.fillStyle = this.flashTimer > 0 ? '#fff' : this.color;
ctx.beginPath();
ctx.arc(sx, sy, this.radius, 0, Math.PI * 2);
ctx.fill();
// Horns / Crown
ctx.fillStyle = '#ffd700';
ctx.beginPath();
ctx.moveTo(sx - 18, sy - 18);
ctx.lineTo(sx - 24, sy - 36);
ctx.lineTo(sx - 10, sy - 24);
ctx.lineTo(sx, sy - 38);
ctx.lineTo(sx + 10, sy - 24);
ctx.lineTo(sx + 24, sy - 36);
ctx.lineTo(sx + 18, sy - 18);
ctx.fill();
// Glowing eyes
ctx.fillStyle = '#ff1744';
ctx.beginPath();
ctx.arc(sx - 8, sy - 4, 3, 0, Math.PI * 2);
ctx.arc(sx + 8, sy - 4, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// --- FINAL BOSS: VOID SOVEREIGN ---
class BossVoidSovereign extends Enemy {
constructor(x, y) {
super(x, y, 'boss_void');
this.name = 'Void Sovereign Malakor';
this.maxHp = 45.0;
this.hp = 45.0;
this.radius = 34;
this.speed = 50;
this.color = '#c2185b';
this.phase = 1;
this.patternTimer = 2.0;
this.isBoss = true;
}
update(dt, world, player) {
if (this.dead) return;
super.update(dt, world, player);
game.updateBossBar(this.name, this.hp, this.maxHp);
if (this.hp < this.maxHp * 0.5) {
this.phase = 2;
this.speed = 70;
}
this.patternTimer -= dt;
if (this.patternTimer <= 0) {
this.patternTimer = this.phase === 1 ? 2.4 : 1.6;
// Bullet hell spiral attack
sounds.playBow();
const count = this.phase === 1 ? 10 : 16;
for (let i = 0; i < count; i++) {
const angle = (Math.PI * 2 / count) * i + (Date.now() * 0.002);
game.projectiles.push(new Projectile(this.x, this.y, Math.cos(angle) * 230, Math.sin(angle) * 230, {
isPlayer: false,
color: '#e91e63',
damage: 1.5,
radius: 5
}));
}
}
}
die() {
super.die();
game.hideBossBar();
game.triggerVictory();
}
render(ctx, camera) {
if (this.dead) return;
const sx = this.x - camera.x;
const sy = this.y - camera.y;
const auraPulse = Math.sin(Date.now() * 0.006) * 6;
ctx.save();
// Void aura
ctx.fillStyle = 'rgba(194, 24, 91, 0.3)';
ctx.beginPath();
ctx.arc(sx, sy, this.radius + 12 + auraPulse, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = this.flashTimer > 0 ? '#fff' : this.color;
ctx.beginPath();
ctx.arc(sx, sy, this.radius, 0, Math.PI * 2);
ctx.fill();
// Void Core
ctx.fillStyle = '#880e4f';
ctx.beginPath();
ctx.arc(sx, sy, 14, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = '#ff80ab';
ctx.beginPath();
ctx.arc(sx - 10, sy - 6, 4, 0, Math.PI * 2);
ctx.arc(sx + 10, sy - 6, 4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// --- PLAYER CHARACTER CLASS ---
class Player {
constructor(x, y) {
this.x = x;
this.y = y;
this.vx = 0;
this.vy = 0;
this.radius = 16;
this.baseSpeed = 160;
this.facingAngle = 0; // Radians
// Health System: 3 containers (6 HP) starting, up to 5 containers (10 HP)
this.heartContainers = 3;
this.maxHp = this.heartContainers * 2; // Each heart = 2 HP
this.hp = this.maxHp;
// Stamina / Dash
this.stamina = 100;
this.maxStamina = 100;
this.isDashing = false;
this.dashTimer = 0;
this.dashCooldown = 0;
// Combat & Weapon Progression
this.swordTier = 1; // Tier 1: Rusty -> Tier 2: Sunforged -> Tier 3: Stormcaller
this.hasBow = false; // Halfway mid-game discovery
this.activeWeapon = 'sword'; // 'sword', 'bow'
this.attackTimer = 0;
this.attackDuration = 0.25;
// Currency & Inventory
this.gold = 30;
this.keys = 1;
this.bombs = 3;
this.inventory = ['potion_heal', 'potion_heal'];
this.equippedAccessories = new Set(); // holds item IDs
// Visual feedback
this.hurtFlash = 0;
this.walkAnimTimer = 0;
this.isMoving = false;
this.stepAudioTimer = 0;
// Checkpoint
this.lastSafeCheckpoint = { zoneId: ZONES.VILLAGE, x: 7 * TILE_SIZE + 20, y: 8 * TILE_SIZE + 20 };
}
getSpeed() {
let s = this.baseSpeed;
if (this.hasEquipped('swift_boots')) s *= 1.3;
return s;
}
getMagnetRadius() {
return this.hasEquipped('gold_ring') ? 110 : 45;
}
hasEquipped(itemId) {
return this.equippedAccessories.has(itemId);
}
unlockBow() {
this.hasBow = true;
document.getElementById('slot-bow').style.display = 'flex';
document.getElementById('btn-touch-bow').style.display = 'flex';
}
upgradeSword(tier) {
if (tier > this.swordTier) {
this.swordTier = tier;
sounds.playPowerup();
const weaponInfo = WEAPONS.SWORD[tier - 1];
game.notify(`Acquired ${weaponInfo.name}! (Tier ${tier})`);
game.updateWeaponIcons();
}
}
takeDamage(amount, fromX = 0, fromY = 0) {
if (this.hurtFlash > 0 || this.isDashing) return; // Invincible during dash/flash
let finalDmg = amount;
if (this.hasEquipped('iron_buckler')) {
finalDmg = Math.max(0.5, finalDmg - 0.5);
}
this.hp = Math.max(0, this.hp - finalDmg);
this.hurtFlash = 0.5;
sounds.playHurt();
// Knockback recoil
if (fromX || fromY) {
const d = Math.hypot(this.x - fromX, this.y - fromY) || 1;
this.vx = ((this.x - fromX) / d) * 220;
this.vy = ((this.y - fromY) / d) * 220;
}
game.particles.emit(this.x, this.y, 14, { color: '#ef5350', size: 3 });
if (this.hp <= 0) {
game.triggerGameOver();
}
}
dash(inputX, inputY) {
if (this.stamina < 30 || this.dashCooldown > 0 || this.isDashing) return;
this.stamina -= 30;
this.isDashing = true;
this.dashTimer = 0.2;
this.dashCooldown = this.hasEquipped('swift_boots') ? 0.35 : 0.5;
const angle = (inputX !== 0 || inputY !== 0) ? Math.atan2(inputY, inputX) : this.facingAngle;
this.vx = Math.cos(angle) * 420;
this.vy = Math.sin(angle) * 420;
sounds.playDash();
game.particles.emit(this.x, this.y, 10, { color: '#80d8ff', shape: 'ring' });
}
performAttack(targetX, targetY, world, enemies) {
if (this.attackTimer > 0) return;
this.facingAngle = Math.atan2(targetY - this.y, targetX - this.x);
if (this.activeWeapon === 'sword') {
const curSword = WEAPONS.SWORD[this.swordTier - 1];
this.attackTimer = curSword.speed;
sounds.playAttack(this.swordTier);
// Sword slash hitbox calculation
const slashArc = curSword.arc;
const slashRange = curSword.range;
let dmg = curSword.damage;
// Berserk bonus check
if (this.hasEquipped('berserk_talisman') && this.hp <= this.maxHp * 0.5) {
dmg += 1.0;
}
// Hit enemies within arc
enemies.forEach(e => {
if (e.dead) return;
const d = dist(this.x, this.y, e.x, e.y);
if (d < slashRange + e.radius) {
const angleToEnemy = Math.atan2(e.y - this.y, e.x - this.x);
let angleDiff = Math.abs(angleToEnemy - this.facingAngle);
if (angleDiff > Math.PI) angleDiff = Math.PI * 2 - angleDiff;
if (angleDiff <= slashArc / 2) {
const pushX = Math.cos(this.facingAngle) * 260;
const pushY = Math.sin(this.facingAngle) * 260;
e.takeDamage(dmg, pushX, pushY);
}
}
});
// Clear grass/bushes
const zone = world.getCurrentZone();
zone.props.forEach(p => {
if (p.hp > 0 && dist(this.x, this.y, p.x, p.y) < slashRange + 10) {
p.hp = 0;
sounds.playStep();
game.particles.emit(p.x, p.y, 8, { color: '#66bb6a', size: 3 });
if (Math.random() < 0.4) {
game.pickups.push(new Pickup(p.x, p.y, 'gold', 1));
}
}
});
// Tier 3 Sword wave projectile (Stormcaller beam)
if (this.swordTier === 3) {
game.projectiles.push(new Projectile(
this.x + Math.cos(this.facingAngle) * 20,
this.y + Math.sin(this.facingAngle) * 20,
Math.cos(this.facingAngle) * 380,
Math.sin(this.facingAngle) * 380,
{ color: '#00e5ff', damage: 1.5, range: 260, radius: 8 }
));
}
} else if (this.activeWeapon === 'bow' && this.hasBow) {
this.attackTimer = 0.35;
sounds.playBow();
const bow = WEAPONS.BOW;
game.projectiles.push(new Projectile(
this.x + Math.cos(this.facingAngle) * 20,
this.y + Math.sin(this.facingAngle) * 20,
Math.cos(this.facingAngle) * bow.projectileSpeed,
Math.sin(this.facingAngle) * bow.projectileSpeed,
{ color: bow.color, damage: bow.damage, range: bow.range, radius: 4 }
));
}
}
dropBomb() {
if (this.bombs <= 0) {
game.notify('No Fire Urns / Bombs left!');
return;
}
this.bombs--;
game.bombs.push(new Bomb(this.x, this.y));
sounds.playStep();
}
update(dt, input, world, enemies) {
if (this.hurtFlash > 0) this.hurtFlash -= dt;
if (this.attackTimer > 0) this.attackTimer -= dt;
if (this.dashCooldown > 0) this.dashCooldown -= dt;
// Dash state logic
if (this.isDashing) {
this.dashTimer -= dt;
game.particles.emit(this.x, this.y, 2, { color: '#b3e5fc', size: 2 });
if (this.dashTimer <= 0) {
this.isDashing = false;
}
} else {
// Regenerate Stamina
this.stamina = Math.min(this.maxStamina, this.stamina + 35 * dt);
// Normal movement input
let moveX = 0;
let moveY = 0;
if (input.up) moveY -= 1;
if (input.down) moveY += 1;
if (input.left) moveX -= 1;
if (input.right) moveX += 1;
if (moveX !== 0 && moveY !== 0) {
moveX *= 0.7071;
moveY *= 0.7071;
}
this.isMoving = moveX !== 0 || moveY !== 0;
if (this.isMoving) {
const spd = this.getSpeed();
this.vx = moveX * spd;
this.vy = moveY * spd;
this.walkAnimTimer += dt * 8;
this.stepAudioTimer += dt;
if (this.stepAudioTimer > 0.32) {
sounds.playStep();
this.stepAudioTimer = 0;
}
} else {
this.vx *= 0.7;
this.vy *= 0.7;
}
}
// World collision & movement resolution
const nextX = this.x + this.vx * dt;
const nextY = this.y + this.vy * dt;
if (!world.isSolid(nextX, this.y)) {
this.x = nextX;
} else {
this.vx = 0;
}
if (!world.isSolid(this.x, nextY)) {
this.y = nextY;
} else {
this.vy = 0;
}
}
render(ctx, camera) {
const sx = this.x - camera.x;
const sy = this.y - camera.y;
ctx.save();
// Damage flash or transparency
if (this.hurtFlash > 0) {
ctx.globalAlpha = Math.sin(Date.now() * 0.05) > 0 ? 0.4 : 1.0;
}
// Dash trail effect
if (this.isDashing) {
ctx.shadowColor = '#00e5ff';
ctx.shadowBlur = 12;
}
// Shadow
ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
ctx.beginPath();
ctx.ellipse(sx, sy + 12, 14, 6, 0, 0, Math.PI * 2);
ctx.fill();
// Player Body (Tunic / Armor based on gear)
const bob = this.isMoving ? Math.sin(this.walkAnimTimer) * 2 : 0;
const curSword = WEAPONS.SWORD[this.swordTier - 1];
// Armor cloak color changes with upgraded sword / equipment
let cloakColor = '#2e7d32'; // Forest Green Tier 1
if (this.swordTier === 2) cloakColor = '#c67100'; // Solar Amber Tier 2
if (this.swordTier === 3) cloakColor = '#1565c0'; // Storm Azure Tier 3
ctx.fillStyle = cloakColor;
ctx.beginPath();
ctx.arc(sx, sy - 2 + bob, 12, 0, Math.PI * 2);
ctx.fill();
// Head / Hair
ctx.fillStyle = '#ffd54f';
ctx.beginPath();
ctx.arc(sx, sy - 8 + bob, 8, 0, Math.PI * 2);
ctx.fill();
// Face / Eye direction
const eyeX = sx + Math.cos(this.facingAngle) * 4;
const eyeY = sy - 8 + Math.sin(this.facingAngle) * 4 + bob;
ctx.fillStyle = '#263238';
ctx.beginPath();
ctx.arc(eyeX, eyeY, 2, 0, Math.PI * 2);
ctx.fill();
// Render Equipped Weapon (Sword / Bow)
const weaponDist = 18;
const wx = sx + Math.cos(this.facingAngle) * weaponDist;
const wy = sy + Math.sin(this.facingAngle) * weaponDist + bob;
if (this.activeWeapon === 'sword') {
ctx.save();
ctx.translate(wx, wy);
ctx.rotate(this.facingAngle + Math.PI / 4);
// Sword Blade
ctx.fillStyle = curSword.color;
ctx.strokeStyle = '#37474f';
ctx.lineWidth = 1.5;
ctx.fillRect(-2, -14, 4, 16);
ctx.strokeRect(-2, -14, 4, 16);
// Crossguard
ctx.fillStyle = '#ffd700';
ctx.fillRect(-5, 0, 10, 3);
ctx.restore();
// Active Slash Arc Rendering
if (this.attackTimer > 0) {
ctx.save();
ctx.strokeStyle = curSword.trailColor;
ctx.lineWidth = 8;
ctx.lineCap = 'round';
ctx.beginPath();
const startA = this.facingAngle - curSword.arc / 2;
const endA = this.facingAngle + curSword.arc / 2;
ctx.arc(sx, sy, curSword.range, startA, endA);
ctx.stroke();
ctx.restore();
}
} else if (this.activeWeapon === 'bow') {
ctx.save();
ctx.translate(wx, wy);
ctx.rotate(this.facingAngle);
ctx.strokeStyle = '#8d6e63';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(0, 0, 10, -Math.PI / 2, Math.PI / 2);
ctx.stroke();
ctx.restore();
}
ctx.restore();
}
}
// --- MAIN GAME MANAGER ---
class GameManager {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.ctx = this.canvas.getContext('2d');
this.minimapCanvas = document.getElementById('minimapCanvas');
this.mmCtx = this.minimapCanvas.getContext('2d');
this.particles = new ParticleSystem();
this.projectiles = [];
this.bombs = [];
this.pickups = [];
this.world = new World(this);
this.player = new Player(7 * TILE_SIZE + 20, 8 * TILE_SIZE + 20);
this.camera = { x: 0, y: 0, targetX: 0, targetY: 0 };
this.input = { up: false, down: false, left: false, right: false, mouseX: 0, mouseY: 0 };
this.gameState = 'TITLE'; // 'TITLE', 'PLAYING', 'DIALOG', 'INVENTORY', 'SHOP', 'GAMEOVER', 'VICTORY'
this.cryptBossDefeated = false;
this.lastTime = performance.now();
this.initEventListeners();
this.renderHUD();
}
initEventListeners() {
// Keyboard
window.addEventListener('keydown', e => {
if (this.gameState === 'TITLE' || this.gameState === 'GAMEOVER') return;
sounds.ensureState();
if (e.key === 'w' || e.key === 'ArrowUp') this.input.up = true;
if (e.key === 's' || e.key === 'ArrowDown') this.input.down = true;
if (e.key === 'a' || e.key === 'ArrowLeft') this.input.left = true;
if (e.key === 'd' || e.key === 'ArrowRight') this.input.right = true;
if (e.code === 'Space') {
if (this.dialogueActive) {
this.advanceDialogue();
} else {
this.player.performAttack(this.camera.x + this.input.mouseX, this.camera.y + this.input.mouseY, this.world, this.world.getCurrentZone().enemies);
}
}
if (e.key === 'Shift') {
let ix = 0, iy = 0;
if (this.input.up) iy -= 1;
if (this.input.down) iy += 1;
if (this.input.left) ix -= 1;
if (this.input.right) ix += 1;
this.player.dash(ix, iy);
}
if (e.key === 'e' || e.key === 'E') {
if (this.dialogueActive) {
this.advanceDialogue();
} else {
this.interact();
}
}
if (e.key === 'q' || e.key === 'Q') {
this.player.dropBomb();
}
if (e.key === 'f' || e.key === 'F') {
if (this.player.hasBow) {
this.player.activeWeapon = this.player.activeWeapon === 'bow' ? 'sword' : 'bow';
this.updateWeaponIcons();
}
}
if (e.key === 'i' || e.key === 'I' || e.key === 'Tab') {
e.preventDefault();
this.toggleInventory();
}
// Quick-potions
if (e.key === '1') this.useQuickPotion();
});
window.addEventListener('keyup', e => {
if (e.key === 'w' || e.key === 'ArrowUp') this.input.up = false;
if (e.key === 's' || e.key === 'ArrowDown') this.input.down = false;
if (e.key === 'a' || e.key === 'ArrowLeft') this.input.left = false;
if (e.key === 'd' || e.key === 'ArrowRight') this.input.right = false;
});
// Mouse & Aim
this.canvas.addEventListener('mousemove', e => {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
this.input.mouseX = (e.clientX - rect.left) * scaleX;
this.input.mouseY = (e.clientY - rect.top) * scaleY;
// Update player facing angle
const worldMouseX = this.camera.x + this.input.mouseX;
const worldMouseY = this.camera.y + this.input.mouseY;
this.player.facingAngle = Math.atan2(worldMouseY - this.player.y, worldMouseX - this.player.x);
});
this.canvas.addEventListener('mousedown', e => {
sounds.ensureState();
if (this.gameState !== 'PLAYING') return;
const worldMouseX = this.camera.x + this.input.mouseX;
const worldMouseY = this.camera.y + this.input.mouseY;
if (e.button === 0) {
// LMB: Sword or current weapon
this.player.performAttack(worldMouseX, worldMouseY, this.world, this.world.getCurrentZone().enemies);
} else if (e.button === 2) {
// RMB: Quick bow shot if available
if (this.player.hasBow) {
const oldW = this.player.activeWeapon;
this.player.activeWeapon = 'bow';
this.player.performAttack(worldMouseX, worldMouseY, this.world, this.world.getCurrentZone().enemies);
this.player.activeWeapon = oldW;
}
}
});
this.canvas.addEventListener('contextmenu', e => e.preventDefault());
// Title screen buttons
document.getElementById('btn-start-game').addEventListener('click', () => {
sounds.init();
sounds.playPowerup();
document.getElementById('title-screen').style.display = 'none';
this.gameState = 'PLAYING';
this.notify('Entered the Whispering Grove. Speak to Elder Orin.');
});
const savedState = localStorage.getItem('sunken_spire_save');
if (savedState) {
const btnCont = document.getElementById('btn-continue-game');
btnCont.style.display = 'inline-block';
btnCont.addEventListener('click', () => {
sounds.init();
this.loadSavedGame();
document.getElementById('title-screen').style.display = 'none';
this.gameState = 'PLAYING';
});
}
document.getElementById('btn-respawn').addEventListener('click', () => {
document.getElementById('gameover-screen').style.display = 'none';
this.respawnAtSave();
});
// Touch controls setup
this.setupTouchControls();
}
setupTouchControls() {
const joystick = document.getElementById('touch-joystick');
const knob = document.getElementById('touch-knob');
let touchId = null;
joystick.addEventListener('touchstart', e => {
const touch = e.changedTouches[0];
touchId = touch.identifier;
handleJoystick(touch);
});
joystick.addEventListener('touchmove', e => {
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === touchId) {
handleJoystick(e.changedTouches[i]);
break;
}
}
});
const resetJoystick = () => {
touchId = null;
knob.style.transform = 'translate(0px, 0px)';
this.input.up = false;
this.input.down = false;
this.input.left = false;
this.input.right = false;
};
joystick.addEventListener('touchend', resetJoystick);
joystick.addEventListener('touchcancel', resetJoystick);
const handleJoystick = (touch) => {
const rect = joystick.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const dx = touch.clientX - centerX;
const dy = touch.clientY - centerY;
const d = Math.hypot(dx, dy);
const maxR = 40;
const clampedX = (dx / (d || 1)) * Math.min(d, maxR);
const clampedY = (dy / (d || 1)) * Math.min(d, maxR);
knob.style.transform = `translate(${clampedX}px, ${clampedY}px)`;
this.input.left = dx < -12;
this.input.right = dx > 12;
this.input.up = dy < -12;
this.input.down = dy > 12;
};
document.getElementById('btn-touch-attack').addEventListener('touchstart', (e) => {
e.preventDefault();
sounds.ensureState();
const targetX = this.player.x + Math.cos(this.player.facingAngle) * 50;
const targetY = this.player.y + Math.sin(this.player.facingAngle) * 50;
this.player.performAttack(targetX, targetY, this.world, this.world.getCurrentZone().enemies);
});
document.getElementById('btn-touch-dash').addEventListener('touchstart', (e) => {
e.preventDefault();
let ix = 0, iy = 0;
if (this.input.up) iy -= 1;
if (this.input.down) iy += 1;
if (this.input.left) ix -= 1;
if (this.input.right) ix += 1;
this.player.dash(ix, iy);
});
document.getElementById('btn-touch-bow').addEventListener('touchstart', (e) => {
e.preventDefault();
if (this.player.hasBow) {
this.player.activeWeapon = this.player.activeWeapon === 'bow' ? 'sword' : 'bow';
this.updateWeaponIcons();
}
});
document.getElementById('btn-touch-bomb').addEventListener('touchstart', (e) => {
e.preventDefault();
this.player.dropBomb();
});
document.getElementById('btn-touch-interact').addEventListener('touchstart', (e) => {
e.preventDefault();
if (this.dialogueActive) {
this.advanceDialogue();
} else {
this.interact();
}
});
}
useQuickPotion() {
const idx = this.player.inventory.findIndex(id => id === 'potion_heal');
if (idx !== -1) {
const item = ITEM_DB.potion_heal;
if (item.use(this.player)) {
this.player.inventory.splice(idx, 1);
this.renderHUD();
}
} else {
this.notify('No Elixirs of Vitality in satchel!');
}
}
interact() {
const zone = this.world.getCurrentZone();
const px = this.player.x;
const py = this.player.y;
// Check NPCs
for (const npc of zone.npcs) {
if (dist(px, py, npc.x, npc.y) < 50) {
if (npc.type === 'merchant') {
this.openShop();
} else {
this.showDialogue(npc.name, npc.dialog, npc.portrait);
}
return;
}
}
// Check Interactables (Shrines, Pedestals, Gates, Chests)
for (const item of zone.interactables) {
const d = dist(px, py, item.x, item.y);
if (d < 50) {
if (item.type === 'shrine') {
this.saveAtShrine(item);
return;
} else if (item.type === 'pedestal_weapon1') {
if (this.player.swordTier < 2) {
this.player.upgradeSword(2);
zone.interactables = zone.interactables.filter(i => i !== item);
}
return;
} else if (item.type === 'pedestal_weapon2') {
if (this.player.swordTier < 3) {
this.player.upgradeSword(3);
zone.interactables = zone.interactables.filter(i => i !== item);
}
return;
} else if (item.type === 'chest' && !item.opened) {
item.opened = true;
sounds.playPowerup();
this.player.inventory.push(item.item);
const meta = ITEM_DB[item.item];
this.notify(`Discovered chest item: ${meta.name}!`);
this.particles.emit(item.x, item.y, 25, { color: '#ffd54f', size: 4 });
this.renderHUD();
return;
} else if (item.type === 'gate') {
if (item.requiresCryptBoss && !this.cryptBossDefeated) {
this.notify('The gate is locked by the Crypt Warden’s dark seal!');
return;
}
this.changeZone(item.targetZone, item.targetX, item.targetY);
return;
}
}
}
}
saveAtShrine(shrine) {
this.player.lastSafeCheckpoint = {
zoneId: this.world.currentZoneId,
x: shrine.x,
y: shrine.y
};
// Restore full HP
this.player.hp = this.player.maxHp;
sounds.playSave();
this.notify(`Restored HP & Progress Saved at ${shrine.name}!`);
this.particles.emit(shrine.x, shrine.y, 35, { shape: 'ring', color: '#64ffda' });
this.saveToStorage();
this.renderHUD();
}
saveToStorage() {
const data = {
hp: this.player.hp,
maxHp: this.player.maxHp,
heartContainers: this.player.heartContainers,
swordTier: this.player.swordTier,
hasBow: this.player.hasBow,
gold: this.player.gold,
bombs: this.player.bombs,
inventory: this.player.inventory,
equipped: Array.from(this.player.equippedAccessories),
checkpoint: this.player.lastSafeCheckpoint,
cryptBossDefeated: this.cryptBossDefeated
};
localStorage.setItem('sunken_spire_save', JSON.stringify(data));
}
loadSavedGame() {
try {
const data = JSON.parse(localStorage.getItem('sunken_spire_save'));
if (!data) return;
this.player.heartContainers = data.heartContainers || 3;
this.player.maxHp = this.player.heartContainers * 2;
this.player.hp = data.hp || this.player.maxHp;
this.player.swordTier = data.swordTier || 1;
this.player.hasBow = !!data.hasBow;
this.player.gold = data.gold || 0;
this.player.bombs = data.bombs || 0;
this.player.inventory = data.inventory || [];
this.player.equippedAccessories = new Set(data.equipped || []);
this.cryptBossDefeated = !!data.cryptBossDefeated;
if (this.player.hasBow) this.player.unlockBow();
this.updateWeaponIcons();
if (data.checkpoint) {
this.world.currentZoneId = data.checkpoint.zoneId;
this.player.x = data.checkpoint.x;
this.player.y = data.checkpoint.y;
}
this.renderHUD();
this.notify('Resumed from Last Shrine Checkpoint!');
} catch (e) {
console.error(e);
}
}
changeZone(targetZoneId, spawnX, spawnY) {
this.world.currentZoneId = targetZoneId;
this.player.x = spawnX;
this.player.y = spawnY;
this.projectiles = [];
this.bombs = [];
this.pickups = [];
const zone = this.world.getCurrentZone();
document.getElementById('zone-display-name').textContent = zone.name;
this.notify(`Entered ${zone.name}`);
sounds.playStep();
this.renderHUD();
}
showDialogue(speaker, text, portrait = '👤') {
this.dialogueActive = true;
this.dialogueQueue = [{ speaker, text, portrait }];
const overlay = document.getElementById('dialogue-overlay');
document.getElementById('dialog-speaker').textContent = speaker;
document.getElementById('dialog-text').textContent = text;
document.getElementById('dialog-portrait').textContent = portrait;
overlay.classList.add('active');
}
advanceDialogue() {
this.dialogueActive = false;
document.getElementById('dialogue-overlay').classList.remove('active');
}
notify(msg) {
const el = document.getElementById('banner-notify');
el.textContent = msg;
el.classList.add('show');
clearTimeout(this.bannerTimer);
this.bannerTimer = setTimeout(() => {
el.classList.remove('show');
}, 2800);
}
toggleInventory() {
const modal = document.getElementById('inventory-modal');
if (modal.classList.contains('open')) {
modal.classList.remove('open');
this.gameState = 'PLAYING';
} else {
this.populateInventoryUI();
modal.classList.add('open');
this.gameState = 'INVENTORY';
}
}
populateInventoryUI() {
const grid = document.getElementById('inventory-grid');
grid.innerHTML = '';
const descEl = document.getElementById('inv-item-description');
// Count item occurrences
const counts = {};
this.player.inventory.forEach(id => {
counts[id] = (counts[id] || 0) + 1;
});
const uniqueItems = Array.from(new Set(this.player.inventory));
if (uniqueItems.length === 0) {
grid.innerHTML = '<div style="grid-column: span 4; color: #8fa1b8; text-align: center; padding: 20px;">Satchel is currently empty. Explore zones or purchase items!</div>';
descEl.textContent = 'Select an item to view properties.';
return;
}
uniqueItems.forEach(id => {
const item = ITEM_DB[id];
if (!item) return;
const card = document.createElement('div');
card.className = 'inv-item-card' + (this.player.hasEquipped(id) ? ' equipped' : '');
card.innerHTML = `
<div class="item-count">${counts[id] > 1 ? 'x' + counts[id] : ''}</div>
<div class="item-icon" style="font-size: 24px;">${item.icon}</div>
<div class="item-name">${item.name}</div>
`;
card.addEventListener('click', () => {
descEl.innerHTML = `
<strong>${item.name}</strong> [${item.type.toUpperCase()}]<br>
${item.desc}<br><br>
<button class="btn-buy" id="btn-use-item">${item.type === 'accessory' ? (this.player.hasEquipped(id) ? 'Unequip' : 'Equip') : 'Use Item'}</button>
`;
document.getElementById('btn-use-item').addEventListener('click', () => {
if (item.type === 'consumable' || item.type === 'upgrade') {
if (item.use(this.player)) {
const idx = this.player.inventory.indexOf(id);
if (idx !== -1) this.player.inventory.splice(idx, 1);
this.populateInventoryUI();
this.renderHUD();
}
} else if (item.type === 'accessory') {
if (this.player.hasEquipped(id)) {
this.player.equippedAccessories.delete(id);
this.notify(`Unequipped ${item.name}`);
} else {
this.player.equippedAccessories.add(id);
this.notify(`Equipped ${item.name}!`);
}
sounds.playPowerup();
this.populateInventoryUI();
this.renderHUD();
}
});
});
grid.appendChild(card);
});
}
openShop() {
this.gameState = 'SHOP';
const modal = document.getElementById('shop-modal');
modal.classList.add('open');
this.populateShopUI();
}
closeShop() {
document.getElementById('shop-modal').classList.remove('open');
this.gameState = 'PLAYING';
}
populateShopUI() {
const list = document.getElementById('shop-items-container');
list.innerHTML = '';
document.getElementById('shop-gold-display').textContent = this.player.gold;
const shopCatalog = ['potion_heal', 'bomb_pack', 'heart_container', 'gold_ring', 'iron_buckler', 'swift_boots'];
shopCatalog.forEach(id => {
const item = ITEM_DB[id];
const row = document.createElement('div');
row.className = 'shop-item-row';
const canAfford = this.player.gold >= item.price;
const isMaxHeart = id === 'heart_container' && this.player.heartContainers >= 5;
row.innerHTML = `
<div class="shop-item-info">
<div style="font-size: 26px;">${item.icon}</div>
<div>
<div style="font-weight: bold; color: var(--text-gold); font-size: 14px;">${item.name}</div>
<div style="font-size: 11px; color: #a0b0c0;">${item.desc}</div>
</div>
</div>
<button class="btn-buy" ${(!canAfford || isMaxHeart) ? 'disabled' : ''}>
${isMaxHeart ? 'MAX (5)' : item.price + ' G'}
</button>
`;
row.querySelector('.btn-buy').addEventListener('click', () => {
if (this.player.gold >= item.price && !isMaxHeart) {
this.player.gold -= item.price;
sounds.playCoin();
if (item.type === 'upgrade') {
item.use(this.player);
} else {
this.player.inventory.push(id);
this.notify(`Purchased ${item.name}!`);
}
this.populateShopUI();
this.renderHUD();
}
});
list.appendChild(row);
});
}
updateBossBar(name, hp, maxHp) {
const wrap = document.getElementById('boss-bar-wrap');
wrap.classList.add('active');
document.getElementById('boss-name-label').textContent = name;
const pct = Math.max(0, Math.min(100, (hp / maxHp) * 100));
document.getElementById('boss-hp-fill').style.width = pct + '%';
}
hideBossBar() {
document.getElementById('boss-bar-wrap').classList.remove('active');
}
triggerGameOver() {
this.gameState = 'GAMEOVER';
sounds.playHurt();
document.getElementById('gameover-screen').style.display = 'flex';
}
respawnAtSave() {
const cp = this.player.lastSafeCheckpoint;
this.world.currentZoneId = cp.zoneId;
this.player.x = cp.x;
this.player.y = cp.y;
this.player.hp = this.player.maxHp;
this.projectiles = [];
this.bombs = [];
this.gameState = 'PLAYING';
this.notify('Awakened with renewed resolve at safe shrine.');
this.renderHUD();
}
triggerVictory() {
this.gameState = 'VICTORY';
sounds.playSave();
document.getElementById('victory-screen').style.display = 'flex';
document.getElementById('victory-stats-text').textContent = `
You achieved all ${this.player.heartContainers} Heart Containers, mastered the ${WEAPONS.SWORD[this.player.swordTier-1].name} and Mystic Bow, collected ${this.player.gold} Glimmer, and vanquished the Void Sovereign!
`;
}
updateWeaponIcons() {
const curSword = WEAPONS.SWORD[this.player.swordTier - 1];
const swordSlot = document.getElementById('slot-sword');
const bowSlot = document.getElementById('slot-bow');
swordSlot.classList.toggle('active', this.player.activeWeapon === 'sword');
bowSlot.classList.toggle('active', this.player.activeWeapon === 'bow');
document.getElementById('sword-icon-container').innerHTML = `
<svg width="28" height="28" viewBox="0 0 24 24" fill="${curSword.color}">
<path d="M19.7 4.3c-0.4-0.4-1-0.4-1.4 0L12 10.6l-2.1-2.1c-0.4-0.4-1-0.4-1.4 0s-0.4 1 0 1.4L10.6 12 4.3 18.3c-0.4 0.4-0.4 1 0 1.4 0.2 0.2 0.5 0.3 0.7 0.3s0.5-0.1 0.7-0.3L12 13.4l2.1 2.1c0.2 0.2 0.5 0.3 0.7 0.3s0.5-0.1 0.7-0.3c0.4-0.4 0.4-1 0-1.4L13.4 12l6.3-6.3c0.4-0.4 0.4-1 0-1.4z"/>
</svg>
`;
document.getElementById('bow-icon-container').innerHTML = `
<svg width="26" height="26" viewBox="0 0 24 24" fill="#64ffda">
<path d="M18 2h-2c-4.4 0-8 3.6-8 8 0 1.4 0.4 2.8 1 4L2 21l1 1 7-7c1.2 0.6 2.6 1 4 1 4.4 0 8-3.6 8-8V2h-4zm2 8c0 3.3-2.7 6-6 6-1 0-1.9-0.2-2.7-0.7l6-6V4h-5.3c-0.5 0.8-0.7 1.7-0.7 2.7 0 3.3 2.7 6 6 6h2v-2.7z"/>
</svg>
`;
document.getElementById('bomb-icon-container').innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="#ff7043">
<path d="M12 2c1.1 0 2 0.9 2 2 0 0.4-0.1 0.7-0.3 1h3.3c1.7 0 3 1.3 3 3v10c0 1.7-1.3 3-3 3H7c-1.7 0-3-1.3-3-3V8c0-1.7 1.3-3 3-3h3.3c-0.2-0.3-0.3-0.6-0.3-1 0-1.1 0.9-2 2-2z"/>
</svg>
`;
}
renderHUD() {
// Heart containers: Each heart container holds 2 HP.
const heartBar = document.getElementById('heart-bar');
heartBar.innerHTML = '';
const totalContainers = this.player.heartContainers; // 3 to 5
const currentHp = this.player.hp; // each container is 2 hp
for (let i = 0; i < totalContainers; i++) {
const containerHp = clamp(currentHp - (i * 2), 0, 2);
const heart = document.createElement('div');
heart.className = 'heart-icon';
// SVG Heart (Full, Half, or Empty)
if (containerHp === 2) {
heart.innerHTML = `<svg viewBox="0 0 24 24" fill="#ef5350" width="28" height="28"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>`;
} else if (containerHp === 1) {
heart.innerHTML = `<svg viewBox="0 0 24 24" width="28" height="28"><defs><linearGradient id="halfGrad"><stop offset="50%" stop-color="#ef5350"/><stop offset="50%" stop-color="#424242"/></linearGradient></defs><path fill="url(#halfGrad)" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>`;
} else {
heart.innerHTML = `<svg viewBox="0 0 24 24" fill="#37474f" width="28" height="28"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>`;
}
heartBar.appendChild(heart);
}
// Stamina Bar
const stamPct = (this.player.stamina / this.player.maxStamina) * 100;
document.getElementById('stamina-fill').style.width = stamPct + '%';
// Gold & Bombs & Keys
document.getElementById('gold-count').textContent = this.player.gold;
document.getElementById('key-count').textContent = this.player.keys;
document.getElementById('bomb-count').textContent = this.player.bombs;
this.updateWeaponIcons();
}
renderMinimap() {
const zone = this.world.getCurrentZone();
const mmW = this.minimapCanvas.width;
const mmH = this.minimapCanvas.height;
this.mmCtx.fillStyle = 'rgba(10, 16, 26, 0.95)';
this.mmCtx.fillRect(0, 0, mmW, mmH);
const cellW = mmW / zone.width;
const cellH = mmH / zone.height;
// Draw mini tiles
for (let y = 0; y < zone.height; y++) {
for (let x = 0; x < zone.width; x++) {
const t = zone.map[y][x];
if (t === TILES.WALL || t === TILES.CRACKED_WALL) {
this.mmCtx.fillStyle = '#37474f';
this.mmCtx.fillRect(x * cellW, y * cellH, cellW, cellH);
} else if (t === TILES.SHRINE) {
this.mmCtx.fillStyle = '#64ffda';
this.mmCtx.fillRect(x * cellW, y * cellH, cellW, cellH);
} else if (t === TILES.DOOR) {
this.mmCtx.fillStyle = '#ffd54f';
this.mmCtx.fillRect(x * cellW, y * cellH, cellW, cellH);
}
}
}
// Player blip
const pmmX = (this.player.x / (zone.width * TILE_SIZE)) * mmW;
const pmmY = (this.player.y / (zone.height * TILE_SIZE)) * mmH;
this.mmCtx.fillStyle = '#ff1744';
this.mmCtx.beginPath();
this.mmCtx.arc(pmmX, pmmY, 3, 0, Math.PI * 2);
this.mmCtx.fill();
}
update(dt) {
if (this.gameState !== 'PLAYING') return;
const zone = this.world.getCurrentZone();
// Update Player
this.player.update(dt, this.input, this.world, zone.enemies);
// Update Camera (Smooth lerp centering on player)
this.camera.targetX = this.player.x - this.canvas.width / 2;
this.camera.targetY = this.player.y - this.canvas.height / 2;
const mapMaxX = zone.width * TILE_SIZE - this.canvas.width;
const mapMaxY = zone.height * TILE_SIZE - this.canvas.height;
this.camera.targetX = clamp(this.camera.targetX, 0, Math.max(0, mapMaxX));
this.camera.targetY = clamp(this.camera.targetY, 0, Math.max(0, mapMaxY));
this.camera.x += (this.camera.targetX - this.camera.x) * 8 * dt;
this.camera.y += (this.camera.targetY - this.camera.y) * 8 * dt;
// Update Enemies
zone.enemies.forEach(e => e.update(dt, this.world, this.player));
// Update Projectiles
for (let i = this.projectiles.length - 1; i >= 0; i--) {
const p = this.projectiles[i];
p.update(dt, this.world, zone.enemies, this.player);
if (p.dead) this.projectiles.splice(i, 1);
}
// Update Bombs
for (let i = this.bombs.length - 1; i >= 0; i--) {
const b = this.bombs[i];
b.update(dt, this.world, zone.enemies, this.player);
if (b.dead) this.bombs.splice(i, 1);
}
// Update Pickups
for (let i = this.pickups.length - 1; i >= 0; i--) {
const p = this.pickups[i];
p.update(dt, this.player);
if (p.dead) this.pickups.splice(i, 1);
}
// Update Particles
this.particles.update(dt);
this.renderHUD();
}
render() {
const ctx = this.ctx;
const zone = this.world.getCurrentZone();
const cfg = ZONE_CONFIGS[this.world.currentZoneId];
// Clear background with ambient tone
ctx.fillStyle = cfg.ambientColor;
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Draw Map Tiles
const startCol = Math.max(0, Math.floor(this.camera.x / TILE_SIZE));
const endCol = Math.min(zone.width - 1, Math.ceil((this.camera.x + this.canvas.width) / TILE_SIZE));
const startRow = Math.max(0, Math.floor(this.camera.y / TILE_SIZE));
const endRow = Math.min(zone.height - 1, Math.ceil((this.camera.y + this.canvas.height) / TILE_SIZE));
for (let y = startRow; y <= endRow; y++) {
for (let x = startCol; x <= endCol; x++) {
const tile = zone.map[y][x];
const sx = x * TILE_SIZE - this.camera.x;
const sy = y * TILE_SIZE - this.camera.y;
if (tile === TILES.GRASS) {
ctx.fillStyle = '#1e3d1a';
ctx.fillRect(sx, sy, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = '#2d5a27';
ctx.fillRect(sx + 1, sy + 1, TILE_SIZE - 2, TILE_SIZE - 2);
ctx.fillStyle = '#3e7b36';
ctx.fillRect(sx + 8, sy + 12, 4, 8);
ctx.fillRect(sx + 24, sy + 20, 5, 6);
} else if (tile === TILES.DIRT) {
ctx.fillStyle = '#4e342e';
ctx.fillRect(sx, sy, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = '#6d4c41';
ctx.fillRect(sx + 2, sy + 2, TILE_SIZE - 4, TILE_SIZE - 4);
ctx.fillStyle = '#3e2723';
ctx.fillRect(sx + 10, sy + 14, 6, 4);
ctx.fillRect(sx + 22, sy + 8, 4, 4);
} else if (tile === TILES.STONE_FLOOR) {
ctx.fillStyle = '#1e282d';
ctx.fillRect(sx, sy, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = '#2c3940';
ctx.fillRect(sx + 1, sy + 1, TILE_SIZE - 2, TILE_SIZE - 2);
ctx.strokeStyle = '#182024';
ctx.lineWidth = 1;
ctx.strokeRect(sx + 2, sy + 2, TILE_SIZE - 4, TILE_SIZE - 4);
} else if (tile === TILES.WALL) {
ctx.fillStyle = '#11171d';
ctx.fillRect(sx, sy, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = '#212d38';
ctx.fillRect(sx + 2, sy + 2, TILE_SIZE - 4, TILE_SIZE - 12);
ctx.fillStyle = '#182028';
ctx.fillRect(sx, sy + TILE_SIZE - 8, TILE_SIZE, 8);
ctx.strokeStyle = '#2d3d4c';
ctx.lineWidth = 1;
ctx.strokeRect(sx + 2, sy + 2, TILE_SIZE - 4, TILE_SIZE - 12);
} else if (tile === TILES.CRACKED_WALL) {
ctx.fillStyle = '#261815';
ctx.fillRect(sx, sy, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = '#3e2723';
ctx.fillRect(sx + 2, sy + 2, TILE_SIZE - 4, TILE_SIZE - 10);
ctx.strokeStyle = '#ff9800';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx + 6, sy + 6);
ctx.lineTo(sx + 20, sy + 20);
ctx.lineTo(sx + 34, sy + 10);
ctx.stroke();
} else if (tile === TILES.DOOR) {
ctx.fillStyle = '#1c1708';
ctx.fillRect(sx, sy, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = '#c69b3f';
ctx.fillRect(sx + 4, sy + 4, TILE_SIZE - 8, TILE_SIZE - 8);
ctx.strokeStyle = '#ffe082';
ctx.lineWidth = 2;
ctx.strokeRect(sx + 4, sy + 4, TILE_SIZE - 8, TILE_SIZE - 8);
} else if (tile === TILES.SHRINE) {
ctx.fillStyle = '#0a231f';
ctx.fillRect(sx, sy, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = '#00695c';
ctx.beginPath();
ctx.arc(sx + 20, sy + 20, 15, 0, Math.PI * 2);
ctx.fill();
} else if (tile === TILES.SHOP_CARPET) {
ctx.fillStyle = '#4a148c';
ctx.fillRect(sx, sy, TILE_SIZE, TILE_SIZE);
ctx.strokeStyle = '#ab47bc';
ctx.lineWidth = 1.5;
ctx.strokeRect(sx + 2, sy + 2, TILE_SIZE - 4, TILE_SIZE - 4);
} else if (tile === TILES.LAVA) {
ctx.fillStyle = '#bf360c';
ctx.fillRect(sx, sy, TILE_SIZE, TILE_SIZE);
ctx.fillStyle = '#ff6f00';
ctx.fillRect(sx + 6, sy + 6, TILE_SIZE - 12, TILE_SIZE - 12);
}
}
}
// Render Interactables (Shrines, Pedestals, Chests, Gates)
zone.interactables.forEach(item => {
const sx = item.x - this.camera.x;
const sy = item.y - this.camera.y;
if (item.type === 'shrine') {
const glow = Math.sin(Date.now() * 0.004) * 4;
ctx.fillStyle = '#26a69a';
ctx.beginPath();
ctx.arc(sx, sy, 16 + glow, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e0f2f1';
ctx.beginPath();
ctx.arc(sx, sy, 8, 0, Math.PI * 2);
ctx.fill();
} else if (item.type === 'pedestal_weapon1') {
ctx.fillStyle = '#ffa726';
ctx.beginPath();
ctx.arc(sx, sy, 14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.fillText('🗡️', sx - 8, sy + 6);
} else if (item.type === 'pedestal_weapon2') {
ctx.fillStyle = '#00e5ff';
ctx.beginPath();
ctx.arc(sx, sy, 16, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.fillText('⚡', sx - 6, sy + 6);
} else if (item.type === 'chest') {
ctx.fillStyle = item.opened ? '#5d4037' : '#ffd54f';
ctx.fillRect(sx - 12, sy - 10, 24, 20);
ctx.strokeStyle = '#3e2723';
ctx.strokeRect(sx - 12, sy - 10, 24, 20);
} else if (item.type === 'ranged_switch') {
ctx.fillStyle = item.activated ? '#64ffda' : '#ef5350';
ctx.beginPath();
ctx.arc(sx, sy, 12, 0, Math.PI * 2);
ctx.fill();
}
});
// Render Props (Bushes)
zone.props.forEach(p => {
if (p.hp > 0) {
const sx = p.x - this.camera.x;
const sy = p.y - this.camera.y;
ctx.fillStyle = '#1b5e20';
ctx.beginPath();
ctx.arc(sx, sy, 14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#2e7d32';
ctx.beginPath();
ctx.arc(sx - 3, sy - 3, 9, 0, Math.PI * 2);
ctx.fill();
}
});
// Render NPCs
zone.npcs.forEach(npc => {
const sx = npc.x - this.camera.x;
const sy = npc.y - this.camera.y;
ctx.fillStyle = '#3949ab';
ctx.beginPath();
ctx.arc(sx, sy, 16, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffca28';
ctx.font = '16px serif';
ctx.fillText('💬', sx - 8, sy - 18);
});
// Render Pickups
this.pickups.forEach(p => p.render(ctx, this.camera));
// Render Bombs
this.bombs.forEach(b => b.render(ctx, this.camera));
// Render Enemies & Bosses
zone.enemies.forEach(e => e.render(ctx, this.camera));
// Render Player
this.player.render(ctx, this.camera);
// Render Projectiles
this.projectiles.forEach(p => p.render(ctx, this.camera));
// Render Particles
this.particles.render(ctx, this.camera);
// Minimap
this.renderMinimap();
}
loop(timestamp) {
const dt = Math.min(0.05, (timestamp - this.lastTime) / 1000);
this.lastTime = timestamp;
this.update(dt);
this.render();
requestAnimationFrame(t => this.loop(t));
}
start() {
this.loop(performance.now());
}
}
// Instantiate and start engine
const game = new GameManager();
game.start();
</script>
</body>
</html>
<!-- agent-meta {"model":"google/gemini-3.7-flash","provider":"openrouter","persona":"full","sessionId":"b9d0162a-7713-4d66-b326-8e4617aebf0d","tokensIn":584597,"tokensOut":46015,"tokensTotal":630612,"cost":0.24213405000000002,"turns":12,"toolCalls":11,"failedToolCalls":0,"timestamp":"2026-08-15T15:20:25.284Z"} -->