STUMBLE DASH
CHOOSE YOUR LOOK
ROOM: XXXX
// Helpers
function getConfig(category, id) {
return window.sekaiEditable[category]?.find(i => i.id === id)?.value;
}
function setNestedValue(obj, path, value) {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) current[parts[i]] = {};
current = current[parts[i]];
}
current[parts[parts.length - 1]] = value;
}
function applyAllEditableValues() {
window.sekaiEditable.tune?.forEach(t => { if (t.path) setNestedValue(window, t.path, t.value); });
window.sekaiEditable.colors?.forEach(c => { if (c.cssVar) document.documentElement.style.setProperty(c.cssVar, c.value); });
window.sekaiEditable.images?.forEach(img => {
if (img.selector) {
const el = document.querySelector(img.selector);
if (el) {
if (img.property === 'src') el.src = img.value;
else if (img.property === 'backgroundImage') el.style.backgroundImage = img.value ? `url('${img.value}')` : 'none';
}
}
});
window.sekaiEditable.music?.forEach(m => {
if (m.selector) {
const el = document.querySelector(m.selector);
if (el && el.src !== m.value) { el.src = m.value; el.load(); }
}
});
window.sekaiEditable.sfx?.forEach(s => {
if (s.selector) {
const el = document.querySelector(s.selector);
if (el && el.src !== s.value) { el.src = s.value; el.load(); }
}
});
window.sekaiEditable.text?.forEach(t => {
if (t.selector) {
const el = document.querySelector(t.selector);
if (el) { el.textContent = t.value; }
}
});
}
// Preview Editing API
(function setupPreviewEditingAPI() {
window.addEventListener('message', (event) => {
const msg = event.data;
if (msg?.origin !== 'sekai_gaming_iframe_api') return;
switch (msg.type) {
case 'get_editable_metadata':
window.parent.postMessage({
origin: 'sekai_gaming_iframe_api',
type: 'receive_editable_metadata',
taskId: msg.taskId,
data: window.sekaiEditable || {}
}, '*');
break;
case 'apply_change':
const success = applySingleChange(msg.data.id, msg.data.value, msg.data.name, msg.data.description);
window.parent.postMessage({
origin: 'sekai_gaming_iframe_api',
type: 'receive_apply_change',
taskId: msg.taskId,
data: { success }
}, '*');
break;
case 'get_current_html':
window.parent.postMessage({
origin: 'sekai_gaming_iframe_api',
type: 'receive_current_html',
taskId: msg.taskId,
data: { html: document.documentElement.outerHTML }
}, '*');
break;
case 'reset_changes':
location.reload();
break;
}
});
function applySingleChange(id, value, name, description) {
const categories = ['tune', 'images', 'videos', 'music', 'sfx', 'colors', 'text', 'prompts', 'voices'];
for (const cat of categories) {
const item = window.sekaiEditable[cat]?.find(i => i.id === id);
if (!item) continue;
item.value = value;
if (name) item.name = name;
if (description) item.description = description;
if (item.cssVar) {
document.documentElement.style.setProperty(item.cssVar, value);
} else if (item.path) {
setNestedValue(window, item.path, value);
} else if (item.selector) {
const el = document.querySelector(item.selector);
if (!el) return false;
if (el.tagName === 'AUDIO') {
const wasPlaying = !el.paused;
el.src = value;
el.load();
if (wasPlaying) el.play().catch(() => {});
} else if (item.property === 'src') {
el.src = value;
} else if (item.property === 'backgroundImage') {
el.style.backgroundImage = value ? `url('${value}')` : 'none';
} else {
el.textContent = value;
}
}
return true;
}
return false;
}
})();
// ==========================================
// 2. IFRAME API HELPERS
// ==========================================
const IframeAPI = {
sendMessage(type, data, awaitResponse = true, sessionCode = null) {
return new Promise((resolve, reject) => {
const taskId = crypto.randomUUID();
const message = { origin: 'sekai_gaming_iframe_api', type, taskId, data };
if (sessionCode) message.sessionCode = sessionCode;
if (awaitResponse) {
const expectedType = 'receive_' + (type === 'upload_image' ? 'upload_image' : type);
const listener = (event) => {
const msg = event.data;
if (msg?.origin === 'sekai_gaming_iframe_api' && msg.type === expectedType && msg.taskId === taskId) {
window.removeEventListener('message', listener);
if (msg.error) reject(new Error(msg.error.message));
else resolve(msg.data);
}
};
window.addEventListener('message', listener);
setTimeout(() => {
window.removeEventListener('message', listener);
reject(new Error('Timeout'));
}, 5000);
}
window.parent.postMessage(message, '*');
if (!awaitResponse) resolve();
});
},
vibrate(duration) {
this.sendMessage('gen_vibration', { duration }, false).catch(()=>{});
}
};
// ==========================================
// 3. GAME LOGIC & STATE
// ==========================================
// App State accessible by paths
window.appState = {
config: {
speed: 450,
gravity: 2800,
jumpForce: 950
},
stats: {
level: 1,
attempts: 1
}
};
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d', { alpha: false });
// UI Elements Cache
const elements = {
screenStart: document.getElementById('screen-start'),
screenDeath: document.getElementById('screen-death'),
screenWin: document.getElementById('screen-win'),
screenEnd: document.getElementById('screen-end'),
btnRetry: document.getElementById('btn-retry'),
btnNext: document.getElementById('btn-next'),
btnShare: document.getElementById('btn-share'),
btnRestart: document.getElementById('btn-restart-game'),
progressBar: document.getElementById('progress-bar'),
attemptCounter: document.getElementById('attempt-counter'),
deathStats: document.getElementById('death-stats'),
winStats: document.getElementById('win-stats')
};
// Game Constants & Architecture
const TILE_SIZE = 50;
const PLAYER_SIZE = 36; // Slightly smaller than tile for leniency
const GROUND_HEIGHT = 150;
let GAME_STATE = 'IDLE'; // IDLE, PLAYING, DEAD, WIN
let lastTime = 0;
let animationId;
// Viewport scaling
let vWidth = 1000;
let vHeight = 600;
let scale = 1;
// Entities
let player = { x: 200, y: 0, w: PLAYER_SIZE, h: PLAYER_SIZE, vy: 0, isGrounded: true, rotation: 0 };
let obstacles = [];
let particles = [];
let levelLength = 0;
let cameraX = 0;
// Level Designs (Map strings where bottom row is y=1 above ground)
// # = Block, ^ = Spike, > = Trigger Win
const levels = [
// Level 1: Introduction
[
" ",
" ",
" >",
" ### ### #",
" # # ",
" ^ ## ^ ## ^ ",
" # ### # ### ##### ### ### ^ ^ ^ "
],
// Level 2: Staircases and Gaps
[
" ",
" >",
" # # # #",
" ### # # # ",
" # ##### # # # ",
" # ### ####### # # # ",
" # ### ##### ^ ######### ^ # # # ^ ^ "
],
// Level 3: Tight timings
[
" ",
" >",
" # # # # #",
" ### ### # # # # ",
" # # # # # # ### # # # # ",
" # # # # # # # # # # # # # ### ### # ",
" ^ ^ ### # ^ # ### ### # ^ # ### ^^^^^ # # # ^ # # ##### ##### # ^^^^ "
]
];
// Audio Management
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
function playProceduralSound(type) {
if (audioContext.state === 'suspended') audioContext.resume();
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
const now = audioContext.currentTime;
if (type === 'jump') {
osc.type = 'square';
osc.frequency.setValueAtTime(150, now);
osc.frequency.exponentialRampToValueAtTime(300, now + 0.1);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
osc.start(now);
osc.stop(now + 0.1);
} else if (type === 'crash') {
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(100, now);
osc.frequency.exponentialRampToValueAtTime(20, now + 0.3);
gain.gain.setValueAtTime(0.2, now);
gain.gain.linearRampToValueAtTime(0.01, now + 0.3);
osc.start(now);
osc.stop(now + 0.3);
} else if (type === 'win') {
osc.type = 'sine';
osc.frequency.setValueAtTime(440, now);
osc.frequency.setValueAtTime(554.37, now + 0.1); // C#
osc.frequency.setValueAtTime(659.25, now + 0.2); // E
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.2, now + 0.05);
gain.gain.linearRampToValueAtTime(0, now + 0.5);
osc.start(now);
osc.stop(now + 0.5);
}
}
function playSound(id, fallbackType) {
const el = document.getElementById('sfx-' + id);
if (el && el.src && el.src !== window.location.href) {
el.currentTime = 0;
el.play().catch(()=>{});
} else {
playProceduralSound(fallbackType);
}
}
function initBGM() {
const el = document.getElementById('music-bgm');
if (!el?.src || el.src === window.location.href) return;
el.loop = true;
el.volume = 0.5;
const playBgm = () => {
el.play().catch(() => {
document.addEventListener('touchstart', playBgm, { once: true });
document.addEventListener('click', playBgm, { once: true });
});
};
playBgm();
}
// Parse Level String Array into Entities
function loadLevel(levelIndex) {
const map = levels[levelIndex];
obstacles = [];
let maxCol = 0;
// Map height determines ground offset internally, but we render bottom up.
const mapHeight = map.length;
for (let r = 0; r < map.length; r++) {
const row = map[r];
for (let c = 0; c < row.length; c++) {
const char = row[c];
// Calculate logical Y from bottom up (0 is ground)
const yDistFromBottom = mapHeight - 1 - r;
const yPos = yDistFromBottom * TILE_SIZE;
const xPos = c * TILE_SIZE + 800; // Offset start by 800 units
if (char === '#') {
obstacles.push({ type: 'block', x: xPos, y: yPos, w: TILE_SIZE, h: TILE_SIZE });
} else if (char === '^') {
// Spikes are slightly smaller than tile for fair hitboxes
const hitW = TILE_SIZE * 0.6;
const hitH = TILE_SIZE * 0.7;
obstacles.push({
type: 'spike',
x: xPos + (TILE_SIZE - hitW)/2,
y: yPos,
w: hitW,
h: hitH,
drawX: xPos,
drawW: TILE_SIZE
});
} else if (char === '>') {
obstacles.push({ type: 'trigger', x: xPos, y: yPos, w: TILE_SIZE, h: TILE_SIZE * 10 });
}
if (char !== ' ') {
maxCol = Math.max(maxCol, c);
}
}
}
levelLength = maxCol * TILE_SIZE + 800 + 400; // Extra padding at end
resetPlayer();
updateUI();
}
function resetPlayer() {
player.x = 200;
player.y = 0;
player.vy = 0;
player.isGrounded = true;
player.rotation = 0;
cameraX = 0;
particles = [];
elements.progressBar.style.width = '0%';
}
function spawnParticles(x, y, color) {
for (let i = 0; i < 20; i++) {
particles.push({
x: x,
y: y,
vx: (Math.random() - 0.5) * 600,
vy: (Math.random() - 0.5) * 600 - 200,
life: 1.0,
color: color
});
}
}
// ==========================================
// 4. GAME LOOP & PHYSICS
// ==========================================
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// Calculate scale to guarantee at least 'vWidth' units horizontally
scale = canvas.width / vWidth;
vHeight = canvas.height / scale;
}
window.addEventListener('resize', resize);
function jump() {
if (GAME_STATE === 'IDLE') {
// Start game
GAME_STATE = 'PLAYING';
elements.screenStart.style.opacity = '0';
setTimeout(() => { if (GAME_STATE === 'PLAYING') elements.screenStart.classList.add('hidden'); }, 300);
}
if (GAME_STATE === 'PLAYING' && player.isGrounded) {
player.vy = -appState.config.jumpForce;
player.isGrounded = false;
playSound('jump', 'jump');
IframeAPI.vibrate(20);
}
}
// Input handling - unified pointer events
document.addEventListener('pointerdown', (e) => {
// Prevent jump if clicking UI buttons
if (e.target.closest('button')) return;
if (GAME_STATE === 'IDLE' || GAME_STATE === 'PLAYING') {
jump();
}
});
function AABB(r1, r2) {
return r1.x < r2.x + r2.w &&
r1.x + r1.w > r2.x &&
r1.y < r2.y + r2.h &&
r1.y + r1.h > r2.y;
}
function update(dt) {
if (dt > 0.1) dt = 0.1; // Cap delta time to prevent physics glitches
if (GAME_STATE === 'PLAYING') {
// Apply Gravity
player.vy += appState.config.gravity * dt;
// Keep track of old position for collision resolution
const oldY = player.y;
const oldX = player.x;
// Move Player
player.y -= player.vy * dt; // Subtract because Y is up in logic
player.x += appState.config.speed * dt;
// Rotation
if (!player.isGrounded) {
player.rotation += 400 * dt * (Math.PI / 180);
}
// Floor Collision
player.isGrounded = false;
if (player.y <= 0) {
player.y = 0;
player.vy = 0;
player.isGrounded = true;
// Snap rotation to 90 degrees
player.rotation = Math.round(player.rotation / (Math.PI/2)) * (Math.PI/2);
}
// Obstacle Collision
const pRect = { x: player.x, y: player.y, w: player.w, h: player.h };
for (let obs of obstacles) {
// Only check nearby obstacles
if (obs.x > player.x + 400 || obs.x + obs.w < player.x - 100) continue;
if (AABB(pRect, obs)) {
if (obs.type === 'trigger') {
levelComplete();
continue;
}
if (obs.type === 'spike') {
die();
break;
}
if (obs.type === 'block') {
// Check if hit from top
if (oldY >= obs.y + obs.h - 5 && player.vy < 0) {
// Landed on top
player.y = obs.y + obs.h;
player.vy = 0;
player.isGrounded = true;
player.rotation = Math.round(player.rotation / (Math.PI/2)) * (Math.PI/2);
} else {
// Hit side or bottom
die();
break;
}
}
}
}
// Camera follow
cameraX = player.x - 200;
// Update Progress UI
const progress = Math.min(100, Math.max(0, (player.x / levelLength) * 100));
elements.progressBar.style.width = `${progress}%`;
// Check fall out of world (failsafe)
if (player.y < -100) die();
}
// Update Particles
for (let i = particles.length - 1; i >= 0; i--) {
let p = particles[i];
p.x += p.vx * dt;
p.y -= p.vy * dt;
p.vy += appState.config.gravity * dt; // gravity applies
p.life -= dt * 2;
if (p.life <= 0) particles.splice(i, 1);
}
}
function die() {
GAME_STATE = 'DEAD';
playSound('crash', 'crash');
IframeAPI.vibrate(100);
// Visuals
spawnParticles(player.x + player.w/2, player.y + player.h/2, getComputedStyle(document.documentElement).getPropertyValue('--player-color').trim());
// UI
const progress = Math.min(100, Math.max(0, Math.floor((player.x / levelLength) * 100)));
elements.deathStats.textContent = `PROGRESS: ${progress}%`;
showScreen(elements.screenDeath);
}
function levelComplete() {
GAME_STATE = 'WIN';
playSound('win', 'win');
elements.winStats.textContent = `ATTEMPTS: ${appState.stats.attempts}`;
if (appState.stats.level >= levels.length) {
showScreen(elements.screenEnd);
} else {
showScreen(elements.screenWin);
}
}
// Rendering helpers
function getWorldToScreen(wx, wy) {
const sx = (wx - cameraX) * scale;
// Invert Y: virtual 0 is GROUND_HEIGHT from bottom
const groundPixelY = canvas.height - GROUND_HEIGHT;
const sy = groundPixelY - (wy * scale);
return { x: sx, y: sy };
}
function draw(time) {
// Background
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg-color').trim();
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Ground Line
const groundY = canvas.height - GROUND_HEIGHT;
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, groundY);
ctx.lineTo(canvas.width, groundY);
ctx.stroke();
// Grid Lines (Parallax)
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
const bgOffsetX = -(cameraX * 0.5) % (TILE_SIZE * scale);
for (let x = bgOffsetX; x < canvas.width; x += TILE_SIZE * scale) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
// Glow settings
ctx.globalCompositeOperation = 'source-over'; // Changed from lighter for better performance, shadowBlur handles glow
const playerCol = getComputedStyle(document.documentElement).getPropertyValue('--player-color').trim();
const obsCol = getComputedStyle(document.documentElement).getPropertyValue('--obstacle-color').trim();
const spikeCol = getComputedStyle(document.documentElement).getPropertyValue('--spike-color').trim();
// Draw Obstacles
ctx.shadowBlur = 10;
for (let obs of obstacles) {
if (obs.x > cameraX + vWidth + 100 || obs.x + obs.w < cameraX - 100) continue; // Culling
if (obs.type === 'trigger') continue;
if (obs.type === 'block') {
const pos = getWorldToScreen(obs.x, obs.y + obs.h); // Top-left corner
const w = obs.w * scale;
const h = obs.h * scale;
ctx.shadowColor = obsCol;
ctx.strokeStyle = obsCol;
ctx.lineWidth = 3;
ctx.strokeRect(pos.x, pos.y, w, h);
ctx.fillStyle = 'rgba(0,0,0,0.8)';
ctx.fillRect(pos.x, pos.y, w, h);
}
else if (obs.type === 'spike') {
const pos = getWorldToScreen(obs.drawX, obs.y); // Bottom-left
const w = obs.drawW * scale;
const h = obs.h * scale; // Visual height
ctx.shadowColor = spikeCol;
ctx.fillStyle = spikeCol;
ctx.beginPath();
ctx.moveTo(pos.x, pos.y); // Bottom left
ctx.lineTo(pos.x + w/2, pos.y - h); // Top center
ctx.lineTo(pos.x + w, pos.y); // Bottom right
ctx.closePath();
ctx.fill();
}
}
// Draw Player
if (GAME_STATE !== 'DEAD') {
const pCenter = getWorldToScreen(player.x + player.w/2, player.y + player.h/2);
const sW = player.w * scale;
const sH = player.h * scale;
ctx.shadowColor = playerCol;
ctx.fillStyle = playerCol;
ctx.save();
ctx.translate(pCenter.x, pCenter.y);
ctx.rotate(player.rotation);
// Hollow square with thick border
ctx.lineWidth = 4;
ctx.strokeStyle = playerCol;
ctx.strokeRect(-sW/2, -sH/2, sW, sH);
// Small inner core
ctx.fillStyle = 'white';
ctx.shadowBlur = 0;
ctx.fillRect(-sW/4, -sH/4, sW/2, sH/2);
ctx.restore();
// Trail (simple)
if (GAME_STATE === 'PLAYING') {
ctx.fillStyle = playerCol;
ctx.globalAlpha = 0.3;
const trailPos = getWorldToScreen(player.x - 20 + player.w/2, player.y + player.h/2);
ctx.fillRect(trailPos.x - sW/2, trailPos.y - sH/2, sW, sH);
ctx.globalAlpha = 1.0;
}
}
// Draw Particles
ctx.shadowBlur = 5;
for (let p of particles) {
const pos = getWorldToScreen(p.x, p.y);
ctx.shadowColor = p.color;
ctx.fillStyle = p.color;
ctx.globalAlpha = p.life;
const size = 8 * scale * p.life;
ctx.fillRect(pos.x - size/2, pos.y - size/2, size, size);
}
ctx.globalAlpha = 1.0;
ctx.shadowBlur = 0;
}
function gameLoop(timestamp) {
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
update(dt || 0);
draw(timestamp);
animationId = requestAnimationFrame(gameLoop);
}
// ==========================================
// 5. UI & FLOW MANAGEMENT
// ==========================================
function showScreen(screenEl) {
// Hide all first
document.querySelectorAll('.screen').forEach(s => s.classList.remove('visible'));
screenEl.classList.add('visible');
}
function hideAllScreens() {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('visible'));
}
function updateUI() {
elements.attemptCounter.textContent = `LEVEL ${appState.stats.level} - ATTEMPT ${appState.stats.attempts}`;
}
// Button Listeners
elements.btnRetry.addEventListener('click', () => {
appState.stats.attempts++;
GAME_STATE = 'IDLE';
hideAllScreens();
resetPlayer();
updateUI();
elements.screenStart.classList.remove('hidden');
elements.screenStart.style.opacity = '1';
});
elements.btnNext.addEventListener('click', () => {
appState.stats.level++;
appState.stats.attempts = 1;
loadLevel(appState.stats.level - 1);
GAME_STATE = 'IDLE';
hideAllScreens();
elements.screenStart.classList.remove('hidden');
elements.screenStart.style.opacity = '1';
});
elements.btnRestart.addEventListener('click', () => {
appState.stats.level = 1;
appState.stats.attempts = 1;
loadLevel(0);
GAME_STATE = 'IDLE';
hideAllScreens();
elements.screenStart.classList.remove('hidden');
elements.screenStart.style.opacity = '1';
});
elements.btnShare.addEventListener('click', async (e) => {
const btn = e.currentTarget;
if (btn.dataset.loading === 'true') return;
btn.dataset.loading = 'true';
btn.disabled = true;
btn.textContent = '...';
try {
const element = document.getElementById('share-zone');
if (window.snapdom) {
const jpgImg = await window.snapdom.toJpg(element, { quality: 0.8, width: element.offsetWidth * 2, height: element.offsetHeight * 2 });
await IframeAPI.sendMessage('invoke_share', {
title: `I crashed at ${elements.deathStats.textContent.replace('PROGRESS: ', '')} on Level ${appState.stats.level} in Neon Dash!`,
coverImageBase64: jpgImg.src
}, false);
}
} catch (err) {
console.error(err);
} finally {
btn.dataset.loading = 'false';
btn.disabled = false;
btn.textContent = 'SHARE';
}
});
// Initialize
function initApp() {
applyAllEditableValues();
initBGM();
resize();
// Load initial state
appState.stats.level = 1;
appState.stats.attempts = 1;
loadLevel(0);
elements.screenStart.style.opacity = '1';
lastTime = performance.now();
requestAnimationFrame(gameLoop);
}
document.addEventListener('DOMContentLoaded', initApp);