mirror of
https://github.com/github/awesome-copilot.git
synced 2026-06-17 21:21:20 +00:00
chore: publish from staged
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// Agent Arcade — game bootstrap and scene registry.
|
||||
// Each mini-game is a Phaser Scene extending BaseScene.
|
||||
import { W, H, refreshDimensions } from './scenes/BaseScene.js';
|
||||
import { NinjaRunnerScene } from './scenes/NinjaRunner.js';
|
||||
import { GalaxyBlasterScene } from './scenes/GalaxyBlaster.js';
|
||||
import { CosmicRocksScene } from './scenes/CosmicRocks.js';
|
||||
import { AlienOnslaughtScene } from './scenes/AlienOnslaught.js';
|
||||
import { PlanetGuardianScene } from './scenes/PlanetGuardian.js';
|
||||
// Registry of available games
|
||||
const GAMES = [
|
||||
{ key: 'cosmic-rocks', scene: CosmicRocksScene, label: '☄️ Cosmic Rocks' },
|
||||
{ key: 'alien-onslaught', scene: AlienOnslaughtScene, label: '👾 Alien Onslaught' },
|
||||
{ key: 'galaxy-blaster', scene: GalaxyBlasterScene, label: '🚀 Galaxy Blaster' },
|
||||
{ key: 'ninja-runner', scene: NinjaRunnerScene, label: '🥷 Ninja Runner' },
|
||||
{ key: 'defender', scene: PlanetGuardianScene, label: '🛡️ Planet Guardian' },
|
||||
];
|
||||
let currentGameKey;
|
||||
try {
|
||||
// Migrate localStorage from old "galaxy-shooter" name
|
||||
const lastGame = localStorage.getItem('agentArcade_lastGame');
|
||||
if (lastGame === 'galaxy-shooter')
|
||||
localStorage.setItem('agentArcade_lastGame', 'galaxy-blaster');
|
||||
const oldHi = localStorage.getItem('agentArcade_hi_galaxy-shooter');
|
||||
if (oldHi) {
|
||||
localStorage.setItem('agentArcade_hi_galaxy-blaster', oldHi);
|
||||
localStorage.removeItem('agentArcade_hi_galaxy-shooter');
|
||||
}
|
||||
currentGameKey = localStorage.getItem('agentArcade_lastGame') || 'ninja-runner';
|
||||
}
|
||||
catch {
|
||||
currentGameKey = 'ninja-runner';
|
||||
}
|
||||
// Validate stored key exists in registry
|
||||
if (!GAMES.find(g => g.key === currentGameKey))
|
||||
currentGameKey = 'ninja-runner';
|
||||
// Create the Phaser game once the window is full-screen.
|
||||
// Tauri's Rust backend resizes the window after setup — we listen for the
|
||||
// `resize` event so we create the game at the correct dimensions.
|
||||
let game = null;
|
||||
function initGame() {
|
||||
refreshDimensions();
|
||||
game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
parent: 'game',
|
||||
width: W,
|
||||
height: H,
|
||||
transparent: true,
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
scene: GAMES.map(g => g.scene),
|
||||
physics: {
|
||||
default: 'arcade',
|
||||
arcade: { gravity: { y: 1800 }, debug: false },
|
||||
},
|
||||
render: { pixelArt: true, antialias: false, transparent: true },
|
||||
fps: { target: 60 },
|
||||
});
|
||||
// Expose game instance for Playwright testing (no production impact)
|
||||
window.__phaserGame = game;
|
||||
// Start the saved game (stop the default first scene if it's different)
|
||||
if (currentGameKey !== GAMES[0].key) {
|
||||
game.events.once('ready', () => {
|
||||
game.scene.stop(GAMES[0].key);
|
||||
game.scene.start(currentGameKey);
|
||||
});
|
||||
}
|
||||
setupGameSwitcher();
|
||||
}
|
||||
function setupGameSwitcher() {
|
||||
// Expose game switcher for the HUD dropdown
|
||||
window.__agentArcadeSwitchGame = (key) => {
|
||||
const entry = GAMES.find(g => g.key === key);
|
||||
if (!entry || key === currentGameKey)
|
||||
return;
|
||||
const wasPaused = document.getElementById('hud')?.classList.contains('paused') ?? false;
|
||||
// Set skip flag BEFORE anything else so the Rust-triggered onResume
|
||||
// won't fire scene resume callbacks on the new scene.
|
||||
if (wasPaused)
|
||||
window.__agentArcadeSkipResume = true;
|
||||
// Stop all audio globally (covers paused sounds too)
|
||||
if (game.sound)
|
||||
game.sound.stopAll();
|
||||
// Remove DOM overlays from the previous scene (game-over, wave banner, ready screen)
|
||||
for (const id of ['gameover-overlay', 'wave-banner', 'ready-overlay']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el)
|
||||
el.remove();
|
||||
}
|
||||
// Stop current scene, start new one
|
||||
game.scene.stop(currentGameKey);
|
||||
game.scene.start(key);
|
||||
currentGameKey = key;
|
||||
try {
|
||||
localStorage.setItem('agentArcade_lastGame', key);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
// Tell Rust we're unpaused so the window expands back to full-screen.
|
||||
const ab = window.agentArcade;
|
||||
if (wasPaused && ab && ab.setPaused)
|
||||
ab.setPaused(false);
|
||||
// The cursor was over the HUD to trigger this switch, so click-through should
|
||||
// stay OFF. Calling setClickThrough(false) also triggers set_focus() in Rust,
|
||||
// restoring OS keyboard focus after the native <select> interaction. The
|
||||
// cursor-tracking system will re-enable click-through naturally when the
|
||||
// cursor leaves the HUD area — calling setClickThrough(true) here would stall
|
||||
// the tracker (polling is stopped while isOverHud=true, and enabling
|
||||
// click-through kills mousemove events, leaving no way to detect HUD exit).
|
||||
if (ab && ab.setClickThrough)
|
||||
ab.setClickThrough(false);
|
||||
const sel = document.getElementById('game-select');
|
||||
if (sel)
|
||||
sel.blur();
|
||||
game.canvas.focus();
|
||||
};
|
||||
}
|
||||
// Listen for window resize events from Tauri.
|
||||
// On first resize that looks full-screen, create the game.
|
||||
// On later resizes (e.g. monitor change), resize the canvas.
|
||||
// Pause/resume shrinks/expands the window — we must NOT update game
|
||||
// dimensions when the window shrinks to HUD-only size, and must NOT
|
||||
// restart the scene when expanding back from a pause.
|
||||
let resizeDebounce = null;
|
||||
window.addEventListener('resize', () => {
|
||||
if (resizeDebounce)
|
||||
clearTimeout(resizeDebounce);
|
||||
resizeDebounce = window.setTimeout(() => {
|
||||
const newW = window.innerWidth;
|
||||
const newH = window.innerHeight;
|
||||
if (!game && newW > 800 && newH > 400) {
|
||||
// First time: window is now full-screen — create the game
|
||||
refreshDimensions();
|
||||
initGame();
|
||||
}
|
||||
else if (game && newH > 400) {
|
||||
// Full-screen resize (could be unpause expand or genuine resize).
|
||||
// Update dimensions and resize the canvas, but never restart the
|
||||
// scene — the resume system handles unpause, and a simple resize
|
||||
// is enough for monitor/display changes.
|
||||
refreshDimensions();
|
||||
game.scale.resize(W, H);
|
||||
}
|
||||
// If newH <= 400 (pause shrink to HUD), skip entirely —
|
||||
// keep W/H at full-screen values so the paused game state stays valid.
|
||||
}, 150);
|
||||
});
|
||||
// Populate game selector dropdown
|
||||
function populateGameSelector() {
|
||||
const sel = document.getElementById('game-select');
|
||||
if (!sel)
|
||||
return;
|
||||
sel.innerHTML = '';
|
||||
GAMES.forEach(g => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = g.key;
|
||||
opt.textContent = g.label;
|
||||
if (g.key === currentGameKey)
|
||||
opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.addEventListener('change', () => {
|
||||
window.__agentArcadeSwitchGame(sel.value);
|
||||
});
|
||||
}
|
||||
// Wait for DOM
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', populateGameSelector);
|
||||
}
|
||||
else {
|
||||
populateGameSelector();
|
||||
}
|
||||
// If the window is already full-screen (e.g. Playwright tests or fast Tauri
|
||||
// init), create the game immediately since no resize event will fire.
|
||||
setTimeout(() => {
|
||||
if (!game && window.innerWidth > 800 && window.innerHeight > 400) {
|
||||
refreshDimensions();
|
||||
initGame();
|
||||
}
|
||||
}, 200);
|
||||
//# sourceMappingURL=game.js.map
|
||||
@@ -0,0 +1,849 @@
|
||||
// ── HUD Logic ──
|
||||
// Extracted from index.html to reduce inline script bloat.
|
||||
// Handles: media session cleanup, Tauri bridge, mute/settings/help UI,
|
||||
// hotkey picker, transparency slider, drag handle, and canvas refocus.
|
||||
|
||||
// ── Media session cleanup ──
|
||||
// Prevent the app from taking over macOS media/volume controls.
|
||||
// Clear the media session so the OS doesn't treat game audio as "Now Playing".
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
navigator.mediaSession.playbackState = 'none';
|
||||
// Remove all media session action handlers
|
||||
['play','pause','stop','seekbackward','seekforward','previoustrack','nexttrack'].forEach(function(a) {
|
||||
try { navigator.mediaSession.setActionHandler(a, null); } catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tauri compatibility bridge ──
|
||||
// ── Tauri compatibility bridge ──
|
||||
// Creates window.agentArcade matching the Electron preload API so game
|
||||
// scenes work identically on both runtimes.
|
||||
(function() {
|
||||
var ti = window.__TAURI_INTERNALS__;
|
||||
if (!ti) return; // Not running in Tauri
|
||||
|
||||
var resumeCallbacks = [];
|
||||
window.agentArcade = {
|
||||
setClickThrough: function(enabled) {
|
||||
ti.invoke('set_click_through', { enabled: !!enabled });
|
||||
},
|
||||
setPaused: function(paused) {
|
||||
ti.invoke('set_paused', { paused: !!paused });
|
||||
},
|
||||
onResumeRequest: function(cb) {
|
||||
resumeCallbacks = [cb];
|
||||
},
|
||||
quitApp: function() {
|
||||
ti.invoke('quit_app');
|
||||
},
|
||||
hideApp: function() {
|
||||
ti.invoke('hide_app');
|
||||
}
|
||||
};
|
||||
|
||||
// Restores overlay/canvas state after a resume and sets the definitive click-through
|
||||
// value based on which interactive overlays are currently visible. Called inside a
|
||||
// 300ms setTimeout to let the Tauri window finish resizing before touching the DOM.
|
||||
// Both resume paths (Ctrl+Escape via Rust and Resume-button via HUD) use this so the
|
||||
// click-through logic lives in exactly one place.
|
||||
// Note: settingsOv / helpOv / updateBanner are var-hoisted from line ~119 in this IIFE
|
||||
// and are fully assigned before any resume event can fire.
|
||||
function restoreAfterResume() {
|
||||
var go = document.getElementById('gameover-overlay');
|
||||
if (go) go.setAttribute('style',
|
||||
'position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:9999;' +
|
||||
'display:flex;align-items:center;justify-content:center;' +
|
||||
'background:rgba(0,0,0,0.75);pointer-events:auto;');
|
||||
var wb = document.getElementById('wave-banner');
|
||||
if (wb) wb.style.display = '';
|
||||
var c = document.querySelector('canvas');
|
||||
if (c) c.focus();
|
||||
// Determine whether any interactive overlay needs click-through OFF.
|
||||
var hasOverlay = !!go ||
|
||||
!!(settingsOv && settingsOv.classList.contains('show')) ||
|
||||
!!(helpOv && helpOv.classList.contains('show')) ||
|
||||
!!(updateBanner && updateBanner.classList.contains('show'));
|
||||
ti.invoke('set_click_through', { enabled: !hasOverlay });
|
||||
}
|
||||
|
||||
// Shared logic for both resume paths: notify game, clear paused CSS, then restore
|
||||
// overlays + click-through after the window resize settles.
|
||||
function onResume() {
|
||||
// Skip scene resume callbacks if a game switch is in progress
|
||||
if (!window.__agentArcadeSkipResume) {
|
||||
resumeCallbacks.forEach(function(cb) { try { cb(); } catch(e) {} });
|
||||
}
|
||||
window.__agentArcadeSkipResume = false;
|
||||
var hud = document.getElementById('hud');
|
||||
if (hud) hud.classList.remove('paused');
|
||||
document.body.classList.remove('paused');
|
||||
setTimeout(restoreAfterResume, 300);
|
||||
}
|
||||
|
||||
// Called from Rust via win.eval() when the global Ctrl+Escape shortcut fires.
|
||||
window.__agentArcadeResumeFromRust = onResume;
|
||||
|
||||
// Called from Rust when the Resume HUD button triggers set_paused(false).
|
||||
window.__agentArcadeOnResume = onResume;
|
||||
|
||||
// Called from Rust when game should enter paused state
|
||||
// Uses __agentArcadePauseScene (scene-only, no Rust callback) to avoid feedback loops.
|
||||
window.__agentArcadeOnPause = function() {
|
||||
if (window.__agentArcadePauseScene) {
|
||||
try { window.__agentArcadePauseScene(true); } catch(e) {}
|
||||
}
|
||||
var hud = document.getElementById('hud');
|
||||
if (hud) hud.classList.add('paused');
|
||||
document.body.classList.add('paused');
|
||||
var go = document.getElementById('gameover-overlay');
|
||||
if (go) go.style.display = 'none';
|
||||
var wb = document.getElementById('wave-banner');
|
||||
if (wb) wb.style.display = 'none';
|
||||
var ho = document.getElementById('help-overlay');
|
||||
if (ho) ho.classList.remove('show');
|
||||
var ro = document.getElementById('ready-overlay');
|
||||
if (ro) ro.remove();
|
||||
};
|
||||
|
||||
// Hybrid cursor tracking: event-based when over HUD, IPC polling otherwise.
|
||||
// When click-through is OFF (cursor over HUD), mousemove events fire normally
|
||||
// so we detect exit via events. When click-through is ON, events don't fire
|
||||
// so we poll at 250ms to detect HUD entry.
|
||||
var isOverHud = false;
|
||||
var pollTimer = null;
|
||||
var hudEl = document.getElementById('hud');
|
||||
var helpOv = document.getElementById('help-overlay');
|
||||
var settingsOv = document.getElementById('settings-overlay');
|
||||
var updateBanner = document.getElementById('update-banner');
|
||||
|
||||
function isOverHudArea(x, y) {
|
||||
if (!hudEl) return false;
|
||||
var rect = hudEl.getBoundingClientRect();
|
||||
var over = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||
if (helpOv && helpOv.classList.contains('show')) over = true;
|
||||
if (settingsOv && settingsOv.classList.contains('show')) over = true;
|
||||
if (updateBanner && updateBanner.classList.contains('show')) {
|
||||
var br = updateBanner.getBoundingClientRect();
|
||||
if (x >= br.left && x <= br.right && y >= br.top && y <= br.bottom) over = true;
|
||||
}
|
||||
return over;
|
||||
}
|
||||
|
||||
function onCursorOverHud() {
|
||||
if (isOverHud) return;
|
||||
isOverHud = true;
|
||||
ti.invoke('set_click_through', { enabled: false });
|
||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||
document.addEventListener('mousemove', onDocMouseMove);
|
||||
}
|
||||
|
||||
function onCursorLeftHud() {
|
||||
if (!isOverHud) return;
|
||||
isOverHud = false;
|
||||
// When paused, Rust manages click-through (always OFF so HUD bar is clickable).
|
||||
// Only enable click-through in running state to avoid race conditions.
|
||||
if (!document.body.classList.contains('paused')) {
|
||||
ti.invoke('set_click_through', { enabled: true });
|
||||
}
|
||||
document.removeEventListener('mousemove', onDocMouseMove);
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
function onDocMouseMove(e) {
|
||||
if (!isOverHudArea(e.clientX, e.clientY)) onCursorLeftHud();
|
||||
}
|
||||
|
||||
function pollCursorPosition() {
|
||||
var so = settingsOv && settingsOv.classList.contains('show');
|
||||
if (document.hidden || (document.body.classList.contains('paused') && !so)) {
|
||||
schedulePoll();
|
||||
return;
|
||||
}
|
||||
ti.invoke('get_cursor_in_window').then(function(pos) {
|
||||
if (pos && isOverHudArea(pos[0], pos[1])) {
|
||||
onCursorOverHud();
|
||||
} else {
|
||||
schedulePoll();
|
||||
}
|
||||
}).catch(function() { schedulePoll(); });
|
||||
}
|
||||
|
||||
function schedulePoll() {
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
pollTimer = setTimeout(pollCursorPosition, 250);
|
||||
}
|
||||
|
||||
schedulePoll();
|
||||
|
||||
// Reclaim OS keyboard focus the instant the window loses it during active
|
||||
// gameplay. Click-through lets clicks pass to apps below, which causes macOS
|
||||
// to hand keyboard focus to that app. The blur event fires immediately when
|
||||
// that happens — we invoke request_focus to take it back without touching
|
||||
// click-through state. Does nothing when paused or hidden so the user can
|
||||
// freely switch apps after pressing Escape.
|
||||
window.addEventListener('blur', function() {
|
||||
if (!document.body.classList.contains('paused') && !document.hidden) {
|
||||
ti.invoke('request_focus');
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// ── HUD controls ──
|
||||
(function () {
|
||||
var hud = document.getElementById('hud');
|
||||
|
||||
function isPaused() { return hud.classList.contains('paused'); }
|
||||
function setPaused(p) {
|
||||
hud.classList.toggle('paused', p);
|
||||
document.body.classList.toggle('paused', p);
|
||||
if (window.__agentArcadePause) {
|
||||
try { window.__agentArcadePause(p); } catch (e) {}
|
||||
} else if (window.agentArcade && window.agentArcade.setClickThrough) {
|
||||
window.agentArcade.setClickThrough(p);
|
||||
if (window.agentArcade.setPaused) window.agentArcade.setPaused(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Help button — show controls overlay
|
||||
var helpBtn = document.getElementById('help-btn');
|
||||
var helpOverlay = document.getElementById('help-overlay');
|
||||
var helpClose = document.getElementById('help-close');
|
||||
|
||||
var wasGamePausedBeforeHelp = false;
|
||||
|
||||
function showHelp() {
|
||||
wasGamePausedBeforeHelp = isPaused();
|
||||
if (!wasGamePausedBeforeHelp) {
|
||||
var game = window.__phaserGame;
|
||||
if (game && game.scene) {
|
||||
game.scene.getScenes(true).forEach(function(s) {
|
||||
if (s.scene && s.scene.pause) s.scene.pause();
|
||||
if (s.sound && s.sound.pauseAll) s.sound.pauseAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
helpOverlay.classList.add('show');
|
||||
}
|
||||
function hideHelp() {
|
||||
helpOverlay.classList.remove('show');
|
||||
if (!wasGamePausedBeforeHelp) {
|
||||
var game = window.__phaserGame;
|
||||
if (game && game.scene) {
|
||||
game.scene.getScenes(false).forEach(function(s) {
|
||||
if (s.scene && s.scene.resume) s.scene.resume();
|
||||
if (s.sound && s.sound.resumeAll) s.sound.resumeAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
helpBtn.addEventListener('click', showHelp);
|
||||
helpClose.addEventListener('click', hideHelp);
|
||||
helpOverlay.addEventListener('click', function (e) {
|
||||
if (e.target === helpOverlay) hideHelp();
|
||||
});
|
||||
|
||||
// Close button — quit the app
|
||||
var closeBtn = document.getElementById('close-btn');
|
||||
closeBtn.addEventListener('click', function () {
|
||||
if (window.agentArcade && window.agentArcade.quitApp) {
|
||||
window.agentArcade.quitApp();
|
||||
}
|
||||
});
|
||||
|
||||
// Minimize button — hide the app (show again via Ctrl+Alt+M or tray)
|
||||
var minimizeBtn = document.getElementById('minimize-btn');
|
||||
minimizeBtn.addEventListener('click', function () {
|
||||
if (window.agentArcade && window.agentArcade.hideApp) {
|
||||
window.agentArcade.hideApp();
|
||||
}
|
||||
});
|
||||
|
||||
// Resume button — unpause the game (shown only when paused)
|
||||
var resumeBtn = document.getElementById('resume-btn');
|
||||
resumeBtn.addEventListener('click', function () {
|
||||
if (window.agentArcade && window.agentArcade.setPaused) {
|
||||
window.agentArcade.setPaused(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Mute button — toggle all game audio
|
||||
// NOTE: Phaser's game.sound.mute setter uses setValueAtTime(val, 0) which
|
||||
// silently fails once AudioContext.currentTime > 0. We bypass it by directly
|
||||
// setting masterMuteNode.gain.value and tracking state ourselves.
|
||||
var muteBtn = document.getElementById('mute-btn');
|
||||
var audioToggle = document.getElementById('audio-toggle');
|
||||
var isMuted = false;
|
||||
|
||||
function setMuted(muted) {
|
||||
var game = window.__phaserGame;
|
||||
if (!game || !game.sound) return;
|
||||
isMuted = !!muted;
|
||||
// Directly set gain value — bypasses Phaser's broken setValueAtTime(val,0)
|
||||
if (game.sound.masterMuteNode && game.sound.masterMuteNode.gain) {
|
||||
game.sound.masterMuteNode.gain.value = isMuted ? 0 : 1;
|
||||
}
|
||||
muteBtn.textContent = isMuted ? '🔇' : '🔊';
|
||||
muteBtn.classList.toggle('muted', isMuted);
|
||||
if (audioToggle) audioToggle.checked = !isMuted;
|
||||
try { localStorage.setItem('agentArcade_muted', isMuted ? '1' : '0'); } catch(e) {}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
setMuted(!isMuted);
|
||||
}
|
||||
|
||||
muteBtn.addEventListener('click', toggleMute);
|
||||
// Restore mute state from localStorage
|
||||
setTimeout(function() {
|
||||
try {
|
||||
if (localStorage.getItem('agentArcade_muted') === '1') {
|
||||
setMuted(true);
|
||||
}
|
||||
} catch(e) {}
|
||||
}, 500);
|
||||
// M key shortcut
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.code === 'KeyM' && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
// Don't toggle if typing in an input or game is paused
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||||
toggleMute();
|
||||
}
|
||||
});
|
||||
|
||||
// Settings button — show settings overlay
|
||||
var settingsBtn = document.getElementById('settings-btn');
|
||||
var settingsOverlay = document.getElementById('settings-overlay');
|
||||
var settingsClose = document.getElementById('settings-close');
|
||||
var settingsVersion = document.getElementById('settings-version');
|
||||
var bgSlider = document.getElementById('bg-transparency');
|
||||
var bgValue = document.getElementById('bg-transparency-value');
|
||||
|
||||
// Populate version from Rust
|
||||
if (settingsVersion) {
|
||||
var tauriApi = window.__TAURI_INTERNALS__;
|
||||
if (tauriApi) {
|
||||
tauriApi.invoke('get_app_version').then(function(v) {
|
||||
settingsVersion.textContent = 'Version ' + v;
|
||||
}).catch(function() {});
|
||||
}
|
||||
}
|
||||
|
||||
// Default background transparency = 25 (subtle dark tint, desktop shows through)
|
||||
var DEFAULT_BG_TRANSPARENCY = 25;
|
||||
var currentBgTransparency = DEFAULT_BG_TRANSPARENCY;
|
||||
|
||||
// Restore saved settings
|
||||
try {
|
||||
// One-time migration: reset old 100% default to new 25% default
|
||||
if (!localStorage.getItem('agentArcade_bgDefault_v2')) {
|
||||
localStorage.removeItem('agentArcade_bgTransparency');
|
||||
localStorage.setItem('agentArcade_bgDefault_v2', '1');
|
||||
}
|
||||
var savedBg = localStorage.getItem('agentArcade_bgTransparency');
|
||||
if (savedBg !== null) {
|
||||
currentBgTransparency = parseInt(savedBg, 10) || DEFAULT_BG_TRANSPARENCY;
|
||||
} else {
|
||||
// New user — persist the default so it's always in localStorage
|
||||
localStorage.setItem('agentArcade_bgTransparency', String(DEFAULT_BG_TRANSPARENCY));
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
function applyBgTransparency(val) {
|
||||
currentBgTransparency = val;
|
||||
bgSlider.value = val;
|
||||
bgValue.textContent = val + '%';
|
||||
// Update ALL Phaser scenes' backdrop alpha (including paused/sleeping)
|
||||
var game = window.__phaserGame;
|
||||
if (game) {
|
||||
var allScenes = game.scene.scenes;
|
||||
for (var i = 0; i < allScenes.length; i++) {
|
||||
if (typeof allScenes[i].setBackdropAlpha === 'function') {
|
||||
allScenes[i].setBackdropAlpha(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
try { localStorage.setItem('agentArcade_bgTransparency', String(val)); } catch(e) {}
|
||||
}
|
||||
|
||||
bgSlider.addEventListener('input', function() {
|
||||
applyBgTransparency(parseInt(bgSlider.value, 10));
|
||||
});
|
||||
|
||||
// Apply saved transparency once game canvas is ready (retry until scene exists)
|
||||
(function retryApply(attempts) {
|
||||
var game = window.__phaserGame;
|
||||
var hasScene = game && game.scene && game.scene.scenes.length > 0 &&
|
||||
typeof game.scene.scenes[0].setBackdropAlpha === 'function';
|
||||
if (hasScene || attempts <= 0) {
|
||||
applyBgTransparency(currentBgTransparency);
|
||||
} else {
|
||||
setTimeout(function() { retryApply(attempts - 1); }, 300);
|
||||
}
|
||||
})(10);
|
||||
|
||||
// Audio toggle syncs with mute state
|
||||
function syncAudioToggle() {
|
||||
audioToggle.checked = !isMuted;
|
||||
}
|
||||
|
||||
audioToggle.addEventListener('change', function() {
|
||||
setMuted(!audioToggle.checked);
|
||||
});
|
||||
|
||||
// ── Hotkey picker ─────────────────────────────────────────
|
||||
var hotkeyDisplay = document.getElementById('hotkey-display');
|
||||
var hotkeyRecordBtn = document.getElementById('hotkey-record-btn');
|
||||
var hotkeyResetBtn = document.getElementById('hotkey-reset-btn');
|
||||
var hotkeyStatus = document.getElementById('hotkey-status');
|
||||
var DEFAULT_HOTKEY = 'Ctrl+Alt+M';
|
||||
var currentHotkey = DEFAULT_HOTKEY;
|
||||
try {
|
||||
var saved = localStorage.getItem('agentArcade_hotkey');
|
||||
if (saved) currentHotkey = saved;
|
||||
} catch(e) {}
|
||||
hotkeyDisplay.value = currentHotkey;
|
||||
|
||||
// ── Pause hotkey picker ────────────────────────────────────
|
||||
var pauseDisplay = document.getElementById('pause-hotkey-display');
|
||||
var pauseRecordBtn = document.getElementById('pause-hotkey-record-btn');
|
||||
var pauseResetBtn = document.getElementById('pause-hotkey-reset-btn');
|
||||
var pauseStatus = document.getElementById('pause-hotkey-status');
|
||||
var DEFAULT_PAUSE_KEY = 'Escape';
|
||||
var currentPauseKey = DEFAULT_PAUSE_KEY;
|
||||
try {
|
||||
var savedPause = localStorage.getItem('agentArcade_pauseKey');
|
||||
if (savedPause) currentPauseKey = savedPause;
|
||||
} catch(e) {}
|
||||
pauseDisplay.value = currentPauseKey;
|
||||
|
||||
// ── Unpause hotkey picker ──────────────────────────────────
|
||||
var unpauseDisplay = document.getElementById('unpause-hotkey-display');
|
||||
var unpauseRecordBtn = document.getElementById('unpause-hotkey-record-btn');
|
||||
var unpauseResetBtn = document.getElementById('unpause-hotkey-reset-btn');
|
||||
var unpauseStatus = document.getElementById('unpause-hotkey-status');
|
||||
var DEFAULT_UNPAUSE_KEY = 'Ctrl+Escape';
|
||||
var currentUnpauseKey = DEFAULT_UNPAUSE_KEY;
|
||||
try {
|
||||
var savedUnpause = localStorage.getItem('agentArcade_unpauseKey');
|
||||
if (savedUnpause) currentUnpauseKey = savedUnpause;
|
||||
} catch(e) {}
|
||||
unpauseDisplay.value = currentUnpauseKey;
|
||||
|
||||
// Shared recording state: 'none', 'toggle', 'pause', or 'unpause'
|
||||
var recordingTarget = 'none';
|
||||
|
||||
function getRecordingUI(target) {
|
||||
if (target === 'toggle') return { display: hotkeyDisplay, btn: hotkeyRecordBtn, status: hotkeyStatus };
|
||||
if (target === 'pause') return { display: pauseDisplay, btn: pauseRecordBtn, status: pauseStatus };
|
||||
return { display: unpauseDisplay, btn: unpauseRecordBtn, status: unpauseStatus };
|
||||
}
|
||||
|
||||
function getCurrentValue(target) {
|
||||
if (target === 'toggle') return currentHotkey;
|
||||
if (target === 'pause') return currentPauseKey;
|
||||
return currentUnpauseKey;
|
||||
}
|
||||
|
||||
function startRecording(target) {
|
||||
// Cancel any other active recording
|
||||
if (recordingTarget !== 'none' && recordingTarget !== target) stopRecording(recordingTarget);
|
||||
recordingTarget = target;
|
||||
var ui = getRecordingUI(target);
|
||||
ui.display.value = target === 'toggle' ? 'Press keys...' : 'Press key...';
|
||||
ui.display.style.borderColor = '#f0c020';
|
||||
ui.btn.textContent = 'Cancel';
|
||||
ui.status.textContent = '';
|
||||
}
|
||||
|
||||
function stopRecording(target) {
|
||||
recordingTarget = 'none';
|
||||
var ui = getRecordingUI(target);
|
||||
ui.display.value = getCurrentValue(target);
|
||||
ui.display.style.borderColor = '#555';
|
||||
ui.btn.textContent = 'Change';
|
||||
}
|
||||
|
||||
hotkeyRecordBtn.addEventListener('click', function() {
|
||||
if (recordingTarget === 'toggle') stopRecording('toggle');
|
||||
else startRecording('toggle');
|
||||
});
|
||||
hotkeyDisplay.addEventListener('click', function() {
|
||||
if (recordingTarget !== 'toggle') startRecording('toggle');
|
||||
});
|
||||
pauseRecordBtn.addEventListener('click', function() {
|
||||
if (recordingTarget === 'pause') stopRecording('pause');
|
||||
else startRecording('pause');
|
||||
});
|
||||
pauseDisplay.addEventListener('click', function() {
|
||||
if (recordingTarget !== 'pause') startRecording('pause');
|
||||
});
|
||||
unpauseRecordBtn.addEventListener('click', function() {
|
||||
if (recordingTarget === 'unpause') stopRecording('unpause');
|
||||
else startRecording('unpause');
|
||||
});
|
||||
unpauseDisplay.addEventListener('click', function() {
|
||||
if (recordingTarget !== 'unpause') startRecording('unpause');
|
||||
});
|
||||
|
||||
// Map KeyboardEvent to combo string (requireModifier=true for toggle, false for pause)
|
||||
function keyEventToCombo(e, requireModifier) {
|
||||
var parts = [];
|
||||
if (e.ctrlKey) parts.push('Ctrl');
|
||||
if (e.altKey) parts.push('Alt');
|
||||
if (e.shiftKey) parts.push('Shift');
|
||||
if (e.metaKey) parts.push('Super');
|
||||
|
||||
var key = e.key;
|
||||
if (['Control', 'Alt', 'Shift', 'Meta'].indexOf(key) >= 0) return null;
|
||||
|
||||
// Normalize the key name
|
||||
if (key.length === 1) {
|
||||
parts.push(key.toUpperCase());
|
||||
} else if (key.match(/^F\d+$/)) {
|
||||
parts.push(key);
|
||||
} else if (key === 'Escape') {
|
||||
parts.push('Escape');
|
||||
} else if (key === ' ') {
|
||||
parts.push('Space');
|
||||
} else if (key === 'Tab') {
|
||||
parts.push('Tab');
|
||||
} else if (key === 'Enter') {
|
||||
parts.push('Enter');
|
||||
} else if (key === 'Backspace') {
|
||||
parts.push('Backspace');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (requireModifier && parts.length < 2) return null;
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
// Save a hotkey via Tauri invoke and update UI
|
||||
function saveHotkey(type, combo, displayEl, statusEl, storageKey, tauriCmd, currentRef) {
|
||||
displayEl.value = combo;
|
||||
displayEl.style.borderColor = '#555';
|
||||
var ui = getRecordingUI(type);
|
||||
ui.btn.textContent = 'Change';
|
||||
statusEl.textContent = 'Saving...';
|
||||
statusEl.style.color = '#888';
|
||||
recordingTarget = 'none';
|
||||
|
||||
var ti = window.__TAURI_INTERNALS__;
|
||||
if (ti) {
|
||||
ti.invoke(tauriCmd, { combo: combo }).then(function() {
|
||||
currentRef.value = combo;
|
||||
displayEl.value = combo;
|
||||
statusEl.textContent = '✓ Saved';
|
||||
statusEl.style.color = '#4f4';
|
||||
try { localStorage.setItem(storageKey, combo); } catch(ex) {}
|
||||
setTimeout(function() { statusEl.textContent = ''; }, 2000);
|
||||
}).catch(function() {
|
||||
displayEl.value = currentRef.value;
|
||||
statusEl.textContent = 'Taken!';
|
||||
statusEl.style.color = '#f44';
|
||||
setTimeout(function() { statusEl.textContent = ''; }, 3000);
|
||||
});
|
||||
} else {
|
||||
currentRef.value = combo;
|
||||
try { localStorage.setItem(storageKey, combo); } catch(ex) {}
|
||||
statusEl.textContent = '✓ Saved';
|
||||
statusEl.style.color = '#4f4';
|
||||
setTimeout(function() { statusEl.textContent = ''; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Mutable refs so saveHotkey can update the outer variables
|
||||
var toggleRef = { value: currentHotkey };
|
||||
var pauseRef = { value: currentPauseKey };
|
||||
var unpauseRef = { value: currentUnpauseKey };
|
||||
|
||||
// Keep help dialog hotkeys in sync with current settings
|
||||
function syncHelpHotkeys() {
|
||||
var toggleEl = document.getElementById('help-toggle-keys');
|
||||
var pauseEl = document.getElementById('help-pause-keys');
|
||||
var unpauseEl = document.getElementById('help-unpause-keys');
|
||||
if (toggleEl) { toggleEl.textContent = ''; toggleEl.appendChild(comboToKbds(currentHotkey)); }
|
||||
if (pauseEl) { pauseEl.textContent = ''; pauseEl.appendChild(comboToKbds(currentPauseKey)); }
|
||||
if (unpauseEl) { unpauseEl.textContent = ''; unpauseEl.appendChild(comboToKbds(currentUnpauseKey)); }
|
||||
}
|
||||
// Convert a combo string like "Ctrl+Alt+M" into <kbd> elements
|
||||
function comboToKbds(combo) {
|
||||
var map = { Ctrl: '⌃', Alt: '⌥', Shift: '⇧', Super: '⌘', Escape: 'Esc' };
|
||||
var frag = document.createDocumentFragment();
|
||||
combo.split('+').forEach(function(part) {
|
||||
var kbd = document.createElement('kbd');
|
||||
kbd.textContent = map[part] || part;
|
||||
frag.appendChild(kbd);
|
||||
});
|
||||
return frag;
|
||||
}
|
||||
syncHelpHotkeys();
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (recordingTarget === 'none') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Toggle requires modifier; pause and unpause allow any combo
|
||||
var requireMod = recordingTarget === 'toggle';
|
||||
var combo = keyEventToCombo(e, requireMod);
|
||||
|
||||
if (!combo) {
|
||||
var ui = getRecordingUI(recordingTarget);
|
||||
ui.display.value = requireMod ? 'Need modifier + key' : 'Press a valid key';
|
||||
return;
|
||||
}
|
||||
|
||||
if (recordingTarget === 'toggle') {
|
||||
saveHotkey('toggle', combo, hotkeyDisplay, hotkeyStatus, 'agentArcade_hotkey', 'set_toggle_shortcut', toggleRef);
|
||||
currentHotkey = combo;
|
||||
} else if (recordingTarget === 'pause') {
|
||||
saveHotkey('pause', combo, pauseDisplay, pauseStatus, 'agentArcade_pauseKey', 'set_pause_shortcut', pauseRef);
|
||||
currentPauseKey = combo;
|
||||
} else if (recordingTarget === 'unpause') {
|
||||
saveHotkey('unpause', combo, unpauseDisplay, unpauseStatus, 'agentArcade_unpauseKey', 'set_unpause_shortcut', unpauseRef);
|
||||
currentUnpauseKey = combo;
|
||||
}
|
||||
syncHelpHotkeys();
|
||||
}, true);
|
||||
|
||||
// Reset buttons
|
||||
hotkeyResetBtn.addEventListener('click', function() {
|
||||
if (recordingTarget === 'toggle') stopRecording('toggle');
|
||||
saveHotkey('toggle', DEFAULT_HOTKEY, hotkeyDisplay, hotkeyStatus, 'agentArcade_hotkey', 'set_toggle_shortcut', toggleRef);
|
||||
currentHotkey = DEFAULT_HOTKEY;
|
||||
syncHelpHotkeys();
|
||||
});
|
||||
pauseResetBtn.addEventListener('click', function() {
|
||||
if (recordingTarget === 'pause') stopRecording('pause');
|
||||
saveHotkey('pause', DEFAULT_PAUSE_KEY, pauseDisplay, pauseStatus, 'agentArcade_pauseKey', 'set_pause_shortcut', pauseRef);
|
||||
currentPauseKey = DEFAULT_PAUSE_KEY;
|
||||
syncHelpHotkeys();
|
||||
});
|
||||
unpauseResetBtn.addEventListener('click', function() {
|
||||
if (recordingTarget === 'unpause') stopRecording('unpause');
|
||||
saveHotkey('unpause', DEFAULT_UNPAUSE_KEY, unpauseDisplay, unpauseStatus, 'agentArcade_unpauseKey', 'set_unpause_shortcut', unpauseRef);
|
||||
currentUnpauseKey = DEFAULT_UNPAUSE_KEY;
|
||||
syncHelpHotkeys();
|
||||
});
|
||||
|
||||
// On startup, apply saved hotkeys to Rust
|
||||
setTimeout(function() {
|
||||
var ti = window.__TAURI_INTERNALS__;
|
||||
if (ti) {
|
||||
if (currentHotkey !== DEFAULT_HOTKEY) {
|
||||
ti.invoke('set_toggle_shortcut', { combo: currentHotkey }).catch(function() {
|
||||
hotkeyStatus.textContent = 'Hotkey unavailable';
|
||||
hotkeyStatus.style.color = '#f44';
|
||||
});
|
||||
}
|
||||
if (currentPauseKey !== DEFAULT_PAUSE_KEY) {
|
||||
ti.invoke('set_pause_shortcut', { combo: currentPauseKey }).catch(function() {
|
||||
pauseStatus.textContent = 'Pause key unavailable';
|
||||
pauseStatus.style.color = '#f44';
|
||||
});
|
||||
}
|
||||
if (currentUnpauseKey !== DEFAULT_UNPAUSE_KEY) {
|
||||
ti.invoke('set_unpause_shortcut', { combo: currentUnpauseKey }).catch(function() {
|
||||
unpauseStatus.textContent = 'Resume key unavailable';
|
||||
unpauseStatus.style.color = '#f44';
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 800);
|
||||
|
||||
// ── Clear scores ────────────────────────────────────────────
|
||||
var clearScoresBtn = document.getElementById('clear-scores-btn');
|
||||
var clearScoresGame = document.getElementById('clear-scores-game');
|
||||
var clearScoresStatus = document.getElementById('clear-scores-status');
|
||||
clearScoresBtn.addEventListener('click', function() {
|
||||
var gameKey = clearScoresGame.value;
|
||||
try {
|
||||
localStorage.removeItem('agentArcade_hi_' + gameKey);
|
||||
localStorage.removeItem('agentArcade_board_' + gameKey);
|
||||
} catch(e) {}
|
||||
clearScoresStatus.textContent = '✓ Cleared';
|
||||
clearScoresStatus.style.color = '#4f4';
|
||||
setTimeout(function() { clearScoresStatus.textContent = ''; }, 2000);
|
||||
|
||||
// Reset HUD and scene high score for the cleared game
|
||||
var game = window.__phaserGame;
|
||||
if (game) {
|
||||
var scene = game.scene.getScene(gameKey);
|
||||
if (scene && typeof scene.highScore !== 'undefined') {
|
||||
scene.highScore = 0;
|
||||
}
|
||||
}
|
||||
// If cleared game is the current game, update HUD immediately
|
||||
var sel = document.getElementById('game-select');
|
||||
var currentKey = sel ? sel.value : '';
|
||||
if (gameKey === currentKey) {
|
||||
var hiEl = document.getElementById('hi-value');
|
||||
if (hiEl) hiEl.textContent = '0';
|
||||
}
|
||||
});
|
||||
|
||||
function expandWindowForOverlay(callback) {
|
||||
var ti = window.__TAURI_INTERNALS__;
|
||||
if (!ti) { if (callback) callback(); return; }
|
||||
ti.invoke('plugin:window|primary_monitor', { label: 'main' }).then(function(mon) {
|
||||
if (!mon) { if (callback) callback(); return; }
|
||||
var size = mon.size;
|
||||
var pos = mon.position;
|
||||
var scale = mon.scaleFactor || 1;
|
||||
var bottomTrim = Math.round(5 * scale);
|
||||
ti.invoke('plugin:window|set_position', { label: 'main', value: { Physical: { x: pos.x, y: pos.y } } });
|
||||
ti.invoke('plugin:window|set_size', { label: 'main', value: { Physical: { width: size.width, height: size.height - bottomTrim } } });
|
||||
ti.invoke('set_click_through', { enabled: false });
|
||||
setTimeout(function() { if (callback) callback(); }, 100);
|
||||
}).catch(function() { if (callback) callback(); });
|
||||
}
|
||||
|
||||
function shrinkWindowForPause() {
|
||||
// Re-trigger paused state so Rust shrinks the window.
|
||||
// setPaused(true) already sets click-through OFF (HUD stays clickable).
|
||||
var ab = window.agentArcade;
|
||||
if (ab && ab.setPaused) ab.setPaused(true);
|
||||
}
|
||||
|
||||
var wasGamePausedBeforeSettings = false;
|
||||
|
||||
function showSettings() {
|
||||
syncAudioToggle();
|
||||
bgSlider.value = currentBgTransparency;
|
||||
bgValue.textContent = currentBgTransparency + '%';
|
||||
// Pause the Phaser scene if running (but don't trigger full Tauri pause)
|
||||
wasGamePausedBeforeSettings = isPaused();
|
||||
if (!wasGamePausedBeforeSettings) {
|
||||
var game = window.__phaserGame;
|
||||
if (game && game.scene) {
|
||||
game.scene.getScenes(true).forEach(function(s) {
|
||||
if (s.scene && s.scene.pause) s.scene.pause();
|
||||
if (s.sound && s.sound.pauseAll) s.sound.pauseAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (wasGamePausedBeforeSettings) {
|
||||
// Window is shrunk — expand it first, then show overlay
|
||||
expandWindowForOverlay(function() {
|
||||
settingsOverlay.classList.add('show');
|
||||
});
|
||||
} else {
|
||||
settingsOverlay.classList.add('show');
|
||||
}
|
||||
}
|
||||
function hideSettings() {
|
||||
settingsOverlay.classList.remove('show');
|
||||
// Re-apply transparency to ensure it sticks after resume
|
||||
applyBgTransparency(currentBgTransparency);
|
||||
if (wasGamePausedBeforeSettings) {
|
||||
// Shrink the window back to the paused HUD bar
|
||||
shrinkWindowForPause();
|
||||
} else {
|
||||
// Resume the Phaser scene if it wasn't paused before we opened settings
|
||||
var game = window.__phaserGame;
|
||||
if (game && game.scene) {
|
||||
game.scene.getScenes(false).forEach(function(s) {
|
||||
if (s.scene && s.scene.resume) s.scene.resume();
|
||||
if (s.sound && s.sound.resumeAll) s.sound.resumeAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
settingsBtn.addEventListener('click', showSettings);
|
||||
settingsClose.addEventListener('click', hideSettings);
|
||||
settingsOverlay.addEventListener('click', function(e) {
|
||||
if (e.target === settingsOverlay) hideSettings();
|
||||
});
|
||||
// Escape closes settings
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.code === 'Escape' && settingsOverlay.classList.contains('show')) {
|
||||
e.stopPropagation();
|
||||
hideSettings();
|
||||
}
|
||||
});
|
||||
|
||||
// Drag handle moves the paused HUD window
|
||||
var dragHandle = document.getElementById('drag-handle');
|
||||
dragHandle.addEventListener('mousedown', function (e) {
|
||||
if (!isPaused()) return;
|
||||
if (e.buttons !== 1) return;
|
||||
var ti = window.__TAURI_INTERNALS__;
|
||||
if (ti) {
|
||||
ti.invoke('plugin:window|start_dragging', { label: 'main' });
|
||||
}
|
||||
});
|
||||
|
||||
// Escape is handled by Rust's global shortcut so it works
|
||||
// even when another app has focus. No in-page handler needed.
|
||||
|
||||
// Auto-refocus the Phaser canvas when the window regains focus
|
||||
// or the user clicks anywhere outside an interactive HUD element.
|
||||
function refocusCanvas() {
|
||||
var c = document.querySelector('#game canvas');
|
||||
if (c && !document.body.classList.contains('paused')) c.focus();
|
||||
}
|
||||
window.addEventListener('focus', refocusCanvas);
|
||||
document.addEventListener('pointerdown', function(e) {
|
||||
// Don't steal focus from HUD controls (buttons, selects, sliders, inputs)
|
||||
if (e.target && e.target.closest && e.target.closest('#hud, #settings-overlay, #help-overlay')) return;
|
||||
refocusCanvas();
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Update notification ──
|
||||
// Called from Rust when a newer version is available.
|
||||
window.__agentArcadeUpdateAvailable = function(version) {
|
||||
var banner = document.getElementById('update-banner');
|
||||
var versionEl = document.getElementById('update-version');
|
||||
var dismissBtn = document.getElementById('update-dismiss');
|
||||
var linkEl = banner ? banner.querySelector('.update-link') : null;
|
||||
var iconEl = banner ? banner.querySelector('.update-icon') : null;
|
||||
if (!banner || !versionEl) return;
|
||||
|
||||
versionEl.textContent = 'v' + version;
|
||||
var autoHideTimer = null;
|
||||
|
||||
// Click the banner to open the releases page
|
||||
banner.onclick = function(e) {
|
||||
if (e.target === dismissBtn) return;
|
||||
var url = 'https://github.com/DanWahlin/agent-arcade/releases/latest';
|
||||
var ti = window.__TAURI_INTERNALS__;
|
||||
if (ti) {
|
||||
ti.invoke('plugin:opener|open_url', { url: url }).catch(function() {
|
||||
window.open(url, '_blank');
|
||||
});
|
||||
} else {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
// Dismiss button hides the banner
|
||||
dismissBtn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
banner.classList.remove('show');
|
||||
if (autoHideTimer) clearTimeout(autoHideTimer);
|
||||
};
|
||||
|
||||
// Fade in after a short delay
|
||||
setTimeout(function() { banner.classList.add('show'); }, 500);
|
||||
|
||||
// Auto-hide after 30 seconds
|
||||
autoHideTimer = setTimeout(function() { banner.classList.remove('show'); }, 30000);
|
||||
};
|
||||
|
||||
// Called from Rust to update banner status during download/install.
|
||||
window.__agentArcadeUpdateStatus = function(status) {
|
||||
var banner = document.getElementById('update-banner');
|
||||
var linkEl = banner ? banner.querySelector('.update-link') : null;
|
||||
var iconEl = banner ? banner.querySelector('.update-icon') : null;
|
||||
if (status === 'downloading') {
|
||||
if (linkEl) linkEl.textContent = 'Downloading…';
|
||||
if (iconEl) iconEl.textContent = '📦';
|
||||
} else if (status === 'restarting') {
|
||||
if (linkEl) linkEl.textContent = 'Installing… Restarting!';
|
||||
if (iconEl) iconEl.textContent = '✨';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,934 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:; connect-src 'self' blob:;">
|
||||
<title>Agent Arcade</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
body.paused {
|
||||
background: transparent !important;
|
||||
}
|
||||
body.paused #game,
|
||||
body.paused #game *,
|
||||
body.paused #pause-overlay,
|
||||
body.paused #gameover-overlay,
|
||||
body.paused #wave-banner,
|
||||
body.paused #help-overlay {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
#game {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
canvas {
|
||||
background: transparent !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---------- HUD bar ---------- */
|
||||
#hud {
|
||||
position: fixed;
|
||||
top: 37px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 16px 16px;
|
||||
background: linear-gradient(180deg, #1a1f3a 0%, #0a0e22 100%);
|
||||
border: 2px solid #ffd54a;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||
0 6px 24px rgba(0, 0, 0, 0.7),
|
||||
0 0 18px rgba(255, 213, 74, 0.35);
|
||||
color: #e8ecff;
|
||||
font-family: -apple-system, system-ui, 'Helvetica Neue', sans-serif;
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hud-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hud-divider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: linear-gradient(180deg, transparent, rgba(255,255,255,0.25), transparent);
|
||||
}
|
||||
|
||||
.hud-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ctrl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
min-width: 18px;
|
||||
padding: 2px 6px;
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, #3b4570 0%, #232846 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.ctrl-label {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
color: #b8c2e8;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: #ffd54a;
|
||||
text-shadow: 0 0 8px rgba(255, 213, 74, 0.5);
|
||||
}
|
||||
|
||||
/* Score: big neon */
|
||||
.score-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.score-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
filter: drop-shadow(0 0 4px rgba(255,255,255,0.3));
|
||||
}
|
||||
.score-value {
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
font-size: 26px;
|
||||
font-weight: 900;
|
||||
color: #ffeb3b;
|
||||
text-shadow:
|
||||
0 0 8px rgba(255, 235, 59, 0.9),
|
||||
0 0 18px rgba(255, 152, 0, 0.55),
|
||||
0 2px 0 rgba(0, 0, 0, 0.6);
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
transition: transform 120ms ease-out;
|
||||
}
|
||||
.score-value.bump {
|
||||
transform: scale(1.25);
|
||||
filter: brightness(1.4);
|
||||
}
|
||||
@keyframes scorePop {
|
||||
0% { transform: translateY(0) scale(1); color: #ffeb3b; }
|
||||
30% { transform: translateY(-3px) scale(1.28); color: #fff59d; }
|
||||
100% { transform: translateY(0) scale(1); color: #ffeb3b; }
|
||||
}
|
||||
.score-value.pop {
|
||||
animation: scorePop 320ms ease-out;
|
||||
}
|
||||
|
||||
.lives-value {
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
font-size: 26px;
|
||||
font-weight: 900;
|
||||
color: #ff5252;
|
||||
text-shadow:
|
||||
0 0 8px rgba(255, 82, 82, 0.9),
|
||||
0 0 14px rgba(255, 0, 0, 0.5),
|
||||
0 2px 0 rgba(0, 0, 0, 0.6);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.level-value {
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
color: #4fc3f7;
|
||||
text-shadow:
|
||||
0 0 8px rgba(79, 195, 247, 0.9),
|
||||
0 0 14px rgba(33, 150, 243, 0.5),
|
||||
0 2px 0 rgba(0, 0, 0, 0.6);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hi-value {
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
color: #aaa;
|
||||
text-shadow:
|
||||
0 0 6px rgba(170, 170, 170, 0.5),
|
||||
0 2px 0 rgba(0, 0, 0, 0.6);
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Help button */
|
||||
.help-btn {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(180deg, #2a3258 0%, #161a30 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
color: #b8c2e8;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
font-size: 12px;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.help-btn:hover {
|
||||
color: #fff;
|
||||
border-color: #ffd54a;
|
||||
box-shadow: 0 0 12px rgba(255, 213, 74, 0.6);
|
||||
}
|
||||
.help-btn .badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Mute button */
|
||||
.mute-btn {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background: linear-gradient(180deg, #2a3258 0%, #161a30 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
color: #b8c2e8;
|
||||
font-size: 16px;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.mute-btn:hover {
|
||||
color: #fff;
|
||||
border-color: #ffd54a;
|
||||
box-shadow: 0 0 12px rgba(255, 213, 74, 0.6);
|
||||
}
|
||||
.mute-btn.muted {
|
||||
color: #ff5252;
|
||||
border-color: rgba(255, 82, 82, 0.4);
|
||||
}
|
||||
|
||||
/* Settings button */
|
||||
.settings-btn {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background: linear-gradient(180deg, #2a3258 0%, #161a30 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
color: #b8c2e8;
|
||||
font-size: 16px;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.settings-btn:hover {
|
||||
color: #fff;
|
||||
border-color: #ffd54a;
|
||||
box-shadow: 0 0 12px rgba(255, 213, 74, 0.6);
|
||||
}
|
||||
|
||||
/* Settings overlay & modal */
|
||||
#settings-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(5, 8, 20, 0.7);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#settings-overlay.show { display: flex; }
|
||||
|
||||
#settings-modal {
|
||||
min-width: 360px;
|
||||
max-width: 90vw;
|
||||
padding: 28px 32px;
|
||||
background: linear-gradient(180deg, #1a1f3a 0%, #0a0e22 100%);
|
||||
border: 2px solid #ffd54a;
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||
0 12px 48px rgba(0, 0, 0, 0.85),
|
||||
0 0 32px rgba(255, 213, 74, 0.45);
|
||||
color: #e8ecff;
|
||||
font-family: -apple-system, system-ui, 'Helvetica Neue', sans-serif;
|
||||
animation: modalIn 200ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||
}
|
||||
|
||||
#settings-modal h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
color: #ffd54a;
|
||||
text-shadow: 0 0 10px rgba(255, 213, 74, 0.6);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
#settings-modal .subtitle {
|
||||
margin: 0 0 20px;
|
||||
font-size: 12px;
|
||||
color: #8892bd;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.settings-row:last-of-type { border-bottom: none; }
|
||||
|
||||
.settings-row-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #c8d0ee;
|
||||
}
|
||||
|
||||
.settings-row-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Range slider */
|
||||
.settings-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 140px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, #2a3258, #3b4570);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #ffd54a, #c9a020);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 0 8px rgba(255, 213, 74, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #ffd54a, #c9a020);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 0 8px rgba(255, 213, 74, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-slider-value {
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ffd54a;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.toggle-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
background: #2a3258;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
transition: background 200ms, border-color 200ms;
|
||||
}
|
||||
.toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #667;
|
||||
transition: transform 200ms, background 200ms;
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-track {
|
||||
background: rgba(76, 175, 80, 0.3);
|
||||
border-color: #4caf50;
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-track::after {
|
||||
transform: translateX(20px);
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
/* Help modal */
|
||||
#help-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(5, 8, 20, 0.7);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#help-overlay.show { display: flex; }
|
||||
|
||||
#help-modal {
|
||||
min-width: 380px;
|
||||
max-width: 90vw;
|
||||
padding: 28px 32px;
|
||||
background: linear-gradient(180deg, #1a1f3a 0%, #0a0e22 100%);
|
||||
border: 2px solid #ffd54a;
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||
0 12px 48px rgba(0, 0, 0, 0.85),
|
||||
0 0 32px rgba(255, 213, 74, 0.45);
|
||||
color: #e8ecff;
|
||||
font-family: -apple-system, system-ui, 'Helvetica Neue', sans-serif;
|
||||
animation: modalIn 200ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||
}
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.85) translateY(-10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
#help-modal h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
color: #ffd54a;
|
||||
text-shadow: 0 0 10px rgba(255, 213, 74, 0.6);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
#help-modal .subtitle {
|
||||
margin: 0 0 20px;
|
||||
font-size: 12px;
|
||||
color: #8892bd;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.help-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
row-gap: 12px;
|
||||
column-gap: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
.help-grid .keys {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.help-grid .desc {
|
||||
color: #c8d0ee;
|
||||
font-size: 14px;
|
||||
}
|
||||
.help-close {
|
||||
margin-top: 22px;
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(180deg, #ffd54a 0%, #c9a020 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
color: #1a1a1a;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
font-size: 13px;
|
||||
transition: filter 120ms;
|
||||
}
|
||||
.help-close:hover { filter: brightness(1.15); }
|
||||
|
||||
/* ---------- Pause: HUD bar variant ---------- */
|
||||
#hud.paused {
|
||||
border-color: #ffd54a;
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
@keyframes hudPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 1px rgba(255,255,255,0.08) inset, 0 6px 24px rgba(0,0,0,0.7), 0 0 18px rgba(255, 213, 74, 0.45); }
|
||||
50% { box-shadow: 0 0 0 1px rgba(255,255,255,0.08) inset, 0 6px 24px rgba(0,0,0,0.7), 0 0 32px rgba(255, 213, 74, 0.85); }
|
||||
}
|
||||
.pause-badge {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(180deg, #4d3a06 0%, #2a1f00 100%);
|
||||
border: 1px solid #ffd54a;
|
||||
border-radius: 6px;
|
||||
color: #ffd54a;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1.2px;
|
||||
text-shadow: 0 0 6px rgba(255, 213, 74, 0.6);
|
||||
}
|
||||
#hud.paused .pause-badge { display: inline-flex; }
|
||||
#hud.paused .when-running { display: none; }
|
||||
.pause-badge .dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ffd54a;
|
||||
box-shadow: 0 0 8px #ffd54a;
|
||||
animation: dotBlink 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes dotBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.25; }
|
||||
}
|
||||
|
||||
/* Resume button — shown only when paused */
|
||||
.resume-btn {
|
||||
display: none;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
background: linear-gradient(180deg, #2a5828 0%, #163015 100%);
|
||||
border: 1px solid #4caf50;
|
||||
border-radius: 6px;
|
||||
color: #4caf50;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 0 6px rgba(76, 175, 80, 0.6);
|
||||
transition: all 120ms ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
.resume-btn:hover {
|
||||
color: #fff;
|
||||
border-color: #4caf50;
|
||||
box-shadow: 0 0 12px rgba(76, 175, 80, 0.7);
|
||||
background: linear-gradient(180deg, #357a32 0%, #1e3f1c 100%);
|
||||
}
|
||||
#hud.paused .resume-btn { display: inline-flex; }
|
||||
|
||||
/* Drag handle — shown only when paused */
|
||||
.drag-handle {
|
||||
display: none;
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 16px;
|
||||
letter-spacing: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
#hud.paused .drag-handle { display: inline-flex; }
|
||||
|
||||
/* Close button — shown only when paused, positioned top-right corner */
|
||||
.close-btn {
|
||||
display: inline-flex;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: linear-gradient(180deg, #e84040 0%, #b02020 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
transition: all 120ms ease;
|
||||
box-shadow: 0 0 6px rgba(232, 64, 64, 0.5);
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
z-index: 20;
|
||||
}
|
||||
.close-btn:hover {
|
||||
background: linear-gradient(180deg, #ff5050 0%, #d03030 100%);
|
||||
box-shadow: 0 0 14px rgba(255, 80, 80, 0.8);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* Minimize button — to the left of close */
|
||||
.minimize-btn {
|
||||
display: inline-flex;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: linear-gradient(180deg, #f0c040 0%, #c09020 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
transition: all 120ms ease;
|
||||
box-shadow: 0 0 6px rgba(240, 192, 64, 0.5);
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: 18px;
|
||||
z-index: 20;
|
||||
}
|
||||
.minimize-btn:hover {
|
||||
background: linear-gradient(180deg, #ffdd55 0%, #d0a030 100%);
|
||||
box-shadow: 0 0 14px rgba(255, 213, 74, 0.8);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Game selector ---------- */
|
||||
.game-select {
|
||||
pointer-events: auto;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #ffd700;
|
||||
border: 1px solid rgba(255,215,0,0.4);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-family: -apple-system, system-ui, 'Helvetica Neue', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.game-select:hover {
|
||||
border-color: rgba(255,215,0,0.8);
|
||||
}
|
||||
.game-select option {
|
||||
background: #1a1a2e;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
/* Update notification banner */
|
||||
#update-banner {
|
||||
position: fixed;
|
||||
top: 90px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, #1a3a1a 0%, #0a220e 100%);
|
||||
border: 2px solid #4caf50;
|
||||
border-radius: 10px;
|
||||
padding: 10px 20px;
|
||||
color: #c8e6c9;
|
||||
font-family: -apple-system, system-ui, 'Helvetica Neue', sans-serif;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.6), 0 0 12px rgba(76,175,80,0.3);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.4s ease;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#update-banner.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#update-banner:hover {
|
||||
border-color: #81c784;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.6), 0 0 18px rgba(76,175,80,0.5);
|
||||
}
|
||||
#update-banner .update-icon { margin-right: 8px; }
|
||||
#update-banner .update-link {
|
||||
color: #81c784;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
margin-left: 6px;
|
||||
}
|
||||
#update-banner .update-dismiss {
|
||||
margin-left: 12px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#update-banner .update-dismiss:hover { color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="hud">
|
||||
<div class="hud-section">
|
||||
<span id="drag-handle" class="drag-handle" title="Drag to move">⠿</span>
|
||||
<span id="hud-title" class="title">AGENT ARCADE</span>
|
||||
</div>
|
||||
<div class="hud-spacer"></div>
|
||||
<div class="hud-section">
|
||||
<select id="game-select" class="game-select">
|
||||
<!-- populated by game.ts -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="hud-divider"></div>
|
||||
<div class="hud-section score-wrap">
|
||||
<span class="score-icon" title="Lives">❤️</span>
|
||||
<span id="lives-value" class="lives-value">3</span>
|
||||
</div>
|
||||
<div class="hud-divider"></div>
|
||||
<div class="hud-section score-wrap">
|
||||
<span class="score-icon" title="Score">⭐</span>
|
||||
<span id="score-value" class="score-value">0</span>
|
||||
</div>
|
||||
<div class="hud-divider"></div>
|
||||
<div class="hud-section score-wrap">
|
||||
<span class="score-icon" title="High Score">🏆</span>
|
||||
<span id="hi-value" class="hi-value">0</span>
|
||||
</div>
|
||||
<div class="hud-divider"></div>
|
||||
<div class="hud-section">
|
||||
<button id="help-btn" class="help-btn when-running" type="button">
|
||||
<span class="badge">?</span>
|
||||
</button>
|
||||
<button id="mute-btn" class="mute-btn when-running" type="button" title="Toggle sound (M)">🔊</button>
|
||||
<button id="resume-btn" class="resume-btn" type="button" title="Resume game">▶ RESUME</button>
|
||||
<button id="settings-btn" class="settings-btn" type="button" title="Settings">⚙️</button>
|
||||
</div>
|
||||
<button id="minimize-btn" class="minimize-btn" type="button" title="Hide Agent Arcade">−</button>
|
||||
<button id="close-btn" class="close-btn" type="button" title="Quit Agent Arcade">✕</button>
|
||||
</div>
|
||||
|
||||
<div id="settings-overlay">
|
||||
<div id="settings-modal" role="dialog" aria-labelledby="settings-title">
|
||||
<h2 id="settings-title">⚙️ SETTINGS</h2>
|
||||
<p class="subtitle">Customize your experience</p>
|
||||
|
||||
<div class="settings-row">
|
||||
<span class="settings-row-label">Background Transparency</span>
|
||||
<div class="settings-row-control">
|
||||
<input id="bg-transparency" class="settings-slider" type="range" min="0" max="100" value="100">
|
||||
<span id="bg-transparency-value" class="settings-slider-value">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<span class="settings-row-label">Enable Audio</span>
|
||||
<div class="settings-row-control">
|
||||
<label class="toggle-switch">
|
||||
<input id="audio-toggle" type="checkbox" checked>
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<span class="settings-row-label">Toggle Hotkey</span>
|
||||
<div class="settings-row-control" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<input id="hotkey-display" type="text" readonly
|
||||
style="width:180px;padding:6px 12px;border:2px solid #555;border-radius:6px;
|
||||
background:#222;color:#fff;font-family:'Press Start 2P',monospace;
|
||||
font-size:13px;text-align:center;cursor:pointer;"
|
||||
value="Ctrl+Alt+M" title="Click to change, then press new combo">
|
||||
<button id="hotkey-record-btn" type="button"
|
||||
style="padding:6px 14px;border:2px solid #888;border-radius:6px;
|
||||
background:#333;color:#fff;font-family:'Press Start 2P',monospace;
|
||||
font-size:12px;cursor:pointer;">
|
||||
Change
|
||||
</button>
|
||||
<button id="hotkey-reset-btn" type="button"
|
||||
style="padding:6px 10px;border:2px solid #666;border-radius:6px;
|
||||
background:#2a2a2a;color:#aaa;font-family:'Press Start 2P',monospace;
|
||||
font-size:10px;cursor:pointer;"
|
||||
title="Reset to Ctrl+Alt+M">
|
||||
Reset
|
||||
</button>
|
||||
<span id="hotkey-status" style="font-size:11px;color:#888;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<span class="settings-row-label">Pause Hotkey</span>
|
||||
<div class="settings-row-control" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<input id="pause-hotkey-display" type="text" readonly
|
||||
style="width:180px;padding:6px 12px;border:2px solid #555;border-radius:6px;
|
||||
background:#222;color:#fff;font-family:'Press Start 2P',monospace;
|
||||
font-size:13px;text-align:center;cursor:pointer;"
|
||||
value="Escape" title="Click to change, then press new key or combo">
|
||||
<button id="pause-hotkey-record-btn" type="button"
|
||||
style="padding:6px 14px;border:2px solid #888;border-radius:6px;
|
||||
background:#333;color:#fff;font-family:'Press Start 2P',monospace;
|
||||
font-size:12px;cursor:pointer;">
|
||||
Change
|
||||
</button>
|
||||
<button id="pause-hotkey-reset-btn" type="button"
|
||||
style="padding:6px 10px;border:2px solid #666;border-radius:6px;
|
||||
background:#2a2a2a;color:#aaa;font-family:'Press Start 2P',monospace;
|
||||
font-size:10px;cursor:pointer;"
|
||||
title="Reset to Escape">
|
||||
Reset
|
||||
</button>
|
||||
<span id="pause-hotkey-status" style="font-size:11px;color:#888;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<span class="settings-row-label">Resume Hotkey</span>
|
||||
<div class="settings-row-control" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<input id="unpause-hotkey-display" type="text" readonly
|
||||
style="width:180px;padding:6px 12px;border:2px solid #555;border-radius:6px;
|
||||
background:#222;color:#fff;font-family:'Press Start 2P',monospace;
|
||||
font-size:13px;text-align:center;cursor:pointer;"
|
||||
value="Ctrl+Escape" title="Click to change, then press new key or combo">
|
||||
<button id="unpause-hotkey-record-btn" type="button"
|
||||
style="padding:6px 14px;border:2px solid #888;border-radius:6px;
|
||||
background:#333;color:#fff;font-family:'Press Start 2P',monospace;
|
||||
font-size:12px;cursor:pointer;">
|
||||
Change
|
||||
</button>
|
||||
<button id="unpause-hotkey-reset-btn" type="button"
|
||||
style="padding:6px 10px;border:2px solid #666;border-radius:6px;
|
||||
background:#2a2a2a;color:#aaa;font-family:'Press Start 2P',monospace;
|
||||
font-size:10px;cursor:pointer;"
|
||||
title="Reset to Ctrl+Escape">
|
||||
Reset
|
||||
</button>
|
||||
<span id="unpause-hotkey-status" style="font-size:11px;color:#888;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<span class="settings-row-label">Clear Scores</span>
|
||||
<div class="settings-row-control" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<select id="clear-scores-game"
|
||||
style="padding:6px 12px;border:2px solid #555;border-radius:6px;
|
||||
background:#222;color:#fff;font-family:'Press Start 2P',monospace;
|
||||
font-size:11px;cursor:pointer;">
|
||||
<option value="alien-onslaught">👾 Alien Onslaught</option>
|
||||
<option value="cosmic-rocks">☄️ Cosmic Rocks</option>
|
||||
<option value="galaxy-blaster">🚀 Galaxy Blaster</option>
|
||||
<option value="ninja-runner">🥷 Ninja Runner</option>
|
||||
</select>
|
||||
<button id="clear-scores-btn" type="button"
|
||||
style="padding:6px 14px;border:2px solid #c44;border-radius:6px;
|
||||
background:#422;color:#f88;font-family:'Press Start 2P',monospace;
|
||||
font-size:12px;cursor:pointer;">
|
||||
Clear
|
||||
</button>
|
||||
<span id="clear-scores-status" style="font-size:11px;color:#888;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="settings-close" class="help-close" type="button">CLOSE</button>
|
||||
<div id="settings-version" style="text-align:center;margin-top:10px;font-size:11px;color:#555;font-family:-apple-system,system-ui,'Helvetica Neue',sans-serif;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="help-overlay">
|
||||
<div id="help-modal" role="dialog" aria-labelledby="help-title">
|
||||
<h2 id="help-title">🕹️ AGENT ARCADE</h2>
|
||||
<p class="subtitle">Controls</p>
|
||||
<div class="help-grid">
|
||||
<div class="keys"><kbd>←</kbd><kbd>→</kbd></div>
|
||||
<div class="desc">Move left / right</div>
|
||||
|
||||
<div class="keys"><kbd>Space</kbd></div>
|
||||
<div class="desc">Jump (hold for higher)</div>
|
||||
|
||||
<div class="keys"><kbd>⇧ Shift</kbd></div>
|
||||
<div class="desc">Run (hold while moving)</div>
|
||||
|
||||
<div class="keys"><kbd>F</kbd></div>
|
||||
<div class="desc">Fire (when powered up)</div>
|
||||
|
||||
<div class="keys" id="help-pause-keys"><kbd>Esc</kbd></div>
|
||||
<div class="desc">Pause</div>
|
||||
|
||||
<div class="keys" id="help-unpause-keys"><kbd>Ctrl</kbd>+<kbd>Esc</kbd></div>
|
||||
<div class="desc">Resume (or click ▶ RESUME)</div>
|
||||
|
||||
<div class="keys"><kbd>M</kbd></div>
|
||||
<div class="desc">Toggle sound on / off</div>
|
||||
|
||||
<div class="keys" id="help-toggle-keys"><kbd>⌃</kbd><kbd>⌥</kbd><kbd>M</kbd></div>
|
||||
<div class="desc">Show / Hide window (global)</div>
|
||||
|
||||
<div class="keys">Tray menu</div>
|
||||
<div class="desc">Show / Hide window</div>
|
||||
|
||||
<div class="keys"><kbd>⌘</kbd><kbd>Q</kbd></div>
|
||||
<div class="desc">Quit</div>
|
||||
</div>
|
||||
<button id="help-close" class="help-close" type="button">CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="update-banner">
|
||||
<span class="update-icon">🚀</span>
|
||||
<span>Version <strong id="update-version"></strong> is available!</span>
|
||||
<span class="update-link">View Release</span>
|
||||
<span class="update-dismiss" id="update-dismiss" title="Dismiss">✕</span>
|
||||
</div>
|
||||
|
||||
<div id="pause-overlay" style="display:none"></div>
|
||||
|
||||
<div id="game"></div>
|
||||
<script src="./hud.js"></script>
|
||||
<script src="./phaser.min.js"></script>
|
||||
<script type="module" src="./game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,812 @@
|
||||
// AlienOnslaught — Space Invaders-style arcade shooter.
|
||||
// Rows of aliens march across the screen, descending as they reach the
|
||||
// edges. The player defends from the bottom with destructible shields.
|
||||
// All graphics are procedural (Phaser Graphics) — no external sprite sheets.
|
||||
import { BaseScene, W, H } from './BaseScene.js';
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants — recalculated in create() for responsive sizing */
|
||||
/* ------------------------------------------------------------------ */
|
||||
let SCALE = Math.min(W / 1920, H / 1080);
|
||||
// Grid layout
|
||||
const ALIEN_COLS = 11;
|
||||
const ALIEN_ROWS = 5;
|
||||
// Alien types per row (top → bottom): squid, crab, crab, octopus, octopus
|
||||
const ALIEN_TYPES = [
|
||||
{ name: 'squid', points: 30, color: 0xff4444 }, // row 0 (top)
|
||||
{ name: 'crab', points: 20, color: 0x44ff44 }, // rows 1-2
|
||||
{ name: 'octopus', points: 10, color: 0x44aaff }, // rows 3-4
|
||||
];
|
||||
// Timing / speeds
|
||||
const BASE_MARCH_INTERVAL = 700; // ms between march steps at full grid
|
||||
const MIN_MARCH_INTERVAL = 60; // fastest march with few aliens left
|
||||
const MARCH_DROP = 0; // calculated in create()
|
||||
const PLAYER_SPEED = 350; // px/s
|
||||
const PLAYER_BULLET_SPEED = 500; // px/s
|
||||
const ALIEN_BULLET_SPEED = 250; // px/s
|
||||
const ALIEN_FIRE_INTERVAL = 1200; // ms between alien shots (base)
|
||||
const MYSTERY_INTERVAL_MIN = 15000;
|
||||
const MYSTERY_INTERVAL_MAX = 30000;
|
||||
const MYSTERY_SPEED = 150; // px/s
|
||||
const INVINCIBLE_TIME = 2000; // ms
|
||||
// Shield config
|
||||
const SHIELD_COUNT = 4;
|
||||
const SHIELD_BLOCK_COLS = 22;
|
||||
const SHIELD_BLOCK_ROWS = 16;
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Scene */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export class AlienOnslaughtScene extends BaseScene {
|
||||
/* player */
|
||||
playerGfx;
|
||||
playerX = 0;
|
||||
playerY = 0;
|
||||
playerAlive = true;
|
||||
/* aliens */
|
||||
aliens = [];
|
||||
alienCellW = 0;
|
||||
alienCellH = 0;
|
||||
alienGridX = 0; // grid origin
|
||||
alienGridY = 0;
|
||||
marchDir = 1; // 1 = right, -1 = left
|
||||
marchTimer = 0;
|
||||
marchInterval = BASE_MARCH_INTERVAL;
|
||||
marchStepX = 0;
|
||||
marchDrop = 0;
|
||||
/* bullets */
|
||||
playerBullets = [];
|
||||
alienBullets = [];
|
||||
alienFireTimer = 0;
|
||||
/* mystery ship */
|
||||
mystery = null;
|
||||
mysteryTimer = 0;
|
||||
/* shields */
|
||||
shields = []; // [shieldIdx][blockIdx]
|
||||
/* starfield */
|
||||
stars = [];
|
||||
/* game state */
|
||||
wave = 0;
|
||||
invincibleTimer = 0;
|
||||
respawnTimer = 0;
|
||||
gameOverFlag = false;
|
||||
waveDelay = 0;
|
||||
/* input */
|
||||
cursors;
|
||||
spaceKey;
|
||||
spaceWasDown = false;
|
||||
/* sizing (calculated in create) */
|
||||
alienW = 0;
|
||||
alienH = 0;
|
||||
playerW = 0;
|
||||
playerH = 0;
|
||||
bulletW = 0;
|
||||
bulletH = 0;
|
||||
constructor() { super('alien-onslaught'); }
|
||||
get displayName() { return 'Alien Onslaught'; }
|
||||
getDescription() {
|
||||
return 'Blast waves of descending aliens before they reach the bottom!';
|
||||
}
|
||||
getControls() {
|
||||
return [
|
||||
{ key: '← →', action: 'Move Left / Right' },
|
||||
{ key: 'SPACE', action: 'Fire' },
|
||||
];
|
||||
}
|
||||
/* ================================================================
|
||||
LIFECYCLE
|
||||
================================================================ */
|
||||
preload() {
|
||||
// Reuse existing sound effects
|
||||
this.load.audio('ao_laser', '../assets/galaxy-blaster/sounds/sfx_laser1.ogg');
|
||||
this.load.audio('ao_explosion', '../assets/galaxy-blaster/sounds/sfx_explosion.ogg');
|
||||
this.load.audio('ao_lose', '../assets/cosmic-rocks/sounds/sfx_lose.ogg');
|
||||
this.load.audio('ao_twoTone', '../assets/cosmic-rocks/sounds/sfx_twoTone.ogg');
|
||||
this.load.audio('ao_shieldHit', '../assets/galaxy-blaster/sounds/sfx_zap.ogg');
|
||||
this.load.audio('ao_mystery', '../assets/galaxy-blaster/sounds/sfx_twoTone.ogg');
|
||||
}
|
||||
create() {
|
||||
this.initBase();
|
||||
// Responsive sizing — scale the grid to fill ~70% of screen width
|
||||
SCALE = Math.min(W / 1920, H / 1080);
|
||||
const s = Math.max(SCALE, 0.5);
|
||||
// Size the grid relative to screen, not a fixed pixel size
|
||||
this.alienCellW = Math.round(W * 0.055); // ~85% of original — tighter grid
|
||||
this.alienCellH = Math.round(this.alienCellW * 0.8);
|
||||
this.alienW = Math.round(this.alienCellW * 0.6);
|
||||
this.alienH = Math.round(this.alienCellH * 0.55);
|
||||
this.playerW = Math.round(this.alienCellW * 0.85);
|
||||
this.playerH = Math.round(this.playerW * 0.55);
|
||||
this.bulletW = Math.round(4 * s);
|
||||
this.bulletH = Math.round(12 * s);
|
||||
this.marchStepX = Math.round(this.alienCellW * 0.25); // bigger steps → hit edges sooner
|
||||
this.marchDrop = Math.round(this.alienCellH * 0.6); // bigger drops → descend faster
|
||||
// Reset state
|
||||
this.score = 0;
|
||||
this.lives = 3;
|
||||
this.wave = 0;
|
||||
this.playerAlive = true;
|
||||
this.gameOverFlag = false;
|
||||
this.invincibleTimer = INVINCIBLE_TIME;
|
||||
this.respawnTimer = 0;
|
||||
this.waveDelay = 0;
|
||||
this.marchDir = 1;
|
||||
this.marchTimer = 0;
|
||||
this.marchInterval = BASE_MARCH_INTERVAL;
|
||||
this.playerBullets = [];
|
||||
this.alienBullets = [];
|
||||
this.aliens = [];
|
||||
this.shields = [];
|
||||
this.mystery = null;
|
||||
this.mysteryTimer = MYSTERY_INTERVAL_MIN + Math.random() * (MYSTERY_INTERVAL_MAX - MYSTERY_INTERVAL_MIN);
|
||||
this.stars = [];
|
||||
this.ensureSparkTexture();
|
||||
// Starfield
|
||||
this.stars = this.createStarfield([
|
||||
{ count: 40, speed: 10, size: 1, alpha: 0.2 },
|
||||
{ count: 25, speed: 20, size: 1.5, alpha: 0.3 },
|
||||
{ count: 10, speed: 40, size: 2, alpha: 0.4 },
|
||||
]);
|
||||
// Player position — bottom of screen with padding
|
||||
this.playerX = W / 2;
|
||||
this.playerY = H * 0.92;
|
||||
this.playerGfx = this.add.graphics().setDepth(10);
|
||||
this.drawPlayer();
|
||||
// Input
|
||||
this.cursors = this.input.keyboard.createCursorKeys();
|
||||
this.spaceKey = this.input.keyboard.addKey('SPACE');
|
||||
this.spaceWasDown = false;
|
||||
// HUD
|
||||
this.syncLivesToHUD();
|
||||
this.syncScoreToHUD();
|
||||
this.loadHighScore();
|
||||
this.startWithReadyScreen(() => this.startWave());
|
||||
}
|
||||
update(_t, dtMs) {
|
||||
if (this.gameOverFlag || !this.cursors)
|
||||
return;
|
||||
const dt = Math.min(dtMs, 33);
|
||||
const dtSec = dt / 1000;
|
||||
this.updateStarfield(this.stars, dt);
|
||||
// Respawn delay
|
||||
if (this.respawnTimer > 0) {
|
||||
this.respawnTimer -= dt;
|
||||
if (this.respawnTimer <= 0)
|
||||
this.respawnPlayer();
|
||||
}
|
||||
// Player input
|
||||
if (this.playerAlive) {
|
||||
this.updatePlayerInput(dtSec);
|
||||
}
|
||||
// Invincibility flicker
|
||||
if (this.invincibleTimer > 0) {
|
||||
this.invincibleTimer -= dt;
|
||||
if (this.playerGfx) {
|
||||
this.playerGfx.setAlpha(Math.sin(performance.now() / 80) > 0 ? 1 : 0.2);
|
||||
}
|
||||
}
|
||||
else if (this.playerGfx) {
|
||||
this.playerGfx.setAlpha(1);
|
||||
}
|
||||
// Alien march
|
||||
this.marchTimer += dt;
|
||||
if (this.marchTimer >= this.marchInterval) {
|
||||
this.marchTimer = 0;
|
||||
this.marchAliens();
|
||||
}
|
||||
// Alien shooting
|
||||
this.alienFireTimer += dt;
|
||||
const fireInterval = Math.max(400, ALIEN_FIRE_INTERVAL - this.wave * 80);
|
||||
if (this.alienFireTimer >= fireInterval) {
|
||||
this.alienFireTimer = 0;
|
||||
this.alienShoot();
|
||||
}
|
||||
// Update bullets
|
||||
this.updatePlayerBullets(dtSec);
|
||||
this.updateAlienBullets(dtSec);
|
||||
// Mystery ship
|
||||
this.updateMystery(dt, dtSec);
|
||||
// Collisions
|
||||
this.checkCollisions();
|
||||
// Wave clear
|
||||
if (this.waveDelay > 0) {
|
||||
this.waveDelay -= dt;
|
||||
if (this.waveDelay <= 0)
|
||||
this.startWave();
|
||||
}
|
||||
else if (this.aliens.filter(a => a.alive).length === 0 && this.waveDelay <= 0) {
|
||||
this.waveDelay = 1500;
|
||||
}
|
||||
}
|
||||
/* ================================================================
|
||||
PLAYER
|
||||
================================================================ */
|
||||
drawPlayer() {
|
||||
const g = this.playerGfx;
|
||||
g.clear();
|
||||
g.setPosition(this.playerX, this.playerY);
|
||||
const hw = this.playerW / 2;
|
||||
const hh = this.playerH / 2;
|
||||
const turretW = hw * 0.2;
|
||||
const turretH = hh * 0.6;
|
||||
// Shadow
|
||||
g.fillStyle(0x000000, 0.5);
|
||||
g.fillRect(-hw - 1, -hh - 1, this.playerW + 2, this.playerH + 2);
|
||||
g.fillRect(-turretW - 1, -hh - turretH - 1, turretW * 2 + 2, turretH + 2);
|
||||
// Body (bright green)
|
||||
g.fillStyle(0x00ff66, 1);
|
||||
g.fillRect(-hw, -hh, this.playerW, this.playerH);
|
||||
// Turret
|
||||
g.fillStyle(0x00ff66, 1);
|
||||
g.fillRect(-turretW, -hh - turretH, turretW * 2, turretH);
|
||||
// Cockpit highlight
|
||||
g.fillStyle(0xaaffcc, 0.6);
|
||||
g.fillRect(-hw * 0.3, -hh * 0.5, hw * 0.6, hh * 0.6);
|
||||
}
|
||||
updatePlayerInput(dtSec) {
|
||||
if (!this.cursors)
|
||||
return;
|
||||
if (this.cursors.left.isDown) {
|
||||
this.playerX -= PLAYER_SPEED * dtSec;
|
||||
}
|
||||
if (this.cursors.right.isDown) {
|
||||
this.playerX += PLAYER_SPEED * dtSec;
|
||||
}
|
||||
// Clamp to screen
|
||||
const hw = this.playerW / 2;
|
||||
this.playerX = Math.max(hw, Math.min(W - hw, this.playerX));
|
||||
this.drawPlayer();
|
||||
// Fire
|
||||
const spaceDown = this.spaceKey.isDown;
|
||||
if (spaceDown && !this.spaceWasDown && this.playerBullets.length < 2) {
|
||||
this.firePlayerBullet();
|
||||
}
|
||||
this.spaceWasDown = spaceDown;
|
||||
}
|
||||
respawnPlayer() {
|
||||
this.playerX = W / 2;
|
||||
this.playerAlive = true;
|
||||
this.invincibleTimer = INVINCIBLE_TIME;
|
||||
if (this.playerGfx) {
|
||||
this.playerGfx.setVisible(true);
|
||||
}
|
||||
this.drawPlayer();
|
||||
}
|
||||
/* ================================================================
|
||||
PLAYER BULLETS
|
||||
================================================================ */
|
||||
firePlayerBullet() {
|
||||
this.sound.play('ao_laser', { volume: 0.3 });
|
||||
const gfx = this.add.graphics().setDepth(8);
|
||||
const bx = this.playerX;
|
||||
const by = this.playerY - this.playerH / 2;
|
||||
// Glow
|
||||
gfx.fillStyle(0x00ffff, 0.3);
|
||||
gfx.fillRect(-this.bulletW, -this.bulletH, this.bulletW * 2, this.bulletH * 2);
|
||||
// Solid
|
||||
gfx.fillStyle(0x00ffff, 1);
|
||||
gfx.fillRect(-this.bulletW / 2, -this.bulletH / 2, this.bulletW, this.bulletH);
|
||||
gfx.setPosition(bx, by);
|
||||
this.playerBullets.push({ gfx, x: bx, y: by });
|
||||
}
|
||||
updatePlayerBullets(dtSec) {
|
||||
for (let i = this.playerBullets.length - 1; i >= 0; i--) {
|
||||
const b = this.playerBullets[i];
|
||||
b.y -= PLAYER_BULLET_SPEED * dtSec;
|
||||
b.gfx.setPosition(b.x, b.y);
|
||||
if (b.y < -this.bulletH) {
|
||||
b.gfx.destroy();
|
||||
this.playerBullets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* ================================================================
|
||||
ALIENS
|
||||
================================================================ */
|
||||
startWave() {
|
||||
this.wave++;
|
||||
this.level = this.wave;
|
||||
this.syncLevelToHUD();
|
||||
this.showWaveBanner(this.wave);
|
||||
// Clear leftover bullets
|
||||
for (const b of this.playerBullets)
|
||||
b.gfx.destroy();
|
||||
this.playerBullets = [];
|
||||
for (const b of this.alienBullets)
|
||||
b.gfx.destroy();
|
||||
this.alienBullets = [];
|
||||
// Reset march
|
||||
this.marchDir = 1;
|
||||
this.marchTimer = 0;
|
||||
this.alienFireTimer = 0;
|
||||
// Calculate grid start position (centered)
|
||||
const gridW = ALIEN_COLS * this.alienCellW;
|
||||
this.alienGridX = (W - gridW) / 2;
|
||||
this.alienGridY = Math.max(H * 0.20, 120);
|
||||
// Create aliens
|
||||
for (const a of this.aliens)
|
||||
a.gfx.destroy();
|
||||
this.aliens = [];
|
||||
for (let row = 0; row < ALIEN_ROWS; row++) {
|
||||
const typeIdx = row === 0 ? 0 : row <= 2 ? 1 : 2;
|
||||
for (let col = 0; col < ALIEN_COLS; col++) {
|
||||
const x = this.alienGridX + col * this.alienCellW + this.alienCellW / 2;
|
||||
const y = this.alienGridY + row * this.alienCellH + this.alienCellH / 2;
|
||||
const gfx = this.add.graphics().setDepth(5);
|
||||
const alien = { gfx, row, col, type: typeIdx, alive: true, x, y, frame: 0 };
|
||||
this.drawAlien(alien);
|
||||
this.aliens.push(alien);
|
||||
}
|
||||
}
|
||||
this.updateMarchInterval();
|
||||
// Recreate shields on first wave only
|
||||
if (this.wave === 1) {
|
||||
this.createShields();
|
||||
}
|
||||
}
|
||||
drawAlien(alien) {
|
||||
const g = alien.gfx;
|
||||
g.clear();
|
||||
g.setPosition(alien.x, alien.y);
|
||||
const type = ALIEN_TYPES[alien.type];
|
||||
const hw = this.alienW / 2;
|
||||
const hh = this.alienH / 2;
|
||||
const px = Math.max(2, Math.round(this.alienW / 10)); // pixel unit size
|
||||
// Draw pixel-art alien based on type
|
||||
g.fillStyle(type.color, 1);
|
||||
if (type.name === 'squid') {
|
||||
// Squid alien — narrow top, wider middle
|
||||
g.fillRect(-px, -hh, px * 2, px); // top antenna
|
||||
g.fillRect(-px * 2, -hh + px, px * 4, px); // head top
|
||||
g.fillRect(-px * 3, -hh + px * 2, px * 6, px * 2); // head body
|
||||
g.fillRect(-px * 4, -hh + px * 4, px * 8, px); // wider
|
||||
g.fillRect(-px * 3, -hh + px * 5, px * 6, px); // middle
|
||||
if (alien.frame === 0) {
|
||||
// legs out
|
||||
g.fillRect(-px * 4, -hh + px * 6, px * 2, px);
|
||||
g.fillRect(px * 2, -hh + px * 6, px * 2, px);
|
||||
}
|
||||
else {
|
||||
// legs in
|
||||
g.fillRect(-px * 2, -hh + px * 6, px * 2, px);
|
||||
g.fillRect(0, -hh + px * 6, px * 2, px);
|
||||
}
|
||||
}
|
||||
else if (type.name === 'crab') {
|
||||
// Crab alien — classic shape with claws
|
||||
g.fillRect(-px, -hh, px * 2, px); // antenna
|
||||
g.fillRect(-px * 3, -hh + px, px * 6, px); // top
|
||||
g.fillRect(-px * 4, -hh + px * 2, px * 8, px * 2); // body
|
||||
g.fillRect(-px * 5, -hh + px * 4, px * 10, px); // wide row
|
||||
g.fillRect(-px * 4, -hh + px * 5, px * 8, px); // narrower
|
||||
// Eyes (dark cutouts)
|
||||
g.fillStyle(0x000000, 1);
|
||||
g.fillRect(-px * 2, -hh + px * 2, px, px);
|
||||
g.fillRect(px, -hh + px * 2, px, px);
|
||||
g.fillStyle(type.color, 1);
|
||||
if (alien.frame === 0) {
|
||||
g.fillRect(-px * 5, -hh + px * 5, px, px * 2);
|
||||
g.fillRect(px * 4, -hh + px * 5, px, px * 2);
|
||||
}
|
||||
else {
|
||||
g.fillRect(-px * 3, -hh + px * 6, px * 2, px);
|
||||
g.fillRect(px, -hh + px * 6, px * 2, px);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Octopus alien — round with tentacles
|
||||
g.fillRect(-px * 2, -hh, px * 4, px); // top
|
||||
g.fillRect(-px * 4, -hh + px, px * 8, px * 2); // upper body
|
||||
g.fillRect(-px * 5, -hh + px * 3, px * 10, px * 2); // body
|
||||
g.fillRect(-px * 4, -hh + px * 5, px * 8, px); // lower
|
||||
// Eyes
|
||||
g.fillStyle(0x000000, 1);
|
||||
g.fillRect(-px * 3, -hh + px * 2, px * 2, px);
|
||||
g.fillRect(px, -hh + px * 2, px * 2, px);
|
||||
g.fillStyle(type.color, 1);
|
||||
if (alien.frame === 0) {
|
||||
// tentacles down/out
|
||||
g.fillRect(-px * 5, -hh + px * 6, px * 2, px);
|
||||
g.fillRect(-px * 2, -hh + px * 6, px, px);
|
||||
g.fillRect(px, -hh + px * 6, px, px);
|
||||
g.fillRect(px * 3, -hh + px * 6, px * 2, px);
|
||||
}
|
||||
else {
|
||||
// tentacles up/in
|
||||
g.fillRect(-px * 4, -hh + px * 6, px * 2, px);
|
||||
g.fillRect(-px, -hh + px * 6, px * 2, px);
|
||||
g.fillRect(px * 2, -hh + px * 6, px * 2, px);
|
||||
}
|
||||
}
|
||||
}
|
||||
marchAliens() {
|
||||
const alive = this.aliens.filter(a => a.alive);
|
||||
if (alive.length === 0)
|
||||
return;
|
||||
// Check if any alien hit the edge
|
||||
let hitEdge = false;
|
||||
const margin = this.alienCellW * 0.3;
|
||||
for (const a of alive) {
|
||||
if (this.marchDir === 1 && a.x + this.alienW / 2 + this.marchStepX > W - margin) {
|
||||
hitEdge = true;
|
||||
break;
|
||||
}
|
||||
if (this.marchDir === -1 && a.x - this.alienW / 2 - this.marchStepX < margin) {
|
||||
hitEdge = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hitEdge) {
|
||||
// Drop down and reverse
|
||||
this.marchDir *= -1;
|
||||
for (const a of alive) {
|
||||
a.y += this.marchDrop;
|
||||
a.frame = 1 - a.frame;
|
||||
this.drawAlien(a);
|
||||
// Check if aliens reached player row — instant game over (classic rules)
|
||||
if (a.y + this.alienH / 2 >= this.playerY - this.playerH / 2) {
|
||||
this.triggerGameOver();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// March sideways
|
||||
for (const a of alive) {
|
||||
a.x += this.marchStepX * this.marchDir;
|
||||
a.frame = 1 - a.frame;
|
||||
this.drawAlien(a);
|
||||
}
|
||||
}
|
||||
// Play march sound (alternate tone)
|
||||
this.sound.play('ao_twoTone', { volume: 0.15 });
|
||||
}
|
||||
updateMarchInterval() {
|
||||
const aliveCount = this.aliens.filter(a => a.alive).length;
|
||||
const total = ALIEN_COLS * ALIEN_ROWS;
|
||||
if (total === 0)
|
||||
return;
|
||||
// Exponential speed-up as aliens are destroyed
|
||||
const ratio = aliveCount / total;
|
||||
this.marchInterval = MIN_MARCH_INTERVAL + (BASE_MARCH_INTERVAL - MIN_MARCH_INTERVAL) * ratio;
|
||||
// Wave speed bonus
|
||||
this.marchInterval = Math.max(MIN_MARCH_INTERVAL, this.marchInterval - this.wave * 20);
|
||||
}
|
||||
/* ================================================================
|
||||
ALIEN SHOOTING
|
||||
================================================================ */
|
||||
alienShoot() {
|
||||
const alive = this.aliens.filter(a => a.alive);
|
||||
if (alive.length === 0)
|
||||
return;
|
||||
// Find bottommost alien in each column, then pick one at random
|
||||
const bottomAliens = [];
|
||||
for (let col = 0; col < ALIEN_COLS; col++) {
|
||||
const colAliens = alive.filter(a => a.col === col);
|
||||
if (colAliens.length > 0) {
|
||||
colAliens.sort((a, b) => b.row - a.row);
|
||||
bottomAliens.push(colAliens[0]);
|
||||
}
|
||||
}
|
||||
if (bottomAliens.length === 0)
|
||||
return;
|
||||
const shooter = bottomAliens[Math.floor(Math.random() * bottomAliens.length)];
|
||||
const gfx = this.add.graphics().setDepth(7);
|
||||
// Alien bullet — different color (yellow/red)
|
||||
gfx.fillStyle(0xffaa00, 0.4);
|
||||
gfx.fillRect(-this.bulletW, -this.bulletH / 2, this.bulletW * 2, this.bulletH);
|
||||
gfx.fillStyle(0xff4444, 1);
|
||||
gfx.fillRect(-this.bulletW / 2, -this.bulletH / 2, this.bulletW, this.bulletH);
|
||||
gfx.setPosition(shooter.x, shooter.y + this.alienH / 2);
|
||||
this.alienBullets.push({ gfx, x: shooter.x, y: shooter.y + this.alienH / 2 });
|
||||
}
|
||||
updateAlienBullets(dtSec) {
|
||||
for (let i = this.alienBullets.length - 1; i >= 0; i--) {
|
||||
const b = this.alienBullets[i];
|
||||
b.y += ALIEN_BULLET_SPEED * dtSec;
|
||||
b.gfx.setPosition(b.x, b.y);
|
||||
if (b.y > H + this.bulletH) {
|
||||
b.gfx.destroy();
|
||||
this.alienBullets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* ================================================================
|
||||
MYSTERY SHIP
|
||||
================================================================ */
|
||||
spawnMystery() {
|
||||
const dir = Math.random() < 0.5 ? 1 : -1;
|
||||
const x = dir === 1 ? -40 : W + 40;
|
||||
// Position just above the alien grid, below the HUD
|
||||
const y = this.alienGridY - this.alienCellH * 1.2;
|
||||
const gfx = this.add.graphics().setDepth(12);
|
||||
this.mystery = { gfx, x, y, direction: dir, active: true };
|
||||
this.drawMystery();
|
||||
this.sound.play('ao_mystery', { volume: 0.2 });
|
||||
}
|
||||
drawMystery() {
|
||||
if (!this.mystery)
|
||||
return;
|
||||
const g = this.mystery.gfx;
|
||||
g.clear();
|
||||
g.setPosition(this.mystery.x, this.mystery.y);
|
||||
const s = Math.max(SCALE, 0.5);
|
||||
const w = 30 * s;
|
||||
const h = 12 * s;
|
||||
// Saucer shape
|
||||
g.fillStyle(0x000000, 0.5);
|
||||
g.fillEllipse(0, 0, w * 2 + 2, h + 2);
|
||||
g.fillStyle(0xff00ff, 0.8);
|
||||
g.fillEllipse(0, 0, w * 2, h);
|
||||
// Dome
|
||||
g.fillStyle(0xff66ff, 1);
|
||||
g.fillEllipse(0, -h * 0.4, w, h * 0.7);
|
||||
// Lights
|
||||
g.fillStyle(0xffff00, 1);
|
||||
g.fillCircle(-w * 0.5, 0, 2 * s);
|
||||
g.fillCircle(0, 0, 2 * s);
|
||||
g.fillCircle(w * 0.5, 0, 2 * s);
|
||||
}
|
||||
updateMystery(dt, dtSec) {
|
||||
if (this.mystery && this.mystery.active) {
|
||||
this.mystery.x += MYSTERY_SPEED * this.mystery.direction * dtSec;
|
||||
this.drawMystery();
|
||||
// Off screen?
|
||||
if ((this.mystery.direction === 1 && this.mystery.x > W + 60) ||
|
||||
(this.mystery.direction === -1 && this.mystery.x < -60)) {
|
||||
this.mystery.gfx.destroy();
|
||||
this.mystery = null;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.mysteryTimer -= dt;
|
||||
if (this.mysteryTimer <= 0) {
|
||||
this.mysteryTimer = MYSTERY_INTERVAL_MIN + Math.random() * (MYSTERY_INTERVAL_MAX - MYSTERY_INTERVAL_MIN);
|
||||
this.spawnMystery();
|
||||
}
|
||||
}
|
||||
}
|
||||
/* ================================================================
|
||||
SHIELDS
|
||||
================================================================ */
|
||||
createShields() {
|
||||
// Clear existing
|
||||
for (const shield of this.shields) {
|
||||
for (const block of shield) {
|
||||
if (block.gfx)
|
||||
block.gfx.destroy();
|
||||
}
|
||||
}
|
||||
this.shields = [];
|
||||
const s = Math.max(SCALE, 0.5);
|
||||
// Original shields were ~6% of screen height tall; derive block size from that
|
||||
const targetShieldH = H * 0.055;
|
||||
const blockH = Math.max(2, Math.round(targetShieldH / SHIELD_BLOCK_ROWS));
|
||||
const blockW = blockH;
|
||||
const shieldW = SHIELD_BLOCK_COLS * blockW;
|
||||
const shieldH = SHIELD_BLOCK_ROWS * blockH;
|
||||
const totalShieldsW = SHIELD_COUNT * shieldW;
|
||||
const gap = (W - totalShieldsW) / (SHIELD_COUNT + 1);
|
||||
const shieldY = this.playerY - this.playerH - shieldH - 20;
|
||||
// Classic shield shape mask (inverted U)
|
||||
const shieldMask = this.generateShieldMask();
|
||||
for (let si = 0; si < SHIELD_COUNT; si++) {
|
||||
const shieldX = gap + si * (shieldW + gap);
|
||||
const blocks = [];
|
||||
for (let r = 0; r < SHIELD_BLOCK_ROWS; r++) {
|
||||
for (let c = 0; c < SHIELD_BLOCK_COLS; c++) {
|
||||
if (!shieldMask[r][c])
|
||||
continue;
|
||||
const bx = shieldX + c * blockW;
|
||||
const by = shieldY + r * blockH;
|
||||
const gfx = this.add.graphics().setDepth(6);
|
||||
gfx.fillStyle(0x00ff66, 1);
|
||||
gfx.fillRect(0, 0, blockW, blockH);
|
||||
gfx.setPosition(bx, by);
|
||||
blocks.push({ gfx, x: bx, y: by, w: blockW, h: blockH, alive: true });
|
||||
}
|
||||
}
|
||||
this.shields.push(blocks);
|
||||
}
|
||||
}
|
||||
generateShieldMask() {
|
||||
const mask = [];
|
||||
for (let r = 0; r < SHIELD_BLOCK_ROWS; r++) {
|
||||
mask[r] = [];
|
||||
for (let c = 0; c < SHIELD_BLOCK_COLS; c++) {
|
||||
// Round top
|
||||
if (r < 4) {
|
||||
const center = SHIELD_BLOCK_COLS / 2;
|
||||
const dist = Math.abs(c - center + 0.5);
|
||||
const maxDist = (SHIELD_BLOCK_COLS / 2) * (1 - r * 0.05);
|
||||
mask[r][c] = dist < maxDist;
|
||||
}
|
||||
// Middle — solid
|
||||
else if (r < SHIELD_BLOCK_ROWS - 5) {
|
||||
mask[r][c] = true;
|
||||
}
|
||||
// Bottom — cut out arch
|
||||
else {
|
||||
const center = SHIELD_BLOCK_COLS / 2;
|
||||
const dist = Math.abs(c - center + 0.5);
|
||||
const archRow = r - (SHIELD_BLOCK_ROWS - 5);
|
||||
const archWidth = 3 + archRow * 0.8;
|
||||
mask[r][c] = dist > archWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
/* ================================================================
|
||||
COLLISIONS
|
||||
================================================================ */
|
||||
checkCollisions() {
|
||||
// Player bullets vs aliens
|
||||
for (let bi = this.playerBullets.length - 1; bi >= 0; bi--) {
|
||||
const b = this.playerBullets[bi];
|
||||
let hit = false;
|
||||
for (const a of this.aliens) {
|
||||
if (!a.alive)
|
||||
continue;
|
||||
if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, a.x - this.alienW / 2, a.y - this.alienH / 2, this.alienW, this.alienH)) {
|
||||
a.alive = false;
|
||||
a.gfx.setVisible(false);
|
||||
this.addScore(ALIEN_TYPES[a.type].points, a.x, a.y);
|
||||
this.spawnExplosion(a.x, a.y, ALIEN_TYPES[a.type].color);
|
||||
this.sound.play('ao_explosion', { volume: 0.25 });
|
||||
this.updateMarchInterval();
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Player bullets vs mystery
|
||||
if (!hit && this.mystery && this.mystery.active) {
|
||||
const mw = 30 * Math.max(SCALE, 0.5);
|
||||
const mh = 12 * Math.max(SCALE, 0.5);
|
||||
if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, this.mystery.x - mw, this.mystery.y - mh / 2, mw * 2, mh)) {
|
||||
const mysteryPoints = [50, 100, 150, 300][Math.floor(Math.random() * 4)];
|
||||
this.addScore(mysteryPoints, this.mystery.x, this.mystery.y);
|
||||
this.spawnExplosion(this.mystery.x, this.mystery.y, 0xff00ff);
|
||||
this.sound.play('ao_explosion', { volume: 0.3 });
|
||||
this.mystery.gfx.destroy();
|
||||
this.mystery = null;
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
// Player bullets vs shields
|
||||
if (!hit) {
|
||||
for (const shield of this.shields) {
|
||||
for (const block of shield) {
|
||||
if (!block.alive)
|
||||
continue;
|
||||
if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, block.x, block.y, block.w, block.h)) {
|
||||
block.alive = false;
|
||||
block.gfx.destroy();
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hit)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hit) {
|
||||
b.gfx.destroy();
|
||||
this.playerBullets.splice(bi, 1);
|
||||
}
|
||||
}
|
||||
// Alien bullets vs player
|
||||
if (this.playerAlive && this.invincibleTimer <= 0) {
|
||||
for (let bi = this.alienBullets.length - 1; bi >= 0; bi--) {
|
||||
const b = this.alienBullets[bi];
|
||||
if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, this.playerX - this.playerW / 2, this.playerY - this.playerH / 2, this.playerW, this.playerH)) {
|
||||
b.gfx.destroy();
|
||||
this.alienBullets.splice(bi, 1);
|
||||
this.playerHit();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Alien bullets vs shields
|
||||
for (let bi = this.alienBullets.length - 1; bi >= 0; bi--) {
|
||||
const b = this.alienBullets[bi];
|
||||
let hit = false;
|
||||
for (const shield of this.shields) {
|
||||
for (const block of shield) {
|
||||
if (!block.alive)
|
||||
continue;
|
||||
if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, block.x, block.y, block.w, block.h)) {
|
||||
block.alive = false;
|
||||
block.gfx.destroy();
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hit)
|
||||
break;
|
||||
}
|
||||
if (hit) {
|
||||
b.gfx.destroy();
|
||||
this.alienBullets.splice(bi, 1);
|
||||
}
|
||||
}
|
||||
// Aliens vs shields (aliens marching into shields)
|
||||
for (const a of this.aliens) {
|
||||
if (!a.alive)
|
||||
continue;
|
||||
for (const shield of this.shields) {
|
||||
for (const block of shield) {
|
||||
if (!block.alive)
|
||||
continue;
|
||||
if (this.rectOverlap(a.x - this.alienW / 2, a.y - this.alienH / 2, this.alienW, this.alienH, block.x, block.y, block.w, block.h)) {
|
||||
block.alive = false;
|
||||
block.gfx.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rectOverlap(x1, y1, w1, h1, x2, y2, w2, h2) {
|
||||
return x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2;
|
||||
}
|
||||
playerHit() {
|
||||
if (this.gameOverFlag)
|
||||
return;
|
||||
this.lives--;
|
||||
this.syncLivesToHUD();
|
||||
this.sound.play('ao_lose', { volume: 0.4 });
|
||||
this.spawnExplosion(this.playerX, this.playerY, 0x00ff66);
|
||||
if (this.lives <= 0) {
|
||||
this.triggerGameOver();
|
||||
}
|
||||
else {
|
||||
this.playerAlive = false;
|
||||
this.playerGfx.setVisible(false);
|
||||
this.respawnTimer = 1200;
|
||||
}
|
||||
}
|
||||
triggerGameOver() {
|
||||
this.gameOverFlag = true;
|
||||
this.playerAlive = false;
|
||||
this.playerGfx.setVisible(false);
|
||||
// Clear all bullets
|
||||
for (const b of this.playerBullets)
|
||||
b.gfx.destroy();
|
||||
this.playerBullets = [];
|
||||
for (const b of this.alienBullets)
|
||||
b.gfx.destroy();
|
||||
this.alienBullets = [];
|
||||
this.showGameOver(this.score, () => {
|
||||
this.gameOverFlag = false;
|
||||
this.scene.restart();
|
||||
});
|
||||
}
|
||||
/* ================================================================
|
||||
EFFECTS
|
||||
================================================================ */
|
||||
spawnExplosion(x, y, color) {
|
||||
this.spawnParticleExplosion(x, y, color, 10);
|
||||
}
|
||||
/* ================================================================
|
||||
SHUTDOWN
|
||||
================================================================ */
|
||||
shutdown() {
|
||||
super.shutdown();
|
||||
// Clean up transient DOM
|
||||
const banner = document.getElementById('wave-banner');
|
||||
if (banner)
|
||||
banner.remove();
|
||||
// Clean up graphics
|
||||
for (const a of this.aliens)
|
||||
a.gfx?.destroy();
|
||||
for (const b of this.playerBullets)
|
||||
b.gfx?.destroy();
|
||||
for (const b of this.alienBullets)
|
||||
b.gfx?.destroy();
|
||||
if (this.mystery)
|
||||
this.mystery.gfx?.destroy();
|
||||
for (const shield of this.shields) {
|
||||
for (const block of shield)
|
||||
block.gfx?.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=AlienOnslaught.js.map
|
||||
@@ -0,0 +1,785 @@
|
||||
// BaseScene — shared contract for all Agent Arcade mini-games.
|
||||
// Provides score bridge to the HTML HUD, pause/resume hooks, and
|
||||
// a consistent lifecycle so the game bootstrap can swap scenes.
|
||||
export let W = window.innerWidth;
|
||||
export let H = window.innerHeight;
|
||||
/** Call before creating the Phaser game to ensure dimensions are current. */
|
||||
export function refreshDimensions() {
|
||||
W = window.innerWidth;
|
||||
H = window.innerHeight;
|
||||
}
|
||||
export class BaseScene extends Phaser.Scene {
|
||||
score = 0;
|
||||
highScore = 0;
|
||||
lives = 3;
|
||||
level = 0;
|
||||
scoreAnimTimer;
|
||||
gameOverKeyListener;
|
||||
/** Full-screen dark backdrop controlled by the transparency slider. */
|
||||
_backdrop = null;
|
||||
/** Ready-screen state */
|
||||
_readyOverlay = null;
|
||||
_readyKeyListener;
|
||||
_readyOnStart;
|
||||
_wasOnReadyScreen = false;
|
||||
/** Timer for game-over delayed callback (cancel on shutdown to prevent leaks). */
|
||||
_gameOverDelayTimer = null;
|
||||
/** Tracked particle emitters for cleanup on shutdown. */
|
||||
activeEmitters = [];
|
||||
constructor(key) {
|
||||
super(key);
|
||||
}
|
||||
/** Safe localStorage helpers */
|
||||
storageGet(key) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
storageSet(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
catch { /* quota exceeded or disabled */ }
|
||||
}
|
||||
storageRemove(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
/** Safely destroy a Phaser game object and return null for assignment. */
|
||||
destroyObj(obj) {
|
||||
if (obj) {
|
||||
try {
|
||||
obj.destroy();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** Spawn a particle explosion, track the emitter, and auto-cleanup. */
|
||||
spawnParticleExplosion(x, y, color, count, lifespan = 400) {
|
||||
try {
|
||||
const emitter = this.add.particles(x, y, 'spark', {
|
||||
speed: { min: 60, max: 180 },
|
||||
angle: { min: 0, max: 360 },
|
||||
scale: { start: 1.2, end: 0 },
|
||||
lifespan,
|
||||
quantity: count,
|
||||
tint: color,
|
||||
emitting: false,
|
||||
});
|
||||
emitter.setDepth(20);
|
||||
emitter.explode(count);
|
||||
this.activeEmitters.push(emitter);
|
||||
this.time.delayedCall(lifespan + 100, () => {
|
||||
const idx = this.activeEmitters.indexOf(emitter);
|
||||
if (idx >= 0)
|
||||
this.activeEmitters.splice(idx, 1);
|
||||
emitter.destroy();
|
||||
});
|
||||
}
|
||||
catch {
|
||||
// Particle system unavailable, skip
|
||||
}
|
||||
}
|
||||
/** Load high score for this scene from localStorage. */
|
||||
loadHighScore() {
|
||||
// Clean up old agentBreak keys (from before rename)
|
||||
this.storageRemove(`agentBreak_board_${this.scene.key}`);
|
||||
this.storageRemove(`agentBreak_hi_${this.scene.key}`);
|
||||
const stored = this.storageGet(`agentArcade_hi_${this.scene.key}`);
|
||||
this.highScore = stored ? parseInt(stored, 10) || 0 : 0;
|
||||
this.gameOverShown = false;
|
||||
this.syncHighScoreToHUD();
|
||||
}
|
||||
/**
|
||||
* Common create() setup. Call at the start of every scene's create().
|
||||
* Registers pause bridge, shutdown listener, and resets shared state.
|
||||
*/
|
||||
initBase() {
|
||||
this.setupPauseBridge();
|
||||
this.events.once('shutdown', () => this.shutdown());
|
||||
this.createBackdrop();
|
||||
}
|
||||
/** Create a full-screen dark backdrop whose alpha is controlled by the settings slider. */
|
||||
createBackdrop() {
|
||||
const g = this.add.graphics().setDepth(-100);
|
||||
g.fillStyle(0x000000, 1);
|
||||
g.fillRect(0, 0, W, H);
|
||||
g.setScrollFactor(0);
|
||||
// Read saved transparency (1–100 → alpha 0.01–1.0)
|
||||
let alpha = 1;
|
||||
try {
|
||||
const saved = localStorage.getItem('agentArcade_bgTransparency');
|
||||
if (saved !== null)
|
||||
alpha = Math.max(0.01, Math.min(1, parseInt(saved, 10) / 100));
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
g.setAlpha(alpha);
|
||||
this._backdrop = g;
|
||||
}
|
||||
/** Called by the HUD slider to update the backdrop opacity in real time. */
|
||||
setBackdropAlpha(percent) {
|
||||
if (this._backdrop) {
|
||||
this._backdrop.setAlpha(Math.max(0.01, Math.min(1, percent / 100)));
|
||||
}
|
||||
}
|
||||
/** Save high score if current score exceeds it. */
|
||||
checkHighScore() {
|
||||
if (this.score > this.highScore) {
|
||||
this.highScore = this.score;
|
||||
this.storageSet(`agentArcade_hi_${this.scene.key}`, String(this.highScore));
|
||||
this.syncHighScoreToHUD();
|
||||
}
|
||||
}
|
||||
/** Push current score into the HTML HUD element. */
|
||||
syncScoreToHUD() {
|
||||
const el = document.getElementById('score-value');
|
||||
if (el)
|
||||
el.textContent = String(this.score);
|
||||
}
|
||||
/** Push high score into the HTML HUD element. */
|
||||
syncHighScoreToHUD() {
|
||||
const el = document.getElementById('hi-value');
|
||||
if (el)
|
||||
el.textContent = String(this.highScore);
|
||||
}
|
||||
/** Push lives count into the HTML HUD element. */
|
||||
syncLivesToHUD() {
|
||||
const el = document.getElementById('lives-value');
|
||||
if (el)
|
||||
el.textContent = String(this.lives);
|
||||
}
|
||||
/** Push level/wave number into the HTML HUD element. */
|
||||
syncLevelToHUD(value) {
|
||||
const el = document.getElementById('level-value');
|
||||
if (el)
|
||||
el.textContent = String(value ?? this.level);
|
||||
}
|
||||
/** Animated score bump (count-up + pop class). */
|
||||
addScore(points, worldX, worldY) {
|
||||
const prev = this.score;
|
||||
this.score += points;
|
||||
// Floating "+N" text at world position
|
||||
if (worldX !== undefined && worldY !== undefined) {
|
||||
const txt = this.add.text(worldX, worldY, `+${points}`, {
|
||||
fontFamily: '"Press Start 2P", monospace',
|
||||
fontSize: '14px',
|
||||
color: '#ffff00',
|
||||
stroke: '#000',
|
||||
strokeThickness: 3,
|
||||
});
|
||||
txt.setOrigin(0.5, 0.5).setDepth(900);
|
||||
this.tweens.add({
|
||||
targets: txt,
|
||||
y: worldY - 50,
|
||||
alpha: 0,
|
||||
duration: 800,
|
||||
onComplete: () => txt.destroy(),
|
||||
});
|
||||
}
|
||||
// Count-up animation in HUD
|
||||
const el = document.getElementById('score-value');
|
||||
if (!el)
|
||||
return;
|
||||
if (this.scoreAnimTimer)
|
||||
clearInterval(this.scoreAnimTimer);
|
||||
const start = prev;
|
||||
const end = this.score;
|
||||
const duration = 450;
|
||||
const startTime = performance.now();
|
||||
this.scoreAnimTimer = window.setInterval(() => {
|
||||
const t = Math.min(1, (performance.now() - startTime) / duration);
|
||||
const ease = 1 - Math.pow(1 - t, 3);
|
||||
el.textContent = String(Math.round(start + (end - start) * ease));
|
||||
if (t >= 1) {
|
||||
clearInterval(this.scoreAnimTimer);
|
||||
this.scoreAnimTimer = undefined;
|
||||
el.classList.remove('pop');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('pop');
|
||||
}
|
||||
}, 16);
|
||||
this.checkHighScore();
|
||||
}
|
||||
/** Get top 10 scores for this game from localStorage. */
|
||||
getLeaderboard() {
|
||||
const stored = this.storageGet(`agentArcade_board_${this.scene.key}`);
|
||||
if (!stored)
|
||||
return [];
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (!Array.isArray(parsed))
|
||||
return [];
|
||||
return parsed.filter((n) => typeof n === 'number');
|
||||
}
|
||||
catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
/** Add a score to the leaderboard, keep top 10, return rank (1-based, 0 = not in top 10). */
|
||||
addToLeaderboard(score) {
|
||||
if (score <= 0)
|
||||
return 0;
|
||||
const board = this.getLeaderboard();
|
||||
board.push(score);
|
||||
board.sort((a, b) => b - a);
|
||||
const trimmed = board.slice(0, 10);
|
||||
this.storageSet(`agentArcade_board_${this.scene.key}`, JSON.stringify(trimmed));
|
||||
this.checkHighScore();
|
||||
const rank = trimmed.indexOf(score) + 1;
|
||||
return rank <= 10 ? rank : 0;
|
||||
}
|
||||
gameOverShown = false;
|
||||
/** Show game over overlay with leaderboard. Call restartFn when dismissed. */
|
||||
showGameOver(finalScore, restartFn) {
|
||||
if (this.gameOverShown)
|
||||
return;
|
||||
this.gameOverShown = true;
|
||||
const rank = this.addToLeaderboard(finalScore);
|
||||
let board = this.getLeaderboard();
|
||||
// Reconcile: if stored high score isn't on the board, add it
|
||||
if (this.highScore > 0 && (board.length === 0 || this.highScore > board[0])) {
|
||||
board.push(this.highScore);
|
||||
board.sort((a, b) => b - a);
|
||||
board = board.slice(0, 10);
|
||||
this.storageSet(`agentArcade_board_${this.scene.key}`, JSON.stringify(board));
|
||||
}
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'gameover-overlay';
|
||||
overlay.style.cssText = `
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.75); pointer-events: auto;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
`;
|
||||
const modal = document.createElement('div');
|
||||
modal.style.cssText = `
|
||||
background: linear-gradient(145deg, #0d1b2a 0%, #1b2838 50%, #0d1b2a 100%);
|
||||
border: 2px solid rgba(255,215,0,0.4);
|
||||
border-radius: 20px; padding: 36px 48px;
|
||||
text-align: center; min-width: 460px; max-width: 540px;
|
||||
box-shadow: 0 0 60px rgba(255,215,0,0.15), 0 0 100px rgba(0,0,0,0.8), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
font-family: 'Press Start 2P', 'SF Mono', monospace;
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
`;
|
||||
// Title
|
||||
const title = document.createElement('h2');
|
||||
title.textContent = 'GAME OVER';
|
||||
title.style.cssText = `
|
||||
color: #ff4444; font-size: 28px; margin: 0 0 20px;
|
||||
text-shadow: 0 0 20px rgba(255,68,68,0.6), 0 0 40px rgba(255,0,0,0.3);
|
||||
letter-spacing: 4px;
|
||||
`;
|
||||
modal.appendChild(title);
|
||||
// Divider
|
||||
const div1 = document.createElement('div');
|
||||
div1.style.cssText = 'height: 1px; background: linear-gradient(90deg, transparent, rgba(255,215,0,0.3), transparent); margin: 0 0 20px;';
|
||||
modal.appendChild(div1);
|
||||
// Score
|
||||
const scoreLine = document.createElement('p');
|
||||
scoreLine.innerHTML = `YOUR SCORE<br><span style="font-size:28px; color:#ffeb3b; text-shadow: 0 0 12px rgba(255,235,59,0.5);">${finalScore.toLocaleString()}</span>`;
|
||||
scoreLine.style.cssText = 'color: #8899aa; font-size: 10px; margin: 0 0 12px; letter-spacing: 2px; line-height: 2.2;';
|
||||
modal.appendChild(scoreLine);
|
||||
// Rank badge
|
||||
if (rank === 1) {
|
||||
const badge = document.createElement('div');
|
||||
badge.innerHTML = '🏆 NEW HIGH SCORE!';
|
||||
badge.style.cssText = `
|
||||
color: #ffd700; font-size: 13px; margin: 8px 0 16px;
|
||||
padding: 8px 16px; border-radius: 8px;
|
||||
background: rgba(255,215,0,0.1); border: 1px solid rgba(255,215,0,0.3);
|
||||
display: inline-block;
|
||||
text-shadow: 0 0 8px rgba(255,215,0,0.4);
|
||||
`;
|
||||
modal.appendChild(badge);
|
||||
}
|
||||
else if (rank > 0) {
|
||||
const badge = document.createElement('div');
|
||||
badge.textContent = `#${rank} ON LEADERBOARD`;
|
||||
badge.style.cssText = `
|
||||
color: #4fc3f7; font-size: 11px; margin: 8px 0 16px;
|
||||
padding: 6px 14px; border-radius: 8px;
|
||||
background: rgba(79,195,247,0.1); border: 1px solid rgba(79,195,247,0.2);
|
||||
display: inline-block;
|
||||
`;
|
||||
modal.appendChild(badge);
|
||||
}
|
||||
else {
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height: 12px;';
|
||||
modal.appendChild(spacer);
|
||||
}
|
||||
// Divider
|
||||
const div2 = document.createElement('div');
|
||||
div2.style.cssText = 'height: 1px; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent); margin: 12px 0 16px;';
|
||||
modal.appendChild(div2);
|
||||
// Leaderboard header
|
||||
const boardTitle = document.createElement('p');
|
||||
boardTitle.textContent = '─── TOP 10 ───';
|
||||
boardTitle.style.cssText = 'color: #667; font-size: 9px; margin: 0 0 10px; letter-spacing: 3px;';
|
||||
modal.appendChild(boardTitle);
|
||||
// Score list
|
||||
const table = document.createElement('div');
|
||||
table.style.cssText = 'margin: 0 auto; display: inline-block; width: 100%;';
|
||||
board.forEach((s, i) => {
|
||||
const isMe = (i === rank - 1);
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = `
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 16px; padding: 8px 16px; margin: 3px 0;
|
||||
border-radius: 8px;
|
||||
background: ${isMe ? 'rgba(255,235,59,0.12)' : (i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent')};
|
||||
${isMe ? 'border: 1px solid rgba(255,235,59,0.25);' : ''}
|
||||
`;
|
||||
const rankEl = document.createElement('span');
|
||||
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
|
||||
rankEl.textContent = medal;
|
||||
rankEl.style.cssText = `
|
||||
color: ${isMe ? '#ffeb3b' : '#778'};
|
||||
min-width: 42px; text-align: left;
|
||||
font-size: ${i < 3 ? '20px' : '16px'};
|
||||
`;
|
||||
const scoreEl = document.createElement('span');
|
||||
scoreEl.textContent = s.toLocaleString();
|
||||
scoreEl.style.cssText = `
|
||||
color: ${isMe ? '#ffeb3b' : '#bcc'};
|
||||
font-size: ${i < 3 ? '20px' : '16px'};
|
||||
font-weight: ${i < 3 ? '900' : '700'};
|
||||
${isMe ? 'text-shadow: 0 0 10px rgba(255,235,59,0.5);' : ''}
|
||||
`;
|
||||
if (isMe) {
|
||||
const youTag = document.createElement('span');
|
||||
youTag.textContent = '◄';
|
||||
youTag.style.cssText = 'color: #ffeb3b; font-size: 10px; margin-left: 6px;';
|
||||
scoreEl.appendChild(youTag);
|
||||
}
|
||||
row.appendChild(rankEl);
|
||||
row.appendChild(scoreEl);
|
||||
table.appendChild(row);
|
||||
});
|
||||
// Fill empty slots
|
||||
for (let i = board.length; i < 10; i++) {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = `
|
||||
display: flex; justify-content: space-between;
|
||||
font-size: 16px; padding: 8px 16px; margin: 3px 0;
|
||||
color: #334;
|
||||
`;
|
||||
row.innerHTML = `<span>${i + 1}.</span><span>---</span>`;
|
||||
table.appendChild(row);
|
||||
}
|
||||
modal.appendChild(table);
|
||||
// Restart button — matches .help-close style from settings/help dialogs
|
||||
const restartBtn = document.createElement('button');
|
||||
restartBtn.textContent = 'RESTART';
|
||||
restartBtn.style.cssText = `
|
||||
display: block; margin: 22px auto 0; width: 100%; padding: 9px;
|
||||
background: linear-gradient(180deg, #ffd54a 0%, #c9a020 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 8px;
|
||||
color: #1a1a1a; font-weight: 700; letter-spacing: 1px; font-size: 13px;
|
||||
cursor: pointer; transition: filter 120ms;
|
||||
`;
|
||||
restartBtn.addEventListener('mouseenter', () => { restartBtn.style.filter = 'brightness(1.15)'; });
|
||||
restartBtn.addEventListener('mouseleave', () => { restartBtn.style.filter = ''; });
|
||||
modal.appendChild(restartBtn);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
// Disable click-through so the overlay is interactive
|
||||
const ti = window.__TAURI_INTERNALS__;
|
||||
if (ti)
|
||||
ti.invoke('set_click_through', { enabled: false });
|
||||
const dismiss = () => {
|
||||
this.gameOverShown = false;
|
||||
document.removeEventListener('keydown', onKey);
|
||||
overlay.remove();
|
||||
// Re-enable click-through
|
||||
if (ti)
|
||||
ti.invoke('set_click_through', { enabled: true });
|
||||
restartFn();
|
||||
};
|
||||
const onKey = (ev) => {
|
||||
if (ev.code === 'Space' || ev.code === 'Enter') {
|
||||
ev.preventDefault();
|
||||
dismiss();
|
||||
}
|
||||
};
|
||||
this.gameOverKeyListener = onKey;
|
||||
// Brief delay before accepting input (prevent accidental dismiss).
|
||||
// Guard against the scene being stopped during the delay.
|
||||
this._gameOverDelayTimer = this.time.delayedCall(500, () => {
|
||||
if (!this.scene.isActive())
|
||||
return;
|
||||
document.addEventListener('keydown', onKey);
|
||||
restartBtn.addEventListener('click', dismiss);
|
||||
});
|
||||
}
|
||||
// ── Ready screen ───────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Freeze the scene and show the "Press any key to start" screen.
|
||||
* Call as the LAST statement in every scene's create() so all game objects
|
||||
* exist but nothing moves until the player is ready.
|
||||
* @param onStart Optional callback invoked the moment the player presses a
|
||||
* key and the scene resumes — use this to defer first-wave setup so it
|
||||
* doesn't render on top of the ready screen.
|
||||
*/
|
||||
startWithReadyScreen(onStart) {
|
||||
this._readyOnStart = onStart;
|
||||
this.scene.pause();
|
||||
this.sound.stopAll(); // stop any sounds that fired during create()
|
||||
this._showPressAnyKey();
|
||||
}
|
||||
_showPressAnyKey() {
|
||||
this._cleanupReadyScreen();
|
||||
if (!document.getElementById('ready-screen-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ready-screen-style';
|
||||
style.textContent = `
|
||||
@keyframes readyBlink { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
@keyframes readyGlow { 0%,100%{text-shadow:0 0 10px rgba(0,200,255,0.6),0 0 30px rgba(0,200,255,0.3)} 50%{text-shadow:0 0 20px rgba(0,200,255,0.9),0 0 50px rgba(0,200,255,0.5),0 0 80px rgba(0,100,255,0.2)} }
|
||||
@keyframes titleShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
|
||||
@keyframes titleFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
|
||||
@keyframes neonPulse { 0%,100%{filter:drop-shadow(0 0 15px rgba(0,255,136,0.8)) drop-shadow(0 0 40px rgba(0,255,136,0.4)) drop-shadow(0 0 80px rgba(0,255,136,0.2))} 50%{filter:drop-shadow(0 0 25px rgba(0,255,136,1)) drop-shadow(0 0 60px rgba(0,255,136,0.6)) drop-shadow(0 0 120px rgba(0,255,136,0.3))} }
|
||||
@keyframes dividerPulse { 0%,100%{opacity:0.6;width:280px} 50%{opacity:1;width:360px} }
|
||||
@keyframes fadeSlideUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
|
||||
@keyframes starTwinkle { 0%,100%{opacity:0.2} 50%{opacity:1} }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'ready-overlay';
|
||||
overlay.style.cssText = `
|
||||
position:fixed;inset:0;z-index:8000;pointer-events:none;
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
background:radial-gradient(ellipse at 50% 40%,rgba(0,15,60,0.80) 0%,rgba(0,5,20,0.92) 60%,rgba(0,0,0,0.95) 100%);
|
||||
`;
|
||||
// Decorative star particles
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const star = document.createElement('div');
|
||||
const size = Math.random() < 0.3 ? 3 : 2;
|
||||
const x = Math.random() * 100;
|
||||
const y = Math.random() * 100;
|
||||
const delay = Math.random() * 3;
|
||||
const dur = 1.5 + Math.random() * 2;
|
||||
star.style.cssText = `
|
||||
position:absolute;left:${x}%;top:${y}%;width:${size}px;height:${size}px;
|
||||
background:#fff;border-radius:50%;
|
||||
animation:starTwinkle ${dur}s ease-in-out ${delay}s infinite;
|
||||
opacity:0.3;
|
||||
`;
|
||||
overlay.appendChild(star);
|
||||
}
|
||||
// Main content wrapper — styled panel matching the game-over dialog
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `
|
||||
display:flex;flex-direction:column;align-items:center;
|
||||
animation:fadeSlideUp 0.6s ease-out both;
|
||||
position:relative;z-index:1;
|
||||
background:linear-gradient(145deg,#0d1b2a 0%,#1b2838 50%,#0d1b2a 100%);
|
||||
border:2px solid rgba(0,200,255,0.25);
|
||||
border-radius:20px;padding:42px 56px;
|
||||
box-shadow:0 0 60px rgba(0,200,255,0.1),0 0 100px rgba(0,0,0,0.8),inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
max-width:700px;
|
||||
`;
|
||||
const title = document.createElement('div');
|
||||
title.textContent = this.displayName.toUpperCase();
|
||||
title.style.cssText = `
|
||||
font-family:'Press Start 2P',monospace;font-size:48px;letter-spacing:6px;
|
||||
-webkit-text-stroke:2px rgba(0,255,136,0.3);
|
||||
background:linear-gradient(90deg,#00ff88,#ffffff,#00ff88,#ffffff,#00ff88);
|
||||
background-size:200% auto;
|
||||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
|
||||
background-clip:text;
|
||||
animation:titleShimmer 8s linear infinite,titleFloat 4s ease-in-out infinite,neonPulse 3s ease-in-out infinite;
|
||||
margin-bottom:22px;
|
||||
`;
|
||||
const divider = document.createElement('div');
|
||||
divider.style.cssText = `
|
||||
width:320px;height:2px;margin-bottom:20px;
|
||||
background:linear-gradient(90deg,transparent 0%,#00c8ff 20%,#ff6b35 50%,#00c8ff 80%,transparent 100%);
|
||||
border-radius:1px;box-shadow:0 0 12px rgba(0,200,255,0.4);
|
||||
animation:dividerPulse 3s ease-in-out infinite;
|
||||
`;
|
||||
const prompt = document.createElement('div');
|
||||
prompt.textContent = 'PRESS ANY KEY TO START';
|
||||
prompt.style.cssText = `
|
||||
font-family:'Press Start 2P',monospace;font-size:16px;letter-spacing:4px;
|
||||
color:#fff;
|
||||
animation:readyBlink 1.4s ease-in-out infinite,readyGlow 2s ease-in-out infinite;
|
||||
text-shadow:0 0 15px rgba(0,200,255,0.8);
|
||||
`;
|
||||
content.appendChild(title);
|
||||
const desc = this.getDescription();
|
||||
if (desc) {
|
||||
const descEl = document.createElement('div');
|
||||
descEl.textContent = desc;
|
||||
descEl.style.cssText = `
|
||||
font-family:'Press Start 2P',monospace;font-size:14px;letter-spacing:1px;
|
||||
color:#d0e8ff;max-width:700px;text-align:center;line-height:2;
|
||||
margin-bottom:18px;
|
||||
text-shadow:0 0 10px rgba(150,210,255,0.4);
|
||||
`;
|
||||
content.appendChild(descEl);
|
||||
}
|
||||
content.appendChild(divider);
|
||||
// Show control hints if the scene provides them
|
||||
const controls = this.getControls();
|
||||
if (controls.length > 0) {
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.style.cssText = `
|
||||
margin-top:24px;padding:18px 28px;
|
||||
background:linear-gradient(135deg,rgba(0,20,60,0.6) 0%,rgba(0,10,40,0.7) 100%);
|
||||
border:1px solid rgba(0,200,255,0.2);
|
||||
border-radius:12px;display:inline-block;
|
||||
box-shadow:0 4px 20px rgba(0,0,0,0.3),inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
backdrop-filter:blur(4px);
|
||||
`;
|
||||
const controlsTitle = document.createElement('div');
|
||||
controlsTitle.textContent = 'CONTROLS';
|
||||
controlsTitle.style.cssText = `
|
||||
font-family:'Press Start 2P',monospace;font-size:13px;letter-spacing:5px;
|
||||
color:rgba(200,230,255,0.9);margin-bottom:16px;text-align:center;
|
||||
text-shadow:0 0 8px rgba(150,200,255,0.4);
|
||||
`;
|
||||
controlsDiv.appendChild(controlsTitle);
|
||||
for (const { key, action } of controls) {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = `
|
||||
display:flex;justify-content:space-between;align-items:center;
|
||||
margin:8px 0;gap:28px;
|
||||
`;
|
||||
const keyEl = document.createElement('span');
|
||||
keyEl.textContent = key;
|
||||
keyEl.style.cssText = `
|
||||
font-family:'Press Start 2P',monospace;font-size:15px;
|
||||
color:#ffd54a;background:rgba(255,213,74,0.08);
|
||||
padding:7px 16px;border-radius:6px;border:1px solid rgba(255,213,74,0.25);
|
||||
min-width:90px;text-align:center;
|
||||
box-shadow:0 2px 6px rgba(0,0,0,0.2),inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
text-shadow:0 0 6px rgba(255,213,74,0.3);
|
||||
`;
|
||||
const actionEl = document.createElement('span');
|
||||
actionEl.textContent = action;
|
||||
actionEl.style.cssText = `
|
||||
font-family:'Press Start 2P',monospace;font-size:14px;
|
||||
color:#d0dde8;text-align:left;
|
||||
`;
|
||||
row.appendChild(keyEl);
|
||||
row.appendChild(actionEl);
|
||||
controlsDiv.appendChild(row);
|
||||
}
|
||||
content.appendChild(controlsDiv);
|
||||
}
|
||||
prompt.style.cssText += 'margin-top:32px;';
|
||||
content.appendChild(prompt);
|
||||
overlay.appendChild(content);
|
||||
document.body.appendChild(overlay);
|
||||
this._readyOverlay = overlay;
|
||||
const onKey = (e) => {
|
||||
if (['Meta', 'Alt', 'Control', 'Shift'].includes(e.key))
|
||||
return;
|
||||
document.removeEventListener('keydown', onKey);
|
||||
this._readyKeyListener = undefined;
|
||||
this._cleanupReadyScreen();
|
||||
if (e.key === 'Escape') {
|
||||
// Let the normal pause system take over; re-show ready screen on resume
|
||||
this._wasOnReadyScreen = true;
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
this.scene.resume();
|
||||
this._fireReadyOnStart();
|
||||
};
|
||||
this._readyKeyListener = onKey;
|
||||
document.addEventListener('keydown', onKey);
|
||||
}
|
||||
_cleanupReadyScreen() {
|
||||
if (this._readyOverlay) {
|
||||
this._readyOverlay.remove();
|
||||
this._readyOverlay = null;
|
||||
}
|
||||
if (this._readyKeyListener) {
|
||||
document.removeEventListener('keydown', this._readyKeyListener);
|
||||
this._readyKeyListener = undefined;
|
||||
}
|
||||
}
|
||||
_fireReadyOnStart() {
|
||||
if (this._readyOnStart) {
|
||||
const fn = this._readyOnStart;
|
||||
this._readyOnStart = undefined;
|
||||
fn();
|
||||
}
|
||||
}
|
||||
/** Called by the pause system. Override if the scene needs custom cleanup. */
|
||||
pauseGame() {
|
||||
this.scene.pause();
|
||||
this.sound.pauseAll();
|
||||
}
|
||||
/** Called by the resume system. Override if needed. */
|
||||
resumeGame() {
|
||||
if (this._wasOnReadyScreen) {
|
||||
// Re-show the ready screen instead of resuming gameplay
|
||||
this._wasOnReadyScreen = false;
|
||||
this._showPressAnyKey();
|
||||
return;
|
||||
}
|
||||
this.scene.resume();
|
||||
this.sound.resumeAll();
|
||||
this._fireReadyOnStart();
|
||||
}
|
||||
/**
|
||||
* Wire up the pause/resume bridge between the HUD and the Phaser scene.
|
||||
* Call from create() — replaces the per-scene boilerplate that was duplicated
|
||||
* in every scene previously.
|
||||
*/
|
||||
setupPauseBridge() {
|
||||
// __agentArcadePauseScene: pauses/resumes the Phaser scene ONLY (no Rust call).
|
||||
// Used by Rust-originated pause/resume to avoid feedback loops.
|
||||
window.__agentArcadePauseScene = (shouldPause) => {
|
||||
if (shouldPause)
|
||||
this.pauseGame();
|
||||
else
|
||||
this.resumeGame();
|
||||
};
|
||||
// __agentArcadePause: called from in-page UI (HUD buttons, game-switcher).
|
||||
// Pauses scene AND notifies Rust to shrink/expand window.
|
||||
window.__agentArcadePause = (shouldPause) => {
|
||||
const ab = window.agentArcade;
|
||||
if (shouldPause)
|
||||
this.pauseGame();
|
||||
else
|
||||
this.resumeGame();
|
||||
if (ab && ab.setClickThrough)
|
||||
ab.setClickThrough(shouldPause);
|
||||
if (ab && ab.setPaused)
|
||||
ab.setPaused(shouldPause);
|
||||
};
|
||||
const ab = window.agentArcade;
|
||||
if (ab && ab.onResumeRequest) {
|
||||
ab.onResumeRequest(() => {
|
||||
const hud = document.getElementById('hud');
|
||||
if (hud)
|
||||
hud.classList.remove('paused');
|
||||
this.resumeGame();
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Show a "WAVE N" banner overlay — shared by space game scenes.
|
||||
* Auto-animates in/out and removes itself after ~2.2 seconds.
|
||||
*/
|
||||
showWaveBanner(waveNum) {
|
||||
const existing = document.getElementById('wave-banner');
|
||||
if (existing)
|
||||
existing.remove();
|
||||
const banner = document.createElement('div');
|
||||
banner.id = 'wave-banner';
|
||||
banner.style.cssText = `
|
||||
position: fixed; top: 45%; left: 50%; transform: translate(-50%, -50%);
|
||||
padding: 12px 36px;
|
||||
background: linear-gradient(180deg, #1a1f3a 0%, #0a0e22 100%);
|
||||
border: 2px solid #ffd54a;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||
0 6px 24px rgba(0, 0, 0, 0.7),
|
||||
0 0 22px rgba(255, 213, 74, 0.45);
|
||||
font-family: -apple-system, system-ui, 'Helvetica Neue', sans-serif;
|
||||
font-size: 22px; font-weight: 700; letter-spacing: 2px;
|
||||
color: #ffd54a;
|
||||
text-shadow: 0 0 8px rgba(255, 213, 74, 0.6);
|
||||
z-index: 50; pointer-events: none; user-select: none;
|
||||
animation: waveBannerIn 0.3s ease-out;
|
||||
`;
|
||||
banner.textContent = `WAVE ${waveNum}`;
|
||||
document.body.appendChild(banner);
|
||||
if (!document.getElementById('wave-banner-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'wave-banner-style';
|
||||
style.textContent = `
|
||||
@keyframes waveBannerIn { from { opacity: 0; transform: translate(-50%, -50%) scale(0.85); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }
|
||||
@keyframes waveBannerOut { from { opacity: 1; } to { opacity: 0; } }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
setTimeout(() => {
|
||||
banner.style.animation = 'waveBannerOut 0.6s ease-in forwards';
|
||||
setTimeout(() => banner.remove(), 700);
|
||||
}, 1500);
|
||||
}
|
||||
/** Create the shared 'spark' texture used for particle effects. */
|
||||
ensureSparkTexture() {
|
||||
if (this.textures.exists('spark'))
|
||||
return;
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0xffffff);
|
||||
g.fillCircle(4, 4, 4);
|
||||
g.generateTexture('spark', 8, 8);
|
||||
g.destroy();
|
||||
}
|
||||
/**
|
||||
* Create a parallax starfield. Returns the Star array for use with updateStarfield().
|
||||
* Each scene provides its own layer config (count, speed, size, alpha per layer).
|
||||
*/
|
||||
createStarfield(layers) {
|
||||
const stars = [];
|
||||
for (const l of layers) {
|
||||
for (let i = 0; i < l.count; i++) {
|
||||
const gfx = this.add.graphics();
|
||||
const x = Math.random() * W;
|
||||
const y = Math.random() * H;
|
||||
gfx.fillStyle(0xffffff, l.alpha);
|
||||
gfx.fillCircle(0, 0, l.size);
|
||||
gfx.setPosition(x, y).setDepth(-9);
|
||||
stars.push({ x, y, speed: l.speed, size: l.size, alpha: l.alpha, gfx });
|
||||
}
|
||||
}
|
||||
return stars;
|
||||
}
|
||||
/** Update parallax starfield positions (call from update). */
|
||||
updateStarfield(stars, dt) {
|
||||
for (const s of stars) {
|
||||
s.y += s.speed * (dt / 1000);
|
||||
if (s.y > H)
|
||||
s.y -= H;
|
||||
s.gfx.setPosition(s.x, s.y);
|
||||
}
|
||||
}
|
||||
/** Clean up timers and listeners on scene shutdown. */
|
||||
shutdown() {
|
||||
if (this.scoreAnimTimer) {
|
||||
clearInterval(this.scoreAnimTimer);
|
||||
this.scoreAnimTimer = undefined;
|
||||
}
|
||||
if (this.gameOverKeyListener) {
|
||||
document.removeEventListener('keydown', this.gameOverKeyListener);
|
||||
this.gameOverKeyListener = undefined;
|
||||
}
|
||||
if (this._gameOverDelayTimer) {
|
||||
this._gameOverDelayTimer.remove();
|
||||
this._gameOverDelayTimer = null;
|
||||
}
|
||||
this._cleanupReadyScreen();
|
||||
this._readyOnStart = undefined;
|
||||
this._wasOnReadyScreen = false;
|
||||
this.time.removeAllEvents();
|
||||
this.activeEmitters.forEach(e => this.destroyObj(e));
|
||||
this.activeEmitters = [];
|
||||
const overlay = document.getElementById('gameover-overlay');
|
||||
if (overlay)
|
||||
overlay.remove();
|
||||
}
|
||||
/** Return a one-line description for the ready screen. Override in each scene. */
|
||||
getDescription() {
|
||||
return '';
|
||||
}
|
||||
/** Return control hints for the ready screen. Override in each scene. */
|
||||
getControls() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=BaseScene.js.map
|
||||
@@ -0,0 +1,697 @@
|
||||
// CosmicRocks — Asteroids-style space shooter.
|
||||
// Ship rotates and thrusts through space, destroying asteroids that split
|
||||
// into smaller fragments. Vector-style graphics drawn with Phaser Graphics.
|
||||
import { BaseScene, W, H } from './BaseScene.js';
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants — SCALE/SHIP_SIZE recalculated in create() */
|
||||
/* ------------------------------------------------------------------ */
|
||||
let SCALE = Math.min(W / 1920, H / 1080);
|
||||
let SHIP_SIZE = 20 * Math.max(SCALE, 0.6);
|
||||
const ROTATE_SPEED = 4; // rad/s
|
||||
const THRUST = 400; // px/s²
|
||||
const FRICTION = 0.98;
|
||||
const BULLET_SPEED = 600;
|
||||
const BULLET_LIFE = 3000; // ms
|
||||
const MAX_BULLETS = 4;
|
||||
const INITIAL_ASTEROIDS = 5;
|
||||
const INVINCIBLE_TIME = 2000; // ms
|
||||
const RESPAWN_DELAY = 800; // ms before respawn
|
||||
const ASTEROID_SIZES = [
|
||||
{ radius: [40, 60], speed: [40, 80], score: 20 }, // large (size index 0)
|
||||
{ radius: [25, 40], speed: [60, 120], score: 50 }, // medium (size index 1)
|
||||
{ radius: [12, 20], speed: [80, 160], score: 100 }, // small (size index 2)
|
||||
];
|
||||
const BULLET_COLORS = [0x00ff88, 0xff8800, 0x00ccff];
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Scene */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export class CosmicRocksScene extends BaseScene {
|
||||
/* ship state */
|
||||
shipGfx;
|
||||
shipX = 0;
|
||||
shipY = 0;
|
||||
shipVx = 0;
|
||||
shipVy = 0;
|
||||
shipAngle = -Math.PI / 2; // pointing up
|
||||
thrustGfx;
|
||||
/* game objects */
|
||||
asteroids = [];
|
||||
bullets = [];
|
||||
stars = [];
|
||||
/* UFO */
|
||||
ufo = null;
|
||||
ufoBullets = [];
|
||||
ufoTimer = 0;
|
||||
/* game state */
|
||||
wave = 0;
|
||||
invincibleTimer = 0;
|
||||
respawnTimer = 0;
|
||||
shipAlive = true;
|
||||
gameOver = false;
|
||||
waveDelay = 0;
|
||||
/* input */
|
||||
cursors;
|
||||
spaceKey;
|
||||
spaceWasDown = false;
|
||||
constructor() { super('cosmic-rocks'); }
|
||||
get displayName() { return 'Cosmic Rocks'; }
|
||||
getDescription() {
|
||||
return 'Survive the asteroid field. Shoot rocks to break them apart!';
|
||||
}
|
||||
getControls() {
|
||||
return [
|
||||
{ key: '← →', action: 'Rotate' },
|
||||
{ key: '↑', action: 'Thrust' },
|
||||
{ key: 'SPACE', action: 'Fire' },
|
||||
];
|
||||
}
|
||||
/* ================================================================
|
||||
LIFECYCLE
|
||||
================================================================ */
|
||||
preload() {
|
||||
this.load.audio('sfx_laser', '../assets/cosmic-rocks/sounds/sfx_laser1.ogg');
|
||||
this.load.audio('sfx_zap', '../assets/cosmic-rocks/sounds/sfx_explosion.ogg');
|
||||
this.load.audio('sfx_lose', '../assets/cosmic-rocks/sounds/sfx_lose.ogg');
|
||||
this.load.audio('sfx_twoTone', '../assets/cosmic-rocks/sounds/sfx_twoTone.ogg');
|
||||
}
|
||||
create() {
|
||||
this.initBase();
|
||||
// Recalculate screen-dependent constants
|
||||
SCALE = Math.min(W / 1920, H / 1080);
|
||||
SHIP_SIZE = 20 * Math.max(SCALE, 0.6);
|
||||
this.score = 0;
|
||||
this.lives = 3;
|
||||
this.wave = 0;
|
||||
this.shipX = W / 2;
|
||||
this.shipY = H / 2;
|
||||
this.shipVx = 0;
|
||||
this.shipVy = 0;
|
||||
this.shipAngle = -Math.PI / 2;
|
||||
this.invincibleTimer = 0;
|
||||
this.respawnTimer = 0;
|
||||
this.shipAlive = true;
|
||||
this.gameOver = false;
|
||||
this.waveDelay = 0;
|
||||
this.asteroids = [];
|
||||
this.bullets = [];
|
||||
this.stars = [];
|
||||
this.activeEmitters = [];
|
||||
this.ufo = null;
|
||||
this.ufoBullets = [];
|
||||
this.ufoTimer = 15000 + Math.random() * 10000;
|
||||
this.ensureSparkTexture();
|
||||
this.stars = this.createStarfield([
|
||||
{ count: 40, speed: 15, size: 1, alpha: 0.25 },
|
||||
{ count: 25, speed: 30, size: 1.5, alpha: 0.35 },
|
||||
{ count: 15, speed: 55, size: 2, alpha: 0.45 },
|
||||
]);
|
||||
this.createShip();
|
||||
this.cursors = this.input.keyboard.createCursorKeys();
|
||||
this.spaceKey = this.input.keyboard.addKey('SPACE');
|
||||
this.spaceWasDown = false;
|
||||
this.syncLivesToHUD();
|
||||
this.syncScoreToHUD();
|
||||
this.loadHighScore();
|
||||
this.startWithReadyScreen(() => this.startWave());
|
||||
}
|
||||
update(_t, dtMs) {
|
||||
if (this.gameOver || !this.cursors)
|
||||
return;
|
||||
const dt = Math.min(dtMs, 33);
|
||||
const dtSec = dt / 1000;
|
||||
this.updateStarfield(this.stars, dt);
|
||||
if (this.respawnTimer > 0) {
|
||||
this.respawnTimer -= dt;
|
||||
if (this.respawnTimer <= 0)
|
||||
this.respawnShip();
|
||||
}
|
||||
if (this.shipAlive) {
|
||||
this.updateShipInput(dtSec);
|
||||
this.updateShipPhysics(dtSec);
|
||||
this.drawShip();
|
||||
}
|
||||
this.updateBullets(dtSec);
|
||||
this.updateAsteroids(dtSec);
|
||||
this.updateUfo(dt, dtSec);
|
||||
this.checkCollisions();
|
||||
this.checkUfoCollisions();
|
||||
if (this.waveDelay > 0) {
|
||||
this.waveDelay -= dt;
|
||||
if (this.waveDelay <= 0 && this.asteroids.length === 0)
|
||||
this.startWave();
|
||||
}
|
||||
if (this.invincibleTimer > 0) {
|
||||
this.invincibleTimer -= dt;
|
||||
if (this.shipGfx) {
|
||||
this.shipGfx.setAlpha(Math.sin(performance.now() / 80) > 0 ? 1 : 0.2);
|
||||
}
|
||||
}
|
||||
else if (this.shipGfx) {
|
||||
this.shipGfx.setAlpha(1);
|
||||
}
|
||||
}
|
||||
/* ================================================================
|
||||
SHIP
|
||||
================================================================ */
|
||||
createShip() {
|
||||
this.shipGfx = this.add.graphics().setDepth(10);
|
||||
this.thrustGfx = this.add.graphics().setDepth(9);
|
||||
this.drawShip();
|
||||
}
|
||||
updateShipInput(dtSec) {
|
||||
if (!this.cursors)
|
||||
return;
|
||||
if (this.cursors.left.isDown)
|
||||
this.shipAngle -= ROTATE_SPEED * dtSec;
|
||||
if (this.cursors.right.isDown)
|
||||
this.shipAngle += ROTATE_SPEED * dtSec;
|
||||
if (this.cursors.up.isDown) {
|
||||
this.shipVx += Math.cos(this.shipAngle) * THRUST * dtSec;
|
||||
this.shipVy += Math.sin(this.shipAngle) * THRUST * dtSec;
|
||||
}
|
||||
// Fire
|
||||
const spaceDown = this.spaceKey.isDown;
|
||||
if (spaceDown && !this.spaceWasDown && this.bullets.length < MAX_BULLETS) {
|
||||
this.fireBullet();
|
||||
}
|
||||
this.spaceWasDown = spaceDown;
|
||||
}
|
||||
updateShipPhysics(dtSec) {
|
||||
// Friction (time-based)
|
||||
const friction = Math.pow(FRICTION, dtSec / (1 / 60));
|
||||
this.shipVx *= friction;
|
||||
this.shipVy *= friction;
|
||||
this.shipX += this.shipVx * dtSec;
|
||||
this.shipY += this.shipVy * dtSec;
|
||||
// Screen wrap
|
||||
if (this.shipX < -SHIP_SIZE)
|
||||
this.shipX = W + SHIP_SIZE;
|
||||
else if (this.shipX > W + SHIP_SIZE)
|
||||
this.shipX = -SHIP_SIZE;
|
||||
if (this.shipY < -SHIP_SIZE)
|
||||
this.shipY = H + SHIP_SIZE;
|
||||
else if (this.shipY > H + SHIP_SIZE)
|
||||
this.shipY = -SHIP_SIZE;
|
||||
}
|
||||
drawShip() {
|
||||
const g = this.shipGfx;
|
||||
g.clear();
|
||||
g.setPosition(this.shipX, this.shipY);
|
||||
const cos = Math.cos(this.shipAngle);
|
||||
const sin = Math.sin(this.shipAngle);
|
||||
const s = SHIP_SIZE;
|
||||
// Triangle ship
|
||||
const nose = { x: cos * s, y: sin * s };
|
||||
const leftWing = { x: Math.cos(this.shipAngle + 2.4) * s * 0.85, y: Math.sin(this.shipAngle + 2.4) * s * 0.85 };
|
||||
const rightWing = { x: Math.cos(this.shipAngle - 2.4) * s * 0.85, y: Math.sin(this.shipAngle - 2.4) * s * 0.85 };
|
||||
// Dark shadow backdrop for visibility on light backgrounds
|
||||
g.lineStyle(6, 0x000000, 0.5);
|
||||
g.beginPath();
|
||||
g.moveTo(nose.x, nose.y);
|
||||
g.lineTo(leftWing.x, leftWing.y);
|
||||
g.lineTo(rightWing.x, rightWing.y);
|
||||
g.closePath();
|
||||
g.strokePath();
|
||||
// Outer glow (soft cyan)
|
||||
g.lineStyle(4, 0x00ffff, 0.2);
|
||||
g.beginPath();
|
||||
g.moveTo(nose.x, nose.y);
|
||||
g.lineTo(leftWing.x, leftWing.y);
|
||||
g.lineTo(rightWing.x, rightWing.y);
|
||||
g.closePath();
|
||||
g.strokePath();
|
||||
// Solid ship outline (bright cyan)
|
||||
g.lineStyle(2.5, 0x00ffff, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(nose.x, nose.y);
|
||||
g.lineTo(leftWing.x, leftWing.y);
|
||||
g.lineTo(rightWing.x, rightWing.y);
|
||||
g.closePath();
|
||||
g.strokePath();
|
||||
// Thrust flame
|
||||
const tg = this.thrustGfx;
|
||||
tg.clear();
|
||||
if (this.cursors && this.cursors.up.isDown) {
|
||||
tg.setPosition(this.shipX, this.shipY);
|
||||
const tailLen = s * (0.6 + Math.random() * 0.4);
|
||||
const tailX = -cos * tailLen;
|
||||
const tailY = -sin * tailLen;
|
||||
const spread = 0.4;
|
||||
const tl = { x: Math.cos(this.shipAngle + Math.PI - spread) * s * 0.35, y: Math.sin(this.shipAngle + Math.PI - spread) * s * 0.35 };
|
||||
const tr = { x: Math.cos(this.shipAngle + Math.PI + spread) * s * 0.35, y: Math.sin(this.shipAngle + Math.PI + spread) * s * 0.35 };
|
||||
// Dark shadow for thrust
|
||||
tg.lineStyle(5, 0x000000, 0.3);
|
||||
tg.beginPath();
|
||||
tg.moveTo(tl.x, tl.y);
|
||||
tg.lineTo(tailX, tailY);
|
||||
tg.lineTo(tr.x, tr.y);
|
||||
tg.strokePath();
|
||||
tg.lineStyle(3, 0xff8800, 0.25);
|
||||
tg.beginPath();
|
||||
tg.moveTo(tl.x, tl.y);
|
||||
tg.lineTo(tailX, tailY);
|
||||
tg.lineTo(tr.x, tr.y);
|
||||
tg.strokePath();
|
||||
tg.lineStyle(2.5, 0xff8800, 0.9);
|
||||
tg.beginPath();
|
||||
tg.moveTo(tl.x, tl.y);
|
||||
tg.lineTo(tailX, tailY);
|
||||
tg.lineTo(tr.x, tr.y);
|
||||
tg.strokePath();
|
||||
}
|
||||
}
|
||||
respawnShip() {
|
||||
this.shipX = W / 2;
|
||||
this.shipY = H / 2;
|
||||
this.shipVx = 0;
|
||||
this.shipVy = 0;
|
||||
this.shipAngle = -Math.PI / 2;
|
||||
this.shipAlive = true;
|
||||
this.invincibleTimer = INVINCIBLE_TIME;
|
||||
if (this.shipGfx)
|
||||
this.shipGfx.setVisible(true);
|
||||
if (this.thrustGfx)
|
||||
this.thrustGfx.setVisible(true);
|
||||
}
|
||||
/* ================================================================
|
||||
BULLETS
|
||||
================================================================ */
|
||||
fireBullet() {
|
||||
this.sound.play('sfx_laser', { volume: 0.3 });
|
||||
const color = BULLET_COLORS[Math.floor(Math.random() * BULLET_COLORS.length)];
|
||||
const gfx = this.add.graphics().setDepth(8);
|
||||
// Dark backdrop
|
||||
gfx.fillStyle(0x000000, 0.5);
|
||||
gfx.fillCircle(0, 0, 10);
|
||||
// Glow
|
||||
gfx.fillStyle(color, 0.3);
|
||||
gfx.fillCircle(0, 0, 8);
|
||||
// Solid center
|
||||
gfx.fillStyle(color, 1);
|
||||
gfx.fillCircle(0, 0, 4);
|
||||
const bx = this.shipX + Math.cos(this.shipAngle) * SHIP_SIZE;
|
||||
const by = this.shipY + Math.sin(this.shipAngle) * SHIP_SIZE;
|
||||
gfx.setPosition(bx, by);
|
||||
this.bullets.push({
|
||||
gfx,
|
||||
x: bx, y: by,
|
||||
vx: Math.cos(this.shipAngle) * BULLET_SPEED,
|
||||
vy: Math.sin(this.shipAngle) * BULLET_SPEED,
|
||||
life: BULLET_LIFE,
|
||||
color,
|
||||
});
|
||||
}
|
||||
updateBullets(dtSec) {
|
||||
for (let i = this.bullets.length - 1; i >= 0; i--) {
|
||||
const b = this.bullets[i];
|
||||
b.x += b.vx * dtSec;
|
||||
b.y += b.vy * dtSec;
|
||||
b.life -= dtSec * 1000;
|
||||
b.gfx.setPosition(b.x, b.y);
|
||||
// Destroy bullet when it leaves the screen or expires
|
||||
if (b.life <= 0 || b.x < 0 || b.x > W || b.y < 0 || b.y > H) {
|
||||
b.gfx.destroy();
|
||||
this.bullets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* ================================================================
|
||||
ASTEROIDS
|
||||
================================================================ */
|
||||
generateAsteroidVertices(radius) {
|
||||
const verts = [];
|
||||
const sides = 12;
|
||||
for (let i = 0; i < sides; i++) {
|
||||
const angle = (i / sides) * Math.PI * 2;
|
||||
const r = radius * (0.7 + Math.random() * 0.3);
|
||||
verts.push({ x: Math.cos(angle) * r, y: Math.sin(angle) * r });
|
||||
}
|
||||
return verts;
|
||||
}
|
||||
spawnAsteroid(sizeIdx, x, y, aimAtShip = false) {
|
||||
const info = ASTEROID_SIZES[sizeIdx];
|
||||
const radius = info.radius[0] + Math.random() * (info.radius[1] - info.radius[0]);
|
||||
const scaledRadius = radius * Math.max(SCALE, 0.5);
|
||||
// Position: at edges if not specified
|
||||
let ax, ay;
|
||||
if (x !== undefined && y !== undefined) {
|
||||
ax = x;
|
||||
ay = y;
|
||||
}
|
||||
else {
|
||||
const edge = Math.floor(Math.random() * 4);
|
||||
if (edge === 0) {
|
||||
ax = Math.random() * W;
|
||||
ay = -scaledRadius;
|
||||
}
|
||||
else if (edge === 1) {
|
||||
ax = Math.random() * W;
|
||||
ay = H + scaledRadius;
|
||||
}
|
||||
else if (edge === 2) {
|
||||
ax = -scaledRadius;
|
||||
ay = Math.random() * H;
|
||||
}
|
||||
else {
|
||||
ax = W + scaledRadius;
|
||||
ay = Math.random() * H;
|
||||
}
|
||||
// Make sure not too close to player
|
||||
const dx = ax - this.shipX;
|
||||
const dy = ay - this.shipY;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < 150) {
|
||||
ax = (ax + W / 2) % W;
|
||||
ay = (ay + H / 2) % H;
|
||||
}
|
||||
}
|
||||
const speed = info.speed[0] + Math.random() * (info.speed[1] - info.speed[0]);
|
||||
const speedBoost = Math.random() < 0.4 ? 1.5 : 1.0; // 40% chance of fast asteroid
|
||||
// Aim toward the ship if requested, otherwise random direction
|
||||
let angle;
|
||||
let finalSpeed = speed * speedBoost;
|
||||
if (aimAtShip) {
|
||||
angle = Math.atan2(this.shipY - ay, this.shipX - ax);
|
||||
// Add slight random spread (±15°) so it's not a perfect snipe
|
||||
angle += (Math.random() - 0.5) * (Math.PI / 6);
|
||||
// Ensure it arrives in ~3-4s regardless of base speed
|
||||
const dist = Math.sqrt((this.shipX - ax) ** 2 + (this.shipY - ay) ** 2);
|
||||
const minSpeed = dist / (3 + Math.random());
|
||||
finalSpeed = Math.max(finalSpeed, minSpeed);
|
||||
}
|
||||
else {
|
||||
angle = Math.random() * Math.PI * 2;
|
||||
}
|
||||
const vertices = this.generateAsteroidVertices(scaledRadius);
|
||||
const gfx = this.add.graphics().setDepth(5);
|
||||
this.drawAsteroid(gfx, vertices);
|
||||
this.asteroids.push({
|
||||
gfx,
|
||||
x: ax, y: ay,
|
||||
vx: Math.cos(angle) * finalSpeed,
|
||||
vy: Math.sin(angle) * finalSpeed,
|
||||
radius: scaledRadius,
|
||||
sizeIdx,
|
||||
rotation: 0,
|
||||
rotSpeed: (Math.random() - 0.5) * 2,
|
||||
vertices,
|
||||
});
|
||||
}
|
||||
drawAsteroid(gfx, vertices) {
|
||||
gfx.clear();
|
||||
// Dark shadow backdrop for visibility on light backgrounds
|
||||
gfx.lineStyle(5, 0x000000, 0.5);
|
||||
gfx.beginPath();
|
||||
gfx.moveTo(vertices[0].x, vertices[0].y);
|
||||
for (let i = 1; i < vertices.length; i++) {
|
||||
gfx.lineTo(vertices[i].x, vertices[i].y);
|
||||
}
|
||||
gfx.closePath();
|
||||
gfx.strokePath();
|
||||
// Outer glow (soft green)
|
||||
gfx.lineStyle(3, 0x44ff44, 0.25);
|
||||
gfx.beginPath();
|
||||
gfx.moveTo(vertices[0].x, vertices[0].y);
|
||||
for (let i = 1; i < vertices.length; i++) {
|
||||
gfx.lineTo(vertices[i].x, vertices[i].y);
|
||||
}
|
||||
gfx.closePath();
|
||||
gfx.strokePath();
|
||||
// Solid outline (bright green-white)
|
||||
gfx.lineStyle(2.5, 0x88ff88, 1);
|
||||
gfx.beginPath();
|
||||
gfx.moveTo(vertices[0].x, vertices[0].y);
|
||||
for (let i = 1; i < vertices.length; i++) {
|
||||
gfx.lineTo(vertices[i].x, vertices[i].y);
|
||||
}
|
||||
gfx.closePath();
|
||||
gfx.strokePath();
|
||||
}
|
||||
updateAsteroids(dtSec) {
|
||||
for (const a of this.asteroids) {
|
||||
a.x += a.vx * dtSec;
|
||||
a.y += a.vy * dtSec;
|
||||
a.rotation += a.rotSpeed * dtSec;
|
||||
// Screen wrap
|
||||
if (a.x < -a.radius)
|
||||
a.x = W + a.radius;
|
||||
else if (a.x > W + a.radius)
|
||||
a.x = -a.radius;
|
||||
if (a.y < -a.radius)
|
||||
a.y = H + a.radius;
|
||||
else if (a.y > H + a.radius)
|
||||
a.y = -a.radius;
|
||||
a.gfx.setPosition(a.x, a.y);
|
||||
a.gfx.setRotation(a.rotation);
|
||||
}
|
||||
}
|
||||
destroyAsteroid(idx) {
|
||||
const a = this.asteroids[idx];
|
||||
const info = ASTEROID_SIZES[a.sizeIdx];
|
||||
this.addScore(info.score, a.x, a.y - 10);
|
||||
this.spawnExplosion(a.x, a.y);
|
||||
this.sound.play('sfx_zap', { volume: 0.3 });
|
||||
// Spawn children
|
||||
if (a.sizeIdx < 2) {
|
||||
const childSize = a.sizeIdx + 1;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
this.spawnAsteroid(childSize, a.x, a.y);
|
||||
}
|
||||
}
|
||||
a.gfx.destroy();
|
||||
this.asteroids.splice(idx, 1);
|
||||
// Check if wave cleared
|
||||
if (this.asteroids.length === 0 && this.waveDelay <= 0) {
|
||||
this.waveDelay = 2000;
|
||||
}
|
||||
}
|
||||
/* ================================================================
|
||||
COLLISIONS (manual rect/circle overlap — same pattern as Galaxy)
|
||||
================================================================ */
|
||||
checkCollisions() {
|
||||
// Bullets vs asteroids
|
||||
for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
|
||||
const b = this.bullets[bi];
|
||||
for (let ai = this.asteroids.length - 1; ai >= 0; ai--) {
|
||||
const a = this.asteroids[ai];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
if (dx * dx + dy * dy < a.radius * a.radius) {
|
||||
b.gfx.destroy();
|
||||
this.bullets.splice(bi, 1);
|
||||
this.destroyAsteroid(ai);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ship vs asteroids
|
||||
if (this.shipAlive && this.invincibleTimer <= 0) {
|
||||
for (let ai = this.asteroids.length - 1; ai >= 0; ai--) {
|
||||
const a = this.asteroids[ai];
|
||||
const dx = this.shipX - a.x;
|
||||
const dy = this.shipY - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < a.radius + SHIP_SIZE * 0.6) {
|
||||
this.hitShip();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* ================================================================
|
||||
SHIP DEATH / LIVES
|
||||
================================================================ */
|
||||
hitShip() {
|
||||
this.lives--;
|
||||
this.syncLivesToHUD();
|
||||
this.spawnExplosion(this.shipX, this.shipY);
|
||||
this.sound.play('sfx_zap', { volume: 0.5 });
|
||||
this.sound.play('sfx_lose', { volume: 0.4 });
|
||||
if (this.lives <= 0) {
|
||||
this.shipAlive = false;
|
||||
if (this.shipGfx)
|
||||
this.shipGfx.setVisible(false);
|
||||
if (this.thrustGfx)
|
||||
this.thrustGfx.setVisible(false);
|
||||
this.gameOver = true;
|
||||
this.time.delayedCall(1000, () => {
|
||||
this.showGameOver(this.score, () => this.scene.restart());
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.shipAlive = false;
|
||||
if (this.shipGfx)
|
||||
this.shipGfx.setVisible(false);
|
||||
if (this.thrustGfx)
|
||||
this.thrustGfx.setVisible(false);
|
||||
this.respawnTimer = RESPAWN_DELAY;
|
||||
}
|
||||
}
|
||||
/* ================================================================
|
||||
PARTICLES
|
||||
================================================================ */
|
||||
spawnExplosion(x, y) {
|
||||
this.spawnParticleExplosion(x, y, 0xffffff, 8);
|
||||
}
|
||||
/* ================================================================
|
||||
UFO ENEMY
|
||||
================================================================ */
|
||||
spawnUfo() {
|
||||
const fromRight = Math.random() < 0.5;
|
||||
const x = fromRight ? W + 30 : -30;
|
||||
const y = H * (0.15 + Math.random() * 0.3);
|
||||
const vx = (fromRight ? -1 : 1) * (120 + Math.random() * 80);
|
||||
const gfx = this.add.graphics().setDepth(12);
|
||||
this.drawUfo(gfx);
|
||||
gfx.setPosition(x, y);
|
||||
this.ufo = { gfx, x, y, vx, shootTimer: 1500 + Math.random() * 1000, active: true };
|
||||
}
|
||||
drawUfo(gfx) {
|
||||
gfx.clear();
|
||||
const s = SHIP_SIZE * 1.2;
|
||||
// Dark shadow backdrop
|
||||
gfx.lineStyle(5, 0x000000, 0.5);
|
||||
gfx.strokeEllipse(0, 0, s * 2, s * 0.7);
|
||||
gfx.strokeEllipse(0, -s * 0.2, s, s * 0.5);
|
||||
// Outer glow (soft magenta)
|
||||
gfx.lineStyle(3, 0xff44ff, 0.25);
|
||||
gfx.strokeEllipse(0, 0, s * 2, s * 0.7);
|
||||
gfx.strokeEllipse(0, -s * 0.2, s, s * 0.5);
|
||||
// Solid
|
||||
gfx.lineStyle(2.5, 0xff88ff, 1);
|
||||
gfx.strokeEllipse(0, 0, s * 2, s * 0.7);
|
||||
gfx.strokeEllipse(0, -s * 0.2, s, s * 0.5);
|
||||
}
|
||||
updateUfo(dt, dtSec) {
|
||||
// Spawn timer
|
||||
if (!this.ufo) {
|
||||
this.ufoTimer -= dt;
|
||||
if (this.ufoTimer <= 0) {
|
||||
this.spawnUfo();
|
||||
this.ufoTimer = 15000 + Math.random() * 10000;
|
||||
}
|
||||
// Update UFO bullets even when no UFO
|
||||
this.updateUfoBullets(dtSec);
|
||||
return;
|
||||
}
|
||||
const u = this.ufo;
|
||||
u.x += u.vx * dtSec;
|
||||
u.gfx.setPosition(u.x, u.y);
|
||||
// Off-screen — remove
|
||||
if ((u.vx > 0 && u.x > W + 60) || (u.vx < 0 && u.x < -60)) {
|
||||
u.gfx.destroy();
|
||||
this.ufo = null;
|
||||
return;
|
||||
}
|
||||
// Shoot at player
|
||||
u.shootTimer -= dt;
|
||||
if (u.shootTimer <= 0 && this.shipAlive) {
|
||||
u.shootTimer = 1200 + Math.random() * 800;
|
||||
const angle = Math.atan2(this.shipY - u.y, this.shipX - u.x);
|
||||
const speed = 250;
|
||||
const bGfx = this.add.graphics().setDepth(8);
|
||||
bGfx.fillStyle(0x000000, 0.5);
|
||||
bGfx.fillCircle(0, 0, 9);
|
||||
bGfx.fillStyle(0xff44ff, 0.3);
|
||||
bGfx.fillCircle(0, 0, 7);
|
||||
bGfx.fillStyle(0xff88ff, 1);
|
||||
bGfx.fillCircle(0, 0, 3);
|
||||
bGfx.setPosition(u.x, u.y);
|
||||
this.ufoBullets.push({
|
||||
gfx: bGfx, x: u.x, y: u.y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
this.updateUfoBullets(dtSec);
|
||||
}
|
||||
updateUfoBullets(dtSec) {
|
||||
for (let i = this.ufoBullets.length - 1; i >= 0; i--) {
|
||||
const b = this.ufoBullets[i];
|
||||
b.x += b.vx * dtSec;
|
||||
b.y += b.vy * dtSec;
|
||||
b.life -= dtSec * 1000;
|
||||
b.gfx.setPosition(b.x, b.y);
|
||||
if (b.life <= 0 || b.x < -50 || b.x > W + 50 || b.y < -50 || b.y > H + 50) {
|
||||
b.gfx.destroy();
|
||||
this.ufoBullets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
checkUfoCollisions() {
|
||||
if (!this.ufo)
|
||||
return;
|
||||
const u = this.ufo;
|
||||
// Player bullets vs UFO
|
||||
for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
|
||||
const b = this.bullets[bi];
|
||||
const dx = b.x - u.x;
|
||||
const dy = b.y - u.y;
|
||||
if (dx * dx + dy * dy < (SHIP_SIZE * 1.5) ** 2) {
|
||||
b.gfx.destroy();
|
||||
this.bullets.splice(bi, 1);
|
||||
this.addScore(500, u.x, u.y - 10);
|
||||
this.spawnExplosion(u.x, u.y);
|
||||
this.sound.play('sfx_zap', { volume: 0.4 });
|
||||
u.gfx.destroy();
|
||||
this.ufo = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// UFO bullets vs player
|
||||
if (this.shipAlive && this.invincibleTimer <= 0) {
|
||||
for (let i = this.ufoBullets.length - 1; i >= 0; i--) {
|
||||
const b = this.ufoBullets[i];
|
||||
const dx = b.x - this.shipX;
|
||||
const dy = b.y - this.shipY;
|
||||
if (dx * dx + dy * dy < (SHIP_SIZE * 0.8) ** 2) {
|
||||
b.gfx.destroy();
|
||||
this.ufoBullets.splice(i, 1);
|
||||
this.hitShip();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// UFO body vs player
|
||||
if (this.shipAlive && this.invincibleTimer <= 0) {
|
||||
const dx = this.shipX - u.x;
|
||||
const dy = this.shipY - u.y;
|
||||
if (dx * dx + dy * dy < (SHIP_SIZE * 1.8) ** 2) {
|
||||
this.spawnExplosion(u.x, u.y);
|
||||
u.gfx.destroy();
|
||||
this.ufo = null;
|
||||
this.hitShip();
|
||||
}
|
||||
}
|
||||
}
|
||||
/* ================================================================
|
||||
WAVE SYSTEM
|
||||
================================================================ */
|
||||
startWave() {
|
||||
this.wave++;
|
||||
this.syncLevelToHUD(this.wave);
|
||||
this.sound.play('sfx_twoTone', { volume: 0.3 });
|
||||
const count = INITIAL_ASTEROIDS + (this.wave - 1) * 2;
|
||||
// Aim the first 2 asteroids at the ship so the player must act quickly
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.spawnAsteroid(0, undefined, undefined, i < 2);
|
||||
}
|
||||
this.showWaveBanner(this.wave);
|
||||
}
|
||||
/* ================================================================
|
||||
CLEANUP
|
||||
================================================================ */
|
||||
shutdown() {
|
||||
super.shutdown();
|
||||
if (this.ufo) {
|
||||
this.ufo.gfx.destroy();
|
||||
this.ufo = null;
|
||||
}
|
||||
this.ufoBullets.forEach(b => b.gfx.destroy());
|
||||
this.ufoBullets = [];
|
||||
const banner = document.getElementById('wave-banner');
|
||||
if (banner)
|
||||
banner.remove();
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=CosmicRocks.js.map
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user