Skip to content

Instantly share code, notes, and snippets.

@semanticentity
Last active December 25, 2025 23:26
Show Gist options
  • Select an option

  • Save semanticentity/22068fe767ae41888bd410d0a31e33f3 to your computer and use it in GitHub Desktop.

Select an option

Save semanticentity/22068fe767ae41888bd410d0a31e33f3 to your computer and use it in GitHub Desktop.
Vector Attack
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const radarCanvas = document.getElementById('radarCanvas');
const radarCtx = radarCanvas.getContext('2d');
const speedIndicator = document.getElementById('speed-indicator');
const headingIndicator = document.getElementById('heading-indicator');
const shieldIndicator = document.getElementById('shield-indicator');
const laserStatus = document.getElementById('laser-status');
const hyperspaceStatus = document.getElementById('hyperspace-status');
const scoreIndicator = document.getElementById('score-indicator');
const enemyIndicator = document.getElementById('enemy-indicator');
const gameOverMessage = document.getElementById('game-over-message');
const pinkDustIndicator = document.getElementById('pink-dust-indicator');
const activeSpeedBoost = document.getElementById('active-speed-boost');
const activeScoreMultiplier = document.getElementById('active-score-multiplier');
const activeTripleLaser = document.getElementById('active-triple-laser');
// NEW UI ELEMENTS
const dockingPrompt = document.getElementById('docking-prompt');
const dockingMenu = document.getElementById('docking-menu');
const dialogMessage = document.getElementById('dialog-message'); // For in-game messages
const randomOscillatorType = ['sine', 'square', 'triangle', 'sawtooth'][Math.floor(Math.random() * 4)];
const randomFilterType = ['lowpass', 'highpass', 'bandpass', 'lowshelf', 'highshelf', 'peaking', 'notch', 'allpass'][Math.floor(Math.random() * 8)];
const randomFilterFrequency = Math.random() * 50 + 50;
const randomFilterResonance = Math.random() + 0.5;
// Standalone control object
const fakeKnobs = {
octaveControl: -2,
lfoRateControl: 5,
lfoDepthControl: 20,
volumeControl: 0.5,
acidOscillatorType: randomOscillatorType,
acidFilterType: randomFilterType,
acidFilterFrequency: randomFilterFrequency,
acidFilterResonance: randomFilterResonance,
guitarOscillatorType: 'sawtooth',
guitarFilterType: 'lowpass',
guitarFilterFrequency: 100,
guitarFilterResonance: 5,
pitchControl: 20
};
const pitchValue = fakeKnobs.pitchControl || -20;
let canvasWidth = 800;
let canvasHeight = 600;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
canvas.addEventListener('click', unlockAudio);
window.addEventListener('keydown', unlockAudio);
function unlockAudio() {
if (audioContext.state === 'suspended') {
audioContext.resume().then(() => {
console.log('AudioContext resumed!');
// Only start beat if not already playing and in 'playing' state
if (gameState === 'playing' && !BeatEngine.isPlaying()) {
startBeat();
}
});
} else {
if (gameState === 'playing' && !BeatEngine.isPlaying()) {
startBeat();
}
}
}
function startBeat() {
if (!BeatEngine.isPlaying()) {
BeatEngine.setBPM(46);
BeatEngine.startPlayback();
}
}
radarCanvas.width = 100; // Match CSS
radarCanvas.height = 100;
const keysPressed = {};
let stars = [];
const NUM_STARS = 200;
let lasers = [];
const LASER_SPEED = 10;
const LASER_COOLDOWN = 300;
const LASER_MAX_LIFE = 60; // frames
let lastLaserTime = 0;
let enemyLasers = [];
const ENEMY_LASER_SPEED = 7;
const ENEMY_LASER_MAX_LIFE = 70;
let shockwaves = [];
let pinkMissiles = [];
const HYPERSPACE_COOLDOWN = 10000; // Increased cooldown
let lastHyperspaceTime = 0;
let isHyperspacing = false;
let hyperspaceEffectTimer = 0;
let score = 0;
let gameOver = false;
let paused = false; // PAUSED FLAG
let pinkDustPoints = 0;
let rewindBuffer = [];
const MAX_REWIND_FRAMES = 600;
const REWIND_COST = 50;
let enemies = [];
const MAX_ENEMIES = 5;
const ENEMY_SPAWN_INTERVAL = 5000; // ms
let lastEnemySpawnTime = 0;
const ENEMY_SHOOT_COOLDOWN = 2000; // ms
const ENEMY_AGGRO_RANGE = 400; // pixels
const ENEMY_SHOOT_RANGE = 300; // pixels
let pinkDust = [];
const PLAYER_MAX_SHIELDS = 100;
const player = {
x: canvasWidth / 2,
y: canvasHeight / 2,
radius: 15,
angle: -Math.PI / 2,
rotationSpeed: 0.07,
thrust: 0.15,
velocity: { x: 0, y: 0 },
maxSpeed: 4, // Default maxSpeed
friction: 0.985,
isThrusting: false,
color: '#0f0',
laserColor: '#ff0000',
shields: PLAYER_MAX_SHIELDS,
maxShields: PLAYER_MAX_SHIELDS,
reticleDistance: 100 // How far the targeting reticle is
};
let powerUpStates = {
speed: { active: false, expires: 0 },
score: { active: false, expires: 0 },
laser: { active: false, expires: 0 }
};
// NEW GAME STATE VARIABLES
// 'playing' (space combat), 'docked_menu' (station interior menu), 'town' (walk around), 'station_interior' (procedural station), 'gameover'
let gameState = 'playing';
let dockingStations = [];
let currentDockedStation = null;
let selectedDockOption = 0; // For menu navigation
// New player object for town/station walking - RENAMED FOR CLARITY
let player2D = {
x: 0,
y: 0,
speed: 3,
radius: 10,
color: '#0f0', // Player triangle color
facingDirection: 'down' // 'up', 'down', 'left', 'right' for visual flair
};
// Town map definition
let townMap = {
width: 1600, // Make the town map larger than the canvas
height: 1200,
elements: [], // Buildings, paths, decorations
interactions: [], // Zones for dialogs, transitions etc.
// Entry/exit points specific to the town map
entryPoints: {
dockingStationExit: { x: 800, y: 1000 } // Where player2D lands after docking scene
},
exitPoints: [] // Zones that transition to other game states (e.g., back to space)
};
// Station interior map definition (for procedural generation)
let currentStationInterior = {
outerRadius: 0,
innerRadius: 0,
centerX: 0,
centerY: 0,
elements: [], // Rooms, corridors, walls, doors (for drawing and collision)
interactions: [], // NPCs, terminals, shops (for player interaction)
// Camera bounds
mapMinX: 0,
mapMinY: 0,
mapMaxX: 0,
mapMaxY: 0,
};
// Helper function to show temporary messages
function showMessage(text, duration = 2000) {
dialogMessage.textContent = text;
dialogMessage.style.display = 'block';
setTimeout(() => {
dialogMessage.style.display = 'none';
}, duration);
}
// === NEW/ENHANCED: Station Color Palette ===
const stationColors = {
background: '#181D26', // Dark desaturated blue-grey
floorBase: '#283040',
wallBase: '#30384D',
doorWay: '#38425A',
hudGreen: '#00FF00',
hudCyan: '#00FFFF',
hudMagenta: '#FF00FF',
hudYellow: '#FFFF00',
hudRed: '#FF4444',
npcBody: ['#4A7A7A', '#7A4A7A', '#7A7A4A', '#5B6B8B'], // Teal, Purple, Olive, Slate
npcHead: ['#609090', '#906090', '#909060', '#7080A0'],
furnitureDark: '#2A1E17',
furnitureMid: '#4C382A',
furnitureAccent: '#6B503E',
furnitureHighlight: '#8B705E',
interactiveHighlight: '#FFFF00', // Yellow for interactable glow
screenGlow: 'rgba(100, 200, 255, 0.7)',
windowView: '#080A0F', // Very dark blue for window space views
labelColor: '#A0B0D0', // Off-white for labels
};
// === NEW/ENHANCED: Generate more detailed furniture based on room type ===
function generateFurniture(roomElement) {
const furnitureList = [];
const roomInnerX = roomElement.x + 10;
const roomInnerY = roomElement.y + 10;
const roomInnerWidth = roomElement.width - 20;
const roomInnerHeight = roomElement.height - 20;
// Add NPCs
const numNPCs = Math.random() < 0.2 ? 0 : (Math.random() < 0.6 ? 1 : (Math.random() < 0.85 ? 2 : 3));
for (let i = 0; i < numNPCs; i++) {
const npcX = roomInnerX + roomInnerWidth * (0.2 + Math.random() * 0.6);
const npcY = roomInnerY + roomInnerHeight * (0.2 + Math.random() * 0.6);
const colorIndex = Math.floor(Math.random() * stationColors.npcBody.length);
furnitureList.push({
type: 'furniture', subtype: 'npc',
x: npcX, y: npcY, radius: 8,
bodyColor: stationColors.npcBody[colorIndex],
headColor: stationColors.npcHead[colorIndex]
});
}
// Add room-specific furniture
if (roomElement.subtype === 'Bar') {
furnitureList.push({ type: 'furniture', subtype: 'bar_counter', x: roomInnerX + roomInnerWidth * 0.1, y: roomInnerY + roomInnerHeight * 0.6, width: roomInnerWidth * 0.8, height: roomInnerHeight * 0.3, color: stationColors.furnitureMid, accent: stationColors.furnitureDark });
for (let i = 0; i < Math.max(1,Math.floor(roomInnerWidth / 60)) ; i++) {
furnitureList.push({ type: 'furniture', subtype: 'stool', x: roomInnerX + roomInnerWidth * (0.15 + i * 0.2), y: roomInnerY + roomInnerHeight * 0.75, radius: 6, color: stationColors.furnitureAccent, seatColor: stationColors.furnitureHighlight });
}
furnitureList.push({ type: 'furniture', subtype: 'dispenser', x: roomInnerX + roomInnerWidth * 0.85, y: roomInnerY + roomInnerHeight * 0.5, width: 15, height: 25, color: stationColors.hudCyan, baseColor: stationColors.furnitureMid });
furnitureList.push({ type: 'furniture', subtype: 'shelf', x: roomInnerX + roomInnerWidth * 0.2, y: roomInnerY + roomInnerHeight * 0.1, width: roomInnerWidth * 0.6, height: 10, color: stationColors.furnitureAccent});
} else if (roomElement.subtype === 'TradingPost') {
furnitureList.push({ type: 'furniture', subtype: 'counter', x: roomInnerX + roomInnerWidth * 0.1, y: roomInnerY + roomInnerHeight * 0.4, width: roomInnerWidth * 0.8, height: 25, color: stationColors.furnitureMid, accent: stationColors.furnitureDark });
furnitureList.push({ type: 'furniture', subtype: 'display_case', x: roomInnerX + roomInnerWidth * 0.15, y: roomInnerY + roomInnerHeight * 0.6, width: roomInnerWidth * 0.3, height: 40, color: stationColors.furnitureAccent, glassColor: stationColors.screenGlow });
furnitureList.push({ type: 'furniture', subtype: 'display_case', x: roomInnerX + roomInnerWidth * 0.55, y: roomInnerY + roomInnerHeight * 0.6, width: roomInnerWidth * 0.3, height: 40, color: stationColors.furnitureAccent, glassColor: stationColors.screenGlow });
} else if (roomElement.subtype === 'Office') {
furnitureList.push({ type: 'furniture', subtype: 'desk', x: roomInnerX + roomInnerWidth * 0.25, y: roomInnerY + roomInnerHeight * 0.2, width: roomInnerWidth * 0.5, height: roomInnerHeight * 0.25, color: stationColors.furnitureMid, accent: stationColors.furnitureDark });
furnitureList.push({ type: 'furniture', subtype: 'chair_office', x: roomInnerX + roomInnerWidth * 0.5, y: roomInnerY + roomInnerHeight * 0.5, radius: 10, color: stationColors.furnitureAccent, seatColor: stationColors.furnitureHighlight });
furnitureList.push({ type: 'furniture', subtype: 'terminal', x: roomInnerX + roomInnerWidth * 0.4, y: roomInnerY + roomInnerHeight * 0.15, width: 30, height: 20, color: stationColors.hudCyan, baseColor: stationColors.furnitureMid });
furnitureList.push({ type: 'furniture', subtype: 'file_cabinet', x: roomInnerX + roomInnerWidth * 0.8, y: roomInnerY + roomInnerHeight * 0.2, width: 20, height: roomInnerHeight * 0.6, color: stationColors.furnitureAccent});
} else if (roomElement.subtype === 'ObservationLounge') {
furnitureList.push({ type: 'furniture', subtype: 'window_large', x: roomInnerX + roomInnerWidth * 0.1, y: roomInnerY + roomInnerHeight * 0.05, width: roomInnerWidth * 0.8, height: roomInnerHeight * 0.4, color: stationColors.windowView });
furnitureList.push({ type: 'furniture', subtype: 'sofa', x: roomInnerX + roomInnerWidth * 0.2, y: roomInnerY + roomInnerHeight * 0.7, width: roomInnerWidth * 0.6, height: 30, color: stationColors.furnitureMid, seatColor: stationColors.furnitureHighlight });
furnitureList.push({ type: 'furniture', subtype: 'potted_plant', x: roomInnerX + roomInnerWidth * 0.05, y: roomInnerY + roomInnerHeight * 0.8, radius: 12, potColor: stationColors.furnitureAccent, plantColor: '#2A5B2A' });
furnitureList.push({ type: 'furniture', subtype: 'potted_plant', x: roomInnerX + roomInnerWidth * 0.9, y: roomInnerY + roomInnerHeight * 0.8, radius: 12, potColor: stationColors.furnitureAccent, plantColor: '#3A8B3A'});
} else if (roomElement.subtype === 'Arcade') {
for (let i = 0; i < Math.max(1, Math.floor(roomInnerWidth / 100)); i++) {
furnitureList.push({ type: 'furniture', subtype: 'arcade_machine', x: roomInnerX + roomInnerWidth * (0.2 + i * 0.3), y: roomInnerY + roomInnerHeight * 0.2, width: 35, height: 55, color: stationColors.npcBody[i % stationColors.npcBody.length], screenColor: stationColors.screenGlow });
}
furnitureList.push({ type: 'furniture', subtype: 'bench', x: roomInnerX + roomInnerWidth * 0.1, y: roomInnerY + roomInnerHeight * 0.8, width: roomInnerWidth * 0.8, height: 20, color: stationColors.furnitureAccent });
} else if (roomElement.subtype === 'Theater') {
for (let row = 0; row < 3; row++) {
for (let seat = 0; seat < Math.max(2, Math.floor(roomInnerWidth / 50)); seat++) {
furnitureList.push({ type: 'furniture', subtype: 'theater_seat', x: roomInnerX + roomInnerWidth * (0.05 + seat * 0.15), y: roomInnerY + roomInnerHeight * (0.4 + row * 0.2), width: 20, height: 15, color: stationColors.furnitureAccent, seatColor: stationColors.npcBody[2] });
}
}
furnitureList.push({ type: 'furniture', subtype: 'screen_large', x: roomInnerX + roomInnerWidth * 0.1, y: roomInnerY + roomInnerHeight * 0.05, width: roomInnerWidth * 0.8, height: roomInnerHeight * 0.25, color: stationColors.hudCyan, borderColor: stationColors.furnitureAccent });
} else if (roomElement.subtype === 'Storage') {
for(let i=0; i< Math.max(1, Math.floor(roomInnerWidth/80)); i++) {
for(let j=0; j<Math.max(1, Math.floor(roomInnerHeight/70)); j++) {
furnitureList.push({ type: 'furniture', subtype: 'crate', x: roomInnerX + roomInnerWidth * (0.05 + i * 0.25), y: roomInnerY + roomInnerHeight * (0.1 + j * 0.3), width: roomInnerWidth * 0.18, height: roomInnerHeight * 0.25, color: stationColors.furnitureAccent, accent: stationColors.furnitureHighlight });
}
}
}
// Add generic details like vents or pipes
if (Math.random() < 0.5) {
furnitureList.push({ type: 'furniture', subtype: 'vent', x: roomInnerX + Math.random() * (roomInnerWidth - 20), y: roomInnerY + (Math.random() < 0.5 ? 0 : roomInnerHeight - 5) , width: 20 + Math.random()*20, height: 5, color: '#444' });
}
if (Math.random() < 0.5) {
furnitureList.push({ type: 'furniture', subtype: 'pipe', x: roomInnerX + (Math.random() < 0.5 ? 0 : roomInnerWidth - 5), y: roomInnerY + Math.random() * (roomInnerHeight - 15), width: 5, height: 15 + Math.random()*10, color: '#555' });
}
return furnitureList;
}
// === NEW/ENHANCED: More robust station generation logic ===
function generateStationInterior(stationName) {
currentStationInterior.elements = [];
currentStationInterior.interactions = [];
// TWEAK: More space between inner core and outer wall for generation
currentStationInterior.outerRadius = Math.min(canvasWidth, canvasHeight) * 0.65;
currentStationInterior.innerRadius = currentStationInterior.outerRadius * 0.20;
currentStationInterior.centerX = canvasWidth / 2;
currentStationInterior.centerY = canvasHeight / 2;
currentStationInterior.mapMinX = currentStationInterior.centerX - currentStationInterior.outerRadius - 150;
currentStationInterior.mapMinY = currentStationInterior.centerY - currentStationInterior.outerRadius - 150;
currentStationInterior.mapMaxX = currentStationInterior.centerX + currentStationInterior.outerRadius + 150;
currentStationInterior.mapMaxY = currentStationInterior.centerY + currentStationInterior.outerRadius + 150;
const MIN_ROOMS = 3;
const MAX_ROOMS = 7;
const sectorCount = Math.floor(Math.random() * (MAX_ROOMS - MIN_ROOMS + 1)) + MIN_ROOMS;
const sectorAngle = Math.PI * 2 / sectorCount;
let roomsPlaced = 0;
const roomTemplates = [
{ type: 'Bar', label: 'CANTEENA', minSize: 0.20, maxSize: 0.35 },
{ type: 'TradingPost', label: 'TRADE HUB', minSize: 0.22, maxSize: 0.4 },
{ type: 'ObservationLounge', label: 'VIEWPORT LOUNGE', minSize: 0.25, maxSize: 0.45 },
{ type: 'Office', label: 'ADMIN OFFICE', minSize: 0.18, maxSize: 0.3 },
{ type: 'Foyer', label: 'MAIN CONCOURSE', minSize: 0.25, maxSize: 0.4 },
{ type: 'Arcade', label: 'GAME ARCADE', minSize: 0.20, maxSize: 0.35 },
{ type: 'Theater', label: 'HOLODECK', minSize: 0.28, maxSize: 0.5 },
{ type: 'Storage', label: 'CARGO BAY', minSize: 0.20, maxSize: 0.35 }
];
roomTemplates.forEach(rt => {
rt.interactionType = (rt.type === 'TradingPost') ? 'shop' : 'dialog';
rt.message = rt.message || `Welcome to the ${rt.label}.`;
});
currentStationInterior.elements.push({
type: 'core', x: currentStationInterior.centerX, y: currentStationInterior.centerY,
radius: currentStationInterior.innerRadius, color: stationColors.floorBase
});
const doorThickness = player2D.radius * 3;
const corridorLength = player2D.radius * 2.5;
let sectorIndices = Array.from({length: sectorCount}, (_, k) => k);
for (let i = sectorIndices.length - 1; i > 0; i--) { // Shuffle sectors
const j = Math.floor(Math.random() * (i + 1));
[sectorIndices[i], sectorIndices[j]] = [sectorIndices[j], sectorIndices[i]];
}
// --- Main placement loop ---
const placeRoomInSector = (sectorIndex) => {
const startAngle = sectorIndex * sectorAngle;
const midAngle = startAngle + sectorAngle / 2;
// Draw sector divider lines
if (!currentStationInterior.elements.find(el => el.type === 'sector_line' && el.angle === startAngle)) {
currentStationInterior.elements.push({
type: 'sector_line', angle: startAngle,
x1: currentStationInterior.centerX + currentStationInterior.innerRadius * Math.cos(startAngle),
y1: currentStationInterior.centerY + currentStationInterior.innerRadius * Math.sin(startAngle),
x2: currentStationInterior.centerX + currentStationInterior.outerRadius * Math.cos(startAngle),
y2: currentStationInterior.centerY + currentStationInterior.outerRadius * Math.sin(startAngle),
color: stationColors.wallBase
});
}
const roomTemplate = roomTemplates[Math.floor(Math.random() * roomTemplates.length)];
let roomWidth = (roomTemplate.minSize + Math.random() * (roomTemplate.maxSize - roomTemplate.minSize)) * canvasWidth * 0.8;
let roomHeight = (roomTemplate.minSize + Math.random() * (roomTemplate.maxSize - roomTemplate.minSize)) * canvasHeight * 0.8;
roomWidth = Math.max(player2D.radius * 8, Math.min(roomWidth, currentStationInterior.outerRadius * 0.75));
roomHeight = Math.max(player2D.radius * 8, Math.min(roomHeight, currentStationInterior.outerRadius * 0.75));
const roomInnerEdgeRadialPos = currentStationInterior.innerRadius + corridorLength;
const roomCenterRadialDistance = roomInnerEdgeRadialPos + Math.min(roomWidth, roomHeight) / 1.5;
const roomCenterX = currentStationInterior.centerX + roomCenterRadialDistance * Math.cos(midAngle);
const roomCenterY = currentStationInterior.centerY + roomCenterRadialDistance * Math.sin(midAngle);
let roomElement = {
type: 'room', subtype: roomTemplate.type,
x: roomCenterX - roomWidth / 2, y: roomCenterY - roomHeight / 2,
width: roomWidth, height: roomHeight,
label: roomTemplate.label, color: stationColors.floorBase
};
const roomPadding = 10; // Allow tighter packing
let overlaps = false;
for (const el of currentStationInterior.elements) {
if (el.type === 'room' && checkAABBCollision(roomElement, el, roomPadding)) {
overlaps = true; break;
}
}
const distToOuterEdge = Math.hypot(roomCenterX - currentStationInterior.centerX, roomCenterY - currentStationInterior.centerY) + Math.hypot(roomWidth/2, roomHeight/2);
if (distToOuterEdge > currentStationInterior.outerRadius - 5) {
overlaps = true;
}
if (!overlaps) {
currentStationInterior.elements.push(roomElement);
roomsPlaced++;
currentStationInterior.interactions.push({ type: roomTemplate.interactionType, x: roomCenterX, y: roomCenterY, radius: player2D.radius * 2, message: roomTemplate.message, color: stationColors.hudYellow });
let doorRect = { type: 'door', color: stationColors.doorWay, x:0, y:0, width:0, height:0 };
const coreEdgeX = currentStationInterior.centerX + currentStationInterior.innerRadius * Math.cos(midAngle);
const coreEdgeY = currentStationInterior.centerY + currentStationInterior.innerRadius * Math.sin(midAngle);
const corridorOuterEdgeX = currentStationInterior.centerX + roomInnerEdgeRadialPos * Math.cos(midAngle);
const corridorOuterEdgeY = currentStationInterior.centerY + roomInnerEdgeRadialPos * Math.sin(midAngle);
if (Math.abs(Math.cos(midAngle)) > Math.abs(Math.sin(midAngle))) {
doorRect.height = doorThickness; doorRect.y = coreEdgeY - doorThickness / 2;
doorRect.x = Math.min(coreEdgeX, corridorOuterEdgeX); doorRect.width = Math.abs(coreEdgeX - corridorOuterEdgeX);
} else {
doorRect.width = doorThickness; doorRect.x = coreEdgeX - doorThickness / 2;
doorRect.y = Math.min(coreEdgeY, corridorOuterEdgeY); doorRect.height = Math.abs(coreEdgeY - corridorOuterEdgeY);
}
if (doorRect.width > 0 && doorRect.height > 0) currentStationInterior.elements.push(doorRect);
currentStationInterior.elements.push(...generateFurniture(roomElement));
return true;
}
return false;
};
for (const sectorIndex of sectorIndices) {
if (roomsPlaced < MAX_ROOMS && (Math.random() > 0.1 || roomsPlaced < MIN_ROOMS)) {
placeRoomInSector(sectorIndex);
}
}
// --- Fallback loop to ensure minimum rooms are placed ---
let attempts = 0;
while(roomsPlaced < MIN_ROOMS && attempts < sectorCount * 3) {
const randomSector = Math.floor(Math.random() * sectorCount);
placeRoomInSector(randomSector);
attempts++;
}
console.log(`Station generated with ${roomsPlaced} rooms in ${sectorCount} sectors.`);
currentStationInterior.interactions.push({
type: 'exit_station', x: currentStationInterior.centerX, y: currentStationInterior.centerY,
radius: currentStationInterior.innerRadius * 0.7, color: stationColors.hudRed, message: 'Return to Docking Bay'
});
player2D.x = currentStationInterior.centerX;
player2D.y = currentStationInterior.centerY;
}
// === NEW/ENHANCED: Detailed drawing function for station interiors ===
function drawStationInterior() {
ctx.fillStyle = stationColors.background;
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
let cameraX = player2D.x - canvasWidth / 2;
let cameraY = player2D.y - canvasHeight / 2;
cameraX = Math.max(currentStationInterior.mapMinX, Math.min(cameraX, currentStationInterior.mapMaxX - canvasWidth));
cameraY = Math.max(currentStationInterior.mapMinY, Math.min(cameraY, currentStationInterior.mapMaxY - canvasHeight));
ctx.save();
ctx.translate(-cameraX, -cameraY);
// --- Draw Floors ---
currentStationInterior.elements.forEach(element => {
if (element.type === 'room' || element.type === 'core' || element.type === 'door') {
ctx.fillStyle = element.color;
if (element.type === 'core') {
ctx.beginPath(); ctx.arc(element.x, element.y, element.radius, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillRect(element.x, element.y, element.width, element.height);
}
}
});
// --- Draw Walls & Structure ---
currentStationInterior.elements.forEach(element => {
if (element.type === 'room') {
ctx.strokeStyle = stationColors.wallBase; ctx.lineWidth = 5;
ctx.strokeRect(element.x, element.y, element.width, element.height);
} else if (element.type === 'core') {
ctx.strokeStyle = stationColors.wallBase; ctx.lineWidth = 5;
ctx.beginPath(); ctx.arc(element.x, element.y, element.radius, 0, Math.PI * 2); ctx.stroke();
const pulse = 0.5 + Math.sin(Date.now() * 0.001) * 0.5;
ctx.fillStyle = `rgba(120, 190, 255, ${0.1 + pulse * 0.2})`;
ctx.beginPath(); ctx.arc(element.x, element.y, element.radius * (0.75 + pulse * 0.2), 0, Math.PI * 2); ctx.fill();
} else if (element.type === 'sector_line') {
ctx.strokeStyle = stationColors.wallBase + '88'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(element.x1, element.y1); ctx.lineTo(element.x2, element.y2); ctx.stroke();
}
});
// --- Draw Furniture & NPCs (with lots of new details) ---
currentStationInterior.elements.forEach(element => {
if (element.type === 'furniture') {
ctx.lineWidth = 1; // Reset for furniture
if (element.subtype === 'npc') {
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.beginPath(); ctx.ellipse(element.x, element.y + element.radius * 0.9, element.radius * 0.8, element.radius * 0.3, 0,0, Math.PI*2); ctx.fill();
ctx.fillStyle = element.bodyColor;
ctx.beginPath(); ctx.arc(element.x, element.y, element.radius, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = element.headColor;
ctx.beginPath(); ctx.arc(element.x, element.y - element.radius * 0.6, element.radius * 0.6, 0, Math.PI * 2); ctx.fill();
} else if (element.subtype === 'bar_counter' || element.subtype === 'counter' || element.subtype === 'desk') {
ctx.fillStyle = element.color; ctx.fillRect(element.x, element.y, element.width, element.height);
ctx.fillStyle = element.accent; ctx.fillRect(element.x, element.y, element.width, 5);
ctx.strokeStyle = stationColors.furnitureDark; ctx.strokeRect(element.x, element.y, element.width, element.height);
} else if (element.subtype === 'stool') {
ctx.fillStyle = element.color; ctx.fillRect(element.x - element.radius * 0.3, element.y, element.radius * 0.6, element.radius);
ctx.fillStyle = element.seatColor; ctx.beginPath(); ctx.arc(element.x, element.y, element.radius, 0, Math.PI * 2); ctx.fill();
} else if (element.subtype === 'chair_office') {
ctx.fillStyle = element.color; ctx.beginPath(); ctx.roundRect(element.x - element.radius, element.y - element.radius*0.5, element.radius*2, element.radius*1.5, 5); ctx.fill();
ctx.fillStyle = element.seatColor; ctx.beginPath(); ctx.roundRect(element.x - element.radius*0.8, element.y - element.radius, element.radius*1.6, element.radius, 5); ctx.fill();
} else if (element.subtype === 'terminal' || element.subtype === 'dispenser') {
ctx.fillStyle = element.baseColor; ctx.fillRect(element.x, element.y, element.width, element.height);
ctx.fillStyle = element.color; ctx.fillRect(element.x + 2, element.y + 2, element.width - 4, element.height * 0.4);
if (Date.now() % 1000 < 500) {
ctx.fillStyle = stationColors.interactiveHighlight; ctx.fillRect(element.x + element.width*0.4, element.y + element.height*0.7, element.width*0.2, 5);
}
} else if (element.subtype === 'display_case') {
ctx.fillStyle = element.color; ctx.fillRect(element.x, element.y, element.width, element.height);
ctx.fillStyle = element.glassColor; ctx.fillRect(element.x + 5, element.y + 5, element.width - 10, element.height * 0.5 - 5);
ctx.fillStyle = stationColors.hudMagenta; ctx.fillRect(element.x + 10, element.y + 10, 10, 5); // Item 1
ctx.fillStyle = stationColors.hudCyan; ctx.beginPath(); ctx.arc(element.x + 30, element.y + 12, 3,0, Math.PI*2); ctx.fill(); // Item 2
} else if (element.subtype === 'shelf') {
ctx.fillStyle = element.color; ctx.fillRect(element.x, element.y, element.width, element.height);
for(let b=0; b<5; b++){
if(Math.random() < 0.7) {
ctx.fillStyle = stationColors.npcHead[b%stationColors.npcHead.length] + 'aa';
ctx.fillRect(element.x + 5 + b * (element.width/5.5), element.y - 15, 8, 15);
}
}
} else if (element.subtype === 'file_cabinet') {
ctx.fillStyle = element.color; ctx.fillRect(element.x, element.y, element.width, element.height);
for(let h=0; h<3; h++) {
ctx.fillStyle = stationColors.furnitureDark; ctx.fillRect(element.x + element.width*0.2, element.y + element.height*(0.2 + h*0.3), element.width*0.6, 5);
}
} else if (element.subtype === 'window_large') {
ctx.fillStyle = element.color; ctx.fillRect(element.x, element.y, element.width, element.height);
for(let s=0; s < 15; s++) {
if (Math.random() < 0.6) {
ctx.fillStyle = `rgba(200, 200, 255, ${Math.random() * 0.6 + 0.3})`;
ctx.beginPath(); ctx.arc(element.x + Math.random() * element.width, element.y + Math.random() * element.height, Math.random()*1.8 + 0.5, 0, Math.PI*2); ctx.fill();
}
}
ctx.strokeStyle = stationColors.wallBase; ctx.lineWidth = 4; ctx.strokeRect(element.x, element.y, element.width, element.height);
} else if (element.subtype === 'sofa' || element.subtype === 'bench') {
ctx.fillStyle = element.color; ctx.fillRect(element.x, element.y, element.width, element.height);
if (element.seatColor) {
ctx.fillStyle = element.seatColor; ctx.fillRect(element.x+3, element.y+3, element.width-6, element.height-6);
}
} else if (element.subtype === 'potted_plant') {
ctx.fillStyle = element.potColor; ctx.beginPath(); ctx.arc(element.x, element.y + element.radius*0.5, element.radius, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = element.plantColor; ctx.beginPath(); ctx.ellipse(element.x, element.y - element.radius*0.5, element.radius*0.9, element.radius*1.3, 0, 0, Math.PI*2); ctx.fill();
} else if (element.subtype === 'arcade_machine') {
ctx.fillStyle = element.color; ctx.fillRect(element.x, element.y, element.width, element.height);
ctx.fillStyle = element.screenColor; ctx.fillRect(element.x + 5, element.y + 5, element.width - 10, element.height * 0.4);
ctx.fillStyle = '#333'; ctx.fillRect(element.x + element.width * 0.4, element.y + element.height * 0.6, 8, 10);
ctx.beginPath(); ctx.arc(element.x + element.width * 0.4 + 4, element.y + element.height * 0.55, 4,0,Math.PI*2); ctx.fill();
} else if (element.subtype === 'theater_seat') {
ctx.fillStyle = element.color; ctx.fillRect(element.x, element.y, element.width, element.height);
ctx.fillStyle = element.seatColor; ctx.fillRect(element.x+2, element.y+2, element.width-4, element.height-4);
} else if (element.subtype === 'screen_large') {
ctx.fillStyle = element.borderColor; ctx.fillRect(element.x, element.y, element.width, element.height);
ctx.fillStyle = element.color; ctx.fillRect(element.x+5, element.y+5, element.width-10, element.height-10);
if(Math.random() < 0.2) { // Scanline effect
ctx.fillStyle = `rgba(200,200,255,${0.05 + Math.random()*0.1})`;
for(let sl=0; sl < (element.height-10)/4; sl++){
ctx.fillRect(element.x+5, element.y+5 + sl*4, element.width-10, 2);
}
}
} else if (element.subtype === 'crate') {
ctx.fillStyle = element.color; ctx.fillRect(element.x, element.y, element.width, element.height);
ctx.strokeStyle = element.accent; ctx.lineWidth = 2; ctx.strokeRect(element.x,element.y,element.width, element.height);
ctx.beginPath(); ctx.moveTo(element.x, element.y); ctx.lineTo(element.x+element.width, element.y+element.height);
ctx.moveTo(element.x+element.width, element.y); ctx.lineTo(element.x, element.y+element.height); ctx.stroke();
} else if (element.subtype === 'vent' || element.subtype === 'pipe') {
ctx.fillStyle = element.color; ctx.fillRect(element.x, element.y, element.width, element.height);
}
}
});
// --- Draw Player ---
ctx.save();
ctx.translate(player2D.x, player2D.y);
const playerDrawRadius = player2D.radius * 1.2;
ctx.strokeStyle = player2D.color; ctx.lineWidth = 2;
let angle = -Math.PI / 2;
if (player2D.facingDirection === 'down') angle = Math.PI / 2;
else if (player2D.facingDirection === 'left') angle = Math.PI;
else if (player2D.facingDirection === 'right') angle = 0;
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(playerDrawRadius, 0); ctx.lineTo(-playerDrawRadius * 0.7, -playerDrawRadius * 0.7);
ctx.lineTo(-playerDrawRadius * 0.7, playerDrawRadius * 0.7); ctx.closePath();
ctx.fillStyle = player2D.color + 'AA'; ctx.fill(); ctx.stroke();
ctx.restore();
// --- Draw Room Labels ---
currentStationInterior.elements.forEach(element => {
if (element.type === 'room' && element.label) {
ctx.font = '22px Nabla'; ctx.fillStyle = stationColors.labelColor;
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(element.label, element.x + element.width / 2, element.y - 10);
}
});
// --- Draw Interaction Prompts (with better visuals) ---
currentStationInterior.interactions.forEach(interaction => {
const dist = Math.hypot(player2D.x - interaction.x, player2D.y - interaction.y);
if (dist < player2D.radius + interaction.radius) {
ctx.font = '16px Nabla'; ctx.textAlign = 'center';
let promptText = `(E) ${interaction.message}`;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillText(promptText, interaction.x, interaction.y - interaction.radius - 9);
ctx.fillStyle = interaction.color;
ctx.fillText(promptText, interaction.x, interaction.y - interaction.radius - 10);
ctx.strokeStyle = interaction.color + '99'; ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.beginPath(); ctx.arc(interaction.x, interaction.y, interaction.radius, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
});
ctx.restore();
}
// ... (The rest of your code remains largely the same) ...
// No changes needed for:
// - initTownMap, updateTownPlayer, handleTownInteractions, drawTownScene, drawPlayer2D
// - initGame, all the 'playing' state functions (updatePlayer, fireLaser, collisions, etc.)
// - All the UI update functions (drawUI, drawRadar)
// - All the BeatEngine and audio code
// - All event listeners
// I've only modified the station-interior related functions as requested.
// You can copy and paste this entire block into your file, replacing the old code.
// The functions below are included for completeness but are unchanged.
function initTownMap() {
townMap.elements = [];
townMap.interactions = [];
townMap.exitPoints = []; // Clear for re-init
// Main road (simple rectangles for now)
townMap.elements.push({ type: 'path', x: 0, y: townMap.height * 0.7, width: townMap.width, height: 100, color: '#4a4a4a' });
townMap.elements.push({ type: 'path', x: townMap.width * 0.4, y: 0, width: 100, height: townMap.height, color: '#4a4a4a' });
// Buildings (rectangles for simplicity, can be more complex vector shapes)
// Shop
townMap.elements.push({ type: 'building', x: 200, y: 700, width: 150, height: 100, color: '#888800' });
townMap.interactions.push({ type: 'dialog', x: 275, y: 800, radius: 20, message: "Welcome to the Spaceman's Emporium! Need anything?" });
// Bar
townMap.elements.push({ type: 'building', x: 1000, y: 650, width: 200, height: 120, color: '#6a0000' });
townMap.interactions.push({ type: 'dialog', x: 1100, y: 770, radius: 20, message: "The 'Cosmic Brew' is closed for the night. Come back tomorrow!" });
// Guild Hall
townMap.elements.push({ type: 'building', x: 600, y: 200, width: 250, height: 150, color: '#006a6a' });
townMap.interactions.push({ type: 'dialog', x: 725, y: 350, radius: 20, message: "The Galactic Explorers Guild: Adventure Awaits!" });
// Docking station entrance (where the ship is "parked" in the town, leads to the 'docked_menu')
townMap.elements.push({ type: 'building', x: townMap.entryPoints.dockingStationExit.x - 150, y: townMap.entryPoints.dockingStationExit.y - 150, width: 300, height: 300, color: '#00aaaa' });
townMap.interactions.push({ type: 're_dock', x: townMap.entryPoints.dockingStationExit.x, y: townMap.entryPoints.dockingStationExit.y, radius: 30, color: '#0ff' });
// Trees/Decorations (circles or simple shapes)
townMap.elements.push({ type: 'decoration', x: 300, y: 500, radius: 20, color: '#008800' });
townMap.elements.push({ type: 'decoration', x: 900, y: 900, radius: 30, color: '#008800' });
// Exit point to space (example: at the bottom of the map)
townMap.exitPoints.push({ x: 800, y: 1150, radius: 50, color: '#f0f' });
// Set player2D starting position for town
player2D.x = townMap.entryPoints.dockingStationExit.x;
player2D.y = townMap.entryPoints.dockingStationExit.y + 100; // Start just below the docking entrance
}
function initGame() {
gameOver = false;
paused = false; // Ensure pause is reset
gameOverMessage.style.display = 'none';
score = 0;
pinkDustPoints = 0;
player.x = canvasWidth / 2;
player.y = canvasHeight / 2;
player.velocity = { x: 0, y: 0 };
player.angle = -Math.PI / 2;
player.shields = player.maxShields;
lasers = [];
enemyLasers = [];
enemies = [];
pinkMissiles = [];
shockwaves = [];
pinkDust = [];
lastLaserTime = 0;
lastEnemySpawnTime = Date.now(); // Initialize this to avoid immediate spawn on start
lastHyperspaceTime = Date.now() - HYPERSPACE_COOLDOWN; // Reset cooldown for hyperspace
isHyperspacing = true; // Start with a short hyperspace effect for intro
hyperspaceEffectTimer = 120;
initStars();
// Reset power-ups and their visual states on new game
window.doubleLaser = false;
window.scoreMultiplier = 1;
player.maxSpeed = 4; // Reset to default max speed
powerUpStates = { // Reset the tracking object for UI
speed: { active: false, expires: 0 },
score: { active: false, expires: 0 },
laser: { active: false, expires: 0 }
};
activeSpeedBoost.style.display = 'none'; // Ensure UI is hidden
activeSpeedBoost.textContent = '';
activeScoreMultiplier.style.display = 'none';
activeScoreMultiplier.textContent = '';
activeTripleLaser.style.display = 'none';
activeTripleLaser.textContent = '';
// NEW: Initialize docking station (moved it slightly for better visibility if player starts near origin)
dockingStations = [];
dockingStations.push({
x: canvasWidth / 10,
y: canvasHeight - 60, // Place it towards bottom-left corner
radius: 40,
color: '#00ffff00', // Cyan with opacity 0 while WIP
name: 'Outpost',
menu: ['REPAIR SHIELDS', 'TRADE RESOURCES', 'LEAVE DOCK']
});
initTownMap(); // Initialize town map elements and player2D position
// START MUSIC
if (window.BeatEngine && !BeatEngine.isPlaying()) {
BeatEngine.setBPM(46);
BeatEngine.startPlayback();
}
// Ensure game state starts correctly
gameState = 'playing';
currentDockedStation = null;
dockingPrompt.style.display = 'none';
dockingMenu.style.display = 'none';
dialogMessage.style.display = 'none';
}
function updatePlayer() {
if (gameOver || isHyperspacing || gameState !== 'playing') return; // Only update player in 'playing' state
// 1. Record state every frame
rewindBuffer.push({
x: player.x,
y: player.y,
velocity: { ...player.velocity }, // Important: use spread to copy velocity object, not reference it
angle: player.angle,
shields: player.shields,
score: score,
pinkDustPoints: pinkDustPoints
});
if (rewindBuffer.length > MAX_REWIND_FRAMES) { // Use MAX_REWIND_FRAMES
rewindBuffer.shift();
}
player.isThrusting = false;
/* ─── THRUST ─── */
if (keysPressed['ArrowUp'] || keysPressed['w']) {
player.velocity.x += Math.cos(player.angle) * player.thrust;
player.velocity.y += Math.sin(player.angle) * player.thrust;
player.isThrusting = true;
if (Math.random() < 0.004) {
playDrumSound(7 + Math.floor(Math.random() * 7), audioContext.currentTime);
}
}
/* ─── ROTATE ─── */
if (keysPressed['ArrowLeft'] || keysPressed['a']) player.angle -= player.rotationSpeed;
if (keysPressed['ArrowRight'] || keysPressed['d']) player.angle += player.rotationSpeed;
/* ─── SPEED CAP + FRICTION ─── */
const speed = Math.hypot(player.velocity.x, player.velocity.y);
if (speed > player.maxSpeed) {
player.velocity.x = (player.velocity.x / speed) * player.maxSpeed;
player.velocity.y = (player.velocity.y / speed) * player.maxSpeed;
}
player.velocity.x *= player.friction;
player.velocity.y *= player.friction;
player.x += player.velocity.x;
player.y += player.velocity.y;
/* ─── SCREEN WRAP ─── */
if (player.x < -player.radius) player.x = canvasWidth + player.radius;
else if (player.x > canvasWidth+player.radius) player.x = -player.radius;
if (player.y < -player.radius) player.y = canvasHeight + player.radius;
else if (player.y > canvasHeight+player.radius) player.y = -player.radius;
// Player death check is now handled in checkCollisions, or specific rewindFromDeath logic
}
function rewindFromDeath() {
const rewindFrames = 300;
const target = Math.max(0, rewindBuffer.length - rewindFrames);
const past = rewindBuffer[target];
if (!past) { // Safety check: should not happen if buffer check is done before this call
console.warn("Rewind buffer empty or insufficient, cannot rewind.");
gameOver = true;
gameOverMessage.style.display = 'block';
explodePlayer();
BeatEngine.stopPlayback(); // Stop music on game over
return;
}
player.x = past.x;
player.y = past.y;
player.velocity = { ...past.velocity };
player.angle = past.angle;
player.shields = player.maxShields; // Full shields on rewind
score = past.score;
pinkDustPoints = Math.max(0, past.pinkDustPoints - REWIND_COST); // Deduct cost
rewindBuffer = []; // Clear buffer after rewind
gameOver = false; // Reset game over flag
gameState = 'playing'; // Ensure game state is playing after rewind
// Reset power-ups and their visual states after rewind
window.doubleLaser = false;
window.scoreMultiplier = 1;
player.maxSpeed = 4;
powerUpStates = { // Reset the tracking object for UI
speed: { active: false, expires: 0 },
score: { active: false, expires: 0 },
laser: { active: false, expires: 0 }
};
activeSpeedBoost.style.display = 'none'; // Ensure UI is hidden
activeScoreMultiplier.style.display = 'none';
activeTripleLaser.style.display = 'none';
console.log("REWOUND! Pink Dust: " + pinkDustPoints);
// Visual feedback for rewind
canvas.style.backgroundColor = 'rgba(255,255,255,0.5)';
setTimeout(() => canvas.style.backgroundColor = 'transparent', 100);
}
function explodePlayer() {
// Simple debris effect, similar to enemy explosion but for player
for(let k=0; k<20; k++) { // More debris for player
stars.push({
x: player.x, y: player.y,
size: Math.random()*3+1, // Larger debris
speedFactor: Math.random()*4 - 2,
vx: (Math.random()-0.5)*8, // Faster initial velocity
vy: (Math.random()-0.5)*8,
life: 60 // Longer life
});
}
// Add a large shockwave
shockwaves.push({
x: player.x,
y: player.y,
radius: 0,
maxRadius: 200, // Bigger explosion
alpha: 1
});
playDrumSound(5, audioContext.currentTime); // A deep tom for player explosion
}
function fireLaser() {
if (gameOver || isHyperspacing || gameState !== 'playing') return; // Only fire laser in 'playing' state
const currentTime = Date.now();
if (currentTime - lastLaserTime > LASER_COOLDOWN) {
const base = {
x: player.x + Math.cos(player.angle) * player.radius,
y: player.y + Math.sin(player.angle) * player.radius,
angle: player.angle,
speed: LASER_SPEED,
life: LASER_MAX_LIFE,
color: player.laserColor
};
lasers.push(base);
if (window.doubleLaser) { // window.doubleLaser means total 3 lasers: one base, two extras
lasers.push({ ...base, angle: player.angle + 0.1 });
lasers.push({ ...base, angle: player.angle - 0.1 });
}
lastLaserTime = currentTime;
if (window.BeatEngine && BeatEngine.isPlaying()) {
playDrumSound(6, audioContext.currentTime); // lazer tom
}
}
}
function updatePinkDust() {
if (gameState !== 'playing') return; // Only update pink dust in 'playing' state
for (let i = pinkDust.length - 1; i >= 0; i--) {
const p = pinkDust[i];
const dx = player.x - p.x;
const dy = player.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 120) {
p.vx += dx / dist * 0.1;
p.vy += dy / dist * 0.1;
}
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.97;
p.vy *= 0.97;
p.life--;
if (dist < player.radius + 4) {
score += p.value * window.scoreMultiplier;
pinkDustPoints += p.value;
player.shields += p.value * 2;
if (player.shields > player.maxShields) {
player.shields = player.maxShields;
}
pinkDust.splice(i, 1);
console.log(`Collected ${p.value} dust! Shields: ${player.shields.toFixed(0)}%, Total Dust: ${pinkDustPoints}`);
const chance = Math.random();
if (chance < 0.1 && enemies.length > 0) {
const target = enemies[Math.floor(Math.random() * enemies.length)];
pinkMissiles.push({
x: player.x,
y: player.y,
speed: 4,
radius: 6,
target: target,
life: 180
});
console.log("POWER-UP: Pink Missile!");
// No expiration for pink missiles, they fire and are gone.
} else if (chance < 0.15) {
const duration = 5000;
player.maxSpeed *= 1.5;
powerUpStates.speed = { active: true, expires: Date.now() + duration }; // Activate indicator
setTimeout(() => { player.maxSpeed = 4; powerUpStates.speed.active = false; }, duration); // Deactivate effect and indicator
console.log("POWER-UP: Speed Boost (x1.5)!");
} else if (chance < 0.2) {
const duration = 10000;
window.scoreMultiplier = 2;
powerUpStates.score = { active: true, expires: Date.now() + duration }; // Activate indicator
setTimeout(() => { window.scoreMultiplier = 1; powerUpStates.score.active = false; }, duration); // Deactivate effect and indicator
console.log("POWER-UP: Score Multiplier (x2)!");
} else if (chance < 0.25) {
const duration = 15000;
window.doubleLaser = true;
powerUpStates.laser = { active: true, expires: Date.now() + duration }; // Activate indicator
setTimeout(() => { window.doubleLaser = false; powerUpStates.laser.active = false; }, duration); // Deactivate effect and indicator
console.log("POWER-UP: Triple Laser!");
}
} else if (p.life <= 0) {
pinkDust.splice(i, 1);
}
}
}
function checkCollisions() {
if (gameOver || gameState !== 'playing') return; // Only check collisions in 'playing' state
// Player death check (moved from updatePlayer)
if (player.shields <= 0) {
if (pinkDustPoints >= REWIND_COST && rewindBuffer.length > 60) { // Check cost and buffer size
rewindFromDeath();
return; // Stop further player updates this frame
} else {
player.shields = 0;
gameOver = true;
gameOverMessage.style.display = 'block';
explodePlayer();
BeatEngine.stopPlayback(); // Stop music on game over
return;
}
}
// Player lasers vs Enemies
for (let i = lasers.length - 1; i >= 0; i--) {
for (let j = enemies.length - 1; j >= 0; j--) {
const laser = lasers[i];
const enemy = enemies[j];
const dist = Math.sqrt((laser.x - enemy.x)**2 + (laser.y - enemy.y)**2);
if (dist < enemy.radius) {
lasers.splice(i, 1); // Remove laser
enemy.shields -= 10; // Damage enemy
if (enemy.shields <= 0) {
enemies.splice(j, 1); // Remove enemy
shockwaves.push({
x: enemy.x,
y: enemy.y,
radius: 0,
maxRadius: 100,
alpha: 1
});
playDrumSound(1, audioContext.currentTime); // snare on kill
score += 100 * window.scoreMultiplier; // APPLY SCORE MULTIPLIER
// Add explosion effect (simple particle burst)
for(let k=0; k<10; k++) {
stars.push({
x: enemy.x, y: enemy.y,
size: Math.random()*2+0.5,
speedFactor: Math.random()*3 - 1.5,
vx: (Math.random()-0.5)*5,
vy: (Math.random()-0.5)*5,
life: 30 // frames
});
}
for (let k = 0; k < 5; k++) { // Spawn pink dust on enemy destruction
pinkDust.push({
x: enemy.x + (Math.random() - 0.5) * enemy.radius,
y: enemy.y + (Math.random() - 0.5) * enemy.radius,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
life: 180,
value: 1 + Math.floor(Math.random() * 5)
});
}
}
break; // Laser can only hit one enemy
}
}
}
// Enemy lasers vs Player
for (let i = enemyLasers.length - 1; i >= 0; i--) {
const laser = enemyLasers[i];
const dist = Math.sqrt((laser.x - player.x)**2 + (laser.y - player.y)**2);
if (dist < player.radius) {
enemyLasers.splice(i, 1);
player.shields -= 5; // Player takes damage
playDrumSound(0, audioContext.currentTime); // bass on hit
// Flash player ship red briefly (visual feedback)
player.color = '#f00';
setTimeout(() => { player.color = '#0f0';}, 100);
if (player.shields < 0) player.shields = 0;
}
}
// Player vs Enemies (simple circle collision)
enemies.forEach(enemy => {
const dist = Math.sqrt((player.x - enemy.x)**2 + (player.y - enemy.y)**2);
if (dist < player.radius + enemy.radius) {
// Player takes more damage from collision
player.shields -= 10;
if (player.shields < 0) player.shields = 0;
player.color = '#f00';
setTimeout(() => { player.color = '#0f0';}, 100);
// Bounce effect (simple)
const angle = Math.atan2(enemy.y - player.y, enemy.x - player.x);
player.velocity.x -= Math.cos(angle) * 1.5;
player.velocity.y -= Math.sin(angle) * 1.5;
enemy.velocity.x += Math.cos(angle) * 1;
enemy.velocity.y += Math.sin(angle) * 1;
// Damage enemy slightly from collision
enemy.shields -= 5;
if (enemy.shields <= 0 && enemies.includes(enemy)) { // Check if not already removed
const index = enemies.indexOf(enemy);
if (index > -1) {
enemies.splice(index, 1);
score += 50 * window.scoreMultiplier; // APPLY SCORE MULTIPLIER
}
}
}
});
}
function initStars() {
stars = [];
for (let i = 0; i < NUM_STARS; i++) {
stars.push({
x: Math.random() * canvasWidth,
y: Math.random() * canvasHeight,
size: Math.random() * 2 + 0.5,
speedFactor: 0.2 + Math.random() * 0.8
});
}
}
function drawStars() {
stars.forEach(star => {
ctx.fillStyle = `rgba(200, 200, 255, ${star.size / 2})`;
ctx.beginPath();
ctx.arc(star.x, star.y, star.size / 2, 0, Math.PI * 2);
ctx.fill();
});
}
function updateStars() {
if (gameState !== 'playing' && gameState !== 'gameover') return; // Stars only move when playing or during game over debris
if (isHyperspacing) {
const centerX = canvasWidth / 2;
const centerY = canvasHeight / 2;
stars.forEach(star => {
const dx = star.x - centerX;
const dy = star.y - centerY;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist > 1) {
star.x += dx / dist * 30; // Faster for hyperspace
star.y += dy / dist * 30;
}
if (star.x < -100 || star.x > canvasWidth + 100 || star.y < -100 || star.y > canvasHeight + 100) {
star.x = Math.random() * canvasWidth;
star.y = Math.random() * canvasHeight;
}
});
} else if (gameState === 'playing') { // Only move with player if playing
stars.forEach(star => {
star.x -= player.velocity.x * star.speedFactor * 0.5;
star.y -= player.velocity.y * star.speedFactor * 0.5;
// Screen wrap for stars
if (star.x < 0) star.x = canvasWidth;
if (star.x > canvasWidth) star.x = 0;
if (star.y < 0) star.y = canvasHeight;
if (star.y > canvasHeight) star.y = 0;
});
}
}
function drawPlayer() {
if (gameState === 'docked_menu' || gameState === 'town' || gameState === 'station_interior') return; // Player ship is drawn as a fixed part of the scene when docked or is not present in town/station
ctx.save();
ctx.translate(player.x, player.y);
ctx.rotate(player.angle);
ctx.strokeStyle = player.color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(player.radius, 0);
ctx.lineTo(-player.radius / 2, -player.radius / 2);
ctx.lineTo(-player.radius / 2, player.radius / 2);
ctx.closePath();
ctx.stroke();
const cooldown = Math.max(0, LASER_COOLDOWN - (Date.now() - lastLaserTime));
if (cooldown > 0) {
ctx.strokeStyle = '#ff000044';
ctx.beginPath();
ctx.arc(0, 0, player.radius + 4, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * (1 - cooldown / LASER_COOLDOWN));
ctx.stroke();
}
if (player.isThrusting && !isHyperspacing && !gameOver) {
ctx.fillStyle = '#ffa500';
ctx.beginPath();
ctx.moveTo(-player.radius / 2 - 2, 0);
ctx.lineTo(-player.radius * 1.2, Math.random() * 4 - 2);
ctx.lineTo(-player.radius / 2 - 2, 0);
ctx.fill();
}
ctx.restore();
}
function drawReticle() {
if (gameState !== 'playing') return; // Only draw reticle when playing
const reticleX = player.x + Math.cos(player.angle) * player.reticleDistance;
const reticleY = player.y + Math.sin(player.angle) * player.reticleDistance;
const reticleSize = 10;
ctx.strokeStyle = 'rgba(0, 255, 0, 0.5)'; // Semi-transparent green
ctx.lineWidth = 1;
ctx.strokeRect(reticleX - reticleSize / 2, reticleY - reticleSize / 2, reticleSize, reticleSize);
}
function updateLasers(laserArray) {
if (gameState !== 'playing') return; // Only update lasers when playing
for (let i = laserArray.length - 1; i >= 0; i--) {
const laser = laserArray[i];
laser.x += Math.cos(laser.angle) * laser.speed;
laser.y += Math.sin(laser.angle) * laser.speed;
laser.life--;
if (laser.life <= 0 || laser.x < 0 || laser.x > canvasWidth || laser.y < 0 || laser.y > canvasHeight) {
laserArray.splice(i, 1);
}
}
}
function drawLasers(laserArray) {
if (gameState !== 'playing') return; // Only draw lasers when playing
laserArray.forEach(laser => {
ctx.strokeStyle = laser.color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(laser.x, laser.y);
ctx.lineTo(
laser.x - Math.cos(laser.angle) * 10,
laser.y - Math.sin(laser.angle) * 10
);
ctx.stroke();
});
}
function spawnEnemy() {
if (enemies.length >= MAX_ENEMIES || isHyperspacing || gameOver || gameState !== 'playing') return; // Only spawn enemies in 'playing' state
const edge = Math.floor(Math.random() * 4);
let x, y;
const spawnOffset = 30; // Spawn just off screen
switch (edge) {
case 0: // Top
x = Math.random() * canvasWidth;
y = -spawnOffset;
break;
case 1: // Right
x = canvasWidth + spawnOffset;
y = Math.random() * canvasHeight;
break;
case 2: // Bottom
x = Math.random() * canvasWidth;
y = canvasHeight + spawnOffset;
break;
case 3: // Left
x = -spawnOffset;
y = Math.random() * canvasHeight;
break;
}
enemies.push({
x: x,
y: y,
radius: 18,
angle: Math.random() * Math.PI * 2,
velocity: { x: 0, y: 0 },
maxSpeed: 1 + Math.random() * 1, // Slower than player
rotationSpeed: 0.03 + Math.random() * 0.02,
thrust: 0.05,
color: '#f0f', // Magenta
laserColor: '#0ff', // Cyan
shields: 20, // Enemies are weaker
lastShotTime: Date.now() + Math.random() * ENEMY_SHOOT_COOLDOWN, // Stagger initial shots
isThrusting: false
});
}
function spawnKamikaze() {
if (gameOver || isHyperspacing || enemies.length >= MAX_ENEMIES || gameState !== 'playing') return; // Only spawn kamikaze in 'playing' state
const edge = Math.floor(Math.random() * 4);
let x, y;
const spawnOffset = 30;
switch (edge) {
case 0: x = Math.random() * canvasWidth; y = -spawnOffset; break;
case 1: x = canvasWidth + spawnOffset; y = Math.random() * canvasHeight; break;
case 2: x = Math.random() * canvasWidth; y = canvasHeight + spawnOffset; break;
case 3: x = -spawnOffset; y = Math.random() * canvasHeight; break;
}
enemies.push({
x: x,
y: y,
radius: 12,
angle: 0,
velocity: { x: 0, y: 0 },
maxSpeed: 3,
thrust: 0.2,
color: '#ff00ff',
laserColor: null,
shields: 1, // Explode on hit
isKamikaze: true
});
}
function updateEnemies() {
if (gameOver || isHyperspacing || gameState !== 'playing') return; // Only update enemies in 'playing' state
const currentTime = Date.now();
enemies.forEach(enemy => {
const dx = player.x - enemy.x;
const dy = player.y - enemy.y;
const distanceToPlayer = Math.sqrt(dx * dx + dy * dy);
let targetAngle = Math.atan2(dy, dx);
if (enemy.isKamikaze) {
const dx_k = player.x - enemy.x; // Use different variable name to avoid conflict if needed, though scope is fine here
const dy_k = player.y - enemy.y;
const dist_k = Math.sqrt(dx_k * dx_k + dy_k * dy_k);
const angle_k = Math.atan2(dy_k, dx_k);
enemy.velocity.x += Math.cos(angle_k) * enemy.thrust;
enemy.velocity.y += Math.sin(angle_k) * enemy.thrust;
const speed = Math.sqrt(enemy.velocity.x ** 2 + enemy.velocity.y ** 2);
if (speed > enemy.maxSpeed) {
enemy.velocity.x = (enemy.velocity.x / speed) * enemy.maxSpeed;
enemy.velocity.y = (enemy.velocity.y / speed) * enemy.maxSpeed;
}
enemy.velocity.x *= 0.99;
enemy.velocity.y *= 0.99;
enemy.x += enemy.velocity.x;
enemy.y += enemy.velocity.y;
return;
}
enemy.isThrusting = false;
if (distanceToPlayer < ENEMY_AGGRO_RANGE) { // Only act if player is close
// Rotate towards player
let angleDiff = targetAngle - enemy.angle;
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
if (Math.abs(angleDiff) > 0.1) { // Threshold to prevent jitter
enemy.angle += Math.sign(angleDiff) * enemy.rotationSpeed;
}
// Thrust if facing somewhat towards player or to maintain distance
if (Math.abs(angleDiff) < Math.PI / 2 || distanceToPlayer > ENEMY_SHOOT_RANGE * 0.8) {
if (distanceToPlayer > ENEMY_SHOOT_RANGE * 0.5) { // Don't get too close
enemy.velocity.x += Math.cos(enemy.angle) * enemy.thrust;
enemy.velocity.y += Math.sin(enemy.angle) * enemy.thrust;
enemy.isThrusting = true;
}
}
// Shoot if facing player and cooldown ready and in range
if (Math.abs(angleDiff) < 0.3 && // Fairly accurate aim
distanceToPlayer < ENEMY_SHOOT_RANGE &&
currentTime - enemy.lastShotTime > ENEMY_SHOOT_COOLDOWN) {
enemyLasers.push({
x: enemy.x + Math.cos(enemy.angle) * enemy.radius,
y: enemy.y + Math.sin(enemy.angle) * enemy.radius,
angle: enemy.angle,
speed: ENEMY_LASER_SPEED,
life: ENEMY_LASER_MAX_LIFE,
color: enemy.laserColor
});
enemy.lastShotTime = currentTime;
}
}
// Limit speed
const speed = Math.sqrt(enemy.velocity.x ** 2 + enemy.velocity.y ** 2);
if (speed > enemy.maxSpeed) {
enemy.velocity.x = (enemy.velocity.x / speed) * enemy.maxSpeed;
enemy.velocity.y = (enemy.velocity.y / speed) * enemy.maxSpeed;
}
// Apply friction (less friction than player for persistence)
enemy.velocity.x *= 0.995;
enemy.velocity.y *= 0.995;
enemy.x += enemy.velocity.x;
enemy.y += enemy.velocity.y;
// Enemy screen wrap (optional, or they can fly off)
if (enemy.x < 0 - enemy.radius * 2) enemy.x = canvasWidth + enemy.radius * 2;
if (enemy.x > canvasWidth + enemy.radius*2) enemy.x = 0 - enemy.radius*2;
if (enemy.y < 0 - enemy.radius*2) enemy.y = canvasHeight + enemy.radius*2;
if (enemy.y > canvasHeight + enemy.radius*2) enemy.y = 0 - enemy.radius*2;
});
if (currentTime - lastEnemySpawnTime > ENEMY_SPAWN_INTERVAL) {
if (Math.random() < 0.5) {
spawnEnemy();
} else {
spawnKamikaze();
}
lastEnemySpawnTime = currentTime;
}
}
function drawEnemies() {
if (gameState !== 'playing') return; // Only draw enemies when playing
enemies.forEach(enemy => {
ctx.save();
ctx.translate(enemy.x, enemy.y);
ctx.rotate(enemy.angle);
ctx.strokeStyle = enemy.color;
ctx.lineWidth = 2;
// Diamond shape
ctx.beginPath();
ctx.moveTo(enemy.radius, 0); // Nose
ctx.lineTo(0, -enemy.radius / 1.5); // Top point
ctx.lineTo(-enemy.radius / 2, 0); // Tail center
ctx.lineTo(0, enemy.radius / 1.5); // Bottom point
ctx.closePath();
ctx.stroke();
if (enemy.isKamikaze) {
ctx.fillStyle = '#33caffaa';
ctx.beginPath();
ctx.arc(0, 0, enemy.radius / 3, 0, Math.PI * 2);
ctx.fill();
}
if (enemy.isThrusting && !isHyperspacing && !gameOver) {
ctx.fillStyle = '#ff00ff'; // Magenta flame
ctx.beginPath();
ctx.moveTo(-enemy.radius / 2 - 2, 0);
ctx.lineTo(-enemy.radius * 0.9, Math.random() * 3 - 1.5);
ctx.lineTo(-enemy.radius / 2 - 2, 0);
ctx.fill();
}
ctx.restore();
});
}
function startHyperspace() {
if (gameOver || gameState !== 'playing') return; // Only trigger hyperspace from 'playing' state
const currentTime = Date.now();
if (currentTime - lastHyperspaceTime < HYPERSPACE_COOLDOWN || isHyperspacing) {
return;
}
isHyperspacing = true;
lastHyperspaceTime = currentTime;
hyperspaceEffectTimer = 120; // 2 seconds at 60fps
player.velocity.x = 0;
player.velocity.y = 0;
playDrumSound(14, audioContext.currentTime); // guitar whammy
enemies = []; // Clear enemies on jump
enemyLasers = [];
pinkDust = []; // Clear any floating pink dust
}
function updateHyperspace() {
if (isHyperspacing) {
hyperspaceEffectTimer--;
if (hyperspaceEffectTimer <= 0) {
isHyperspacing = false;
initStars();
player.x = Math.random() * (canvasWidth - 200) + 100; // Arrive somewhere in central area
player.y = Math.random() * (canvasHeight - 200) + 100;
player.velocity.x = 0;
player.velocity.y = 0;
lastHyperspaceTime = Date.now();
lastEnemySpawnTime = Date.now(); // Reset enemy spawn timer
}
}
}
// Special handling for player explosion debris
function updateDebrisStars() {
for (let i = stars.length - 1; i >= 0; i--) {
const star = stars[i];
if (star.life) { // Check if it's a debris particle (has a 'life' property)
star.x += star.vx;
star.y += star.vy;
star.vx *= 0.98; // Slow down
star.vy *= 0.98;
star.life--;
if (star.life <= 0) {
stars.splice(i, 1);
}
}
}
}
function updatePinkMissiles() {
if (gameState !== 'playing') return; // Only update missiles when playing
for (let i = pinkMissiles.length - 1; i >= 0; i--) {
const missile = pinkMissiles[i];
if (!missile.target || missile.life <= 0 || !enemies.includes(missile.target)) {
pinkMissiles.splice(i, 1);
continue;
}
const dx = missile.target.x - missile.x;
const dy = missile.target.y - missile.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
missile.x += Math.cos(angle) * missile.speed;
missile.y += Math.sin(angle) * missile.speed;
missile.life--;
// Collision check
if (dist < missile.target.radius + missile.radius) {
missile.target.shields -= 20;
if (missile.target.shields <= 0) {
const index = enemies.indexOf(missile.target);
if (index !== -1) enemies.splice(index, 1);
score += 100 * window.scoreMultiplier; // Apply multiplier for missile kills
}
// Optional: explosion effect
shockwaves.push({
x: missile.x,
y: missile.y,
radius: 0,
maxRadius: 80,
alpha: 1
});
// lazer tom for impact
playDrumSound(6, audioContext.currentTime);
pinkMissiles.splice(i, 1);
}
}
}
function drawPinkDust() {
if (gameState !== 'playing') return; // Only draw dust when playing
pinkDust.forEach(p => {
ctx.fillStyle = `rgba(255, 105, 180, ${p.life / 90})`;
ctx.beginPath();
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
ctx.fill();
});
}
function drawPinkMissiles() {
if (gameState !== 'playing') return; // Only draw missiles when playing
pinkMissiles.forEach(missile => {
ctx.beginPath();
ctx.fillStyle = 'rgba(255,105,180,0.9)';
ctx.arc(missile.x, missile.y, missile.radius, 0, Math.PI * 2);
ctx.fill();
});
}
// Helper to draw shockwaves, used in gameLoop
function drawShockwaves() {
shockwaves.forEach((s, i) => {
s.radius += 2;
s.alpha -= 0.02;
ctx.strokeStyle = `rgba(255,105,180,${s.alpha})`;
ctx.beginPath();
ctx.arc(s.x, s.y, s.radius, 0, Math.PI * 2);
ctx.stroke();
if (s.alpha <= 0) shockwaves.splice(i, 1);
});
}
// NEW DOCKING STATION DRAWING (in space)
function drawDockingStations() {
dockingStations.forEach(station => {
ctx.strokeStyle = station.color;
ctx.lineWidth = 2;
ctx.beginPath();
// A simple square or diamond for a station
ctx.rect(station.x - station.radius, station.y - station.radius, station.radius * 2, station.radius * 2);
ctx.stroke();
// Add a small identifier or name
ctx.font = '12px Nabla';
ctx.fillStyle = station.color;
ctx.textAlign = 'center';
ctx.fillText(station.name, station.x, station.y - station.radius - 10);
});
}
// NEW DOCKING PROXIMITY CHECK
function checkDockingProximity() {
let nearbyStation = null;
for (const station of dockingStations) {
const dist = Math.sqrt((player.x - station.x)**2 + (player.y - station.y)**2);
if (dist < player.radius + station.radius + 20) { // Slightly larger detection zone
nearbyStation = station;
break;
}
}
if (nearbyStation && gameState === 'playing') { // Only show prompt if playing
dockingPrompt.textContent = `Press E to Dock at ${nearbyStation.name}`;
dockingPrompt.style.display = 'block';
if (keysPressed['e'] || keysPressed['KeyE']) { // Added KeyE for e.code
gameState = 'docked_menu'; // Change to the new state name
currentDockedStation = nearbyStation;
selectedDockOption = 0; // Reset selected option
dockingPrompt.style.display = 'none';
BeatEngine.stopPlayback(); // Stop game music
player.velocity.x = 0; // Stop player movement
player.velocity.y = 0;
keysPressed['e'] = false; // Consume key press to prevent multiple triggers
keysPressed['KeyE'] = false;
}
} else {
dockingPrompt.style.display = 'none';
}
}
// *** MODIFIED drawDockingScene for blueprint/Star Trek UI style ***
function drawDockingScene() {
ctx.fillStyle = '#000022'; // Deep blue-black for Star Trek UI feel
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
ctx.strokeStyle = '#00ff00'; // Green for UI lines
ctx.lineWidth = 2;
ctx.font = '18px Nabla';
ctx.fillStyle = '#00ff00';
ctx.textAlign = 'left';
// Top Panel: Station Status & Info
ctx.strokeRect(20, 20, canvasWidth - 40, 100);
ctx.fillText(`<< ${currentDockedStation.name} - OPERATIONS LOG >>`, 30, 45);
ctx.fillText(`STATUS: ONLINE | LOCAL TIME: ${new Date().toLocaleTimeString()} | SHIELDS: ${player.shields.toFixed(0)}%`, 30, 75);
ctx.fillText(`PINK DUST RESERVES: ${pinkDustPoints} units`, 30, 100);
// Main Central Display: Station Schematic
const schematicX = 20;
const schematicY = 140;
const schematicWidth = canvasWidth * 0.45; // About half width
const schematicHeight = canvasHeight - 160; // Fills most of height below top panel
ctx.strokeRect(schematicX, schematicY, schematicWidth, schematicHeight);
ctx.fillText("STATION SCHEMATIC", schematicX + 10, schematicY + 30);
// Draw a simplified radial schematic
const centerX = schematicX + schematicWidth / 2;
const centerY = schematicY + schematicHeight / 2;
const outerRadius = schematicWidth * 0.4;
const innerRadius = outerRadius * 0.4;
ctx.beginPath();
ctx.arc(centerX, centerY, outerRadius, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2);
ctx.stroke();
// Radial divisions (e.g., 6 sectors)
for (let i = 0; i < 6; i++) {
const angle = (Math.PI * 2 / 6) * i;
ctx.beginPath();
ctx.moveTo(centerX + innerRadius * Math.cos(angle), centerY + innerRadius * Math.sin(angle));
ctx.lineTo(centerX + outerRadius * Math.cos(angle), centerY + outerRadius * Math.sin(angle));
ctx.stroke();
}
// Add some random internal rectangles for rooms (procedurally *styled*)
for(let i = 0; i < 8; i++) {
const randAngle = Math.random() * Math.PI * 2;
const randDist = innerRadius + Math.random() * (outerRadius - innerRadius - 20);
const rectSize = Math.random() * 20 + 15;
const rectX = centerX + randDist * Math.cos(randAngle) - rectSize / 2;
const rectY = centerY + randDist * Math.sin(randAngle) - rectSize / 2;
ctx.strokeRect(rectX, rectY, rectSize, rectSize);
}
ctx.font = '12px Nabla';
ctx.fillStyle = '#00ff00';
ctx.textAlign = 'center';
ctx.fillText('ACCESS POINTS', centerX, centerY + outerRadius + 20);
// Right Panel: Ship Systems & Data
const rightPanelX = schematicX + schematicWidth + 20;
const rightPanelY = schematicY;
const rightPanelWidth = canvasWidth - rightPanelX - 20;
const rightPanelHeight = schematicHeight;
ctx.strokeRect(rightPanelX, rightPanelY, rightPanelWidth, rightPanelHeight);
ctx.fillText("SHIP SYSTEMS STATUS", rightPanelX + 10, rightPanelY + 30);
// Display key metrics in a structured way
ctx.font = '16px Nabla';
ctx.fillStyle = '#00ff00';
let currentY_text = rightPanelY + 60; // Renamed to avoid conflict
const lineHeight = 25;
ctx.fillText(`POWER CORE: ${Math.floor(Math.random() * 100) + 1}% OPTIMAL`, rightPanelX + 15, currentY_text); currentY_text += lineHeight;
ctx.fillText(`NAVIGATION: ACTIVE`, rightPanelX + 15, currentY_text); currentY_text += lineHeight;
ctx.fillText(`COMMUNICATION: SECURE`, rightPanelX + 15, currentY_text); currentY_text += lineHeight;
ctx.fillText(`LIFE SUPPORT: ${Math.floor(Math.random() * 20) + 80}%`, rightPanelX + 15, currentY_text); currentY_text += lineHeight;
ctx.fillText(`ARMAMENT: STANDBY`, rightPanelX + 15, currentY_text); currentY_text += lineHeight;
ctx.fillText(`CARGO HOLD: ${Math.floor(pinkDustPoints / 10)}/${Math.floor(pinkDustPoints / 5) + 50} units`, rightPanelX + 15, currentY_text); currentY_text += lineHeight;
// Simulate some active data
ctx.font = '14px Nabla';
ctx.fillStyle = '#00ffff'; // Cyan for dynamic data
currentY_text += lineHeight;
ctx.fillText(`TEMP: ${ (25 + Math.random() * 5).toFixed(1) }°C | HUMIDITY: ${ (40 + Math.random() * 10).toFixed(1) }%`, rightPanelX + 15, currentY_text);
currentY_text += lineHeight;
ctx.fillText(`GRAVITY: 1.00G | ATMOS: O2/N2`, rightPanelX + 15, currentY_text);
// Add small blinking lights or animated lines
ctx.fillStyle = `rgba(0,255,0,${0.5 + Math.sin(Date.now() * 0.005) * 0.5})`;
ctx.beginPath();
ctx.arc(rightPanelX + rightPanelWidth - 25, rightPanelY + 25, 5, 0, Math.PI * 2);
ctx.fill();
}
// NEW DOCKING MENU DRAWING
function drawDockingMenu() {
if (!currentDockedStation) return; // Ensure there's a station to draw menu for
let menuHtml = '';
currentDockedStation.menu.forEach((item, index) => {
const isSelected = index === selectedDockOption;
menuHtml += `<div style="color: ${isSelected ? '#fff' : '#0f0'}; font-weight: ${isSelected ? 'bold' : 'normal'}; background-color: ${isSelected ? 'rgba(0,255,0,0.2)' : 'transparent'}; padding: 5px; cursor: pointer;">${item}</div>`;
});
dockingMenu.innerHTML = menuHtml;
// Position menu explicitly over the right panel for Star Trek UI look
dockingMenu.style.position = 'absolute';
dockingMenu.style.top = '150px'; // Adjust as needed
dockingMenu.style.right = '40px'; // Adjust as needed
dockingMenu.style.width = '300px'; // Adjust as needed
dockingMenu.style.border = '2px solid #0f0';
dockingMenu.style.padding = '10px';
dockingMenu.style.backgroundColor = 'rgba(0,0,34,0.8)';
dockingMenu.style.zIndex = '100'; // Ensure it's on top of canvas
dockingMenu.style.display = 'block'; // Make sure the HTML element is visible
}
// --- NEW FUNCTIONS FOR TOWN MODE ---
function updateTownPlayer() {
if (gameState !== 'town') return;
let nextX = player2D.x;
let nextY = player2D.y;
let moved = false;
if (keysPressed['ArrowUp'] || keysPressed['w'] || keysPressed['KeyW']) {
nextY -= player2D.speed;
player2D.facingDirection = 'up';
moved = true;
}
if (keysPressed['ArrowDown'] || keysPressed['s'] || keysPressed['KeyS']) {
nextY += player2D.speed;
player2D.facingDirection = 'down';
moved = true;
}
if (keysPressed['ArrowLeft'] || keysPressed['a'] || keysPressed['KeyA']) {
nextX -= player2D.speed;
player2D.facingDirection = 'left';
moved = true;
}
if (keysPressed['ArrowRight'] || keysPressed['d'] || keysPressed['KeyD']) {
nextX += player2D.speed;
player2D.facingDirection = 'right';
moved = true;
}
// Basic collision detection with town map elements (AABB collision with rectangles)
let collision = false;
for (const element of townMap.elements) {
if (element.type === 'building') { // Only collide with buildings
// Define player's bounding box for collision check
const playerLeft = nextX - player2D.radius;
const playerRight = nextX + player2D.radius;
const playerTop = nextY - player2D.radius;
const playerBottom = nextY + player2D.radius;
const elementLeft = element.x;
const elementRight = element.x + element.width;
const elementTop = element.y;
const elementBottom = element.y + element.height;
if (playerRight > elementLeft &&
playerLeft < elementRight &&
playerBottom > elementTop &&
playerTop < elementBottom) {
collision = true;
break;
}
}
}
if (!collision) {
// Keep player within map boundaries
player2D.x = Math.max(0, Math.min(nextX, townMap.width));
player2D.y = Math.max(0, Math.min(nextY, townMap.height));
}
// Play subtle walking sound if moving
// (currently no dedicated walking sound, can add later)
if (moved && Math.random() < 0.005) { // Reduce frequency to prevent spam
// play a very light percussion sound, e.g., a quiet hi-hat or click
// playDrumSound(2, audioContext.currentTime); // Hi-Hat for subtle footsteps
}
}
function handleTownInteractions() {
if (gameState !== 'town') return;
let interactedThisFrame = false; // To prevent multiple interactions on one keypress
// Check for general interactions (dialogs, specific building actions)
for (const interaction of townMap.interactions) {
const dist = Math.sqrt((player2D.x - interaction.x)**2 + (player2D.y - interaction.y)**2);
if (dist < player2D.radius + interaction.radius) {
if (keysPressed['e'] || keysPressed['KeyE']) { // Press 'E' to interact
if (interaction.type === 'dialog') {
showMessage(interaction.message);
} else if (interaction.type === 're_dock') {
// This is the "ship dock" area, pressing E here takes you back to the docked_menu view
gameState = 'docked_menu';
currentDockedStation = dockingStations[0]; // Assuming Alpha Outpost is the only one for now
selectedDockOption = currentDockedStation.menu.length - 1; // Default to 'LEAVE DOCK'
BeatEngine.stopPlayback(); // Stop town music / current music
}
interactedThisFrame = true;
keysPressed['e'] = false; // Consume key press
keysPressed['KeyE'] = false;
break; // Only one interaction per key press
}
}
}
// Check for "to space" exit point (no 'E' needed, just walk over it)
for (const exit of townMap.exitPoints) {
// Only check if it's an exit to space (could have other types later)
const dist = Math.sqrt((player2D.x - exit.x)**2 + (player2D.y - exit.y)**2);
if (dist < player2D.radius + exit.radius) {
showMessage("Entering outer space...");
gameState = 'playing';
// Place player near a relevant docking station in space, or simply recenter
// For now, place near the Alpha Outpost
player.x = dockingStations[0].x + dockingStations[0].radius + 50;
player.y = dockingStations[0].y;
startBeat(); // Resume combat music
interactedThisFrame = true;
break; // Only one exit per frame
}
}
return interactedThisFrame;
}
function drawTownScene() {
ctx.fillStyle = '#0a0a0a'; // Dark ground/background
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Calculate camera offset
// This keeps the player2D generally centered on the screen,
// clamped to map boundaries so camera doesn't show black space when player is at map edge.
let cameraX = player2D.x - canvasWidth / 2;
let cameraY = player2D.y - canvasHeight / 2;
// Clamp camera to map boundaries
cameraX = Math.max(0, Math.min(cameraX, townMap.width - canvasWidth));
cameraY = Math.max(0, Math.min(cameraY, townMap.height - canvasHeight));
ctx.lineWidth = 2;
// Draw map elements
townMap.elements.forEach(element => {
const drawX = element.x - cameraX;
const drawY = element.y - cameraY;
// Only draw if within screen bounds (with a small buffer)
const buffer = 50;
// Simplified visibility check for rectangular elements
let isVisible = false;
if (element.width && element.height) {
isVisible = drawX + element.width > -buffer && drawX < canvasWidth + buffer &&
drawY + element.height > -buffer && drawY < canvasHeight + buffer;
} else if (element.radius) { // For circular decorations
isVisible = drawX + element.radius > -buffer && drawX - element.radius < canvasWidth + buffer &&
drawY + element.radius > -buffer && drawY - element.radius < canvasHeight + buffer;
}
if (isVisible) {
ctx.fillStyle = element.color;
ctx.strokeStyle = '#0f0'; // Consistent retro stroke
if (element.type === 'path') {
ctx.fillRect(drawX, drawY, element.width, element.height);
ctx.strokeRect(drawX, drawY, element.width, element.height);
} else if (element.type === 'building') {
ctx.fillRect(drawX, drawY, element.width, element.height);
ctx.strokeRect(drawX, drawY, element.width, element.height);
// Add simple window details
ctx.strokeStyle = '#0ff'; // Cyan for windows
ctx.lineWidth = 1;
ctx.strokeRect(drawX + element.width * 0.1, drawY + element.height * 0.1, element.width * 0.2, element.height * 0.2);
ctx.strokeRect(drawX + element.width * 0.7, drawY + element.height * 0.1, element.width * 0.2, element.height * 0.2);
ctx.lineWidth = 2; // Reset for next elements
} else if (element.type === 'decoration' && element.radius) {
ctx.beginPath();
ctx.arc(drawX, drawY, element.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
}
});
// Draw interaction zones (for debugging/visual feedback)
townMap.interactions.forEach(interaction => {
const drawX = interaction.x - cameraX;
const drawY = interaction.y - cameraY;
const dist = Math.sqrt((player2D.x - interaction.x)**2 + (player2D.y - interaction.y)**2);
if (dist < player2D.radius + interaction.radius) {
ctx.strokeStyle = interaction.color || '#ff0'; // Yellow if active
ctx.fillStyle = 'rgba(255,255,0,0.1)';
} else {
ctx.strokeStyle = interaction.color || '#0a0'; // Dim green if inactive
ctx.fillStyle = 'transparent';
}
ctx.beginPath();
ctx.arc(drawX, drawY, interaction.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.font = '12px monospace';
ctx.fillStyle = ctx.strokeStyle;
ctx.textAlign = 'center';
if (interaction.type === 'dialog') {
ctx.fillText('Interact (E)', drawX, drawY + interaction.radius + 20);
} else if (interaction.type === 're_dock') {
ctx.fillText('Ship Dock (E)', drawX, drawY + interaction.radius + 20);
}
});
// Draw town exit points
townMap.exitPoints.forEach(exit => {
const drawX = exit.x - cameraX;
const drawY = exit.y - cameraY;
const dist = Math.sqrt((player2D.x - exit.x)**2 + (player2D.y - exit.y)**2);
if (dist < player2D.radius + exit.radius) {
ctx.strokeStyle = '#f00'; // Red if active
ctx.fillStyle = 'rgba(255,0,0,0.1)';
} else {
ctx.strokeStyle = '#a00'; // Dim red if inactive
ctx.fillStyle = 'transparent';
}
ctx.beginPath();
ctx.arc(drawX, drawY, exit.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.font = '16px monospace';
ctx.fillStyle = ctx.strokeStyle;
ctx.textAlign = 'center';
ctx.fillText('TO SPACE', drawX, drawY + exit.radius + 20);
});
// Pass the camera offsets, player's actual position isn't needed here
drawPlayer2D(player2D, cameraX, cameraY);
}
function drawPlayer2D(playerObj, cameraX, cameraY) {
// Player is drawn relative to the camera, so their screen position is
// playerObj.x - cameraX, playerObj.y - cameraY
const drawX = playerObj.x - cameraX;
const drawY = playerObj.y - cameraY;
ctx.save();
ctx.translate(drawX, drawY);
ctx.strokeStyle = playerObj.color;
ctx.lineWidth = 2;
// Player is a green triangle (same as ship, but smaller/simpler for 2D map)
// Adjust triangle orientation based on facingDirection
let angle = -Math.PI / 2; // Default: up
if (playerObj.facingDirection === 'down') angle = Math.PI / 2;
else if (playerObj.facingDirection === 'left') angle = Math.PI;
else if (playerObj.facingDirection === 'right') angle = 0;
ctx.rotate(angle);
ctx.beginPath();
// Simplified triangle for 2D map, size based on player2D.radius
ctx.moveTo(playerObj.radius, 0); // Point
ctx.lineTo(-playerObj.radius, -playerObj.radius); // Base left
ctx.lineTo(-playerObj.radius, playerObj.radius); // Base right
ctx.closePath();
ctx.stroke();
ctx.fillStyle = playerObj.color + "aa"; // Semi-transparent fill
ctx.fill();
ctx.restore();
}
// Function to check for AABB intersection
function checkAABBCollision(rect1, rect2, padding = 0) {
return rect1.x < rect2.x + rect2.width + padding &&
rect1.x + rect1.width > rect2.x - padding &&
rect1.y < rect2.y + rect2.height + padding &&
rect1.y + rect1.height > rect2.y - padding;
}
function handleDockingMenuSelection(optionIndex) {
if (!currentDockedStation) return;
const selectedOptionText = currentDockedStation.menu[optionIndex];
if (selectedOptionText === 'REPAIR SHIELDS') {
const repairNeeded = player.maxShields - player.shields;
if (repairNeeded <= 0) {
showMessage("Shields already full!");
return;
}
const costPerShield = 0.5; // Example: 0.5 dust per shield point
const actualCost = Math.ceil(repairNeeded * costPerShield); // Calculate cost based on repair needed
if (pinkDustPoints >= actualCost) {
player.shields = player.maxShields;
pinkDustPoints -= actualCost;
showMessage(`Shields fully repaired for ${actualCost} dust.`);
console.log("Shields repaired! Dust: " + pinkDustPoints);
} else {
showMessage(`Not enough dust! Need ${actualCost} dust.`);
console.log("Not enough dust to repair!");
}
} else if (selectedOptionText === 'TRADE RESOURCES') {
// Transition to station interior walk-around mode
gameState = 'station_interior';
dockingMenu.style.display = 'none';
generateStationInterior(currentDockedStation.name); // Generate a new interior each time
BeatEngine.stopPlayback(); // Stop current music
} else if (selectedOptionText === 'LEAVE DOCK') {
gameState = 'town'; // Now "Leave Dock" transitions to town map
dockingMenu.style.display = 'none'; // Hide the menu
// Position player2D at the docking station's exit point
player2D.x = townMap.entryPoints.dockingStationExit.x;
player2D.y = townMap.entryPoints.dockingStationExit.y + 100; // Offset slightly for better placement
BeatEngine.stopPlayback(); // Stop combat music, town music can be added here
currentDockedStation = null; // Clear current docked station
}
}
function updateStationPlayer() {
if (gameState !== 'station_interior') return;
let nextX = player2D.x;
let nextY = player2D.y;
let moved = false;
if (keysPressed['ArrowUp'] || keysPressed['w'] || keysPressed['KeyW']) {
nextY -= player2D.speed;
player2D.facingDirection = 'up';
moved = true;
}
if (keysPressed['ArrowDown'] || keysPressed['s'] || keysPressed['KeyS']) {
nextY += player2D.speed;
player2D.facingDirection = 'down';
moved = true;
}
if (keysPressed['ArrowLeft'] || keysPressed['a'] || keysPressed['KeyA']) {
nextX -= player2D.speed;
player2D.facingDirection = 'left';
moved = true;
}
if (keysPressed['ArrowRight'] || keysPressed['d'] || keysPressed['KeyD']) {
nextX += player2D.speed;
player2D.facingDirection = 'right';
moved = true;
}
// Collision detection: Check if the future position is within *any* traversable area
let canMove = false;
const futurePlayerBounds = {
left: nextX - player2D.radius,
right: nextX + player2D.radius,
top: nextY - player2D.radius,
bottom: nextY + player2D.radius
};
// Check if the player can move into the new position (i.e., it's a valid room, corridor, or door)
for (const element of currentStationInterior.elements) {
// Only consider traversable elements for collision
if (element.type === 'room' || element.type === 'corridor' || element.type === 'core' || element.type === 'door') {
// Special handling for core (circle)
if (element.type === 'core') {
const dist = Math.sqrt((nextX - element.x)**2 + (nextY - element.y)**2);
// The original code had a bug here. This new logic is more permissive
// and assumes if you are in the core, you can move within it.
if (dist < element.radius) {
canMove = true;
break;
}
} else { // Rectangular elements (room, corridor, door)
const elementBounds = {
left: element.x,
right: element.x + element.width,
top: element.y,
bottom: element.y + element.height
};
// Check for intersection with the traversable element
if (futurePlayerBounds.right > elementBounds.left &&
futurePlayerBounds.left < elementBounds.right &&
futurePlayerBounds.bottom > elementBounds.top &&
futurePlayerBounds.top < elementBounds.bottom) {
canMove = true;
break;
}
}
}
}
if (canMove) {
player2D.x = nextX;
player2D.y = nextY;
} else {
// If movement is blocked, try to slide along axis (simple collision response)
// Try Y-axis movement first (if original intent was Y movement or both)
if (nextY !== player2D.y) {
const tempPlayerX = player2D.x; // Keep current X, attempt new Y
let canMoveY = false;
const tempFuturePlayerBoundsY = {
left: tempPlayerX - player2D.radius,
right: tempPlayerX + player2D.radius,
top: nextY - player2D.radius,
bottom: nextY + player2D.radius
};
for (const element of currentStationInterior.elements) {
if (element.type === 'room' || element.type === 'corridor' || element.type === 'core' || element.type === 'door') {
if (element.type === 'core') {
const dist = Math.sqrt((tempPlayerX - element.x)**2 + (nextY - element.y)**2);
if (dist < element.radius) {
canMoveY = true;
break;
}
} else {
const elementBounds = {
left: element.x,
right: element.x + element.width,
top: element.y,
bottom: element.y + element.height
};
if (tempFuturePlayerBoundsY.right > elementBounds.left &&
tempFuturePlayerBoundsY.left < elementBounds.right &&
tempFuturePlayerBoundsY.bottom > elementBounds.top &&
tempFuturePlayerBoundsY.top < elementBounds.bottom) {
canMoveY = true;
break;
}
}
}
}
if (canMoveY) {
player2D.y = nextY;
}
}
// Try X-axis movement (if original intent was X movement or both, and Y slide didn't happen or wasn't primary)
if (nextX !== player2D.x) {
const tempPlayerY = player2D.y; // Keep current Y (possibly updated by Y-slide), attempt new X
let canMoveX = false;
const tempFuturePlayerBoundsX = {
left: nextX - player2D.radius,
right: nextX + player2D.radius,
top: tempPlayerY - player2D.radius,
bottom: tempPlayerY + player2D.radius
};
for (const element of currentStationInterior.elements) {
if (element.type === 'room' || element.type === 'corridor' || element.type === 'core' || element.type === 'door') {
if (element.type === 'core') {
const dist = Math.sqrt((nextX - element.x)**2 + (tempPlayerY - element.y)**2);
if (dist < element.radius) {
canMoveX = true;
break;
}
} else {
const elementBounds = {
left: element.x,
right: element.x + element.width,
top: element.y,
bottom: element.y + element.height
};
if (tempFuturePlayerBoundsX.right > elementBounds.left &&
tempFuturePlayerBoundsX.left < elementBounds.right &&
tempFuturePlayerBoundsX.bottom > elementBounds.top &&
tempFuturePlayerBoundsX.top < elementBounds.bottom) {
canMoveX = true;
break;
}
}
}
}
if (canMoveX) {
player2D.x = nextX;
}
}
}
if (moved && Math.random() < 0.005) {
// play a very light percussion sound, e.g., a quiet hi-hat or click
}
}
function handleStationInteractions() {
if (gameState !== 'station_interior') return;
let interactedThisFrame = false;
for (const interaction of currentStationInterior.interactions) {
const dist = Math.sqrt((player2D.x - interaction.x)**2 + (player2D.y - interaction.y)**2);
if (dist < player2D.radius + interaction.radius) {
if (keysPressed['e'] || keysPressed['KeyE']) {
if (interaction.type === 'dialog') {
showMessage(interaction.message);
} else if (interaction.type === 'shop') {
showMessage("You try to browse, but this shop is still under construction!");
}
else if (interaction.type === 'exit_station') {
gameState = 'docked_menu'; // Return to the docking menu
BeatEngine.stopPlayback(); // Stop any station music
}
interactedThisFrame = true;
keysPressed['e'] = false; // Consume key press
keysPressed['KeyE'] = false;
break;
}
}
}
return interactedThisFrame;
}
function gameLoop() {
// Clear the canvas and draw the background
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
if (gameOver) { // If game over, only draw debris and game over message
updateDebrisStars(); // Continue debris animation
drawStars(); // Draw remaining stars/debris
drawUI(); // Update UI to show game over message
requestAnimationFrame(gameLoop);
return;
}
// Update beat engine parameters only when playing (space combat)
if (gameState === 'playing') {
fakeKnobs.octaveControl = Math.floor(player.velocity.y);
fakeKnobs.lfoRateControl = Math.abs(player.angle % 1) * 10;
fakeKnobs.lfoDepthControl = Math.abs(player.velocity.x) * 5;
fakeKnobs.volumeControl = Math.min(0.3, Math.max(0.05, player.shields / 100)); // Scale volume by shields
} else {
// When not in space combat, mute or set subtle audio parameters
fakeKnobs.volumeControl = 0.01; // Very low volume for non-combat states
}
// ======= PAUSE HANDLING START =======
if (paused) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; // Semi-transparent overlay
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
ctx.font = '40px Nabla';
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.fillText('PAUSED', canvasWidth / 2, canvasHeight / 2);
// Still need to call requestAnimationFrame to keep the loop alive
// and responsive to unpausing.
requestAnimationFrame(gameLoop);
drawStars()
return; // Skip all other game logic and drawing
}
// ======= PAUSE HANDLING END =======
// State-dependent rendering and logic
switch (gameState) {
case 'playing':
updateStars();
drawStars();
updatePinkDust();
drawPinkDust();
updatePinkMissiles();
drawPinkMissiles();
updatePlayer();
updateEnemies();
if (keysPressed[' '] || keysPressed['Spacebar']) {
fireLaser();
}
updateLasers(lasers);
updateLasers(enemyLasers);
checkCollisions();
updateHyperspace();
drawPlayer();
drawEnemies();
drawShockwaves(); // Helper function for existing shockwave drawing
drawLasers(lasers);
drawLasers(enemyLasers);
drawReticle();
drawDockingStations(); // Draw stations in space
checkDockingProximity(); // Check if player is near a station
break;
case 'docked_menu': // Renamed from 'docked' for clarity
drawDockingScene(); // Draw the static docking scene (now blueprint style)
drawDockingMenu(); // Draw the interactive menu (HTML overlay)
break;
case 'town':
updateTownPlayer();
handleTownInteractions(); // Check for player interactions in town
drawTownScene(); // The "Earthbound" style town map
break;
case 'station_interior':
updateStationPlayer();
handleStationInteractions();
drawStationInterior(); // The procedural blueprint
break;
}
drawUI(); // Always draw UI, which now handles visibility based on gameState
drawRadar(); // Radar visibility handled within drawRadar based on gameState
requestAnimationFrame(gameLoop);
}
// Update drawUI to handle new 'town' state and boost display
function drawUI() {
const gameUIElements = document.querySelectorAll('#ui > div, #controls-info, #radar-container');
if (gameState === 'docked_menu' || gameState === 'town' || gameState === 'station_interior') {
gameUIElements.forEach(el => el.style.display = 'none');
dockingPrompt.style.display = 'none';
// Explicitly hide power-ups here as they are part of gameUIElements when selector is '#ui > div'
activeSpeedBoost.style.display = 'none';
activeScoreMultiplier.style.display = 'none';
activeTripleLaser.style.display = 'none';
} else { // 'playing' or 'gameover' state
gameUIElements.forEach(el => el.style.display = ''); // Show all general UI elements
// ======= BOOST UI HANDLING START =======
if (powerUpStates.speed.active) {
activeSpeedBoost.textContent = 'SPEED BOOST';
activeSpeedBoost.style.display = 'block';
} else {
activeSpeedBoost.style.display = 'none';
activeSpeedBoost.textContent = ''; // Clear text when inactive
}
if (powerUpStates.score.active) {
activeScoreMultiplier.textContent = 'SCORE x2';
activeScoreMultiplier.style.display = 'block';
} else {
activeScoreMultiplier.style.display = 'none';
activeScoreMultiplier.textContent = ''; // Clear text when inactive
}
if (powerUpStates.laser.active) {
activeTripleLaser.textContent = 'TRIPLE LASER';
activeTripleLaser.style.display = 'block';
} else {
activeTripleLaser.style.display = 'none';
activeTripleLaser.textContent = ''; // Clear text when inactive
}
// ======= BOOST UI HANDLING END =======
// Docking prompt visibility is handled by checkDockingProximity, so don't force display here.
}
// Always update core indicators if they are visible (i.e., in 'playing' or 'gameover')
if (gameState === 'playing' || gameState === 'gameover') {
const speed = Math.sqrt(player.velocity.x ** 2 + player.velocity.y ** 2);
speedIndicator.textContent = `SPEED: ${speed.toFixed(1)}`;
pinkDustIndicator.textContent = `DUST: ${pinkDustPoints}`;
let headingDegrees = (player.angle * 180 / Math.PI) % 360;
if (headingDegrees < 0) headingDegrees += 360;
headingIndicator.textContent = `HDG: ${Math.round(headingDegrees)}°`;
const shieldPercentage = Math.max(0, (player.shields / player.maxShields) * 100);
shieldIndicator.textContent = `SHIELDS: ${shieldPercentage.toFixed(0)}%`;
shieldIndicator.style.color = shieldPercentage < 30 ? '#f00' : (shieldPercentage < 60 ? '#ff0' : '#0f0');
const laserCooldownRemaining = Math.max(0, LASER_COOLDOWN - (Date.now() - lastLaserTime));
laserStatus.textContent = `LASER: ${laserCooldownRemaining === 0 ? 'READY' : (laserCooldownRemaining / 1000).toFixed(1) + 's'}`;
const hsCooldownRemaining = Math.max(0, HYPERSPACE_COOLDOWN - (Date.now() - lastHyperspaceTime));
if (isHyperspacing) {
hyperspaceStatus.textContent = `HYPERSPACE: JUMPING...`;
} else {
hyperspaceStatus.textContent = `HYPERSPACE: ${hsCooldownRemaining === 0 ? 'READY' : (hsCooldownRemaining / 1000).toFixed(1) + 's'}`;
}
scoreIndicator.textContent = `SCORE: ${score}`;
enemyIndicator.textContent = `ENEMIES: ${enemies.length}`;
}
}
// Update radar drawing visibility
function drawRadar() {
if (gameState !== 'playing') { // Only draw radar when in space combat
radarCtx.clearRect(0, 0, radarCanvas.width, radarCanvas.height); // Clear radar if not visible
return;
}
// ... existing radar drawing logic ...
const radarX = radarCanvas.width / 2;
const radarY = radarCanvas.height / 2;
const radarRadius = radarCanvas.width / 2 - 5; // Leave a small border
const radarRange = 800; // Game units visible on radar
radarCtx.clearRect(0, 0, radarCanvas.width, radarCanvas.height);
const sweepAngle = (Date.now() % 2000) / 2000 * Math.PI * 2;
radarCtx.strokeStyle = 'rgba(0,255,0,0.4)';
radarCtx.beginPath();
radarCtx.moveTo(radarX, radarY);
radarCtx.arc(radarX, radarY, radarRadius, sweepAngle, sweepAngle + 0.05);
radarCtx.lineTo(radarX, radarY);
radarCtx.stroke();
// Player blip (always at center, pointing up)
radarCtx.fillStyle = player.color;
radarCtx.beginPath();
radarCtx.moveTo(radarX, radarY - 3); // Nose
radarCtx.lineTo(radarX - 2, radarY + 2);
radarCtx.lineTo(radarX + 2, radarY + 2);
ctx.closePath();
ctx.fill();
// Enemy blips
enemies.forEach(enemy => {
// Calculate enemy position relative to player
let dx = enemy.x - player.x;
let dy = enemy.y - player.y;
// Rotate these coordinates to align with player's orientation (player always "up" on radar)
const s = Math.sin(-player.angle - Math.PI/2); // Radar rotation to make player point up
const c = Math.cos(-player.angle - Math.PI/2);
let relX = dx * c - dy * s;
let relY = dx * s + dy * c;
const distOnRadar = Math.sqrt(relX * relX + relY * relY) * (radarRadius / radarRange);
if (distOnRadar < radarRadius) { // Only draw if within radar's displayable radius
const radarBlipX = radarX + relX * (radarRadius / radarRange);
const radarBlipY = radarY + relY * (radarRadius / radarRange);
radarCtx.fillStyle = enemy.isKamikaze ? '#33caff' : enemy.color;
radarCtx.fillRect(radarBlipX - 1.5, radarBlipY - 1.5, 3, 3); // Small square for enemy
}
});
}
// General key event listener for all states
window.addEventListener('keydown', (e) => {
// Prevent default action for arrow keys, space, and Enter to stop page scrolling
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Spacebar', ' ', 'Enter'].includes(e.key) ||
['KeyW', 'KeyA', 'KeyS', 'KeyD', 'KeyP', 'KeyH', 'KeyK', 'KeyR', 'KeyE'].includes(e.code) // Also for e.code
) {
e.preventDefault();
}
keysPressed[e.key] = true;
keysPressed[e.code] = true; // Use e.code for more reliable key detection
if (e.key.toLowerCase() === 'p' || e.code === 'KeyP') { // PAUSE TOGGLE
if (!gameOver) { // Don't allow pause if game over
paused = !paused;
if (paused) {
BeatEngine.stopPlayback(); // Optionally stop music on pause
} else {
// Optionally resume music if it was playing and game state is 'playing'
if (gameState === 'playing' && !BeatEngine.isPlaying()) {
startBeat();
}
}
}
}
// Game Over restart
if (gameOver && (e.key.toLowerCase() === 'r' || e.code === 'KeyR')) {
initGame();
}
// Handle game state specific inputs (only if not paused and not game over)
if (!paused && !gameOver) {
if (gameState === 'docked_menu') {
if (e.key === 'ArrowUp') {
selectedDockOption = Math.max(0, selectedDockOption - 1);
} else if (e.key === 'ArrowDown') {
selectedDockOption = Math.min(currentDockedStation.menu.length - 1, selectedDockOption + 1);
} else if (e.key === 'Enter') {
handleDockingMenuSelection(selectedDockOption);
}
} else if (gameState === 'playing') { // Only allow these actions when playing
if (e.key.toLowerCase() === 'h' || e.code === 'KeyH') startHyperspace();
if (e.key.toLowerCase() === 'k' || e.code === 'KeyK') spawnKamikaze(); // Test for kamikaze
}
// Quick dock/new interior for QA (also check e.code)
if (e.key.toLowerCase() === 'd' || e.code === 'KeyD') {
if (gameState === 'playing' || gameState === 'docked_menu' || gameState === 'town' || gameState === 'station_interior') {
currentDockedStation = dockingStations[0] || { // Ensure a station exists or create a dummy
name: 'QA Hub',
menu: ['REPAIR SHIELDS', 'TRADE RESOURCES', 'LEAVE DOCK'] // Dummy menu
};
if (dockingStations.length === 0) dockingStations.push(currentDockedStation); // Add if it was a dummy
gameState = 'station_interior';
if(dockingMenu) dockingMenu.style.display = 'none'; // Hide UI
generateStationInterior(currentDockedStation.name); // Generate a fresh interior
BeatEngine.stopPlayback(); // Stop music if any
}
}
}
});
window.addEventListener('keyup', (e) => {
keysPressed[e.key] = false;
keysPressed[e.code] = false;
});
initGame();
gameLoop();
// Core BeatEngine library (simplified for CodePen usage)
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
let bpm = 60;
let beatDuration = 15 / bpm;
// let sequence = []; // This variable is declared but never used in the provided code.
let currentStep = 0;
let nextNoteTime = 0;
let isPlaying = false;
let acidMode = false;
let whammyMode = false;
function playSimpleKick(time) {
if (audioContext.state === 'suspended') return;
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(120, time);
osc.frequency.exponentialRampToValueAtTime(40, time + 0.5);
gain.gain.setValueAtTime(1, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.5);
osc.connect(gain).connect(audioContext.destination);
osc.start(time);
osc.stop(time + 0.5);
}
// PATCH: All synth parameter reads now use fakeKnobs only — no DOM lookups left.
function playBassDrum(time, row) { // 'row' parameter is unused
if (audioContext.state === 'suspended') return;
const baseFrequency = 60;
const frequencyVariation = 45;
const frequency = baseFrequency + (Math.random() * frequencyVariation - frequencyVariation / 2.5);
const octave = fakeKnobs.octaveControl;
const adjustedFrequency = frequency * Math.pow(2.4, octave);
const lfoRate = fakeKnobs.lfoRateControl;
const lfoDepth = fakeKnobs.lfoDepthControl;
const oscillator = audioContext.createOscillator();
const lfo = audioContext.createOscillator();
let lfoGain = audioContext.createGain(); // Declare with let for scope
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
lfo.connect(lfoGain);
lfoGain.connect(oscillator.frequency);
lfo.frequency.setValueAtTime(lfoRate, time);
lfoGain.gain.setValueAtTime(lfoDepth, time);
oscillator.frequency.setValueAtTime(adjustedFrequency, time);
if (acidMode) {
oscillator.type = fakeKnobs.acidOscillatorType;
const filter = audioContext.createBiquadFilter();
filter.type = fakeKnobs.acidFilterType;
filter.frequency.setValueAtTime(fakeKnobs.acidFilterFrequency, time);
filter.Q.setValueAtTime(fakeKnobs.acidFilterResonance / 1000, time);
oscillator.connect(filter);
filter.connect(gainNode);
} else {
oscillator.type = "sine";
}
const baseGain = 1.5;
const gainVariation = 1.5;
const gainVal = baseGain + (Math.random() * gainVariation - gainVariation / 2); // Renamed 'gain' to 'gainVal'
gainNode.gain.setValueAtTime(gainVal * fakeKnobs.volumeControl, time); // Apply master volume
gainNode.gain.exponentialRampToValueAtTime(0.05 * fakeKnobs.volumeControl, time + 0.75);
oscillator.start(time);
lfo.start(time);
oscillator.stop(time + 1);
lfo.stop(time + 1);
}
function playPianoSound(keyIndex, time) {
if (audioContext.state === 'suspended') return;
const frequencies = [261.63, 293.66, 329.63, 349.23, 392.0, 440.0, 493.88];
const frequency = frequencies[keyIndex];
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.type = "sine";
let targetFrequency = frequency * Math.pow(2, fakeKnobs.pitchControl / 18);
oscillator.frequency.setValueAtTime(targetFrequency, time);
gainNode.gain.setValueAtTime(fakeKnobs.volumeControl, time); // Use fakeKnobs.volumeControl
gainNode.gain.exponentialRampToValueAtTime(0.01 * fakeKnobs.volumeControl, time + 1); // Apply master volume
oscillator.start(time);
oscillator.stop(time + 1.5);
}
function playGuitarSound(keyIndex, time) {
if (audioContext.state === 'suspended') return;
const frequencies = [82.41, 110.0, 146.83, 196.0, 246.94, 329.63, 392.0];
const baseFrequency = frequencies[keyIndex];
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
let soundSourceNode = oscillator; // This will be the node that connects to gainNode
if (whammyMode) {
// 1. Create the LFO and its GainNode specifically for the whammy effect
const lfo = audioContext.createOscillator();
const whammyLfoGain = audioContext.createGain(); // Use a distinct name
// 2. Configure LFO parameters (rate and depth)
// Use fakeKnobs for dynamic control based on game state
lfo.frequency.setValueAtTime(fakeKnobs.lfoRateControl, time); // LFO speed
// Adjust LFO depth for frequency modulation.
const whammyDepth = fakeKnobs.lfoDepthControl * 12.5;
whammyLfoGain.gain.setValueAtTime(whammyDepth, time);
// 3. Connect LFO to modulate the main oscillator's frequency
lfo.connect(whammyLfoGain);
whammyLfoGain.connect(oscillator.frequency);
// 4. Start and schedule stop for the LFO
lfo.start(time);
lfo.stop(time + 10); // Stop LFO when the main note stops
// 5. Set oscillator type and apply filter for whammy mode
oscillator.type = fakeKnobs.guitarOscillatorType;
const filter = audioContext.createBiquadFilter();
filter.type = fakeKnobs.guitarFilterType;
filter.frequency.setValueAtTime(fakeKnobs.guitarFilterFrequency, time);
filter.Q.setValueAtTime(fakeKnobs.guitarFilterResonance, time);
oscillator.connect(filter);
soundSourceNode = filter; // The output now comes from the filter
} else {
oscillator.type = "sawtooth";
// soundSourceNode remains the oscillator itself
}
// Connect the final sound source (oscillator or filter) to the main gain
soundSourceNode.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.setValueAtTime(baseFrequency, time);
gainNode.gain.setValueAtTime(fakeKnobs.volumeControl, time);
gainNode.gain.exponentialRampToValueAtTime(0.01 * fakeKnobs.volumeControl, time + 1); // Apply master volume
oscillator.start(time);
oscillator.stop(time + 1);
}
const swingAmount = 0.01; // 0 to 1, more = heavier swing
let groove1 = [1, 0, 0, 1, 0, 1, 0, 0]; // Kick pattern
let groove2 = [0, 1, 1, 0, 1, 0, 1, 1]; // Snare/Hat hybrid
function scheduleNotes() {
if (!isPlaying || audioContext.state === 'suspended') { // Also check if audioContext is suspended
if (isPlaying) { // If it was supposed to be playing, try to reschedule later
requestAnimationFrame(scheduleNotes);
}
return;
}
while (nextNoteTime < audioContext.currentTime + 0.2) {
const beatIndex = currentStep % 16;
const isGroove1 = groove1[beatIndex % groove1.length];
const isGroove2 = groove2[beatIndex % groove2.length];
const swingOffset = beatIndex % 2 === 1 ? beatDuration * swingAmount * 0.5 : 0;
const scheduledTime = nextNoteTime + swingOffset;
// Dropout with probability
if (gameState === 'playing') { // Only play combat music elements if in 'playing' state
if (isGroove1 && Math.random() > 0.15) playBassDrum(scheduledTime, 0);
if (isGroove2 && Math.random() > 0.1) {
Math.random() > 0.5
? playSnareDrum(scheduledTime, 1)
: playHiHat(scheduledTime, 2);
}
// Wobbly hat on 8ths
if (Math.random() < 0.2) playWobblyHat(scheduledTime + 0.03);
// Ghost hats on 16ths
if (Math.random() < 0.1 && beatIndex % 2 === 1) {
playHiHat(scheduledTime + 0.05, 2);
}
}
currentStep++;
nextNoteTime += beatDuration * 0.5;
}
requestAnimationFrame(scheduleNotes);
}
// let grooveMode = 0; // Declared but not used, commented out
// function toggleGroove() { // This function is declared but never called
// grooveMode = (grooveMode + 1) % 2;
// if (grooveMode === 0) {
// groove1 = [1, 0, 0, 1, 0, 1, 0, 0];
// groove2 = [0, 1, 1, 0, 1, 0, 1, 1];
// } else {
// groove1 = [1, 0, 1, 0, 1, 0, 1, 0];
// groove2 = [0, 1, 0, 1, 0, 1, 0, 1];
// }
// }
function playWobblyHat(time) {
if (audioContext.state === 'suspended') return;
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
const pan = audioContext.createStereoPanner();
osc.type = 'sine';
const freq = 1380 + Math.random() * 0.2;
osc.frequency.setValueAtTime(freq, time);
gain.gain.setValueAtTime((0.0125 + Math.random() / 2) * fakeKnobs.volumeControl, time); // Apply master volume
gain.gain.exponentialRampToValueAtTime(0.013 * fakeKnobs.volumeControl, time + 1.125); // Apply master volume
pan.pan.setValueAtTime(Math.random() * 2 - 1, time);
osc.connect(gain).connect(pan).connect(audioContext.destination);
const startTime = time + 0.125;
osc.start(startTime);
const stopTime = startTime + 0.125 + Math.random() * 0.208;
osc.stop(stopTime);
}
function startPlayback() {
if (isPlaying || audioContext.state === 'suspended') return;
isPlaying = true;
nextNoteTime = audioContext.currentTime;
currentStep = 0; // Reset step on new playback
scheduleNotes();
}
function stopPlayback() {
isPlaying = false;
}
window.BeatEngine = {
startPlayback,
stopPlayback,
setBPM: (newBpm) => {
bpm = newBpm;
beatDuration = 15 / bpm;
},
isPlaying: () => isPlaying
};
function playTom(time, type, row) { // 'row' parameter is unused
if (audioContext.state === 'suspended') return;
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
let frequency = 80; // default for mid
let decayTime = 0.25;
let initialGain = 1.0; // Default initial gain before master volume
switch (type) {
case "high":
frequency = 100 + Math.random() * 50 - 25; // Adjusted to avoid negative freq
if (frequency < 20) frequency = 20; // Min frequency
decayTime = 0.2 + Math.random() * 0.2;
initialGain = 0.9; // Adjusted gain factor
break;
case "mid":
frequency = 80;
decayTime = 0.25;
initialGain = 1.5; // Adjusted gain factor
break;
case "low":
frequency = 60;
decayTime = 0.25 + Math.random() * 1.7;
initialGain = 1.0; // Adjusted gain factor
break;
case "lazer":
oscillator.type = "square";
frequency = 440;
decayTime = 0.33;
initialGain = 0.15;
break;
}
oscillator.frequency.setValueAtTime(frequency, time);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(0.01, frequency/100), time + decayTime); // Ensure positive target
gainNode.gain.setValueAtTime(initialGain * fakeKnobs.volumeControl, time); // Apply master volume
gainNode.gain.exponentialRampToValueAtTime(0.01 * fakeKnobs.volumeControl, time + decayTime); // Apply master volume
oscillator.start(time);
oscillator.stop(time + decayTime + 0.1);
}
function playHiHat(time, row) { // 'row' parameter is unused
if (audioContext.state === 'suspended') return;
const noiseBuffer = audioContext.createBuffer(
1,
audioContext.sampleRate * 0.1,
audioContext.sampleRate
);
const output = noiseBuffer.getChannelData(0);
for (let i = 0; i < output.length; i++) {
output[i] = Math.random() * 2 - 1;
}
const noiseSource = audioContext.createBufferSource();
noiseSource.buffer = noiseBuffer;
const filter = audioContext.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.value = 6000;
const gainNode = audioContext.createGain();
gainNode.gain.setValueAtTime(0.125 * fakeKnobs.volumeControl, time); // Apply master volume
gainNode.gain.exponentialRampToValueAtTime(0.01 * fakeKnobs.volumeControl, time + 0.1); // Apply master volume
noiseSource.connect(filter);
filter.connect(gainNode);
gainNode.connect(audioContext.destination);
noiseSource.start(time);
noiseSource.stop(time + 0.1);
}
function playSnareDrum(time, row) { // 'row' parameter is unused
if (audioContext.state === 'suspended') return;
const noiseBuffer = audioContext.createBuffer(
1,
audioContext.sampleRate * 0.2,
audioContext.sampleRate
);
const output = noiseBuffer.getChannelData(0);
for (let i = 0; i < output.length; i++) {
output[i] = Math.random() * 2 - 1;
}
const noiseSource = audioContext.createBufferSource();
noiseSource.buffer = noiseBuffer;
const filter = audioContext.createBiquadFilter();
filter.type = "highpass";
filter.frequency.setValueAtTime(1000, time);
const gainNode = audioContext.createGain();
gainNode.gain.setValueAtTime(0.25 * fakeKnobs.volumeControl, time); // Apply master volume
gainNode.gain.exponentialRampToValueAtTime(0.01 * fakeKnobs.volumeControl, time + 0.2); // Apply master volume
noiseSource.connect(filter);
filter.connect(gainNode);
gainNode.connect(audioContext.destination);
noiseSource.start(time);
noiseSource.stop(time + 0.2);
}
function playDrumSound(type, time) {
if (gameState !== 'playing' && type < 7) return; // Only play combat drum sounds if in playing state
// Allow piano/guitar sounds for other things if needed.
if (audioContext.state === 'suspended') return;
if (type >= 0 && type < 7) {
// Existing drum sounds
switch (type) {
case 0:
playBassDrum(time, type);
break;
case 1:
playSnareDrum(time, type);
break;
case 2:
playHiHat(time, type);
break;
case 3:
playTom(time, "high", type);
break;
case 4:
playTom(time, "mid", type);
break;
case 5:
playTom(time, "low", type);
break;
case 6:
playTom(time, "lazer", type);
break;
}
} else if (type >= 7 && type < 14) {
// New piano notes
const pianoKeyIndex = type - 7;
playPianoSound(pianoKeyIndex, time);
} else if (type >= 14 && type < 21) {
// New guitar notes
const guitarKeyIndex = type - 14;
playGuitarSound(guitarKeyIndex, time);
}
}
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: #000507;
color: #0f0;
font-family: 'Nabla', 'Courier New', Courier, monospace;
font-variation-settings: "EDPT" 100, "EHLT" 120;
font-palette: --grey;
visibility: visible;
}
@font-palette-values --grey {
font-family: Nabla;
override-colors:
0 rgb(187, 187, 220), 1 rgb(76, 76, 106), 2 rgb(126, 126, 156), 3 rgb(120, 120, 150), 4 rgb(187, 187, 220), 5 rgb(234, 234, 245), 6 rgb(187, 187, 220), 7 rgb(234, 234, 245), 8 rgb(234, 234, 246), 9 rgb(249, 249, 250);
}
#game-container {
width: 800px;
height: 600px;
display: flex;
justify-content: center;
align-items: center;
background: #000010;
position: relative;
box-shadow: 0 5px 150px rgba(255, 255, 200, 0.1);
}
#gameCanvas {
width: 800px;
height: 600px;
display: block;
background: #000010;
}
#ui {
position: absolute;
max-width: 800px;
max-height: 600px;
bottom: 0;
left: 0;
top: 0;
left: 0;
width: 100%;
height: 100%;
color: #0f0;
font-size: 18px;
padding: 10px;
box-sizing: border-box;
opacity: 0.5;
}
#ui div:not(#radar-container) {
/* Exclude radar-container from this rule */
margin-bottom: 5px;
}
#controls-info {
position: absolute;
top: 15px;
right: 15px;
color: #0c0;
font-size: 16px;
text-align: right;
line-height: 1.7;
opacity: 0.5;
}
#game-over-message {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #f00;
font-size: 80px;
font-weight: bold;
text-align: center;
display: none; /* Initially hidden */
z-index: 100;
opacity: 0.5;
}
#game-over-message span {
font-size: 42px;
display: block;
margin-top: 10px;
}
/* --- Radar Styles --- */
#radar-container {
position: absolute;
bottom: 15px;
right: 15px;
width: 100px;
height: 100px;
border: 1px solid #00ff00;
background-color: rgba(0, 20, 0, 0.7);
box-sizing: border-box;
opacity: 0.9;
}
#radarCanvas {
width: 100%;
height: 100%;
opacity: 0.9;
}
/* For drawing a circular scope line if not done on canvas */
#radar-scope-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
border: 1px solid #0a0; /* Slightly dimmer green */
box-sizing: border-box;
opacity: 0.8;
}
/* Optional: Scanlines effect (can be performance heavy on some systems) */
/*
body::after {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background-image: linear-gradient(
rgba(18, 16, 16, 0) 50%,
rgba(0, 0, 0, 0.15) 50%
);
background-size: 100% 4px;
z-index: 9999;
pointer-events: none;
opacity: 0.3;
}
*/
.powerup-display {
font-size: 16px;
margin-top: 5px;
margin-bottom: 5px;
font-weight: bold;
text-align: left; /* Align with other UI elements */
display: none; /* Initially hidden */
opacity: 1;
transition: opacity 0.5s ease-out; /* Smooth fade-out (if we decide to fade later) */
}
.ui-overlay {
position: absolute;
display: none; /* Initially hidden */
z-index: 50; /* Above canvas, below game over */
}

Vector Attack

Vector Attack is a Web Audio API driven synth-powered space dogfight.

Pilot with arrow keys, fire with space, jump with H. Collect pink dust from eliminated enemies for boosts. Press D key to explore space station levels (WIP)

A Pen by semanticentity on CodePen.

License.

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nabla&display=swap" rel="stylesheet">
<div id="game-container">
<canvas id="gameCanvas" width="800" height="600" style="display:block"></canvas>
<div id="ui">
<div id="speed-indicator">SPEED: 0</div>
<div id="heading-indicator">HDG: 0°</div>
<div id="shield-indicator">SHIELDS: 100%</div>
<div id="laser-status">LASER: READY</div>
<div id="hyperspace-status">HYPERSPACE: READY</div>
<div id="score-indicator">SCORE: 0</div>
<div id="enemy-indicator">ENEMIES: 0</div>
<div id="pink-dust-indicator" class="status-item">DUST: 0</div>
<div id="active-speed-boost" class="powerup-display"></div>
<div id="active-score-multiplier" class="powerup-display"></div>
<div id="active-triple-laser" class="powerup-display"></div>
<div id="radar-container">
<canvas id="radarCanvas"></canvas>
<div id="radar-scope-overlay"></div>
</div>
</div>
<div id="controls-info">
Arrow Keys: Steer & Thrust<br/>
Space: Fire Laser<br/>
H: Hyperspace Jump
</div>
<div id="game-over-message">
GAME OVER<br/><span>Press R to Restart</span>
</div>
<!-- Docking Station -->
<div id="docking-prompt" class="ui-overlay" style="bottom: 15px; left: 15px; color:#0ff; font-size: 20px; opacity: 0.8;"></div>
<div id="docking-menu" class="ui-overlay" style="top: 50%; left: 50%; transform: translate(-50%, -50%); color:#0f0; font-size: 32px; text-align: center; line-height: 1.5;"></div>
<div id="dialog-message" class="ui-overlay" style="top: 20%; left: 50%; transform: translate(-50%, -50%); color:#fff; font-size: 24px; text-align: center; background-color: rgba(0,0,0,0.8); border: 2px solid #0f0; padding: 20px; z-index: 101; display: none;"></div>
</div>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment