Compare commits

..

19 Commits

Author SHA1 Message Date
Thomas Richter
fd3517da85 deploy: update image to latest 2026-02-05 23:31:23 +01:00
Thomas Richter
283c88134a deploy: update image to latest 2026-02-05 19:46:22 +01:00
Thomas Richter
077e58216c deploy: update image to latest 2026-02-05 19:33:30 +01:00
Thomas Richter
2ad205d495 fix: whale stays longer (15s) and no pointer cursor
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 01:43:56 +01:00
Thomas Richter
c220ea4b53 feat: add swimming whale to Deep Sea scene
- Whale enters from random edge, exits to different edge
- 5 second smooth swim with Sine.inOut easing
- 2 second respawn delay
- Bioluminescent eye glow effect
- Subtle bobbing animation during movement

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 00:46:30 +01:00
Thomas Richter
3328269499 feat: add Deep Sea alternative hunting area
- Add Deep Sea location to navigation map
- Create DeepSeaHuntingScene with dark atmosphere
- Add deep ocean transition visuals
- Bioluminescent particles and giant shadows

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 00:29:32 +01:00
Thomas Richter
f2be8ca3d0 style: move fullscreen button to top-left corner 2026-02-05 00:10:16 +01:00
Thomas Richter
be41f912a2 feat: add fullscreen mode toggle button
- Add src/utils/fullscreen.js with createFullscreenButton() helper
- Fullscreen button appears in bottom-right corner of all scenes
- Click to toggle fullscreen mode
- Uses Phaser's built-in scale manager

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 00:05:03 +01:00
Thomas Richter
1154a78908 feat: add responsive font sizing for mobile
- Add src/utils/responsive.js with fontSize() helper
- Mobile fonts scale 1.4x for better readability
- Update all scenes to use responsive font sizes
- Update deploy-k8s.sh with full deployment steps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:50:29 +01:00
Thomas Richter
b0fb15fe7b feat: add Kubernetes deployment with ArgoCD
- Add k8s/ manifests (Deployment, Service, Ingress)
- Use Kustomize for configuration
- ArgoCD application for GitOps deployment
- Traefik ingress with Let's Encrypt TLS
- Deploy script for CI/CD workflow

Deploys to: https://whalehunting.kube2.tricnet.de

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:28:55 +01:00
Thomas Richter
576799ae0e docs: map existing codebase
- STACK.md - Technologies and dependencies
- ARCHITECTURE.md - System design and patterns
- STRUCTURE.md - Directory layout
- CONVENTIONS.md - Code style and patterns
- TESTING.md - Test structure
- INTEGRATIONS.md - External services
- CONCERNS.md - Technical debt and issues
2026-02-04 23:16:04 +01:00
Thomas Richter
24a44583ef Configure Vitest UI for network access
- Set server host to 0.0.0.0 for remote access
- Set explicit port 51204 for Vitest UI
- Allows accessing test UI from other machines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 21:13:45 +01:00
Thomas Richter
b52cdb0685 Add comprehensive test suite with Vitest and Playwright
**Unit Tests (Vitest):**
- Test game logic: inventory management, barrel calculations
- Test whale hunting mechanics: fuel consumption, oil rewards, health system
- Test mobile detection patterns
- Test crosshair bounds and scene transitions
- 21 unit tests covering core game logic
- Fast execution with jsdom environment

**E2E Tests (Playwright):**
- Test complete game flows from intro to hunting
- Test scene navigation and transitions
- Test whale hunting interaction
- Test mobile compatibility and touch interactions
- Test desktop scaling on various viewports
- Run on both desktop (Chrome) and mobile (iPhone 12) configurations

**Test Scripts:**
- npm test - Run unit tests
- npm run test:ui - Run unit tests with UI
- npm run test:coverage - Run with coverage report
- npm run test:e2e - Run E2E tests
- npm run test:e2e:ui - Run E2E tests with UI
- npm run test:all - Run all tests

**Configuration:**
- Vitest configured with jsdom for fast unit testing
- Playwright configured with automatic dev server startup
- Test coverage reporting enabled
- Separate unit and E2E test directories

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 06:48:02 +01:00
Thomas Richter
dc28c9f6eb Add deployment script for automated sync and container rebuild
Creates deploy.sh script that:
- Syncs files to node03 server using rsync
- Rebuilds and restarts the Docker container
- Shows deployment status and container health

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 05:32:44 +01:00
Thomas Richter
89a47cd721 Fix scaling and controls for desktop displays
- Remove max scale bounds to support unlimited display sizes (4K, 8K, ultrawide)
- Remove duplicate width/height from scale config to fix desktop scaling
- Fix HuntingScene to default to mouse mode on all platforms (was incorrectly starting in keyboard mode on desktop)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 05:28:58 +01:00
Thomas Richter
8de3f594f9 Add comprehensive mobile support with touch controls and responsive design
Implemented full mobile optimization to make the game mobile-ready:

**Core Mobile Support:**
- Updated viewport meta tag to prevent zoom and improve touch responsiveness
- Added mobile web app meta tags for iOS/Android standalone mode
- Enhanced CSS to prevent text selection, pull-to-refresh, and tap highlights
- Configured Phaser scale system with min/max bounds (320x240 to 1920x1080)
- Added fullscreen support for game container

**Touch Controls:**
- Added mobile device detection to HuntingScene
- Automatically default to touch controls on mobile devices
- Hide keyboard control instructions on mobile
- Only hide cursor on desktop (preserve default cursor on mobile)
- Adjusted messaging for mobile ("Tap to shoot" vs "Click to shoot")

**Touch Feedback:**
- Added pointerdown/pointerup handlers to all interactive buttons
- Buttons now scale down when pressed (0.95x) for tactile feedback
- Maintained hover effects for desktop compatibility
- Updated IntroScene "SET SAIL" button with touch feedback
- Updated MapScene location markers and close button
- Updated TransitionScene continue button
- All touch feedback works on both touch and mouse inputs

**Backward Compatibility:**
- All desktop functionality preserved
- Hover effects still work on desktop
- Keyboard controls available on desktop
- Touch and mouse inputs work seamlessly together

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 05:17:09 +01:00
Thomas Richter
1e5f4f35cd Fix health check to use IPv4 address
- Change health check from localhost to 127.0.0.1
- Fixes connection refused error (localhost resolves to IPv6)
- Both Dockerfile and docker-compose.yml updated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 04:58:13 +01:00
Thomas Richter
e8f8a6a4ef Remove debug lines from ship's wheel
- Clean up green crosshair and bounding box
- Wheel now displays without debug visualization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-17 05:28:59 +01:00
Thomas Richter
67e3e924de Fix wheel spoke drawing using graphics.lineBetween
- Replace add.line() with graphics.lineBetween()
- Remove debug lines (no longer needed)
- Spokes now draw correctly with proper positioning

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-17 05:28:17 +01:00
33 changed files with 4534 additions and 80 deletions

View File

@@ -0,0 +1,134 @@
# Architecture
**Analysis Date:** 2026-02-04
## Pattern Overview
**Overall:** Scene-based state machine with linear progression through game states.
**Key Characteristics:**
- Phaser 3 game framework with arcade physics
- Scene-based architecture where each major game location is a self-contained scene
- Inventory as shared game state passed between scenes via scene data
- Input-driven gameplay with dual control schemes (mouse/keyboard and touch)
- Responsive scaling for mobile and desktop viewports
## Layers
**Presentation Layer (Scenes):**
- Purpose: Render game visuals and handle user interaction
- Location: `src/scenes/`
- Contains: Five scene classes extending Phaser.Scene
- Depends on: Phaser framework, Phaser graphics/text/physics utilities
- Used by: Main application entry point in `src/main.js`
**Game State Layer (Inventory):**
- Purpose: Track player progress across scenes (fuel, whale oil, penguins)
- Location: Passed as data object between scenes via `scene.start(nextScene, { inventory: ... })`
- Contains: Simple object with three numeric properties: `whaleOil`, `fuel`, `penguins`
- Depends on: Nothing
- Used by: All scenes read/modify inventory during gameplay
**Framework Configuration Layer:**
- Purpose: Initialize Phaser game with physics, scaling, scene list
- Location: `src/main.js`
- Contains: Phaser.Game configuration and setup
- Depends on: All scene classes
- Used by: Entry point for application startup
## Data Flow
**Game Startup:**
1. Browser loads `index.html` which imports `src/main.js` as module
2. `src/main.js` creates Phaser.Game instance with config
3. IntroScene is the first scene in the scene list, auto-starts
4. Player interaction triggers scene transitions
**Between-Scene Transitions:**
1. Current scene calls `this.scene.start(nextSceneKey, { inventory: this.inventory })`
2. Phaser stops current scene, starts next scene
3. Next scene receives inventory data in `init(data)` lifecycle method
4. Scene reads `data.inventory` and stores it locally
5. Scene's `create()` method renders UI with current inventory state
**Inventory State:**
- IntroScene initializes: `{ whaleOil: 0, fuel: 100, penguins: 0 }`
- ShipDeckScene receives and passes forward
- MapScene receives, displays, and passes forward
- TransitionScene modifies (deducts fuel if applicable) and passes forward
- HuntingScene receives, displays, modifies (fuel cost, oil gain, penguin discovery) and returns to MapScene
- Penguin discovery (penguins > 0) unlocks penguin cage UI in ShipDeckScene
## Key Abstractions
**Scene Class Pattern:**
- Purpose: Encapsulate game logic and rendering for a specific game state
- Examples: `src/scenes/IntroScene.js`, `src/scenes/ShipDeckScene.js`, `src/scenes/MapScene.js`, `src/scenes/TransitionScene.js`, `src/scenes/HuntingScene.js`
- Pattern: Each scene extends `Phaser.Scene`, implements `create()` for setup and `update()` for per-frame logic
**Interactive UI Elements:**
- Purpose: Handle pointer (mouse/touch) input for clickable objects
- Pattern: Rectangle or circle objects with `.setInteractive()` and `.on('pointerdown')` or `.on('pointerup')` listeners
- Examples in ShipDeckScene: wheel click to open map, barrel zones, penguin cage
- Examples in MapScene: location markers with hover effects and click handlers
- Examples in HuntingScene: crosshair positioning and firing
**Inventory Display Pattern:**
- Purpose: Show player resources in consistent UI
- Pattern: Text panel with semi-transparent background showing formatted resource strings
- Used in: ShipDeckScene, MapScene, HuntingScene
- Format: Multi-line text with labels and current/max values
**Message System Pattern:**
- Purpose: Communicate game state changes and feedback to player
- Pattern: Semi-transparent rectangle with text object, updated via `showMessage(text)` method
- Used in: ShipDeckScene, MapScene, TransitionScene, HuntingScene
## Entry Points
**Application Entry:**
- Location: `index.html`
- Triggers: Page load
- Responsibilities: Load HTML structure, import main.js as module, render into game-container div
**Game Initialization:**
- Location: `src/main.js`
- Triggers: Module import by index.html
- Responsibilities: Configure Phaser game (physics, scaling, resolution), instantiate Phaser.Game, list all scenes
**First Scene:**
- Location: `src/scenes/IntroScene.js`
- Triggers: Phaser auto-starts first scene in config array
- Responsibilities: Display title, draw decorative elements, show SET SAIL button, initialize inventory with defaults
## Error Handling
**Strategy:** Silent failures with user messaging; no error boundaries or try-catch blocks in current codebase.
**Patterns:**
- Fuel depletion check before whale processing (HuntingScene.killWhale): if fuel < 2, whale is discarded without processing
- Whale only spawns if no alive whale exists: `if (this.currentWhale && this.currentWhale.getData('alive')) return;`
- Inventory bounds clamped by Math.min/Math.ceil to prevent negative values
- Crosshair position clamped to screen bounds: `Phaser.Math.Clamp(value, 0, max)`
## Cross-Cutting Concerns
**Logging:** None implemented. Debug would require browser console.
**Validation:** Minimal; mostly relies on initial state setup and boundary clamping.
- Fuel: clamped to [0, 100] via Math.min operations
- Whale oil: clamped to [0, 50] via Math.min operations
- Penguins: clamped to [0, 20] via Math.min operations
- Crosshair X: `Phaser.Math.Clamp(x, 0, 800)`
- Crosshair Y: `Phaser.Math.Clamp(y, 0, 600)`
**Authentication:** Not applicable; single-player game.
**Device Detection:** Mobile detection via user agent regex in HuntingScene.create():
```javascript
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
```
---
*Architecture analysis: 2026-02-04*

View File

@@ -0,0 +1,205 @@
# Codebase Concerns
**Analysis Date:** 2026-02-04
## Architecture & State Management
**Fragile Inventory Passing System:**
- Issue: Inventory object is manually passed between scenes via `data` parameter in every scene transition. No centralized state management.
- Files: `src/scenes/IntroScene.js`, `src/scenes/ShipDeckScene.js`, `src/scenes/MapScene.js`, `src/scenes/HuntingScene.js`, `src/scenes/TransitionScene.js`
- Impact: Each scene maintains its own copy of inventory. A player could lose progress if a scene fails to properly receive or pass inventory data. Adding new inventory items requires changes across all 5 scenes. Risk of inventory corruption during rapid scene transitions.
- Fix approach: Create a centralized GameState or InventoryManager singleton that all scenes access. Example structure:
```javascript
class GameState {
static instance = null;
static getInstance() {
if (!this.instance) this.instance = new GameState();
return this.instance;
}
constructor() {
this.inventory = { whaleOil: 0, fuel: 100, penguins: 0 };
}
}
```
Then all scenes reference `GameState.getInstance().inventory` instead of managing copies.
## Testing Coverage Gaps
**Unit Tests Don't Test Actual Scene Logic:**
- What's not tested: Game logic tests in `tests/unit/game-logic.test.js` only test pure functions and simple calculations. They don't test the actual scene behavior, event handlers, or UI state changes.
- Files: `tests/unit/game-logic.test.js`, `src/scenes/*.js`
- Specific gaps:
- No tests for barrel creation/destruction logic in ShipDeckScene
- No tests for whale spawning, movement, health tracking
- No tests for harpoon firing and collision detection
- No tests for scene transition data passing
- No tests for inventory display updates
- Risk: Bugs in core game mechanics (whale health, fuel consumption, barrel positioning) can't be caught automatically. Changes to scene code could break functionality without detection.
- Priority: High - Core game loop (hunting scene) is untested
**E2E Tests Use Hard-coded Click Positions:**
- What's tested: `tests/e2e/game-flow.spec.js` relies on exact pixel coordinates for button clicks. Example: `canvas.click({ position: { x: 400, y: 400 } })`
- Problem: Any change to UI layout, button positioning, or screen resolution will break these tests without actually breaking the game
- Files: `tests/e2e/game-flow.spec.js` (lines 24, 40, 58, 66, 73, 95, 113-117, 125, 136)
- Risk: Tests become maintenance burden; false positives when layout changes
- Fix approach: Use Phaser's scene testing utilities or find elements by ID/class instead of pixel positions
**No Mobile Touch Testing:**
- What's tested: E2E includes mobile viewport test but only checks canvas visibility and scaling, not touch interaction functionality
- Files: `tests/e2e/game-flow.spec.js` (lines 144-173)
- Missing: Tests for touch event handlers, multi-touch scenarios, long-press interactions
- Risk: Touch controls may break without detection. Buttons may be non-responsive on actual devices.
## Performance & Complexity Issues
**HuntingScene is Large and Complex:**
- Issue: Single 637-line file handles whale AI, harpoon physics, collision detection, camera effects, input management, and rendering
- Files: `src/scenes/HuntingScene.js`
- Specific problem areas:
- Lines 316-398: Whale update logic duplicates movement calculation and bounds checking
- Lines 434-450: Harpoon update is simple loop-over-array that could become O(n²) with many harpoons
- Lines 453-489: Collision detection recalculates screen position every frame for every harpoon
- Lines 607-630: Camera sway uses a `time.addEvent()` with permanent loop instead of native `update()` method
- Impact: Difficult to debug, test, or modify. Camera sway timer creates memory leak potential (never cancelled). Whale movement becomes laggy with multiple whales.
- Fix approach: Extract whale logic to separate `Whale` class, harpoons to `Harpoon` class, collision logic to separate method, use `update()` for camera instead of addEvent()
**Barrel Recreation is Inefficient:**
- Issue: In `ShipDeckScene.js`, `updateInventoryDisplay()` calls `clearBarrels()` then recreates all barrels every time inventory changes (lines 294-297)
- Files: `src/scenes/ShipDeckScene.js` (lines 225-297)
- Impact: Visual flicker; hundreds of objects created/destroyed repeatedly. On low-end devices, could cause jank.
- Fix approach: Reuse barrel objects or update only visual properties (fill color, scale) instead of destroying/recreating
**Graphics Objects Never Destroyed:**
- Issue: Graphics objects created in multiple scenes are never destroyed - only drawn once
- Files:
- `src/scenes/IntroScene.js` (lines 82-98, 101-116): waves and ship drawn once
- `src/scenes/HuntingScene.js` (lines 144-170): crosshair graphics.generateTexture() leaves original graphics object
- `src/scenes/TransitionScene.js` (lines 286-301): ocean graphics never destroyed
- Impact: Memory leak. Small issue now but accumulates with multiple playthroughs. Old graphics objects persist in memory even after scene changes.
- Fix approach: Call `.destroy()` on graphics objects after `.generateTexture()` or scene cleanup
## Known Behavioral Issues
**Incomplete Features Referenced in Code:**
- Issue: Code contains references to unimplemented scenes
- Files: `src/scenes/MapScene.js` (lines 197, 207)
- Problem: Pressing buttons to go to Antarctic or Port just returns to MapScene. Code comments say "Will change to 'AntarcticScene' when implemented" and "PortScene when implemented"
- Impact: Two major gameplay areas not implemented. Players can't sell whale oil or gather penguins as intended
- No fix needed for this sprint; just document that these scenes don't exist
**Penguin Cage Visibility Logic is Opaque:**
- Issue: Penguin cage shown/hidden based on `inventory.penguins > 0` but there's no way in current code to acquire penguins
- Files: `src/scenes/ShipDeckScene.js` (lines 83-94, 281-291)
- Impact: Code path never executes. Feature is stub/placeholder.
- Severity: Low - intentional incomplete feature, but makes code harder to understand
**Whale Diving Mechanic is Incomplete:**
- Issue: Whales go transparent when diving but no actual "invulnerability" is enforced - collision detection still runs (line 461 checks `diving` but collision only prevented if whale is visible or alpha check)
- Files: `src/scenes/HuntingScene.js` (lines 304-306, 333-342, 394-410, 460-463)
- Risk: Harpoons might hit whale during dive in edge cases
- Fix approach: Ensure collision detection returns early if `whale.getData('diving')`
## Security & Validation Issues
**No Inventory Bounds Checking:**
- Issue: Inventory values can go negative or exceed max without validation
- Files: All scene files, particularly `src/scenes/HuntingScene.js` (lines 586, 589)
- Example problem: If fuel goes below 0, the display shows negative fuel and barrel count calculation breaks
- Current safeguard: Line 547 checks `fuel < 2` before consuming, but nothing prevents other code paths from setting fuel to -5
- Fix approach: Create Inventory class with setters that enforce bounds:
```javascript
class Inventory {
setFuel(value) { this.fuel = Math.max(0, Math.min(100, value)); }
}
```
**Hard-coded Game Constants Scattered Throughout:**
- Issue: Magic numbers for inventory limits, costs, and gameplay values are hard-coded in multiple files
- Files:
- Fuel max: `src/scenes/ShipDeckScene.js:276`, `src/scenes/MapScene.js:149`, `src/scenes/HuntingScene.js:234`
- Oil max: `src/scenes/ShipDeckScene.js:277`, `src/scenes/MapScene.js:150`, `src/scenes/HuntingScene.js:235`
- Whale health: `src/scenes/HuntingScene.js:292` (3 hits)
- Fuel cost to process: `src/scenes/HuntingScene.js:547, 586` (2 fuel)
- Barrel size calculations: `src/scenes/ShipDeckScene.js:105-165` (hardcoded ratios)
- Impact: Changing game balance requires finding and updating 10+ locations. High risk of inconsistency.
- Fix approach: Create `GameConstants.js` file:
```javascript
export const GAME_CONSTANTS = {
INVENTORY: { MAX_FUEL: 100, MAX_OIL: 50, MAX_PENGUINS: 20 },
WHALE: { HEALTH: 3, PROCESS_FUEL_COST: 2 },
BARRELS: { FUEL_UNITS_PER_BARREL: 10, OIL_UNITS_PER_BARREL: 10 }
};
```
## Code Quality Issues
**Duplicate Inventory Initialization:**
- Issue: Default inventory object defined in 5 different places
- Files: `src/scenes/IntroScene.js:120-124`, `src/scenes/ShipDeckScene.js:6-10`, `src/scenes/HuntingScene.js:9`, `src/scenes/MapScene.js:10`, `src/scenes/TransitionScene.js:10`
- Risk: If inventory structure changes, must update in all 5 places
- Fix: One source of truth (GameState or constants)
**Inconsistent Data Passing Pattern:**
- Issue: Scene init() method doesn't always match how data is received
- Files: `src/scenes/TransitionScene.js` (line 10) uses OR operator for defaults but other scenes (ShipDeckScene line 15) use if check
- Impact: Minor style inconsistency, slightly reduces readability
**No Error Handling for Scene Transitions:**
- Issue: All `this.scene.start()` calls don't handle failures
- Files: All scene files
- Risk: If scene key is misspelled or doesn't exist, silently fails
- Fix: Add error handling or validation
## Missing Features Blocking Full Gameplay
**No Penguin Collection Mechanic:**
- What's missing: Antarctic Island scene doesn't exist, so penguins can't be collected
- Blocks: Complete game loop where fuel can be managed through penguin burning
- Files: Referenced in `src/scenes/MapScene.js:197` but scene doesn't exist
**No Port/Trading System:**
- What's missing: Port scene doesn't exist, can't sell whale oil
- Blocks: Economic gameplay loop and victory condition
- Files: Referenced in `src/scenes/MapScene.js:207` but scene doesn't exist
**No Game Over or Win Condition:**
- What's missing: No way to fail (fuel runs out) or win (enough whale oil collected)
- Impact: Game has no clear objective or end state
- Files: N/A - logic doesn't exist anywhere
**No Persistence:**
- What's missing: Game state lost on page reload
- Impact: Can't save/load progress
- Not critical for prototype but important for release
## Scaling & Device Issues
**Hardcoded Screen Dimensions:**
- Issue: Game assumes 800x600 canvas with many hardcoded position values
- Files: `src/main.js:10-11`, all scene files
- Example: `src/scenes/IntroScene.js:21` (text at x:400, y:150), `src/scenes/HuntingScene.js:139-140` (crosshair clamped to 0-800, 0-600)
- Risk: Resizing game window breaks positioning. Mobile scaling works via Phaser but coordinates are still hardcoded
- Fix: Use relative positioning or percentage-based coordinates
**Mobile Detection Uses navigator.userAgent:**
- Issue: `src/scenes/HuntingScene.js:23` uses userAgent string matching
- Risk: Can be spoofed, unreliable on some devices
- Better approach: Use Phaser's `this.sys.game.device` API or check touch availability
## Testing Infrastructure Concerns
**Vitest Config Exposes Dev Server Publicly:**
- Issue: `vitest.config.js:21-22` sets `host: '0.0.0.0'` allowing external connections
- Files: `vitest.config.js`
- Risk: In production, test server would be exposed to internet
- Fix: Only use in development (check NODE_ENV)
**E2E Tests Have Long Arbitrary Waits:**
- Issue: Tests use `waitForTimeout(1000)` and `waitForTimeout(2000)` instead of waiting for elements/state
- Files: `tests/e2e/game-flow.spec.js` (almost every test)
- Risk: Tests are slow and flaky; may timeout on slow devices but pass on fast ones
- Fix: Use `waitForSelector()`, `waitForFunction()`, or Phaser's scene ready events
---
*Concerns audit: 2026-02-04*

View File

@@ -0,0 +1,139 @@
# Coding Conventions
**Analysis Date:** 2026-02-04
## Naming Patterns
**Files:**
- PascalCase for scene classes: `IntroScene.js`, `MapScene.js`, `HuntingScene.js`, `ShipDeckScene.js`, `TransitionScene.js`
- kebab-case for test files: `game-logic.test.js`, `game-flow.spec.js`
- camelCase for configuration files: `vitest.config.js`, `playwright.config.js`
**Functions:**
- camelCase for all methods: `drawWaves()`, `createLocation()`, `updateInventoryDisplay()`, `spawnWhale()`, `hitWhale()`
- Method names are descriptive and indicate action: `create*` for initialization, `update*` for state changes, `draw*` for graphics
- Private-like pattern: methods starting with event handlers (`on*` callbacks) or utility methods
**Variables:**
- camelCase for local variables and object properties: `whaleOil`, `currentWhale`, `inventory`, `crosshairX`, `swimSpeedX`
- PascalCase for class names: `IntroScene`, `MapScene`, `HuntingScene`
- SCREAMING_SNAKE_CASE for constants is not used; magic numbers are inline or stored as camelCase object properties
- Abbreviated names in loops: `i`, `x`, `y` for coordinates
**Types:**
- No TypeScript; vanilla JavaScript with JSDoc comments for complex logic
- Object literals for data structures (e.g., inventory: `{ whaleOil: 0, fuel: 100, penguins: 0 }`)
- No explicit type annotations; types inferred from usage context
## Code Style
**Formatting:**
- No explicit formatter configured (no .prettierrc or similar found)
- 4 spaces for indentation (observed consistently across all files)
- Single statements per line; no cramping of expressions
- Line length varies; some lines exceed 100 characters
**Linting:**
- No ESLint or similar linting tool configured
- No explicit style guide enforced; conventions followed naturally
## Import Organization
**Order:**
1. Phaser framework imports: `import Phaser from 'phaser'`
2. Scene exports: `export default class SceneName extends Phaser.Scene`
3. Test framework imports: `import { describe, it, expect } from 'vitest'` and `import { test, expect } from '@playwright/test'`
**Path Aliases:**
- No path aliases configured
- Relative imports with `.js` extension explicit: `import IntroScene from './scenes/IntroScene.js'`
## Error Handling
**Patterns:**
- Defensive checks before operations: `if (this.currentWhale && this.currentWhale.getData('alive'))`
- Boundary checking for game state: `if (this.inventory.fuel < 2)` before processing whale
- No explicit error throwing; invalid states default to safe fallbacks
- Scene transitions use `this.scene.start()` with null coalescing: `this.inventory = data.inventory || { whaleOil: 0, fuel: 100, penguins: 0 }`
- Array bounds checking: loop guards with `.length` checks, array splice after removal
## Logging
**Framework:** console (implicit; not found in code)
**Patterns:**
- No logging statements observed in source code
- Game messaging via `showMessage()` method: text display in game UI
- Debug output would likely use console.log if needed (not observed)
## Comments
**When to Comment:**
- Method-level comments for non-obvious logic: `// Ocean blue background`, `// Draw decorative waves`
- Inline comments for complex calculations: `// Can't hit whale while diving`, `// Keep within bounds`
- Comment frequency is moderate; most code is self-documenting through naming
**JSDoc/TSDoc:**
- No JSDoc comments found
- Vanilla JavaScript with method names that describe intent
## Function Design
**Size:**
- Methods range from 10-70 lines
- Larger methods like `create()` (30+ lines) perform initialization with calls to smaller helper methods
- Complex game logic broken into focused methods: `updateWhale()`, `updateHarpoons()`, `checkCollisions()`
**Parameters:**
- Methods accept 0-3 parameters typically
- Event handlers use single parameter (e.g., `pointerdown` callbacks)
- Data passed through scene initialization: `this.scene.start('NextScene', { inventory: data })`
**Return Values:**
- Mostly void/no return (Phaser scene lifecycle methods)
- Some utility functions return boolean: `hasOwnProperty()` checks
- Game state propagated through `this` context, not function returns
## Module Design
**Exports:**
- Each scene file exports default class: `export default class SceneName extends Phaser.Scene`
- All scenes imported into `src/main.js` as class references
- Scene registration in Phaser config array: `scene: [IntroScene, ShipDeckScene, MapScene, TransitionScene, HuntingScene]`
**Barrel Files:**
- No index.js or barrel files found
- Each scene is independent; imports are explicit by filename
## Data Structures
**Object Literals:**
- Inventory object: `{ whaleOil: 0, fuel: 100, penguins: 0 }`
- Configuration objects: Phaser game config in `src/main.js`
- Destination mapping in `TransitionScene.js`: `destinations` object with title, description, backgroundColor, visualType
**Phaser-Specific Patterns:**
- `setData(key, value)` / `getData(key)` for storing state on game objects
- Container objects with `add([children])` for grouping (e.g., whale body with parts)
- Scene transitions with inventory passed as data parameter
## Common Patterns Observed
**Scene Lifecycle:**
1. `constructor()`: Set scene key
2. `init(data)`: Receive data from previous scene
3. `create()`: Initialize scene graphics and setup
4. `update()`: Game loop updates (only used in HuntingScene)
**UI Element Creation:**
- Rectangle/circle/text created with `.add.rectangle()`, `.add.circle()`, `.add.text()`
- Interactive zones created with `setInteractive()` and `.on('pointerX', callback)`
- Hover/click feedback with `setScale()`, `setFillStyle()` updates
**Animation:**
- Phaser tweens for smooth transitions: `this.tweens.add({ targets: obj, property: value, duration: ms })`
- Sine/cosine wave patterns for natural movement: `Math.sin()`, `Math.cos()` for bobbing/sway
---
*Convention analysis: 2026-02-04*

View File

@@ -0,0 +1,161 @@
# External Integrations
**Analysis Date:** 2026-02-04
## APIs & External Services
**Not applicable** - No external APIs integrated. Game is fully self-contained.
## Data Storage
**Databases:**
- Not applicable - No database required or used
- Game state is managed in-memory during gameplay
- State location: Passed via scene parameters in `src/main.js`
**File Storage:**
- Local filesystem only - Assets would be bundled with application
- No remote file storage configured
- Asset directories (planned): `assets/sprites/`, `assets/backgrounds/`
**Caching:**
- Browser caching only - Static assets cached by nginx (1 year TTL)
- No server-side caching layer
- Vite dev server handles hot module replacement during development
**State Management:**
- Phaser Scene-based state management
- Game state example from `src/scenes/IntroScene.js`:
```javascript
const inventory = {
whaleOil: 0,
fuel: 100,
penguins: 0
};
this.scene.start('ShipDeckScene', { inventory: inventory });
```
- State passed between scenes as parameters
- No persistence layer (state resets on page refresh)
## Authentication & Identity
**Not applicable** - No user authentication
- Game is anonymous and stateless
- No user accounts or login required
- All players access the same game instance
## Monitoring & Observability
**Error Tracking:**
- Not integrated
- Console errors only (browser developer tools)
- No remote error reporting configured
**Logs:**
- Browser console logging only
- Nginx access logs available in container at `/var/log/nginx/`
- Docker container logs accessible via: `docker-compose logs -f`
- Vite dev server logs to terminal
**Health Checks:**
- Docker health check: `wget --quiet --tries=1 --spider http://127.0.0.1/`
- Interval: 30 seconds
- Timeout: 3 seconds
- Retries: 3 before marking unhealthy
- Start period: 5 seconds
## CI/CD & Deployment
**Hosting:**
- Docker container on `node03.tricnet.de`
- Served via Nginx (Alpine Linux)
- Manual deployment via `deploy.sh`
**CI Pipeline:**
- Not detected - No automated CI/CD system configured
- Deployment process: Manual `./deploy.sh`
- Uses rsync to sync files to node03
- SSH to rebuild Docker container
- Rebuilds docker-compose services
**Deployment Method:**
- Deployment file: `deploy.sh`
- Sync target: `tho@node03.tricnet.de:/home/tho/whalehunting/`
- Excludes: `node_modules/`, `dist/`, `.git/`, `.DS_Store`
- Build command: `docker-compose down && docker-compose up -d --build`
## Environment Configuration
**Required env vars:**
- None - Game requires no environment variables at runtime
- Docker container runs with default environment
**Secrets location:**
- Not applicable - No secrets, API keys, or credentials required
- SSH key required for deployment (stored on developer machine)
**Development Environment:**
- Game automatically runs on `http://localhost:5173` (Vite default)
- Playwright configured to use `http://localhost:5173` as baseURL
- No .env file needed for development
## Webhooks & Callbacks
**Incoming:**
- Not applicable - No webhooks received
**Outgoing:**
- Not applicable - No webhooks sent
- Game makes no HTTP requests
## Network Requirements
**Development:**
- Must be able to access `localhost:5173` (Vite dev server)
- Must be able to access `localhost:5173` for Playwright E2E tests
- Vitest UI server binds to `0.0.0.0:51204` (all interfaces)
**Production:**
- Must be accessible on port 8880 (external Docker port)
- Health checks require outbound HTTP to `http://127.0.0.1/` (internal to container)
- No outbound internet access required from game runtime
## Data Flow
**Client-Side Only:**
- All game logic runs in browser
- No data sent to any backend or external service
- Game state never persists (no save/load feature implemented)
- Player actions: Click/tap interactions → Phaser input handlers → Scene state changes
**Example State Flow** (from `src/scenes/IntroScene.js`):
1. User clicks "SET SAIL" button
2. Button click handler creates inventory object
3. Scene transitions: `this.scene.start('ShipDeckScene', { inventory })`
4. ShipDeckScene receives inventory via init parameter
5. Game continues with same inventory state
## Testing Infrastructure
**Unit Tests:**
- Framework: Vitest 4.0.16
- Environment: jsdom
- Test directory: `tests/unit/`
- Run: `npm run test` or `npm run test:ui` (with visual UI)
- Coverage: `npm run test:coverage`
**E2E Tests:**
- Framework: Playwright 1.57.0
- Test directory: `tests/e2e/`
- Browsers tested: Chromium, iPhone 12 (mobile)
- Run: `npm run test:e2e` or `npm run test:e2e:ui`
- Screenshots: Captured on failure only
- Traces: Captured on first retry
**Test Configuration Files:**
- `vitest.config.js` - Unit test setup
- `playwright.config.js` - E2E test setup
---
*Integration audit: 2026-02-04*

135
.planning/codebase/STACK.md Normal file
View File

@@ -0,0 +1,135 @@
# Technology Stack
**Analysis Date:** 2026-02-04
## Languages
**Primary:**
- JavaScript (ES6 modules) - All source code and configuration
## Runtime
**Environment:**
- Node.js 20 (Alpine) - Development and build time
- Browser (Chromium, Safari) - Runtime environment for game
**Package Manager:**
- npm - Dependency management
- Lockfile: `package-lock.json` (present)
## Frameworks
**Core:**
- Phaser 3.80.1 - 2D game framework and physics engine
- Used in: `src/main.js` and all scene files
- Provides game loop, rendering, input handling, physics (arcade)
**Build/Dev:**
- Vite 5.0.0 - Build tool and development server
- Config: Not detected (uses Vite defaults)
- Dev server: Port 5173 (standard)
- Output directory: `dist/`
**Testing:**
- Vitest 4.0.16 - Unit test framework
- Config: `vitest.config.js`
- Environment: jsdom (browser-like environment for testing)
- UI: @vitest/ui 4.0.16 for visual test runner
- Playwright 1.57.0 - E2E testing framework
- Config: `playwright.config.js`
- Test directory: `tests/e2e/`
- Browsers: Chromium, iPhone 12 (mobile simulation)
- Screenshots: Captured on test failure
## Key Dependencies
**Critical:**
- phaser (3.80.1) - Complete game development framework
- Why it matters: Core engine for all game logic, rendering, and physics
**Development Only:**
- @playwright/test (1.57.0) - E2E test runner with browser automation
- @vitest/ui (4.0.16) - Visual UI for viewing test results
- jsdom (27.3.0) - DOM implementation for testing in Node.js environment
- vite (5.0.0) - Lightning-fast build tool and dev server
- vitest (4.0.16) - Unit test framework built on Vite
## Configuration
**Environment:**
- No environment variables required for runtime
- Game runs entirely in browser with no backend dependencies
**Build:**
- `vite.config.js` - Not present (uses defaults)
- `playwright.config.js` - E2E test configuration
- `vitest.config.js` - Unit test configuration with jsdom environment
- Vite configured for module type: module (ES6)
## Platform Requirements
**Development:**
- Node.js 20+
- npm 8+ (included with Node 20)
- Git (for version control)
- Linux/macOS/Windows (any platform with Node.js)
**Production:**
- Nginx web server (Alpine Linux image)
- Docker (for containerized deployment)
- Static file hosting (game is fully client-side)
- No database required
- No backend API required
## Build & Deployment
**Development:**
```bash
npm install # Install dependencies
npm run dev # Start dev server on port 5173
npm run test # Run unit tests
npm run test:ui # Run tests with visual UI
npm run test:e2e # Run Playwright E2E tests
```
**Production:**
```bash
npm run build # Build to dist/ directory
docker build -t whalehunting-game . # Build Docker image
docker-compose up -d # Deploy with Docker Compose
```
**Docker Stack:**
- Base image: `nginx:alpine` (production serving)
- Build stage: `node:20-alpine` (build application)
- Port: 8880 (external), 80 (container)
- Health checks: wget ping every 30s
- Network: Docker bridge network `whalehunting-network`
**Deployment Target:**
- Docker container on node03.tricnet.de
- Deployed via rsync + SSH (see `deploy.sh`)
- Game served at: `http://node03.tricnet.de:8880`
## Performance Optimizations
**Build:**
- Vite provides fast module bundling
- Production build is minified and optimized
**Runtime:**
- Phaser uses WebGL/Canvas rendering (automatic selection via Phaser.AUTO)
- Game configured with fixed dimensions (800x600) with responsive scaling
- Responsive scaling mode: FIT with auto-centering
- Min viewport: 320x240 (mobile support)
- Arcade physics engine configured with no gravity (2D top-down view)
**Serving:**
- Nginx configured with gzip compression
- Static asset caching: 1 year (as noted in README)
- Health checks ensure container availability
---
*Stack analysis: 2026-02-04*

View File

@@ -0,0 +1,177 @@
# Codebase Structure
**Analysis Date:** 2026-02-04
## Directory Layout
```
whalehunting/
├── src/ # Game source code
│ ├── main.js # Phaser game initialization and configuration
│ └── scenes/ # Game scene implementations
│ ├── IntroScene.js
│ ├── ShipDeckScene.js
│ ├── MapScene.js
│ ├── TransitionScene.js
│ └── HuntingScene.js
├── tests/ # Test suites
│ ├── unit/ # Unit tests (Vitest)
│ │ └── game-logic.test.js
│ └── e2e/ # End-to-end tests (Playwright)
│ └── game-flow.spec.js
├── .planning/ # GSD planning documents
│ └── codebase/ # Architecture analysis
├── index.html # HTML entry point
├── package.json # Dependencies and scripts
├── vitest.config.js # Unit test configuration
├── playwright.config.js # E2E test configuration
├── nginx.conf # Web server config for deployment
├── Dockerfile # Container image definition
├── docker-compose.yml # Multi-container orchestration
└── deploy.sh # Automated deployment script
```
## Directory Purposes
**src/:**
- Purpose: All game source code
- Contains: Main game initialization and scene implementations
- Key files: `main.js` is the entry point that configures Phaser
**src/scenes/:**
- Purpose: Game scene implementations
- Contains: Five scene classes, one per major game location/mode
- Key files: IntroScene starts the game, HuntingScene has most complex logic
**tests/:**
- Purpose: All test code separated from source
- Contains: Unit tests and E2E tests in separate subdirectories
- Key files: `game-logic.test.js` for game mechanics, `game-flow.spec.js` for user flows
**tests/unit/:**
- Purpose: Logic testing without Phaser
- Contains: Vitest tests for inventory calculations, fuel costs, barrel logic
- Key files: `game-logic.test.js`
**tests/e2e/:**
- Purpose: Full application flow testing
- Contains: Playwright tests simulating user interactions on actual game canvas
- Key files: `game-flow.spec.js`
**.planning/codebase/:**
- Purpose: Analysis documents for code navigation
- Contains: ARCHITECTURE.md, STRUCTURE.md, CONVENTIONS.md, TESTING.md, CONCERNS.md
- Generated by: GSD mapping processes
## Key File Locations
**Entry Points:**
- `index.html`: Browser loads this, contains game-container div and script import
- `src/main.js`: Phaser game configuration and scene list initialization
**Configuration:**
- `vitest.config.js`: Unit test runner with jsdom environment, port 51204 for UI server
- `playwright.config.js`: E2E test runner with Chromium and iPhone 12 profiles
- `package.json`: Dependencies (Phaser 3.80.1, Vite, Vitest, Playwright)
- `nginx.conf`: Web server routing (not actively used in dev)
- `docker-compose.yml`: Local dev: port 5173 for Vite server
- `Dockerfile`: Production build with multi-stage for size optimization
**Core Logic:**
- `src/scenes/IntroScene.js`: Game entry, starts with fuel=100, oil=0, penguins=0
- `src/scenes/ShipDeckScene.js`: Inventory display, barrel visualization, penguin cage reveal
- `src/scenes/MapScene.js`: Location selection (Hunting Grounds, Antarctic, Port)
- `src/scenes/TransitionScene.js`: Atmospheric journey scenes, destination-specific visuals
- `src/scenes/HuntingScene.js`: Core gameplay - whale spawning, harpoon mechanics, fuel consumption
**Testing:**
- `tests/unit/game-logic.test.js`: Barrel calculations, inventory limits, fuel costs, whale health, crosshair clamping
- `tests/e2e/game-flow.spec.js`: Full flows (intro→ship→map→hunting), mobile viewport, desktop scaling
## Naming Conventions
**Files:**
- PascalCase + Scene suffix: `IntroScene.js`, `ShipDeckScene.js`
- camelCase for non-scene modules: Would apply if utilities existed
- Test files: Matched source with `.test.js` or `.spec.js` suffix
- Config files: kebab-case: `playwright.config.js`, `vitest.config.js`
**Directories:**
- lowercase plural: `src/scenes/`, `tests/unit/`, `tests/e2e/`
- camelCase for compound: Not currently used
**Class Names:**
- PascalCase: `IntroScene`, `ShipDeckScene`, `MapScene`, `TransitionScene`, `HuntingScene`
- Extend `Phaser.Scene` directly
**Methods:**
- camelCase: `create()`, `update()`, `init()`, `createDeck()`, `createBarrels()`, `updateInventoryDisplay()`
- Lifecycle methods (create, update) are Phaser conventions
- Helper methods prefix with action: `create*()`, `update*()`, `show*()`, `draw*()`
**Variables:**
- camelCase for instance: `this.inventory`, `this.currentWhale`, `this.harpoons`
- camelCase for local: `barrelCount`, `swimSpeedX`, `healthPercent`
- UPPERCASE for constants: `FUEL_COST = 2`, `MAX_HEALTH = 3`
**Game State Objects:**
- Inventory structure: `{ whaleOil: number, fuel: number, penguins: number }`
- Whale data stored via `setData()`: `health`, `alive`, `diving`, `swimSpeedX`, `swimSpeedY`, `direction`
## Where to Add New Code
**New Scene/Location:**
1. Create `src/scenes/NewScene.js` extending Phaser.Scene
2. Implement `init(data)` to receive inventory
3. Implement `create()` for rendering
4. Implement `update()` if frame-by-frame logic needed
5. Call `this.scene.start('MapScene', { inventory: this.inventory })` to transition
6. Add to scene list in `src/main.js` config array
7. Add location marker in `MapScene.js` that routes to new scene
**New Game Mechanic:**
- Hunting scene has most game logic: HuntingScene.js handles whale spawning, harpoon firing, collision detection, health tracking
- Add mechanics as methods in relevant scene class
- Store persistent state in `this.inventory` object
- Display UI changes via `updateInventoryDisplay()` pattern
**Utilities (if needed):**
- Create `src/utils/` directory for shared helpers
- Example: `src/utils/inventory.js` for max capacity functions
- Example: `src/utils/collision.js` for distance calculations
- Import in scenes: `import { calculateBarrels } from '../utils/inventory.js';`
**New UI Component Pattern:**
- Create methods in scene: `createInventoryDisplay()`, `createMessageBox()`
- Return reference to element for later updates
- Update via methods: `updateInventoryDisplay()`, `showMessage(text)`
- Use semi-transparent rectangles for panels with `.setFillStyle(0x000000, 0.7)`
## Special Directories
**node_modules/:**
- Purpose: Installed dependencies
- Generated: Yes (via npm install)
- Committed: No (.gitignore)
**dist/:**
- Purpose: Vite production build output
- Generated: Yes (via npm run build)
- Committed: No (.gitignore)
- Files: Optimized JS/CSS for deployment
**.git/:**
- Purpose: Git repository metadata
- Generated: Yes (git init)
- Committed: Meta repository
- Notable: Recent commits track feature additions and testing infrastructure
**.planning/:**
- Purpose: GSD orchestration files and analysis documents
- Generated: Yes (via /gsd commands)
- Committed: Yes (tracked in git)
- Structure: `codebase/` contains ARCHITECTURE.md, STRUCTURE.md, etc.
---
*Structure analysis: 2026-02-04*

View File

@@ -0,0 +1,314 @@
# Testing Patterns
**Analysis Date:** 2026-02-04
## Test Framework
**Runner:**
- Vitest 4.0.16 - Unit test runner
- Playwright 1.57.0 - E2E test runner
**Config Files:**
- Unit tests: `vitest.config.js`
- E2E tests: `playwright.config.js`
**Run Commands:**
```bash
npm run test # Run unit tests (Vitest)
npm run test:ui # Run tests with Vitest UI
npm run test:coverage # Run tests with coverage report
npm run test:e2e # Run E2E tests (Playwright)
npm run test:e2e:ui # Run E2E tests with Playwright UI
npm run test:all # Run all tests (unit + E2E)
```
## Test File Organization
**Location:**
- Unit tests: `tests/unit/` directory
- E2E tests: `tests/e2e/` directory
- Separate from source code (not co-located)
**Naming:**
- Unit tests: `.test.js` suffix: `game-logic.test.js`
- E2E tests: `.spec.js` suffix: `game-flow.spec.js`
**Structure:**
```
tests/
├── unit/
│ └── game-logic.test.js
└── e2e/
└── game-flow.spec.js
```
## Unit Test Structure
**Framework:** Vitest with globals enabled
**Pattern from `tests/unit/game-logic.test.js`:**
```javascript
import { describe, it, expect } from 'vitest';
describe('Game Logic - Inventory Management', () => {
describe('Barrel Calculations', () => {
it('should calculate correct fuel barrel count', () => {
const fuel = 100;
const barrelCount = Math.ceil(fuel / 10);
expect(barrelCount).toBe(10);
});
});
});
```
**Test Organization:**
- Top-level `describe()` blocks group by feature/system (e.g., "Game Logic - Inventory Management")
- Nested `describe()` blocks organize related tests (e.g., "Barrel Calculations")
- Individual `it()` blocks test single logical assertions
- Test names are descriptive: "should [expected behavior]"
## E2E Test Structure
**Framework:** Playwright with page object patterns
**Pattern from `tests/e2e/game-flow.spec.js`:**
```javascript
import { test, expect } from '@playwright/test';
test.describe('Whale Hunting Game - Main Flow', () => {
test('should load the intro scene', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
const canvas = page.locator('canvas');
await expect(canvas).toBeVisible();
await expect(page).toHaveTitle(/Whale Hunting/);
});
});
```
**Test Organization:**
- `test.describe()` blocks group related scenarios
- Individual `test()` blocks test user flows
- Setup patterns: `test.beforeEach()` for common navigation
- Viewport configuration: `test.use()` for device-specific testing
- Async/await pattern for all page interactions
## Mocking
**Framework:** None configured or used
**Patterns:**
- No mocks found in unit tests
- Tests verify pure calculation logic (Math.ceil, Math.min)
- E2E tests work with actual running game instance
**What to Mock:**
- Not applicable; unit tests use pure functions without external dependencies
- Phaser framework is not mocked (games tested in browser context)
**What NOT to Mock:**
- Scene transitions (tested via canvas visibility)
- Phaser game instances (tested via E2E)
## Fixtures and Test Data
**Test Data:**
```javascript
// Direct inline values
it('should calculate correct fuel barrel count', () => {
const fuel = 100;
const barrelCount = Math.ceil(fuel / 10);
expect(barrelCount).toBe(10);
});
// Inventory object template
const inventory = { whaleOil: 0, fuel: 100, penguins: 0 };
```
**Location:**
- No shared fixture files; test data created inline within test functions
- Inventory object structure repeated across multiple tests
## Coverage
**Configuration:**
```javascript
// vitest.config.js
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'dist/',
'*.config.js',
'tests/e2e/**'
]
}
```
**View Coverage:**
```bash
npm run test:coverage
# Creates HTML report in coverage/
# View: coverage/index.html
```
**Excluded from Coverage:**
- E2E tests (`tests/e2e/**`)
- Config files (`*.config.js`)
- Dependencies and build output
## Test Types
**Unit Tests:**
- Scope: Pure calculation logic (barrel counts, fuel consumption, bounds checking)
- Approach: Direct function calls with known inputs
- No DOM interaction; test game logic in isolation
- Coverage: `tests/unit/game-logic.test.js`
- Examples:
- Barrel calculation: `Math.ceil(fuel / 10)`
- Inventory limits: `Math.min(currentFuel + 10, maxFuel)`
- Whale health: Health deduction logic
- Mobile detection: User agent regex testing
- Crosshair bounds: Clamp function testing
**Integration Tests:**
- Not explicitly used
- Closest: E2E tests that verify scene transitions
**E2E Tests:**
- Scope: Complete user workflows (game startup to hunting)
- Approach: Browser automation via Playwright
- Tests: Canvas visibility, scene transitions, button clicks, viewport scaling
- Coverage: `tests/e2e/game-flow.spec.js`
- Desktop & mobile scenarios tested with viewport configuration
- Examples:
- "should load the intro scene"
- "should navigate to hunting grounds"
- "should work on mobile viewport"
- "should scale properly on desktop"
## Common Patterns
**Async Testing (E2E):**
```javascript
test('should start game from intro scene', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
const canvas = page.locator('canvas');
await canvas.click({ position: { x: 400, y: 400 } });
await page.waitForTimeout(1000);
await expect(canvas).toBeVisible();
});
```
- `async ({ page })` destructures Playwright fixture
- `await` on page navigation and interactions
- `page.waitForTimeout()` for animation/transition delays
- `page.locator()` for element selection
**Error Testing (Unit):**
```javascript
it('should not process whale without enough fuel', () => {
let fuel = 1;
const requiredFuel = 2;
const canProcess = fuel >= requiredFuel;
expect(canProcess).toBe(false);
});
```
- Boolean logic to test conditions
- No exception throwing; test state validation
**Mobile Testing (E2E):**
```javascript
test.describe('Mobile Compatibility', () => {
test.use({
viewport: { width: 375, height: 667 },
isMobile: true,
});
test('should work on mobile viewport', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
const canvas = page.locator('canvas');
await expect(canvas).toBeVisible();
const canvasBox = await canvas.boundingBox();
expect(canvasBox?.width).toBeLessThanOrEqual(375);
});
});
```
- `test.use()` sets viewport and device context
- Tap interactions: `canvas.tap()`
- BoundingBox assertions: verify canvas scaling
## Test Isolation
**Setup:**
- E2E tests use `test.beforeEach()` for repeated navigation setup
- No shared state between tests
- Each test starts fresh from home page
**Teardown:**
- Playwright automatically closes browser context between tests
- No explicit cleanup required
## Configuration Details
**Vitest (`vitest.config.js`):**
- Environment: jsdom (browser-like DOM)
- Globals enabled: `describe`, `it`, `expect` available without imports
- Include pattern: `tests/unit/**/*.test.js`
- Server runs on port 51204 for Vitest UI
**Playwright (`playwright.config.js`):**
- Projects: Chromium and mobile (iPhone 12)
- Base URL: `http://localhost:5173` (dev server)
- Test directory: `tests/e2e/`
- Retries: 2 in CI, 0 locally
- Screenshots: Only on failure
- Trace: On first retry
- Server: Runs `npm run dev` before tests
## Test Execution Flow
1. **Unit Tests (Vitest):**
- Runs isolated game logic tests
- Uses jsdom environment
- No Phaser instance needed
- Fast execution
2. **E2E Tests (Playwright):**
- Starts dev server via `npm run dev`
- Launches Chromium and mobile browsers
- Navigates to `http://localhost:5173`
- Performs user interactions
- Verifies game state via canvas visibility
- Slower execution but tests full stack
## Coverage Gaps
**Not Tested (Unit):**
- Scene rendering and graphics creation
- Phaser animation timing and tweens
- Player input handling (mouse/touch)
- Collision detection logic
**Not Tested (E2E):**
- Detailed game mechanics (whale health, harpoon trajectory)
- Inventory state persistence
- Exact positioning of game elements
- Performance under load
---
*Testing analysis: 2026-02-04*

View File

@@ -31,7 +31,7 @@ EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
CMD wget --quiet --tries=1 --spider http://127.0.0.1/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

51
deploy-k8s.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/bash
# Deploy whalehunting to Kubernetes via ArgoCD
# Usage: ./deploy-k8s.sh [tag]
# Example: ./deploy-k8s.sh v1.2.0
set -e
GITEA_URL="git.kube2.tricnet.de"
REPO_NAME="whalehunting"
IMAGE_TAG="${1:-latest}"
echo "=== Whalehunting Kubernetes Deployment ==="
echo ""
# Step 1: Build Docker image
echo "1. Building Docker image..."
docker build -t ${GITEA_URL}/admin/${REPO_NAME}:${IMAGE_TAG} .
# Step 2: Push image to registry
echo ""
echo "2. Pushing image to Gitea registry..."
docker push ${GITEA_URL}/admin/${REPO_NAME}:${IMAGE_TAG}
# Step 3: Update image tag in kustomization
echo ""
echo "3. Updating image tag in kustomization.yaml..."
sed -i "s/newTag: .*/newTag: ${IMAGE_TAG}/" k8s/kustomization.yaml
# Step 4: Commit and push to Gitea
echo ""
echo "4. Committing and pushing to Gitea..."
git add -A
git commit -m "deploy: update image to ${IMAGE_TAG}" || echo "No changes to commit"
git push origin master
# Step 5: Ensure ArgoCD application exists
echo ""
echo "5. Ensuring ArgoCD application exists..."
ssh root@kube2.tricnet.de "kubectl apply -f -" < k8s/argocd-application.yaml
# Step 6: Wait for sync and check status
echo ""
echo "6. Checking deployment status..."
sleep 5
ssh root@kube2.tricnet.de "kubectl get application whalehunting -n argocd"
ssh root@kube2.tricnet.de "kubectl get pods -n whalehunting"
echo ""
echo "=== Deployment complete ==="
echo "Game URL: https://whalehunting.kube2.tricnet.de"

23
deploy.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
# Deploy script for whale hunting game
# Syncs files to node03 and rebuilds the Docker container
set -e # Exit on error
echo "🚢 Syncing files to node03..."
rsync -avz --exclude 'node_modules' --exclude 'dist' --exclude '.git' --exclude '.DS_Store' \
./ tho@node03.tricnet.de:/home/tho/whalehunting/
echo ""
echo "🐳 Rebuilding and restarting Docker container..."
ssh tho@node03.tricnet.de "cd /home/tho/whalehunting && docker-compose down && docker-compose up -d --build"
echo ""
echo "✅ Deployment complete!"
echo "🌐 Game available at: http://node03.tricnet.de:8880"
# Check container status
echo ""
echo "📊 Container status:"
ssh tho@node03.tricnet.de "cd /home/tho/whalehunting && docker-compose ps"

View File

@@ -11,7 +11,7 @@ services:
- "8880:80"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
interval: 30s
timeout: 3s
retries: 3

View File

@@ -2,9 +2,19 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-fullscreen">
<meta name="mobile-web-app-capable" content="yes">
<title>Whale Hunting - A Point & Click Adventure</title>
<style>
* {
-webkit-touch-callout: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
body {
margin: 0;
padding: 0;
@@ -14,9 +24,14 @@
align-items: center;
min-height: 100vh;
font-family: 'Courier New', monospace;
overscroll-behavior: none;
touch-action: none;
}
#game-container {
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
touch-action: none;
max-width: 100vw;
max-height: 100vh;
}
</style>
</head>

View File

@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: whalehunting
namespace: argocd
spec:
project: default
source:
repoURL: http://gitea-http.gitea.svc.cluster.local:3000/admin/whalehunting.git
targetRevision: HEAD
path: k8s
destination:
server: https://kubernetes.default.svc
namespace: whalehunting
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true

45
k8s/deployment.yaml Normal file
View File

@@ -0,0 +1,45 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: whalehunting
namespace: whalehunting
labels:
app.kubernetes.io/name: whalehunting
app.kubernetes.io/component: web
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: whalehunting
template:
metadata:
labels:
app.kubernetes.io/name: whalehunting
app.kubernetes.io/component: web
spec:
containers:
- name: whalehunting
image: git.kube2.tricnet.de/admin/whalehunting:latest
ports:
- name: http
containerPort: 80
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 10
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 50m
memory: 64Mi

26
k8s/ingress.yaml Normal file
View File

@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: whalehunting
namespace: whalehunting
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
labels:
app.kubernetes.io/name: whalehunting
spec:
ingressClassName: traefik
rules:
- host: whalehunting.kube2.tricnet.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whalehunting
port:
name: http
tls:
- hosts:
- whalehunting.kube2.tricnet.de
secretName: whalehunting-tls

17
k8s/kustomization.yaml Normal file
View File

@@ -0,0 +1,17 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: whalehunting
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
commonLabels:
app.kubernetes.io/managed-by: argocd
images:
- name: git.kube2.tricnet.de/admin/whalehunting
newTag: latest

6
k8s/namespace.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: whalehunting
labels:
app.kubernetes.io/name: whalehunting

16
k8s/service.yaml Normal file
View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: whalehunting
namespace: whalehunting
labels:
app.kubernetes.io/name: whalehunting
spec:
type: ClusterIP
ports:
- port: 80
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: whalehunting

1723
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,35 @@
{
"name": "whalehunting-game",
"version": "1.0.0",
"version": "1.2.0",
"description": "A Monkey Island-style point-and-click adventure game about 18th century whaling",
"main": "src/main.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm run test && npm run test:e2e"
},
"keywords": ["game", "phaser", "adventure", "point-and-click"],
"keywords": [
"game",
"phaser",
"adventure",
"point-and-click"
],
"author": "",
"license": "MIT",
"dependencies": {
"phaser": "^3.80.1"
},
"devDependencies": {
"vite": "^5.0.0"
"@playwright/test": "^1.57.0",
"@vitest/ui": "^4.0.16",
"jsdom": "^27.3.0",
"vite": "^5.0.0",
"vitest": "^4.0.16"
}
}

32
playwright.config.js Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});

View File

@@ -4,6 +4,7 @@ import ShipDeckScene from './scenes/ShipDeckScene.js';
import MapScene from './scenes/MapScene.js';
import TransitionScene from './scenes/TransitionScene.js';
import HuntingScene from './scenes/HuntingScene.js';
import DeepSeaHuntingScene from './scenes/DeepSeaHuntingScene.js';
const config = {
type: Phaser.AUTO,
@@ -11,7 +12,7 @@ const config = {
height: 600,
parent: 'game-container',
backgroundColor: '#2d5f8e',
scene: [IntroScene, ShipDeckScene, MapScene, TransitionScene, HuntingScene],
scene: [IntroScene, ShipDeckScene, MapScene, TransitionScene, HuntingScene, DeepSeaHuntingScene],
physics: {
default: 'arcade',
arcade: {
@@ -21,7 +22,12 @@ const config = {
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
autoCenter: Phaser.Scale.CENTER_BOTH,
min: {
width: 320,
height: 240
},
fullscreenTarget: 'game-container'
}
};

View File

@@ -0,0 +1,538 @@
import Phaser from 'phaser';
import { fontSize } from '../utils/responsive.js';
import { createFullscreenButton } from '../utils/fullscreen.js';
export default class DeepSeaHuntingScene extends Phaser.Scene {
constructor() {
super({ key: 'DeepSeaHuntingScene' });
}
init(data) {
this.inventory = data.inventory || { whaleOil: 0, fuel: 100, penguins: 0 };
this.whalesHunted = 0;
this.currentWhale = null;
this.harpoons = [];
this.crosshairX = 400;
this.crosshairY = 300;
}
create() {
// Dark deep ocean background
this.add.rectangle(400, 300, 800, 600, 0x0a1a2a);
// Create atmospheric effects
this.createDeepOceanAtmosphere();
// UI elements
this.createHUD();
this.createInstructions();
// Fullscreen button
createFullscreenButton(this);
// Create crosshair
this.createCrosshair();
// Setup input
this.setupInput();
// Spawn first whale
this.spawnWhale();
// Detect mobile
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Show initial message
const shootMessage = this.isMobile ? 'The leviathan approaches... Tap to harpoon!' : 'The leviathan approaches... Click to harpoon!';
this.showMessage(shootMessage);
}
update() {
// Update crosshair position
this.crosshairX = this.input.x;
this.crosshairY = this.input.y;
this.crosshair.setPosition(this.crosshairX, this.crosshairY);
// Update harpoons
this.updateHarpoons();
// Check collisions
this.checkCollisions();
}
createCrosshair() {
const graphics = this.add.graphics();
graphics.lineStyle(3, 0x4a9fff, 1);
// Crosshair lines
graphics.beginPath();
graphics.moveTo(-20, 0);
graphics.lineTo(-5, 0);
graphics.moveTo(5, 0);
graphics.lineTo(20, 0);
graphics.moveTo(0, -20);
graphics.lineTo(0, -5);
graphics.moveTo(0, 5);
graphics.lineTo(0, 20);
graphics.strokePath();
// Center circle
graphics.lineStyle(2, 0x4a9fff, 1);
graphics.strokeCircle(0, 0, 3);
// Convert to texture and create sprite
graphics.generateTexture('crosshair_deep', 40, 40);
graphics.destroy();
this.crosshair = this.add.sprite(400, 300, 'crosshair_deep');
this.crosshair.setDepth(100);
}
setupInput() {
// Mouse/touch click to shoot
this.input.on('pointerdown', () => {
this.shootHarpoon();
});
// Space to shoot
this.spaceKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
this.spaceKey.on('down', () => {
this.shootHarpoon();
});
// Hide cursor on desktop
if (!/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
this.input.setDefaultCursor('none');
}
}
shootHarpoon() {
// Create harpoon at crosshair position
const harpoon = this.add.container(this.crosshairX, this.crosshairY);
// Harpoon shaft with glow effect
const glow = this.add.rectangle(0, 0, 8, 30, 0x4a9fff, 0.3);
const shaft = this.add.rectangle(0, 0, 4, 25, 0x6abfff);
const tip = this.add.triangle(0, -15, 0, -8, -5, 0, 5, 0, 0x4a9fff);
harpoon.add([glow, shaft, tip]);
harpoon.setData('active', true);
harpoon.setDepth(50);
this.harpoons.push(harpoon);
// Flash effect
const flash = this.add.circle(this.crosshairX, this.crosshairY, 15, 0x4a9fff, 0.6);
this.tweens.add({
targets: flash,
alpha: 0,
scale: 2,
duration: 200,
onComplete: () => flash.destroy()
});
}
updateHarpoons() {
for (let i = this.harpoons.length - 1; i >= 0; i--) {
const harpoon = this.harpoons[i];
if (!harpoon.getData('active')) {
continue;
}
// Move harpoon up
harpoon.y -= 10;
// Remove if off screen
if (harpoon.y < -30) {
harpoon.destroy();
this.harpoons.splice(i, 1);
}
}
}
checkCollisions() {
if (!this.currentWhale || !this.currentWhale.getData('alive')) {
return;
}
const whale = this.currentWhale;
for (let i = this.harpoons.length - 1; i >= 0; i--) {
const harpoon = this.harpoons[i];
if (!harpoon.getData('active')) {
continue;
}
const distance = Phaser.Math.Distance.Between(
harpoon.x, harpoon.y,
whale.x, whale.y
);
if (distance < 90) {
this.hitWhale(whale, harpoon, i);
break;
}
}
}
hitWhale(whale, harpoon, harpoonIndex) {
harpoon.setData('active', false);
harpoon.destroy();
this.harpoons.splice(harpoonIndex, 1);
// Reduce health
let health = whale.getData('health');
health -= 1;
whale.setData('health', health);
// Update health bar
const healthBar = whale.getData('healthBar');
const maxHealth = whale.getData('maxHealth');
const healthPercent = health / maxHealth;
healthBar.setDisplaySize(120 * healthPercent, 10);
// Change color based on health
if (healthPercent > 0.6) {
healthBar.setFillStyle(0x00ff00);
} else if (healthPercent > 0.3) {
healthBar.setFillStyle(0xffff00);
} else {
healthBar.setFillStyle(0xff0000);
}
// Hit flash
const hitFlash = this.add.circle(whale.x, whale.y, 60, 0x4a9fff, 0.5);
this.tweens.add({
targets: hitFlash,
alpha: 0,
scale: 1.5,
duration: 300,
onComplete: () => hitFlash.destroy()
});
if (health <= 0) {
this.killWhale(whale);
} else {
this.showMessage(`Hit! Leviathan health: ${health}/${maxHealth}`);
}
}
killWhale(whale) {
whale.setData('alive', false);
// Stop the path tween
this.tweens.killTweensOf(whale.getData('pathData'));
// Check fuel
if (this.inventory.fuel < 3) {
this.tweens.add({
targets: whale,
alpha: 0,
y: whale.y + 100,
duration: 1500,
onComplete: () => {
whale.destroy();
this.currentWhale = null;
this.time.delayedCall(2000, () => this.spawnWhale());
}
});
this.showMessage('Leviathan slain but no fuel to process! Need 3 fuel.');
return;
}
// Death animation
this.tweens.add({
targets: whale,
alpha: 0,
y: whale.y + 100,
angle: whale.rotation * (180 / Math.PI) + 90,
duration: 1500,
onComplete: () => {
whale.destroy();
this.currentWhale = null;
this.time.delayedCall(2000, () => this.spawnWhale());
}
});
// Rewards - leviathans give more oil
this.inventory.fuel -= 3;
this.inventory.whaleOil += 3;
this.whalesHunted++;
this.updateStats();
this.showMessage(`Leviathan slain! +3 Oil, -3 Fuel`);
// Success flash
const flash = this.add.rectangle(400, 300, 800, 600, 0x4a9fff, 0.3);
this.tweens.add({
targets: flash,
alpha: 0,
duration: 500,
onComplete: () => flash.destroy()
});
}
createDeepOceanAtmosphere() {
// Gradient darkness
for (let i = 0; i < 6; i++) {
const alpha = 0.05 + i * 0.03;
this.add.rectangle(400, 500 - i * 80, 800, 100, 0x000000, alpha);
}
// Subtle waves
const graphics = this.add.graphics();
graphics.lineStyle(2, 0x1a3a5a, 0.3);
for (let i = 0; i < 8; i++) {
const y = 80 + i * 70;
graphics.beginPath();
for (let x = 0; x < 800; x += 30) {
const waveY = y + Math.sin((x + i * 50) * 0.012) * 8;
if (x === 0) {
graphics.moveTo(x, waveY);
} else {
graphics.lineTo(x, waveY);
}
}
graphics.strokePath();
}
// Bioluminescent particles
for (let i = 0; i < 20; i++) {
const x = Math.random() * 800;
const y = 100 + Math.random() * 400;
const particle = this.add.circle(x, y, 2 + Math.random() * 2, 0x4a9fff, 0.5);
this.tweens.add({
targets: particle,
alpha: { from: 0.5, to: 0.1 },
y: y - 30,
duration: 3000 + Math.random() * 2000,
yoyo: true,
repeat: -1,
ease: 'Sine.inOut'
});
}
}
spawnWhale() {
// Get random entry and exit points on different edges
const edges = ['top', 'bottom', 'left', 'right'];
const entryEdge = edges[Math.floor(Math.random() * edges.length)];
// Pick a different edge for exit
const exitEdges = edges.filter(e => e !== entryEdge);
const exitEdge = exitEdges[Math.floor(Math.random() * exitEdges.length)];
const entry = this.getEdgePoint(entryEdge);
const exit = this.getEdgePoint(exitEdge);
// Calculate angle for whale rotation
const angle = Phaser.Math.Angle.Between(entry.x, entry.y, exit.x, exit.y);
// Create whale container
const whale = this.add.container(entry.x, entry.y);
// Whale body (large deep sea whale)
const body = this.add.ellipse(0, 0, 180, 70, 0x1a2a3a);
body.setStrokeStyle(2, 0x2a4a6a);
body.disableInteractive();
// Whale underbelly
const belly = this.add.ellipse(0, 10, 140, 40, 0x2a3a4a);
// Tail
const tail = this.add.triangle(-100, 0, 0, -35, 0, 35, -50, 0, 0x1a2a3a);
// Dorsal fin
const dorsalFin = this.add.triangle(20, -25, 0, 0, 30, 0, 15, -30, 0x1a2a3a);
// Eye (bioluminescent glow)
const eyeGlow = this.add.circle(60, -10, 12, 0x4a9fff, 0.3);
const eye = this.add.circle(60, -10, 6, 0x6abfff);
// Add glow effect to eye
this.tweens.add({
targets: eyeGlow,
alpha: { from: 0.3, to: 0.6 },
scale: { from: 1, to: 1.3 },
duration: 1000,
yoyo: true,
repeat: -1,
ease: 'Sine.inOut'
});
// Health bar background
const healthBg = this.add.rectangle(0, -55, 120, 10, 0x000000);
healthBg.setStrokeStyle(1, 0x4a9fff);
// Health bar
const healthBar = this.add.rectangle(0, -55, 120, 10, 0x00ff00);
whale.add([tail, body, belly, dorsalFin, eyeGlow, eye, healthBg, healthBar]);
whale.setRotation(angle);
whale.setDepth(10);
// Set whale data
whale.setData('alive', true);
whale.setData('health', 5); // Leviathans are tougher - 5 hits
whale.setData('maxHealth', 5);
whale.setData('healthBar', healthBar);
this.currentWhale = whale;
// Subtle bobbing animation during movement
this.tweens.add({
targets: whale,
scaleY: { from: 1, to: 1.05 },
duration: 800,
yoyo: true,
repeat: -1,
ease: 'Sine.inOut'
});
// Create curved path from entry to exit
const path = new Phaser.Curves.Path(entry.x, entry.y);
// Calculate midpoint and perpendicular offset for curve
const midX = (entry.x + exit.x) / 2;
const midY = (entry.y + exit.y) / 2;
// Create perpendicular offset for natural swimming curve
const dx = exit.x - entry.x;
const dy = exit.y - entry.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Perpendicular direction (normalized)
const perpX = -dy / dist;
const perpY = dx / dist;
// Random curve amplitude (positive or negative for variety)
const curveAmount = (Math.random() > 0.5 ? 1 : -1) * (80 + Math.random() * 120);
// Control points for smooth S-curve
const ctrl1X = entry.x + dx * 0.25 + perpX * curveAmount;
const ctrl1Y = entry.y + dy * 0.25 + perpY * curveAmount;
const ctrl2X = entry.x + dx * 0.75 - perpX * curveAmount * 0.7;
const ctrl2Y = entry.y + dy * 0.75 - perpY * curveAmount * 0.7;
// Add cubic bezier curve
path.cubicBezierTo(exit.x, exit.y, ctrl1X, ctrl1Y, ctrl2X, ctrl2Y);
// Store path progress
const pathData = { t: 0 };
whale.setData('pathData', pathData);
// Animate along curved path
this.tweens.add({
targets: pathData,
t: 1,
duration: 15000,
ease: 'Sine.inOut',
onUpdate: () => {
const point = path.getPoint(pathData.t);
const tangent = path.getTangent(pathData.t);
whale.setPosition(point.x, point.y);
whale.setRotation(Math.atan2(tangent.y, tangent.x));
},
onComplete: () => {
this.onWhaleExit(whale);
}
});
}
getEdgePoint(edge) {
const margin = 100; // How far off-screen to spawn
const padding = 100; // Padding from corners
switch (edge) {
case 'top':
return {
x: padding + Math.random() * (800 - padding * 2),
y: -margin
};
case 'bottom':
return {
x: padding + Math.random() * (800 - padding * 2),
y: 600 + margin
};
case 'left':
return {
x: -margin,
y: padding + Math.random() * (600 - padding * 2)
};
case 'right':
return {
x: 800 + margin,
y: padding + Math.random() * (600 - padding * 2)
};
}
}
onWhaleExit(whale) {
// Destroy the whale
whale.destroy();
this.currentWhale = null;
// Respawn after 2 seconds
this.time.delayedCall(2000, () => {
this.spawnWhale();
});
}
createHUD() {
// HUD background
const hudBg = this.add.rectangle(400, 30, 780, 50, 0x000000, 0.7);
hudBg.setStrokeStyle(2, 0x4a9fff);
// Stats
this.statsText = this.add.text(20, 15, '', {
fontSize: fontSize(16),
fill: '#4a9fff'
});
// Return button
const returnBtn = this.add.rectangle(750, 30, 80, 35, 0x1a3a5a);
returnBtn.setInteractive({ useHandCursor: true });
returnBtn.setStrokeStyle(2, 0x4a9fff);
const returnText = this.add.text(750, 30, 'RETURN', {
fontSize: fontSize(14),
fill: '#4a9fff'
}).setOrigin(0.5);
returnBtn.on('pointerdown', () => {
this.returnToMap();
});
this.updateStats();
}
updateStats() {
this.statsText.setText([
`Fuel: ${this.inventory.fuel}/100`,
`Oil: ${this.inventory.whaleOil}/50`,
`Leviathans: ${this.whalesHunted}`
]);
}
createInstructions() {
this.messageText = this.add.text(400, 570, '', {
fontSize: fontSize(16),
fill: '#4a9fff',
backgroundColor: '#000000',
padding: { x: 10, y: 5 }
}).setOrigin(0.5);
}
showMessage(text) {
this.messageText.setText(text);
}
returnToMap() {
this.input.setDefaultCursor('default');
this.scene.start('MapScene', { inventory: this.inventory });
}
}

View File

@@ -1,4 +1,6 @@
import Phaser from 'phaser';
import { fontSize } from '../utils/responsive.js';
import { createFullscreenButton } from '../utils/fullscreen.js';
export default class HuntingScene extends Phaser.Scene {
constructor() {
@@ -19,6 +21,12 @@ export default class HuntingScene extends Phaser.Scene {
}
create() {
// Detect mobile device
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Default to mouse/touch mode on all platforms
this.useKeyboard = false;
// Ocean background
this.add.rectangle(400, 300, 800, 600, 0x1e5a8e);
this.createOceanWaves();
@@ -38,7 +46,11 @@ export default class HuntingScene extends Phaser.Scene {
this.setupOceanMovement();
// Message display
this.showMessage('Hunt whales! Click or press SPACE to shoot harpoon.');
const shootMessage = this.isMobile ? 'Hunt whales! Tap to shoot harpoon.' : 'Hunt whales! Click or press SPACE to shoot harpoon.';
this.showMessage(shootMessage);
// Fullscreen button
createFullscreenButton(this);
}
update() {
@@ -89,7 +101,8 @@ export default class HuntingScene extends Phaser.Scene {
this.shootHarpoon();
});
// Tab to toggle control mode
// Tab to toggle control mode (desktop only)
if (!this.isMobile) {
this.tabKey.on('down', () => {
this.useKeyboard = !this.useKeyboard;
this.controlModeText.setText(`Controls: ${this.useKeyboard ? 'KEYBOARD' : 'MOUSE'} (TAB to switch)`);
@@ -99,9 +112,12 @@ export default class HuntingScene extends Phaser.Scene {
this.input.setDefaultCursor('none');
}
});
}
// Start with mouse control (hide cursor)
this.input.setDefaultCursor('none');
// Start with mouse control (hide cursor on desktop only)
if (!this.isMobile) {
this.input.setDefaultCursor(this.useKeyboard ? 'default' : 'none');
}
}
updateCrosshairMouse() {
@@ -185,17 +201,19 @@ export default class HuntingScene extends Phaser.Scene {
// Stats (fixed to screen)
this.statsText = this.add.text(20, 15, '', {
fontSize: '16px',
fontSize: fontSize(16),
fill: '#fff'
});
this.statsText.setScrollFactor(0);
// Control mode indicator (fixed to screen)
// Control mode indicator (fixed to screen, desktop only)
if (!this.isMobile) {
this.controlModeText = this.add.text(400, 15, 'Controls: MOUSE (TAB to switch)', {
fontSize: '16px',
fontSize: fontSize(16),
fill: '#ffff00'
}).setOrigin(0.5, 0);
this.controlModeText.setScrollFactor(0);
}
// Return button (fixed to screen)
const returnBtn = this.add.rectangle(750, 30, 80, 35, 0x8B0000);
@@ -204,7 +222,7 @@ export default class HuntingScene extends Phaser.Scene {
returnBtn.setScrollFactor(0);
const returnText = this.add.text(750, 30, 'RETURN', {
fontSize: '14px',
fontSize: fontSize(14),
fill: '#fff'
}).setOrigin(0.5);
returnText.setScrollFactor(0);
@@ -226,7 +244,7 @@ export default class HuntingScene extends Phaser.Scene {
createInstructions() {
this.messageText = this.add.text(400, 570, '', {
fontSize: '16px',
fontSize: fontSize(16),
fill: '#fff',
backgroundColor: '#000000',
padding: { x: 10, y: 5 }

View File

@@ -1,3 +1,6 @@
import { fontSize } from '../utils/responsive.js';
import { createFullscreenButton } from '../utils/fullscreen.js';
export default class IntroScene extends Phaser.Scene {
constructor() {
super({ key: 'IntroScene' });
@@ -19,7 +22,7 @@ export default class IntroScene extends Phaser.Scene {
// Game title
const title = this.add.text(400, 150, 'WHALE HUNTING', {
fontSize: '56px',
fontSize: fontSize(56),
fill: '#ffffff',
fontStyle: 'bold'
}).setOrigin(0.5);
@@ -27,7 +30,7 @@ export default class IntroScene extends Phaser.Scene {
// Subtitle
this.add.text(400, 210, 'A Whaling Adventure on the High Seas', {
fontSize: '22px',
fontSize: fontSize(22),
fill: '#cccccc',
fontStyle: 'italic'
}).setOrigin(0.5);
@@ -43,12 +46,12 @@ export default class IntroScene extends Phaser.Scene {
buttonBg.setInteractive({ useHandCursor: true });
const buttonText = this.add.text(400, 400, 'SET SAIL', {
fontSize: '28px',
fontSize: fontSize(28),
fill: '#ffffff',
fontStyle: 'bold'
}).setOrigin(0.5);
// Button hover effects
// Button hover effects (desktop)
buttonBg.on('pointerover', () => {
buttonBg.setFillStyle(0x4a90c4);
});
@@ -57,16 +60,35 @@ export default class IntroScene extends Phaser.Scene {
buttonBg.setFillStyle(0x2d5f8e);
});
// Button click handler
// Touch feedback (works on both touch and mouse)
buttonBg.on('pointerdown', () => {
buttonBg.setFillStyle(0x4a90c4);
buttonBg.setScale(0.95);
buttonText.setScale(0.95);
});
buttonBg.on('pointerup', () => {
buttonBg.setFillStyle(0x2d5f8e);
buttonBg.setScale(1.0);
buttonText.setScale(1.0);
this.startGame();
});
// Instructions text
this.add.text(400, 530, 'Click to cast off and seek yer fortune!', {
fontSize: '16px',
fontSize: fontSize(16),
fill: '#ffff99'
}).setOrigin(0.5);
// Fullscreen button
createFullscreenButton(this);
// Version display
this.add.text(790, 590, 'v1.2.0', {
fontSize: fontSize(12),
fill: '#ffffff',
alpha: 0.5
}).setOrigin(1, 1);
}
drawWaves() {

View File

@@ -1,4 +1,6 @@
import Phaser from 'phaser';
import { fontSize } from '../utils/responsive.js';
import { createFullscreenButton } from '../utils/fullscreen.js';
export default class MapScene extends Phaser.Scene {
constructor() {
@@ -16,7 +18,7 @@ export default class MapScene extends Phaser.Scene {
// Title
this.add.text(400, 30, 'Navigation Map - Choose Your Destination', {
fontSize: '24px',
fontSize: fontSize(24),
fill: '#fff',
fontStyle: 'bold'
}).setOrigin(0.5);
@@ -44,6 +46,11 @@ export default class MapScene extends Phaser.Scene {
'Return to port to resupply fuel and sell whale oil.',
() => this.goToPort());
// Deep Sea - Alternative hunting area
this.createLocation(550, 150, 'DEEP\nSEA', 0x0a2a4a,
'Treacherous deep waters. Giant whales lurk in the abyss...',
() => this.goToDeepSea());
// Inventory display
this.createInventoryDisplay();
@@ -56,14 +63,25 @@ export default class MapScene extends Phaser.Scene {
closeButton.setInteractive({ useHandCursor: true });
closeButton.setStrokeStyle(2, 0xffffff);
this.add.text(750, 50, 'CLOSE', {
fontSize: '16px',
const closeText = this.add.text(750, 50, 'CLOSE', {
fontSize: fontSize(16),
fill: '#fff'
}).setOrigin(0.5);
// Touch feedback (works on both touch and mouse)
closeButton.on('pointerdown', () => {
closeButton.setScale(0.95);
closeText.setScale(0.95);
});
closeButton.on('pointerup', () => {
closeButton.setScale(1.0);
closeText.setScale(1.0);
this.returnToShip();
});
// Fullscreen button
createFullscreenButton(this);
}
drawWaves() {
@@ -94,13 +112,13 @@ export default class MapScene extends Phaser.Scene {
// Location name
const text = this.add.text(x, y, name, {
fontSize: '12px',
fontSize: fontSize(12),
fill: '#fff',
fontStyle: 'bold',
align: 'center'
}).setOrigin(0.5);
// Hover effect
// Hover effect (desktop)
marker.on('pointerover', () => {
marker.setScale(1.1);
this.showMessage(description);
@@ -110,7 +128,17 @@ export default class MapScene extends Phaser.Scene {
marker.setScale(1);
});
marker.on('pointerdown', onClick);
// Touch feedback (works on both touch and mouse)
marker.on('pointerdown', () => {
marker.setScale(0.9);
text.setScale(0.9);
});
marker.on('pointerup', () => {
marker.setScale(1);
text.setScale(1);
onClick();
});
}
createInventoryDisplay() {
@@ -118,7 +146,7 @@ export default class MapScene extends Phaser.Scene {
panel.setStrokeStyle(2, 0xffffff);
this.inventoryText = this.add.text(20, 40, '', {
fontSize: '14px',
fontSize: fontSize(14),
fill: '#fff'
});
@@ -145,7 +173,7 @@ export default class MapScene extends Phaser.Scene {
this.messageBox.setStrokeStyle(2, 0xcccccc);
this.messageText = this.add.text(400, 560, '', {
fontSize: '16px',
fontSize: fontSize(16),
fill: '#fff',
wordWrap: { width: 740 },
align: 'center'
@@ -189,4 +217,14 @@ export default class MapScene extends Phaser.Scene {
nextScene: 'MapScene' // Will change to 'PortScene' when implemented
});
}
goToDeepSea() {
// Sailing is free - fuel is only for cooking
this.scene.start('TransitionScene', {
inventory: this.inventory,
destination: 'deepsea',
fuelCost: 0,
nextScene: 'DeepSeaHuntingScene'
});
}
}

View File

@@ -1,4 +1,6 @@
import Phaser from 'phaser';
import { fontSize } from '../utils/responsive.js';
import { createFullscreenButton } from '../utils/fullscreen.js';
export default class ShipDeckScene extends Phaser.Scene {
constructor() {
@@ -21,7 +23,7 @@ export default class ShipDeckScene extends Phaser.Scene {
// Background - ship deck
this.add.rectangle(400, 300, 800, 600, 0x8B4513);
this.add.text(400, 30, 'The Deck - Yer Whaling Vessel', {
fontSize: '24px',
fontSize: fontSize(24),
fill: '#fff'
}).setOrigin(0.5);
@@ -34,9 +36,12 @@ export default class ShipDeckScene extends Phaser.Scene {
// Instructions
this.add.text(400, 560, 'Click on things to have a look, ye scurvy dog', {
fontSize: '16px',
fontSize: fontSize(16),
fill: '#ffff99'
}).setOrigin(0.5);
// Fullscreen button
createFullscreenButton(this);
}
createDeck() {
@@ -85,7 +90,7 @@ export default class ShipDeckScene extends Phaser.Scene {
this.penguinCage.setStrokeStyle(3, 0x000000);
this.penguinEmoji = this.add.text(650, 350, '🐧', {
fontSize: '32px'
fontSize: fontSize(32)
}).setOrigin(0.5);
// Hide penguin elements if not yet discovered
@@ -143,7 +148,7 @@ export default class ShipDeckScene extends Phaser.Scene {
// Add label only on first barrel
if (i === 0) {
const label = this.add.text(x, y, 'FUEL', {
fontSize: '12px',
fontSize: fontSize(12),
fill: '#fff'
}).setOrigin(0.5);
this.fuelBarrels.push(label);
@@ -201,7 +206,7 @@ export default class ShipDeckScene extends Phaser.Scene {
// Add label only on first barrel
if (i === 0) {
const label = this.add.text(x, y, 'OIL', {
fontSize: '12px',
fontSize: fontSize(12),
fill: '#fff'
}).setOrigin(0.5);
this.oilBarrels.push(label);
@@ -239,8 +244,8 @@ export default class ShipDeckScene extends Phaser.Scene {
wheel.setStrokeStyle(4, 0x654321);
// Wheel spokes
const spokeDebug = this.add.graphics();
spokeDebug.lineStyle(2, 0x0000FF, 0.8);
const spokes = this.add.graphics();
spokes.lineStyle(3, 0x654321);
for (let i = 0; i < 8; i++) {
const angle = (i * 45) * Math.PI / 180;
@@ -248,30 +253,13 @@ export default class ShipDeckScene extends Phaser.Scene {
const y1 = 200 + Math.sin(angle) * 15;
const x2 = 550 + Math.cos(angle) * 35;
const y2 = 200 + Math.sin(angle) * 35;
this.add.line(0, 0, x1, y1, x2, y2, 0x654321).setLineWidth(3);
// Debug: Draw blue lines showing spoke endpoints
spokeDebug.lineBetween(x1, y1, x2, y2);
// Draw circles at endpoints
spokeDebug.strokeCircle(x1, y1, 3);
spokeDebug.strokeCircle(x2, y2, 3);
spokes.lineBetween(x1, y1, x2, y2);
}
wheel.on('pointerdown', () => {
// Open the map scene
this.scene.start('MapScene', { inventory: this.inventory });
});
// Debug: Draw lines for wheel positioning
const debugLines = this.add.graphics();
debugLines.lineStyle(2, 0x00FF00, 0.8);
// Center crosshair
debugLines.lineBetween(550 - 50, 200, 550 + 50, 200); // Horizontal
debugLines.lineBetween(550, 200 - 50, 550, 200 + 50); // Vertical
// Bounding box (radius 40)
debugLines.strokeRect(550 - 40, 200 - 40, 80, 80);
}
createInventoryDisplay() {
@@ -280,7 +268,7 @@ export default class ShipDeckScene extends Phaser.Scene {
panel.setStrokeStyle(2, 0xffffff);
this.inventoryText = this.add.text(20, 40, '', {
fontSize: '14px',
fontSize: fontSize(14),
fill: '#fff'
});
@@ -320,7 +308,7 @@ export default class ShipDeckScene extends Phaser.Scene {
this.messageBox.setStrokeStyle(2, 0xcccccc);
this.messageText = this.add.text(400, 500, 'Ahoy, matey! Welcome aboard. Have a look around the vessel.', {
fontSize: '16px',
fontSize: fontSize(16),
fill: '#fff',
wordWrap: { width: 740 },
align: 'center'

View File

@@ -1,4 +1,6 @@
import Phaser from 'phaser';
import { fontSize } from '../utils/responsive.js';
import { createFullscreenButton } from '../utils/fullscreen.js';
export default class TransitionScene extends Phaser.Scene {
constructor() {
@@ -30,7 +32,7 @@ export default class TransitionScene extends Phaser.Scene {
// Destination title
this.add.text(400, 360, content.title, {
fontSize: '32px',
fontSize: fontSize(32),
fill: '#ffffff',
fontStyle: 'bold',
stroke: '#000000',
@@ -39,7 +41,7 @@ export default class TransitionScene extends Phaser.Scene {
// Journey description
this.add.text(400, 430, content.description, {
fontSize: '18px',
fontSize: fontSize(18),
fill: '#ffffff',
align: 'center',
wordWrap: { width: 660 }
@@ -48,12 +50,12 @@ export default class TransitionScene extends Phaser.Scene {
// Fuel cost display (only if there's a cost)
if (this.fuelCost > 0) {
this.add.text(400, 500, `Fuel consumed: ${this.fuelCost} units`, {
fontSize: '16px',
fontSize: fontSize(16),
fill: '#ffff00'
}).setOrigin(0.5);
} else {
this.add.text(400, 500, 'The wind carries your sails...', {
fontSize: '16px',
fontSize: fontSize(16),
fill: '#cccccc',
fontStyle: 'italic'
}).setOrigin(0.5);
@@ -65,12 +67,12 @@ export default class TransitionScene extends Phaser.Scene {
continueBtn.setStrokeStyle(3, 0xffffff);
const btnText = this.add.text(400, 540, 'CONTINUE', {
fontSize: '20px',
fontSize: fontSize(20),
fill: '#fff',
fontStyle: 'bold'
}).setOrigin(0.5);
// Hover effect
// Hover effect (desktop)
continueBtn.on('pointerover', () => {
continueBtn.setFillStyle(0x4a90c4);
});
@@ -79,7 +81,18 @@ export default class TransitionScene extends Phaser.Scene {
continueBtn.setFillStyle(0x2d5f8e);
});
// Touch feedback (works on both touch and mouse)
continueBtn.on('pointerdown', () => {
continueBtn.setFillStyle(0x4a90c4);
continueBtn.setScale(0.95);
btnText.setScale(0.95);
});
continueBtn.on('pointerup', () => {
continueBtn.setFillStyle(0x2d5f8e);
continueBtn.setScale(1.0);
btnText.setScale(1.0);
// Deduct fuel (if any cost)
if (this.fuelCost > 0) {
this.inventory.fuel -= this.fuelCost;
@@ -88,6 +101,9 @@ export default class TransitionScene extends Phaser.Scene {
// Proceed to next scene
this.scene.start(this.nextScene, { inventory: this.inventory });
});
// Fullscreen button
createFullscreenButton(this);
}
getDestinationContent(destination) {
@@ -109,6 +125,12 @@ export default class TransitionScene extends Phaser.Scene {
description: 'The familiar harbor comes into view. Seagulls circle overhead.\nYou can already smell the taverns and hear the merchants haggling.\nTime to resupply and sell your cargo.',
backgroundColor: 0x654321,
visualType: 'harbor'
},
'deepsea': {
title: 'Descending into the Deep Sea',
description: 'The waters grow dark and cold. Your ship ventures into uncharted depths.\nMassive shadows move beneath the surface. The crew whispers of leviathans.\nOnly the bravest—or most foolish—hunt here...',
backgroundColor: 0x0a1a2a,
visualType: 'deep_ocean'
}
};
@@ -131,6 +153,9 @@ export default class TransitionScene extends Phaser.Scene {
case 'harbor':
this.createHarbor();
break;
case 'deep_ocean':
this.createDeepOcean();
break;
default:
this.createOcean();
break;
@@ -254,7 +279,7 @@ export default class TransitionScene extends Phaser.Scene {
const x = 200 + i * 200;
const y = 80 + Math.random() * 40;
const bird = this.add.text(x, y, 'v', {
fontSize: '20px',
fontSize: fontSize(20),
fill: '#ffffff'
});
@@ -289,4 +314,74 @@ export default class TransitionScene extends Phaser.Scene {
graphics.strokePath();
}
}
createDeepOcean() {
// Dark, eerie deep ocean atmosphere
// Gradient effect with darker rectangles
for (let i = 0; i < 6; i++) {
const alpha = 0.1 + i * 0.05;
this.add.rectangle(400, 50 + i * 50, 800, 50, 0x000000, alpha);
}
// Subtle dark waves
const graphics = this.add.graphics();
graphics.lineStyle(2, 0x1a3a5a, 0.4);
for (let i = 0; i < 6; i++) {
const y = 80 + i * 50;
graphics.beginPath();
for (let x = 0; x < 800; x += 25) {
const waveY = y + Math.sin((x + i * 40) * 0.015) * 12;
if (x === 0) {
graphics.moveTo(x, waveY);
} else {
graphics.lineTo(x, waveY);
}
}
graphics.strokePath();
}
// Massive shadows lurking below
for (let i = 0; i < 2; i++) {
const x = 200 + i * 400;
const y = 200 + Math.random() * 80;
// Giant whale shadow
const shadow = this.add.ellipse(x, y, 200, 80, 0x000000, 0.3);
// Slow, ominous movement
this.tweens.add({
targets: shadow,
x: x + 30,
y: y + 15,
scaleX: 1.1,
duration: 4000,
yoyo: true,
repeat: -1,
ease: 'Sine.inOut'
});
}
// Bioluminescent particles
for (let i = 0; i < 15; i++) {
const x = Math.random() * 800;
const y = Math.random() * 300;
const particle = this.add.circle(x, y, 2, 0x4a9fff, 0.6);
this.tweens.add({
targets: particle,
alpha: { from: 0.6, to: 0.1 },
y: y - 20,
duration: 2000 + Math.random() * 2000,
yoyo: true,
repeat: -1,
ease: 'Sine.inOut'
});
}
// Ship silhouette at top
this.add.rectangle(100, 60, 70, 35, 0x1a1a1a, 0.8);
this.add.polygon(100, 45, [0, 0, -35, 30, 35, 30], 0x2a2a2a, 0.8);
}
}

75
src/utils/fullscreen.js Normal file
View File

@@ -0,0 +1,75 @@
// Fullscreen toggle utility for Phaser scenes
import { fontSize } from './responsive.js';
/**
* Create a fullscreen toggle button in the corner of the screen
* @param {Phaser.Scene} scene - The Phaser scene to add the button to
* @param {object} options - Optional configuration
* @param {number} options.x - X position (default: 30)
* @param {number} options.y - Y position (default: 20)
*/
export function createFullscreenButton(scene, options = {}) {
const x = options.x || 30;
const y = options.y || 20;
// Button background
const btn = scene.add.rectangle(x, y, 50, 30, 0x000000, 0.6);
btn.setStrokeStyle(2, 0xffffff);
btn.setInteractive({ useHandCursor: true });
btn.setScrollFactor(0);
btn.setDepth(1000);
// Button icon/text
const icon = scene.add.text(x, y, '⛶', {
fontSize: fontSize(18),
fill: '#ffffff'
}).setOrigin(0.5);
icon.setScrollFactor(0);
icon.setDepth(1001);
// Update icon based on fullscreen state
const updateIcon = () => {
if (scene.scale.isFullscreen) {
icon.setText('⛶');
} else {
icon.setText('⛶');
}
};
// Toggle fullscreen on click
btn.on('pointerdown', () => {
btn.setFillStyle(0x333333, 0.8);
btn.setScale(0.95);
icon.setScale(0.95);
});
btn.on('pointerup', () => {
btn.setFillStyle(0x000000, 0.6);
btn.setScale(1);
icon.setScale(1);
if (scene.scale.isFullscreen) {
scene.scale.stopFullscreen();
} else {
scene.scale.startFullscreen();
}
updateIcon();
});
btn.on('pointerover', () => {
btn.setFillStyle(0x333333, 0.8);
});
btn.on('pointerout', () => {
btn.setFillStyle(0x000000, 0.6);
});
// Listen for fullscreen changes
scene.scale.on('enterfullscreen', updateIcon);
scene.scale.on('leavefullscreen', updateIcon);
return { btn, icon };
}
export default { createFullscreenButton };

26
src/utils/responsive.js Normal file
View File

@@ -0,0 +1,26 @@
// Responsive utilities for mobile-friendly text sizing
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Font size multiplier for mobile devices
const MOBILE_SCALE = 1.4;
/**
* Get responsive font size - larger on mobile for better readability
* @param {number} baseSize - Base font size in pixels
* @returns {string} Font size string with 'px' suffix
*/
export function fontSize(baseSize) {
const size = isMobile ? Math.round(baseSize * MOBILE_SCALE) : baseSize;
return `${size}px`;
}
/**
* Check if running on mobile device
* @returns {boolean}
*/
export function checkMobile() {
return isMobile;
}
export default { fontSize, checkMobile, isMobile };

193
tests/e2e/game-flow.spec.js Normal file
View File

@@ -0,0 +1,193 @@
import { test, expect } from '@playwright/test';
test.describe('Whale Hunting Game - Main Flow', () => {
test('should load the intro scene', async ({ page }) => {
await page.goto('/');
// Wait for Phaser to load
await page.waitForTimeout(2000);
// Check that the game canvas exists
const canvas = page.locator('canvas');
await expect(canvas).toBeVisible();
// Check page title
await expect(page).toHaveTitle(/Whale Hunting/);
});
test('should start game from intro scene', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
// Click on the canvas where the "SET SAIL" button should be (center area)
const canvas = page.locator('canvas');
await canvas.click({ position: { x: 400, y: 400 } });
// Wait for scene transition
await page.waitForTimeout(1000);
// Game should still be running
await expect(canvas).toBeVisible();
});
test('should navigate to map scene', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
const canvas = page.locator('canvas');
// Click SET SAIL
await canvas.click({ position: { x: 400, y: 400 } });
await page.waitForTimeout(1000);
// Click on ship's wheel (around x:550, y:200)
await canvas.click({ position: { x: 550, y: 200 } });
await page.waitForTimeout(1000);
// Should now be in map scene
await expect(canvas).toBeVisible();
});
test('should navigate to hunting grounds', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
const canvas = page.locator('canvas');
// Start game
await canvas.click({ position: { x: 400, y: 400 } });
await page.waitForTimeout(1000);
// Go to map
await canvas.click({ position: { x: 550, y: 200 } });
await page.waitForTimeout(1000);
// Click hunting grounds marker (around x:250, y:200)
await canvas.click({ position: { x: 250, y: 200 } });
await page.waitForTimeout(1000);
// Should show transition scene
await expect(canvas).toBeVisible();
// Click continue button
await canvas.click({ position: { x: 400, y: 540 } });
await page.waitForTimeout(1000);
// Should now be in hunting scene
await expect(canvas).toBeVisible();
});
test('should return to ship from map', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
const canvas = page.locator('canvas');
// Start game
await canvas.click({ position: { x: 400, y: 400 } });
await page.waitForTimeout(1000);
// Go to map
await canvas.click({ position: { x: 550, y: 200 } });
await page.waitForTimeout(1000);
// Click CLOSE button (top right)
await canvas.click({ position: { x: 750, y: 50 } });
await page.waitForTimeout(1000);
// Should be back at ship deck
await expect(canvas).toBeVisible();
});
});
test.describe('Whale Hunting Scene', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
const canvas = page.locator('canvas');
// Navigate to hunting scene
await canvas.click({ position: { x: 400, y: 400 } }); // SET SAIL
await page.waitForTimeout(1000);
await canvas.click({ position: { x: 550, y: 200 } }); // Ship wheel
await page.waitForTimeout(1000);
await canvas.click({ position: { x: 250, y: 200 } }); // Hunting grounds
await page.waitForTimeout(1000);
await canvas.click({ position: { x: 400, y: 540 } }); // Continue
await page.waitForTimeout(1000);
});
test('should allow shooting harpoons', async ({ page }) => {
const canvas = page.locator('canvas');
// Click to shoot harpoon
await canvas.click({ position: { x: 400, y: 300 } });
await page.waitForTimeout(500);
// Game should still be running
await expect(canvas).toBeVisible();
});
test('should return to map from hunting scene', async ({ page }) => {
const canvas = page.locator('canvas');
// Click RETURN button (top right area)
await canvas.click({ position: { x: 750, y: 30 } });
await page.waitForTimeout(1000);
// Should be back at map
await expect(canvas).toBeVisible();
});
});
test.describe('Mobile Compatibility', () => {
test.use({
viewport: { width: 375, height: 667 },
isMobile: true,
});
test('should work on mobile viewport', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
const canvas = page.locator('canvas');
await expect(canvas).toBeVisible();
// Should be scaled to fit
const canvasBox = await canvas.boundingBox();
expect(canvasBox?.width).toBeLessThanOrEqual(375);
});
test('should handle touch interactions', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
const canvas = page.locator('canvas');
// Tap to start game
await canvas.tap({ position: { x: 200, y: 300 } });
await page.waitForTimeout(1000);
await expect(canvas).toBeVisible();
});
});
test.describe('Desktop Scaling', () => {
test.use({
viewport: { width: 1920, height: 1080 },
});
test('should scale properly on desktop', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);
const canvas = page.locator('canvas');
await expect(canvas).toBeVisible();
// Canvas should be visible and scaled
const canvasBox = await canvas.boundingBox();
expect(canvasBox).toBeTruthy();
expect(canvasBox?.width).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,179 @@
import { describe, it, expect } from 'vitest';
describe('Game Logic - Inventory Management', () => {
describe('Barrel Calculations', () => {
it('should calculate correct fuel barrel count', () => {
const fuel = 100;
const barrelCount = Math.ceil(fuel / 10);
expect(barrelCount).toBe(10);
});
it('should calculate correct oil barrel count', () => {
const whaleOil = 25;
const barrelCount = Math.ceil(whaleOil / 10);
expect(barrelCount).toBe(3);
});
it('should handle zero barrels', () => {
expect(Math.ceil(0 / 10)).toBe(0);
});
it('should handle partial barrels', () => {
expect(Math.ceil(15 / 10)).toBe(2);
expect(Math.ceil(1 / 10)).toBe(1);
});
});
describe('Inventory Limits', () => {
it('should enforce fuel max capacity of 100', () => {
const maxFuel = 100;
let currentFuel = 100;
// Try to add more fuel
currentFuel = Math.min(currentFuel + 10, maxFuel);
expect(currentFuel).toBe(100);
});
it('should enforce whale oil max capacity of 50', () => {
const maxOil = 50;
let currentOil = 50;
// Try to add more oil
currentOil = Math.min(currentOil + 5, maxOil);
expect(currentOil).toBe(50);
});
it('should enforce penguin max capacity of 20', () => {
const maxPenguins = 20;
let currentPenguins = 20;
// Try to add more penguins
currentPenguins = Math.min(currentPenguins + 3, maxPenguins);
expect(currentPenguins).toBe(20);
});
});
});
describe('Game Logic - Whale Hunting', () => {
describe('Fuel Consumption', () => {
it('should consume 2 fuel when processing a whale', () => {
let fuel = 100;
const fuelCost = 2;
fuel -= fuelCost;
expect(fuel).toBe(98);
});
it('should not process whale without enough fuel', () => {
let fuel = 1;
const requiredFuel = 2;
const canProcess = fuel >= requiredFuel;
expect(canProcess).toBe(false);
});
it('should process whale with exact fuel amount', () => {
let fuel = 2;
const requiredFuel = 2;
const canProcess = fuel >= requiredFuel;
expect(canProcess).toBe(true);
});
});
describe('Whale Oil Rewards', () => {
it('should gain 1 oil per whale killed', () => {
let whaleOil = 0;
whaleOil += 1;
expect(whaleOil).toBe(1);
});
it('should accumulate oil from multiple whales', () => {
let whaleOil = 5;
whaleOil += 1;
whaleOil += 1;
whaleOil += 1;
expect(whaleOil).toBe(8);
});
});
describe('Whale Health System', () => {
it('should require 3 hits to kill a whale', () => {
let health = 3;
health -= 1; // Hit 1
expect(health).toBe(2);
health -= 1; // Hit 2
expect(health).toBe(1);
health -= 1; // Hit 3
expect(health).toBe(0);
expect(health <= 0).toBe(true);
});
it('should check if whale is alive', () => {
let health = 2;
expect(health > 0).toBe(true);
health = 0;
expect(health > 0).toBe(false);
});
});
describe('Crosshair Bounds', () => {
it('should clamp crosshair X within 0-800', () => {
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
expect(clamp(-50, 0, 800)).toBe(0);
expect(clamp(900, 0, 800)).toBe(800);
expect(clamp(400, 0, 800)).toBe(400);
});
it('should clamp crosshair Y within 0-600', () => {
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
expect(clamp(-100, 0, 600)).toBe(0);
expect(clamp(700, 0, 600)).toBe(600);
expect(clamp(300, 0, 600)).toBe(300);
});
});
});
describe('Game Logic - Mobile Detection', () => {
it('should detect iPhone user agent', () => {
const mobileUA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)';
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(mobileUA);
expect(isMobile).toBe(true);
});
it('should detect Android user agent', () => {
const mobileUA = 'Mozilla/5.0 (Linux; Android 10; SM-G973F)';
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(mobileUA);
expect(isMobile).toBe(true);
});
it('should not detect desktop as mobile', () => {
const desktopUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0';
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(desktopUA);
expect(isMobile).toBe(false);
});
});
describe('Game Logic - Scene Transitions', () => {
it('should calculate fuel cost for destinations', () => {
const destinations = {
hunting: 0,
antarctic: 0,
port: 0
};
expect(destinations.hunting).toBe(0);
expect(destinations.antarctic).toBe(0);
expect(destinations.port).toBe(0);
});
it('should validate penguin discovery state', () => {
let penguins = 0;
let discovered = penguins > 0;
expect(discovered).toBe(false);
penguins = 5;
discovered = penguins > 0;
expect(discovered).toBe(true);
});
});

24
vitest.config.js Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
include: ['tests/unit/**/*.test.js'],
exclude: ['tests/e2e/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'dist/',
'*.config.js',
'tests/e2e/**'
]
}
},
server: {
host: '0.0.0.0',
port: 51204
}
});