Warden
Gemini 3.8 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 100,961 bytes · SHA-256 03ba28f694f3
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Warden's Corrosion</title>
<style>
:root {
--gold: #f5c542;
--gold-glow: rgba(245, 197, 66, 0.4);
--orange: #e66826;
--orange-glow: rgba(230, 104, 38, 0.5);
--slate: #151a24;
--slate-deep: #0a0d14;
--slate-panel: rgba(20, 26, 38, 0.94);
--slate-border: #334155;
--corrosion: #1c222b;
--aether: #5eead4;
--aether-glow: rgba(94, 234, 212, 0.5);
--ruby: #ef4444;
--ruby-dim: #7f1d1d;
--font-serif: "Cinzel", "Trajan Pro", "Palatino Linotype", "Book Antiqua", Georgia, serif;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
}
body, html {
width: 100%;
height: 100%;
background: var(--slate-deep);
color: #e2e8f0;
font-family: var(--font-sans);
overflow: hidden;
position: relative;
}
#game-container {
position: relative;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: #000;
}
#viewport-wrapper {
position: relative;
box-shadow: 0 0 50px rgba(0,0,0,0.8), 0 0 20px rgba(245, 197, 66, 0.1);
border: 2px solid #2a3447;
border-radius: 4px;
overflow: hidden;
}
canvas#gameCanvas {
display: block;
background: #0c1017;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/* Screen effects */
#vignette {
position: absolute;
inset: 0;
pointer-events: none;
box-shadow: inset 0 0 100px rgba(0,0,0,0.8);
transition: opacity 0.3s;
}
#damage-flash {
position: absolute;
inset: 0;
background: radial-gradient(circle, transparent 40%, rgba(239, 68, 68, 0.65) 100%);
opacity: 0;
pointer-events: none;
transition: opacity 0.08s ease-out;
}
#upgrade-halo {
position: absolute;
inset: 0;
border: 6px solid var(--gold);
box-shadow: inset 0 0 50px var(--gold-glow);
opacity: 0;
pointer-events: none;
transition: opacity 0.5s ease-out;
}
/* HUD Overlay */
#hud {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 16px 22px;
display: flex;
justify-content: space-between;
align-items: flex-start;
pointer-events: none;
z-index: 10;
}
.hud-left {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.zone-title-bar {
font-family: var(--font-serif);
font-size: 13px;
letter-spacing: 2px;
text-transform: uppercase;
color: #94a3b8;
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
display: flex;
align-items: center;
gap: 8px;
}
.zone-title-bar .accent {
color: var(--gold);
font-weight: bold;
}
/* Hearts System */
.hearts-row {
display: flex;
gap: 6px;
align-items: center;
}
.heart-container {
width: 26px;
height: 26px;
position: relative;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.7));
}
.heart-container svg {
width: 100%;
height: 100%;
display: block;
}
.heart-bg {
fill: #1e2530;
stroke: #475569;
stroke-width: 1.5;
}
.heart-fill {
fill: var(--ruby);
transition: clip-path 0.2s ease-out;
}
/* Gold display */
.gold-pill {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(15, 23, 42, 0.85);
border: 1px solid #334155;
padding: 5px 12px;
border-radius: 20px;
font-family: var(--font-serif);
font-size: 14px;
color: var(--gold);
font-weight: 600;
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
}
.gold-pill svg {
width: 16px;
height: 16px;
fill: var(--gold);
}
.hud-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.quick-weapon-status {
display: flex;
align-items: center;
gap: 12px;
background: rgba(15, 23, 42, 0.85);
border: 1px solid #334155;
padding: 6px 14px;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
.weapon-icon-box {
display: flex;
flex-direction: column;
align-items: center;
font-size: 10px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 1px;
font-family: var(--font-serif);
}
.weapon-icon-box .slot-label {
font-size: 9px;
color: #64748b;
margin-bottom: 2px;
}
.weapon-tag {
font-size: 12px;
font-weight: 700;
color: var(--gold);
}
/* Quick Prompts / Controls banner */
#controls-hint {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
background: rgba(15, 23, 42, 0.75);
border: 1px solid rgba(255,255,255,0.1);
padding: 6px 16px;
border-radius: 20px;
font-size: 11px;
letter-spacing: 0.5px;
color: #cbd5e1;
pointer-events: none;
z-index: 10;
display: flex;
gap: 14px;
backdrop-filter: blur(4px);
}
.key-chip {
background: #334155;
color: #fff;
padding: 1px 6px;
border-radius: 4px;
font-weight: 600;
font-size: 10px;
border: 1px solid #475569;
}
/* Interaction prompt over game elements */
#prompt-banner {
position: absolute;
top: 75%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(10, 15, 25, 0.92);
border: 1px solid var(--gold);
color: #fff;
padding: 10px 22px;
border-radius: 8px;
font-family: var(--font-serif);
font-size: 13px;
letter-spacing: 1px;
box-shadow: 0 0 25px rgba(245, 197, 66, 0.25);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
z-index: 15;
text-align: center;
}
#prompt-banner.active {
opacity: 1;
transform: translate(-50%, -60%);
}
/* MODALS & PANELS: Inventory, Shop, Dialogues, Beacon End */
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(6, 9, 15, 0.85);
backdrop-filter: blur(6px);
display: none;
justify-content: center;
align-items: center;
z-index: 30;
pointer-events: auto;
}
.modal-overlay.active {
display: flex;
}
.panel-window {
width: 620px;
max-width: 90%;
background: var(--slate-panel);
border: 2px solid var(--gold);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.9), 0 0 30px var(--gold-glow);
border-radius: 8px;
padding: 24px 28px;
position: relative;
}
.panel-header {
border-bottom: 1px solid #334155;
padding-bottom: 12px;
margin-bottom: 18px;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-title {
font-family: var(--font-serif);
font-size: 20px;
color: var(--gold);
letter-spacing: 2px;
text-transform: uppercase;
}
.panel-close-hint {
font-size: 11px;
color: #94a3b8;
}
.panel-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.inventory-card {
background: rgba(15, 23, 42, 0.6);
border: 1px solid #334155;
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
transition: border-color 0.2s, transform 0.15s;
}
.inventory-card.equipped {
border-color: var(--aether);
box-shadow: inset 0 0 10px rgba(94, 234, 212, 0.15);
}
.inventory-card.equipped::after {
content: "EQUIPPED";
position: absolute;
top: 8px;
right: 8px;
font-size: 9px;
font-weight: bold;
color: var(--aether);
letter-spacing: 1px;
}
.item-name {
font-family: var(--font-serif);
font-size: 14px;
font-weight: 700;
color: #f1f5f9;
}
.item-desc {
font-size: 11px;
line-height: 1.4;
color: #94a3b8;
}
.item-actions {
margin-top: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn {
background: #1e293b;
color: #f1f5f9;
border: 1px solid #475569;
padding: 6px 14px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
cursor: pointer;
transition: all 0.15s;
}
.btn:hover {
background: var(--gold);
color: #000;
border-color: var(--gold);
}
.btn.btn-accent {
background: #b45309;
border-color: var(--gold);
color: #fff;
}
.btn.btn-accent:hover {
background: var(--gold);
color: #000;
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
background: #1e293b !important;
color: #64748b !important;
border-color: #334155 !important;
}
/* Shop Specifics */
.shop-item-row {
background: rgba(15, 23, 42, 0.6);
border: 1px solid #334155;
border-radius: 6px;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.shop-item-info h4 {
font-family: var(--font-serif);
color: #fff;
font-size: 14px;
margin-bottom: 2px;
}
.shop-item-info p {
font-size: 11px;
color: #94a3b8;
}
.shop-item-action {
display: flex;
align-items: center;
gap: 12px;
}
.price-tag {
color: var(--gold);
font-weight: bold;
font-size: 13px;
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-serif);
}
/* Notification Banner */
#toast {
position: absolute;
bottom: 48px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: rgba(12, 17, 26, 0.95);
border: 1px solid var(--gold);
color: #f1f5f9;
padding: 10px 24px;
border-radius: 30px;
font-family: var(--font-serif);
font-size: 13px;
letter-spacing: 1px;
box-shadow: 0 4px 20px rgba(0,0,0,0.8), 0 0 15px var(--gold-glow);
opacity: 0;
pointer-events: none;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 40;
text-align: center;
}
#toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* Zone title splash */
#zone-splash {
position: absolute;
top: 30%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
opacity: 0;
transition: opacity 0.8s ease-in-out;
z-index: 25;
}
#zone-splash.active {
opacity: 1;
}
#zone-splash h1 {
font-family: var(--font-serif);
font-size: 32px;
letter-spacing: 6px;
text-transform: uppercase;
color: #fff;
text-shadow: 0 0 20px rgba(245, 197, 66, 0.8);
margin-bottom: 6px;
}
#zone-splash p {
font-size: 13px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--gold);
}
/* End Game Overlay */
#victory-modal {
position: absolute;
inset: 0;
background: radial-gradient(circle, #1a2233 0%, #06090e 100%);
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 100;
text-align: center;
padding: 30px;
}
#victory-modal.active {
display: flex;
}
#victory-modal h1 {
font-family: var(--font-serif);
font-size: 40px;
letter-spacing: 6px;
color: var(--gold);
text-shadow: 0 0 30px var(--gold-glow);
margin-bottom: 16px;
}
#victory-modal p {
max-width: 600px;
font-size: 15px;
line-height: 1.7;
color: #cbd5e1;
margin-bottom: 24px;
}
/* Responsive touch controls on mobile */
#mobile-controls {
display: none;
position: absolute;
bottom: 20px;
left: 0;
right: 0;
padding: 0 20px;
justify-content: space-between;
pointer-events: none;
z-index: 20;
}
@media (max-width: 800px) {
#mobile-controls { display: flex; }
#controls-hint { display: none; }
}
.touch-dpad, .touch-actions {
display: flex;
gap: 8px;
pointer-events: auto;
}
.touch-btn {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(30, 41, 59, 0.85);
border: 1px solid #475569;
color: #fff;
font-size: 14px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
/* Audio mute toggle */
#sound-btn {
position: absolute;
top: 16px;
right: 16px;
background: rgba(15, 23, 42, 0.85);
border: 1px solid #334155;
color: #94a3b8;
padding: 6px 12px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
z-index: 15;
font-family: var(--font-serif);
letter-spacing: 1px;
}
#sound-btn:hover {
color: var(--gold);
border-color: var(--gold);
}
</style>
</head>
<body>
<div id="game-container">
<div id="viewport-wrapper">
<canvas id="gameCanvas" width="960" height="600"></canvas>
<div id="vignette"></div>
<div id="damage-flash"></div>
<div id="upgrade-halo"></div>
<!-- Sound Toggle inside HUD right -->
<div id="hud">
<div class="hud-left">
<div class="zone-title-bar">
<span id="hud-zone-name">THE ASHEN THRESHOLD</span>
<span class="accent">•</span>
<span id="hud-zone-sub">ZONE I</span>
</div>
<div class="hearts-row" id="hearts-container">
<!-- Dynamically populated hearts -->
</div>
<div class="gold-pill">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="#f5c542" stroke-width="2" fill="#d97706"/><polygon points="12,5 15,12 12,19 9,12" fill="#fef08a"/></svg>
<span id="gold-counter">0</span> SIGILS
</div>
</div>
<div class="hud-right">
<div style="display: flex; gap: 8px; align-items: center;">
<div class="quick-weapon-status">
<div class="weapon-icon-box">
<span class="slot-label">MELEE [J/Z]</span>
<span class="weapon-tag" id="hud-melee-name">EMBER BLADE</span>
</div>
<div class="weapon-icon-box" id="hud-ranged-box" style="opacity: 0.35;">
<span class="slot-label">RANGED [K/X]</span>
<span class="weapon-tag" id="hud-ranged-name">LOCKED</span>
</div>
<button class="btn" id="btn-open-inventory" style="pointer-events:auto; margin-left:6px;" onclick="toggleInventory()">BAG [I]</button>
</div>
<button id="sound-btn" style="pointer-events:auto; position:static;" onclick="toggleAudio()">AUDIO: ON</button>
</div>
</div>
</div>
<!-- Zone Splash Display -->
<div id="zone-splash">
<h1 id="splash-title">THE ASHEN THRESHOLD</h1>
<p id="splash-subtitle">Aurelion's Sunken Observatory</p>
</div>
<!-- Context Interaction Prompt -->
<div id="prompt-banner">PRESS <span class="key-chip">E</span> TO INTERACT</div>
<!-- Controls Bar -->
<div id="controls-hint">
<span><span class="key-chip">WASD / ARROWS</span> Move</span>
<span><span class="key-chip">J / Z / SPACE</span> Melee</span>
<span><span class="key-chip">K / X</span> Aether Shard</span>
<span><span class="key-chip">E</span> Interact</span>
<span><span class="key-chip">I / TAB</span> Inventory</span>
</div>
<!-- Toast Notification -->
<div id="toast">Checkpoint Reached</div>
<!-- INVENTORY MODAL -->
<div class="modal-overlay" id="inventory-modal">
<div class="panel-window">
<div class="panel-header">
<div class="panel-title">WARDEN'S SATCHEL</div>
<div class="panel-close-hint">Press <span class="key-chip">I</span> or <span class="key-chip">ESC</span> to return</div>
</div>
<div style="margin-bottom: 12px; font-size: 12px; color: #94a3b8;">
Active Equipment & Consumables. Only one accessory active at a time.
</div>
<div class="panel-grid" id="inventory-items-grid">
<!-- Dynamically populated items -->
</div>
<div style="display:flex; justify-content:space-between; align-items:center; border-top:1px solid #334155; padding-top:14px;">
<div style="font-size:12px; color:#cbd5e1;">
Equipped: <strong style="color:var(--gold)" id="equipped-accessory-label">None</strong>
</div>
<button class="btn" onclick="toggleInventory()">Close</button>
</div>
</div>
</div>
<!-- SHOP MODAL -->
<div class="modal-overlay" id="shop-modal">
<div class="panel-window" style="width: 660px;">
<div class="panel-header">
<div class="panel-title">MERCHANT OF THE ARCHIVES</div>
<div class="price-tag"><span id="shop-gold-display">0</span> SIGILS</div>
</div>
<div style="margin-bottom: 16px; font-size: 12px; color: #94a3b8; font-style: italic;">
"The black-iron corrosion devours all, Warden... Exchange your sigils for surviving antiquities."
</div>
<div id="shop-items-container">
<!-- Dynamically populated shop stock -->
</div>
<div style="display:flex; justify-content:flex-end; border-top:1px solid #334155; padding-top:14px; margin-top:12px;">
<button class="btn" onclick="closeShop()">Depart Altar</button>
</div>
</div>
</div>
<!-- VICTORY / RESOLUTION MODAL -->
<div id="victory-modal">
<h1>THE BEACON REIGNITED</h1>
<p>
The celestial gears groan into life. Purifying solar light floods through Aurelion's Sunken Observatory, dissolving the black-iron corrosion that choked its ancient towers.
</p>
<p style="color: var(--gold); font-family: var(--font-serif); font-size: 17px;">
The Last Warden stands vigilant as the storm clouds break above the valley.
</p>
<div style="margin-top: 20px; display:flex; gap:12px;">
<button class="btn btn-accent" onclick="restartGame()">Begin Anew (New Cycle)</button>
</div>
</div>
</div>
</div>
<script>
/* =========================================================================
THE WARDEN'S CORROSION - GAME ENGINE & SYSTEM IMPLEMENTATION
========================================================================= */
// --- AUDIO SYNTHESIS SYSTEM (Web Audio API) ---
class SoundEngine {
constructor() {
this.ctx = null;
this.enabled = true;
}
init() {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioCtx();
}
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
}
playStep(type = 'stone') {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
if (type === 'metal') {
osc.type = 'triangle';
osc.frequency.setValueAtTime(320 + Math.random() * 40, t);
osc.frequency.exponentialRampToValueAtTime(140, t + 0.08);
filter.type = 'bandpass';
filter.frequency.value = 600;
gain.gain.setValueAtTime(0.06, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.08);
} else if (type === 'water') {
osc.type = 'sine';
osc.frequency.setValueAtTime(450, t);
osc.frequency.exponentialRampToValueAtTime(180, t + 0.12);
filter.type = 'lowpass';
filter.frequency.value = 800;
gain.gain.setValueAtTime(0.08, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.12);
} else {
// stone / bronze grind
osc.type = 'triangle';
osc.frequency.setValueAtTime(160 + Math.random() * 20, t);
osc.frequency.exponentialRampToValueAtTime(60, t + 0.06);
filter.type = 'lowpass';
filter.frequency.value = 400;
gain.gain.setValueAtTime(0.07, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.06);
}
osc.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.12);
}
playAttack(upgradeLevel) {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
if (upgradeLevel === 0) {
// Base Ember Blade: Sharp whoosh + crisp impact thud
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(400, t);
osc.frequency.exponentialRampToValueAtTime(120, t + 0.15);
gain.gain.setValueAtTime(0.2, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.15);
// Noise whoosh
this.playWhiteNoise(0.12, 0.15, 800);
} else if (upgradeLevel === 1) {
// Ignited Edge: Whoosh + flame crackle + deeper resonant thud
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(320, t);
osc.frequency.exponentialRampToValueAtTime(90, t + 0.22);
gain.gain.setValueAtTime(0.25, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.22);
// flame crackle burst
this.playWhiteNoise(0.2, 0.2, 1600);
} else {
// Solar Falchion: Heavy wind-up + resonant clang + low bass rumble
const bass = this.ctx.createOscillator();
const bassGain = this.ctx.createGain();
bass.type = 'triangle';
bass.frequency.setValueAtTime(140, t);
bass.frequency.exponentialRampToValueAtTime(40, t + 0.38);
bassGain.gain.setValueAtTime(0.4, t);
bassGain.gain.exponentialRampToValueAtTime(0.001, t + 0.38);
bass.connect(bassGain);
bassGain.connect(this.ctx.destination);
bass.start(t);
bass.stop(t + 0.38);
// Clang
const clang = this.ctx.createOscillator();
const clangGain = this.ctx.createGain();
clang.type = 'sine';
clang.frequency.setValueAtTime(880, t);
clang.frequency.exponentialRampToValueAtTime(220, t + 0.3);
clangGain.gain.setValueAtTime(0.25, t);
clangGain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
clang.connect(clangGain);
clangGain.connect(this.ctx.destination);
clang.start(t);
clang.stop(t + 0.3);
}
}
playAetherShoot() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
// Sharp crystal twang
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(1100, t);
osc.frequency.exponentialRampToValueAtTime(320, t + 0.18);
gain.gain.setValueAtTime(0.25, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.18);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.18);
}
playAetherHit() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
// Distant impact chime
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(784, t);
osc.frequency.setValueAtTime(987, t + 0.05);
gain.gain.setValueAtTime(0.18, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.35);
}
playDamage() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
// Dull body-thud + brief high-pitch dissonant chime
const thud = this.ctx.createOscillator();
const thudGain = this.ctx.createGain();
thud.type = 'triangle';
thud.frequency.setValueAtTime(90, t);
thud.frequency.exponentialRampToValueAtTime(30, t + 0.18);
thudGain.gain.setValueAtTime(0.35, t);
thudGain.gain.exponentialRampToValueAtTime(0.001, t + 0.18);
thud.connect(thudGain);
thudGain.connect(this.ctx.destination);
thud.start(t);
thud.stop(t + 0.18);
const chime = this.ctx.createOscillator();
const chimeGain = this.ctx.createGain();
chime.type = 'sawtooth';
chime.frequency.setValueAtTime(630, t);
chime.frequency.setValueAtTime(610, t + 0.08); // Dissonant shift
chimeGain.gain.setValueAtTime(0.15, t);
chimeGain.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
chime.connect(chimeGain);
chimeGain.connect(this.ctx.destination);
chime.start(t);
chime.stop(t + 0.25);
}
playEnemyHit() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(240, t);
osc.frequency.exponentialRampToValueAtTime(70, t + 0.1);
gain.gain.setValueAtTime(0.2, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.1);
}
playEnemyDeath() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(180, t);
osc.frequency.exponentialRampToValueAtTime(35, t + 0.3);
gain.gain.setValueAtTime(0.3, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.3);
this.playWhiteNoise(0.25, 0.25, 500);
}
playCoin() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
// Metallic clink
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(1320, t);
osc.frequency.setValueAtTime(1760, t + 0.05);
gain.gain.setValueAtTime(0.12, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.14);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.14);
}
playPickup() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
// Ascending melodic chime
[523.25, 659.25, 783.99, 1046.5].forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, t + i * 0.07);
gain.gain.setValueAtTime(0.15, t + i * 0.07);
gain.gain.exponentialRampToValueAtTime(0.001, t + i * 0.07 + 0.22);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t + i * 0.07);
osc.stop(t + i * 0.07 + 0.25);
});
}
playRareItem() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
// Resonant chord
[440, 554.37, 659.25, 880].forEach(freq => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, t);
gain.gain.setValueAtTime(0.12, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.6);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.65);
});
}
playSanctuarySave() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
[329.63, 392.00, 493.88, 587.33, 659.25].forEach((freq, idx) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, t + idx * 0.08);
gain.gain.setValueAtTime(0.15, t + idx * 0.08);
gain.gain.exponentialRampToValueAtTime(0.001, t + idx * 0.08 + 0.45);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t + idx * 0.08);
osc.stop(t + idx * 0.08 + 0.5);
});
}
playAltarUpgrade() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
// Deep majestic fanfare
[261.63, 329.63, 392.00, 523.25, 659.25, 783.99].forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = i < 3 ? 'triangle' : 'sine';
osc.frequency.setValueAtTime(freq, t + i * 0.09);
gain.gain.setValueAtTime(0.18, t + i * 0.09);
gain.gain.exponentialRampToValueAtTime(0.001, t + i * 0.09 + 0.7);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t + i * 0.09);
osc.stop(t + i * 0.09 + 0.75);
});
}
playShopBuy() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
// Coin drop + confirmation chime
this.playCoin();
setTimeout(() => {
if (!this.enabled || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(987.77, this.ctx.currentTime);
gain.gain.setValueAtTime(0.18, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(this.ctx.currentTime);
osc.stop(this.ctx.currentTime + 0.35);
}, 80);
}
playSwitchClick() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(360, t);
osc.frequency.exponentialRampToValueAtTime(180, t + 0.09);
gain.gain.setValueAtTime(0.18, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.09);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.09);
}
playWallBreak() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(110, t);
osc.frequency.exponentialRampToValueAtTime(30, t + 0.28);
gain.gain.setValueAtTime(0.3, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.28);
this.playWhiteNoise(0.2, 0.25, 450);
}
playFreeze() {
if (!this.enabled) return;
this.init();
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(1400, t);
osc.frequency.exponentialRampToValueAtTime(400, t + 0.3);
gain.gain.setValueAtTime(0.2, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.35);
}
playWhiteNoise(duration, vol, freqCutoff = 1000) {
if (!this.ctx) return;
const bufferSize = this.ctx.sampleRate * duration;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = this.ctx.createBufferSource();
noise.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = freqCutoff;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(vol, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
noise.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
noise.start();
}
}
const audio = new SoundEngine();
function toggleAudio() {
audio.enabled = !audio.enabled;
document.getElementById('sound-btn').innerText = audio.enabled ? 'AUDIO: ON' : 'AUDIO: MUTED';
}
/* =========================================================================
GAME STATE & DATA DEFINITIONS
========================================================================= */
const ZONES = [
{ id: 0, name: "The Ashen Threshold", sub: "ZONE I • Prologue", theme: "ashen", floorType: "stone" },
{ id: 1, name: "The Rusted Archives", sub: "ZONE II • Early Archives", theme: "bronze", floorType: "metal" },
{ id: 2, name: "The Collapsed Gallery", sub: "ZONE III • Mid-Early", theme: "gallery", floorType: "stone" },
{ id: 3, name: "The Sunken Vault", sub: "ZONE IV • Turning Point", theme: "sunken", floorType: "water" },
{ id: 4, name: "The Twin Spire", sub: "ZONE V • Late Gauntlet", theme: "spire", floorType: "metal" },
{ id: 5, name: "The Core Chamber", sub: "ZONE VI • Climax", theme: "core", floorType: "stone" }
];
// Player State
const player = {
x: 200,
y: 300,
vx: 0,
vy: 0,
speed: 3.4,
radius: 14,
facing: { x: 1, y: 0 },
maxContainers: 3, // Target is 5 max. 1 container = 2 HP
hp: 6, // Current HP (starts at 3 * 2 = 6)
gold: 0,
weaponUpgrade: 0, // 0: Ember Blade, 1: Ignited Edge (Zone 3), 2: Solar Falchion (Zone 5)
hasRanged: false, // Aether Shard acquired in Zone 4
rangedAmmo: 20,
maxRangedAmmo: 30,
equippedAccessory: null, // "wardenBand", "phantomLantern", "shadowCloak"
inventory: {
// Accessories
wardenBand: false,
phantomLantern: false,
shadowCloak: false,
// Consumables
vitalStem: 1, // Restores 2 containers (4 HP)
bindingSeal: 0 // Freezes one enemy in place ~3 sec
},
// Attack state
isAttacking: false,
attackTimer: 0,
attackDuration: 0.22,
attackCooldown: 0,
rangedCooldown: 0,
invulnerableTimer: 0,
footstepTimer: 0,
// Animation
walkCycle: 0,
moving: false
};
// Checkpoint state
let activeSavePoint = 0; // 0: Not yet saved, 1, 2, 3
let lastCheckpointState = null;
// Game Systems State
let currentZone = 0;
let enemies = [];
let projectiles = [];
let particles = [];
let floatingTexts = [];
let mapEntities = []; // Chests, switches, walls, altars, merchants, etc.
let promptContext = null;
let isShopOpen = false;
let isInventoryOpen = false;
let isGameWon = false;
// Input Keys
const keys = {
up: false,
down: false,
left: false,
right: false,
attackMelee: false,
attackRanged: false,
interact: false
};
/* =========================================================================
ZONE LAYOUT & ENTITY POPULATION
========================================================================= */
class Wall {
constructor(x, y, w, h, isBreakable = false, hp = 3, id = null) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.isBreakable = isBreakable;
this.maxHp = hp;
this.hp = hp;
this.id = id;
this.broken = false;
}
damage(amount) {
if (!this.isBreakable || this.broken) return;
this.hp -= amount;
createSparks(this.x + this.w/2, this.y + this.h/2, '#94a3b8', 6);
audio.playEnemyHit();
if (this.hp <= 0) {
this.broken = true;
audio.playWallBreak();
createSparks(this.x + this.w/2, this.y + this.h/2, '#cbd5e1', 20);
showToast("Weakened Barrier Shattered!");
}
}
draw(ctx) {
if (this.broken) return;
ctx.save();
if (this.isBreakable) {
// Dark discolored wall with hairline cracks
ctx.fillStyle = '#1e2633';
ctx.fillRect(this.x, this.y, this.w, this.h);
ctx.strokeStyle = '#3b4b66';
ctx.lineWidth = 2;
ctx.strokeRect(this.x, this.y, this.w, this.h);
// Cracks pattern
ctx.strokeStyle = player.equippedAccessory === 'phantomLantern' ? 'rgba(94, 234, 212, 0.9)' : 'rgba(255, 180, 100, 0.6)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(this.x + 6, this.y + 6);
ctx.lineTo(this.x + this.w * 0.45, this.y + this.h * 0.5);
ctx.lineTo(this.x + this.w * 0.35, this.y + this.h - 6);
ctx.moveTo(this.x + this.w * 0.45, this.y + this.h * 0.5);
ctx.lineTo(this.x + this.w - 8, this.y + this.h * 0.3);
ctx.stroke();
if (player.equippedAccessory === 'phantomLantern') {
// Phantom lantern soft blue glow revealing hidden cracks
ctx.fillStyle = 'rgba(94, 234, 212, 0.2)';
ctx.fillRect(this.x, this.y, this.w, this.h);
}
} else {
// Sturdy ancient bronze/stone boundary
ctx.fillStyle = '#161c28';
ctx.fillRect(this.x, this.y, this.w, this.h);
ctx.strokeStyle = '#2d3748';
ctx.lineWidth = 2;
ctx.strokeRect(this.x, this.y, this.w, this.h);
// Bronze rivets / aesthetic lines
ctx.fillStyle = '#2b394f';
ctx.fillRect(this.x + 4, this.y + 4, 3, 3);
ctx.fillRect(this.x + this.w - 7, this.y + 4, 3, 3);
ctx.fillRect(this.x + 4, this.y + this.h - 7, 3, 3);
ctx.fillRect(this.x + this.w - 7, this.y + this.h - 7, 3, 3);
}
ctx.restore();
}
}
class SanctuaryStone {
constructor(x, y, stoneId) {
this.x = x;
this.y = y;
this.stoneId = stoneId; // 1, 2, 3
this.radius = 24;
this.active = false;
this.glowTimer = 0;
}
update(dt) {
this.glowTimer += dt * 2.5;
}
activate() {
this.active = true;
activeSavePoint = this.stoneId;
// Save checkpoint
saveCheckpoint();
// Heal player completely
player.hp = player.maxContainers * 2;
updateHealthUI();
audio.playSanctuarySave();
createSparks(this.x, this.y, '#f5c542', 25);
showToast(`Sanctuary Stone ${this.stoneId} Activated — Wounds Cleansed & Progress Saved`);
}
draw(ctx) {
ctx.save();
// Base pedestal
ctx.fillStyle = '#1e293b';
ctx.strokeStyle = this.active ? '#f5c542' : '#64748b';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Inner glowing celestial rune
const pulse = Math.sin(this.glowTimer) * 0.3 + 0.7;
ctx.fillStyle = this.active ? `rgba(245, 197, 66, ${pulse})` : 'rgba(100, 116, 139, 0.4)';
ctx.beginPath();
ctx.arc(this.x, this.y, 11, 0, Math.PI * 2);
ctx.fill();
// Rune spikes
ctx.strokeStyle = this.active ? '#fef08a' : '#94a3b8';
ctx.lineWidth = 2;
for (let i = 0; i < 4; i++) {
const angle = (Math.PI / 2) * i + (this.active ? this.glowTimer * 0.2 : 0);
ctx.beginPath();
ctx.moveTo(this.x + Math.cos(angle) * 7, this.y + Math.sin(angle) * 7);
ctx.lineTo(this.x + Math.cos(angle) * 20, this.y + Math.sin(angle) * 20);
ctx.stroke();
}
// Ambient floating golden motes if active
if (this.active && Math.random() < 0.15) {
particles.push(new Particle(
this.x + (Math.random() - 0.5) * 30,
this.y + (Math.random() - 0.5) * 30,
(Math.random() - 0.5) * 10,
-15 - Math.random() * 15,
'#f5c542',
1.2,
2
));
}
ctx.restore();
}
}
class WeaponAltar {
constructor(x, y, upgradeLevel, title) {
this.x = x;
this.y = y;
this.upgradeLevel = upgradeLevel; // 1 (Ignited Edge) or 2 (Solar Falchion)
this.title = title;
this.radius = 24;
this.claimed = false;
}
activate() {
if (this.claimed) return;
this.claimed = true;
player.weaponUpgrade = this.upgradeLevel;
updateWeaponUI();
audio.playAltarUpgrade();
triggerUpgradeHalo();
createSparks(this.x, this.y, this.upgradeLevel === 1 ? '#e66826' : '#f5c542', 40);
showToast(`Ancient Altar Consecrated: ${this.title} Acquired!`);
}
draw(ctx) {
ctx.save();
// Altar dais
ctx.fillStyle = '#1e2230';
ctx.strokeStyle = this.claimed ? '#64748b' : (this.upgradeLevel === 1 ? '#e66826' : '#f5c542');
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
if (!this.claimed) {
// Floating glowing sword icon
const hover = Math.sin(Date.now() / 250) * 4;
ctx.save();
ctx.translate(this.x, this.y + hover);
ctx.rotate(Math.PI / 4);
ctx.fillStyle = this.upgradeLevel === 1 ? '#f97316' : '#fbbf24';
ctx.fillRect(-2, -14, 4, 28); // Blade
ctx.fillStyle = '#fff';
ctx.fillRect(-6, 2, 12, 3); // Crossguard
ctx.restore();
} else {
ctx.fillStyle = '#475569';
ctx.font = '10px serif';
ctx.textAlign = 'center';
ctx.fillText('EMPTY', this.x, this.y + 4);
}
ctx.restore();
}
}
class PressurePlate {
constructor(x, y, w, h, targetDoorId) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.targetDoorId = targetDoorId;
this.depressed = false;
}
update() {
let pressed = false;
// Check player
if (player.x >= this.x && player.x <= this.x + this.w &&
player.y >= this.y && player.y <= this.y + this.h) {
pressed = true;
}
// Check enemies on plate
for (let e of enemies) {
if (e.x >= this.x && e.x <= this.x + this.w &&
e.y >= this.y && e.y <= this.y + this.h) {
pressed = true;
break;
}
}
if (pressed && !this.depressed) {
this.depressed = true;
audio.playSwitchClick();
// Unlock/open target door
const door = mapEntities.find(ent => ent instanceof Gate && ent.id === this.targetDoorId);
if (door) {
door.open = true;
showToast("Heavy Mechanism Depressed — Pathway Cleared");
}
}
}
draw(ctx) {
ctx.save();
ctx.fillStyle = this.depressed ? '#334155' : '#475569';
ctx.fillRect(this.x, this.y, this.w, this.h);
ctx.strokeStyle = this.depressed ? '#5eead4' : '#94a3b8';
ctx.lineWidth = 2;
ctx.strokeRect(this.x, this.y, this.w, this.h);
// Inner bronze grooves
ctx.strokeStyle = this.depressed ? '#5eead4' : '#334155';
ctx.strokeRect(this.x + 4, this.y + 4, this.w - 8, this.h - 8);
ctx.restore();
}
}
class RangedCrystalSwitch {
constructor(x, y, targetDoorId) {
this.x = x;
this.y = y;
this.radius = 16;
this.targetDoorId = targetDoorId;
this.activated = false;
this.pulse = 0;
}
hit() {
if (this.activated) return;
this.activated = true;
audio.playSwitchClick();
audio.playAetherHit();
createSparks(this.x, this.y, '#5eead4', 24);
const door = mapEntities.find(ent => ent instanceof Gate && ent.id === this.targetDoorId);
if (door) {
door.open = true;
showToast("Aether Crystal Struck — Gateway Unsealed");
}
}
draw(ctx) {
this.pulse += 0.05;
ctx.save();
ctx.fillStyle = this.activated ? '#5eead4' : '#0f766e';
ctx.strokeStyle = this.activated ? '#a7f3d0' : '#14b8a6';
ctx.lineWidth = 3;
const scale = this.activated ? 1.0 : (1.0 + Math.sin(this.pulse) * 0.1);
ctx.translate(this.x, this.y);
ctx.scale(scale, scale);
// Diamond shape crystal
ctx.beginPath();
ctx.moveTo(0, -this.radius);
ctx.lineTo(this.radius, 0);
ctx.lineTo(0, this.radius);
ctx.lineTo(-this.radius, 0);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Core highlight
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(0, 0, 4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
class Gate {
constructor(x, y, w, h, id, reqText = "LOCKED GATE") {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.id = id;
this.open = false;
this.reqText = reqText;
}
draw(ctx) {
if (this.open) return;
ctx.save();
// Iron portcullis bars
ctx.fillStyle = '#0f172a';
ctx.fillRect(this.x, this.y, this.w, this.h);
ctx.strokeStyle = '#64748b';
ctx.lineWidth = 3;
ctx.strokeRect(this.x, this.y, this.w, this.h);
// Vertical bars
const barCount = Math.max(3, Math.floor(this.w / 12));
ctx.strokeStyle = '#94a3b8';
ctx.lineWidth = 2;
for (let i = 1; i < barCount; i++) {
const bx = this.x + (this.w / barCount) * i;
ctx.beginPath();
ctx.moveTo(bx, this.y);
ctx.lineTo(bx, this.y + this.h);
ctx.stroke();
}
ctx.restore();
}
}
class ZoneExit {
constructor(x, y, w, h, targetZone, spawnX, spawnY) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.targetZone = targetZone;
this.spawnX = spawnX;
this.spawnY = spawnY;
this.glowTimer = 0;
}
draw(ctx) {
this.glowTimer += 0.04;
ctx.save();
const glow = Math.sin(this.glowTimer) * 0.25 + 0.75;
ctx.fillStyle = `rgba(245, 197, 66, ${glow * 0.35})`;
ctx.fillRect(this.x, this.y, this.w, this.h);
ctx.strokeStyle = `rgba(245, 197, 66, ${glow})`;
ctx.lineWidth = 3;
ctx.strokeRect(this.x, this.y, this.w, this.h);
// Gateway arch runes
ctx.fillStyle = '#fef08a';
ctx.font = 'bold 10px serif';
ctx.textAlign = 'center';
ctx.fillText('PORTAL', this.x + this.w / 2, this.y + this.h / 2 + 3);
ctx.restore();
}
}
class Chest {
constructor(x, y, itemType, isGold = false, goldAmount = 0) {
this.x = x;
this.y = y;
this.w = 26;
this.h = 22;
this.itemType = itemType; // e.g. "wardenBand", "heartContainer4", "vitalStem", "shadowCloak"
this.isGold = isGold;
this.goldAmount = goldAmount;
this.opened = false;
}
open() {
if (this.opened) return;
this.opened = true;
createSparks(this.x + this.w/2, this.y + this.h/2, '#f5c542', 20);
if (this.isGold) {
player.gold += this.goldAmount;
updateGoldUI();
audio.playRareItem();
showToast(`Ancient Relic Coffer: Found ${this.goldAmount} Sigils!`);
return;
}
if (this.itemType === 'wardenBand') {
player.inventory.wardenBand = true;
if (!player.equippedAccessory) player.equippedAccessory = 'wardenBand';
audio.playRareItem();
showToast("Found Relic: Warden's Band! (Equipped on wrist - Increases swing tempo)");
} else if (this.itemType === 'phantomLantern') {
player.inventory.phantomLantern = true;
if (!player.equippedAccessory) player.equippedAccessory = 'phantomLantern';
audio.playRareItem();
showToast("Acquired: Phantom Lantern! (Reveals hidden cracks and secret paths)");
} else if (this.itemType === 'shadowCloak') {
player.inventory.shadowCloak = true;
if (!player.equippedAccessory) player.equippedAccessory = 'shadowCloak';
audio.playRareItem();
showToast("Acquired: Shadow Cloak! (Reinforces defense against environmental traps)");
} else if (this.itemType === 'heartContainer4') {
if (player.maxContainers < 4) {
player.maxContainers = 4;
player.hp = Math.min(player.hp + 2, player.maxContainers * 2);
updateHealthUI();
audio.playRareItem();
triggerUpgradeHalo();
showToast("Heart Container Discovered! Health capacity permanently expanded.");
}
} else if (this.itemType === 'heartContainer5') {
if (player.maxContainers < 5) {
player.maxContainers = 5;
player.hp = Math.min(player.hp + 2, player.maxContainers * 2);
updateHealthUI();
audio.playRareItem();
triggerUpgradeHalo();
showToast("Final Heart Container Discovered! Maximum vitality unlocked.");
}
} else if (this.itemType === 'vitalStem') {
player.inventory.vitalStem++;
audio.playPickup();
showToast("Found: Vital Stem (Restores 2 Containers)");
} else if (this.itemType === 'bindingSeal') {
player.inventory.bindingSeal++;
audio.playPickup();
showToast("Found: Binding Seal (Freezes enemy in place)");
} else if (this.itemType === 'aetherShard') {
player.hasRanged = true;
player.rangedAmmo = player.maxRangedAmmo;
updateWeaponUI();
audio.playRareItem();
triggerUpgradeHalo();
showToast("THE AETHER SHARD UNLOCKED! Press [K] or [X] to fire distant projectiles.");
}
}
draw(ctx) {
ctx.save();
ctx.fillStyle = this.opened ? '#1e293b' : '#78350f';
ctx.fillRect(this.x, this.y, this.w, this.h);
ctx.strokeStyle = this.opened ? '#475569' : '#f5c542';
ctx.lineWidth = 2;
ctx.strokeRect(this.x, this.y, this.w, this.h);
if (!this.opened) {
// Golden lock latch
ctx.fillStyle = '#fef08a';
ctx.fillRect(this.x + this.w/2 - 2, this.y + this.h/2 - 2, 4, 6);
}
ctx.restore();
}
}
class MerchantAltar {
constructor(x, y) {
this.x = x;
this.y = y;
this.radius = 28;
this.aura = 0;
}
update(dt) {
this.aura += dt * 1.5;
}
interact() {
openShop();
}
draw(ctx) {
ctx.save();
// Ambient merchant glow
const pulse = Math.sin(this.aura) * 0.2 + 0.8;
ctx.fillStyle = `rgba(147, 51, 234, ${pulse * 0.25})`;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius + 6, 0, Math.PI * 2);
ctx.fill();
// Stone Merchant Pedestal
ctx.fillStyle = '#1e1b4b';
ctx.strokeStyle = '#c084fc';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Floating merchant sigil / lantern
ctx.fillStyle = '#f5c542';
ctx.beginPath();
ctx.arc(this.x, this.y - 4, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px serif';
ctx.textAlign = 'center';
ctx.fillText('MERCHANT', this.x, this.y + 16);
ctx.restore();
}
}
class BeaconMechanism {
constructor(x, y) {
this.x = x;
this.y = y;
this.radius = 42;
this.activated = false;
this.spin = 0;
}
update(dt) {
this.spin += dt * 1.2;
}
activate() {
if (this.activated) return;
this.activated = true;
audio.playAltarUpgrade();
triggerUpgradeHalo();
isGameWon = true;
setTimeout(() => {
document.getElementById('victory-modal').classList.add('active');
}, 1200);
}
draw(ctx) {
ctx.save();
ctx.translate(this.x, this.y);
// Large sunburst gears
ctx.rotate(this.spin);
ctx.strokeStyle = this.activated ? '#f5c542' : '#ca8a04';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(0, 0, this.radius, 0, Math.PI * 2);
ctx.stroke();
for (let i = 0; i < 8; i++) {
const angle = (Math.PI / 4) * i;
ctx.beginPath();
ctx.moveTo(Math.cos(angle) * (this.radius - 8), Math.sin(angle) * (this.radius - 8));
ctx.lineTo(Math.cos(angle) * (this.radius + 12), Math.sin(angle) * (this.radius + 12));
ctx.stroke();
}
// Core sphere
ctx.rotate(-this.spin * 2);
ctx.fillStyle = this.activated ? '#fff' : '#f59e0b';
ctx.beginPath();
ctx.arc(0, 0, 16, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// Environmental Spike Traps
class SpikeTrap {
constructor(x, y, w, h) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.timer = 0;
this.active = false;
}
update(dt) {
this.timer += dt;
this.active = (Math.sin(this.timer * 2.5) > 0.1);
if (this.active) {
// Check collision with player
if (player.x >= this.x && player.x <= this.x + this.w &&
player.y >= this.y && player.y <= this.y + this.h) {
// Shadow cloak reduces environmental trap damage
const dmg = player.equippedAccessory === 'shadowCloak' ? 1 : 2;
damagePlayer(dmg);
}
}
}
draw(ctx) {
ctx.save();
ctx.fillStyle = '#1e1b24';
ctx.fillRect(this.x, this.y, this.w, this.h);
ctx.strokeStyle = '#374151';
ctx.strokeRect(this.x, this.y, this.w, this.h);
if (this.active) {
ctx.fillStyle = '#ef4444';
// Draw spike teeth
const cols = Math.floor(this.w / 12);
for (let i = 0; i < cols; i++) {
const sx = this.x + i * 12 + 6;
ctx.beginPath();
ctx.moveTo(sx - 4, this.y + this.h);
ctx.lineTo(sx, this.y + 4);
ctx.lineTo(sx + 4, this.y + this.h);
ctx.fill();
}
}
ctx.restore();
}
}
/* =========================================================================
ENEMY TYPES: CORRUPTED CONSTRUCTS
========================================================================= */
class Construct {
constructor(x, y, type = 'sentinel') {
this.x = x;
this.y = y;
this.type = type; // "sentinel", "rustHulk", "spireGuardian", "archivist"
this.vx = 0;
this.vy = 0;
this.frozenTimer = 0;
this.attackCooldown = 0;
if (type === 'sentinel') {
this.radius = 16;
this.hp = 3;
this.maxHp = 3;
this.speed = 1.6;
this.damage = 1;
this.goldDrop = 10;
this.color = '#78350f';
} else if (type === 'rustHulk') {
this.radius = 24;
this.hp = 7;
this.maxHp = 7;
this.speed = 1.0;
this.damage = 2;
this.goldDrop = 22;
this.color = '#451a03';
} else if (type === 'spireGuardian') {
this.radius = 28;
this.hp = 14;
this.maxHp = 14;
this.speed = 1.35;
this.damage = 2;
this.goldDrop = 55;
this.color = '#312e81';
} else if (type === 'archivist') {
this.radius = 15;
this.hp = 4;
this.maxHp = 4;
this.speed = 1.8;
this.damage = 1;
this.goldDrop = 14;
this.color = '#065f46';
}
}
update(dt) {
if (this.frozenTimer > 0) {
this.frozenTimer -= dt;
return; // Can not move or attack while frozen
}
if (this.attackCooldown > 0) this.attackCooldown -= dt;
// AI: Track player if within aggro distance (360 px)
const dx = player.x - this.x;
const dy = player.y - this.y;
const dist = Math.hypot(dx, dy);
if (dist < 360 && dist > 18) {
this.vx = (dx / dist) * this.speed;
this.vy = (dy / dist) * this.speed;
} else {
this.vx *= 0.9;
this.vy *= 0.9;
}
// Move with wall collisions
const nextX = this.x + this.vx;
const nextY = this.y + this.vy;
if (!isSolidAt(nextX, this.y, this.radius)) this.x = nextX;
if (!isSolidAt(this.x, nextY, this.radius)) this.y = nextY;
// Attack player on contact
if (dist < this.radius + player.radius) {
if (this.attackCooldown <= 0) {
damagePlayer(this.damage);
this.attackCooldown = 1.2;
}
}
}
takeDamage(amt, knockbackX = 0, knockbackY = 0) {
this.hp -= amt;
this.x += knockbackX * 8;
this.y += knockbackY * 8;
audio.playEnemyHit();
// Hit particle feedback
if (player.weaponUpgrade === 1) {
// Burn flinch & orange sparks
createSparks(this.x, this.y, '#e66826', 10);
floatingTexts.push(new FloatingText(this.x, this.y - 12, `${amt} BURN`, '#f97316'));
} else if (player.weaponUpgrade === 2) {
// Solar light impact
createSparks(this.x, this.y, '#f5c542', 15);
floatingTexts.push(new FloatingText(this.x, this.y - 12, `${amt} SOLAR`, '#fde047'));
} else {
createSparks(this.x, this.y, '#f87171', 8);
floatingTexts.push(new FloatingText(this.x, this.y - 12, `${amt}`, '#fff'));
}
if (this.hp <= 0) {
this.die();
}
}
die() {
audio.playEnemyDeath();
createSparks(this.x, this.y, '#94a3b8', 25);
// Sigils / Money drop
const drop = this.goldDrop + Math.floor(Math.random() * 6);
player.gold += drop;
updateGoldUI();
floatingTexts.push(new FloatingText(this.x, this.y - 20, `+${drop} SIGILS`, '#f5c542'));
audio.playCoin();
// Chance to drop Vital Stem or Binding Seal
if (Math.random() < 0.2) {
player.inventory.vitalStem++;
floatingTexts.push(new FloatingText(this.x + 10, this.y - 32, `+1 VITAL STEM`, '#4ade80'));
}
// Specific boss drops
if (this.type === 'spireGuardian' && !player.inventory.shadowCloak) {
player.inventory.shadowCloak = true;
if (!player.equippedAccessory) player.equippedAccessory = 'shadowCloak';
showToast("Spire Guardian Defeated: Acquired Shadow Cloak!");
audio.playRareItem();
}
// Remove from enemy array
const idx = enemies.indexOf(this);
if (idx !== -1) enemies.splice(idx, 1);
}
draw(ctx) {
ctx.save();
// Frozen visual indication
if (this.frozenTimer > 0) {
ctx.fillStyle = 'rgba(94, 234, 212, 0.4)';
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius + 6, 0, Math.PI * 2);
ctx.fill();
}
// Construct Body
ctx.fillStyle = this.color;
ctx.strokeStyle = '#1e293b';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Black-iron corrosion creep lines
ctx.strokeStyle = '#020617';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(this.x - this.radius * 0.5, this.y - this.radius * 0.3);
ctx.lineTo(this.x + this.radius * 0.2, this.y + this.radius * 0.4);
ctx.moveTo(this.x - this.radius * 0.2, this.y + this.radius * 0.6);
ctx.lineTo(this.x + this.radius * 0.5, this.y - this.radius * 0.2);
ctx.stroke();
// Glowing corrupted optical eye
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.arc(this.x, this.y - 2, 4, 0, Math.PI * 2);
ctx.fill();
// Health bar above construct
if (this.hp < this.maxHp) {
const barW = this.radius * 2;
const barH = 4;
const hpPct = Math.max(0, this.hp / this.maxHp);
ctx.fillStyle = '#1e293b';
ctx.fillRect(this.x - barW/2, this.y - this.radius - 10, barW, barH);
ctx.fillStyle = '#ef4444';
ctx.fillRect(this.x - barW/2, this.y - this.radius - 10, barW * hpPct, barH);
}
ctx.restore();
}
}
/* =========================================================================
PROJECTILES & PARTICLES
========================================================================= */
class AetherProjectile {
constructor(x, y, vx, vy) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.radius = 5;
this.alive = true;
this.life = 1.4;
}
update(dt) {
this.life -= dt;
if (this.life <= 0) {
this.alive = false;
return;
}
this.x += this.vx * dt * 60;
this.y += this.vy * dt * 60;
// Trail particle
if (Math.random() < 0.6) {
particles.push(new Particle(this.x, this.y, (Math.random()-0.5)*15, (Math.random()-0.5)*15, '#5eead4', 0.4, 2));
}
// Check hit switches
for (let ent of mapEntities) {
if (ent instanceof RangedCrystalSwitch) {
const d = Math.hypot(this.x - ent.x, this.y - ent.y);
if (d < this.radius + ent.radius) {
ent.hit();
this.alive = false;
return;
}
}
// Check breakable walls
if (ent instanceof Wall && ent.isBreakable && !ent.broken) {
if (this.x >= ent.x && this.x <= ent.x + ent.w &&
this.y >= ent.y && this.y <= ent.y + ent.h) {
ent.damage(1);
this.alive = false;
return;
}
}
// Check solid gates/walls
if (ent instanceof Wall && !ent.broken && !ent.isBreakable) {
if (this.x >= ent.x && this.x <= ent.x + ent.w &&
this.y >= ent.y && this.y <= ent.y + ent.h) {
audio.playAetherHit();
createSparks(this.x, this.y, '#5eead4', 6);
this.alive = false;
return;
}
}
if (ent instanceof Gate && !ent.open) {
if (this.x >= ent.x && this.x <= ent.x + ent.w &&
this.y >= ent.y && this.y <= ent.y + ent.h) {
audio.playAetherHit();
this.alive = false;
return;
}
}
}
// Check hit enemies
for (let e of enemies) {
const d = Math.hypot(this.x - e.x, this.y - e.y);
if (d < this.radius + e.radius) {
e.takeDamage(2, this.vx > 0 ? 1 : -1, this.vy > 0 ? 1 : -1);
audio.playAetherHit();
this.alive = false;
return;
}
}
}
draw(ctx) {
ctx.save();
ctx.fillStyle = '#a7f3d0';
ctx.strokeStyle = '#2dd4bf';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
}
}
class Particle {
constructor(x, y, vx, vy, color, life, size = 3) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.color = color;
this.maxLife = life;
this.life = life;
this.size = size;
}
update(dt) {
this.life -= dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
}
draw(ctx) {
const alpha = Math.max(0, this.life / this.maxLife);
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, this.size, this.size);
ctx.restore();
}
}
function createSparks(x, y, color, count = 12) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 25 + Math.random() * 95;
particles.push(new Particle(
x, y,
Math.cos(angle) * speed,
Math.sin(angle) * speed,
color,
0.35 + Math.random() * 0.35,
2 + Math.random() * 2
));
}
}
class FloatingText {
constructor(x, y, text, color) {
this.x = x;
this.y = y;
this.text = text;
this.color = color;
this.life = 1.0;
}
update(dt) {
this.life -= dt;
this.y -= 25 * dt;
}
draw(ctx) {
ctx.save();
ctx.globalAlpha = Math.max(0, this.life);
ctx.fillStyle = this.color;
ctx.font = 'bold 12px "Cinzel", Georgia, serif';
ctx.textAlign = 'center';
ctx.fillText(this.text, this.x, this.y);
ctx.restore();
}
}
/* =========================================================================
ZONE SETUP & POPULATION BUILDER
========================================================================= */
function loadZone(zoneIdx, spawnX = 200, spawnY = 300) {
currentZone = zoneIdx;
player.x = spawnX;
player.y = spawnY;
player.vx = 0;
player.vy = 0;
enemies = [];
projectiles = [];
particles = [];
floatingTexts = [];
mapEntities = [];
const zoneInfo = ZONES[zoneIdx];
document.getElementById('hud-zone-name').innerText = zoneInfo.name.toUpperCase();
document.getElementById('hud-zone-sub').innerText = zoneInfo.sub.toUpperCase();
// Show splash notification
triggerZoneSplash(zoneInfo.name, zoneInfo.sub);
// Outer Room Boundaries for all 960x600 canvas
const W = 960;
const H = 600;
const T = 24; // Wall thickness
mapEntities.push(new Wall(0, 0, W, T)); // Top
mapEntities.push(new Wall(0, H - T, W, T)); // Bottom
mapEntities.push(new Wall(0, 0, T, H)); // Left
mapEntities.push(new Wall(W - T, 0, T, H)); // Right
/* -------------------------------------------------------------
ZONE SPECIFIC MAP POPULATION
------------------------------------------------------------- */
if (zoneIdx === 0) {
// ZONE 1: The Ashen Threshold (Prologue)
// Sanctuary Stone 1 right at entrance
mapEntities.push(new SanctuaryStone(140, 300, 1));
// Dividing columns / barriers
mapEntities.push(new Wall(320, 24, 28, 200));
mapEntities.push(new Wall(320, 360, 28, 216));
// Breakable cracked wall hiding a small starter cache
mapEntities.push(new Wall(320, 224, 28, 136, true, 2, "z1_crack"));
mapEntities.push(new Chest(240, 100, "vitalStem"));
// Introductory Corrupted Constructs
enemies.push(new Construct(540, 240, 'sentinel'));
enemies.push(new Construct(620, 360, 'sentinel'));
enemies.push(new Construct(740, 280, 'sentinel'));
// Gateway to Zone 2 at the right
mapEntities.push(new ZoneExit(W - 40, 260, 30, 80, 1, 60, 300));
} else if (zoneIdx === 1) {
// ZONE 2: The Rusted Archives (Early Archives)
// Sanctuary Stone 2 at center
mapEntities.push(new SanctuaryStone(480, 300, 2));
// Merchant Altar unlocked here
mapEntities.push(new MerchantAltar(560, 160));
// Library bookshelf walls
mapEntities.push(new Wall(180, 120, 160, 24));
mapEntities.push(new Wall(180, 440, 160, 24));
mapEntities.push(new Wall(640, 120, 160, 24));
mapEntities.push(new Wall(640, 440, 160, 24));
// Hidden chest for Warden's Band
mapEntities.push(new Chest(840, 80, "wardenBand"));
// Secret breakable wall hiding gold coffer
mapEntities.push(new Wall(760, 24, 24, 96, true, 3));
mapEntities.push(new Chest(880, 200, null, true, 80));
// Archivist & Sentinel constructs
enemies.push(new Construct(340, 240, 'sentinel'));
enemies.push(new Construct(340, 380, 'archivist'));
enemies.push(new Construct(700, 260, 'archivist'));
enemies.push(new Construct(760, 360, 'rustHulk'));
// Return to Zone 1
mapEntities.push(new ZoneExit(10, 260, 25, 80, 0, W - 80, 300));
// Advance to Zone 3
mapEntities.push(new ZoneExit(W - 40, 260, 30, 80, 2, 60, 300));
} else if (zoneIdx === 2) {
// ZONE 3: The Collapsed Gallery (Mid-Early)
// Weapon Upgrade 1 Altar: Ignited Edge
mapEntities.push(new WeaponAltar(480, 160, 1, "Ignited Edge"));
// Environmental Pressure Plate puzzle: Opens gate to Zone 4
mapEntities.push(new PressurePlate(460, 440, 40, 40, "gallery_gate"));
mapEntities.push(new Gate(W - 45, 240, 25, 120, "gallery_gate", "PLATE CONTROLLED GATE"));
// Crack-marked breakable wall hiding Heart Container 4
mapEntities.push(new Wall(660, 24, 24, 180, true, 4, "hc4_wall"));
mapEntities.push(new Chest(780, 90, "heartContainer4"));
// Mini-boss Hulk guarding the altar
enemies.push(new Construct(480, 280, 'rustHulk'));
enemies.push(new Construct(260, 200, 'sentinel'));
enemies.push(new Construct(260, 380, 'sentinel'));
enemies.push(new Construct(700, 380, 'archivist'));
// Spikes along side passage
mapEntities.push(new SpikeTrap(280, 460, 140, 20));
// Return to Zone 2
mapEntities.push(new ZoneExit(10, 260, 25, 80, 1, W - 80, 300));
// Advance to Zone 4
mapEntities.push(new ZoneExit(W - 40, 260, 30, 80, 3, 60, 300));
} else if (zoneIdx === 3) {
// ZONE 4: The Sunken Vault (Mid-Game / Halfway Turning Point)
// Discovery: The Aether Shard (Ranged Weapon) behind cracked wall!
mapEntities.push(new Wall(340, 340, 28, 140, true, 3, "shard_wall"));
mapEntities.push(new Chest(220, 420, "aetherShard"));
// Reward for solving light reflection / switch: Phantom Lantern!
mapEntities.push(new RangedCrystalSwitch(820, 100, "lantern_vault"));
mapEntities.push(new Gate(700, 380, 24, 100, "lantern_vault"));
mapEntities.push(new Chest(780, 420, "phantomLantern"));
// Another switch to open main exit
mapEntities.push(new RangedCrystalSwitch(820, 480, "vault_exit_gate"));
mapEntities.push(new Gate(W - 45, 240, 25, 120, "vault_exit_gate"));
// Water barriers & constructs
mapEntities.push(new Wall(480, 120, 28, 220));
enemies.push(new Construct(380, 220, 'rustHulk'));
enemies.push(new Construct(580, 200, 'archivist'));
enemies.push(new Construct(580, 380, 'sentinel'));
enemies.push(new Construct(720, 260, 'archivist'));
// Return to Zone 3
mapEntities.push(new ZoneExit(10, 260, 25, 80, 2, W - 80, 300));
// Advance to Zone 5
mapEntities.push(new ZoneExit(W - 40, 260, 30, 80, 4, 60, 300));
} else if (zoneIdx === 4) {
// ZONE 5: The Twin Spire (Late Gauntlet)
// Sanctuary Stone 3 at entrance
mapEntities.push(new SanctuaryStone(140, 300, 3));
// Weapon Upgrade 2 Altar: Solar Falchion
mapEntities.push(new WeaponAltar(500, 140, 2, "Solar Falchion"));
// Ranged switch over a chasm/spike row for Heart Container 5
mapEntities.push(new SpikeTrap(320, 380, 340, 26));
mapEntities.push(new RangedCrystalSwitch(840, 440, "hc5_spire_gate"));
mapEntities.push(new Gate(800, 40, 25, 100, "hc5_spire_gate"));
mapEntities.push(new Chest(880, 80, "heartContainer5"));
// Large bonus gold chest & broken jar with Binding Seal
mapEntities.push(new Chest(300, 100, null, true, 120));
mapEntities.push(new Chest(680, 480, "bindingSeal"));
// Tough combat gauntlet: Spire Guardian (mini-boss) + Constructs
enemies.push(new Construct(520, 300, 'spireGuardian'));
enemies.push(new Construct(380, 220, 'rustHulk'));
enemies.push(new Construct(660, 240, 'archivist'));
enemies.push(new Construct(740, 360, 'sentinel'));
// Return to Zone 4
mapEntities.push(new ZoneExit(10, 260, 25, 80, 3, W - 80, 300));
// Advance to Zone 6 (Climax)
mapEntities.push(new ZoneExit(W - 40, 260, 30, 80, 5, 60, 300));
} else if (zoneIdx === 5) {
// ZONE 6: The Core Chamber (Climax)
// Final confrontation & The Core Beacon
const beacon = new BeaconMechanism(480, 300);
mapEntities.push(beacon);
// Gate protecting the beacon until side crystal switches are activated
mapEntities.push(new RangedCrystalSwitch(160, 120, "beacon_gate_left"));
mapEntities.push(new RangedCrystalSwitch(800, 120, "beacon_gate_right"));
mapEntities.push(new Gate(430, 200, 100, 20, "beacon_gate_left"));
mapEntities.push(new Gate(430, 380, 100, 20, "beacon_gate_right"));
// Elite Guardian constructs guarding the Core
enemies.push(new Construct(340, 300, 'spireGuardian'));
enemies.push(new Construct(620, 300, 'spireGuardian'));
enemies.push(new Construct(480, 140, 'rustHulk'));
enemies.push(new Construct(480, 460, 'rustHulk'));
// Return to Zone 5
mapEntities.push(new ZoneExit(10, 260, 25, 80, 4, W - 80, 300));
}
}
function isSolidAt(x, y, radius = 10) {
for (let ent of mapEntities) {
if (ent instanceof Wall && !ent.broken) {
if (x + radius > ent.x && x - radius < ent.x + ent.w &&
y + radius > ent.y && y - radius < ent.y + ent.h) {
return true;
}
}
if (ent instanceof Gate && !ent.open) {
if (x + radius > ent.x && x - radius < ent.x + ent.w &&
y + radius > ent.y && y - radius < ent.y + ent.h) {
return true;
}
}
}
return false;
}
/* =========================================================================
CHECKPOINT & PERSISTENCE
========================================================================= */
function saveCheckpoint() {
lastCheckpointState = {
zone: currentZone,
x: player.x,
y: player.y,
maxContainers: player.maxContainers,
gold: player.gold,
weaponUpgrade: player.weaponUpgrade,
hasRanged: player.hasRanged,
inventory: JSON.parse(JSON.stringify(player.inventory)),
equippedAccessory: player.equippedAccessory,
activeSavePoint: activeSavePoint
};
}
function restoreCheckpoint() {
if (!lastCheckpointState) {
// Respawn in current zone at entrance with half health
player.hp = player.maxContainers * 2;
player.x = 200;
player.y = 300;
loadZone(currentZone, 200, 300);
return;
}
player.maxContainers = lastCheckpointState.maxContainers;
player.hp = player.maxContainers * 2;
player.gold = lastCheckpointState.gold;
player.weaponUpgrade = lastCheckpointState.weaponUpgrade;
player.hasRanged = lastCheckpointState.hasRanged;
player.inventory = JSON.parse(JSON.stringify(lastCheckpointState.inventory));
player.equippedAccessory = lastCheckpointState.equippedAccessory;
activeSavePoint = lastCheckpointState.activeSavePoint;
loadZone(lastCheckpointState.zone, lastCheckpointState.x, lastCheckpointState.y);
updateHealthUI();
updateGoldUI();
updateWeaponUI();
showToast("Restored at Sanctuary Stone");
}
/* =========================================================================
COMBAT & DAMAGE HANDLING
========================================================================= */
function damagePlayer(amt) {
if (player.invulnerableTimer > 0 || isGameWon) return;
player.hp = Math.max(0, player.hp - amt);
player.invulnerableTimer = 1.0;
audio.playDamage();
// Screen-edge damage red flash
const flash = document.getElementById('damage-flash');
flash.style.opacity = '1';
setTimeout(() => { flash.style.opacity = '0'; }, 150);
// Red particle burst centered on player
createSparks(player.x, player.y, '#ef4444', 16);
floatingTexts.push(new FloatingText(player.x, player.y - 16, `-${amt} HP`, '#ef4444'));
updateHealthUI();
if (player.hp <= 0) {
showToast("The Warden collapsed under the corrosion... Restoring from sanctuary.");
setTimeout(() => {
restoreCheckpoint();
}, 1000);
}
}
function performMeleeAttack() {
if (player.attackCooldown > 0) return;
const speedBonus = (player.equippedAccessory === 'wardenBand') ? 0.06 : 0.0;
// Upgrade 1 (Ignited Edge) increases attack speed noticeably
const baseDuration = (player.weaponUpgrade === 1) ? 0.16 : 0.22;
player.attackDuration = Math.max(0.12, baseDuration - speedBonus);
player.isAttacking = true;
player.attackTimer = player.attackDuration;
player.attackCooldown = player.attackDuration + 0.12;
audio.playAttack(player.weaponUpgrade);
// Arc range calculation
// Upgrade 2 (Solar Falchion): wider arc sweep / visual range extension
let range = 42;
let sweepAngle = Math.PI * 0.65;
let dmg = 1;
if (player.weaponUpgrade === 1) {
range = 48;
dmg = 2; // Fire/ember damage
} else if (player.weaponUpgrade === 2) {
range = 62;
sweepAngle = Math.PI * 0.95;
dmg = 4; // Solar/light heavy impact
}
const baseAngle = Math.atan2(player.facing.y, player.facing.x);
// Check hits against breakable walls
for (let ent of mapEntities) {
if (ent instanceof Wall && ent.isBreakable && !ent.broken) {
const cx = ent.x + ent.w / 2;
const cy = ent.y + ent.h / 2;
const dist = Math.hypot(cx - player.x, cy - player.y);
if (dist < range + Math.max(ent.w, ent.h) / 2) {
// Solar Falchion breaks them faster
const wallDmg = (player.weaponUpgrade === 2) ? 3 : 1;
ent.damage(wallDmg);
}
}
}
// Check hits against enemies
for (let e of enemies) {
const dx = e.x - player.x;
const dy = e.y - player.y;
const dist = Math.hypot(dx, dy);
if (dist <= range + e.radius) {
const enemyAngle = Math.atan2(dy, dx);
let diff = enemyAngle - baseAngle;
while (diff < -Math.PI) diff += Math.PI * 2;
while (diff > Math.PI) diff -= Math.PI * 2;
if (Math.abs(diff) <= sweepAngle / 2) {
e.takeDamage(dmg, player.facing.x, player.facing.y);
}
}
}
}
function performRangedAttack() {
if (!player.hasRanged || player.rangedCooldown > 0) return;
if (player.rangedAmmo <= 0) {
showToast("Aether Shard Depleted! Restock at the Merchant Altar.");
return;
}
player.rangedAmmo--;
player.rangedCooldown = 0.35;
audio.playAetherShoot();
// Fire crystal projectile in facing direction
const speed = 9.5;
const vx = player.facing.x * speed;
const vy = player.facing.y * speed;
const px = player.x + player.facing.x * 16;
const py = player.y + player.facing.y * 16;
projectiles.push(new AetherProjectile(px, py, vx, vy));
updateWeaponUI();
}
/* =========================================================================
CONSUMABLES & INVENTORY LOGIC
========================================================================= */
function useVitalStem() {
if (player.inventory.vitalStem <= 0) return;
if (player.hp >= player.maxContainers * 2) {
showToast("Vitality is already at full capacity.");
return;
}
player.inventory.vitalStem--;
// Restores 2 full containers (4 HP)
player.hp = Math.min(player.hp + 4, player.maxContainers * 2);
audio.playPickup();
triggerUpgradeHalo();
createSparks(player.x, player.y, '#22c55e', 20);
updateHealthUI();
updateInventoryModal();
showToast("Used Vital Stem: Restored 2 full heart containers (4 HP)!");
}
function useBindingSeal() {
if (player.inventory.bindingSeal <= 0) return;
// Freeze closest enemy in place for ~3 seconds
let closest = null;
let minDist = 300;
for (let e of enemies) {
const d = Math.hypot(e.x - player.x, e.y - player.y);
if (d < minDist) {
minDist = d;
closest = e;
}
}
if (!closest) {
showToast("No enemies nearby to bind!");
return;
}
player.inventory.bindingSeal--;
closest.frozenTimer = 3.2;
audio.playFreeze();
createSparks(closest.x, closest.y, '#38bdf8', 22);
floatingTexts.push(new FloatingText(closest.x, closest.y - 18, "FROZEN", "#38bdf8"));
updateInventoryModal();
showToast("Binding Seal Released: Construct frozen in black ice!");
}
function equipAccessory(accName) {
if (player.equippedAccessory === accName) {
player.equippedAccessory = null; // Toggle off
showToast(`Unequipped accessory.`);
} else {
player.equippedAccessory = accName;
audio.playRareItem();
showToast(`Equipped: ${formatItemName(accName)}`);
}
updateInventoryModal();
}
function formatItemName(id) {
switch(id) {
case 'wardenBand': return "Warden's Band";
case 'phantomLantern': return "Phantom Lantern";
case 'shadowCloak': return "Shadow Cloak";
default: return "None";
}
}
/* =========================================================================
SHOP SYSTEM (Merchant Altar in Zone 2)
========================================================================= */
const shopStock = {
vitalStem: { name: "Vital Stem", desc: "Restores 2 full containers (4 HP).", price: 50, remaining: 3 },
bindingSeal: { name: "Binding Seal", desc: "Freezes one enemy in place for ~3s.", price: 75, remaining: 2 },
heartContainer5: { name: "Heart Container Upgrade #5", desc: "Permanently grants 5th Heart Container (+2 Max HP).", price: 300, remaining: 1 },
aetherRestock: { name: "Aether Shard Restock", desc: "Adds +10 projectile charges for the Aether Shard.", price: 10, remaining: 99 }
};
function openShop() {
isShopOpen = true;
document.getElementById('shop-modal').classList.add('active');
renderShop();
}
function closeShop() {
isShopOpen = false;
document.getElementById('shop-modal').classList.remove('active');
}
function renderShop() {
document.getElementById('shop-gold-display').innerText = player.gold;
const container = document.getElementById('shop-items-container');
container.innerHTML = '';
for (let key in shopStock) {
const item = shopStock[key];
// Hide ammo if ranged shard not yet unlocked
if (key === 'aetherRestock' && !player.hasRanged) continue;
// Hide heart container if already bought or max reached
if (key === 'heartContainer5' && player.maxContainers >= 5) continue;
const row = document.createElement('div');
row.className = 'shop-item-row';
const canAfford = player.gold >= item.price && item.remaining > 0;
row.innerHTML = `
<div class="shop-item-info">
<h4>${item.name}</h4>
<p>${item.desc} (Stock: ${item.remaining})</p>
</div>
<div class="shop-item-action">
<div class="price-tag">${item.price} SIGILS</div>
<button class="btn btn-accent" ${canAfford ? '' : 'disabled'} onclick="buyShopItem('${key}')">PURCHASE</button>
</div>
`;
container.appendChild(row);
}
}
function buyShopItem(itemKey) {
const item = shopStock[itemKey];
if (!item || player.gold < item.price || item.remaining <= 0) return;
player.gold -= item.price;
item.remaining--;
audio.playShopBuy();
if (itemKey === 'vitalStem') {
player.inventory.vitalStem++;
showToast("Purchased Vital Stem!");
} else if (itemKey === 'bindingSeal') {
player.inventory.bindingSeal++;
showToast("Purchased Binding Seal!");
} else if (itemKey === 'heartContainer5') {
player.maxContainers = 5;
player.hp = Math.min(player.hp + 2, player.maxContainers * 2);
updateHealthUI();
triggerUpgradeHalo();
showToast("Purchased Final Heart Container! Maximum 5 containers unlocked.");
} else if (itemKey === 'aetherRestock') {
player.rangedAmmo = Math.min(player.maxRangedAmmo, player.rangedAmmo + 10);
updateWeaponUI();
showToast("Replenished 10 Aether Shard charges.");
}
updateGoldUI();
renderShop();
}
/* =========================================================================
UI & OVERLAYS MANAGEMENT
========================================================================= */
function updateHealthUI() {
const container = document.getElementById('hearts-container');
container.innerHTML = '';
for (let i = 0; i < player.maxContainers; i++) {
const heartDiv = document.createElement('div');
heartDiv.className = 'heart-container';
// 1 container = 2 HP
const hpInThisHeart = Math.max(0, Math.min(2, player.hp - i * 2));
const fillPercent = (hpInThisHeart / 2) * 100;
heartDiv.innerHTML = `
<svg viewBox="0 0 32 32">
<path class="heart-bg" d="M16 29s-12-7.5-12-16a7 7 0 0 1 12-4.5 7 7 0 0 1 12 4.5c0 8.5-12 16-12 16z"/>
<path class="heart-fill" style="clip-path: inset(${100 - fillPercent}% 0 0 0);" d="M16 29s-12-7.5-12-16a7 7 0 0 1 12-4.5 7 7 0 0 1 12 4.5c0 8.5-12 16-12 16z"/>
</svg>
`;
container.appendChild(heartDiv);
}
}
function updateGoldUI() {
document.getElementById('gold-counter').innerText = player.gold;
}
function updateWeaponUI() {
const meleeName = document.getElementById('hud-melee-name');
if (player.weaponUpgrade === 0) {
meleeName.innerText = "EMBER BLADE";
meleeName.style.color = "#f5c542";
} else if (player.weaponUpgrade === 1) {
meleeName.innerText = "IGNITED EDGE";
meleeName.style.color = "#e66826";
} else if (player.weaponUpgrade === 2) {
meleeName.innerText = "SOLAR FALCHION";
meleeName.style.color = "#fde047";
}
const rangedBox = document.getElementById('hud-ranged-box');
const rangedName = document.getElementById('hud-ranged-name');
if (player.hasRanged) {
rangedBox.style.opacity = "1";
rangedName.innerText = `AETHER SHARD [${player.rangedAmmo}]`;
rangedName.style.color = "#5eead4";
} else {
rangedBox.style.opacity = "0.35";
rangedName.innerText = "LOCKED";
rangedName.style.color = "#94a3b8";
}
}
function toggleInventory() {
if (isShopOpen) return;
isInventoryOpen = !isInventoryOpen;
const modal = document.getElementById('inventory-modal');
if (isInventoryOpen) {
modal.classList.add('active');
updateInventoryModal();
} else {
modal.classList.remove('active');
}
}
function updateInventoryModal() {
const grid = document.getElementById('inventory-items-grid');
grid.innerHTML = '';
document.getElementById('equipped-accessory-label').innerText = formatItemName(player.equippedAccessory);
// Accessories
const accList = [
{ id: 'wardenBand', name: "Warden's Band", type: 'Accessory', desc: "Equipped on wrist: increases weapon swing cadence.", owned: player.inventory.wardenBand },
{ id: 'phantomLantern', name: "Phantom Lantern", type: 'Accessory', desc: "Emits faint ethereal light: illuminates hairline cracks & hidden passages.", owned: player.inventory.phantomLantern },
{ id: 'shadowCloak', name: "Shadow Cloak", type: 'Accessory', desc: "Woven from dusk silk: halves damage taken from environmental floor spike traps.", owned: player.inventory.shadowCloak }
];
accList.forEach(acc => {
if (!acc.owned) return;
const card = document.createElement('div');
const isEquipped = player.equippedAccessory === acc.id;
card.className = `inventory-card ${isEquipped ? 'equipped' : ''}`;
card.innerHTML = `
<div class="item-name">${acc.name}</div>
<div class="item-desc">${acc.desc}</div>
<div class="item-actions">
<span style="font-size:10px; color:#64748b;">${acc.type}</span>
<button class="btn" onclick="equipAccessory('${acc.id}')">${isEquipped ? 'UNEQUIP' : 'EQUIP'}</button>
</div>
`;
grid.appendChild(card);
});
// Consumables
if (player.inventory.vitalStem > 0) {
const stemCard = document.createElement('div');
stemCard.className = 'inventory-card';
stemCard.innerHTML = `
<div class="item-name">Vital Stem (x${player.inventory.vitalStem})</div>
<div class="item-desc">Purifying glowing bulb. Restores 2 full heart containers (4 HP).</div>
<div class="item-actions">
<span style="font-size:10px; color:#22c55e;">CONSUMABLE</span>
<button class="btn btn-accent" onclick="useVitalStem()">USE</button>
</div>
`;
grid.appendChild(stemCard);
}
if (player.inventory.bindingSeal > 0) {
const sealCard = document.createElement('div');
sealCard.className = 'inventory-card';
sealCard.innerHTML = `
<div class="item-name">Binding Seal (x${player.inventory.bindingSeal})</div>
<div class="item-desc">Runic ice seal. Freezes the nearest construct in place for 3 seconds.</div>
<div class="item-actions">
<span style="font-size:10px; color:#38bdf8;">CONSUMABLE</span>
<button class="btn btn-accent" onclick="useBindingSeal()">DISCHARGE</button>
</div>
`;
grid.appendChild(sealCard);
}
if (grid.children.length === 0) {
grid.innerHTML = `<div style="grid-column: 1 / -1; color:#64748b; font-style:italic; padding:20px; text-align:center;">Satchel is empty. Explore chests and shrines to gather artifacts.</div>`;
}
}
let toastTimer = null;
function showToast(msg) {
const toast = document.getElementById('toast');
toast.innerText = msg;
toast.classList.add('show');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.classList.remove('show');
}, 2600);
}
function triggerUpgradeHalo() {
const halo = document.getElementById('upgrade-halo');
halo.style.opacity = '1';
setTimeout(() => {
halo.style.opacity = '0';
}, 1800);
}
function triggerZoneSplash(title, subtitle) {
const splash = document.getElementById('zone-splash');
document.getElementById('splash-title').innerText = title;
document.getElementById('splash-subtitle').innerText = subtitle;
splash.classList.add('active');
setTimeout(() => {
splash.classList.remove('active');
}, 2200);
}
/* =========================================================================
MAIN GAME LOOP & UPDATE
========================================================================= */
let lastTime = 0;
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
function gameLoop(timestamp) {
if (!lastTime) lastTime = timestamp;
const dt = Math.min((timestamp - lastTime) / 1000, 0.1);
lastTime = timestamp;
update(dt);
render();
requestAnimationFrame(gameLoop);
}
function update(dt) {
if (isShopOpen || isInventoryOpen || isGameWon) return;
// Player cooldowns
if (player.attackCooldown > 0) player.attackCooldown -= dt;
if (player.rangedCooldown > 0) player.rangedCooldown -= dt;
if (player.invulnerableTimer > 0) player.invulnerableTimer -= dt;
if (player.isAttacking) {
player.attackTimer -= dt;
if (player.attackTimer <= 0) {
player.isAttacking = false;
}
}
// Player Movement handling
let moveX = 0;
let moveY = 0;
if (keys.up) moveY -= 1;
if (keys.down) moveY += 1;
if (keys.left) moveX -= 1;
if (keys.right) moveX += 1;
if (moveX !== 0 || moveY !== 0) {
const len = Math.hypot(moveX, moveY);
player.vx = (moveX / len) * player.speed;
player.vy = (moveY / len) * player.speed;
player.facing = { x: moveX / len, y: moveY / len };
player.moving = true;
player.walkCycle += dt * 8;
// Footstep audio feedback based on zone flooring
player.footstepTimer += dt;
if (player.footstepTimer > 0.32) {
player.footstepTimer = 0;
audio.playStep(ZONES[currentZone].floorType);
}
} else {
player.vx *= 0.5;
player.vy *= 0.5;
player.moving = false;
}
// Next position collision check with walls/gates
const newX = player.x + player.vx;
const newY = player.y + player.vy;
if (!isSolidAt(newX, player.y, player.radius)) player.x = newX;
if (!isSolidAt(player.x, newY, player.radius)) player.y = newY;
// Ember trail if Upgrade 1 or 2
if (player.weaponUpgrade >= 1 && player.moving && Math.random() < 0.25) {
particles.push(new Particle(
player.x - player.facing.x * 10,
player.y - player.facing.y * 10,
(Math.random() - 0.5) * 8,
(Math.random() - 0.5) * 8,
player.weaponUpgrade === 1 ? '#ea580c' : '#f5c542',
0.4,
2
));
}
// Update Map Entities (Pressure plates, spikes, merchant, beacon, etc.)
promptContext = null;
for (let ent of mapEntities) {
if (ent.update) ent.update(dt);
// Check interaction prompt proximity
if (ent instanceof SanctuaryStone) {
const d = Math.hypot(player.x - ent.x, player.y - ent.y);
if (d < 38) {
promptContext = { text: "SANCTUARY STONE [E]", action: () => ent.activate() };
}
} else if (ent instanceof WeaponAltar && !ent.claimed) {
const d = Math.hypot(player.x - ent.x, player.y - ent.y);
if (d < 38) {
promptContext = { text: `CONSECRATE ${ent.title.toUpperCase()} [E]`, action: () => ent.activate() };
}
} else if (ent instanceof Chest && !ent.opened) {
const d = Math.hypot(player.x - (ent.x + ent.w/2), player.y - (ent.y + ent.h/2));
if (d < 38) {
promptContext = { text: "OPEN COFFER [E]", action: () => ent.open() };
}
} else if (ent instanceof MerchantAltar) {
const d = Math.hypot(player.x - ent.x, player.y - ent.y);
if (d < 46) {
promptContext = { text: "MERCHANT OF THE ARCHIVES [E]", action: () => ent.interact() };
}
} else if (ent instanceof BeaconMechanism && !ent.activated) {
const d = Math.hypot(player.x - ent.x, player.y - ent.y);
if (d < 50) {
promptContext = { text: "REIGNITE THE CORE BEACON [E]", action: () => ent.activate() };
}
} else if (ent instanceof ZoneExit) {
// Step into portal
if (player.x >= ent.x && player.x <= ent.x + ent.w &&
player.y >= ent.y && player.y <= ent.y + ent.h) {
loadZone(ent.targetZone, ent.spawnX, ent.spawnY);
return;
}
}
}
// Update prompt banner UI
const banner = document.getElementById('prompt-banner');
if (promptContext) {
banner.innerText = promptContext.text;
banner.classList.add('active');
} else {
banner.classList.remove('active');
}
// Update Enemies
for (let e of enemies) {
e.update(dt);
}
// Update Projectiles
for (let i = projectiles.length - 1; i >= 0; i--) {
projectiles[i].update(dt);
if (!projectiles[i].alive) {
projectiles.splice(i, 1);
}
}
// Update Particles
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update(dt);
if (particles[i].life <= 0) {
particles.splice(i, 1);
}
}
// Update Floating Texts
for (let i = floatingTexts.length - 1; i >= 0; i--) {
floatingTexts[i].update(dt);
if (floatingTexts[i].life <= 0) {
floatingTexts.splice(i, 1);
}
}
}
/* =========================================================================
RENDERING ENGINE
========================================================================= */
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 1. Draw Zone Environment Floor
drawEnvironmentFloor();
// 2. Draw Map Entities (plates, walls, altars, chests)
for (let ent of mapEntities) {
ent.draw(ctx);
}
// 3. Draw Enemies
for (let e of enemies) {
e.draw(ctx);
}
// 4. Draw Projectiles
for (let p of projectiles) {
p.draw(ctx);
}
// 5. Draw Player Avatar with Gear Progression
drawPlayer();
// 6. Draw Particles & Damage Numbers
for (let part of particles) {
part.draw(ctx);
}
for (let ft of floatingTexts) {
ft.draw(ctx);
}
}
function drawEnvironmentFloor() {
// Atmospheric floor tiles reflecting zone theme
const theme = ZONES[currentZone].theme;
if (theme === 'ashen') {
ctx.fillStyle = '#0f141d';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Subtle ashen grid
ctx.strokeStyle = '#1a2230';
ctx.lineWidth = 1;
for (let x = 0; x < canvas.width; x += 48) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
}
for (let y = 0; y < canvas.height; y += 48) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
}
} else if (theme === 'bronze') {
ctx.fillStyle = '#141217';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#29201a';
ctx.lineWidth = 1.5;
for (let x = 0; x < canvas.width; x += 60) {
for (let y = 0; y < canvas.height; y += 60) {
ctx.strokeRect(x, y, 60, 60);
}
}
} else if (theme === 'sunken') {
// Water reflections
ctx.fillStyle = '#0a131c';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(45, 212, 191, 0.08)';
ctx.lineWidth = 2;
for (let y = 10; y < canvas.height; y += 36) {
ctx.beginPath();
ctx.moveTo(0, y + Math.sin(Date.now() / 800 + y) * 4);
ctx.lineTo(canvas.width, y + Math.sin(Date.now() / 800 + y) * 4);
ctx.stroke();
}
} else {
// Spire & Core
ctx.fillStyle = '#0d1017';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#202838';
ctx.lineWidth = 1;
for (let x = 0; x < canvas.width; x += 50) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
}
}
}
function drawPlayer() {
ctx.save();
ctx.translate(player.x, player.y);
// Invulnerability flicker
if (player.invulnerableTimer > 0 && Math.floor(Date.now() / 60) % 2 === 0) {
ctx.globalAlpha = 0.4;
}
// Shadow
ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
ctx.beginPath();
ctx.ellipse(0, 10, 12, 6, 0, 0, Math.PI * 2);
ctx.fill();
const angle = Math.atan2(player.facing.y, player.facing.x);
const bob = player.moving ? Math.sin(player.walkCycle) * 2 : 0;
// --- Visual Character Progression ---
// 1. Cloak & Base Stance
// If Upgrade 2: wider stance; cloak edges shimmer
const stanceWidth = (player.weaponUpgrade === 2) ? 14 : 10;
ctx.fillStyle = '#1e293b'; // Warden cloak body
ctx.beginPath();
ctx.ellipse(0, bob, stanceWidth, 12, 0, 0, Math.PI * 2);
ctx.fill();
// Cloak hem shimmer for Upgrade 2
if (player.weaponUpgrade === 2) {
ctx.strokeStyle = '#f5c542';
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 2. Equipped Accessories Visual Tokens on Model
if (player.equippedAccessory === 'wardenBand') {
// Glowing silver ring on wrist
ctx.fillStyle = '#e2e8f0';
ctx.strokeStyle = '#94a3b8';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(player.facing.x * 10, player.facing.y * 10 + bob, 3, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else if (player.equippedAccessory === 'phantomLantern') {
// Soft blue light halo
ctx.fillStyle = 'rgba(94, 234, 212, 0.2)';
ctx.beginPath();
ctx.arc(0, 0, 32, 0, Math.PI * 2);
ctx.fill();
} else if (player.equippedAccessory === 'shadowCloak') {
// Dark cloth trim
ctx.strokeStyle = '#020617';
ctx.lineWidth = 3;
ctx.strokeRect(-8, bob - 8, 16, 16);
}
// 3. Off-hand: Aether Shard crystal if discovered
if (player.hasRanged) {
// Balanced dual-ready posture with off-hand pulsing crystal
const offAngle = angle - Math.PI / 2;
const crystalX = Math.cos(offAngle) * 11;
const crystalY = Math.sin(offAngle) * 11 + bob;
ctx.fillStyle = '#5eead4';
ctx.beginPath();
ctx.arc(crystalX, crystalY, 3.5, 0, Math.PI * 2);
ctx.fill();
// Crystal pulse
ctx.strokeStyle = 'rgba(94, 234, 212, 0.6)';
ctx.stroke();
}
// 4. Primary Weapon: The Ember Blade / Upgrades
// Draw blade in hand
ctx.save();
ctx.rotate(angle);
let bladeColor = '#cbd5e1';
let bladeGlow = null;
let bladeLength = 16;
let bladeWidth = 3;
if (player.weaponUpgrade === 1) {
// Ignited Edge: Blade glows orange
bladeColor = '#ea580c';
bladeGlow = 'rgba(234, 88, 12, 0.5)';
} else if (player.weaponUpgrade === 2) {
// Solar Falchion: Blade emits golden light; larger sword
bladeColor = '#f5c542';
bladeGlow = 'rgba(245, 197, 66, 0.6)';
bladeLength = 22;
bladeWidth = 4.5;
}
if (bladeGlow) {
ctx.shadowColor = bladeGlow;
ctx.shadowBlur = 8;
}
// Draw Sword
ctx.fillStyle = bladeColor;
ctx.fillRect(8, -bladeWidth / 2 + bob, bladeLength, bladeWidth);
// Sword Crossguard & Hilt
ctx.fillStyle = '#78350f';
ctx.fillRect(7, -5 + bob, 2.5, 10);
ctx.restore();
// 5. Melee Attack Swing Arc Effect
if (player.isAttacking) {
const progress = 1 - (player.attackTimer / player.attackDuration);
const sweep = (player.weaponUpgrade === 2) ? Math.PI * 0.95 : Math.PI * 0.65;
const startA = angle - sweep / 2;
const currentA = startA + sweep * progress;
const arcRadius = (player.weaponUpgrade === 2) ? 56 : ((player.weaponUpgrade === 1) ? 44 : 36);
ctx.save();
ctx.strokeStyle = (player.weaponUpgrade === 2) ? '#fef08a' : ((player.weaponUpgrade === 1) ? '#ea580c' : '#e2e8f0');
ctx.lineWidth = (player.weaponUpgrade === 2) ? 5 : 3;
ctx.beginPath();
ctx.arc(0, bob, arcRadius, startA, currentA);
ctx.stroke();
ctx.restore();
}
// Head/Helmet
ctx.fillStyle = '#334155';
ctx.beginPath();
ctx.arc(0, -2 + bob, 6, 0, Math.PI * 2);
ctx.fill();
// Golden Warden Visor
ctx.fillStyle = '#f5c542';
ctx.fillRect(player.facing.x * 3 - 2, player.facing.y * 3 - 3 + bob, 4, 2);
ctx.restore();
}
/* =========================================================================
INPUT EVENT LISTENERS
========================================================================= */
window.addEventListener('keydown', (e) => {
// Resume Web Audio context upon first keypress
audio.init();
if (e.code === 'KeyW' || e.code === 'ArrowUp') keys.up = true;
if (e.code === 'KeyS' || e.code === 'ArrowDown') keys.down = true;
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keys.left = true;
if (e.code === 'KeyD' || e.code === 'ArrowRight') keys.right = true;
// Melee attack
if (e.code === 'KeyJ' || e.code === 'KeyZ' || e.code === 'Space') {
if (!isShopOpen && !isInventoryOpen) performMeleeAttack();
}
// Ranged attack
if (e.code === 'KeyK' || e.code === 'KeyX') {
if (!isShopOpen && !isInventoryOpen) performRangedAttack();
}
// Interaction
if (e.code === 'KeyE') {
if (promptContext && promptContext.action) {
promptContext.action();
}
}
// Inventory toggle
if (e.code === 'KeyI' || e.code === 'Tab') {
e.preventDefault();
toggleInventory();
}
// Escape key closes modals
if (e.code === 'Escape') {
if (isInventoryOpen) toggleInventory();
if (isShopOpen) closeShop();
}
});
window.addEventListener('keyup', (e) => {
if (e.code === 'KeyW' || e.code === 'ArrowUp') keys.up = false;
if (e.code === 'KeyS' || e.code === 'ArrowDown') keys.down = false;
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keys.left = false;
if (e.code === 'KeyD' || e.code === 'ArrowRight') keys.right = false;
});
// Canvas mouse/pointer click fallback for attacks
canvas.addEventListener('mousedown', (e) => {
audio.init();
if (isShopOpen || isInventoryOpen) return;
if (e.button === 0) performMeleeAttack();
if (e.button === 2) performRangedAttack();
});
canvas.addEventListener('contextmenu', e => e.preventDefault());
function restartGame() {
player.maxContainers = 3;
player.hp = 6;
player.gold = 0;
player.weaponUpgrade = 0;
player.hasRanged = false;
player.rangedAmmo = 20;
player.equippedAccessory = null;
player.inventory = {
wardenBand: false,
phantomLantern: false,
shadowCloak: false,
vitalStem: 1,
bindingSeal: 0
};
activeSavePoint = 0;
lastCheckpointState = null;
isGameWon = false;
document.getElementById('victory-modal').classList.remove('active');
loadZone(0, 200, 300);
updateHealthUI();
updateGoldUI();
updateWeaponUI();
showToast("Cycle Reborn at The Ashen Threshold.");
}
/* =========================================================================
INITIALIZATION
========================================================================= */
window.addEventListener('DOMContentLoaded', () => {
loadZone(0, 200, 300);
updateHealthUI();
updateGoldUI();
updateWeaponUI();
requestAnimationFrame(gameLoop);
});
</script>
</body>
</html>
<!-- agent-meta {"model":"google/gemini-3.8-flash","provider":"openrouter","persona":"full","sessionId":"ac34e708-0fb0-494d-9655-11debcf92961","tokensIn":1779071,"tokensOut":42052,"tokensTotal":1821123,"cost":0.5489651999999999,"turns":30,"toolCalls":29,"failedToolCalls":1,"timestamp":"2026-09-03T13:55:42.810Z"} -->