Initial commit: Danger Field Game with directional audio

- Open field gameplay with boundary walls only
- Three types of squares: safe (white), visual danger (red), audio danger (hidden)
- Directional audio system with stereo panning and pitch variation
- Proximity warnings with spatial audio cues
- Multi-sensory feedback (visual, audio, haptic)
- HTML5 Canvas rendering with smooth animations
- Web Audio API integration for rich sound effects
- Responsive design and modern JavaScript ES6+ modules
This commit is contained in:
kuhyx 2025-08-01 16:40:12 +02:00
commit a91a61a288
14 changed files with 2172 additions and 0 deletions

26
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,26 @@
# Copilot Instructions
<!-- Use this file to provide workspace-specific custom instructions to Copilot. For more details, visit https://code.visualstudio.com/docs/copilot/copilot-customization#_use-a-githubcopilotinstructionsmd-file -->
## Project Overview
This is a labyrinth game built with vanilla JavaScript and HTML5 Canvas. The game features:
- Player navigation through a maze using keyboard controls
- Visual feedback for dangerous vs safe squares (different colors/patterns)
- Audio feedback using Web Audio API for dangerous areas
- Haptic vibration feedback on supported devices
- Canvas-based rendering for smooth graphics
## Code Style Guidelines
- Use modern ES6+ JavaScript features
- Implement modular code structure with separate files for game logic, rendering, and audio
- Use clear variable and function names that describe their purpose
- Add comments for complex game mechanics and algorithms
## Key Components
- Game engine with update/render loop
- Player movement and collision detection
- Maze generation or predefined layouts
- Audio system for sound effects
- Vibration API integration for haptic feedback
- Visual effects and animations for enhanced user experience

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

126
README.md Normal file
View File

@ -0,0 +1,126 @@
# Danger Field Game
A browser-based danger field game where players navigate through an open area filled with hidden and visible dangers. The game provides multiple types of feedback to enhance the gaming experience.
## Features
### Core Gameplay
- **Player Movement**: Navigate using WASD keys or arrow keys
- **Open Field**: No internal walls - only outer boundary is impassable
- **Mixed Dangers**: Scattered visual and audio danger squares throughout the field
- **Goal**: Reach the exit (🏁) while avoiding various types of dangers
### Feedback Systems
- **Visual Feedback**:
- **Safe squares**: Pure white appearance
- **Visual danger squares**: Animated red with warning patterns (no audio)
- **Audio danger squares**: Look identical to safe squares (white)
- Exit square pulses green with a flag symbol
- Player has a glowing green circle with subtle animations
- **Audio Feedback**:
- **Directional proximity warnings**: Spatial audio indicates direction of hidden dangers
- **Left/Right**: Sound pans to the side where danger is located
- **Up/Down**: Pitch changes (higher for above, lower for below)
- **Multiple directions**: Combined audio cues for complex danger patterns
- **Danger sounds**: When stepping on audio danger squares
- **Victory melody**: When completing the maze
- Built with Web Audio API with stereo panning and pitch modulation
- **Haptic Feedback**:
- **Visual danger**: Quick double vibration (no audio)
- **Audio danger**: Long vibration pattern with audio
- **Victory**: Celebration vibration pattern
- Toggle on/off functionality
### Technical Features
- **HTML5 Canvas Rendering**: Smooth 60fps graphics
- **Modular Architecture**: Clean separation of concerns
- **Responsive Design**: Works on desktop and mobile devices
- **Modern JavaScript**: ES6+ features and modules
- **Open Field Design**: No maze walls, only boundary constraints
## Getting Started
### Prerequisites
- Node.js (v20+ recommended)
- Modern web browser with Web Audio API support
### Installation
1. Clone or download the project
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run dev
```
4. Open your browser and navigate to the provided local URL (typically `http://localhost:5173`)
### Building for Production
```bash
npm run build
```
## How to Play
1. **Movement**: Use WASD keys or arrow keys to move your character (green circle)
2. **Visual Dangers**: Red animated squares are dangerous - avoid them for safety
3. **Hidden Audio Dangers**: Some white squares look safe but are dangerous
4. **Directional Audio Cues**:
- **Left/Right panning**: Sound comes from the side where hidden dangers are located
- **Pitch variation**: Higher pitch for dangers above, lower pitch for dangers below
- **Rhythmic patterns**: Different timing patterns indicate direction
5. **Open Navigation**: Move freely through the field - only the outer boundary blocks you
6. **Find the Exit**: Navigate to the green flag (🏁) to complete the level
7. **Headphone Recommended**: Use headphones or good speakers for optimal directional audio experience
## Game Controls
- **W / ↑**: Move up
- **A / ←**: Move left
- **S / ↓**: Move down
- **D / →**: Move right
- **Sound Toggle**: Enable/disable audio feedback
- **Vibration Toggle**: Enable/disable haptic feedback
## Technical Architecture
The game is built with a modular architecture:
- `game.js`: Main game loop and state management
- `player.js`: Player entity and rendering
- `maze.js`: Field generation and rendering
- `audio.js`: Sound effects and Web Audio API integration
- `input.js`: Keyboard input handling
- `style.css`: Game styling and responsive design
## Browser Compatibility
- **Audio**: Requires Web Audio API support with stereo panning (all modern browsers)
- **Vibration**: Requires Vibration API support (mobile browsers, some desktop)
- **Graphics**: HTML5 Canvas support (all modern browsers)
- **Directional Audio**: Best experienced with headphones or stereo speakers
## Development
This project uses Vite for fast development and building. The codebase follows modern JavaScript standards with ES6+ modules.
### Project Structure
```
src/
├── main.js # Entry point
├── game.js # Main game class
├── player.js # Player entity
├── maze.js # Maze generation and rendering
├── audio.js # Audio system
├── input.js # Input handling
└── style.css # Styling
```
## License
This project is open source and available under the MIT License.

28
index.html Normal file
View File

@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Danger Field Game</title>
</head>
<body>
<div id="app">
<div class="game-container">
<h1>Danger Field Game</h1>
<div class="game-info">
<p>Use WASD or Arrow Keys to move</p>
<p class="visual-warning">⚠️ Red squares are visually dangerous!</p>
<p class="audio-warning">🔊 Listen carefully - directional audio warns of hidden dangers!</p>
<p class="audio-hint">🎧 Use headphones for best directional audio experience</p>
</div>
<canvas id="gameCanvas" width="800" height="600"></canvas>
<div class="controls">
<button id="soundToggle">🔊 Sound: ON</button>
<button id="vibrationToggle">📳 Vibration: ON</button>
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1026
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

14
package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "gamejam2025",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^7.0.4"
}
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

209
src/audio.js Normal file
View File

@ -0,0 +1,209 @@
export class AudioSystem {
constructor() {
this.audioContext = null;
this.gainNode = null;
this.activeSounds = new Set();
this.initialized = false;
this.initializeAudio();
}
async initializeAudio() {
try {
// Create audio context (requires user interaction)
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.gainNode = this.audioContext.createGain();
this.gainNode.connect(this.audioContext.destination);
this.gainNode.gain.value = 0.3; // Set volume to 30%
this.initialized = true;
} catch (error) {
console.warn('Web Audio API not supported:', error);
}
}
async ensureAudioContext() {
if (!this.initialized) {
await this.initializeAudio();
}
if (this.audioContext && this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
}
createOscillator(frequency, type = 'sine', duration = 0.2, panValue = 0, pitchBend = 0) {
if (!this.audioContext || !this.initialized) return null;
const oscillator = this.audioContext.createOscillator();
const envelope = this.audioContext.createGain();
const panner = this.audioContext.createStereoPanner();
oscillator.connect(envelope);
envelope.connect(panner);
panner.connect(this.gainNode);
oscillator.frequency.value = frequency + pitchBend;
oscillator.type = type;
// Set stereo panning (-1 = left, 0 = center, 1 = right)
panner.pan.value = Math.max(-1, Math.min(1, panValue));
// Create ADSR envelope
const now = this.audioContext.currentTime;
envelope.gain.setValueAtTime(0, now);
envelope.gain.linearRampToValueAtTime(1, now + 0.01); // Attack
envelope.gain.exponentialRampToValueAtTime(0.01, now + duration); // Decay
oscillator.start(now);
oscillator.stop(now + duration);
this.activeSounds.add(oscillator);
oscillator.onended = () => {
this.activeSounds.delete(oscillator);
};
return oscillator;
}
createNoise(duration = 0.1) {
if (!this.audioContext || !this.initialized) return null;
const bufferSize = this.audioContext.sampleRate * duration;
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate);
const output = buffer.getChannelData(0);
// Generate white noise
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
const source = this.audioContext.createBufferSource();
const filter = this.audioContext.createBiquadFilter();
const envelope = this.audioContext.createGain();
source.buffer = buffer;
source.connect(filter);
filter.connect(envelope);
envelope.connect(this.gainNode);
filter.type = 'lowpass';
filter.frequency.value = 300;
const now = this.audioContext.currentTime;
envelope.gain.setValueAtTime(0.5, now);
envelope.gain.linearRampToValueAtTime(0, now + duration);
source.start(now);
this.activeSounds.add(source);
source.onended = () => {
this.activeSounds.delete(source);
};
return source;
}
async playDangerSound() {
await this.ensureAudioContext();
// Create a danger sound with multiple frequencies and noise
this.createOscillator(150, 'sawtooth', 0.3);
this.createOscillator(200, 'square', 0.2);
this.createNoise(0.15);
// Add a low rumble
setTimeout(() => {
this.createOscillator(80, 'sine', 0.4);
}, 100);
}
async playProximitySound() {
await this.ensureAudioContext();
// Create a subtle warning sound for proximity to audio danger
this.createOscillator(300, 'sine', 0.1);
this.createOscillator(250, 'sine', 0.1);
}
async playDirectionalProximitySound(directions) {
await this.ensureAudioContext();
// Calculate the dominant direction for panning
let panValue = 0;
let pitchModifier = 0;
// Horizontal panning (left/right)
if (directions.left && !directions.right) {
panValue = -0.7; // Pan left
} else if (directions.right && !directions.left) {
panValue = 0.7; // Pan right
}
// Vertical pitch variation (up/down)
if (directions.up && !directions.down) {
pitchModifier = 50; // Higher pitch for above
} else if (directions.down && !directions.up) {
pitchModifier = -50; // Lower pitch for below
}
// Create directional warning sounds
this.createOscillator(300, 'sine', 0.15, panValue, pitchModifier);
this.createOscillator(250, 'sine', 0.15, panValue, pitchModifier * 0.5);
// Add subtle rhythmic variation based on direction
if (directions.up) {
// Quick double beep for above
setTimeout(() => {
this.createOscillator(350, 'sine', 0.05, panValue, pitchModifier);
}, 80);
} else if (directions.down) {
// Slower, deeper sound for below
this.createOscillator(200, 'sine', 0.2, panValue, pitchModifier);
}
}
async playWinSound() {
await this.ensureAudioContext();
// Create a victory melody
const notes = [262, 330, 392, 523]; // C, E, G, C (one octave higher)
notes.forEach((frequency, index) => {
setTimeout(() => {
this.createOscillator(frequency, 'sine', 0.4);
}, index * 100);
});
// Add harmony
setTimeout(() => {
this.createOscillator(659, 'sine', 0.6); // E
}, 200);
}
playMoveSound() {
if (!this.initialized) return;
// Subtle movement sound
this.createOscillator(440, 'sine', 0.05);
}
stopAll() {
this.activeSounds.forEach(sound => {
try {
if (sound.stop) {
sound.stop();
}
} catch (error) {
// Sound may have already stopped
}
});
this.activeSounds.clear();
}
setVolume(volume) {
if (this.gainNode) {
this.gainNode.gain.value = Math.max(0, Math.min(1, volume));
}
}
}

177
src/game.js Normal file
View File

@ -0,0 +1,177 @@
import { Player } from './player.js';
import { Maze } from './maze.js';
import { AudioSystem } from './audio.js';
import { InputHandler } from './input.js';
export class Game {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.running = false;
this.lastTime = 0;
// Game settings
this.cellSize = 40;
this.cols = Math.floor(canvas.width / this.cellSize);
this.rows = Math.floor(canvas.height / this.cellSize);
// Initialize game systems
this.maze = new Maze(this.cols, this.rows);
this.player = new Player(1, 1, this.cellSize);
this.audioSystem = new AudioSystem();
this.inputHandler = new InputHandler();
// Game state
this.soundEnabled = true;
this.vibrationEnabled = true;
this.lastProximityWarning = 0;
this.setupControls();
this.bindEvents();
}
setupControls() {
const soundToggle = document.getElementById('soundToggle');
const vibrationToggle = document.getElementById('vibrationToggle');
soundToggle.addEventListener('click', () => {
this.soundEnabled = !this.soundEnabled;
soundToggle.textContent = `🔊 Sound: ${this.soundEnabled ? 'ON' : 'OFF'}`;
if (!this.soundEnabled) {
this.audioSystem.stopAll();
}
});
vibrationToggle.addEventListener('click', () => {
this.vibrationEnabled = !this.vibrationEnabled;
vibrationToggle.textContent = `📳 Vibration: ${this.vibrationEnabled ? 'ON' : 'OFF'}`;
});
}
bindEvents() {
this.inputHandler.onMove = (direction) => {
this.movePlayer(direction);
};
}
movePlayer(direction) {
const newX = this.player.x + direction.x;
const newY = this.player.y + direction.y;
// Check boundaries and walls
if (this.maze.canMoveTo(newX, newY)) {
this.player.moveTo(newX, newY);
// Check if player stepped on a visual danger square
if (this.maze.isDangerousVisual(newX, newY)) {
this.handleVisualDanger();
}
// Check if player stepped on an audio danger square
if (this.maze.isDangerousAudio(newX, newY)) {
this.handleAudioDanger();
}
// Check if player reached the exit
if (this.maze.isExit(newX, newY)) {
this.handleWin();
}
}
}
handleVisualDanger() {
// Visual danger squares only provide vibration feedback (no audio)
if (this.vibrationEnabled && navigator.vibrate) {
navigator.vibrate([150, 50, 150]); // Quick double vibration
}
}
handleAudioDanger() {
// Audio danger squares provide both sound and vibration
if (this.soundEnabled) {
this.audioSystem.playDangerSound();
}
if (this.vibrationEnabled && navigator.vibrate) {
navigator.vibrate([200, 100, 200]); // Pattern: vibrate-pause-vibrate
}
}
handleWin() {
if (this.soundEnabled) {
this.audioSystem.playWinSound();
}
if (this.vibrationEnabled && navigator.vibrate) {
navigator.vibrate([100, 50, 100, 50, 100]); // Victory pattern
}
setTimeout(() => {
alert('Congratulations! You completed the labyrinth!');
this.resetGame();
}, 500);
}
resetGame() {
this.maze.generate();
this.player.moveTo(1, 1);
}
update(deltaTime) {
this.player.update(deltaTime);
// Check proximity to audio danger squares and play directional warning sounds
if (this.soundEnabled) {
const directions = this.maze.getAudioDangerDirections(this.player.x, this.player.y);
if (directions.squares.length > 0) {
this.playDirectionalProximityWarning(directions);
}
}
}
playDirectionalProximityWarning(directions) {
// Play a directional warning sound when near audio danger squares
// Use a timer to avoid playing too frequently
const now = Date.now();
if (!this.lastProximityWarning || now - this.lastProximityWarning > 1000) {
this.audioSystem.playDirectionalProximitySound(directions);
this.lastProximityWarning = now;
}
}
render() {
// Clear canvas
this.ctx.fillStyle = '#1a1a1a';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Render maze
this.maze.render(this.ctx, this.cellSize);
// Render player
this.player.render(this.ctx);
}
gameLoop(currentTime) {
if (!this.running) return;
const deltaTime = currentTime - this.lastTime;
this.lastTime = currentTime;
this.update(deltaTime);
this.render();
requestAnimationFrame((time) => this.gameLoop(time));
}
start() {
this.running = true;
this.maze.generate();
this.lastTime = performance.now();
requestAnimationFrame((time) => this.gameLoop(time));
}
stop() {
this.running = false;
this.audioSystem.stopAll();
}
}

79
src/input.js Normal file
View File

@ -0,0 +1,79 @@
export class InputHandler {
constructor() {
this.keys = new Set();
this.onMove = null; // Callback for movement
this.moveDelay = 150; // Milliseconds between moves
this.lastMoveTime = 0;
this.bindEvents();
}
bindEvents() {
document.addEventListener('keydown', (e) => this.handleKeyDown(e));
document.addEventListener('keyup', (e) => this.handleKeyUp(e));
// Prevent default behavior for game keys
document.addEventListener('keydown', (e) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
}
});
}
handleKeyDown(e) {
this.keys.add(e.code);
this.processMovement();
}
handleKeyUp(e) {
this.keys.delete(e.code);
}
processMovement() {
const now = Date.now();
if (now - this.lastMoveTime < this.moveDelay) {
return; // Too soon to move again
}
let direction = null;
// Check for movement keys (WASD or Arrow keys)
if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) {
direction = { x: 0, y: -1 };
} else if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) {
direction = { x: 0, y: 1 };
} else if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) {
direction = { x: -1, y: 0 };
} else if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) {
direction = { x: 1, y: 0 };
}
if (direction && this.onMove) {
this.onMove(direction);
this.lastMoveTime = now;
}
}
// Method to handle continuous key holding
update() {
if (this.keys.size > 0) {
this.processMovement();
}
}
// Get current input state
getInputState() {
return {
up: this.keys.has('KeyW') || this.keys.has('ArrowUp'),
down: this.keys.has('KeyS') || this.keys.has('ArrowDown'),
left: this.keys.has('KeyA') || this.keys.has('ArrowLeft'),
right: this.keys.has('KeyD') || this.keys.has('ArrowRight'),
};
}
// Clean up event listeners
destroy() {
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
}
}

11
src/main.js Normal file
View File

@ -0,0 +1,11 @@
import './style.css'
import { Game } from './game.js'
// Initialize the game when the page loads
document.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('gameCanvas');
const game = new Game(canvas);
// Start the game
game.start();
});

224
src/maze.js Normal file
View File

@ -0,0 +1,224 @@
export class Maze {
constructor(cols, rows) {
this.cols = cols;
this.rows = rows;
this.grid = [];
this.dangerousSquares = new Set();
this.exitX = cols - 2;
this.exitY = rows - 2;
// Cell types
this.WALL = 0;
this.SAFE = 1;
this.DANGEROUS_VISUAL = 2; // Visually dangerous, no audio
this.DANGEROUS_AUDIO = 3; // Looks safe, has audio when near
this.EXIT = 4;
}
generate() {
// Initialize grid with safe squares
this.grid = Array(this.rows).fill().map(() => Array(this.cols).fill(this.SAFE));
this.dangerousSquares.clear();
// Create outer boundary walls only
this.createOuterWalls();
// Add dangerous squares (about 30% of inner safe squares)
this.addDangerousSquares();
// Set exit (ensure it's not on the outer wall)
this.exitX = this.cols - 2;
this.exitY = this.rows - 2;
this.grid[this.exitY][this.exitX] = this.EXIT;
this.dangerousSquares.delete(`${this.exitX},${this.exitY}`);
}
createOuterWalls() {
// Create walls only on the outer perimeter
for (let y = 0; y < this.rows; y++) {
for (let x = 0; x < this.cols; x++) {
if (x === 0 || x === this.cols - 1 || y === 0 || y === this.rows - 1) {
this.grid[y][x] = this.WALL;
}
}
}
}
addDangerousSquares() {
const innerSafeSquares = [];
// Find all inner safe squares (excluding outer wall boundary and player start)
for (let y = 1; y < this.rows - 1; y++) {
for (let x = 1; x < this.cols - 1; x++) {
if (this.grid[y][x] === this.SAFE && !(x === 1 && y === 1) && !(x === this.exitX && y === this.exitY)) {
innerSafeSquares.push({ x, y });
}
}
}
// Make 30% of inner safe squares dangerous (15% visual, 15% audio)
const totalDangerousCount = Math.floor(innerSafeSquares.length * 0.3);
const visualDangerousCount = Math.floor(totalDangerousCount * 0.5);
const audioDangerousCount = totalDangerousCount - visualDangerousCount;
this.shuffle(innerSafeSquares);
// Add visual-only dangerous squares
for (let i = 0; i < visualDangerousCount; i++) {
const { x, y } = innerSafeSquares[i];
this.grid[y][x] = this.DANGEROUS_VISUAL;
this.dangerousSquares.add(`${x},${y}`);
}
// Add audio-only dangerous squares
for (let i = visualDangerousCount; i < visualDangerousCount + audioDangerousCount; i++) {
const { x, y } = innerSafeSquares[i];
this.grid[y][x] = this.DANGEROUS_AUDIO;
this.dangerousSquares.add(`${x},${y}`);
}
}
shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
canMoveTo(x, y) {
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) {
return false;
}
return this.grid[y][x] !== this.WALL;
}
isDangerous(x, y) {
return this.grid[y][x] === this.DANGEROUS_VISUAL || this.grid[y][x] === this.DANGEROUS_AUDIO;
}
isDangerousVisual(x, y) {
return this.grid[y][x] === this.DANGEROUS_VISUAL;
}
isDangerousAudio(x, y) {
return this.grid[y][x] === this.DANGEROUS_AUDIO;
}
isNearAudioDanger(playerX, playerY, range = 1) {
// Check if player is within range of any audio danger squares
for (let dy = -range; dy <= range; dy++) {
for (let dx = -range; dx <= range; dx++) {
const checkX = playerX + dx;
const checkY = playerY + dy;
if (checkX >= 0 && checkX < this.cols && checkY >= 0 && checkY < this.rows) {
if (this.grid[checkY][checkX] === this.DANGEROUS_AUDIO) {
return true;
}
}
}
}
return false;
}
getAudioDangerDirections(playerX, playerY, range = 1) {
// Get all directions where audio danger squares are located
const directions = {
left: false,
right: false,
up: false,
down: false,
squares: []
};
for (let dy = -range; dy <= range; dy++) {
for (let dx = -range; dx <= range; dx++) {
if (dx === 0 && dy === 0) continue; // Skip player's current position
const checkX = playerX + dx;
const checkY = playerY + dy;
if (checkX >= 0 && checkX < this.cols && checkY >= 0 && checkY < this.rows) {
if (this.grid[checkY][checkX] === this.DANGEROUS_AUDIO) {
directions.squares.push({ x: checkX, y: checkY, dx, dy });
// Determine primary directions
if (dx < 0) directions.left = true;
if (dx > 0) directions.right = true;
if (dy < 0) directions.up = true;
if (dy > 0) directions.down = true;
}
}
}
}
return directions;
}
isExit(x, y) {
return this.grid[y][x] === this.EXIT;
}
render(ctx, cellSize) {
for (let y = 0; y < this.rows; y++) {
for (let x = 0; x < this.cols; x++) {
const pixelX = x * cellSize;
const pixelY = y * cellSize;
switch (this.grid[y][x]) {
case this.WALL:
ctx.fillStyle = '#333333';
ctx.fillRect(pixelX, pixelY, cellSize, cellSize);
// Add some texture to walls
ctx.fillStyle = '#444444';
ctx.fillRect(pixelX + 2, pixelY + 2, cellSize - 4, cellSize - 4);
break;
case this.SAFE:
case this.DANGEROUS_AUDIO:
// Safe squares and audio-danger squares look identical (white)
ctx.fillStyle = '#ffffff';
ctx.fillRect(pixelX, pixelY, cellSize, cellSize);
// Add subtle grid lines
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
ctx.strokeRect(pixelX, pixelY, cellSize, cellSize);
break;
case this.DANGEROUS_VISUAL:
// Visual-only danger squares - animated red appearance
const time = Date.now() * 0.003;
const intensity = (Math.sin(time + x + y) + 1) * 0.5;
const red = Math.floor(150 + intensity * 105);
ctx.fillStyle = `rgb(${red}, 50, 50)`;
ctx.fillRect(pixelX, pixelY, cellSize, cellSize);
// Add warning pattern
ctx.fillStyle = `rgba(255, 255, 255, ${0.3 + intensity * 0.3})`;
ctx.fillRect(pixelX + 5, pixelY + 5, cellSize - 10, cellSize - 10);
// Add border
ctx.strokeStyle = '#ff6666';
ctx.lineWidth = 2;
ctx.strokeRect(pixelX, pixelY, cellSize, cellSize);
break;
case this.EXIT:
// Animated exit square
const exitTime = Date.now() * 0.005;
const exitGlow = (Math.sin(exitTime) + 1) * 0.5;
const green = Math.floor(100 + exitGlow * 155);
ctx.fillStyle = `rgb(50, ${green}, 50)`;
ctx.fillRect(pixelX, pixelY, cellSize, cellSize);
// Add exit symbol
ctx.fillStyle = '#ffffff';
ctx.font = `${cellSize * 0.6}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('🏁', pixelX + cellSize / 2, pixelY + cellSize / 2);
break;
}
}
}
}
}

71
src/player.js Normal file
View File

@ -0,0 +1,71 @@
export class Player {
constructor(x, y, size) {
this.x = x;
this.y = y;
this.size = size;
this.color = '#4CAF50';
this.animationTime = 0;
this.isMoving = false;
this.moveSpeed = 5; // Animation speed multiplier
}
moveTo(x, y) {
this.x = x;
this.y = y;
this.isMoving = true;
this.animationTime = 0;
}
update(deltaTime) {
if (this.isMoving) {
this.animationTime += deltaTime * this.moveSpeed;
if (this.animationTime >= 1000) { // 1 second animation
this.isMoving = false;
this.animationTime = 0;
}
}
}
render(ctx) {
const pixelX = this.x * this.size;
const pixelY = this.y * this.size;
// Add a subtle pulse animation
const pulse = Math.sin(Date.now() * 0.005) * 0.1 + 1;
const radius = (this.size * 0.3) * pulse;
// Draw player as a circle with glow effect
ctx.save();
// Glow effect
ctx.shadowColor = this.color;
ctx.shadowBlur = 15;
// Main player circle
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(
pixelX + this.size / 2,
pixelY + this.size / 2,
radius,
0,
Math.PI * 2
);
ctx.fill();
// Inner highlight
ctx.shadowBlur = 0;
ctx.fillStyle = '#81C784';
ctx.beginPath();
ctx.arc(
pixelX + this.size / 2 - 3,
pixelY + this.size / 2 - 3,
radius * 0.6,
0,
Math.PI * 2
);
ctx.fill();
ctx.restore();
}
}

156
src/style.css Normal file
View File

@ -0,0 +1,156 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark light;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
}
#app {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.game-container {
background: rgba(0, 0, 0, 0.3);
border-radius: 15px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
h1 {
font-size: 3.2em;
line-height: 1.1;
margin-bottom: 1rem;
background: linear-gradient(45deg, #4CAF50, #81C784);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 2px 10px rgba(76, 175, 80, 0.3);
}
.game-info {
margin-bottom: 1.5rem;
}
.game-info p {
margin: 0.5rem 0;
font-size: 1.1em;
opacity: 0.9;
}
.visual-warning {
color: #ff6b6b !important;
font-weight: bold;
animation: pulse 2s infinite;
}
.audio-warning {
color: #ffd700 !important;
font-weight: bold;
animation: pulse 2s infinite;
}
.audio-hint {
color: #87ceeb !important;
font-size: 0.9em;
font-style: italic;
opacity: 0.8;
}
@keyframes pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
#gameCanvas {
border: 3px solid rgba(255, 255, 255, 0.2);
border-radius: 10px;
background: #1a1a1a;
box-shadow:
0 0 20px rgba(0, 0, 0, 0.5),
inset 0 0 20px rgba(255, 255, 255, 0.05);
margin: 1rem 0;
display: block;
margin-left: auto;
margin-right: auto;
}
.controls {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
}
.controls button {
background: linear-gradient(45deg, #4CAF50, #45a049);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 25px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.controls button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.4);
background: linear-gradient(45deg, #45a049, #4CAF50);
}
.controls button:active {
transform: translateY(0);
box-shadow: 0 2px 10px rgba(76, 175, 80, 0.3);
}
/* Responsive design */
@media (max-width: 768px) {
#app {
padding: 1rem;
}
.game-container {
padding: 1rem;
}
h1 {
font-size: 2.5em;
}
#gameCanvas {
max-width: 100%;
height: auto;
}
.controls {
flex-direction: column;
gap: 0.5rem;
}
}