|
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); |
|
} |
|
} |