Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/Jennys-Drumste / freestyle-mode.html |
| Date | Not specified in source |
| Repository | Janady13/Jennys-Drumste |
| Revision | 1f946b9e18e552f80f4b2ffd7f59c01d0126aa53 |
| Branch | claude/jenny-drumsets-research-MofSb |
| Path or archive member | freestyle-mode.html |
| Original SHA-256 | dac5cdb7da7b72be13664ed1614b0f764741e8c92774aef192d2bc1aaf8dc1f5 |
| Git blob SHA | 361bf3bfa48f3a6676e4e8b81dc6ca12b792335c |
| Reading-copy SHA-256 | dac5cdb7da7b72be13664ed1614b0f764741e8c92774aef192d2bc1aaf8dc1f5 |
Recovered source transcript
Download complete source text ↓981 lines · 33.8 KB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DrumMaster Pro - Freestyle Jam Mode</title>
<script src="seferim-core.js"></script>
<script src="audio-engine.js"></script>
<script src="game-systems.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--kick: #ff6b35;
--snare: #f7c948;
--hihat: #4ecdc4;
--crash: #ff006e;
--tom1: #8338ec;
--tom2: #3a86ff;
--tom3: #06d6a0;
--ride: #06ffa5;
--neon-pink: #ff00ff;
--neon-cyan: #00ffff;
}
body {
font-family: 'Orbitron', sans-serif;
background: linear-gradient(135deg, #0a0015 0%, #1a0030 50%, #0a0a20 100%);
color: white;
min-height: 100vh;
overflow: hidden;
}
.container {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh;
padding: 20px;
gap: 20px;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 25px;
background: rgba(0,0,0,0.5);
border: 1px solid rgba(255,0,255,0.3);
border-radius: 15px;
}
.logo {
font-family: 'Press Start 2P', cursive;
font-size: 1.2em;
background: linear-gradient(90deg, var(--neon-pink), var(--neon-cyan));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.controls {
display: flex;
gap: 15px;
align-items: center;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-family: 'Orbitron', sans-serif;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-metro {
background: linear-gradient(135deg, #8338ec, #6b2cb3);
color: white;
}
.btn-metro.active {
background: linear-gradient(135deg, var(--neon-cyan), #00aacc);
box-shadow: 0 0 20px rgba(0,255,255,0.5);
}
.btn-record {
background: linear-gradient(135deg, #ff006e, #cc0058);
color: white;
}
.btn-record.recording {
animation: pulse-red 0.5s infinite;
}
@keyframes pulse-red {
0%, 100% { box-shadow: 0 0 10px #ff006e; }
50% { box-shadow: 0 0 30px #ff006e; }
}
.bpm-control {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 15px;
background: rgba(0,0,0,0.5);
border-radius: 8px;
}
.bpm-value {
font-size: 1.5em;
font-weight: 900;
color: var(--neon-cyan);
min-width: 60px;
text-align: center;
}
.bpm-btn {
width: 30px;
height: 30px;
border: none;
border-radius: 50%;
background: rgba(255,255,255,0.1);
color: white;
font-size: 1.2em;
cursor: pointer;
}
.bpm-btn:hover {
background: rgba(0,255,255,0.2);
}
/* Main Drum Kit Area */
.kit-container {
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.drum-kit {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 20px;
max-width: 900px;
}
.drum-pad {
width: 150px;
height: 150px;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.1s;
position: relative;
user-select: none;
}
.drum-pad::before {
content: '';
position: absolute;
inset: -5px;
border-radius: 50%;
background: inherit;
opacity: 0.3;
filter: blur(15px);
z-index: -1;
}
.drum-pad[data-drum="kick"] {
background: radial-gradient(circle at 30% 30%, var(--kick), #aa4520);
border: 4px solid var(--kick);
grid-column: 2;
grid-row: 2;
width: 180px;
height: 180px;
}
.drum-pad[data-drum="snare"] {
background: radial-gradient(circle at 30% 30%, var(--snare), #aa8830);
border: 4px solid var(--snare);
grid-column: 3;
grid-row: 2;
}
.drum-pad[data-drum="hihat"] {
background: radial-gradient(circle at 30% 30%, var(--hihat), #2a9a90);
border: 4px solid var(--hihat);
grid-column: 1;
grid-row: 1;
}
.drum-pad[data-drum="crash"] {
background: radial-gradient(circle at 30% 30%, var(--crash), #aa0050);
border: 4px solid var(--crash);
grid-column: 1;
grid-row: 2;
width: 130px;
height: 130px;
}
.drum-pad[data-drum="tom1"] {
background: radial-gradient(circle at 30% 30%, var(--tom1), #5a20aa);
border: 4px solid var(--tom1);
grid-column: 2;
grid-row: 1;
}
.drum-pad[data-drum="tom2"] {
background: radial-gradient(circle at 30% 30%, var(--tom2), #2060cc);
border: 4px solid var(--tom2);
grid-column: 3;
grid-row: 1;
}
.drum-pad[data-drum="tom3"] {
background: radial-gradient(circle at 30% 30%, var(--tom3), #04a87a);
border: 4px solid var(--tom3);
grid-column: 4;
grid-row: 2;
}
.drum-pad[data-drum="ride"] {
background: radial-gradient(circle at 30% 30%, var(--ride), #04cc80);
border: 4px solid var(--ride);
grid-column: 4;
grid-row: 1;
width: 130px;
height: 130px;
}
.drum-pad.hit {
transform: scale(0.92);
filter: brightness(1.5);
}
.drum-pad .name {
font-family: 'Press Start 2P', cursive;
font-size: 0.7em;
margin-bottom: 5px;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
.drum-pad .key {
font-size: 1.5em;
opacity: 0.8;
}
.drum-pad .key-hint {
position: absolute;
bottom: 10px;
font-size: 0.6em;
opacity: 0.5;
font-family: 'Press Start 2P', cursive;
}
/* Metronome indicator */
.metro-indicator {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 200px;
height: 200px;
border-radius: 50%;
border: 4px solid rgba(255,255,255,0.1);
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
}
.metro-indicator.active {
opacity: 1;
}
.metro-indicator.beat {
animation: metroPulse 0.2s ease-out;
}
.metro-indicator.accent {
border-color: var(--neon-cyan);
box-shadow: 0 0 40px var(--neon-cyan);
}
@keyframes metroPulse {
0% { transform: translate(-50%, -50%) scale(0.8); opacity: 1; }
100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
}
/* Beat counter */
.beat-counter {
position: fixed;
top: 120px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 15px;
opacity: 0;
transition: opacity 0.3s;
}
.beat-counter.active {
opacity: 1;
}
.beat-dot {
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(255,255,255,0.2);
border: 2px solid rgba(255,255,255,0.3);
transition: all 0.1s;
}
.beat-dot.current {
background: var(--neon-cyan);
box-shadow: 0 0 15px var(--neon-cyan);
transform: scale(1.3);
}
.beat-dot.accent {
border-color: var(--neon-pink);
}
/* Recording visualization */
.recording-viz {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
width: 80%;
max-width: 800px;
height: 100px;
background: rgba(0,0,0,0.5);
border: 1px solid rgba(255,0,110,0.3);
border-radius: 10px;
overflow: hidden;
display: none;
}
.recording-viz.active {
display: block;
}
.recording-timeline {
height: 100%;
display: flex;
align-items: center;
padding: 10px;
gap: 2px;
}
.recording-hit {
height: 60%;
width: 8px;
border-radius: 4px;
opacity: 0.8;
}
/* Footer Stats */
.footer {
display: flex;
justify-content: center;
gap: 30px;
padding: 15px;
background: rgba(0,0,0,0.5);
border: 1px solid rgba(0,255,255,0.2);
border-radius: 15px;
}
.stat {
text-align: center;
}
.stat-value {
font-size: 1.8em;
font-weight: 900;
color: var(--neon-cyan);
}
.stat-label {
font-size: 0.7em;
opacity: 0.6;
text-transform: uppercase;
}
/* Effects canvas */
#effectsCanvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 100;
}
/* Volume slider */
.volume-control {
display: flex;
align-items: center;
gap: 10px;
}
.volume-slider {
width: 100px;
-webkit-appearance: none;
height: 6px;
background: rgba(255,255,255,0.2);
border-radius: 3px;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: var(--neon-cyan);
border-radius: 50%;
cursor: pointer;
}
/* Kit selector */
.kit-selector {
display: flex;
gap: 10px;
}
.kit-option {
padding: 8px 15px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.kit-option:hover {
background: rgba(255,255,255,0.1);
}
.kit-option.active {
background: linear-gradient(135deg, rgba(0,255,255,0.2), rgba(255,0,255,0.2));
border-color: var(--neon-cyan);
}
</style>
</head>
<body>
<canvas id="effectsCanvas"></canvas>
<div class="container">
<!-- Header -->
<div class="header">
<div class="logo">FREESTYLE JAM</div>
<div class="controls">
<div class="volume-control">
<span>🔊</span>
<input type="range" class="volume-slider" id="volumeSlider" min="0" max="100" value="80">
</div>
<div class="kit-selector">
<div class="kit-option active" data-kit="rock">Rock</div>
<div class="kit-option" data-kit="jazz">Jazz</div>
<div class="kit-option" data-kit="electronic">Electronic</div>
</div>
<div class="bpm-control">
<button class="bpm-btn" onclick="changeBPM(-5)">-</button>
<div class="bpm-value" id="bpmDisplay">120</div>
<button class="bpm-btn" onclick="changeBPM(5)">+</button>
<span style="font-size: 0.8em; opacity: 0.6;">BPM</span>
</div>
<button class="btn btn-metro" id="metroBtn" onclick="toggleMetronome()">
METRONOME
</button>
<button class="btn btn-record" id="recordBtn" onclick="toggleRecording()">
⏺ RECORD
</button>
</div>
</div>
<!-- Beat Counter -->
<div class="beat-counter" id="beatCounter">
<div class="beat-dot accent" data-beat="0"></div>
<div class="beat-dot" data-beat="1"></div>
<div class="beat-dot" data-beat="2"></div>
<div class="beat-dot" data-beat="3"></div>
</div>
<!-- Metronome Indicator -->
<div class="metro-indicator" id="metroIndicator"></div>
<!-- Drum Kit -->
<div class="kit-container">
<div class="drum-kit">
<div class="drum-pad" data-drum="hihat" data-note="46">
<span class="name">HI-HAT</span>
<span class="key">🎩</span>
<span class="key-hint">H</span>
</div>
<div class="drum-pad" data-drum="tom1" data-note="48">
<span class="name">TOM 1</span>
<span class="key">🥁</span>
<span class="key-hint">T</span>
</div>
<div class="drum-pad" data-drum="tom2" data-note="45">
<span class="name">TOM 2</span>
<span class="key">🥁</span>
<span class="key-hint">Y</span>
</div>
<div class="drum-pad" data-drum="ride" data-note="51">
<span class="name">RIDE</span>
<span class="key">🔔</span>
<span class="key-hint">R</span>
</div>
<div class="drum-pad" data-drum="crash" data-note="49">
<span class="name">CRASH</span>
<span class="key">💥</span>
<span class="key-hint">C</span>
</div>
<div class="drum-pad" data-drum="kick" data-note="36">
<span class="name">KICK</span>
<span class="key">👢</span>
<span class="key-hint">SPACE</span>
</div>
<div class="drum-pad" data-drum="snare" data-note="38">
<span class="name">SNARE</span>
<span class="key">🎵</span>
<span class="key-hint">S</span>
</div>
<div class="drum-pad" data-drum="tom3" data-note="43">
<span class="name">FLOOR</span>
<span class="key">🥁</span>
<span class="key-hint">F</span>
</div>
</div>
</div>
<!-- Recording Visualization -->
<div class="recording-viz" id="recordingViz">
<div class="recording-timeline" id="recordingTimeline"></div>
</div>
<!-- Footer Stats -->
<div class="footer">
<div class="stat">
<div class="stat-value" id="totalHits">0</div>
<div class="stat-label">Total Hits</div>
</div>
<div class="stat">
<div class="stat-value" id="currentCombo">0</div>
<div class="stat-label">Combo</div>
</div>
<div class="stat">
<div class="stat-value" id="maxCombo">0</div>
<div class="stat-label">Best Combo</div>
</div>
<div class="stat">
<div class="stat-value" id="sessionTime">0:00</div>
<div class="stat-label">Session Time</div>
</div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════════════════════
// FREESTYLE MODE
// ═══════════════════════════════════════════════════════════════════════════
const DRUM_COLORS = {
kick: '#ff6b35', snare: '#f7c948', hihat: '#4ecdc4',
crash: '#ff006e', tom1: '#8338ec', tom2: '#3a86ff',
tom3: '#06d6a0', ride: '#06ffa5'
};
const KEY_MAP = {
' ': 'kick', 's': 'snare', 'h': 'hihat', 'c': 'crash',
't': 'tom1', 'y': 'tom2', 'f': 'tom3', 'r': 'ride'
};
const MIDI_MAP = {
36: 'kick', 38: 'snare', 46: 'hihat', 49: 'crash',
48: 'tom1', 45: 'tom2', 43: 'tom3', 51: 'ride'
};
// State
let state = {
bpm: 120,
metronomeOn: false,
recording: false,
recordedHits: [],
recordStartTime: 0,
totalHits: 0,
currentCombo: 0,
maxCombo: 0,
lastHitTime: 0,
sessionStartTime: Date.now()
};
// Audio & Effects
let effectsCanvas, effectsCtx;
let activeEffects = [];
let metronome = null;
// ═══════════════════════════════════════════════════════════════════════════
// INITIALIZATION
// ═══════════════════════════════════════════════════════════════════════════
function init() {
effectsCanvas = document.getElementById('effectsCanvas');
effectsCtx = effectsCanvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
setupKeyboard();
setupDrumPads();
setupMIDI();
setupVolumeControl();
setupKitSelector();
// Wait for audio engine
const waitForAudio = setInterval(() => {
if (window.drumAudio && window.drumAudio.isInitialized) {
clearInterval(waitForAudio);
metronome = new Metronome(window.drumAudio);
metronome.onBeat = onMetronomeBeat;
console.log('[Freestyle] Audio ready');
}
}, 100);
// Start render loop
requestAnimationFrame(render);
// Session timer
setInterval(updateSessionTime, 1000);
}
function resizeCanvas() {
effectsCanvas.width = window.innerWidth;
effectsCanvas.height = window.innerHeight;
}
// ═══════════════════════════════════════════════════════════════════════════
// INPUT HANDLERS
// ═══════════════════════════════════════════════════════════════════════════
function setupKeyboard() {
document.addEventListener('keydown', (e) => {
const drum = KEY_MAP[e.key.toLowerCase()];
if (drum) {
e.preventDefault();
playDrum(drum);
}
});
}
function setupDrumPads() {
document.querySelectorAll('.drum-pad').forEach(pad => {
pad.addEventListener('mousedown', () => playDrum(pad.dataset.drum));
pad.addEventListener('touchstart', (e) => {
e.preventDefault();
playDrum(pad.dataset.drum);
});
});
}
async function setupMIDI() {
try {
const access = await navigator.requestMIDIAccess();
for (const input of access.inputs.values()) {
input.onmidimessage = (msg) => {
const [status, note, velocity] = msg.data;
if (status >= 144 && status <= 159 && velocity > 0) {
const drum = MIDI_MAP[note];
if (drum) playDrum(drum, velocity / 127);
}
};
}
console.log('[Freestyle] MIDI connected');
} catch (e) {
console.log('[Freestyle] MIDI not available');
}
}
function setupVolumeControl() {
const slider = document.getElementById('volumeSlider');
slider.addEventListener('input', () => {
if (window.drumAudio) {
window.drumAudio.setVolume(slider.value / 100);
}
});
}
function setupKitSelector() {
document.querySelectorAll('.kit-option').forEach(opt => {
opt.addEventListener('click', () => {
document.querySelectorAll('.kit-option').forEach(o => o.classList.remove('active'));
opt.classList.add('active');
if (window.drumAudio) {
window.drumAudio.setKit(opt.dataset.kit);
}
});
});
}
// ═══════════════════════════════════════════════════════════════════════════
// DRUM PLAYING
// ═══════════════════════════════════════════════════════════════════════════
function playDrum(drumName, velocity = 1) {
// Initialize audio on first interaction
if (window.drumAudio && !window.drumAudio.isInitialized) {
window.drumAudio.init();
}
// Play sound
if (window.drumAudio) {
window.drumAudio.play(drumName, velocity);
}
// Visual feedback
const pad = document.querySelector(`.drum-pad[data-drum="${drumName}"]`);
if (pad) {
pad.classList.add('hit');
setTimeout(() => pad.classList.remove('hit'), 100);
}
// Create explosion effect
createExplosion(drumName);
// Update stats
state.totalHits++;
document.getElementById('totalHits').textContent = state.totalHits;
// Combo tracking (within 2 seconds)
const now = Date.now();
if (now - state.lastHitTime < 2000) {
state.currentCombo++;
} else {
state.currentCombo = 1;
}
state.lastHitTime = now;
if (state.currentCombo > state.maxCombo) {
state.maxCombo = state.currentCombo;
document.getElementById('maxCombo').textContent = state.maxCombo;
}
document.getElementById('currentCombo').textContent = state.currentCombo;
// Recording
if (state.recording) {
const time = now - state.recordStartTime;
state.recordedHits.push({ drum: drumName, time, velocity });
addRecordingHit(drumName);
}
// SEFERIM learning
if (window.SEFERIM) {
window.SEFERIM.processDrumHit(drumName, velocity * 127, now, now, 'freestyle');
}
// Profile stats
if (window.playerProfile) {
window.playerProfile.data.totalNotesHit++;
const statMap = {
kick: 'kicksHit', snare: 'snaresHit', hihat: 'hihatsHit',
tom1: 'tomsHit', tom2: 'tomsHit', tom3: 'tomsHit',
crash: 'cymbalsHit', ride: 'cymbalsHit'
};
if (statMap[drumName]) {
window.playerProfile.data.stats[statMap[drumName]]++;
}
window.playerProfile.save();
}
}
// ═══════════════════════════════════════════════════════════════════════════
// METRONOME
// ═══════════════════════════════════════════════════════════════════════════
function toggleMetronome() {
state.metronomeOn = !state.metronomeOn;
const btn = document.getElementById('metroBtn');
const counter = document.getElementById('beatCounter');
if (state.metronomeOn) {
btn.classList.add('active');
counter.classList.add('active');
if (metronome) {
metronome.setBPM(state.bpm);
metronome.start();
}
} else {
btn.classList.remove('active');
counter.classList.remove('active');
if (metronome) {
metronome.stop();
}
}
}
function onMetronomeBeat(beat, isAccent) {
// Update beat dots
document.querySelectorAll('.beat-dot').forEach((dot, i) => {
dot.classList.toggle('current', i === beat);
});
// Pulse indicator
const indicator = document.getElementById('metroIndicator');
indicator.classList.remove('beat', 'accent');
indicator.classList.add('active');
void indicator.offsetWidth; // Trigger reflow
indicator.classList.add('beat');
if (isAccent) indicator.classList.add('accent');
}
function changeBPM(delta) {
state.bpm = Math.max(40, Math.min(300, state.bpm + delta));
document.getElementById('bpmDisplay').textContent = state.bpm;
if (metronome && state.metronomeOn) {
metronome.setBPM(state.bpm);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// RECORDING
// ═══════════════════════════════════════════════════════════════════════════
function toggleRecording() {
state.recording = !state.recording;
const btn = document.getElementById('recordBtn');
const viz = document.getElementById('recordingViz');
if (state.recording) {
btn.classList.add('recording');
btn.innerHTML = '⏹ STOP';
viz.classList.add('active');
document.getElementById('recordingTimeline').innerHTML = '';
state.recordedHits = [];
state.recordStartTime = Date.now();
} else {
btn.classList.remove('recording');
btn.innerHTML = '⏺ RECORD';
if (state.recordedHits.length > 0) {
console.log('[Freestyle] Recording:', state.recordedHits);
// Could export or save the recording here
}
}
}
function addRecordingHit(drum) {
const timeline = document.getElementById('recordingTimeline');
const hit = document.createElement('div');
hit.className = 'recording-hit';
hit.style.background = DRUM_COLORS[drum];
timeline.appendChild(hit);
// Scroll to end
timeline.scrollLeft = timeline.scrollWidth;
}
// ═══════════════════════════════════════════════════════════════════════════
// VISUAL EFFECTS
// ═══════════════════════════════════════════════════════════════════════════
function createExplosion(drum) {
const pad = document.querySelector(`.drum-pad[data-drum="${drum}"]`);
if (!pad) return;
const rect = pad.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const color = DRUM_COLORS[drum];
// Ring effect
activeEffects.push({
type: 'ring',
x, y,
radius: rect.width / 2,
maxRadius: rect.width * 1.5,
color,
alpha: 1
});
// Particles
for (let i = 0; i < 12; i++) {
const angle = (Math.PI * 2 / 12) * i;
const speed = 4 + Math.random() * 6;
activeEffects.push({
type: 'particle',
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
radius: 4 + Math.random() * 4,
color,
alpha: 1,
decay: 0.02 + Math.random() * 0.02
});
}
}
function render() {
effectsCtx.clearRect(0, 0, effectsCanvas.width, effectsCanvas.height);
for (let i = activeEffects.length - 1; i >= 0; i--) {
const e = activeEffects[i];
if (e.type === 'ring') {
e.radius += 5;
e.alpha -= 0.05;
effectsCtx.beginPath();
effectsCtx.arc(e.x, e.y, e.radius, 0, Math.PI * 2);
effectsCtx.strokeStyle = e.color + Math.floor(e.alpha * 255).toString(16).padStart(2, '0');
effectsCtx.lineWidth = 4;
effectsCtx.stroke();
} else if (e.type === 'particle') {
e.x += e.vx;
e.y += e.vy;
e.vy += 0.15;
e.alpha -= e.decay;
effectsCtx.beginPath();
effectsCtx.arc(e.x, e.y, e.radius, 0, Math.PI * 2);
effectsCtx.fillStyle = e.color + Math.floor(Math.max(0, e.alpha) * 255).toString(16).padStart(2, '0');
effectsCtx.fill();
}
if (e.alpha <= 0) {
activeEffects.splice(i, 1);
}
}
requestAnimationFrame(render);
}
// ═══════════════════════════════════════════════════════════════════════════
// SESSION TIME
// ═══════════════════════════════════════════════════════════════════════════
function updateSessionTime() {
const elapsed = Math.floor((Date.now() - state.sessionStartTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
document.getElementById('sessionTime').textContent =
`${mins}:${secs.toString().padStart(2, '0')}`;
// Update profile play time
if (window.playerProfile) {
window.playerProfile.data.totalPlayTime++;
if (elapsed % 60 === 0) {
window.playerProfile.save();
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>