diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc9ddfe --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# Bitstream Bluffs šŸŽæāš” + +A neon-soaked, downhill sledding game with a 1-bit Tron-wireframe aesthetic. Ride procedurally generated mountains, perform tricks, and stay ahead of **The Bit Stream**! + +## šŸŽ® How to Play + +### Controls + +#### Sled Mode (Default) +- **TAB**: Toggle between Sled and Walking modes +- **W/S**: Rotate counterclockwise/clockwise (perform rotation tricks in air) +- **A**: Drag to slow down (ground) / Air Brake trick (air) +- **D**: Tuck to accelerate (ground) / Parachute trick (air) +- **SPACE**: Jump +- **SHIFT**: Restart game +- **ESC**: Pause + +#### Walking Mode +- **A/D**: Move left/right +- **SPACE**: Small jump +- **W/S**: No effect in walking mode + +### Tricks & Scoring + +**Tricks** (perform in mid-air): +- **Rotation**: 200 points Ɨ rotations (W/S keys) +- **Air Brake**: 150 points (A key) +- **Parachute**: 200 points (D key) + +**Combo System**: +- Chain multiple tricks in one jump for bonus multipliers +- Multiplier increases by 0.25Ɨ for each unique trick +- Resets when you land + +**Scoring**: +- Distance: +1 point per meter traveled +- Tricks: Base points Ɨ combo multiplier +- Blue Terrain: Bonus points while riding + +### Terrain Types + +- **Blue** (0x0088ff): Awards bonus points while riding +- **Green** (0x00ff44): Low friction - go faster! +- **Magenta** (0xff00aa): High friction - slows you down + +### The Bit Stream + +A relentless glitch wall chases you down the mountain. Keep moving or get consumed! + +## šŸ› ļø Technical Details + +### Stack +- **Phaser 3.90**: Game framework +- **Matter.js**: Physics engine +- **Vite**: Build tool and dev server +- **ES6 Modules**: Clean, modern JavaScript + +### Architecture + +Clean, focused implementation with minimal dependencies: + +``` +src/ +ā”œā”€ā”€ main.js # Entry point & Phaser config +ā”œā”€ā”€ scenes/ +│ ā”œā”€ā”€ BootScene.js # Initial boot +│ ā”œā”€ā”€ PreloadScene.js # Asset loading +│ └── GameScene.js # Main gameplay +ā”œā”€ā”€ core/ +│ ā”œā”€ā”€ Player.js # Player physics & controls +│ ā”œā”€ā”€ TerrainGenerator.js # Procedural terrain +│ ā”œā”€ā”€ TrickSystem.js # Trick tracking & combos +│ └── ScoringSystem.js # Score calculation +ā”œā”€ā”€ effects/ +│ ā”œā”€ā”€ Starfield.js # Parallax background +│ └── GlitchFX.js # Periodic glitch effects +└── utils/ + └── seedUtils.js # Seeded random generation +``` + +### Features + +āœ… **480Ɨ270 pixel-perfect resolution** with scaling +āœ… **Procedural terrain generation** with seed sharing +āœ… **Three terrain types** with different physics properties +āœ… **Full trick system** with combos and multipliers +āœ… **The Bit Stream** chasing mechanic +āœ… **1-bit Tron wireframe aesthetic** with neon colors +āœ… **Parallax starfield** background +āœ… **Periodic glitch VFX** (RGB split, scanlines, screen shake) +āœ… **Sled/Walking mode toggle** +āœ… **Seed-based random generation** for shareable runs + +## šŸš€ Development + +### Setup +```bash +npm install +``` + +### Run Dev Server +```bash +npm run dev +``` +Opens at `http://localhost:5173` + +### Build for Production +```bash +npm run build +``` + +### Preview Production Build +```bash +npm run preview +``` + +## šŸŽØ Design Philosophy + +This rebuild follows the principle of **simplicity over complexity**: + +- **Single-file systems**: Each core system is self-contained +- **Minimal abstraction**: Direct, readable code over complex architecture +- **Performance-first**: Efficient rendering and physics +- **Quick iteration**: Clean structure for easy modifications + +Built from the ground up following: +- `/public/docs/instructions.html` (game mechanics) +- `/docs/DESIGN-DOC.md` (technical specifications) + +## šŸ“‹ Game Specifications + +- **Resolution**: 480Ɨ270 (virtual), scaled to fit +- **Physics**: Matter.js at ~900 px/s² gravity +- **Target FPS**: 60 (PC), 30-60 (mobile) +- **Retry Loop**: < 10 seconds +- **Visual Style**: 1-bit Tron wireframe with neon palette + +## šŸŽÆ Future Enhancements + +- Leaderboard system with 3-character initials (IndexedDB) +- Additional hazards and power-ups +- Mobile touch controls +- Music and sound effects +- More terrain variety +- Perfect landing detection +- Replay system + +## šŸ“ Version + +**2.0.0** - Complete rebuild with clean, focused architecture + +--- + +Built with ⚔ by Claude Code diff --git a/index.html b/index.html index 1a518ca..4f3bb89 100644 --- a/index.html +++ b/index.html @@ -3,111 +3,39 @@ - BitstreamBluffs - - - - - - - - - - - - - + Bitstream Bluffs -
+
+ diff --git a/package-lock.json b/package-lock.json index adb910e..f5d6e32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitstream-bluffs", - "version": "1.7.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bitstream-bluffs", - "version": "1.7.0", + "version": "1.8.0", "devDependencies": { "chai": "^5.2.0", "express": "^5.1.0", diff --git a/src/core/Player.js b/src/core/Player.js new file mode 100644 index 0000000..71e9fc4 --- /dev/null +++ b/src/core/Player.js @@ -0,0 +1,312 @@ +/** + * Player - Sled/Walking character controller + * Handles two modes: Sled (default) and Walking (TAB toggle) + * + * Sled Mode: + * - W/S: Rotate counterclockwise/clockwise (tricks in air) + * - A: Drag (ground) / Air Brake (air) + * - D: Tuck (ground) / Parachute (air) + * - SPACE: Jump + * + * Walking Mode: + * - A/D: Move left/right + * - SPACE: Small jump + * - W/S: No effect + */ + +import Phaser from 'phaser'; + +export default class Player { + constructor(scene, x, y) { + this.scene = scene; + + // Mode state + this.isWalkingMode = false; + + // Create player sprite and physics body + this.sprite = scene.matter.add.sprite(x, y, 'sled'); + this.sprite.setFixedRotation(false); + this.sprite.setFrictionAir(0.01); + this.sprite.setFriction(0.3); + this.sprite.setMass(2); + this.sprite.setBounce(0.2); + + // Set collision category + this.sprite.setCollisionCategory(0x0001); + this.sprite.setCollidesWith([0x0002]); // Collide with terrain + + // Physics state + this.onGround = false; + this.groundCheckTimer = 0; + + // Trick state + this.isRotating = false; + this.rotationDirection = 0; // -1 = CCW, 1 = CW + this.rotationStartAngle = 0; + this.totalRotation = 0; + + this.isTucking = false; + this.isDragging = false; + this.isAirBraking = false; + this.isParachuting = false; + + // Trick cooldowns + this.airBrakeCooldown = 0; + this.parachuteCooldown = 0; + + // Visual effects + this.trail = null; + this.createTrailEffect(); + + // Listen for collisions + this.sprite.setOnCollide((pair) => { + this.handleCollision(pair); + }); + } + + createTrailEffect() { + // Create a simple particle trail using Phaser 3.60+ API + this.trail = this.scene.add.particles(0, 0, 'particle-cyan', { + follow: this.sprite, + speed: { min: 10, max: 30 }, + scale: { start: 1, end: 0 }, + alpha: { start: 0.8, end: 0 }, + lifespan: 300, + frequency: 50, + tint: 0x00ffff, + emitting: false // Start stopped + }); + } + + update(keys, delta) { + const deltaSeconds = delta / 1000; + + // Update ground check + this.updateGroundCheck(deltaSeconds); + + // Update cooldowns + this.airBrakeCooldown = Math.max(0, this.airBrakeCooldown - deltaSeconds); + this.parachuteCooldown = Math.max(0, this.parachuteCooldown - deltaSeconds); + + // Handle mode toggle + if (Phaser.Input.Keyboard.JustDown(keys.tab)) { + this.toggleMode(); + } + + // Handle input based on mode + if (this.isWalkingMode) { + this.handleWalkingInput(keys, deltaSeconds); + } else { + this.handleSledInput(keys, deltaSeconds); + } + + // Update visual effects + this.updateVisuals(); + } + + updateGroundCheck(deltaSeconds) { + // Simple ground check based on vertical velocity + const velocity = this.sprite.body.velocity; + const angularVelocity = Math.abs(this.sprite.body.angularVelocity); + + // Consider grounded if moving slowly vertically and not spinning much + const wasOnGround = this.onGround; + this.onGround = Math.abs(velocity.y) < 2 && angularVelocity < 0.5; + + // Detect landing (transition from air to ground) + if (!wasOnGround && this.onGround) { + this.onLanded(); + } + } + + handleSledInput(keys, deltaSeconds) { + const onGround = this.onGround; + const body = this.sprite.body; + + // W/S - Rotation (tricks in air, less effective on ground) + if (keys.w.isDown) { + if (!onGround) { + // Start rotation trick + if (!this.isRotating) { + this.startRotation(-1); // Counter-clockwise + } + this.sprite.setAngularVelocity(-0.15); + } else { + // Minimal effect on ground + this.sprite.setAngularVelocity(-0.05); + } + } else if (keys.s.isDown) { + if (!onGround) { + // Start rotation trick + if (!this.isRotating) { + this.startRotation(1); // Clockwise + } + this.sprite.setAngularVelocity(0.15); + } else { + // Minimal effect on ground + this.sprite.setAngularVelocity(0.05); + } + } + + // A - Drag (ground) / Air Brake (air) + if (keys.a.isDown) { + if (onGround) { + // Drag to slow down + this.isDragging = true; + this.sprite.setFrictionAir(0.05); + this.sprite.setVelocityX(body.velocity.x * 0.95); + } else if (this.airBrakeCooldown === 0) { + // Air Brake trick (slow horizontal, boost vertical) + this.isAirBraking = true; + this.sprite.setVelocityX(body.velocity.x * 0.7); + this.sprite.setVelocityY(body.velocity.y - 2); // Slight upward boost + this.airBrakeCooldown = 0.5; + + // Notify trick system + this.scene.trickSystem?.onAirBrake(); + } + } else { + this.isDragging = false; + this.isAirBraking = false; + this.sprite.setFrictionAir(0.01); + } + + // D - Tuck (ground) / Parachute (air) + if (keys.d.isDown) { + if (onGround) { + // Tuck to accelerate + this.isTucking = true; + this.sprite.setFrictionAir(0.005); + // Add slight forward force + const angle = this.sprite.rotation; + this.sprite.applyForce({ + x: Math.cos(angle) * 0.01, + y: Math.sin(angle) * 0.01 + }); + } else if (this.parachuteCooldown === 0) { + // Parachute trick (slower descent, more horizontal travel) + this.isParachuting = true; + this.sprite.setVelocityY(body.velocity.y * 0.5); + this.parachuteCooldown = 1.0; + + // Notify trick system + this.scene.trickSystem?.onParachute(); + } + } else { + this.isTucking = false; + this.isParachuting = false; + if (!this.isDragging) { + this.sprite.setFrictionAir(0.01); + } + } + + // SPACE - Jump + if (Phaser.Input.Keyboard.JustDown(keys.space) && onGround) { + this.jump(8); + } + } + + handleWalkingInput(keys, deltaSeconds) { + const walkSpeed = 2; + const body = this.sprite.body; + + // A/D - Move left/right + if (keys.a.isDown) { + this.sprite.setVelocityX(-walkSpeed); + } else if (keys.d.isDown) { + this.sprite.setVelocityX(walkSpeed); + } else { + // Slow down when not moving + this.sprite.setVelocityX(body.velocity.x * 0.9); + } + + // SPACE - Small jump + if (Phaser.Input.Keyboard.JustDown(keys.space) && this.onGround) { + this.jump(4); + } + + // Keep upright in walking mode + this.sprite.setAngularVelocity(0); + const targetAngle = 0; + const currentAngle = this.sprite.rotation; + const angleDiff = Phaser.Math.Angle.Wrap(targetAngle - currentAngle); + this.sprite.setRotation(currentAngle + angleDiff * 0.1); + } + + jump(power) { + this.sprite.setVelocityY(-power); + this.onGround = false; + } + + startRotation(direction) { + this.isRotating = true; + this.rotationDirection = direction; + this.rotationStartAngle = this.sprite.rotation; + this.totalRotation = 0; + } + + onLanded() { + // Check if we completed a rotation trick + if (this.isRotating) { + const rotationAmount = Math.abs(this.totalRotation); + if (rotationAmount > Math.PI / 2) { // At least 90 degrees + this.scene.trickSystem?.onRotationComplete(rotationAmount); + } + this.isRotating = false; + this.totalRotation = 0; + } + + // Reset trick states + this.isAirBraking = false; + this.isParachuting = false; + + // Notify trick system of landing + this.scene.trickSystem?.onLanding(); + } + + toggleMode() { + this.isWalkingMode = !this.isWalkingMode; + + if (this.isWalkingMode) { + // Switch to walking mode + this.sprite.setMass(1); + this.sprite.setFriction(0.8); + } else { + // Switch to sled mode + this.sprite.setMass(2); + this.sprite.setFriction(0.3); + } + + console.log(`Mode: ${this.isWalkingMode ? 'Walking' : 'Sled'}`); + } + + updateVisuals() { + // Update trail based on speed + const speed = Math.sqrt( + Math.pow(this.sprite.body.velocity.x, 2) + + Math.pow(this.sprite.body.velocity.y, 2) + ); + + // Control particle emission (Phaser 3.60+ uses emitting property) + if (speed > 5 && !this.isWalkingMode) { + this.trail.emitting = true; + this.trail.frequency = Math.max(20, 100 - speed); + } else { + this.trail.emitting = false; + } + + // Change trail color based on tricks (Phaser 3.60+ uses setParticleTint) + if (this.isAirBraking) { + this.trail.setParticleTint(0xff00ff); // Magenta + } else if (this.isParachuting) { + this.trail.setParticleTint(0xffaa00); // Amber + } else { + this.trail.setParticleTint(0x00ffff); // Cyan + } + } + + handleCollision(pair) { + // Handle terrain collision + // This will be expanded when we add terrain types + } +} diff --git a/src/core/ScoringSystem.js b/src/core/ScoringSystem.js new file mode 100644 index 0000000..daf7a08 --- /dev/null +++ b/src/core/ScoringSystem.js @@ -0,0 +1,101 @@ +/** + * ScoringSystem - Manages score calculation and tracking + * + * Scoring sources: + * - Distance: +1 point per meter traveled downward + * - Tricks: Variable points based on trick type and combo + * - Terrain bonus: Blue terrain awards points over time + */ + +export default class ScoringSystem { + constructor(scene) { + this.scene = scene; + + // Score components + this.totalScore = 0; + this.distanceScore = 0; + this.trickScore = 0; + this.terrainBonusScore = 0; + + // Distance tracking + this.lastDistance = 0; + + // Terrain bonus tracking + this.blueTerrainTimer = 0; + this.blueTerrainPointsPerSecond = 10; + } + + updateDistance(currentDistance) { + // Award points for distance traveled + const distanceTraveled = currentDistance - this.lastDistance; + + if (distanceTraveled > 0) { + this.distanceScore += distanceTraveled; + this.updateTotal(); + } + + this.lastDistance = currentDistance; + } + + addTrickScore(points) { + this.trickScore += points; + this.updateTotal(); + } + + updateTerrainBonus(terrainType, deltaSeconds) { + // Award bonus points for riding on blue terrain + if (terrainType === 'blue') { + this.blueTerrainTimer += deltaSeconds; + + // Award points every second + if (this.blueTerrainTimer >= 1.0) { + const bonusPoints = this.blueTerrainPointsPerSecond; + this.terrainBonusScore += bonusPoints; + this.updateTotal(); + this.blueTerrainTimer = 0; + + // Show feedback + this.showBonusFeedback(bonusPoints); + } + } else { + this.blueTerrainTimer = 0; + } + } + + showBonusFeedback(points) { + const player = this.scene.player.sprite; + const x = player.x + 20; + const y = player.y - 10; + + const text = this.scene.add.text(x, y, `+${points}`, { + fontFamily: 'Courier New', + fontSize: '8px', + color: '#0088ff', + stroke: '#000000', + strokeThickness: 1 + }).setOrigin(0.5); + + // Animate + this.scene.tweens.add({ + targets: text, + y: y - 15, + alpha: 0, + duration: 600, + ease: 'Power2', + onComplete: () => text.destroy() + }); + } + + updateTotal() { + this.totalScore = this.distanceScore + this.trickScore + this.terrainBonusScore; + } + + reset() { + this.totalScore = 0; + this.distanceScore = 0; + this.trickScore = 0; + this.terrainBonusScore = 0; + this.lastDistance = 0; + this.blueTerrainTimer = 0; + } +} diff --git a/src/core/TerrainGenerator.js b/src/core/TerrainGenerator.js new file mode 100644 index 0000000..0439520 --- /dev/null +++ b/src/core/TerrainGenerator.js @@ -0,0 +1,275 @@ +/** + * TerrainGenerator - Procedural terrain generation with seeded randomness + * + * Generates sloped, downhill terrain with three types: + * - Blue: Bonus points for riding + * - Green: Reduced friction (faster) + * - Magenta: Increased friction (slower) + * + * Creates angled slopes instead of flat platforms for proper sledding + */ + +import Phaser from 'phaser'; + +export default class TerrainGenerator { + constructor(scene, randomFunc) { + this.scene = scene; + this.random = randomFunc; + + // Terrain colors + this.colors = { + blue: 0x0088ff, + green: 0x00ff44, + magenta: 0xff00aa, + normal: 0x00ffff // Default cyan + }; + + // Terrain segments (active physics bodies) + this.segments = []; + this.graphics = scene.add.graphics(); + this.graphics.setDepth(-1); + + // Generation parameters + this.segmentLength = 100; // Length of each slope segment + this.worldWidth = 480; + this.generateAhead = 1500; // Generate this far ahead + this.lastGeneratedY = 0; + + // Slope parameters + this.minSlope = 10; // Minimum downward slope angle (degrees) + this.maxSlope = 45; // Maximum downward slope angle (degrees) + + // Initialize first segments + this.generateInitialTerrain(); + } + + generateInitialTerrain() { + // Start with a wide flat starting platform + // Player spawns at (240, 100), platform top should be at y=110 so player sits on it + // Create platform from x=40 to x=440 + this.createSlopeSegment(40, 110, 400, 0, 'normal', 20); + + // Generate initial downhill segments starting from end of platform + let currentX = 440; + let currentY = 110; + + for (let i = 0; i < 15; i++) { + const angle = this.minSlope + this.random() * (this.maxSlope - this.minSlope); + const length = 80 + this.random() * 80; + const terrainType = this.getTerrainType(currentX, currentY); + const thickness = 12 + this.random() * 8; + + // Calculate end position based on angle + const rad = (angle * Math.PI) / 180; + const endX = currentX + length * Math.cos(rad); + const endY = currentY + length * Math.sin(rad); + + this.createSlopeSegment(currentX, currentY, length, angle, terrainType, thickness); + + // Add some horizontal variation + currentX = endX + (this.random() - 0.5) * 50; + currentY = endY; + + this.lastGeneratedY = Math.max(this.lastGeneratedY, currentY); + } + } + + createSlopeSegment(startX, startY, length, angle, type, thickness = 15) { + // Calculate end point + const rad = (angle * Math.PI) / 180; + const endX = startX + length * Math.cos(rad); + const endY = startY + length * Math.sin(rad); + + // Create angled rectangle as slope + const centerX = (startX + endX) / 2; + const centerY = (startY + endY) / 2; + + // Create Matter.js body with angle + const rect = this.scene.matter.add.rectangle( + centerX, + centerY, + length, + thickness, + { + isStatic: true, + friction: this.getFriction(type), + angle: rad, + label: type + } + ); + + // Set collision category + rect.collisionFilter.category = 0x0002; + + // Store segment data + this.segments.push({ + body: rect, + startX: startX, + startY: startY, + endX: endX, + endY: endY, + length: length, + angle: angle, + type: type, + thickness: thickness, + color: this.colors[type] + }); + + // Draw segment + this.drawSlopeSegment(startX, startY, endX, endY, thickness, this.colors[type]); + + return rect; + } + + drawSlopeSegment(startX, startY, endX, endY, thickness, color) { + const angle = Math.atan2(endY - startY, endX - startX); + const perpX = Math.cos(angle + Math.PI / 2) * (thickness / 2); + const perpY = Math.sin(angle + Math.PI / 2) * (thickness / 2); + + // Draw filled slope + this.graphics.fillStyle(color, 0.6); + this.graphics.beginPath(); + this.graphics.moveTo(startX + perpX, startY + perpY); + this.graphics.lineTo(endX + perpX, endY + perpY); + this.graphics.lineTo(endX - perpX, endY - perpY); + this.graphics.lineTo(startX - perpX, startY - perpY); + this.graphics.closePath(); + this.graphics.fillPath(); + + // Draw wireframe border + this.graphics.lineStyle(2, color, 1); + this.graphics.strokePath(); + + // Draw center line for visual interest + this.graphics.lineStyle(1, color, 0.5); + this.graphics.lineBetween(startX, startY, endX, endY); + } + + getFriction(type) { + switch (type) { + case 'blue': return 0.4; + case 'green': return 0.1; // Low friction (fast) + case 'magenta': return 0.8; // High friction (slow) + default: return 0.3; + } + } + + getTerrainType(x, y) { + // Determine terrain type based on position + const hash = (Math.floor(x / 100) * 73 + Math.floor(y / 100) * 37) % 100; + + if (hash < 15) return 'blue'; // 15% blue (bonus points) + if (hash < 35) return 'green'; // 20% green (low friction) + if (hash < 50) return 'magenta'; // 15% magenta (high friction) + return 'normal'; // 50% normal + } + + update(playerY) { + // Generate new terrain ahead of player + while (this.lastGeneratedY < playerY + this.generateAhead) { + // Get last segment end position + const lastSegment = this.segments[this.segments.length - 1]; + let currentX, currentY; + + if (lastSegment) { + currentX = lastSegment.endX; + currentY = lastSegment.endY; + } else { + currentX = 240; + currentY = 100; + } + + // Generate new slope segment + const angle = this.minSlope + this.random() * (this.maxSlope - this.minSlope); + const length = 80 + this.random() * 100; + const terrainType = this.getTerrainType(currentX, currentY); + const thickness = 12 + this.random() * 8; + + // Calculate end position + const rad = (angle * Math.PI) / 180; + const endY = currentY + length * Math.sin(rad); + + this.createSlopeSegment(currentX, currentY, length, angle, terrainType, thickness); + + // Add horizontal variation occasionally + if (this.random() < 0.3) { + currentX += (this.random() - 0.5) * 100; + } + + this.lastGeneratedY = endY; + } + + // Remove terrain far behind player + const removeThreshold = playerY - 500; + this.segments = this.segments.filter(segment => { + if (segment.startY < removeThreshold) { + // Remove physics body + this.scene.matter.world.remove(segment.body); + return false; + } + return true; + }); + + // Redraw visible terrain (clear and redraw for efficiency) + if (this.segments.length > 0 && Math.floor(playerY / 100) !== Math.floor((playerY - 1) / 100)) { + this.redrawTerrain(playerY); + } + } + + redrawTerrain(playerY) { + // Clear graphics + this.graphics.clear(); + + // Redraw visible segments + const visibleRange = 800; + this.segments.forEach(segment => { + if (segment.startY > playerY - visibleRange && segment.startY < playerY + visibleRange) { + this.drawSlopeSegment( + segment.startX, + segment.startY, + segment.endX, + segment.endY, + segment.thickness, + segment.color + ); + } + }); + } + + /** + * Check what terrain type the player is on + * Returns terrain type or null + */ + getTerrainAt(x, y) { + // Check which segment the player is touching + for (let segment of this.segments) { + // Simple distance check to segment line + const dx = segment.endX - segment.startX; + const dy = segment.endY - segment.startY; + const lengthSq = dx * dx + dy * dy; + + if (lengthSq === 0) continue; + + const t = Math.max(0, Math.min(1, ((x - segment.startX) * dx + (y - segment.startY) * dy) / lengthSq)); + const projX = segment.startX + t * dx; + const projY = segment.startY + t * dy; + + const distSq = (x - projX) * (x - projX) + (y - projY) * (y - projY); + const threshold = (segment.thickness / 2 + 5) * (segment.thickness / 2 + 5); + + if (distSq < threshold) { + return segment.type; + } + } + return null; + } + + destroy() { + // Clean up + this.segments.forEach(segment => { + this.scene.matter.world.remove(segment.body); + }); + this.segments = []; + this.graphics.destroy(); + } +} diff --git a/src/core/TrickSystem.js b/src/core/TrickSystem.js new file mode 100644 index 0000000..3c6321d --- /dev/null +++ b/src/core/TrickSystem.js @@ -0,0 +1,167 @@ +/** + * TrickSystem - Manages tricks, combos, and trick scoring + * + * Three primary tricks: + * - Rotation: 100-300 points (based on rotation amount) + * - Air Brake: 150 points + * - Parachute: 200 points + * + * Combo System: + * - Starts at 1.0x + * - Increases by 0.25x for each unique trick in current air session + * - Resets on landing + */ + +export default class TrickSystem { + constructor(scene) { + this.scene = scene; + + // Current combo state + this.comboMultiplier = 1.0; + this.tricksInCombo = new Set(); // Track unique tricks + + // Trick scores + this.baseScores = { + rotation: 200, + airBrake: 150, + parachute: 200 + }; + + // Current session points (not yet awarded) + this.pendingScore = 0; + this.lastTrickText = null; + } + + update(player, delta) { + // Update trick tracking based on player rotation + if (player.isRotating) { + const previousRotation = player.totalRotation; + const currentAngle = player.sprite.rotation; + const angleDiff = Phaser.Math.Angle.Wrap(currentAngle - player.rotationStartAngle); + + player.totalRotation = angleDiff; + + // Detect full rotations + const prevFullRotations = Math.floor(Math.abs(previousRotation) / (Math.PI * 2)); + const currentFullRotations = Math.floor(Math.abs(player.totalRotation) / (Math.PI * 2)); + + if (currentFullRotations > prevFullRotations) { + console.log(`Full rotation #${currentFullRotations}!`); + } + } + } + + onRotationComplete(rotationAmount) { + // Calculate points based on rotation + const rotations = rotationAmount / (Math.PI * 2); + let points = this.baseScores.rotation * rotations; + + // Add to combo + this.tricksInCombo.add('rotation'); + this.updateComboMultiplier(); + + // Apply combo multiplier + points *= this.comboMultiplier; + + this.pendingScore += points; + this.showTrickFeedback('ROTATION', points); + + console.log(`Rotation: ${(rotations * 360).toFixed(0)}° = ${points.toFixed(0)} pts (${this.comboMultiplier.toFixed(2)}x)`); + } + + onAirBrake() { + if (this.tricksInCombo.has('airBrake')) { + return; // Already did this trick in current combo + } + + let points = this.baseScores.airBrake; + + this.tricksInCombo.add('airBrake'); + this.updateComboMultiplier(); + + points *= this.comboMultiplier; + + this.pendingScore += points; + this.showTrickFeedback('AIR BRAKE', points); + + console.log(`Air Brake: ${points.toFixed(0)} pts (${this.comboMultiplier.toFixed(2)}x)`); + } + + onParachute() { + if (this.tricksInCombo.has('parachute')) { + return; // Already did this trick in current combo + } + + let points = this.baseScores.parachute; + + this.tricksInCombo.add('parachute'); + this.updateComboMultiplier(); + + points *= this.comboMultiplier; + + this.pendingScore += points; + this.showTrickFeedback('PARACHUTE', points); + + console.log(`Parachute: ${points.toFixed(0)} pts (${this.comboMultiplier.toFixed(2)}x)`); + } + + updateComboMultiplier() { + // Combo increases by 0.25x for each unique trick + this.comboMultiplier = 1.0 + (this.tricksInCombo.size - 1) * 0.25; + } + + onLanding() { + // Award pending points + if (this.pendingScore > 0) { + this.scene.scoring?.addTrickScore(this.pendingScore); + + // Show landing bonus if perfect + // TODO: Add perfect landing detection + const landingBonus = 0; + if (landingBonus > 0) { + this.showTrickFeedback('PERFECT LANDING!', landingBonus); + } + } + + // Reset combo + this.comboMultiplier = 1.0; + this.tricksInCombo.clear(); + this.pendingScore = 0; + + console.log('Combo reset on landing'); + } + + showTrickFeedback(trickName, points) { + // Remove previous trick text if still visible + if (this.lastTrickText) { + this.lastTrickText.destroy(); + } + + // Create floating text near player + const player = this.scene.player.sprite; + const x = player.x; + const y = player.y - 30; + + this.lastTrickText = this.scene.add.text(x, y, `${trickName}\n+${Math.floor(points)}`, { + fontFamily: 'Courier New', + fontSize: '10px', + color: '#ffaa00', + stroke: '#000000', + strokeThickness: 2, + align: 'center' + }).setOrigin(0.5); + + // Animate text + this.scene.tweens.add({ + targets: this.lastTrickText, + y: y - 20, + alpha: 0, + duration: 1000, + ease: 'Power2', + onComplete: () => { + this.lastTrickText?.destroy(); + this.lastTrickText = null; + } + }); + } +} diff --git a/src/effects/GlitchFX.js b/src/effects/GlitchFX.js new file mode 100644 index 0000000..05c2b6f --- /dev/null +++ b/src/effects/GlitchFX.js @@ -0,0 +1,91 @@ +/** + * GlitchFX - Periodic glitch visual effects + * Adds RGB split, scanlines, and screen shake for retro-cyber aesthetic + * + * Triggers every 3-6 seconds as per design spec + */ + +export default class GlitchFX { + constructor(scene) { + this.scene = scene; + this.camera = scene.cameras.main; + + // Timing + this.nextGlitchTime = 3000 + Math.random() * 3000; + this.glitchDuration = 0; + this.isGlitching = false; + + // Create scanline overlay + this.createScanlines(); + } + + createScanlines() { + // Create a graphics object for scanlines + this.scanlines = this.scene.add.graphics(); + this.scanlines.setScrollFactor(0); + this.scanlines.setDepth(1500); + this.scanlines.setAlpha(0.15); + + // Draw horizontal scanlines + for (let y = 0; y < 270; y += 2) { + this.scanlines.fillStyle(0x000000, 1); + this.scanlines.fillRect(0, y, 480, 1); + } + } + + update(time, delta) { + // Check if it's time for a glitch + if (!this.isGlitching) { + this.nextGlitchTime -= delta; + + if (this.nextGlitchTime <= 0) { + this.triggerGlitch(); + } + } else { + // Glitch in progress + this.glitchDuration -= delta; + + if (this.glitchDuration <= 0) { + this.endGlitch(); + } else { + // Apply glitch effects + this.applyGlitchEffects(); + } + } + } + + triggerGlitch() { + this.isGlitching = true; + this.glitchDuration = 200 + Math.random() * 300; // 200-500ms glitch + + console.log('Glitch triggered!'); + } + + applyGlitchEffects() { + // RGB split effect (chromatic aberration) + const offset = Math.random() * 3; + this.camera.scrollX += (Math.random() - 0.5) * offset; + + // Screen shake + if (Math.random() < 0.3) { + this.camera.shake(50, 0.002); + } + + // Random flash + if (Math.random() < 0.1) { + this.camera.flash(50, 100, 0, 100); + } + } + + endGlitch() { + this.isGlitching = false; + this.nextGlitchTime = 3000 + Math.random() * 3000; // Next glitch in 3-6 seconds + + // Reset camera + this.camera.scrollX = this.camera.scrollX; // Clamp any offset + } + + destroy() { + this.scanlines?.destroy(); + } +} diff --git a/src/effects/Starfield.js b/src/effects/Starfield.js new file mode 100644 index 0000000..f268d24 --- /dev/null +++ b/src/effects/Starfield.js @@ -0,0 +1,64 @@ +/** + * Starfield - Parallax scrolling star background + * Creates a neon-colored star field for visual depth + */ + +export default class Starfield { + constructor(scene, numStars = 100) { + this.scene = scene; + this.stars = []; + + // Neon star colors + this.colors = [0x00ffff, 0xff00ff, 0x00ff88, 0xffaa00, 0x8888ff]; + + // Create stars at random positions + for (let i = 0; i < numStars; i++) { + this.createStar(); + } + } + + createStar() { + const x = Math.random() * 480; + const y = Math.random() * 10000; // Spread across whole game world + const depth = Math.random(); // 0 = far, 1 = near + const color = this.colors[Math.floor(Math.random() * this.colors.length)]; + const size = depth * 2 + 0.5; + + const star = this.scene.add.circle(x, y, size, color, 0.3 + depth * 0.7); + star.setDepth(-10); + star.setScrollFactor(0.1 + depth * 0.3); // Parallax effect + + // Add twinkle + this.scene.tweens.add({ + targets: star, + alpha: 0.2 + depth * 0.3, + duration: 1000 + Math.random() * 2000, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + + this.stars.push({ + graphic: star, + depth: depth, + initialY: y + }); + } + + update(cameraY) { + // Recycle stars that go off screen + for (let star of this.stars) { + const screenY = star.graphic.y - cameraY * star.graphic.scrollFactorY; + + // If star is way above camera, move it below + if (screenY < cameraY - 500) { + star.graphic.y = cameraY + 500 + Math.random() * 1000; + } + } + } + + destroy() { + this.stars.forEach(star => star.graphic.destroy()); + this.stars = []; + } +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..1f37e5c --- /dev/null +++ b/src/main.js @@ -0,0 +1,53 @@ +/** + * Bitstream Bluffs - Main Entry Point + * A neon-soaked downhill sledding game + * + * Design: 480Ɨ270 virtual resolution, pixel-perfect 1-bit Tron aesthetic + */ + +import Phaser from 'phaser'; +import BootScene from './scenes/BootScene.js'; +import PreloadScene from './scenes/PreloadScene.js'; +import GameScene from './scenes/GameScene.js'; + +// Game configuration - 480Ɨ270 virtual canvas with pixel-perfect scaling +const config = { + type: Phaser.AUTO, + width: 480, + height: 270, + parent: 'game', + backgroundColor: '#000000', + pixelArt: true, + + scale: { + mode: Phaser.Scale.FIT, + autoCenter: Phaser.Scale.CENTER_BOTH, + width: 480, + height: 270 + }, + + physics: { + default: 'matter', + matter: { + gravity: { y: 0.9 }, // ~900 px/s² as per spec + debug: false, + debugBodyColor: 0x00ffff, + debugStaticBodyColor: 0xff00ff + } + }, + + scene: [BootScene, PreloadScene, GameScene], + + // Audio config + audio: { + disableWebAudio: false + } +}; + +// Initialize the game +const game = new Phaser.Game(config); + +// Make game accessible for debugging +window.game = game; + +export default game; diff --git a/src/scenes/BootScene.js b/src/scenes/BootScene.js new file mode 100644 index 0000000..d945cc9 --- /dev/null +++ b/src/scenes/BootScene.js @@ -0,0 +1,23 @@ +/** + * BootScene - Initial boot and setup + * Handles early initialization before asset loading + */ + +import Phaser from 'phaser'; + +export default class BootScene extends Phaser.Scene { + constructor() { + super({ key: 'BootScene' }); + } + + create() { + console.log('BootScene: Initializing...'); + + // Set up any global game state + this.registry.set('version', '2.0.0'); + this.registry.set('highScore', 0); + + // Transition to preload + this.scene.start('PreloadScene'); + } +} diff --git a/src/scenes/GameScene.js b/src/scenes/GameScene.js new file mode 100644 index 0000000..39e7445 --- /dev/null +++ b/src/scenes/GameScene.js @@ -0,0 +1,332 @@ +/** + * GameScene - Main gameplay scene + * Handles player, terrain, tricks, scoring, and The Bit Stream + */ + +import Phaser from 'phaser'; +import TerrainGenerator from '../core/TerrainGenerator.js'; +import Player from '../core/Player.js'; +import TrickSystem from '../core/TrickSystem.js'; +import ScoringSystem from '../core/ScoringSystem.js'; +import Starfield from '../effects/Starfield.js'; +import GlitchFX from '../effects/GlitchFX.js'; +import { generateSeed, seededRandom } from '../utils/seedUtils.js'; + +export default class GameScene extends Phaser.Scene { + constructor() { + super({ key: 'GameScene' }); + } + + create() { + console.log('GameScene: Starting new run'); + + // Initialize game state + this.gameActive = true; + this.seed = generateSeed(); + this.random = seededRandom(this.seed); + + // Neon color palette + this.colors = { + cyan: 0x00ffff, + magenta: 0xff00ff, + lime: 0x00ff88, + amber: 0xffaa00, + blue: 0x0088ff, // Blue terrain + green: 0x00ff44, // Green terrain + pink: 0xff00aa // Magenta terrain + }; + + // Set up camera with very wide horizontal bounds to allow left/right following + // X bounds: -10000 to +10000 (20000 total width for horizontal movement) + // Y bounds: 0 to 100000 (vertical downhill progression) + this.cameras.main.setBounds(-10000, 0, 20000, 100000); + this.cameras.main.setBackgroundColor(0x000000); + + // Create starfield background + this.starfield = new Starfield(this, 150); + + // Create glitch FX + this.glitchFX = new GlitchFX(this); + + // Initialize core systems + this.terrain = new TerrainGenerator(this, this.random); + this.player = new Player(this, 240, 100); + this.trickSystem = new TrickSystem(this); + this.scoring = new ScoringSystem(this); + + // Set up input + this.setupInput(); + + // Create HUD + this.createHUD(); + + // Camera follows player in both X and Y with dynamic leading + this.cameras.main.startFollow(this.player.sprite, false, 0.08, 0.2); + + // Camera leading state + this.cameraLeadX = 0; + this.cameraOffsetY = -60; // Keep player slightly above center + + // Initialize The Bit Stream (chasing element) + this.bitStream = { + y: -200, + speed: 0.5, + active: true + }; + + // Create visual representation of The Bit Stream + this.createBitStreamVisual(); + + // Track game state + this.distance = 0; + this.startTime = this.time.now; + } + + setupInput() { + // Keyboard input + this.keys = { + tab: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TAB), + w: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W), + a: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A), + s: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S), + d: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D), + space: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE), + shift: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SHIFT), + esc: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC) + }; + + // Prevent default tab behavior + this.input.keyboard.on('keydown-TAB', (event) => { + event.preventDefault(); + }); + } + + createHUD() { + // Score display (top-left) + this.scoreText = this.add.text(10, 10, 'SCORE: 0', { + fontFamily: 'Courier New', + fontSize: '12px', + color: '#00ffff', + stroke: '#000000', + strokeThickness: 2 + }).setScrollFactor(0).setDepth(1000); + + // Combo display (top-right) + this.comboText = this.add.text(470, 10, '', { + fontFamily: 'Courier New', + fontSize: '12px', + color: '#ff00ff', + stroke: '#000000', + strokeThickness: 2 + }).setOrigin(1, 0).setScrollFactor(0).setDepth(1000); + + // Seed display (bottom-left) + this.seedText = this.add.text(10, 250, `SEED: ${this.seed}`, { + fontFamily: 'Courier New', + fontSize: '10px', + color: '#888888', + stroke: '#000000', + strokeThickness: 2 + }).setScrollFactor(0).setDepth(1000); + + // Mode indicator (bottom-right) + this.modeText = this.add.text(470, 250, 'SLED', { + fontFamily: 'Courier New', + fontSize: '10px', + color: '#00ff88', + stroke: '#000000', + strokeThickness: 2 + }).setOrigin(1, 0).setScrollFactor(0).setDepth(1000); + } + + update(time, delta) { + if (!this.gameActive) return; + + // Update visual effects + this.starfield.update(this.cameras.main.scrollY); + this.glitchFX.update(time, delta); + + // Update player + this.player.update(this.keys, delta); + + // Update camera leading based on player velocity + this.updateCameraLeading(delta); + + // Update terrain + this.terrain.update(this.player.sprite.y); + + // Update trick system + this.trickSystem.update(this.player, delta); + + // Update scoring + this.distance = Math.abs(this.player.sprite.y - 100); + this.scoring.updateDistance(this.distance); + + // Check terrain type for bonuses + const terrainType = this.terrain.getTerrainAt( + this.player.sprite.x, + this.player.sprite.y + 10 + ); + this.scoring.updateTerrainBonus(terrainType, delta / 1000); + + // Update The Bit Stream (chasing element) + if (this.bitStream.active) { + this.bitStream.y += this.bitStream.speed; + + // Update visual (spans across camera view, not fixed coordinates) + const camX = this.cameras.main.scrollX; + const camWidth = this.cameras.main.width; + + this.bitStreamGraphics.clear(); + this.bitStreamGraphics.fillStyle(0xff0000, 0.3); + this.bitStreamGraphics.fillRect(camX, this.bitStream.y - 50, camWidth, 100); + + // Draw glitchy lines across camera width + this.bitStreamGraphics.lineStyle(2, 0xff00ff, 1); + for (let i = 0; i < 10; i++) { + const lineY = this.bitStream.y - 50 + (Math.random() * 100); + const glitchOffset = (Math.random() - 0.5) * 20; + this.bitStreamGraphics.lineBetween( + camX + glitchOffset, + lineY, + camX + camWidth + glitchOffset, + lineY + ); + } + + // Position particle emitter at center of camera + this.bitStreamParticles.setPosition(camX + camWidth / 2, this.bitStream.y); + + // Game over if The Bit Stream catches the player + if (this.bitStream.y > this.player.sprite.y - 100) { + this.gameOver('CAUGHT BY THE BIT STREAM'); + } + } + + // Update HUD + this.updateHUD(); + + // Check for reset + if (Phaser.Input.Keyboard.JustDown(this.keys.shift)) { + this.restartGame(); + } + + // Check for pause + if (Phaser.Input.Keyboard.JustDown(this.keys.esc)) { + this.scene.pause(); + // TODO: Show pause menu + } + } + + createBitStreamVisual() { + // Create graphics for the bit stream + this.bitStreamGraphics = this.add.graphics(); + this.bitStreamGraphics.setDepth(100); + + // Create particles for the bit stream effect using Phaser 3.60+ API + this.bitStreamParticles = this.add.particles(240, 0, 'particle-magenta', { + x: { min: -240, max: 240 }, + y: 0, + speedY: { min: 20, max: 50 }, + speedX: { min: -20, max: 20 }, + scale: { start: 2, end: 0 }, + alpha: { start: 1, end: 0 }, + lifespan: 1000, + frequency: 20, + tint: [0xff0000, 0xff00ff, 0x8800ff] + }); + this.bitStreamParticles.setDepth(99); + } + + updateCameraLeading(delta) { + // Calculate camera lead based on player velocity + // Player sprite is ~24 units wide, so 4 widths = ~96 units + const playerWidth = 24; + const maxLead = playerWidth * 4; // 96 units ahead at max speed + + // Get player velocity + const velocity = this.player.sprite.body.velocity; + const speed = Math.sqrt(velocity.x * velocity.x + velocity.y * velocity.y); + + // Good clip speed is around 10-15 units/frame + const maxSpeed = 15; + const speedRatio = Math.min(speed / maxSpeed, 1.0); + + // Calculate target lead (direction matters) + // NOTE: Camera offset is INVERTED - negative offset shows what's ahead + // If moving right (+X), we want negative offset to look ahead right + const velocityAngle = Math.atan2(velocity.y, velocity.x); + const targetLeadX = -Math.cos(velocityAngle) * maxLead * speedRatio; // NEGATIVE to look ahead + const targetLeadY = -Math.sin(velocityAngle) * maxLead * speedRatio * 0.5; // NEGATIVE, more vertical lead + + // Smooth interpolation to target lead + const lerpFactor = 0.08; + this.cameraLeadX += (targetLeadX - this.cameraLeadX) * lerpFactor; + const cameraLeadY = this.cameraOffsetY + targetLeadY; + + // Update camera offset + this.cameras.main.setFollowOffset(this.cameraLeadX, cameraLeadY); + } + + updateHUD() { + // Update score + this.scoreText.setText(`SCORE: ${Math.floor(this.scoring.totalScore)}`); + + // Update combo + const combo = this.trickSystem.comboMultiplier; + if (combo > 1) { + this.comboText.setText(`COMBO x${combo.toFixed(2)}`); + this.comboText.setVisible(true); + } else { + this.comboText.setVisible(false); + } + + // Update mode + this.modeText.setText(this.player.isWalkingMode ? 'WALKING' : 'SLED'); + } + + gameOver(reason = 'GAME OVER') { + this.gameActive = false; + + // Display game over + const centerX = this.cameras.main.scrollX + 240; + const centerY = this.cameras.main.scrollY + 135; + + this.add.rectangle(centerX, centerY, 480, 270, 0x000000, 0.8) + .setScrollFactor(0) + .setDepth(2000); + + this.add.text(centerX, centerY - 40, reason, { + fontFamily: 'Courier New', + fontSize: '20px', + color: '#ff0000', + stroke: '#000000', + strokeThickness: 3 + }).setOrigin(0.5).setScrollFactor(0).setDepth(2001); + + this.add.text(centerX, centerY, `FINAL SCORE: ${Math.floor(this.scoring.totalScore)}`, { + fontFamily: 'Courier New', + fontSize: '16px', + color: '#00ffff', + stroke: '#000000', + strokeThickness: 2 + }).setOrigin(0.5).setScrollFactor(0).setDepth(2001); + + this.add.text(centerX, centerY + 40, 'PRESS SHIFT TO RESTART', { + fontFamily: 'Courier New', + fontSize: '12px', + color: '#ffffff', + stroke: '#000000', + strokeThickness: 2 + }).setOrigin(0.5).setScrollFactor(0).setDepth(2001); + + // Allow restart + this.input.keyboard.once('keydown-SHIFT', () => { + this.restartGame(); + }); + } + + restartGame() { + this.scene.restart(); + } +} diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js new file mode 100644 index 0000000..8c8e5f4 --- /dev/null +++ b/src/scenes/PreloadScene.js @@ -0,0 +1,119 @@ +/** + * PreloadScene - Asset loading and preparation + * Loads all game assets and creates procedural graphics + */ + +import Phaser from 'phaser'; + +export default class PreloadScene extends Phaser.Scene { + constructor() { + super({ key: 'PreloadScene' }); + } + + preload() { + // Create a simple loading bar + const width = this.cameras.main.width; + const height = this.cameras.main.height; + + const progressBar = this.add.graphics(); + const progressBox = this.add.graphics(); + progressBox.fillStyle(0x222222, 0.8); + progressBox.fillRect(width/2 - 160, height/2 - 25, 320, 50); + + const loadingText = this.add.text(width/2, height/2 - 50, 'LOADING BITSTREAM BLUFFS', { + fontFamily: 'Courier New', + fontSize: '16px', + color: '#00ffff' + }).setOrigin(0.5); + + // Update progress bar + this.load.on('progress', (value) => { + progressBar.clear(); + progressBar.fillStyle(0x00ffff, 1); + progressBar.fillRect(width/2 - 150, height/2 - 15, 300 * value, 30); + }); + + this.load.on('complete', () => { + progressBar.destroy(); + progressBox.destroy(); + loadingText.destroy(); + }); + + // Load any future assets here + // For now, we'll use procedural graphics + } + + create() { + console.log('PreloadScene: Assets loaded'); + + // Create procedural graphics for the game + this.createSledGraphic(); + this.createParticleGraphics(); + + // Transition to game + this.scene.start('GameScene'); + } + + /** + * Create a simple wireframe sled graphic + */ + createSledGraphic() { + const graphics = this.add.graphics(); + + // Draw a simple sled shape (trapezoid) + graphics.lineStyle(2, 0x00ffff, 1); + graphics.beginPath(); + graphics.moveTo(0, 0); + graphics.lineTo(20, 0); + graphics.lineTo(18, 8); + graphics.lineTo(2, 8); + graphics.closePath(); + graphics.strokePath(); + + // Add runners + graphics.lineStyle(2, 0x00ffff, 1); + graphics.beginPath(); + graphics.moveTo(2, 8); + graphics.lineTo(0, 12); + graphics.moveTo(18, 8); + graphics.lineTo(20, 12); + graphics.strokePath(); + + // Generate texture from graphics + graphics.generateTexture('sled', 24, 16); + graphics.destroy(); + } + + /** + * Create particle graphics for effects + */ + createParticleGraphics() { + // Cyan particle + const cyan = this.add.graphics(); + cyan.fillStyle(0x00ffff, 1); + cyan.fillCircle(2, 2, 2); + cyan.generateTexture('particle-cyan', 4, 4); + cyan.destroy(); + + // Magenta particle + const magenta = this.add.graphics(); + magenta.fillStyle(0xff00ff, 1); + magenta.fillCircle(2, 2, 2); + magenta.generateTexture('particle-magenta', 4, 4); + magenta.destroy(); + + // Lime particle + const lime = this.add.graphics(); + lime.fillStyle(0x00ff88, 1); + lime.fillCircle(2, 2, 2); + lime.generateTexture('particle-lime', 4, 4); + lime.destroy(); + + // Amber particle + const amber = this.add.graphics(); + amber.fillStyle(0xffaa00, 1); + amber.fillCircle(2, 2, 2); + amber.generateTexture('particle-amber', 4, 4); + amber.destroy(); + } +} diff --git a/src/utils/seedUtils.js b/src/utils/seedUtils.js new file mode 100644 index 0000000..7a92667 --- /dev/null +++ b/src/utils/seedUtils.js @@ -0,0 +1,74 @@ +/** + * Seed utilities for deterministic procedural generation + * Allows players to share seeds for identical terrain layouts + */ + +/** + * Generate a random seed string (8 alphanumeric characters) + */ +export function generateSeed() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let seed = ''; + for (let i = 0; i < 8; i++) { + seed += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return seed; +} + +/** + * Convert seed string to a numeric hash + */ +function seedToHash(seed) { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + const char = seed.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); +} + +/** + * Create a seeded pseudo-random number generator + * Uses a simple Linear Congruential Generator (LCG) + * + * @param {string} seed - Seed string + * @returns {function} Random function that returns 0-1 + */ +export function seededRandom(seed) { + let state = seedToHash(seed); + + return function() { + // LCG parameters (from Numerical Recipes) + state = (state * 1664525 + 1013904223) & 0xFFFFFFFF; + return (state >>> 0) / 0xFFFFFFFF; + }; +} + +/** + * Parse a seed from user input (validates format) + */ +export function parseSeed(input) { + const cleaned = input.toUpperCase().replace(/[^A-Z0-9]/g, ''); + if (cleaned.length === 8) { + return cleaned; + } + return null; // Invalid seed +} + +/** + * Create a shareable seed URL + */ +export function createSeedURL(seed) { + const baseURL = window.location.origin + window.location.pathname; + return `${baseURL}?seed=${seed}`; +} + +/** + * Get seed from URL parameters + */ +export function getSeedFromURL() { + const params = new URLSearchParams(window.location.search); + const seed = params.get('seed'); + return seed ? parseSeed(seed) : null; +}