diff --git a/STRATEGIC_AUDIT.md b/STRATEGIC_AUDIT.md index 8381b88a..72576226 100644 --- a/STRATEGIC_AUDIT.md +++ b/STRATEGIC_AUDIT.md @@ -698,3 +698,152 @@ Cross-cutting sprint pulling from S3, S4, S6, and S8 to establish product fundam - [x] Fix minimap dimensions for rectangular boards (gridWidth/gridHeight) - [x] Full test suite green (1697/1697) - [x] Push to PR + +--- + +## Red Team Critique — feat/hardening-iv (2026-03-07) + +### Finding 1: Silent Enemy Skip on Spawn Retry Limit (HIGH — FIXED) +**Location:** `wave.service.ts:154` +**Risk:** When enemy spawn fails 300 consecutive times, the enemy is silently discarded. If a map becomes unplayable mid-wave, enemies vanish from the queue without any signal to the player or logs for debugging. +**Fix:** Added `console.warn` when skipping an enemy after max retries. + +### Finding 2: Misplaced JSDoc Comment (LOW — FIXED) +**Location:** `game-board.component.ts:194-195` +**Risk:** `recordGameEndIfNeeded()` JSDoc was orphaned above `rebuildTowerChildrenCache()`. Misleading for maintainers. +**Fix:** Moved JSDoc to the correct method. + +### Finding 3: Misleading Template Comment (LOW — FIXED) +**Location:** `game-board.component.html:53` +**Risk:** Comment said "shown during SETUP and INTERMISSION" but template only shows during INTERMISSION. Intentional design (wave preview makes no sense before game starts) but comment contradicts behavior. +**Fix:** Updated comment to match actual behavior. + +### Verified NOT bugs: +- ReadonlySet for unlockedSet: correct TS pattern (prevents external mutation, internal reassignment is fine) +- BFS on mousemove: preview cache key gates the check — BFS only runs on tile change, not every move +- Mobile shadow cap + 3-point lighting: correctly applied to keyLight after merge resolution +- Raycasting cache invalidation: tileMeshArray rebuilt in renderGameBoard, towerChildrenArray rebuilt after place/sell + +## Deployment Checklist — feat/hardening-iv +- [x] Fix Finding 1: Add console.warn on spawn retry skip +- [x] Fix Finding 2: Move JSDoc to correct method +- [x] Fix Finding 3: Fix misleading template comment +- [x] Run full test suite — 1893/1893 green +- [x] Commit, push, update PR + +--- + +## Red Team Critique — feat/hardening-iv Pass 2 (2026-03-08) + +### Finding 1: saveMap() silently returns success on quota failure (MEDIUM) +**Location:** `map-storage.service.ts:62-71` +**Risk:** When localStorage quota is exceeded, `saveMap()` catches the error, logs a warning, but still returns a mapId and updates the metadata index. The caller (editor auto-save, manual save) sees a valid mapId and believes the save succeeded. The metadata entry points to a non-existent map — next load fails silently. The `importMap()` path was patched with verify-after-write (line 280), but direct saves were not. +**Fix:** Return `null` on save failure so callers can detect and handle the error (e.g., show a toast). + +### Finding 2: testCanvas WebGL context not released (LOW) +**Location:** `game-board.component.ts:813-814`, `novarise.component.ts:245-246` +**Risk:** Both `initializeRenderer()` methods create a temporary canvas + WebGL context for feature detection but never release the context. The canvas is a local variable (eligible for GC) but the WebGL context may persist until GC runs, consuming one of the browser's limited WebGL context slots (typically 8-16). +**Fix:** Call `WEBGL_lose_context` extension after the check to explicitly release the context. + +### Finding 3: Visual-overhaul magic numbers extracted (LOW — FIXED in prior commit) +**Location:** `game-board.component.ts:1595,1778,1801,1824,1834,1836` +**Risk:** Three `time * 0.001` patterns and three `* 0.5 + 0.5` patterns were bare numeric operations without named constants. Already fixed in commit 83c69c4 via `SKYBOX_CONFIG.timeScale` and `sinNormalized()`. + +### Verified NOT bugs: +- Trail disposal: `removeProjectileMesh()` properly disposes trail geometry + material; `cleanup()` calls it for all active projectiles +- Impact flash disposal: shared geometry disposed in `cleanup()`, per-flash materials disposed on expiry +- Chain arc zigzag `Math.random()`: non-deterministic but purely visual — does not affect game state or fixed timestep determinism +- Particle service: all new numeric literals (`emissiveIntensity`, `roughness`, `metalness`, `sizeVariation`, `scaleEnd`) sourced from `DEATH_BURST_CONFIG` +- Child components: all 6 are pure presentational (inputs + EventEmitters), no subscriptions to leak +- `updateStatusVisuals()` `return` in forEach: correctly exits callback for current enemy, does not skip reset for other enemies + +--- + +## Red Team Critique — feat/hardening-iv Pass 3 (2026-03-08) + +### Finding 1: File import has no size limit (MEDIUM — FIXED) +**Location:** `map-storage.service.ts:promptFileImport()` +**Risk:** `file.text()` loads entire file into memory with no size check. A user accidentally selecting a multi-GB file crashes the browser tab. +**Fix:** Added `MAX_IMPORT_FILE_SIZE` (10 MB) check before `file.text()`. + +### Finding 2: Achievements array unbounded on localStorage load (LOW — FIXED) +**Location:** `player-profile.service.ts:load()` +**Risk:** Parsed `achievements` array from localStorage is spread without length validation. Crafted data could contain thousands of entries. +**Fix:** `parsed.achievements.slice(0, ACHIEVEMENTS.length)` caps to defined achievement count. + +### Verified NOT bugs (Pass 3): +- Material casts (`as THREE.MeshStandardMaterial`) in animation loops: materials structurally guaranteed by creation code in GameBoardService — no code path creates mesh without MeshStandardMaterial +- `getEditorMapState()!` in restartGame: guarded by `hasEditorMap()` check on line before +- Spatial grid rebuild: correctly called after tower place/remove +- `startWave()` double-call: guarded by phase check in GameStateService +- restartGame() state reset: covers all fields including new ones from this branch +- `totalInvested` tracking: uses actual modifier-adjusted cost, not base config cost + +## Deployment Checklist — feat/hardening-iv (Final) +- [x] Verify full test suite green (1898/1898) +- [x] Verify no TypeScript compilation errors +- [x] Verify no uncommitted changes remain +- [x] Push branch and update PR #21 + +## Red Team Critique — 2026-03-08 (Raycasting/Minimap fixes) + +### Finding 1: Duplicated mobile breakpoint (LOW) +**Location:** `minimap.constants.ts:4` +**Risk:** `mobileBreakpoint: 480` duplicates `MOBILE_CONFIG.phoneBreakpoint` from `mobile.constants.ts`. If the phone breakpoint is ever changed, the minimap won't follow — two sources of truth for the same threshold. +**Fix:** Import and reference `MOBILE_CONFIG.phoneBreakpoint` instead of hardcoding `480`. + +### Finding 2: OrbitControls mouseButtons.LEFT = -1 type cast (LOW) +**Location:** `game-board.component.ts:1088` +**Risk:** `-1 as THREE.MOUSE` lies to the type system. The runtime behavior is correct (OrbitControls' switch falls to `default: state = NONE`), but a future Three.js update could change how unknown values are handled. The cast is fragile. +**Fix:** Use a named constant (`MOUSE_DISABLED = -1 as THREE.MOUSE`) in camera.constants.ts so the intent is documented and the cast is centralized. + +### Finding 3: Minimap size/position not responsive to orientation change (LOW) +**Location:** `minimap.service.ts:36` +**Risk:** `isMobile` is evaluated once at `init()` time. Device rotation (portrait→landscape) won't trigger recalculation. The minimap stays small and top-left even when landscape has room for the full desktop layout. +**Fix:** Not critical — restartGame() re-inits the minimap which picks up the new orientation. True fix would require a resize listener in the service, which is overengineering for this scope. + +### Verified NOT bugs: +- `updateMatrixWorld()` in editor touchstart/touchmove: defensive but harmless since editor disables orbit/pan on OrbitControls. No runtime cost concern (single matrix multiply per event). +- `updateMatrixWorld()` frequency in mousemove handler: negligible cost vs. the renderer's own per-frame matrix updates. + +## Deployment Checklist — 2026-03-08 (Raycasting/Minimap) +- [x] Fix raycasting after zoom (updateMatrixWorld + disable left-click orbit) +- [x] Fix minimap mobile overlap (shrink + reposition) +- [x] Red team hardening (single-source breakpoint, named constant for disabled mouse) +- [x] Full test suite green (1898/1898) +- [x] Push to remote + +## Red Team Critique — 2026-03-08 (Hardening IV Final Gate) + +### Finding 1: Dead MINIMAP_CONFIG mobile constants (MEDIUM) +**Location:** `constants/minimap.constants.ts:5-9` +**Risk:** `mobileCanvasSize`, `mobileBreakpoint`, `mobilePaddingTop`, `mobilePaddingLeft` are defined but never consumed. CSS media queries in `styles.css` now handle mobile sizing. Dead config implies mobile minimap is JS-driven when it isn't — misleads future devs. +**Fix:** Remove dead constants. CSS is the single source of truth for mobile minimap layout. + +### Finding 2: Dead MINI_SWARM_MESH_SEGMENTS import (LOW) +**Location:** `services/enemy.service.ts:3` +**Risk:** Imported from `enemy.model.ts` but never referenced in the service body. Suggests incomplete refactoring — mini-swarm meshes use hardcoded `OctahedronGeometry(size, 0)` detail level. +**Fix:** Remove from import statement. Keep the constant in the model for future use. + +### Finding 3: Minimap display value inconsistency (LOW) +**Location:** `services/minimap.service.ts:127,138` +**Risk:** `show()` sets `display = ''` (CSS default), `toggleVisibility()` sets `display = 'block'`. Inconsistent — if CSS class has `display: none` by default, `''` would revert to `none` while `'block'` would override correctly. +**Fix:** Normalize both to use `''` (let CSS class control default display). + +### Finding 4: Editor ngOnDestroy auto-save silent failure (MEDIUM) +**Location:** `novarise.component.ts:1842-1846` +**Risk:** `saveMap()` return value unchecked. If localStorage quota is exceeded during navigation-triggered auto-save, user silently loses unsaved editor changes with no notification. +**Fix:** Wrap in try-catch, log warning on failure. + +### Verified NOT bugs: +- StatusEffectService cleanup on restart: Already wired via `towerCombatService.cleanup()` → `statusEffectService.cleanup()` chain. +- WebGL context restore: Three.js handles most GPU state restoration internally. Full scene rebuild would be overengineering. +- Breakpoint inconsistency (768 vs 480): Intentional three-tier model (phone/tablet/desktop). + +## Deployment Checklist — 2026-03-08 (Hardening IV Final) +- [x] Remove dead MINIMAP_CONFIG mobile constants (CSS handles sizing) +- [x] Remove dead MINI_SWARM_MESH_SEGMENTS import from enemy.service +- [x] Normalize minimap display values (show/toggle consistency) +- [x] Add try-catch to editor ngOnDestroy auto-save +- [x] Full test suite green (2028/2028) +- [x] Push to remote diff --git a/angular.json b/angular.json index c224c33d..5703c4a1 100644 --- a/angular.json +++ b/angular.json @@ -24,7 +24,8 @@ "tsConfig": "tsconfig.app.json", "assets": [ "src/favicon.ico", - "src/assets" + "src/assets", + "src/manifest.webmanifest" ], "styles": [ "src/styles.css" diff --git a/src/app/app.component.css b/src/app/app.component.css index 38227141..721be00f 100644 --- a/src/app/app.component.css +++ b/src/app/app.component.css @@ -40,6 +40,13 @@ border-bottom-color: var(--theme-purple-light); } +#main-content { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + @media (max-width: 768px) { .app-nav a { padding: 6px 14px; diff --git a/src/app/app.component.html b/src/app/app.component.html index b4127fbf..8d1e94ee 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,6 +1,9 @@ + - +
+ +
diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7d87df99..eaedb2bb 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,8 +1,9 @@ -import { NgModule } from '@angular/core'; +import { ErrorHandler, NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; +import { GlobalErrorHandler } from './core/global-error-handler'; @NgModule({ declarations: [ @@ -12,6 +13,9 @@ import { AppComponent } from './app.component'; BrowserModule, AppRoutingModule ], + providers: [ + { provide: ErrorHandler, useClass: GlobalErrorHandler } + ], bootstrap: [AppComponent] }) export class AppModule { } diff --git a/src/app/core/global-error-handler.spec.ts b/src/app/core/global-error-handler.spec.ts new file mode 100644 index 00000000..3ac515a6 --- /dev/null +++ b/src/app/core/global-error-handler.spec.ts @@ -0,0 +1,52 @@ +import { ErrorHandler } from '@angular/core'; +import { GlobalErrorHandler } from './global-error-handler'; + +describe('GlobalErrorHandler', () => { + let handler: GlobalErrorHandler; + + beforeEach(() => { + handler = new GlobalErrorHandler(); + }); + + it('should implement ErrorHandler', () => { + const errorHandler: ErrorHandler = handler; + expect(errorHandler.handleError).toBeDefined(); + }); + + it('should log error message to console.error', () => { + spyOn(console, 'error'); + const error = new Error('test error'); + + handler.handleError(error); + + expect(console.error).toHaveBeenCalledWith('Unhandled error:', 'test error'); + }); + + it('should log stack trace when available', () => { + spyOn(console, 'error'); + const error = new Error('test error'); + + handler.handleError(error); + + expect(console.error).toHaveBeenCalledTimes(2); + expect(console.error).toHaveBeenCalledWith(error.stack); + }); + + it('should handle string errors', () => { + spyOn(console, 'error'); + + handler.handleError('string error'); + + expect(console.error).toHaveBeenCalledWith('Unhandled error:', 'string error'); + expect(console.error).toHaveBeenCalledTimes(1); + }); + + it('should handle number errors', () => { + spyOn(console, 'error'); + + handler.handleError(42); + + expect(console.error).toHaveBeenCalledWith('Unhandled error:', '42'); + expect(console.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/core/global-error-handler.ts b/src/app/core/global-error-handler.ts new file mode 100644 index 00000000..9aab1498 --- /dev/null +++ b/src/app/core/global-error-handler.ts @@ -0,0 +1,13 @@ +import { ErrorHandler, Injectable } from '@angular/core'; + +@Injectable() +export class GlobalErrorHandler implements ErrorHandler { + handleError(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.error('Unhandled error:', message); + if (stack) { + console.error(stack); + } + } +} diff --git a/src/app/game/game-board/components/game-controls/game-controls.component.html b/src/app/game/game-board/components/game-controls/game-controls.component.html new file mode 100644 index 00000000..3bc52d86 --- /dev/null +++ b/src/app/game/game-board/components/game-controls/game-controls.component.html @@ -0,0 +1,51 @@ + +
+ +
+ + +
+ +
+ +
+ + +
diff --git a/src/app/game/game-board/components/game-controls/game-controls.component.scss b/src/app/game/game-board/components/game-controls/game-controls.component.scss new file mode 100644 index 00000000..efa53f42 --- /dev/null +++ b/src/app/game/game-board/components/game-controls/game-controls.component.scss @@ -0,0 +1,178 @@ +@use '../../styles/game-mixins' as *; + +:host { + display: contents; +} + +// --- Wave control --- +.wave-control { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: var(--z-index-overlay); + text-align: center; + + .wave-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); + @include glass-panel(rgba(0, 0, 0, 0.85)); + padding: var(--spacing-md) var(--spacing-xl); + border: var(--border-width-medium) solid rgba(255, 255, 255, 0.3); + border-radius: var(--border-radius-lg); + color: var(--text-color); + font-size: var(--font-size-title); + cursor: pointer; + transition: all var(--transition-medium) ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.6); + transform: scale(1.05); + } + + &:active { + transform: scale(0.98); + } + + .wave-hint { + font-size: 0.5625rem; + color: rgba(255, 255, 255, 0.3); + } + } + + @media screen and (max-width: 768px) { + .wave-btn { + min-height: 2.75rem; // 44px + } + } + + @media screen and (max-width: 480px) { + .wave-btn { + min-height: 2.75rem; // 44px + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-size-base); + } + } +} + +// --- Pause / Speed controls --- +.game-controls { + @include glass-panel(rgba(0, 0, 0, 0.75)); + position: absolute; + bottom: var(--spacing-lg); + right: var(--spacing-lg); + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + border: var(--border-width-thin) solid var(--border-color); + + @media screen and (max-width: 768px) { + bottom: var(--spacing-md); + right: var(--spacing-md); + gap: var(--spacing-xs); + padding: var(--spacing-xs); + flex-direction: column; + align-items: stretch; + + .speed-controls { + justify-content: center; + } + } + + @media screen and (max-width: 480px) { + flex-direction: column; + align-items: stretch; + bottom: var(--spacing-sm); + right: var(--spacing-sm); + gap: var(--spacing-xs); + padding: var(--spacing-xs); + } + + .speed-controls { + display: flex; + gap: 0.25rem; + } + + .control-btn { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xs) var(--spacing-sm); + background: transparent; + border: var(--border-width-thin) solid var(--border-color); + border-radius: var(--border-radius-sm); + color: rgba(255, 255, 255, 0.6); + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-small); + cursor: pointer; + transition: all var(--transition-medium) ease; + min-width: 2.75rem; + min-height: 2.75rem; + line-height: 1; + + @media screen and (max-width: 768px) { + min-width: 1.875rem; + font-size: 0.625rem; + padding: var(--spacing-xs) 0.25rem; + } + + @media screen and (max-width: 480px) { + min-width: 2.75rem; // 44px tap target + min-height: 2.75rem; + padding: var(--spacing-xs) var(--spacing-sm); + font-size: 0.625rem; + justify-content: center; + } + + &:hover { + background: var(--theme-purple-a20); + border-color: var(--theme-purple-a80); + color: var(--theme-purple-lighter); + } + + &:active { + transform: scale(0.95); + } + + &.active { + background: var(--theme-purple-a30); + border-color: var(--theme-purple); + color: var(--theme-purple-lighter); + box-shadow: 0 0 var(--spacing-xs) var(--theme-purple-a50); + } + } + + .pause-btn { + min-width: 2.5rem; + line-height: 0; + + &.active { + background: rgba(255, 200, 0, 0.15); + border-color: rgba(255, 200, 0, 0.5); + color: var(--game-gold); + box-shadow: 0 0 var(--spacing-xs) rgba(255, 200, 0, 0.3); + } + + @media screen and (max-width: 480px) { + min-width: 2.75rem; + min-height: 2.75rem; + line-height: 0; + } + } + + .path-btn.active { + background: rgba(0, 255, 136, 0.15); + border-color: rgba(0, 255, 136, 0.5); + color: #88ffbb; + box-shadow: 0 0 var(--spacing-xs) rgba(0, 255, 136, 0.3); + } + + .wave-hint { + font-size: 0.5625rem; + color: rgba(255, 255, 255, 0.3); + } +} diff --git a/src/app/game/game-board/components/game-controls/game-controls.component.spec.ts b/src/app/game/game-board/components/game-controls/game-controls.component.spec.ts new file mode 100644 index 00000000..6398894d --- /dev/null +++ b/src/app/game/game-board/components/game-controls/game-controls.component.spec.ts @@ -0,0 +1,132 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { GameControlsComponent } from './game-controls.component'; +import { GamePhase } from '../../models/game-state.model'; + +describe('GameControlsComponent', () => { + let component: GameControlsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GameControlsComponent], + imports: [CommonModule] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(GameControlsComponent); + component = fixture.componentInstance; + component.phase = GamePhase.INTERMISSION; + component.isPaused = false; + component.gameSpeed = 1; + component.showAllRanges = false; + component.showPathOverlay = false; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('shows Next Wave button when not in COMBAT phase', () => { + component.phase = GamePhase.INTERMISSION; + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('.wave-btn'); + expect(btn).toBeTruthy(); + expect(btn.textContent).toContain('Next Wave'); + }); + + it('hides Next Wave button during COMBAT phase', () => { + component.phase = GamePhase.COMBAT; + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('.wave-btn'); + expect(btn).toBeNull(); + }); + + it('emits startWave when Next Wave button is clicked', () => { + component.phase = GamePhase.INTERMISSION; + fixture.detectChanges(); + spyOn(component.startWave, 'emit'); + + const btn = fixture.nativeElement.querySelector('.wave-btn'); + btn.click(); + + expect(component.startWave.emit).toHaveBeenCalled(); + }); + + it('emits togglePause when pause button is clicked', () => { + fixture.detectChanges(); + spyOn(component.togglePause, 'emit'); + + const btn = fixture.nativeElement.querySelector('.pause-btn'); + btn.click(); + + expect(component.togglePause.emit).toHaveBeenCalled(); + }); + + it('pause button has active class when isPaused is true', () => { + component.isPaused = true; + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('.pause-btn'); + expect(btn.classList.contains('active')).toBeTrue(); + }); + + it('emits setSpeed when a speed button is clicked', () => { + fixture.detectChanges(); + spyOn(component.setSpeed, 'emit'); + + const speedBtns = fixture.nativeElement.querySelectorAll('.speed-btn'); + speedBtns[1].click(); // 2x + + expect(component.setSpeed.emit).toHaveBeenCalledWith(2); + }); + + it('marks the active speed button with active class', () => { + component.gameSpeed = 2; + fixture.detectChanges(); + const speedBtns = fixture.nativeElement.querySelectorAll('.speed-btn'); + expect(speedBtns[0].classList.contains('active')).toBeFalse(); + expect(speedBtns[1].classList.contains('active')).toBeTrue(); + expect(speedBtns[2].classList.contains('active')).toBeFalse(); + }); + + it('emits toggleAllRanges when range button is clicked', () => { + fixture.detectChanges(); + spyOn(component.toggleAllRanges, 'emit'); + + const btn = fixture.nativeElement.querySelector('.range-btn'); + btn.click(); + + expect(component.toggleAllRanges.emit).toHaveBeenCalled(); + }); + + it('range button has active class when showAllRanges is true', () => { + component.showAllRanges = true; + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('.range-btn'); + expect(btn.classList.contains('active')).toBeTrue(); + }); + + it('emits togglePathOverlay when path button is clicked', () => { + fixture.detectChanges(); + spyOn(component.togglePathOverlay, 'emit'); + + const btn = fixture.nativeElement.querySelector('.path-btn'); + btn.click(); + + expect(component.togglePathOverlay.emit).toHaveBeenCalled(); + }); + + it('path button has active class when showPathOverlay is true', () => { + component.showPathOverlay = true; + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('.path-btn'); + expect(btn.classList.contains('active')).toBeTrue(); + }); + + it('renders 3 speed buttons', () => { + fixture.detectChanges(); + const speedBtns = fixture.nativeElement.querySelectorAll('.speed-btn'); + expect(speedBtns.length).toBe(3); + }); +}); diff --git a/src/app/game/game-board/components/game-controls/game-controls.component.ts b/src/app/game/game-board/components/game-controls/game-controls.component.ts new file mode 100644 index 00000000..1832019e --- /dev/null +++ b/src/app/game/game-board/components/game-controls/game-controls.component.ts @@ -0,0 +1,24 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { GamePhase } from '../../models/game-state.model'; + +@Component({ + selector: 'app-game-controls', + templateUrl: './game-controls.component.html', + styleUrls: ['./game-controls.component.scss'] +}) +export class GameControlsComponent { + @Input() phase!: GamePhase; + @Input() isPaused = false; + @Input() gameSpeed = 1; + @Input() showAllRanges = false; + @Input() showPathOverlay = false; + + @Output() startWave = new EventEmitter(); + @Output() togglePause = new EventEmitter(); + @Output() setSpeed = new EventEmitter(); + @Output() toggleAllRanges = new EventEmitter(); + @Output() togglePathOverlay = new EventEmitter(); + + readonly GamePhase = GamePhase; + readonly speeds = [1, 2, 3]; +} diff --git a/src/app/game/game-board/components/game-hud/game-hud.component.html b/src/app/game/game-board/components/game-hud/game-hud.component.html new file mode 100644 index 00000000..3d482abb --- /dev/null +++ b/src/app/game/game-board/components/game-hud/game-hud.component.html @@ -0,0 +1,34 @@ + +
+
+ Lives + {{ gameState.lives }} +
+
+ Gold + {{ gameState.gold }} +
+
+ Wave + {{ gameState.wave }} / {{ gameState.maxWaves }} + {{ gameState.wave }} +
+
+ Score + {{ gameState.score }} +
+
+ Time + {{ formattedTime }} +
+
+ + +
+ {{ enemiesAlive + enemiesToSpawn }} enemies remaining +
+ + +
+ Placement blocked — enemies need a path to the exit +
diff --git a/src/app/game/game-board/components/game-hud/game-hud.component.scss b/src/app/game/game-board/components/game-hud/game-hud.component.scss new file mode 100644 index 00000000..8514e2a9 --- /dev/null +++ b/src/app/game/game-board/components/game-hud/game-hud.component.scss @@ -0,0 +1,145 @@ +@use '../../styles/game-mixins' as *; + +// --- Game HUD --- +.game-hud { + @include glass-panel; + position: absolute; + top: var(--spacing-md); + left: 50%; + transform: translateX(-50%); + display: flex; + gap: var(--spacing-lg); + padding: var(--spacing-sm) var(--spacing-lg); + border: var(--border-width-thin) solid var(--border-color); + user-select: none; + + @media screen and (max-width: 768px) { + gap: var(--spacing-md); + padding: var(--spacing-xs) var(--spacing-md); + font-size: var(--font-size-small); + } + + .hud-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.125rem; + + &.secondary { + opacity: 0.7; + + .hud-value { + font-size: var(--font-size-small); + } + } + + .hud-label { + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.0625rem; + color: rgba(255, 255, 255, 0.5); + } + + .hud-value { + font-size: var(--font-size-base); + font-weight: 700; + color: var(--text-color); + + &.lives { + color: var(--game-health); + + &.critical { + animation: pulse-critical 0.5s ease-in-out infinite alternate; + } + } + + &.gold { + color: var(--game-gold); + } + } + } +} + +.hud-wave-status { + --hud-height: 3.5rem; + + position: absolute; + top: calc(var(--spacing-md) + var(--hud-height)); + left: 50%; + transform: translateX(-50%); + padding: 0.25rem var(--spacing-md); + background: rgba(0, 0, 0, 0.4); + border-radius: var(--border-radius-md); + color: rgba(255, 136, 136, 0.8); + font-family: "Orbitron", sans-serif; + font-size: 0.7rem; + white-space: nowrap; + z-index: var(--z-index-overlay); + + .wave-enemy-count { + font-weight: 700; + color: var(--game-wave-text); + } +} + +.path-blocked-warning { + position: absolute; + top: calc(var(--spacing-md) + 5rem); + left: 50%; + transform: translateX(-50%); + padding: 0.4rem var(--spacing-lg); + background: rgba(180, 40, 40, 0.85); + border: 1px solid rgba(255, 80, 80, 0.6); + border-radius: var(--border-radius-md); + color: #fff; + font-family: "Orbitron", sans-serif; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; + z-index: var(--z-index-overlay); + animation: path-blocked-fade-in 0.15s ease-out; + pointer-events: none; +} + +@keyframes path-blocked-fade-in { + from { opacity: 0; transform: translateX(-50%) translateY(-4px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +@keyframes pulse-critical { + from { opacity: 1; } + to { opacity: 0.5; } +} + +// --- Responsive: Tablet (768px) --- +@media screen and (max-width: 768px) { + .game-hud .hud-stat { + .hud-label { + font-size: 0.5625rem; + } + + .hud-value { + font-size: var(--font-size-small); + } + } +} + +// --- Responsive: Mobile (480px) --- +@media screen and (max-width: 480px) { + .game-hud { + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + font-size: 0.625rem; + + .hud-stat { + .hud-label { + font-size: 0.5rem; + letter-spacing: 0; + } + + .hud-value { + font-size: 0.6875rem; + } + } + } +} diff --git a/src/app/game/game-board/components/game-hud/game-hud.component.spec.ts b/src/app/game/game-board/components/game-hud/game-hud.component.spec.ts new file mode 100644 index 00000000..2ea1bcfd --- /dev/null +++ b/src/app/game/game-board/components/game-hud/game-hud.component.spec.ts @@ -0,0 +1,131 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { GameHUDComponent } from './game-hud.component'; +import { GamePhase, INITIAL_GAME_STATE, GameState } from '../../models/game-state.model'; + +function makeState(overrides: Partial = {}): GameState { + return { ...INITIAL_GAME_STATE, activeModifiers: new Set(), ...overrides }; +} + +describe('GameHUDComponent', () => { + let component: GameHUDComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GameHUDComponent], + imports: [CommonModule] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(GameHUDComponent); + component = fixture.componentInstance; + component.gameState = makeState(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('HUD hidden when wave is 0', () => { + component.gameState = makeState({ wave: 0 }); + fixture.detectChanges(); + const hud = fixture.nativeElement.querySelector('.game-hud'); + expect(hud).toBeNull(); + }); + + it('HUD shows when wave > 0', () => { + component.gameState = makeState({ wave: 1 }); + fixture.detectChanges(); + const hud = fixture.nativeElement.querySelector('.game-hud'); + expect(hud).toBeTruthy(); + }); + + it('Lives shows critical class when <= 5', () => { + component.gameState = makeState({ wave: 1, lives: 3 }); + fixture.detectChanges(); + const livesEl = fixture.nativeElement.querySelector('.hud-value.lives'); + expect(livesEl.classList.contains('critical')).toBeTrue(); + }); + + it('Lives does not show critical class when > 5', () => { + component.gameState = makeState({ wave: 1, lives: 10 }); + fixture.detectChanges(); + const livesEl = fixture.nativeElement.querySelector('.hud-value.lives'); + expect(livesEl.classList.contains('critical')).toBeFalse(); + }); + + it('Wave status shows during COMBAT phase', () => { + component.gameState = makeState({ wave: 1, phase: GamePhase.COMBAT }); + fixture.detectChanges(); + const status = fixture.nativeElement.querySelector('.hud-wave-status'); + expect(status).toBeTruthy(); + }); + + it('Wave status hidden during non-COMBAT phase', () => { + component.gameState = makeState({ wave: 1, phase: GamePhase.INTERMISSION }); + fixture.detectChanges(); + const status = fixture.nativeElement.querySelector('.hud-wave-status'); + expect(status).toBeNull(); + }); + + it('Path blocked warning shows when pathBlocked is true', () => { + component.pathBlocked = true; + fixture.detectChanges(); + const warning = fixture.nativeElement.querySelector('.path-blocked-warning'); + expect(warning).toBeTruthy(); + }); + + it('Path blocked warning hides when pathBlocked is false', () => { + component.pathBlocked = false; + fixture.detectChanges(); + const warning = fixture.nativeElement.querySelector('.path-blocked-warning'); + expect(warning).toBeNull(); + }); + + it('Displays correct formattedTime', () => { + component.gameState = makeState({ wave: 1 }); + component.formattedTime = '05:23'; + fixture.detectChanges(); + const timeEl = fixture.nativeElement.querySelectorAll('.hud-value'); + const timeText = Array.from(timeEl as NodeListOf) + .map(el => el.textContent?.trim()) + .find(t => t === '05:23'); + expect(timeText).toBe('05:23'); + }); + + it('Displays enemy count (enemiesAlive + enemiesToSpawn)', () => { + component.gameState = makeState({ wave: 1, phase: GamePhase.COMBAT }); + component.enemiesAlive = 5; + component.enemiesToSpawn = 3; + fixture.detectChanges(); + const countEl = fixture.nativeElement.querySelector('.wave-enemy-count'); + expect(countEl.textContent?.trim()).toBe('8'); + }); + + describe('ARIA accessibility attributes', () => { + beforeEach(() => { + component.gameState = makeState({ wave: 1 }); + fixture.detectChanges(); + }); + + it('lives element has aria-live="polite"', () => { + const stats = fixture.nativeElement.querySelectorAll('.hud-stat'); + const livesContainer = stats[0] as HTMLElement; + expect(livesContainer.getAttribute('aria-live')).toBe('polite'); + }); + + it('gold element has aria-live="polite"', () => { + const stats = fixture.nativeElement.querySelectorAll('.hud-stat'); + const goldContainer = stats[1] as HTMLElement; + expect(goldContainer.getAttribute('aria-live')).toBe('polite'); + }); + + it('wave element has role="status"', () => { + const stats = fixture.nativeElement.querySelectorAll('.hud-stat'); + const waveContainer = stats[2] as HTMLElement; + expect(waveContainer.getAttribute('role')).toBe('status'); + }); + }); +}); diff --git a/src/app/game/game-board/components/game-hud/game-hud.component.ts b/src/app/game/game-board/components/game-hud/game-hud.component.ts new file mode 100644 index 00000000..725c2c48 --- /dev/null +++ b/src/app/game/game-board/components/game-hud/game-hud.component.ts @@ -0,0 +1,17 @@ +import { Component, Input } from '@angular/core'; +import { GamePhase, GameState } from '../../models/game-state.model'; + +@Component({ + selector: 'app-game-hud', + templateUrl: './game-hud.component.html', + styleUrls: ['./game-hud.component.scss'] +}) +export class GameHUDComponent { + @Input() gameState!: GameState; + @Input() formattedTime = '00:00'; + @Input() enemiesAlive = 0; + @Input() enemiesToSpawn = 0; + @Input() pathBlocked = false; + + readonly GamePhase = GamePhase; +} diff --git a/src/app/game/game-board/components/game-results/game-results.component.html b/src/app/game/game-board/components/game-results/game-results.component.html new file mode 100644 index 00000000..7d59f59d --- /dev/null +++ b/src/app/game/game-board/components/game-results/game-results.component.html @@ -0,0 +1,68 @@ + +
+
+

Victory

+

Defeat

+ + +
+ + ★ + +
+ + +
+
+ Base Score + {{ scoreBreakdown.baseScore }} +
+
+ Difficulty + x{{ scoreBreakdown.difficultyMultiplier }} +
+
+ Modifiers + x{{ scoreBreakdown.modifierMultiplier.toFixed(1) }} +
+
+ Waves Completed + {{ scoreBreakdown.wavesCompleted }} / {{ gameState.maxWaves }} +
+
+ Lives Remaining + {{ scoreBreakdown.livesRemaining }} / {{ scoreBreakdown.livesTotal }} +
+
+ Final Score + {{ scoreBreakdown.finalScore }} +
+
+ +

Score: {{ gameState.score }}

+ + +
+

Achievements Unlocked

+
+
+ 🏆 +
+ {{ ach.name }} + {{ ach.description }} +
+
+
+
+ +
+ + +
+
+
diff --git a/src/app/game/game-board/components/game-results/game-results.component.scss b/src/app/game/game-board/components/game-results/game-results.component.scss new file mode 100644 index 00000000..57a69072 --- /dev/null +++ b/src/app/game/game-board/components/game-results/game-results.component.scss @@ -0,0 +1,290 @@ +@use '../../styles/game-mixins' as *; + +// --- Victory / Defeat overlay --- +.game-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.7); + z-index: var(--z-index-modal); + animation: fade-in 0.5s ease; + + .overlay-content { + text-align: center; + padding: var(--spacing-2xl); + border-radius: var(--border-radius-lg); + backdrop-filter: blur(1rem); + animation: scale-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + + &.victory { + background: rgba(0, 100, 0, 0.3); + border: var(--border-width-medium) solid rgba(0, 255, 0, 0.4); + box-shadow: 0 0 2.5rem rgba(0, 255, 0, 0.15); + + h2 { color: var(--game-victory); } + } + + &.defeat { + background: rgba(100, 0, 0, 0.3); + border: var(--border-width-medium) solid rgba(255, 0, 0, 0.4); + box-shadow: 0 0 2.5rem rgba(255, 0, 0, 0.15); + + h2 { color: var(--game-defeat); } + } + + h2 { + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-hero); + margin-bottom: var(--spacing-md); + text-transform: uppercase; + letter-spacing: 0.25rem; + } + + .final-score { + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-large); + color: var(--game-gold); + margin-bottom: var(--spacing-lg); + } + + // --- Star rating --- + .star-display { + display: flex; + justify-content: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-lg); + + .overlay-star { + font-size: 2.5rem; + line-height: 1; + transition: transform 0.2s ease; + + &.filled { + color: var(--game-star-filled); + text-shadow: 0 0 0.75rem rgba(255, 215, 0, 0.6); + } + + &.empty { + color: var(--game-star-empty); + } + } + } + + // --- Score breakdown --- + .score-breakdown { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-lg); + min-width: 18rem; + text-align: left; + + @media screen and (max-width: 768px) { + min-width: 14rem; + } + + .score-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-xs) 0; + border-bottom: var(--border-width-thin) solid rgba(255, 255, 255, 0.08); + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-small); + + &.final { + border-bottom: none; + border-top: var(--border-width-medium) solid rgba(255, 255, 255, 0.2); + margin-top: var(--spacing-xs); + padding-top: var(--spacing-sm); + } + + .score-label { + color: rgba(255, 255, 255, 0.55); + text-transform: uppercase; + letter-spacing: 0.0313rem; + } + + .score-value { + color: var(--text-color); + font-weight: 600; + + &.multiplier { + color: var(--score-multiplier); + } + + &.final-value { + color: var(--game-gold); + font-size: var(--font-size-large); + font-weight: 700; + } + } + } + } + + // --- Achievements unlocked --- + .achievements-unlocked { + margin-bottom: var(--spacing-lg); + + .achievements-title { + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-small); + color: var(--game-gold); + text-transform: uppercase; + letter-spacing: 0.0625rem; + margin-bottom: var(--spacing-sm); + } + + .achievement-list { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + } + + .achievement-badge { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + background: rgba(255, 215, 0, 0.08); + border: var(--border-width-thin) solid rgba(255, 215, 0, 0.25); + border-radius: var(--border-radius-sm); + animation: scale-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + + .achievement-icon { + font-size: 1.25rem; + } + + .achievement-info { + display: flex; + flex-direction: column; + text-align: left; + } + + .achievement-name { + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-small); + color: var(--game-gold); + font-weight: 600; + } + + .achievement-desc { + font-size: var(--font-size-tiny); + color: rgba(255, 255, 255, 0.5); + } + } + } + + .overlay-actions { + display: flex; + gap: var(--spacing-md); + justify-content: center; + } + + .restart-btn, .edit-map-btn { + padding: var(--spacing-sm) var(--spacing-xl); + background: var(--theme-purple-a20); + border: var(--border-width-medium) solid var(--theme-purple-a50); + border-radius: var(--border-radius-md); + color: var(--text-color); + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-base); + cursor: pointer; + transition: all var(--transition-medium) ease; + + &:hover { + background: var(--theme-purple-a30); + border-color: var(--theme-purple-a80); + } + } + + .edit-map-btn { + border-color: var(--theme-purple-a50); + color: var(--theme-purple-mid); + + &:hover { + background: var(--theme-purple-a20); + border-color: var(--theme-purple-a80); + color: var(--theme-purple-lighter); + } + } + } +} + +// --- Animations --- +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes scale-in { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} + +// --- Responsive: Tablet (768px) --- +@media screen and (max-width: 768px) { + .game-overlay .overlay-content { + padding: var(--spacing-xl); + width: 90vw; + + h2 { + font-size: var(--font-size-title); + letter-spacing: var(--spacing-xs); + } + + .final-score { + font-size: var(--font-size-base); + margin-bottom: var(--spacing-md); + } + } +} + +// --- Responsive: Mobile (480px) --- +@media screen and (max-width: 480px) { + .game-overlay { + align-items: flex-end; + + .overlay-content { + width: 100%; + max-height: 85vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: var(--spacing-xl) var(--spacing-md); + border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; + border-left: none; + border-right: none; + border-bottom: none; + + h2 { + font-size: var(--font-size-large); + letter-spacing: var(--spacing-xs); + margin-bottom: var(--spacing-sm); + } + + .final-score { + font-size: var(--font-size-base); + margin-bottom: var(--spacing-md); + } + + .overlay-actions { + flex-direction: column; + gap: var(--spacing-sm); + + .restart-btn, .edit-map-btn { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + min-height: 2.75rem; // 44px tap target + } + } + } + } +} diff --git a/src/app/game/game-board/components/game-results/game-results.component.spec.ts b/src/app/game/game-board/components/game-results/game-results.component.spec.ts new file mode 100644 index 00000000..b3c1c80e --- /dev/null +++ b/src/app/game/game-board/components/game-results/game-results.component.spec.ts @@ -0,0 +1,125 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { GameResultsComponent } from './game-results.component'; +import { GamePhase, INITIAL_GAME_STATE, GameState } from '../../models/game-state.model'; +import { ScoreBreakdown } from '../../models/score.model'; +import { Achievement } from '../../services/player-profile.service'; + +function makeState(overrides: Partial = {}): GameState { + return { ...INITIAL_GAME_STATE, activeModifiers: new Set(), ...overrides }; +} + +describe('GameResultsComponent', () => { + let component: GameResultsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GameResultsComponent], + imports: [CommonModule] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(GameResultsComponent); + component = fixture.componentInstance; + component.gameState = makeState({ phase: GamePhase.VICTORY }); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('Shows "Victory" when phase is VICTORY', () => { + component.gameState = makeState({ phase: GamePhase.VICTORY }); + fixture.detectChanges(); + const h2 = fixture.nativeElement.querySelector('h2'); + expect(h2.textContent.trim()).toBe('Victory'); + }); + + it('Shows "Defeat" when phase is DEFEAT', () => { + component.gameState = makeState({ phase: GamePhase.DEFEAT }); + fixture.detectChanges(); + const h2 = fixture.nativeElement.querySelector('h2'); + expect(h2.textContent.trim()).toBe('Defeat'); + }); + + it('Displays star rating correctly (3 filled, 0 empty)', () => { + component.gameState = makeState({ phase: GamePhase.VICTORY }); + component.starArray = ['filled', 'filled', 'filled']; + fixture.detectChanges(); + const stars = fixture.nativeElement.querySelectorAll('.overlay-star.filled'); + expect(stars.length).toBe(3); + const emptyStars = fixture.nativeElement.querySelectorAll('.overlay-star.empty'); + expect(emptyStars.length).toBe(0); + }); + + it('Displays star rating correctly (2 filled, 1 empty)', () => { + component.gameState = makeState({ phase: GamePhase.VICTORY }); + component.starArray = ['filled', 'filled', 'empty']; + fixture.detectChanges(); + const filled = fixture.nativeElement.querySelectorAll('.overlay-star.filled'); + const empty = fixture.nativeElement.querySelectorAll('.overlay-star.empty'); + expect(filled.length).toBe(2); + expect(empty.length).toBe(1); + }); + + it('Displays score breakdown fields', () => { + component.gameState = makeState({ phase: GamePhase.VICTORY, maxWaves: 10 }); + component.scoreBreakdown = { + baseScore: 500, + difficultyMultiplier: 1.0, + modifierMultiplier: 1.0, + wavesCompleted: 10, + livesRemaining: 18, + livesTotal: 20, + livesPercent: 0.9, + finalScore: 500, + stars: 3, + isVictory: true, + difficulty: 'normal' as any + }; + fixture.detectChanges(); + const scoreRows = fixture.nativeElement.querySelectorAll('.score-row'); + expect(scoreRows.length).toBeGreaterThanOrEqual(4); + const finalValue = fixture.nativeElement.querySelector('.final-value'); + expect(finalValue.textContent.trim()).toBe('500'); + }); + + it('Emits restart event on Play Again click', () => { + fixture.detectChanges(); + spyOn(component.restart, 'emit'); + const btn = fixture.nativeElement.querySelector('.restart-btn'); + btn.click(); + expect(component.restart.emit).toHaveBeenCalled(); + }); + + it('Emits goToEditor event on Edit Map click', () => { + fixture.detectChanges(); + spyOn(component.goToEditor, 'emit'); + const btn = fixture.nativeElement.querySelector('.edit-map-btn'); + btn.click(); + expect(component.goToEditor.emit).toHaveBeenCalled(); + }); + + it('Shows achievements when newlyUnlockedAchievements is non-empty', () => { + component.gameState = makeState({ phase: GamePhase.VICTORY }); + component.newlyUnlockedAchievements = ['first_blood']; + component.achievementDetails = [ + { id: 'first_blood', name: 'First Blood', description: 'Kill your first enemy', condition: () => true } as Achievement + ]; + fixture.detectChanges(); + const achievementsSection = fixture.nativeElement.querySelector('.achievements-unlocked'); + expect(achievementsSection).toBeTruthy(); + const badge = fixture.nativeElement.querySelector('.achievement-badge'); + expect(badge).toBeTruthy(); + }); + + it('Hides achievements section when empty', () => { + component.gameState = makeState({ phase: GamePhase.VICTORY }); + component.newlyUnlockedAchievements = []; + fixture.detectChanges(); + const achievementsSection = fixture.nativeElement.querySelector('.achievements-unlocked'); + expect(achievementsSection).toBeNull(); + }); +}); diff --git a/src/app/game/game-board/components/game-results/game-results.component.ts b/src/app/game/game-board/components/game-results/game-results.component.ts new file mode 100644 index 00000000..f097478c --- /dev/null +++ b/src/app/game/game-board/components/game-results/game-results.component.ts @@ -0,0 +1,22 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { GamePhase, GameState } from '../../models/game-state.model'; +import { ScoreBreakdown } from '../../models/score.model'; +import { Achievement } from '../../services/player-profile.service'; + +@Component({ + selector: 'app-game-results', + templateUrl: './game-results.component.html', + styleUrls: ['./game-results.component.scss'] +}) +export class GameResultsComponent { + @Input() gameState!: GameState; + @Input() scoreBreakdown: ScoreBreakdown | null = null; + @Input() starArray: Array<'filled' | 'empty'> = []; + @Input() newlyUnlockedAchievements: string[] = []; + @Input() achievementDetails: Achievement[] = []; + + @Output() restart = new EventEmitter(); + @Output() goToEditor = new EventEmitter(); + + readonly GamePhase = GamePhase; +} diff --git a/src/app/game/game-board/components/game-setup/game-setup.component.html b/src/app/game/game-board/components/game-setup/game-setup.component.html new file mode 100644 index 00000000..57380297 --- /dev/null +++ b/src/app/game/game-board/components/game-setup/game-setup.component.html @@ -0,0 +1,40 @@ +
+ +
+ +
+ + + + +
+ +
+
+ Score: {{ modifierScoreMultiplier.toFixed(1) }}x +
+ + +
diff --git a/src/app/game/game-board/components/game-setup/game-setup.component.scss b/src/app/game/game-board/components/game-setup/game-setup.component.scss new file mode 100644 index 00000000..67f5c7cb --- /dev/null +++ b/src/app/game/game-board/components/game-setup/game-setup.component.scss @@ -0,0 +1,189 @@ +@use '../../styles/game-mixins' as *; + +:host { + display: block; +} + +.setup-panel { + @include glass-panel(rgba(0, 0, 0, 0.85)); + position: absolute; + top: var(--spacing-md); + right: var(--spacing-md); + width: min(18rem, 35vw); + max-height: calc(100vh - var(--spacing-xl)); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + padding: var(--spacing-md); + border: var(--border-width-thin) solid var(--theme-purple-a50); + animation: scale-in 0.2s ease-out; +} + +.setup-section-label { + font-size: var(--font-size-2xs); + text-transform: uppercase; + letter-spacing: 0.1rem; + color: var(--text-dim); + font-weight: 600; +} + +.setup-difficulty-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-xs); +} + +.setup-diff-btn { + padding: var(--spacing-xs) var(--spacing-sm); + background: transparent; + border: var(--border-width-thin) solid var(--border-color); + border-radius: var(--border-radius-sm); + color: var(--text-color); + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-xs); + font-weight: 700; + text-transform: uppercase; + cursor: pointer; + transition: all var(--transition-fast) ease; + min-height: 2.75rem; + + &:hover { + background: var(--theme-purple-a20); + border-color: var(--theme-purple-a80); + } + + &.selected { + background: var(--theme-purple-a30); + border-color: var(--theme-purple); + color: var(--theme-purple-lighter); + box-shadow: 0 0 var(--spacing-xs) var(--theme-purple-a50); + } +} + +.setup-endless { + display: flex; + align-items: center; + gap: var(--spacing-xs); + cursor: pointer; + font-size: var(--font-size-small); + color: var(--text-color); + font-family: "Orbitron", sans-serif; + padding: var(--spacing-xs) 0; + border-top: var(--border-width-thin) solid var(--border-color); + + input[type="checkbox"] { + accent-color: var(--theme-purple); + width: 1rem; + height: 1rem; + cursor: pointer; + } +} + +.setup-modifier-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-xs); +} + +.setup-mod-btn { + padding: var(--spacing-xs); + background: transparent; + border: var(--border-width-thin) solid var(--border-color); + border-radius: var(--border-radius-sm); + color: var(--text-color); + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-3xs); + font-weight: 600; + text-transform: uppercase; + cursor: pointer; + transition: all var(--transition-fast) ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-height: 2.75rem; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: var(--theme-purple-a20); + border-color: var(--theme-purple-a80); + } + + &.active { + background: var(--theme-purple-a30); + border-color: var(--theme-purple); + color: var(--theme-purple-lighter); + } +} + +.setup-score { + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-small); + font-weight: 700; + color: var(--theme-purple-lighter); + text-align: center; +} + +.setup-start-btn { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + border: var(--border-width-medium) solid var(--action-success-border); + border-radius: var(--border-radius-md); + background: var(--action-success-bg); + color: var(--action-success); + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-small); + font-weight: 700; + letter-spacing: 0.05em; + cursor: pointer; + transition: all var(--transition-fast) ease; + + &:hover { + background: rgba(0, 180, 80, 0.35); + box-shadow: 0 0 var(--spacing-sm) rgba(0, 255, 100, 0.25); + } +} + +// --- Responsive breakpoints --- + +@media screen and (max-width: 768px) { + .setup-panel { + position: fixed; + top: auto; + bottom: 0; + left: 0; + right: 0; + width: 100%; + max-height: 55vh; + border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; + padding: var(--spacing-sm) var(--spacing-md); + } + + .setup-difficulty-grid { + grid-template-columns: repeat(4, 1fr); + } + + .setup-modifier-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media screen and (max-width: 480px) { + .setup-panel { + max-height: 45vh; + } +} + +// --- Animations --- +@keyframes scale-in { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/src/app/game/game-board/components/game-setup/game-setup.component.spec.ts b/src/app/game/game-board/components/game-setup/game-setup.component.spec.ts new file mode 100644 index 00000000..59835662 --- /dev/null +++ b/src/app/game/game-board/components/game-setup/game-setup.component.spec.ts @@ -0,0 +1,96 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { GameSetupComponent } from './game-setup.component'; +import { DifficultyLevel, INITIAL_GAME_STATE, GameState } from '../../models/game-state.model'; +import { GameModifier, GAME_MODIFIER_CONFIGS } from '../../models/game-modifier.model'; + +function makeState(overrides: Partial = {}): GameState { + return { ...INITIAL_GAME_STATE, activeModifiers: new Set(), ...overrides }; +} + +describe('GameSetupComponent', () => { + let component: GameSetupComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GameSetupComponent], + imports: [CommonModule] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(GameSetupComponent); + component = fixture.componentInstance; + component.gameState = makeState(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('displays difficulty buttons', () => { + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll('.setup-diff-btn'); + expect(buttons.length).toBe(4); + }); + + it('emits selectDifficulty when difficulty button clicked', () => { + fixture.detectChanges(); + spyOn(component.selectDifficulty, 'emit'); + + const buttons = fixture.nativeElement.querySelectorAll('.setup-diff-btn'); + buttons[2].click(); // HARD is the 3rd button + + expect(component.selectDifficulty.emit).toHaveBeenCalledWith(DifficultyLevel.HARD); + }); + + it('emits toggleModifier when modifier button clicked', () => { + fixture.detectChanges(); + spyOn(component.toggleModifier, 'emit'); + + const modButtons = fixture.nativeElement.querySelectorAll('.setup-mod-btn'); + modButtons[0].click(); + + expect(component.toggleModifier.emit).toHaveBeenCalledWith(component.allModifiers[0]); + }); + + it('emits toggleEndless when checkbox changed', () => { + fixture.detectChanges(); + spyOn(component.toggleEndless, 'emit'); + + const checkbox = fixture.nativeElement.querySelector('input[type="checkbox"]'); + checkbox.dispatchEvent(new Event('change')); + + expect(component.toggleEndless.emit).toHaveBeenCalled(); + }); + + it('emits startGame when Start Game button clicked', () => { + fixture.detectChanges(); + spyOn(component.startGame, 'emit'); + + const startBtn = fixture.nativeElement.querySelector('.setup-start-btn'); + startBtn.click(); + + expect(component.startGame.emit).toHaveBeenCalled(); + }); + + it('shows score multiplier when modifiers are active', () => { + component.activeModifiers = new Set([GameModifier.ARMORED_ENEMIES]); + component.modifierScoreMultiplier = 1.3; + fixture.detectChanges(); + + const scoreEl = fixture.nativeElement.querySelector('.setup-score'); + expect(scoreEl).toBeTruthy(); + expect(scoreEl.textContent).toContain('1.3x'); + }); + + it('hides score when no modifiers active', () => { + component.activeModifiers = new Set(); + component.modifierScoreMultiplier = 1.0; + fixture.detectChanges(); + + const scoreEl = fixture.nativeElement.querySelector('.setup-score'); + expect(scoreEl).toBeNull(); + }); +}); diff --git a/src/app/game/game-board/components/game-setup/game-setup.component.ts b/src/app/game/game-board/components/game-setup/game-setup.component.ts new file mode 100644 index 00000000..289862a7 --- /dev/null +++ b/src/app/game/game-board/components/game-setup/game-setup.component.ts @@ -0,0 +1,24 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { DifficultyLevel, DIFFICULTY_PRESETS, GameState } from '../../models/game-state.model'; +import { GameModifier, GAME_MODIFIER_CONFIGS } from '../../models/game-modifier.model'; + +@Component({ + selector: 'app-game-setup', + templateUrl: './game-setup.component.html', + styleUrls: ['./game-setup.component.scss'] +}) +export class GameSetupComponent { + @Input() gameState!: GameState; + @Input() activeModifiers = new Set(); + @Input() modifierScoreMultiplier = 1.0; + + @Output() selectDifficulty = new EventEmitter(); + @Output() toggleModifier = new EventEmitter(); + @Output() toggleEndless = new EventEmitter(); + @Output() startGame = new EventEmitter(); + + readonly difficultyLevels = Object.values(DifficultyLevel); + readonly difficultyPresets = DIFFICULTY_PRESETS; + readonly modifierConfigs = GAME_MODIFIER_CONFIGS; + readonly allModifiers = Object.values(GameModifier); +} diff --git a/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.html b/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.html new file mode 100644 index 00000000..40a5c9bf --- /dev/null +++ b/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.html @@ -0,0 +1,71 @@ +
+
+ {{ selectedTowerInfo.type | titlecase }} + + + + + +
+
+
+ Damage + {{ selectedTowerStats.damage }} +
+
+ Range + {{ selectedTowerStats.range }} +
+
+ Fire Rate + {{ selectedTowerStats.fireRate }}s +
+
+ Kills + {{ selectedTowerInfo.kills }} +
+
+
+ +
+
+ + + {{ selectedTowerInfo.specialization ? (selectedTowerInfo.type | titlecase) + ' — ' + specLabel(selectedTowerInfo) : 'MAX' }} + + +
+
+
Choose Specialization ({{ upgradeCost }}g)
+ + +
+
diff --git a/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.scss b/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.scss new file mode 100644 index 00000000..7e51d8b7 --- /dev/null +++ b/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.scss @@ -0,0 +1,291 @@ +@use '../../styles/game-mixins' as *; + +:host { + display: block; +} + +.tower-info-panel { + --tower-bar-height: 5.5rem; + + @include glass-panel(rgba(0, 0, 0, 0.85)); + position: absolute; + bottom: calc(var(--spacing-lg) + var(--tower-bar-height)); + left: 50%; + transform: translateX(-50%); + min-width: 14rem; + padding: var(--spacing-md); + border: var(--border-width-thin) solid var(--theme-purple-a50); + backdrop-filter: blur(0.75rem); + animation: scale-in 0.2s ease-out; + + @media screen and (max-width: 768px) { + --tower-bar-height: 4.5rem; + min-width: 12rem; + bottom: calc(var(--spacing-md) + var(--tower-bar-height)); + } + + .tower-info-header { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); + padding-bottom: var(--spacing-xs); + border-bottom: var(--border-width-thin) solid var(--border-color); + + .tower-info-name { + font-size: var(--font-size-base); + font-weight: 700; + color: var(--text-color); + text-transform: uppercase; + letter-spacing: 0.0625rem; + } + + .tower-info-level { + display: flex; + gap: 0.125rem; + + .level-star { + font-size: 0.75rem; + color: var(--game-star-filled); + + &.empty { + color: var(--game-star-empty); + } + } + } + + .tower-info-close { + margin-left: auto; + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: var(--font-size-large); + cursor: pointer; + padding: 0 var(--spacing-xs); + line-height: 1; + + &:hover { + color: var(--text-color); + } + } + } + + .tower-info-stats { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); + + .stat-row { + display: flex; + justify-content: space-between; + font-size: var(--font-size-small); + + .stat-label { + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.0313rem; + } + + .stat-value { + color: var(--text-color); + font-weight: 600; + } + } + } + + .tower-info-targeting { + margin-bottom: var(--spacing-xs); + + .targeting-btn { + width: 100%; + padding: var(--spacing-xs) var(--spacing-sm); + border: var(--border-width-thin) solid rgba(100, 180, 255, 0.3); + border-radius: var(--border-radius-sm); + background: rgba(30, 80, 140, 0.3); + color: #88ccff; + font-family: "Orbitron", sans-serif; + font-size: 0.6875rem; + cursor: pointer; + transition: all var(--transition-medium) ease; + text-align: center; + + &:hover { + background: rgba(40, 100, 180, 0.45); + border-color: rgba(100, 180, 255, 0.6); + } + } + } + + .tower-info-actions { + display: flex; + gap: var(--spacing-sm); + + .upgrade-btn, .sell-btn { + flex: 1; + padding: var(--spacing-xs) var(--spacing-sm); + border: var(--border-width-thin) solid var(--border-color); + border-radius: var(--border-radius-sm); + font-family: "Orbitron", sans-serif; + font-size: 0.6875rem; + cursor: pointer; + transition: all var(--transition-medium) ease; + } + + .upgrade-btn { + background: rgba(0, 100, 0, 0.3); + border-color: rgba(0, 255, 0, 0.3); + color: #88ff88; + + &:hover { + background: rgba(0, 150, 0, 0.4); + border-color: rgba(0, 255, 0, 0.6); + } + + &.unaffordable { + opacity: 0.4; + cursor: not-allowed; + color: var(--game-health); + } + } + + .sell-btn { + background: rgba(100, 50, 0, 0.3); + border-color: rgba(255, 200, 0, 0.3); + color: var(--game-gold); + + &:hover { + background: rgba(150, 75, 0, 0.4); + border-color: rgba(255, 200, 0, 0.6); + } + + &.confirm { + background: rgba(200, 50, 0, 0.5); + border-color: rgba(255, 100, 0, 0.8); + color: var(--game-health); + animation: pulse-warn 0.5s ease-in-out infinite alternate; + } + } + + @keyframes pulse-warn { + from { opacity: 0.8; } + to { opacity: 1; } + } + + .max-level-badge { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.6875rem; + color: var(--game-gold); + text-transform: uppercase; + letter-spacing: 0.0625rem; + } + } + + .spec-choice-panel { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + + .spec-choice-header { + font-size: var(--font-size-small); + color: var(--game-gold); + text-transform: uppercase; + letter-spacing: 0.0625rem; + text-align: center; + margin-bottom: var(--spacing-xs); + } + + .spec-btn { + display: flex; + flex-direction: column; + gap: var(--border-width-medium); + padding: var(--spacing-sm); + border: var(--border-width-thin) solid var(--panel-blue-border); + border-radius: var(--border-radius-sm); + background: var(--panel-blue-bg); + font-family: "Orbitron", sans-serif; + cursor: pointer; + transition: all var(--transition-medium) ease; + text-align: left; + min-height: 2.75rem; + + &:hover { + background: var(--panel-blue-hover); + border-color: var(--panel-blue-border-hover); + } + + &.unaffordable { + opacity: 0.4; + cursor: not-allowed; + } + + .spec-label { + font-size: var(--font-size-xs); + font-weight: 700; + color: var(--panel-blue-accent); + text-transform: uppercase; + letter-spacing: 0.0313rem; + } + + .spec-desc { + font-size: var(--font-size-2xs); + color: var(--text-muted); + } + + .spec-stats { + font-size: var(--font-size-3xs); + color: var(--text-faint); + font-family: monospace; + } + } + + .spec-cancel-btn { + padding: var(--spacing-xs); + border: var(--border-width-thin) solid var(--border-color); + border-radius: var(--border-radius-sm); + background: var(--panel-neutral-bg); + color: var(--text-dim); + font-family: "Orbitron", sans-serif; + font-size: var(--font-size-2xs); + cursor: pointer; + transition: all var(--transition-medium) ease; + min-height: 2.75rem; + + &:hover { + background: var(--panel-neutral-hover); + color: var(--text-color); + } + } + } +} + +// Mobile (max-width: 480px) overrides +@media screen and (max-width: 480px) { + .tower-info-panel { + left: 0; + right: 0; + bottom: calc(var(--spacing-lg) + 8rem); + transform: none; + min-width: unset; + width: 100%; + border-radius: var(--border-radius-md) var(--border-radius-md) 0 0; + border-left: none; + border-right: none; + border-bottom: none; + } +} + +// --- Animations --- +@keyframes scale-in { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.spec.ts b/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.spec.ts new file mode 100644 index 00000000..7a49720f --- /dev/null +++ b/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.spec.ts @@ -0,0 +1,173 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { TowerInfoPanelComponent } from './tower-info-panel.component'; +import { TowerType, TowerSpecialization, PlacedTower, MAX_TOWER_LEVEL } from '../../models/tower.model'; + +function makeTower(overrides: Partial = {}): PlacedTower { + return { + id: 'test-tower', + type: TowerType.BASIC, + level: 1, + row: 0, + col: 0, + lastFireTime: 0, + kills: 5, + totalInvested: 50, + targetingMode: 'nearest', + mesh: null, + ...overrides, + }; +} + +describe('TowerInfoPanelComponent', () => { + let component: TowerInfoPanelComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TowerInfoPanelComponent], + imports: [CommonModule] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TowerInfoPanelComponent); + component = fixture.componentInstance; + component.selectedTowerInfo = makeTower(); + component.selectedTowerStats = { damage: 25, range: 3, fireRate: 1.0 }; + component.upgradeCost = 50; + component.sellValue = 25; + component.gold = 200; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('shows tower name, level stars, and stats', () => { + fixture.detectChanges(); + const name = fixture.nativeElement.querySelector('.tower-info-name'); + expect(name.textContent.trim()).toBe('Basic'); + + const filledStars = fixture.nativeElement.querySelectorAll('.level-star:not(.empty)'); + expect(filledStars.length).toBe(1); + + const emptyStars = fixture.nativeElement.querySelectorAll('.level-star.empty'); + expect(emptyStars.length).toBe(MAX_TOWER_LEVEL - 1); + + const statValues = fixture.nativeElement.querySelectorAll('.stat-value'); + expect(statValues[0].textContent.trim()).toBe('25'); // damage + expect(statValues[1].textContent.trim()).toBe('3'); // range + }); + + it('emits deselect on close button click', () => { + fixture.detectChanges(); + spyOn(component.deselect, 'emit'); + + const closeBtn = fixture.nativeElement.querySelector('.tower-info-close'); + closeBtn.click(); + + expect(component.deselect.emit).toHaveBeenCalled(); + }); + + it('emits upgrade on upgrade button click', () => { + fixture.detectChanges(); + spyOn(component.upgrade, 'emit'); + + const upgradeBtn = fixture.nativeElement.querySelector('.upgrade-btn'); + upgradeBtn.click(); + + expect(component.upgrade.emit).toHaveBeenCalled(); + }); + + it('emits sell on sell button click', () => { + fixture.detectChanges(); + spyOn(component.sell, 'emit'); + + const sellBtn = fixture.nativeElement.querySelector('.sell-btn'); + sellBtn.click(); + + expect(component.sell.emit).toHaveBeenCalled(); + }); + + it('shows unaffordable class when gold < upgradeCost', () => { + component.gold = 10; + component.upgradeCost = 50; + fixture.detectChanges(); + + const upgradeBtn = fixture.nativeElement.querySelector('.upgrade-btn'); + expect(upgradeBtn.classList.contains('unaffordable')).toBeTrue(); + }); + + it('emits cycleTargeting on targeting button click', () => { + fixture.detectChanges(); + spyOn(component.cycleTargeting, 'emit'); + + const targetBtn = fixture.nativeElement.querySelector('.targeting-btn'); + targetBtn.click(); + + expect(component.cycleTargeting.emit).toHaveBeenCalled(); + }); + + it('shows specialization choice panel when showSpecializationChoice is true', () => { + component.showSpecializationChoice = true; + component.specOptions = [ + { spec: TowerSpecialization.ALPHA, label: 'Marksman', description: 'High damage', damage: 3.0, range: 1.2, fireRate: 0.8 }, + { spec: TowerSpecialization.BETA, label: 'Rapid', description: 'Fast fire', damage: 1.8, range: 1.5, fireRate: 0.5 }, + ]; + fixture.detectChanges(); + + const specPanel = fixture.nativeElement.querySelector('.spec-choice-panel'); + expect(specPanel).toBeTruthy(); + + const specBtns = fixture.nativeElement.querySelectorAll('.spec-btn'); + expect(specBtns.length).toBe(2); + }); + + it('emits selectSpecialization with correct spec', () => { + component.showSpecializationChoice = true; + component.specOptions = [ + { spec: TowerSpecialization.ALPHA, label: 'Marksman', description: 'High damage', damage: 3.0, range: 1.2, fireRate: 0.8 }, + { spec: TowerSpecialization.BETA, label: 'Rapid', description: 'Fast fire', damage: 1.8, range: 1.5, fireRate: 0.5 }, + ]; + fixture.detectChanges(); + spyOn(component.selectSpecialization, 'emit'); + + const specBtns = fixture.nativeElement.querySelectorAll('.spec-btn'); + specBtns[1].click(); + + expect(component.selectSpecialization.emit).toHaveBeenCalledWith(TowerSpecialization.BETA); + }); + + it('emits cancelSpecialization on cancel', () => { + component.showSpecializationChoice = true; + component.specOptions = [ + { spec: TowerSpecialization.ALPHA, label: 'Marksman', description: 'High damage', damage: 3.0, range: 1.2, fireRate: 0.8 }, + ]; + fixture.detectChanges(); + spyOn(component.cancelSpecialization, 'emit'); + + const cancelBtn = fixture.nativeElement.querySelector('.spec-cancel-btn'); + cancelBtn.click(); + + expect(component.cancelSpecialization.emit).toHaveBeenCalled(); + }); + + it('shows sell confirmation text when sellConfirmPending', () => { + component.sellConfirmPending = true; + fixture.detectChanges(); + + const sellBtn = fixture.nativeElement.querySelector('.sell-btn'); + expect(sellBtn.textContent).toContain('Confirm Sell?'); + expect(sellBtn.classList.contains('confirm')).toBeTrue(); + }); + + it('shows max level badge at MAX_TOWER_LEVEL', () => { + component.selectedTowerInfo = makeTower({ level: MAX_TOWER_LEVEL }); + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('.max-level-badge'); + expect(badge).toBeTruthy(); + expect(badge.textContent).toContain('MAX'); + }); +}); diff --git a/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.ts b/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.ts new file mode 100644 index 00000000..83c28c28 --- /dev/null +++ b/src/app/game/game-board/components/tower-info-panel/tower-info-panel.component.ts @@ -0,0 +1,42 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { TowerType, TowerSpecialization, PlacedTower, MAX_TOWER_LEVEL, TOWER_SPECIALIZATIONS, TARGETING_MODE_LABELS, TargetingMode } from '../../models/tower.model'; + +@Component({ + selector: 'app-tower-info-panel', + templateUrl: './tower-info-panel.component.html', + styleUrls: ['./tower-info-panel.component.scss'] +}) +export class TowerInfoPanelComponent { + @Input() selectedTowerInfo: PlacedTower | null = null; + @Input() selectedTowerStats: { damage: number; range: number; fireRate: number } | null = null; + @Input() upgradeCost = 0; + @Input() sellValue = 0; + @Input() gold = 0; + @Input() showSpecializationChoice = false; + @Input() specOptions: { spec: TowerSpecialization; label: string; description: string; damage: number; range: number; fireRate: number }[] = []; + @Input() sellConfirmPending = false; + + @Output() upgrade = new EventEmitter(); + @Output() sell = new EventEmitter(); + @Output() deselect = new EventEmitter(); + @Output() cycleTargeting = new EventEmitter(); + @Output() selectSpecialization = new EventEmitter(); + @Output() cancelSpecialization = new EventEmitter(); + + readonly MAX_TOWER_LEVEL = MAX_TOWER_LEVEL; + readonly TowerType = TowerType; + readonly targetingModeLabels = TARGETING_MODE_LABELS; + + /** Pre-compute star arrays to avoid template allocation per CD cycle. */ + get filledStars(): number[] { + return this.selectedTowerInfo ? Array(this.selectedTowerInfo.level).fill(0) : []; + } + get emptyStars(): number[] { + return this.selectedTowerInfo ? Array(MAX_TOWER_LEVEL - this.selectedTowerInfo.level).fill(0) : []; + } + + specLabel(tower: PlacedTower): string { + if (!tower.specialization) return ''; + return TOWER_SPECIALIZATIONS[tower.type][tower.specialization].label; + } +} diff --git a/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.html b/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.html new file mode 100644 index 00000000..c5c1613b --- /dev/null +++ b/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.html @@ -0,0 +1,21 @@ +
+ +
diff --git a/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.scss b/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.scss new file mode 100644 index 00000000..3a0c9af9 --- /dev/null +++ b/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.scss @@ -0,0 +1,281 @@ +@use '../../styles/game-mixins' as *; + +:host { + display: contents; +} + +.tower-selection { + @include glass-panel(rgba(0, 0, 0, 0.8)); + position: absolute; + bottom: var(--spacing-lg); + left: 50%; + transform: translateX(-50%); + display: flex; + gap: var(--spacing-md); + padding: var(--spacing-sm); + border: var(--border-width-thin) solid var(--border-color); + + @media screen and (max-width: 768px) { + bottom: var(--spacing-md); + gap: var(--spacing-sm); + padding: var(--spacing-xs); + width: 90%; + justify-content: center; + } + + @media screen and (max-width: 480px) { + display: flex; + flex-direction: column; + top: 5.5rem; + left: 0; + bottom: auto; + right: auto; + transform: none; + width: auto; + border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0; + padding: var(--spacing-xs); + gap: 0.25rem; + border-left: none; + } + + .tower-btn { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-sm); + background: transparent; + border: var(--border-width-medium) solid var(--border-color); + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: all var(--transition-medium) cubic-bezier(0.4, 0, 0.2, 1); + min-width: 5rem; + + @media screen and (max-width: 768px) { + min-width: 4rem; + padding: var(--spacing-xs); + } + + @media screen and (max-width: 480px) { + min-width: unset; + width: 3rem; + padding: var(--spacing-xs); + gap: 0.125rem; + } + + &:hover { + background: rgba(255, 255, 255, 0.1); + border-color: var(--border-color-strong); + transform: translateY(-0.125rem); + } + + &:active { + transform: translateY(0.0625rem); + } + + &.selected { + background: rgba(255, 255, 255, 0.15); + border-color: var(--text-color); + box-shadow: 0 0 var(--spacing-sm) rgba(255, 255, 255, 0.3); + } + + &.unaffordable { + opacity: 0.4; + + .tower-cost { color: var(--game-health); } + } + + .tower-icon { + width: 2.5rem; + height: 2.5rem; + border-radius: var(--border-radius-sm); + display: flex; + align-items: center; + justify-content: center; + position: relative; + + @media screen and (max-width: 768px) { + width: 2rem; + height: 2rem; + } + + @media screen and (max-width: 480px) { + width: 1.75rem; + height: 1.75rem; + } + + &.basic::before { + content: ''; + width: 1.25rem; + height: 1.25rem; + background: var(--tower-color-basic); + border-radius: 50%; + box-shadow: 0 0 var(--spacing-xs) var(--tower-glow-basic); + } + + &.sniper::before { + content: ''; + width: 0; + height: 0; + border-left: 0.75rem solid transparent; + border-right: 0.75rem solid transparent; + border-bottom: 1.5rem solid var(--tower-color-sniper); + // CSS border triangles don't support box-shadow — use filter instead + filter: drop-shadow(0 0 4px var(--tower-glow-sniper)); + } + + &.splash::before { + content: ''; + width: 1.25rem; + height: 1.25rem; + background: var(--tower-color-splash); + box-shadow: 0 0 var(--spacing-xs) var(--tower-glow-splash); + } + + &.slow::before { + content: ''; + width: 0.9rem; + height: 0.9rem; + background: var(--tower-color-slow); + transform: rotate(45deg); + box-shadow: 0 0 var(--spacing-xs) var(--tower-glow-slow); + } + + &.chain::before { + content: ''; + width: 0.25rem; + height: 1.5rem; + background: var(--tower-color-chain); + border-radius: 0.125rem; + box-shadow: 0 0 var(--spacing-xs) var(--tower-glow-chain); + } + + &.mortar::before { + content: ''; + width: 1.4rem; + height: 0.75rem; + background: var(--tower-color-mortar); + border-radius: 0.1rem; + box-shadow: 0 0 var(--spacing-xs) var(--tower-glow-mortar); + } + } + + .tower-name { + font-size: var(--font-size-small); + color: var(--text-color); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.0313rem; + + @media screen and (max-width: 768px) { + font-size: 0.75rem; + } + + @media screen and (max-width: 480px) { + display: none; + } + } + + .tower-cost { + font-size: 0.6875rem; + color: var(--game-gold); + font-family: "Orbitron", sans-serif; + + @media screen and (max-width: 480px) { + font-size: 0.5625rem; + } + } + + .tower-hotkey { + font-size: 0.5rem; + color: rgba(255, 255, 255, 0.25); + font-family: "Orbitron", sans-serif; + letter-spacing: 0.0313rem; + + @media screen and (max-width: 480px) { + display: none; + } + } + + // --- Hover tooltip (hidden on mobile — touch can't hover) --- + .tower-tooltip { + @media screen and (max-width: 480px) { + display: none; + } + + position: absolute; + bottom: calc(100% + 0.625rem); + left: 50%; + transform: translateX(-50%); + min-width: 9rem; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--nav-bg, rgba(10, 5, 20, 0.96)); + border: var(--border-width-thin) solid var(--theme-purple-a50); + border-radius: var(--border-radius-md); + backdrop-filter: blur(0.75rem); + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; + z-index: calc(var(--z-index-overlay) + 10); + white-space: nowrap; + word-wrap: break-word; + box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.6), 0 0 0.5rem var(--theme-purple-a30, rgba(128, 0, 255, 0.2)); + + @media screen and (max-width: 480px) { + max-width: min(200px, 50vw); + white-space: normal; + } + + .tooltip-stat { + display: flex; + justify-content: space-between; + gap: var(--spacing-md); + font-family: "Orbitron", sans-serif; + font-size: clamp(0.5rem, 1.5vw, 0.625rem); + margin-bottom: 0.1875rem; + + .tooltip-label { + color: rgba(255, 255, 255, 0.45); + letter-spacing: 0.0625rem; + } + + .tooltip-val { + color: var(--text-color); + font-weight: 600; + } + } + + .tooltip-desc { + margin-top: var(--spacing-xs); + padding-top: var(--spacing-xs); + border-top: var(--border-width-thin) solid rgba(255, 255, 255, 0.1); + font-family: "Orbitron", sans-serif; + font-size: 0.5rem; + color: var(--theme-purple-mid, rgba(160, 100, 255, 0.8)); + text-align: center; + line-height: 1.4; + white-space: normal; + } + + // Downward-pointing caret + .tooltip-arrow { + position: absolute; + bottom: -0.3125rem; + left: 50%; + transform: translateX(-50%) rotate(45deg); + width: 0.375rem; + height: 0.375rem; + background: var(--nav-bg, rgba(10, 5, 20, 0.96)); + border-right: var(--border-width-thin) solid var(--theme-purple-a50); + border-bottom: var(--border-width-thin) solid var(--theme-purple-a50); + } + } + + &:hover .tower-tooltip { + opacity: 1; + visibility: visible; + } + } +} diff --git a/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.spec.ts b/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.spec.ts new file mode 100644 index 00000000..a5225b60 --- /dev/null +++ b/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.spec.ts @@ -0,0 +1,116 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { TowerSelectionBarComponent } from './tower-selection-bar.component'; +import { TowerType, TOWER_CONFIGS, TOWER_DESCRIPTIONS } from '../../models/tower.model'; + +describe('TowerSelectionBarComponent', () => { + let component: TowerSelectionBarComponent; + let fixture: ComponentFixture; + + const towerTypes: { type: TowerType; hotkey: string }[] = [ + { type: TowerType.BASIC, hotkey: '1' }, + { type: TowerType.SNIPER, hotkey: '2' }, + { type: TowerType.SPLASH, hotkey: '3' }, + { type: TowerType.SLOW, hotkey: '4' }, + { type: TowerType.CHAIN, hotkey: '5' }, + { type: TowerType.MORTAR, hotkey: '6' }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TowerSelectionBarComponent], + imports: [CommonModule] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TowerSelectionBarComponent); + component = fixture.componentInstance; + component.towerTypes = towerTypes; + component.selectedTowerType = TowerType.BASIC; + component.towerConfigs = TOWER_CONFIGS; + component.towerDescriptions = TOWER_DESCRIPTIONS; + component.gold = 100; + + const costs = new Map(); + for (const type of Object.values(TowerType)) { + costs.set(type, TOWER_CONFIGS[type].cost); + } + component.effectiveCosts = costs; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('renders a button for each tower type', () => { + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll('.tower-btn'); + expect(buttons.length).toBe(towerTypes.length); + }); + + it('emits selectTowerType when a tower button is clicked', () => { + fixture.detectChanges(); + spyOn(component.selectTowerType, 'emit'); + + const buttons = fixture.nativeElement.querySelectorAll('.tower-btn'); + buttons[1].click(); + + expect(component.selectTowerType.emit).toHaveBeenCalledWith(TowerType.SNIPER); + }); + + it('marks the selected tower button with selected class', () => { + component.selectedTowerType = TowerType.SPLASH; + fixture.detectChanges(); + + const buttons = fixture.nativeElement.querySelectorAll('.tower-btn'); + // SPLASH is index 2 + expect(buttons[2].classList.contains('selected')).toBeTrue(); + expect(buttons[0].classList.contains('selected')).toBeFalse(); + }); + + it('marks tower as unaffordable when gold is insufficient', () => { + // Set gold lower than sniper cost + const sniperCost = TOWER_CONFIGS[TowerType.SNIPER].cost; + component.gold = sniperCost - 1; + fixture.detectChanges(); + + const buttons = fixture.nativeElement.querySelectorAll('.tower-btn'); + // Sniper is index 1 + expect(buttons[1].classList.contains('unaffordable')).toBeTrue(); + }); + + it('does not mark tower as unaffordable when gold is sufficient', () => { + component.gold = 9999; + fixture.detectChanges(); + + const buttons = fixture.nativeElement.querySelectorAll('.tower-btn'); + for (let i = 0; i < buttons.length; i++) { + expect(buttons[i].classList.contains('unaffordable')).toBeFalse(); + } + }); + + it('displays effective cost from the costs map', () => { + const costs = new Map(); + costs.set(TowerType.BASIC, 42); + costs.set(TowerType.SNIPER, 99); + for (const type of Object.values(TowerType)) { + if (!costs.has(type)) { + costs.set(type, TOWER_CONFIGS[type].cost); + } + } + component.effectiveCosts = costs; + fixture.detectChanges(); + + const costEls = fixture.nativeElement.querySelectorAll('.tower-cost'); + expect(costEls[0].textContent).toContain('42g'); + expect(costEls[1].textContent).toContain('99g'); + }); + + it('displays tower hotkeys', () => { + fixture.detectChanges(); + const hotkeyEls = fixture.nativeElement.querySelectorAll('.tower-hotkey'); + expect(hotkeyEls[0].textContent).toContain('1'); + expect(hotkeyEls[5].textContent).toContain('6'); + }); +}); diff --git a/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.ts b/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.ts new file mode 100644 index 00000000..77be187e --- /dev/null +++ b/src/app/game/game-board/components/tower-selection-bar/tower-selection-bar.component.ts @@ -0,0 +1,18 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { TowerType, TowerStats } from '../../models/tower.model'; + +@Component({ + selector: 'app-tower-selection-bar', + templateUrl: './tower-selection-bar.component.html', + styleUrls: ['./tower-selection-bar.component.scss'] +}) +export class TowerSelectionBarComponent { + @Input() towerTypes: { type: TowerType; hotkey: string }[] = []; + @Input() selectedTowerType!: TowerType; + @Input() towerConfigs!: Record; + @Input() towerDescriptions!: Record; + @Input() effectiveCosts = new Map(); + @Input() gold = 0; + + @Output() selectTowerType = new EventEmitter(); +} diff --git a/src/app/game/game-board/constants/camera.constants.ts b/src/app/game/game-board/constants/camera.constants.ts index 43ef3fe2..12a45fa6 100644 --- a/src/app/game/game-board/constants/camera.constants.ts +++ b/src/app/game/game-board/constants/camera.constants.ts @@ -14,3 +14,9 @@ export const CONTROLS_CONFIG = { minDistanceFactor: 0.5, maxDistanceFactor: 3 }; + +/** + * Sentinel value for disabling an OrbitControls mouse button action. + * OrbitControls' switch statement falls to `default: state = NONE` for unknown values. + */ +export const MOUSE_ACTION_DISABLED = -1; diff --git a/src/app/game/game-board/constants/mobile.constants.ts b/src/app/game/game-board/constants/mobile.constants.ts new file mode 100644 index 00000000..0508b829 --- /dev/null +++ b/src/app/game/game-board/constants/mobile.constants.ts @@ -0,0 +1,12 @@ +export const MOBILE_CONFIG = { + /** Breakpoint below which mobile optimizations apply */ + breakpoint: 768, + /** Breakpoint below which phone-specific optimizations apply */ + phoneBreakpoint: 480, + /** Max pixel ratio on mobile to reduce GPU load */ + maxPixelRatio: 1.5, + /** Shadow map cap on mobile */ + maxShadowMapSize: 1024, + /** Particle count divisor on mobile */ + particleDivisor: 2, +} as const; diff --git a/src/app/game/game-board/constants/rendering.constants.ts b/src/app/game/game-board/constants/rendering.constants.ts index 6385bd29..b1c22273 100644 --- a/src/app/game/game-board/constants/rendering.constants.ts +++ b/src/app/game/game-board/constants/rendering.constants.ts @@ -20,5 +20,12 @@ export const POST_PROCESSING_CONFIG = { export const SKYBOX_CONFIG = { radius: 500, widthSegments: 32, - heightSegments: 32 + heightSegments: 32, + /** Multiplier to convert performance.now() ms → shader seconds */ + timeScale: 0.001, }; + +/** Converts a sin() result from [-1,1] to [0,1] for use in lerp/pulse calculations. */ +export function sinNormalized(value: number): number { + return value * 0.5 + 0.5; +} diff --git a/src/app/game/game-board/constants/ui.constants.ts b/src/app/game/game-board/constants/ui.constants.ts index 590210a6..bdc1cf3f 100644 --- a/src/app/game/game-board/constants/ui.constants.ts +++ b/src/app/game/game-board/constants/ui.constants.ts @@ -44,8 +44,10 @@ export const TOWER_VISUAL_CONFIG = { scaleBase: 1.4, scaleIncrement: 0.15, emissiveBase: 0.7, - emissiveIncrement: 0.25 -}; + emissiveIncrement: 0.25, + /** Mesh names whose emissive is driven per-frame by animations — skip during upgrade boost */ + animatedMeshNames: new Set(['tip', 'orb']), +} as const; export const RANGE_PREVIEW_CONFIG = { opacity: 0.35, @@ -77,4 +79,13 @@ export const ENEMY_VISUAL_CONFIG = { miniSwarmEmissive: 0.4, roughness: 0.6, metalness: 0.2, + /** Fallback death particle color when enemy type has no color defined */ + fallbackDeathColor: 0xff0000, }; + +export const GAME_TIMING_CONFIG = { + /** How long the "path blocked" warning banner stays visible (ms) */ + pathBlockedDismissMs: 2000, + /** Interval at which accumulated elapsed combat time is flushed to state (seconds) */ + elapsedTimeFlushInterval: 1, +} as const; diff --git a/src/app/game/game-board/game-board.component.html b/src/app/game/game-board/game-board.component.html index 7e9da8d0..b8be40ac 100644 --- a/src/app/game/game-board/game-board.component.html +++ b/src/app/game/game-board/game-board.component.html @@ -1,6 +1,33 @@
+ +
+
+
+

Initializing...

+
+
+ + +
+
+

Unable to Start Game

+

{{ initError }}

+

Try using a modern browser with WebGL support, or check your graphics drivers.

+ +
+
+ + +
+
+

Graphics Context Lost

+

The GPU context was interrupted. Attempting to recover...

+
+
+
+
{{ fps }} FPS
@@ -13,84 +40,26 @@
- -
-
- Lives - {{ gameState.lives }} -
-
- Gold - {{ gameState.gold }} -
-
- Wave - {{ gameState.wave }} / {{ gameState.maxWaves }} - {{ gameState.wave }} -
-
- Score - {{ gameState.score }} -
-
- Time - {{ formattedTime }} -
-
- - -
- {{ enemiesAlive + enemiesToSpawn }} enemies remaining -
- - -
- Placement blocked — enemies need a path to the exit -
- - -
- -
- -
- - - - -
- -
-
- Score: {{ modifierScoreMultiplier.toFixed(1) }}x -
- - -
- - + + + + + + +
@@ -109,126 +78,30 @@
- -
- -
- - -
- -
- -
- - -
- - -
-
-

Victory

-

Defeat

- - -
- - ★ - -
- - -
-
- Base Score - {{ scoreBreakdown.baseScore }} -
-
- Difficulty - x{{ scoreBreakdown.difficultyMultiplier }} -
-
- Modifiers - x{{ scoreBreakdown.modifierMultiplier.toFixed(1) }} -
-
- Waves Completed - {{ scoreBreakdown.wavesCompleted }} / {{ gameState.maxWaves }} -
-
- Lives Remaining - {{ scoreBreakdown.livesRemaining }} / {{ scoreBreakdown.livesTotal }} -
-
- Final Score - {{ scoreBreakdown.finalScore }} -
-
- -

Score: {{ gameState.score }}

- - -
-

Achievements Unlocked

-
-
- 🏆 -
- {{ ach.name }} - {{ ach.description }} -
-
-
-
- -
- - -
-
-
+ + + + +
@@ -238,101 +111,34 @@

PAUSED

- -
-
- {{ selectedTowerInfo.type | titlecase }} - - - - - -
-
-
- Damage - {{ selectedTowerStats.damage }} -
-
- Range - {{ selectedTowerStats.range }} -
-
- Fire Rate - {{ selectedTowerStats.fireRate }}s -
-
- Kills - {{ selectedTowerInfo.kills }} -
-
-
- -
-
- - - {{ selectedTowerInfo.specialization ? (selectedTowerInfo.type | titlecase) + ' — ' + specLabel(selectedTowerInfo) : 'MAX' }} - - -
-
-
Choose Specialization ({{ selectedTowerUpgradeCost }}g)
- - -
-
- - -
- -
+ + + + +
diff --git a/src/app/game/game-board/game-board.component.scss b/src/app/game/game-board/game-board.component.scss index da1be437..9cfcfcc0 100644 --- a/src/app/game/game-board/game-board.component.scss +++ b/src/app/game/game-board/game-board.component.scss @@ -1,10 +1,4 @@ -@mixin glass-panel($bg: rgba(0, 0, 0, 0.75)) { - background: $bg; - border-radius: var(--border-radius-md); - backdrop-filter: blur(0.625rem); - z-index: var(--z-index-overlay); - font-family: "Orbitron", sans-serif; -} +@use './styles/game-mixins' as *; .board-container { display: flex; @@ -35,6 +29,12 @@ align-items: center; gap: var(--spacing-sm); z-index: var(--z-index-overlay); + + @media screen and (max-width: 480px) { + top: var(--spacing-xs); + right: var(--spacing-xs); + gap: var(--spacing-xs); + } } .help-btn { @@ -57,6 +57,10 @@ border-color: var(--theme-purple-a80); color: var(--theme-purple-lighter); } + + @media screen and (max-width: 480px) { + display: none; + } } // --- FPS counter --- @@ -65,6 +69,10 @@ font-family: "Orbitron", sans-serif; font-size: 0.7rem; opacity: 0.6; + + @media screen and (max-width: 480px) { + display: none; + } } .audio-toggle-btn { @@ -88,113 +96,6 @@ } } - // --- Game HUD --- - .game-hud { - @include glass-panel; - position: absolute; - top: var(--spacing-md); - left: 50%; - transform: translateX(-50%); - display: flex; - gap: var(--spacing-lg); - padding: var(--spacing-sm) var(--spacing-lg); - border: var(--border-width-thin) solid var(--border-color); - user-select: none; - - @media screen and (max-width: 768px) { - gap: var(--spacing-md); - padding: var(--spacing-xs) var(--spacing-md); - font-size: var(--font-size-small); - } - - .hud-stat { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.125rem; - - &.secondary { - opacity: 0.7; - - .hud-value { - font-size: var(--font-size-small); - } - } - - .hud-label { - font-size: 0.625rem; - text-transform: uppercase; - letter-spacing: 0.0625rem; - color: rgba(255, 255, 255, 0.5); - } - - .hud-value { - font-size: var(--font-size-base); - font-weight: 700; - color: var(--text-color); - - &.lives { - color: var(--game-health); - - &.critical { - animation: pulse-critical 0.5s ease-in-out infinite alternate; - } - } - - &.gold { - color: var(--game-gold); - } - } - } - - } - - .hud-wave-status { - --hud-height: 3.5rem; - - position: absolute; - top: calc(var(--spacing-md) + var(--hud-height)); - left: 50%; - transform: translateX(-50%); - padding: 0.25rem var(--spacing-md); - background: rgba(0, 0, 0, 0.4); - border-radius: var(--border-radius-md); - color: rgba(255, 136, 136, 0.8); - font-family: "Orbitron", sans-serif; - font-size: 0.7rem; - white-space: nowrap; - z-index: var(--z-index-overlay); - - .wave-enemy-count { - font-weight: 700; - color: var(--game-wave-text); - } - } - - .path-blocked-warning { - position: absolute; - top: calc(var(--spacing-md) + 5rem); - left: 50%; - transform: translateX(-50%); - padding: 0.4rem var(--spacing-lg); - background: rgba(180, 40, 40, 0.85); - border: 1px solid rgba(255, 80, 80, 0.6); - border-radius: var(--border-radius-md); - color: #fff; - font-family: "Orbitron", sans-serif; - font-size: 0.75rem; - font-weight: 600; - white-space: nowrap; - z-index: var(--z-index-overlay); - animation: path-blocked-fade-in 0.15s ease-out; - pointer-events: none; - } - - @keyframes path-blocked-fade-in { - from { opacity: 0; transform: translateX(-50%) translateY(-4px); } - to { opacity: 1; transform: translateX(-50%) translateY(0); } - } - // --- Wave preview panel --- .wave-preview { @include glass-panel(rgba(0, 0, 0, 0.82)); @@ -210,6 +111,7 @@ pointer-events: none; max-height: min(40vh, 300px); + overflow-x: hidden; overflow-y: auto; @media screen and (max-width: 768px) { @@ -271,258 +173,36 @@ } } - // --- Wave control --- - .wave-control { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: var(--z-index-overlay); - text-align: center; - - .wave-btn { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--spacing-xs); - @include glass-panel(rgba(0, 0, 0, 0.85)); - padding: var(--spacing-md) var(--spacing-xl); - border: var(--border-width-medium) solid rgba(255, 255, 255, 0.3); - border-radius: var(--border-radius-lg); - color: var(--text-color); - font-size: var(--font-size-title); - cursor: pointer; - transition: all var(--transition-medium) ease; - - &:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.6); - transform: scale(1.05); - } - - &:active { - transform: scale(0.98); - } - - .wave-hint { - font-size: 0.5625rem; - color: rgba(255, 255, 255, 0.3); - } - } - - } - - // --- Victory / Defeat overlay --- - .game-overlay { + // --- Loading overlay --- + .loading-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.7); + background: rgba(0, 0, 0, 0.95); z-index: var(--z-index-modal); - animation: fade-in 0.5s ease; - - .overlay-content { - text-align: center; - padding: var(--spacing-2xl); - border-radius: var(--border-radius-lg); - backdrop-filter: blur(1rem); - animation: scale-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); - - &.victory { - background: rgba(0, 100, 0, 0.3); - border: var(--border-width-medium) solid rgba(0, 255, 0, 0.4); - box-shadow: 0 0 2.5rem rgba(0, 255, 0, 0.15); - - h2 { color: var(--game-victory); } - } - - &.defeat { - background: rgba(100, 0, 0, 0.3); - border: var(--border-width-medium) solid rgba(255, 0, 0, 0.4); - box-shadow: 0 0 2.5rem rgba(255, 0, 0, 0.15); - - h2 { color: var(--game-defeat); } - } - - h2 { - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-hero); - margin-bottom: var(--spacing-md); - text-transform: uppercase; - letter-spacing: 0.25rem; - } - - .final-score { - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-large); - color: var(--game-gold); - margin-bottom: var(--spacing-lg); - } - - // --- Star rating --- - .star-display { - display: flex; - justify-content: center; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-lg); - - .overlay-star { - font-size: 2.5rem; - line-height: 1; - transition: transform 0.2s ease; - - &.filled { - color: var(--game-star-filled); - text-shadow: 0 0 0.75rem rgba(255, 215, 0, 0.6); - } - - &.empty { - color: var(--game-star-empty); - } - } - } - - // --- Score breakdown --- - .score-breakdown { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); - margin-bottom: var(--spacing-lg); - min-width: 18rem; - text-align: left; - - @media screen and (max-width: 768px) { - min-width: 14rem; - } - - .score-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--spacing-xs) 0; - border-bottom: var(--border-width-thin) solid rgba(255, 255, 255, 0.08); - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-small); - - &.final { - border-bottom: none; - border-top: var(--border-width-medium) solid rgba(255, 255, 255, 0.2); - margin-top: var(--spacing-xs); - padding-top: var(--spacing-sm); - } - - .score-label { - color: rgba(255, 255, 255, 0.55); - text-transform: uppercase; - letter-spacing: 0.0313rem; - } - - .score-value { - color: var(--text-color); - font-weight: 600; - - &.multiplier { - color: var(--score-multiplier); - } - - &.final-value { - color: var(--game-gold); - font-size: var(--font-size-large); - font-weight: 700; - } - } - } - } - - // --- Achievements unlocked --- - .achievements-unlocked { - margin-bottom: var(--spacing-lg); - - .achievements-title { - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-small); - color: var(--game-gold); - text-transform: uppercase; - letter-spacing: 0.0625rem; - margin-bottom: var(--spacing-sm); - } - - .achievement-list { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); - } - - .achievement-badge { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-xs) var(--spacing-sm); - background: rgba(255, 215, 0, 0.08); - border: var(--border-width-thin) solid rgba(255, 215, 0, 0.25); - border-radius: var(--border-radius-sm); - animation: scale-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); - - .achievement-icon { - font-size: 1.25rem; - } - - .achievement-info { - display: flex; - flex-direction: column; - text-align: left; - } - - .achievement-name { - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-small); - color: var(--game-gold); - font-weight: 600; - } - - .achievement-desc { - font-size: var(--font-size-tiny); - color: rgba(255, 255, 255, 0.5); - } - } - } - - .overlay-actions { - display: flex; - gap: var(--spacing-md); - justify-content: center; - } - - .restart-btn, .edit-map-btn { - padding: var(--spacing-sm) var(--spacing-xl); - background: var(--theme-purple-a20); - border: var(--border-width-medium) solid var(--theme-purple-a50); - border-radius: var(--border-radius-md); - color: var(--text-color); - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-base); - cursor: pointer; - transition: all var(--transition-medium) ease; + } - &:hover { - background: var(--theme-purple-a30); - border-color: var(--theme-purple-a80); - } - } + .loading-content { + text-align: center; + } - .edit-map-btn { - border-color: var(--theme-purple-a50); - color: var(--theme-purple-mid); + .loading-spinner { + width: 2.5rem; + height: 2.5rem; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--theme-purple-light); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto var(--spacing-md); + } - &:hover { - background: var(--theme-purple-a20); - border-color: var(--theme-purple-a80); - color: var(--theme-purple-lighter); - } - } - } + .loading-text { + font-family: 'Orbitron', sans-serif; + font-size: var(--font-size-small); + color: var(--theme-purple-mid); + letter-spacing: 0.1rem; } // --- Pause overlay --- @@ -558,964 +238,9 @@ } } - // --- Tower info panel --- - .tower-info-panel { - --tower-bar-height: 5.5rem; - - @include glass-panel(rgba(0, 0, 0, 0.85)); - position: absolute; - bottom: calc(var(--spacing-lg) + var(--tower-bar-height)); - left: 50%; - transform: translateX(-50%); - min-width: 14rem; - padding: var(--spacing-md); - border: var(--border-width-thin) solid var(--theme-purple-a50); - backdrop-filter: blur(0.75rem); - animation: scale-in 0.2s ease-out; - - @media screen and (max-width: 768px) { - --tower-bar-height: 4.5rem; - min-width: 12rem; - bottom: calc(var(--spacing-md) + var(--tower-bar-height)); - } - - .tower-info-header { - display: flex; - align-items: center; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-sm); - padding-bottom: var(--spacing-xs); - border-bottom: var(--border-width-thin) solid var(--border-color); - - .tower-info-name { - font-size: var(--font-size-base); - font-weight: 700; - color: var(--text-color); - text-transform: uppercase; - letter-spacing: 0.0625rem; - } - - .tower-info-level { - display: flex; - gap: 0.125rem; - - .level-star { - font-size: 0.75rem; - color: var(--game-star-filled); - - &.empty { - color: var(--game-star-empty); - } - } - } - - .tower-info-close { - margin-left: auto; - background: none; - border: none; - color: rgba(255, 255, 255, 0.5); - font-size: var(--font-size-large); - cursor: pointer; - padding: 0 var(--spacing-xs); - line-height: 1; - - &:hover { - color: var(--text-color); - } - } - } - - .tower-info-stats { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); - margin-bottom: var(--spacing-sm); - - .stat-row { - display: flex; - justify-content: space-between; - font-size: var(--font-size-small); - - .stat-label { - color: rgba(255, 255, 255, 0.5); - text-transform: uppercase; - letter-spacing: 0.0313rem; - } - - .stat-value { - color: var(--text-color); - font-weight: 600; - } - } - } - - .tower-info-targeting { - margin-bottom: var(--spacing-xs); - - .targeting-btn { - width: 100%; - padding: var(--spacing-xs) var(--spacing-sm); - border: var(--border-width-thin) solid rgba(100, 180, 255, 0.3); - border-radius: var(--border-radius-sm); - background: rgba(30, 80, 140, 0.3); - color: #88ccff; - font-family: "Orbitron", sans-serif; - font-size: 0.6875rem; - cursor: pointer; - transition: all var(--transition-medium) ease; - text-align: center; - - &:hover { - background: rgba(40, 100, 180, 0.45); - border-color: rgba(100, 180, 255, 0.6); - } - } - } - - .tower-info-actions { - display: flex; - gap: var(--spacing-sm); - - .upgrade-btn, .sell-btn { - flex: 1; - padding: var(--spacing-xs) var(--spacing-sm); - border: var(--border-width-thin) solid var(--border-color); - border-radius: var(--border-radius-sm); - font-family: "Orbitron", sans-serif; - font-size: 0.6875rem; - cursor: pointer; - transition: all var(--transition-medium) ease; - } - - .upgrade-btn { - background: rgba(0, 100, 0, 0.3); - border-color: rgba(0, 255, 0, 0.3); - color: #88ff88; - - &:hover { - background: rgba(0, 150, 0, 0.4); - border-color: rgba(0, 255, 0, 0.6); - } - - &.unaffordable { - opacity: 0.4; - cursor: not-allowed; - color: var(--game-health); - } - } - - .sell-btn { - background: rgba(100, 50, 0, 0.3); - border-color: rgba(255, 200, 0, 0.3); - color: var(--game-gold); - - &:hover { - background: rgba(150, 75, 0, 0.4); - border-color: rgba(255, 200, 0, 0.6); - } - - &.confirm { - background: rgba(200, 50, 0, 0.5); - border-color: rgba(255, 100, 0, 0.8); - color: var(--game-health); - animation: pulse-warn 0.5s ease-in-out infinite alternate; - } - } - - @keyframes pulse-warn { - from { opacity: 0.8; } - to { opacity: 1; } - } - - .max-level-badge { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.6875rem; - color: var(--game-gold); - text-transform: uppercase; - letter-spacing: 0.0625rem; - } - } - - .spec-choice-panel { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); - - .spec-choice-header { - font-size: var(--font-size-small); - color: var(--game-gold); - text-transform: uppercase; - letter-spacing: 0.0625rem; - text-align: center; - margin-bottom: var(--spacing-xs); - } - - .spec-btn { - display: flex; - flex-direction: column; - gap: var(--border-width-medium); - padding: var(--spacing-sm); - border: var(--border-width-thin) solid var(--panel-blue-border); - border-radius: var(--border-radius-sm); - background: var(--panel-blue-bg); - font-family: "Orbitron", sans-serif; - cursor: pointer; - transition: all var(--transition-medium) ease; - text-align: left; - min-height: 2.75rem; - - &:hover { - background: var(--panel-blue-hover); - border-color: var(--panel-blue-border-hover); - } - - &.unaffordable { - opacity: 0.4; - cursor: not-allowed; - } - - .spec-label { - font-size: var(--font-size-xs); - font-weight: 700; - color: var(--panel-blue-accent); - text-transform: uppercase; - letter-spacing: 0.0313rem; - } - - .spec-desc { - font-size: var(--font-size-2xs); - color: var(--text-muted); - } - - .spec-stats { - font-size: var(--font-size-3xs); - color: var(--text-faint); - font-family: monospace; - } - } - - .spec-cancel-btn { - padding: var(--spacing-xs); - border: var(--border-width-thin) solid var(--border-color); - border-radius: var(--border-radius-sm); - background: var(--panel-neutral-bg); - color: var(--text-dim); - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-2xs); - cursor: pointer; - transition: all var(--transition-medium) ease; - min-height: 2.75rem; - - &:hover { - background: var(--panel-neutral-hover); - color: var(--text-color); - } - } - } - } - - // --- Setup Panel (right side on desktop, bottom sheet on mobile) --- - .setup-panel { - @include glass-panel(rgba(0, 0, 0, 0.85)); - position: absolute; - top: var(--spacing-md); - right: var(--spacing-md); - width: min(18rem, 35vw); - max-height: calc(100vh - var(--spacing-xl)); - overflow-y: auto; - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - padding: var(--spacing-md); - border: var(--border-width-thin) solid var(--theme-purple-a50); - animation: scale-in 0.2s ease-out; - } - - .setup-section-label { - font-size: var(--font-size-2xs); - text-transform: uppercase; - letter-spacing: 0.1rem; - color: var(--text-dim); - font-weight: 600; - } - - .setup-difficulty-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-xs); - } - - .setup-diff-btn { - padding: var(--spacing-xs) var(--spacing-sm); - background: transparent; - border: var(--border-width-thin) solid var(--border-color); - border-radius: var(--border-radius-sm); - color: var(--text-color); - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-xs); - font-weight: 700; - text-transform: uppercase; - cursor: pointer; - transition: all var(--transition-fast) ease; - min-height: 2.75rem; - - &:hover { - background: var(--theme-purple-a20); - border-color: var(--theme-purple-a80); - } - - &.selected { - background: var(--theme-purple-a30); - border-color: var(--theme-purple); - color: var(--theme-purple-lighter); - box-shadow: 0 0 var(--spacing-xs) var(--theme-purple-a50); - } - } - - .setup-endless { - display: flex; - align-items: center; - gap: var(--spacing-xs); - cursor: pointer; - font-size: var(--font-size-small); - color: var(--text-color); - font-family: "Orbitron", sans-serif; - padding: var(--spacing-xs) 0; - border-top: var(--border-width-thin) solid var(--border-color); - - input[type="checkbox"] { - accent-color: var(--theme-purple); - width: 1rem; - height: 1rem; - cursor: pointer; - } - } - - .setup-modifier-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-xs); - } - - .setup-mod-btn { - padding: var(--spacing-xs); - background: transparent; - border: var(--border-width-thin) solid var(--border-color); - border-radius: var(--border-radius-sm); - color: var(--text-color); - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-3xs); - font-weight: 600; - text-transform: uppercase; - cursor: pointer; - transition: all var(--transition-fast) ease; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-height: 2.75rem; - display: flex; - align-items: center; - justify-content: center; - - &:hover { - background: var(--theme-purple-a20); - border-color: var(--theme-purple-a80); - } - - &.active { - background: var(--theme-purple-a30); - border-color: var(--theme-purple); - color: var(--theme-purple-lighter); - } - } - - .setup-score { - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-small); - font-weight: 700; - color: var(--theme-purple-lighter); - text-align: center; - } - - .setup-start-btn { - width: 100%; - padding: var(--spacing-sm) var(--spacing-md); - border: var(--border-width-medium) solid var(--action-success-border); - border-radius: var(--border-radius-md); - background: var(--action-success-bg); - color: var(--action-success); - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-small); - font-weight: 700; - letter-spacing: 0.05em; - cursor: pointer; - transition: all var(--transition-fast) ease; - - &:hover { - background: rgba(0, 180, 80, 0.35); - box-shadow: 0 0 var(--spacing-sm) rgba(0, 255, 100, 0.25); - } - } - - // --- Pause / Speed controls --- - .game-controls { - @include glass-panel(rgba(0, 0, 0, 0.75)); - position: absolute; - bottom: var(--spacing-lg); - right: var(--spacing-lg); - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-xs) var(--spacing-sm); - border: var(--border-width-thin) solid var(--border-color); - - @media screen and (max-width: 768px) { - bottom: var(--spacing-md); - right: var(--spacing-md); - gap: var(--spacing-xs); - padding: var(--spacing-xs); - } - - .speed-controls { - display: flex; - gap: 0.25rem; - } - - .control-btn { - display: flex; - align-items: center; - justify-content: center; - padding: var(--spacing-xs) var(--spacing-sm); - background: transparent; - border: var(--border-width-thin) solid var(--border-color); - border-radius: var(--border-radius-sm); - color: rgba(255, 255, 255, 0.6); - font-family: "Orbitron", sans-serif; - font-size: var(--font-size-small); - cursor: pointer; - transition: all var(--transition-medium) ease; - min-width: 2.75rem; - min-height: 2.75rem; - line-height: 1; - - @media screen and (max-width: 768px) { - min-width: 1.875rem; - font-size: 0.625rem; - padding: var(--spacing-xs) 0.25rem; - } - - &:hover { - background: var(--theme-purple-a20); - border-color: var(--theme-purple-a80); - color: var(--theme-purple-lighter); - } - - &:active { - transform: scale(0.95); - } - - &.active { - background: var(--theme-purple-a30); - border-color: var(--theme-purple); - color: var(--theme-purple-lighter); - box-shadow: 0 0 var(--spacing-xs) var(--theme-purple-a50); - } - } - - .pause-btn { - min-width: 2.5rem; - line-height: 0; - - &.active { - background: rgba(255, 200, 0, 0.15); - border-color: rgba(255, 200, 0, 0.5); - color: var(--game-gold); - box-shadow: 0 0 var(--spacing-xs) rgba(255, 200, 0, 0.3); - } - } - - .path-btn.active { - background: rgba(0, 255, 136, 0.15); - border-color: rgba(0, 255, 136, 0.5); - color: #88ffbb; - box-shadow: 0 0 var(--spacing-xs) rgba(0, 255, 136, 0.3); - } - - .wave-hint { - font-size: 0.5625rem; - color: rgba(255, 255, 255, 0.3); - } - } - - // --- Tower selection --- - .tower-selection { - @include glass-panel(rgba(0, 0, 0, 0.8)); - position: absolute; - bottom: var(--spacing-lg); - left: 50%; - transform: translateX(-50%); - display: flex; - gap: var(--spacing-md); - padding: var(--spacing-sm); - border: var(--border-width-thin) solid var(--border-color); - - @media screen and (max-width: 768px) { - bottom: var(--spacing-md); - gap: var(--spacing-sm); - padding: var(--spacing-xs); - width: 90%; - justify-content: center; - } - - .tower-btn { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - gap: var(--spacing-xs); - padding: var(--spacing-sm); - background: transparent; - border: var(--border-width-medium) solid var(--border-color); - border-radius: var(--border-radius-sm); - cursor: pointer; - transition: all var(--transition-medium) cubic-bezier(0.4, 0, 0.2, 1); - min-width: 5rem; - - @media screen and (max-width: 768px) { - min-width: 4rem; - padding: var(--spacing-xs); - } - - &:hover { - background: rgba(255, 255, 255, 0.1); - border-color: var(--border-color-strong); - transform: translateY(-0.125rem); - } - - &:active { - transform: translateY(0.0625rem); - } - - &.selected { - background: rgba(255, 255, 255, 0.15); - border-color: var(--text-color); - box-shadow: 0 0 var(--spacing-sm) rgba(255, 255, 255, 0.3); - } - - &.unaffordable { - opacity: 0.4; - - .tower-cost { color: var(--game-health); } - } - - .tower-icon { - width: 2.5rem; - height: 2.5rem; - border-radius: var(--border-radius-sm); - display: flex; - align-items: center; - justify-content: center; - position: relative; - - @media screen and (max-width: 768px) { - width: 2rem; - height: 2rem; - } - - &.basic::before { - content: ''; - width: 1.25rem; - height: 1.25rem; - background: var(--tower-color-basic); - border-radius: 50%; - box-shadow: 0 0 var(--spacing-xs) var(--tower-glow-basic); - } - - &.sniper::before { - content: ''; - width: 0; - height: 0; - border-left: 0.75rem solid transparent; - border-right: 0.75rem solid transparent; - border-bottom: 1.5rem solid var(--tower-color-sniper); - // CSS border triangles don't support box-shadow — use filter instead - filter: drop-shadow(0 0 4px var(--tower-glow-sniper)); - } - - &.splash::before { - content: ''; - width: 1.25rem; - height: 1.25rem; - background: var(--tower-color-splash); - box-shadow: 0 0 var(--spacing-xs) var(--tower-glow-splash); - } - - &.slow::before { - content: ''; - width: 0.9rem; - height: 0.9rem; - background: var(--tower-color-slow); - transform: rotate(45deg); - box-shadow: 0 0 var(--spacing-xs) var(--tower-glow-slow); - } - - &.chain::before { - content: ''; - width: 0.25rem; - height: 1.5rem; - background: var(--tower-color-chain); - border-radius: 0.125rem; - box-shadow: 0 0 var(--spacing-xs) var(--tower-glow-chain); - } - - &.mortar::before { - content: ''; - width: 1.4rem; - height: 0.75rem; - background: var(--tower-color-mortar); - border-radius: 0.1rem; - box-shadow: 0 0 var(--spacing-xs) var(--tower-glow-mortar); - } - } - - .tower-name { - font-size: var(--font-size-small); - color: var(--text-color); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.0313rem; - - @media screen and (max-width: 768px) { - font-size: 0.75rem; - } - } - - .tower-cost { - font-size: 0.6875rem; - color: var(--game-gold); - font-family: "Orbitron", sans-serif; - } - - .tower-hotkey { - font-size: 0.5rem; - color: rgba(255, 255, 255, 0.25); - font-family: "Orbitron", sans-serif; - letter-spacing: 0.0313rem; - } - - // --- Hover tooltip --- - .tower-tooltip { - position: absolute; - bottom: calc(100% + 0.625rem); - left: 50%; - transform: translateX(-50%); - min-width: 9rem; - padding: var(--spacing-sm) var(--spacing-md); - background: var(--nav-bg, rgba(10, 5, 20, 0.96)); - border: var(--border-width-thin) solid var(--theme-purple-a50); - border-radius: var(--border-radius-md); - backdrop-filter: blur(0.75rem); - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: opacity 0.15s ease, visibility 0.15s ease; - z-index: calc(var(--z-index-overlay) + 10); - white-space: nowrap; - word-wrap: break-word; - box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.6), 0 0 0.5rem var(--theme-purple-a30, rgba(128, 0, 255, 0.2)); - - @media screen and (max-width: 480px) { - max-width: min(200px, 50vw); - white-space: normal; - } - - .tooltip-stat { - display: flex; - justify-content: space-between; - gap: var(--spacing-md); - font-family: "Orbitron", sans-serif; - font-size: clamp(0.5rem, 1.5vw, 0.625rem); - margin-bottom: 0.1875rem; - - .tooltip-label { - color: rgba(255, 255, 255, 0.45); - letter-spacing: 0.0625rem; - } - - .tooltip-val { - color: var(--text-color); - font-weight: 600; - } - } - - .tooltip-desc { - margin-top: var(--spacing-xs); - padding-top: var(--spacing-xs); - border-top: var(--border-width-thin) solid rgba(255, 255, 255, 0.1); - font-family: "Orbitron", sans-serif; - font-size: 0.5rem; - color: var(--theme-purple-mid, rgba(160, 100, 255, 0.8)); - text-align: center; - line-height: 1.4; - white-space: normal; - } - - // Downward-pointing caret - .tooltip-arrow { - position: absolute; - bottom: -0.3125rem; - left: 50%; - transform: translateX(-50%) rotate(45deg); - width: 0.375rem; - height: 0.375rem; - background: var(--nav-bg, rgba(10, 5, 20, 0.96)); - border-right: var(--border-width-thin) solid var(--theme-purple-a50); - border-bottom: var(--border-width-thin) solid var(--theme-purple-a50); - } - } - - &:hover .tower-tooltip { - opacity: 1; - visibility: visible; - } - } - } -} - -// --- Responsive breakpoints --- -// Tablet (max-width: 768px) enhancements not covered by nested queries above -@media screen and (max-width: 768px) { - .board-container { - // Game controls: stack vertically on tablet - .game-controls { - flex-direction: column; - align-items: stretch; - - .speed-controls { - justify-content: center; - } - } - - // Wave control button: ensure minimum 44px tap target - .wave-control .wave-btn { - min-height: 2.75rem; // 44px - } - - // Setup panel: bottom sheet on mobile - .setup-panel { - position: fixed; - top: auto; - bottom: 0; - left: 0; - right: 0; - width: 100%; - max-height: 55vh; - border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; - padding: var(--spacing-sm) var(--spacing-md); - } - - .setup-difficulty-grid { - grid-template-columns: repeat(4, 1fr); - } - - .setup-modifier-grid { - grid-template-columns: repeat(3, 1fr); - } - - // Victory/defeat overlay: tighten padding - .game-overlay .overlay-content { - padding: var(--spacing-xl); - width: 90vw; - - h2 { - font-size: var(--font-size-title); - letter-spacing: var(--spacing-xs); - } - - .final-score { - font-size: var(--font-size-base); - margin-bottom: var(--spacing-md); - } - } - - // HUD: shrink label + value sizes - .game-hud .hud-stat { - .hud-label { - font-size: 0.5625rem; - } - - .hud-value { - font-size: var(--font-size-small); - } - } - } -} - -// Mobile (max-width: 480px) overrides -@media screen and (max-width: 480px) { - .board-container { - // Tower selection: switch to 3-column grid - .tower-selection { - display: grid; - grid-template-columns: repeat(3, 1fr); - bottom: 0; - left: 0; - right: 0; - transform: none; - width: 100%; - border-radius: var(--border-radius-md) var(--border-radius-md) 0 0; - padding: var(--spacing-sm); - gap: var(--spacing-xs); - border-left: none; - border-right: none; - border-bottom: none; - - .tower-btn { - min-width: unset; - width: 100%; - padding: var(--spacing-xs); - gap: 0.125rem; - - .tower-icon { - width: 1.75rem; - height: 1.75rem; - } - - .tower-name { - font-size: 0.625rem; - } - - .tower-cost { - font-size: 0.5625rem; - } - } - } - - // Tower info panel: full-width at bottom above tower selection - .tower-info-panel { - left: 0; - right: 0; - bottom: calc(var(--spacing-lg) + 8rem); // 8rem = 2-row tower grid height (mobile grid layout) - transform: none; - min-width: unset; - width: 100%; - border-radius: var(--border-radius-md) var(--border-radius-md) 0 0; - border-left: none; - border-right: none; - border-bottom: none; - } - - // Game HUD: further reduce on mobile - .game-hud { - gap: var(--spacing-sm); - padding: var(--spacing-xs) var(--spacing-sm); - font-size: 0.625rem; - - .hud-stat { - .hud-label { - font-size: 0.5rem; - letter-spacing: 0; - } - - .hud-value { - font-size: 0.6875rem; - } - } - } - - // Wave control button: larger tap target (min 44px touch target) - .wave-control .wave-btn { - min-height: 2.75rem; // 44px - padding: var(--spacing-sm) var(--spacing-lg); - font-size: var(--font-size-base); - } - - // Game controls: stack vertically, right side - .game-controls { - flex-direction: column; - align-items: stretch; - bottom: calc(var(--spacing-sm) + 8.5rem); // above tower grid - right: var(--spacing-sm); - gap: var(--spacing-xs); - padding: var(--spacing-xs); - max-width: 100vw; - overflow-x: hidden; - - .control-btn { - min-width: 2.75rem; // 44px tap target - min-height: 2.75rem; - padding: var(--spacing-xs) var(--spacing-sm); - font-size: 0.625rem; - justify-content: center; - } - - .pause-btn { - min-width: 2.75rem; - min-height: 2.75rem; - line-height: 0; - } - - .speed-controls { - flex-direction: column; - gap: var(--spacing-xs); - } - } - - // Setup panel: constrain height on short screens - .setup-panel { - max-height: 45vh; - } - - // Victory/defeat overlay: full-screen with adjusted padding - .game-overlay { - align-items: flex-end; - - .overlay-content { - width: 100%; - max-height: 85vh; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - padding: var(--spacing-xl) var(--spacing-md); - border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; - border-left: none; - border-right: none; - border-bottom: none; - - h2 { - font-size: var(--font-size-large); - letter-spacing: var(--spacing-xs); - margin-bottom: var(--spacing-sm); - } - - .final-score { - font-size: var(--font-size-base); - margin-bottom: var(--spacing-md); - } - - .overlay-actions { - flex-direction: column; - gap: var(--spacing-sm); - - .restart-btn, .edit-map-btn { - width: 100%; - padding: var(--spacing-sm) var(--spacing-md); - min-height: 2.75rem; // 44px tap target - } - } - } - } - } } // --- Animations --- -@keyframes pulse-critical { - from { opacity: 1; } - to { opacity: 0.5; } -} - @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } @@ -1532,6 +257,18 @@ } } +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .loading-spinner { + animation: none; + border-top-color: var(--theme-purple-light); + opacity: 0.6; + } +} + .help-overlay { position: absolute; top: 0; @@ -1585,3 +322,72 @@ } } } + +%fullscreen-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.95); + z-index: var(--z-index-modal); + color: var(--text-color); + font-family: 'Orbitron', sans-serif; +} + +%overlay-content { + text-align: center; + max-width: 28rem; + padding: var(--spacing-xl); + + h2 { + color: var(--game-defeat); + font-size: var(--font-size-title); + margin-bottom: var(--spacing-md); + } + + p { + color: var(--text-muted); + margin-bottom: var(--spacing-sm); + font-family: sans-serif; + line-height: 1.4; + } +} + +.init-error-overlay { + @extend %fullscreen-overlay; +} + +.init-error-content { + @extend %overlay-content; + + .init-error-hint { + font-size: var(--font-size-small); + color: var(--text-dim); + } +} + +.context-lost-overlay { + @extend %fullscreen-overlay; +} + +.context-lost-content { + @extend %overlay-content; +} + +.init-error-btn { + margin-top: var(--spacing-lg); + padding: var(--spacing-sm) var(--spacing-xl); + background: var(--theme-purple); + color: var(--text-color); + border: none; + border-radius: var(--border-radius-lg); + font-family: 'Orbitron', sans-serif; + font-size: var(--font-size-small); + cursor: pointer; + transition: opacity var(--transition-fast); + + &:hover { + opacity: 0.85; + } +} diff --git a/src/app/game/game-board/game-board.component.spec.ts b/src/app/game/game-board/game-board.component.spec.ts index 6cb06178..c822f2e4 100644 --- a/src/app/game/game-board/game-board.component.spec.ts +++ b/src/app/game/game-board/game-board.component.spec.ts @@ -4,6 +4,12 @@ import { RouterTestingModule } from '@angular/router/testing'; import * as THREE from 'three'; import { GameBoardComponent } from './game-board.component'; +import { GameHUDComponent } from './components/game-hud/game-hud.component'; +import { GameResultsComponent } from './components/game-results/game-results.component'; +import { GameSetupComponent } from './components/game-setup/game-setup.component'; +import { TowerInfoPanelComponent } from './components/tower-info-panel/tower-info-panel.component'; +import { TowerSelectionBarComponent } from './components/tower-selection-bar/tower-selection-bar.component'; +import { GameControlsComponent } from './components/game-controls/game-controls.component'; import { GameBoardService } from './game-board.service'; import { MapBridgeService } from './services/map-bridge.service'; import { GameStateService } from './services/game-state.service'; @@ -44,7 +50,7 @@ describe('GameBoardComponent', () => { settingsSpy.get.and.returnValue({ audioMuted: false, difficulty: 'normal' as any, gameSpeed: 1 }); await TestBed.configureTestingModule({ - declarations: [ GameBoardComponent ], + declarations: [ GameBoardComponent, GameHUDComponent, GameResultsComponent, GameSetupComponent, TowerInfoPanelComponent, TowerSelectionBarComponent, GameControlsComponent ], imports: [ RouterTestingModule ], providers: [ GameBoardService, @@ -72,6 +78,10 @@ describe('GameBoardComponent', () => { expect(component).toBeTruthy(); }); + it('isLoading should default to true before initialization', () => { + expect(component.isLoading).toBeTrue(); + }); + describe('goToEditor', () => { it('should navigate to /edit', () => { const router = TestBed.inject(Router); @@ -248,6 +258,134 @@ describe('GameBoardComponent', () => { fireKey('3'); expect(component.selectedTowerType).toBe(TowerType.SNIPER); }); + + it('pressing Space calls startWave', () => { + spyOn(component, 'startWave'); + fireKey(' '); + expect(component.startWave).toHaveBeenCalled(); + }); + + it('pressing r toggles range indicators', () => { + spyOn(component, 'toggleAllRanges'); + fireKey('r'); + expect(component.toggleAllRanges).toHaveBeenCalled(); + }); + + it('pressing R (uppercase) also toggles range indicators', () => { + spyOn(component, 'toggleAllRanges'); + fireKey('R'); + expect(component.toggleAllRanges).toHaveBeenCalled(); + }); + + it('pressing h toggles help overlay', () => { + expect(component.showHelpOverlay).toBeFalse(); + fireKey('h'); + expect(component.showHelpOverlay).toBeTrue(); + }); + + it('pressing H (uppercase) also toggles help overlay', () => { + expect(component.showHelpOverlay).toBeFalse(); + fireKey('H'); + expect(component.showHelpOverlay).toBeTrue(); + }); + + it('pressing h twice toggles help overlay off', () => { + fireKey('h'); + expect(component.showHelpOverlay).toBeTrue(); + fireKey('h'); + expect(component.showHelpOverlay).toBeFalse(); + }); + + it('pressing m toggles minimap visibility', () => { + const minimap = fixture.debugElement.injector.get(MinimapService); + spyOn(minimap, 'toggleVisibility'); + fireKey('m'); + expect(minimap.toggleVisibility).toHaveBeenCalled(); + }); + + it('pressing M (uppercase) also toggles minimap visibility', () => { + const minimap = fixture.debugElement.injector.get(MinimapService); + spyOn(minimap, 'toggleVisibility'); + fireKey('M'); + expect(minimap.toggleVisibility).toHaveBeenCalled(); + }); + + it('pressing v calls togglePathOverlay', () => { + spyOn(component, 'togglePathOverlay'); + fireKey('v'); + expect(component.togglePathOverlay).toHaveBeenCalled(); + }); + + it('pressing V (uppercase) also calls togglePathOverlay', () => { + spyOn(component, 'togglePathOverlay'); + fireKey('V'); + expect(component.togglePathOverlay).toHaveBeenCalled(); + }); + + it('Space is ignored in VICTORY phase', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + gameStateService.setPhase(GamePhase.VICTORY); + spyOn(component, 'startWave'); + fireKey(' '); + expect(component.startWave).not.toHaveBeenCalled(); + }); + + it('Space is ignored in DEFEAT phase', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + gameStateService.setPhase(GamePhase.DEFEAT); + spyOn(component, 'startWave'); + fireKey(' '); + expect(component.startWave).not.toHaveBeenCalled(); + }); + + it('P is ignored in VICTORY phase', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + gameStateService.setPhase(GamePhase.VICTORY); + spyOn(component, 'togglePause'); + fireKey('p'); + expect(component.togglePause).not.toHaveBeenCalled(); + }); + + it('R is ignored in DEFEAT phase', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + gameStateService.setPhase(GamePhase.DEFEAT); + spyOn(component, 'toggleAllRanges'); + fireKey('r'); + expect(component.toggleAllRanges).not.toHaveBeenCalled(); + }); + + it('H is ignored in VICTORY phase', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + gameStateService.setPhase(GamePhase.VICTORY); + component.showHelpOverlay = false; + fireKey('h'); + expect(component.showHelpOverlay).toBeFalse(); + }); + + it('M is ignored in DEFEAT phase', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + const minimap = fixture.debugElement.injector.get(MinimapService); + spyOn(minimap, 'toggleVisibility'); + gameStateService.setPhase(GamePhase.DEFEAT); + fireKey('m'); + expect(minimap.toggleVisibility).not.toHaveBeenCalled(); + }); + + it('Escape is ignored in VICTORY phase', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + gameStateService.setPhase(GamePhase.VICTORY); + component.selectedTowerType = TowerType.SNIPER; + fireKey('Escape'); + expect(component.selectedTowerType).toBe(TowerType.SNIPER); + }); + + it('V is ignored in DEFEAT phase', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + gameStateService.setPhase(GamePhase.DEFEAT); + spyOn(component, 'togglePathOverlay'); + fireKey('v'); + expect(component.togglePathOverlay).not.toHaveBeenCalled(); + }); }); describe('scoreBreakdown', () => { @@ -412,9 +550,10 @@ describe('GameBoardComponent', () => { expect(removedEvents).toContain('touchend'); }); - it('touchStartHandler records start position and resets drag flag', () => { + it('touchStartHandler records start position and resets drag and multi-touch flags', () => { (component as any).setupTouchInteraction(); (component as any).touchIsDragging = true; + (component as any).touchWasMultiTouch = true; const touch = { clientX: 150, clientY: 200 } as Touch; const event = { preventDefault: () => {}, touches: [touch] } as unknown as TouchEvent; @@ -424,9 +563,10 @@ describe('GameBoardComponent', () => { expect((component as any).touchStartX).toBe(150); expect((component as any).touchStartY).toBe(200); expect((component as any).touchIsDragging).toBeFalse(); + expect((component as any).touchWasMultiTouch).toBeFalse(); }); - it('touchStartHandler records pinch start distance for two-finger touch', () => { + it('touchStartHandler records pinch start distance and sets multi-touch flag for two-finger touch', () => { (component as any).setupTouchInteraction(); const t0 = { clientX: 0, clientY: 0 } as Touch; @@ -437,6 +577,7 @@ describe('GameBoardComponent', () => { // distance = sqrt(30^2 + 40^2) = sqrt(900+1600) = 50 expect((component as any).pinchStartDistance).toBe(50); + expect((component as any).touchWasMultiTouch).toBeTrue(); }); it('touchMoveHandler sets touchIsDragging to true when movement exceeds threshold', () => { @@ -473,6 +614,26 @@ describe('GameBoardComponent', () => { expect((component as any).touchIsDragging).toBeFalse(); }); + it('touchMoveHandler sets multi-touch flag when two fingers detected during move', () => { + (component as any).setupTouchInteraction(); + (component as any).touchWasMultiTouch = false; + (component as any).pinchStartDistance = 50; + (component as any).camera = { position: new THREE.Vector3(0, 10, 0) }; + (component as any).controls = { target: new THREE.Vector3(0, 0, 0), dispose: () => {} }; + + const t0 = { clientX: 0, clientY: 0 } as Touch; + const t1 = { clientX: 30, clientY: 40 } as Touch; + const event = { preventDefault: () => {}, touches: [t0, t1] } as unknown as TouchEvent; + + (component as any).touchMoveHandler(event); + + expect((component as any).touchWasMultiTouch).toBeTrue(); + + // Prevent cleanup crash — reset partial mocks + (component as any).camera = null; + (component as any).controls = null; + }); + it('touchEndHandler calls handleTapAsClick for a short tap with no drag', () => { (component as any).setupTouchInteraction(); (component as any).touchStartX = 100; @@ -520,9 +681,10 @@ describe('GameBoardComponent', () => { expect((component as any).handleTapAsClick).not.toHaveBeenCalled(); }); - it('touchEndHandler resets touchIsDragging and pinchStartDistance', () => { + it('touchEndHandler resets touchIsDragging, touchWasMultiTouch, and pinchStartDistance', () => { (component as any).setupTouchInteraction(); (component as any).touchIsDragging = true; + (component as any).touchWasMultiTouch = true; (component as any).pinchStartDistance = 50; // Long press — no tap @@ -534,8 +696,91 @@ describe('GameBoardComponent', () => { (component as any).touchEndHandler(event); expect((component as any).touchIsDragging).toBeFalse(); + expect((component as any).touchWasMultiTouch).toBeFalse(); expect((component as any).pinchStartDistance).toBe(0); }); + + it('touchEndHandler does not call handleTapAsClick after multi-touch gesture', () => { + (component as any).setupTouchInteraction(); + (component as any).touchStartX = 100; + (component as any).touchStartY = 200; + (component as any).touchStartTime = performance.now() - 50; // within tap threshold + (component as any).touchIsDragging = false; + (component as any).touchWasMultiTouch = true; // pinch occurred + + spyOn(component as any, 'handleTapAsClick'); + + const touch = { clientX: 100, clientY: 200 } as Touch; + const event = { preventDefault: () => {}, changedTouches: [touch] } as unknown as TouchEvent; + + (component as any).touchEndHandler(event); + + expect((component as any).handleTapAsClick).not.toHaveBeenCalled(); + }); + + it('touchStartHandler sets multi-touch flag for 3+ finger touch', () => { + (component as any).setupTouchInteraction(); + + const t0 = { clientX: 0, clientY: 0 } as Touch; + const t1 = { clientX: 30, clientY: 40 } as Touch; + const t2 = { clientX: 60, clientY: 80 } as Touch; + const event = { preventDefault: () => {}, touches: [t0, t1, t2] } as unknown as TouchEvent; + + (component as any).touchStartHandler(event); + + expect((component as any).touchWasMultiTouch).toBeTrue(); + }); + + it('single touch after multi-touch clears multi-touch flag and allows tap', () => { + (component as any).setupTouchInteraction(); + + // First: multi-touch gesture + const t0 = { clientX: 0, clientY: 0 } as Touch; + const t1 = { clientX: 30, clientY: 40 } as Touch; + const pinchEvent = { preventDefault: () => {}, touches: [t0, t1] } as unknown as TouchEvent; + (component as any).touchStartHandler(pinchEvent); + expect((component as any).touchWasMultiTouch).toBeTrue(); + + // Then: new single-touch gesture starts — should clear multi-touch flag + const singleTouch = { clientX: 100, clientY: 200 } as Touch; + const singleEvent = { preventDefault: () => {}, touches: [singleTouch] } as unknown as TouchEvent; + (component as any).touchStartHandler(singleEvent); + + expect((component as any).touchWasMultiTouch).toBeFalse(); + }); + + it('handleInteraction early-returns during VICTORY phase without raycasting', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + gameStateService.setPhase(GamePhase.VICTORY); + + // Set up minimal renderer stub with a mock raycaster to verify no raycasting occurs + const mockRaycaster = jasmine.createSpyObj('Raycaster', ['setFromCamera', 'intersectObjects']); + (component as any).raycaster = mockRaycaster; + (component as any).renderer = { + domElement: document.createElement('canvas'), + dispose: () => {} + }; + + (component as any).handleInteraction(100, 200); + + expect(mockRaycaster.setFromCamera).not.toHaveBeenCalled(); + }); + + it('handleInteraction early-returns during DEFEAT phase without raycasting', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + gameStateService.setPhase(GamePhase.DEFEAT); + + const mockRaycaster = jasmine.createSpyObj('Raycaster', ['setFromCamera', 'intersectObjects']); + (component as any).raycaster = mockRaycaster; + (component as any).renderer = { + domElement: document.createElement('canvas'), + dispose: () => {} + }; + + (component as any).handleInteraction(50, 75); + + expect(mockRaycaster.setFromCamera).not.toHaveBeenCalled(); + }); }); describe('achievementDetails', () => { @@ -704,4 +949,68 @@ describe('GameBoardComponent', () => { expect(component.showPathOverlay).toBeTrue(); }); }); + + describe('recordGameEndIfNeeded idempotence', () => { + it('should only call recordGameEnd once even when invoked twice', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + gameStateService.setPhase(GamePhase.VICTORY); + + // Reset call count from any prior setup + playerProfileSpy.recordGameEnd.calls.reset(); + + (component as unknown as Record void>)['recordGameEndIfNeeded'](); + (component as unknown as Record void>)['recordGameEndIfNeeded'](); + + expect(playerProfileSpy.recordGameEnd).toHaveBeenCalledTimes(1); + }); + }); + + describe('contextLost flag', () => { + it('should be truthy when set to true', () => { + component.contextLost = true; + expect(component.contextLost).toBeTruthy(); + }); + }); + + describe('WebGL context loss handling', () => { + let canvas: HTMLCanvasElement; + + beforeEach(() => { + canvas = document.createElement('canvas'); + const renderer = { domElement: canvas, dispose: () => {}, setSize: () => {}, setPixelRatio: () => {} } as unknown as THREE.WebGLRenderer; + (component as unknown as Record)['renderer'] = renderer; + // Wire up the context loss/restore handlers on the canvas + const lostHandler = (event: Event) => { event.preventDefault(); component.contextLost = true; }; + const restoredHandler = () => { component.contextLost = false; }; + (component as unknown as Record)['contextLostHandler'] = lostHandler; + (component as unknown as Record)['contextRestoredHandler'] = restoredHandler; + canvas.addEventListener('webglcontextlost', lostHandler); + canvas.addEventListener('webglcontextrestored', restoredHandler); + }); + + afterEach(() => { + canvas.removeEventListener('webglcontextlost', + (component as unknown as Record void>)['contextLostHandler']); + canvas.removeEventListener('webglcontextrestored', + (component as unknown as Record void>)['contextRestoredHandler']); + }); + + it('should set contextLost to true when webglcontextlost fires', () => { + expect(component.contextLost).toBeFalse(); + + const lostEvent = new Event('webglcontextlost', { cancelable: true }); + canvas.dispatchEvent(lostEvent); + + expect(component.contextLost).toBeTrue(); + expect(lostEvent.defaultPrevented).toBeTrue(); + }); + + it('should set contextLost back to false when webglcontextrestored fires', () => { + component.contextLost = true; + + canvas.dispatchEvent(new Event('webglcontextrestored')); + + expect(component.contextLost).toBeFalse(); + }); + }); }); diff --git a/src/app/game/game-board/game-board.component.ts b/src/app/game/game-board/game-board.component.ts index c77c4fec..a07692b1 100644 --- a/src/app/game/game-board/game-board.component.ts +++ b/src/app/game/game-board/game-board.component.ts @@ -30,14 +30,15 @@ import { BlockType } from './models/game-board-tile'; import { DifficultyLevel, DIFFICULTY_PRESETS, GamePhase, GameSpeed, GameState, VALID_GAME_SPEEDS } from './models/game-state.model'; import { GameModifier, GAME_MODIFIER_CONFIGS, GameModifierConfig, calculateModifierScoreMultiplier } from './models/game-modifier.model'; import { calculateScoreBreakdown, ScoreBreakdown } from './models/score.model'; -import { SCENE_CONFIG, POST_PROCESSING_CONFIG, SKYBOX_CONFIG } from './constants/rendering.constants'; +import { SCENE_CONFIG, POST_PROCESSING_CONFIG, SKYBOX_CONFIG, sinNormalized } from './constants/rendering.constants'; import { KEY_LIGHT, FILL_LIGHT, RIM_LIGHT, UNDER_LIGHT, ACCENT_LIGHTS, HEMISPHERE_LIGHT } from './constants/lighting.constants'; -import { CAMERA_CONFIG, CONTROLS_CONFIG } from './constants/camera.constants'; +import { CAMERA_CONFIG, CONTROLS_CONFIG, MOUSE_ACTION_DISABLED } from './constants/camera.constants'; import { PARTICLE_CONFIG, PARTICLE_COLORS } from './constants/particle.constants'; -import { TOWER_VISUAL_CONFIG, RANGE_PREVIEW_CONFIG, TILE_EMISSIVE } from './constants/ui.constants'; +import { TOWER_VISUAL_CONFIG, RANGE_PREVIEW_CONFIG, TILE_EMISSIVE, ENEMY_VISUAL_CONFIG, GAME_TIMING_CONFIG } from './constants/ui.constants'; import { SCREEN_SHAKE_CONFIG, TOWER_ANIM_CONFIG, TILE_PULSE_CONFIG } from './constants/effects.constants'; import { TOUCH_CONFIG } from './constants/touch.constants'; import { PHYSICS_CONFIG } from './constants/physics.constants'; +import { MOBILE_CONFIG } from './constants/mobile.constants'; import { ENEMY_STATS } from './models/enemy.model'; import { WavePreviewEntry, getWavePreview } from './models/wave-preview.model'; import { PathVisualizationService } from './services/path-visualization.service'; @@ -84,6 +85,8 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { private raycaster = new THREE.Raycaster(); private mouse = new THREE.Vector2(); private tileMeshes: Map = new Map(); + private tileMeshArray: THREE.Mesh[] = []; + private towerChildrenArray: THREE.Object3D[] = []; private hoveredTile: THREE.Mesh | null = null; private selectedTile: { row: number, col: number } | null = null; @@ -121,6 +124,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { allModifiers = Object.values(GameModifier); activeModifiers = new Set(); modifierScoreMultiplier = 1.0; + effectiveTowerCosts = new Map(); towerTypes: { type: TowerType; hotkey: string }[] = Object.entries(TOWER_HOTKEYS).map( ([key, type]) => ({ type, hotkey: key }) ); @@ -143,6 +147,9 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { targetingModeLabels = TARGETING_MODE_LABELS; showHelpOverlay = false; pathBlocked = false; + initError: string | null = null; + isLoading = true; + contextLost = false; private pathBlockedTimerId: ReturnType | null = null; private rangeRingMeshes: THREE.Mesh[] = []; @@ -163,10 +170,13 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { private touchStartHandler: (event: TouchEvent) => void = () => {}; private touchMoveHandler: (event: TouchEvent) => void = () => {}; private touchEndHandler: (event: TouchEvent) => void = () => {}; + private contextLostHandler: (event: Event) => void = () => {}; + private contextRestoredHandler: (event: Event) => void = () => {}; private touchStartX = 0; private touchStartY = 0; private touchStartTime = 0; private touchIsDragging = false; + private touchWasMultiTouch = false; private pinchStartDistance = 0; // Audio state exposed to template @@ -185,6 +195,35 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { .filter((a): a is Achievement => a != null); } + /** Rebuilds the flat array of tower child meshes used for raycasting. Call after any tower add/remove. */ + private rebuildTowerChildrenCache(): void { + this.towerChildrenArray = []; + this.towerMeshes.forEach(g => g.traverse(child => { + if (child instanceof THREE.Mesh) this.towerChildrenArray.push(child); + })); + } + + /** Records game-end stats for player profile. Safe to call multiple times — fires once per game. */ + private recordGameEndIfNeeded(): void { + const phase = this.gameStateService.getState().phase; + if ((phase === GamePhase.VICTORY || phase === GamePhase.DEFEAT) && !this.gameEndRecorded) { + this.gameEndRecorded = true; + const endState = this.gameStateService.getState(); + const stats = this.gameStatsService.getStats(); + const totalKills = Object.values(stats.killsByTowerType).reduce((a, b) => a + b, 0); + const gameEndStats: GameEndStats = { + isVictory: phase === GamePhase.VICTORY, + score: endState.score, + enemiesKilled: totalKills, + goldEarned: stats.totalGoldEarned, + wavesCompleted: endState.wave, + livesLost: DIFFICULTY_PRESETS[endState.difficulty].lives - endState.lives, + }; + this.newlyUnlockedAchievements = this.playerProfileService.recordGameEnd(gameEndStats); + this.updateAchievementDetails(); + } + } + // FPS exposed to template get fps(): number { return this.fpsCounterService.getFps(); } @@ -294,25 +333,32 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { // Seed initial wave preview for the first wave const initialState = this.gameStateService.getState(); this.wavePreview = getWavePreview(initialState.wave + 1, initialState.isEndless); + + this.updateEffectiveCosts(); } ngAfterViewInit(): void { - this.initializeRenderer(); - this.initializePostProcessing(); - this.initializeControls(); - this.setupMouseInteraction(); - this.setupTouchInteraction(); - this.setupKeyboardControls(); - this.minimapService.init(this.canvasContainer.nativeElement); - this.animate(); + try { + this.initializeRenderer(); + this.initializePostProcessing(); + this.initializeControls(); + this.setupMouseInteraction(); + this.setupTouchInteraction(); + this.setupKeyboardControls(); + this.minimapService.init(this.canvasContainer.nativeElement); + this.animate(); + this.isLoading = false; + } catch (error) { + this.initError = error instanceof Error + ? error.message + : 'Failed to initialize game renderer'; + this.isLoading = false; + console.error('Game initialization failed:', error); + } } // --- Public methods for template --- - levelStars(count: number): number[] { - return Array(Math.max(0, count)).fill(0); - } - toggleAudio(): void { this.audioService.toggleMute(); this.settingsService.update({ audioMuted: this.audioService.isMuted }); @@ -336,11 +382,18 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.activeModifiers ); this.towerCombatService.setTowerDamageMultiplier(this.gameStateService.getModifierEffects().towerDamageMultiplier ?? 1); + this.updateEffectiveCosts(); } - getEffectiveTowerCost(type: TowerType): number { + private updateEffectiveCosts(): void { const costMult = this.gameStateService.getModifierEffects().towerCostMultiplier ?? 1; - return Math.round(TOWER_CONFIGS[type].cost * costMult); + for (const type of Object.values(TowerType)) { + this.effectiveTowerCosts.set(type, Math.round(TOWER_CONFIGS[type].cost * costMult)); + } + } + + getEffectiveTowerCost(type: TowerType): number { + return this.effectiveTowerCosts.get(type) ?? TOWER_CONFIGS[type].cost; } selectTowerType(type: TowerType): void { @@ -389,9 +442,8 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { towerMesh.scale.set(scale, scale, scale); // Boost emissive intensity on upgrade (skip animated children — their emissive is driven per-frame) - const animatedNames = new Set(['tip', 'orb']); towerMesh.traverse(child => { - if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial && !animatedNames.has(child.name)) { + if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial && !TOWER_VISUAL_CONFIG.animatedMeshNames.has(child.name)) { child.material.emissiveIntensity = TOWER_VISUAL_CONFIG.emissiveBase + (newLevel - 1) * TOWER_VISUAL_CONFIG.emissiveIncrement; } }); @@ -400,6 +452,11 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { // Refresh info panel this.refreshTowerInfoPanel(); this.showRangePreview(this.selectedTowerInfo); + + // Refresh all-range overlay if active so rings reflect upgraded stats + if (this.showAllRanges) { + this.refreshAllRanges(); + } } selectSpecialization(spec: TowerSpecialization): void { @@ -438,6 +495,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { } }); this.towerMeshes.delete(this.selectedTowerInfo.id); + this.rebuildTowerChildrenCache(); } // Restore tile to BASE @@ -456,11 +514,6 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.towerCombatService.cycleTargetingMode(this.selectedTowerInfo.id); } - specLabel(tower: PlacedTower): string { - if (!tower.specialization) return ''; - return TOWER_SPECIALIZATIONS[tower.type][tower.specialization].label; - } - deselectTower(): void { this.selectedTowerInfo = null; this.selectedTowerStats = null; @@ -621,6 +674,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.lastTime = 0; this.elapsedTimeAccumulator = 0; this.physicsAccumulator = 0; + this.updateEffectiveCosts(); } @@ -671,6 +725,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { }); }); this.towerMeshes.clear(); + this.towerChildrenArray = []; // Clean up tile meshes this.tileMeshes.forEach(mesh => { @@ -679,6 +734,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { disposeMaterial(mesh.material); }); this.tileMeshes.clear(); + this.tileMeshArray = []; // Clean up grid lines if (this.gridLines) { @@ -757,11 +813,21 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { } private initializeRenderer(): void { + const testCanvas = document.createElement('canvas'); + const gl = testCanvas.getContext('webgl') || testCanvas.getContext('experimental-webgl'); + if (!gl) { + throw new Error('WebGL is not supported by your browser'); + } + // Release the test WebGL context to free the browser context slot + (gl as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext(); + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); - this.renderer.setPixelRatio(window.devicePixelRatio); + const isMobile = window.innerWidth <= MOBILE_CONFIG.breakpoint; + const maxPixelRatio = isMobile ? MOBILE_CONFIG.maxPixelRatio : window.devicePixelRatio; + this.renderer.setPixelRatio(Math.min(maxPixelRatio, window.devicePixelRatio)); this.renderer.setSize(window.innerWidth, window.innerHeight); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; @@ -781,6 +847,22 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { } }; window.addEventListener('resize', this.resizeHandler); + + this.contextLostHandler = (event: Event) => { + event.preventDefault(); + cancelAnimationFrame(this.animationFrameId); + this.contextLost = true; + }; + + this.contextRestoredHandler = () => { + if (!this.renderer?.getContext()) return; + this.contextLost = false; + this.lastTime = 0; + this.animate(); + }; + + this.renderer.domElement.addEventListener('webglcontextlost', this.contextLostHandler); + this.renderer.domElement.addEventListener('webglcontextrestored', this.contextRestoredHandler); } private initializePostProcessing(): void { @@ -789,13 +871,15 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.renderPass = new RenderPass(this.scene, this.camera); this.composer.addPass(this.renderPass); - this.bloomPass = new UnrealBloomPass( - new THREE.Vector2(window.innerWidth, window.innerHeight), - POST_PROCESSING_CONFIG.bloom.strength, - POST_PROCESSING_CONFIG.bloom.radius, - POST_PROCESSING_CONFIG.bloom.threshold - ); - this.composer.addPass(this.bloomPass); + if (window.innerWidth > MOBILE_CONFIG.phoneBreakpoint) { + this.bloomPass = new UnrealBloomPass( + new THREE.Vector2(window.innerWidth, window.innerHeight), + POST_PROCESSING_CONFIG.bloom.strength, + POST_PROCESSING_CONFIG.bloom.radius, + POST_PROCESSING_CONFIG.bloom.threshold + ); + this.composer.addPass(this.bloomPass); + } const vignetteShader = { uniforms: { @@ -818,9 +902,9 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { void main() { vec4 texel = texture2D(tDiffuse, vUv); - vec2 uv = (vUv - vec2(0.5)) * vec2(offset); - float vignette = clamp(1.0 - dot(uv, uv), 0.0, 1.0); - vignette = pow(vignette, darkness); + vec2 uv = (vUv - vec2(0.5)) * vec2(offset); // 0.5 = UV center; re-centers coords to [-0.5, 0.5] + float vignette = clamp(1.0 - dot(uv, uv), 0.0, 1.0); // radial falloff from center + vignette = pow(vignette, darkness); // darkness exponent controls falloff curve steepness texel.rgb *= vignette; gl_FragColor = texel; } @@ -850,6 +934,10 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { keyLight.shadow.camera.bottom = -KEY_LIGHT.shadow.bounds; keyLight.shadow.mapSize.width = KEY_LIGHT.shadow.mapSize; keyLight.shadow.mapSize.height = KEY_LIGHT.shadow.mapSize; + if (window.innerWidth <= MOBILE_CONFIG.breakpoint) { + keyLight.shadow.mapSize.width = Math.min(keyLight.shadow.mapSize.width, MOBILE_CONFIG.maxShadowMapSize); + keyLight.shadow.mapSize.height = Math.min(keyLight.shadow.mapSize.height, MOBILE_CONFIG.maxShadowMapSize); + } keyLight.shadow.bias = KEY_LIGHT.shadow.bias; this.keyLight = keyLight; this.scene.add(this.keyLight); @@ -885,6 +973,8 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.scene.add(mesh); }); }); + + this.tileMeshArray = Array.from(this.tileMeshes.values()); } private addGridLines(): void { @@ -924,34 +1014,43 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { varying vec3 vPosition; uniform float time; + // Standard hash function (Hugo Elias / Book of Shaders). + // 12.9898, 78.233, 43758.5453123 are co-prime magic seeds that produce + // a uniform pseudo-random distribution — do not change individually. float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123); } void main() { - vec3 deepPurple = vec3(0.04, 0.02, 0.08); - vec3 darkBlue = vec3(0.06, 0.04, 0.12); - vec3 color = mix(deepPurple, darkBlue, vUv.y * 0.5); + // Background gradient: deep purple at horizon → dark blue at zenith + vec3 deepPurple = vec3(0.04, 0.02, 0.08); // RGB ≈ #0A0514 + vec3 darkBlue = vec3(0.06, 0.04, 0.12); // RGB ≈ #0F0A1F + vec3 color = mix(deepPurple, darkBlue, vUv.y * 0.5); // 0.5 = blend reaches halfway up the sphere - // Stars with twinkle - vec2 starPos = vUv * 150.0; + // --- Stars with twinkle --- + vec2 starPos = vUv * 150.0; // 150 = grid density — higher = more/smaller star cells float star = random(floor(starPos)); - if (star > 0.992) { - float baseBright = random(floor(starPos) + 1.0) * 0.5; + if (star > 0.992) { // 0.992 = sparsity threshold — only top 0.8% of cells become stars + float baseBright = random(floor(starPos) + 1.0) * 0.5; // 0.5 = peak brightness cap (prevents blowout) + // Twinkle: oscillates [0.6, 1.0]. 1.0–3.0 = per-star random speed range. float twinkle = 0.6 + 0.4 * sin(time * (1.0 + random(floor(starPos) + 2.0) * 3.0)); float brightness = baseBright * twinkle; + // (0.4, 0.3, 0.5) = cool purple-blue tint — red < green < blue color += vec3(brightness * 0.4, brightness * 0.3, brightness * 0.5); } - // Drifting nebula veins - float drift = time * 0.02; + // --- Drifting nebula veins --- + float drift = time * 0.02; // 0.02 = slow drift speed (full cycle ≈ 314s) + // 40.0 = vein cell grid scale; 10.0 = horizontal warp factor; 0.5 = vertical drift damping float vein1 = random(floor(vUv * 40.0 + vec2(drift, vUv.x * 10.0 + drift * 0.5))); - if (vein1 > 0.97) { - color += vec3(0.25, 0.15, 0.3) * vein1; + if (vein1 > 0.97) { // 0.97 = top 3% of cells glow as nebula veins + color += vec3(0.25, 0.15, 0.3) * vein1; // purple nebula tint (R=0.25, G=0.15, B=0.3) } - // Slow-shifting bioluminescence + // --- Slow-shifting bioluminescence --- + // 25.0 = bio cell grid scale; 0.3 = drift speed relative to nebula; 0.12 = max intensity cap float bio = random(floor(vUv * 25.0 + vec2(drift * 0.3))) * 0.12; + // (0.3, 0.5, 0.7) = blue-green bio tint — weighted toward blue channel color += vec3(bio * 0.3, bio * 0.5, bio * 0.7); gl_FragColor = vec4(color, 1.0); @@ -966,7 +1065,9 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { } private initializeParticles(): void { - const particleCount = PARTICLE_CONFIG.count; + const particleCount = window.innerWidth <= MOBILE_CONFIG.breakpoint + ? Math.floor(PARTICLE_CONFIG.count / MOBILE_CONFIG.particleDivisor) + : PARTICLE_CONFIG.count; const positions = new Float32Array(particleCount * 3); const colors = new Float32Array(particleCount * 3); @@ -1012,6 +1113,10 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.controls.minPolarAngle = CONTROLS_CONFIG.minPolarAngle; this.controls.maxPolarAngle = CONTROLS_CONFIG.maxPolarAngle; this.controls.target.set(0, 0, 0); + // Left-click reserved for game interaction (tower placement/selection). + // Orbit moved to right-click; WASD handles panning. + this.controls.mouseButtons.LEFT = MOUSE_ACTION_DISABLED as THREE.MOUSE; + this.controls.mouseButtons.RIGHT = THREE.MOUSE.ROTATE; this.controls.update(); } @@ -1025,8 +1130,12 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + // Ensure camera world matrix is fresh — OrbitControls.update() modifies + // position/quaternion without updating matrixWorld, which stales the + // raycaster when a click gesture triggers a synchronous orbit update. + this.camera.updateMatrixWorld(); this.raycaster.setFromCamera(this.mouse, this.camera); - const intersects = this.raycaster.intersectObjects(Array.from(this.tileMeshes.values())); + const intersects = this.raycaster.intersectObjects(this.tileMeshArray); if (this.hoveredTile && this.hoveredTile !== this.getSelectedTileMesh()) { const material = this.hoveredTile.material as THREE.MeshStandardMaterial; @@ -1069,61 +1178,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { }; this.clickHandler = (event: MouseEvent) => { - const rect = canvas.getBoundingClientRect(); - this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; - this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; - - this.raycaster.setFromCamera(this.mouse, this.camera); - - // Check for tower mesh clicks first - const towerGroups = Array.from(this.towerMeshes.values()); - const towerChildren: THREE.Object3D[] = []; - towerGroups.forEach(g => g.traverse(child => { if (child instanceof THREE.Mesh) towerChildren.push(child); })); - const towerHits = this.raycaster.intersectObjects(towerChildren); - - if (towerHits.length > 0) { - // Walk up to find the tower group and its key - let hitObj: THREE.Object3D | null = towerHits[0].object; - let foundKey: string | null = null; - while (hitObj) { - for (const [key, group] of this.towerMeshes) { - if (group === hitObj) { foundKey = key; break; } - } - if (foundKey) break; - hitObj = hitObj.parent; - } - if (foundKey) { - this.selectPlacedTower(foundKey); - return; - } - } - - // Check tile clicks - const intersects = this.raycaster.intersectObjects(Array.from(this.tileMeshes.values())); - - const prevSelected = this.getSelectedTileMesh(); - if (prevSelected) { - const material = prevSelected.material as THREE.MeshStandardMaterial; - const tileType = prevSelected.userData['tile'].type; - material.emissiveIntensity = tileType === BlockType.BASE ? TILE_EMISSIVE.base : tileType === BlockType.WALL ? TILE_EMISSIVE.wall : TILE_EMISSIVE.special; - } - - if (intersects.length > 0) { - const mesh = intersects[0].object as THREE.Mesh; - const row = mesh.userData['row']; - const col = mesh.userData['col']; - - this.selectedTile = { row, col }; - - const material = mesh.material as THREE.MeshStandardMaterial; - material.emissiveIntensity = TILE_EMISSIVE.selected; - - this.deselectTower(); - this.tryPlaceTower(row, col); - } else { - this.selectedTile = null; - this.deselectTower(); - } + this.handleInteraction(event.clientX, event.clientY); }; canvas.addEventListener('mousemove', this.mousemoveHandler); @@ -1143,11 +1198,16 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.touchStartY = touch.clientY; this.touchStartTime = performance.now(); this.touchIsDragging = false; - } else if (event.touches.length === 2) { - // Two-finger: record initial pinch distance for zoom - const dx = event.touches[0].clientX - event.touches[1].clientX; - const dy = event.touches[0].clientY - event.touches[1].clientY; - this.pinchStartDistance = Math.sqrt(dx * dx + dy * dy); + this.touchWasMultiTouch = false; + } else if (event.touches.length >= 2) { + // Multi-finger: flag to prevent tap on final finger lift + this.touchWasMultiTouch = true; + if (event.touches.length === 2) { + // Two-finger: record initial pinch distance for zoom + const dx = event.touches[0].clientX - event.touches[1].clientX; + const dy = event.touches[0].clientY - event.touches[1].clientY; + this.pinchStartDistance = Math.sqrt(dx * dx + dy * dy); + } } }; @@ -1155,6 +1215,11 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { event.preventDefault(); if (!this.camera || !this.controls) return; + // Safety: if multiple fingers appear in move, flag multi-touch + if (event.touches.length >= 2) { + this.touchWasMultiTouch = true; + } + if (event.touches.length === 1) { const touch = event.touches[0]; const dx = touch.clientX - this.touchStartX; @@ -1205,7 +1270,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.touchEndHandler = (event: TouchEvent) => { event.preventDefault(); - if (event.changedTouches.length === 1 && !this.touchIsDragging) { + if (event.changedTouches.length === 1 && !this.touchIsDragging && !this.touchWasMultiTouch) { const elapsed = performance.now() - this.touchStartTime; if (elapsed < TOUCH_CONFIG.tapThresholdMs) { // Short tap with no drag — treat as a click at the original touch position @@ -1214,6 +1279,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { } this.touchIsDragging = false; + this.touchWasMultiTouch = false; this.pinchStartDistance = 0; }; @@ -1222,22 +1288,24 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { canvas.addEventListener('touchend', this.touchEndHandler, { passive: false }); } - /** Converts a canvas-relative touch position to NDC and runs the same raycasting as a mouse click. */ - private handleTapAsClick(clientX: number, clientY: number): void { + /** Shared raycasting logic for both mouse click and touch tap interactions. */ + private handleInteraction(clientX: number, clientY: number): void { + const phase = this.gameStateService.getState().phase; + if (phase === GamePhase.VICTORY || phase === GamePhase.DEFEAT) return; + const canvas = this.renderer.domElement; const rect = canvas.getBoundingClientRect(); this.mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1; + this.camera.updateMatrixWorld(); this.raycaster.setFromCamera(this.mouse, this.camera); - // Check for tower mesh taps first - const towerGroups = Array.from(this.towerMeshes.values()); - const towerChildren: THREE.Object3D[] = []; - towerGroups.forEach(g => g.traverse(child => { if (child instanceof THREE.Mesh) towerChildren.push(child); })); - const towerHits = this.raycaster.intersectObjects(towerChildren); + // Check for tower mesh clicks/taps first + const towerHits = this.raycaster.intersectObjects(this.towerChildrenArray); if (towerHits.length > 0) { + // Walk up to find the tower group and its key let hitObj: THREE.Object3D | null = towerHits[0].object; let foundKey: string | null = null; while (hitObj) { @@ -1253,8 +1321,8 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { } } - // Check tile taps - const intersects = this.raycaster.intersectObjects(Array.from(this.tileMeshes.values())); + // Check tile clicks/taps + const intersects = this.raycaster.intersectObjects(this.tileMeshArray); const prevSelected = this.getSelectedTileMesh(); if (prevSelected) { @@ -1281,7 +1349,10 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { } } - private static readonly PATH_BLOCKED_DISMISS_MS = 2000; + /** Converts a canvas-relative touch position to NDC and runs the same raycasting as a mouse click. */ + private handleTapAsClick(clientX: number, clientY: number): void { + this.handleInteraction(clientX, clientY); + } private showPathBlockedWarning(): void { this.pathBlocked = true; @@ -1291,7 +1362,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.pathBlockedTimerId = setTimeout(() => { this.pathBlocked = false; this.pathBlockedTimerId = null; - }, GameBoardComponent.PATH_BLOCKED_DISMISS_MS); + }, GAME_TIMING_CONFIG.pathBlockedDismissMs); } private tryPlaceTower(row: number, col: number): void { @@ -1308,9 +1379,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { return; } - const towerStats = TOWER_CONFIGS[this.selectedTowerType]; - const costMult = this.gameStateService.getModifierEffects().towerCostMultiplier ?? 1; - const effectiveCost = Math.round(towerStats.cost * costMult); + const effectiveCost = this.getEffectiveTowerCost(this.selectedTowerType); // Check if player can afford tower if (!this.gameStateService.canAfford(effectiveCost)) return; @@ -1337,6 +1406,9 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { // Clear enemy path cache since board layout changed this.enemyService.clearPathCache(); this.refreshPathOverlay(); + + // Rebuild tower children cache for raycasting + this.rebuildTowerChildrenCache(); } } @@ -1389,7 +1461,11 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { toggleAllRanges(): void { this.showAllRanges = !this.showAllRanges; + this.refreshAllRanges(); + } + /** Removes existing range rings and recreates them if showAllRanges is active. */ + private refreshAllRanges(): void { // Remove existing range rings for (const mesh of this.rangeRingMeshes) { this.scene.remove(mesh); @@ -1532,6 +1608,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { // --- Game loop --- private animate = (time: number = 0): void => { + if (!this.renderer || this.initError || this.contextLost || !this.renderer.getContext()) return; this.animationFrameId = requestAnimationFrame(this.animate); const rawDelta = this.lastTime === 0 ? 0 : (time - this.lastTime) / 1000; @@ -1564,7 +1641,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { // Update skybox time uniform for star twinkle and nebula drift if (this.skybox) { - (this.skybox.material as THREE.ShaderMaterial).uniforms['time'].value = time * 0.001; + (this.skybox.material as THREE.ShaderMaterial).uniforms['time'].value = time * SKYBOX_CONFIG.timeScale; } // Gameplay tick — fixed timestep accumulator @@ -1575,9 +1652,9 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.physicsAccumulator += deltaTime * state.gameSpeed; let stepCount = 0; - // Elapsed time tracking — accumulate real (unscaled) time, flush every ~1 second + // Elapsed time tracking — accumulate real (unscaled) time, flush periodically this.elapsedTimeAccumulator += deltaTime; - if (this.elapsedTimeAccumulator >= 1) { + if (this.elapsedTimeAccumulator >= GAME_TIMING_CONFIG.elapsedTimeFlushInterval) { this.gameStateService.addElapsedTime(this.elapsedTimeAccumulator); this.elapsedTimeAccumulator = 0; } @@ -1614,10 +1691,11 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { frameKills.push({ damage: killInfo.damage, position: { ...enemy.position }, - color: ENEMY_STATS[enemy.type]?.color ?? 0xff0000, + color: ENEMY_STATS[enemy.type]?.color ?? ENEMY_VISUAL_CONFIG.fallbackDeathColor, value: enemy.value, }); + this.statusEffectService.removeAllEffects(killInfo.id); this.enemyService.removeEnemy(killInfo.id, this.scene); } } @@ -1626,13 +1704,12 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { const reachedExit = this.enemyService.updateEnemies(PHYSICS_CONFIG.fixedTimestep); // Enemies reaching the exit cost lives scaled by enemy type - for (const enemyId of reachedExit) { - const leakedEnemy = this.enemyService.getEnemies().get(enemyId); - const leakCost = leakedEnemy?.leakDamage ?? 1; - this.gameStateService.loseLife(leakCost); + for (const leaked of reachedExit) { + this.gameStateService.loseLife(leaked.leakDamage); this.gameStatsService.recordEnemyLeaked(); frameExitCount++; - this.enemyService.removeEnemy(enemyId, this.scene); + this.statusEffectService.removeAllEffects(leaked.id); + this.enemyService.removeEnemy(leaked.id, this.scene); } // Check wave completion: no spawning and no enemies alive @@ -1658,41 +1735,11 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { } // Record game end stats for profile (VICTORY or DEFEAT, fires once per game) - if ((postWavePhase === GamePhase.VICTORY || postWavePhase === GamePhase.DEFEAT) && !this.gameEndRecorded) { - this.gameEndRecorded = true; - const endState = this.gameStateService.getState(); - const stats = this.gameStatsService.getStats(); - const totalKills = Object.values(stats.killsByTowerType).reduce((a, b) => a + b, 0); - const gameEndStats: GameEndStats = { - isVictory: postWavePhase === GamePhase.VICTORY, - score: endState.score, - enemiesKilled: totalKills, - goldEarned: stats.totalGoldEarned, - wavesCompleted: endState.wave, - livesLost: DIFFICULTY_PRESETS[endState.difficulty].lives - endState.lives, - }; - this.newlyUnlockedAchievements = this.playerProfileService.recordGameEnd(gameEndStats); - this.updateAchievementDetails(); - } + this.recordGameEndIfNeeded(); } // DEFEAT mid-frame (from loseLife) — record game end if not yet done - if (currentPhase === GamePhase.DEFEAT && !this.gameEndRecorded) { - this.gameEndRecorded = true; - const endState = this.gameStateService.getState(); - const stats = this.gameStatsService.getStats(); - const totalKills = Object.values(stats.killsByTowerType).reduce((a, b) => a + b, 0); - const gameEndStats: GameEndStats = { - isVictory: false, - score: endState.score, - enemiesKilled: totalKills, - goldEarned: stats.totalGoldEarned, - wavesCompleted: endState.wave, - livesLost: DIFFICULTY_PRESETS[endState.difficulty].lives - endState.lives, - }; - this.newlyUnlockedAchievements = this.playerProfileService.recordGameEnd(gameEndStats); - this.updateAchievementDetails(); - } + this.recordGameEndIfNeeded(); this.physicsAccumulator -= PHYSICS_CONFIG.fixedTimestep; stepCount++; @@ -1777,7 +1824,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { } private updateTowerAnimations(time: number): void { - const t = time * 0.001; // Convert ms to seconds + const t = time * SKYBOX_CONFIG.timeScale; for (const group of this.towerMeshes.values()) { const towerType = group.userData['towerType'] as TowerType | undefined; if (!towerType) continue; @@ -1800,7 +1847,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { case 'orb': { const pulseScale = TOWER_ANIM_CONFIG.orbPulseMin - + (Math.sin(t * TOWER_ANIM_CONFIG.orbPulseSpeed) * 0.5 + 0.5) + + sinNormalized(Math.sin(t * TOWER_ANIM_CONFIG.orbPulseSpeed)) * (TOWER_ANIM_CONFIG.orbPulseMax - TOWER_ANIM_CONFIG.orbPulseMin); child.scale.setScalar(pulseScale); break; @@ -1823,7 +1870,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { case 'tip': { const mat = child.material as THREE.MeshStandardMaterial; mat.emissiveIntensity = TOWER_ANIM_CONFIG.tipGlowMin - + (Math.sin(t * TOWER_ANIM_CONFIG.tipGlowSpeed) * 0.5 + 0.5) + + sinNormalized(Math.sin(t * TOWER_ANIM_CONFIG.tipGlowSpeed)) * (TOWER_ANIM_CONFIG.tipGlowMax - TOWER_ANIM_CONFIG.tipGlowMin); break; } @@ -1833,9 +1880,9 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { } private updateTilePulse(time: number): void { - const t = time * 0.001; + const t = time * SKYBOX_CONFIG.timeScale; const intensity = TILE_PULSE_CONFIG.min - + (Math.sin(t * TILE_PULSE_CONFIG.speed) * 0.5 + 0.5) + + sinNormalized(Math.sin(t * TILE_PULSE_CONFIG.speed)) * (TILE_PULSE_CONFIG.max - TILE_PULSE_CONFIG.min); for (const mesh of this.tileMeshes.values()) { @@ -1873,6 +1920,8 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { canvas.removeEventListener('touchstart', this.touchStartHandler); canvas.removeEventListener('touchmove', this.touchMoveHandler); canvas.removeEventListener('touchend', this.touchEndHandler); + canvas.removeEventListener('webglcontextlost', this.contextLostHandler); + canvas.removeEventListener('webglcontextrestored', this.contextRestoredHandler); } if (this.controls) { @@ -1881,12 +1930,14 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { if (this.scene) { this.cleanupGameObjects(); + this.particleService.cleanup(this.scene); + this.goldPopupService.cleanup(this.scene); } this.audioService.cleanup(); - this.particleService.cleanup(this.scene); - this.goldPopupService.cleanup(this.scene); - this.screenShakeService.cleanup(this.camera); + if (this.camera) { + this.screenShakeService.cleanup(this.camera); + } this.fpsCounterService.reset(); if (this.vignettePass) { diff --git a/src/app/game/game-board/models/demo-maps.model.spec.ts b/src/app/game/game-board/models/demo-maps.model.spec.ts new file mode 100644 index 00000000..354458f3 --- /dev/null +++ b/src/app/game/game-board/models/demo-maps.model.spec.ts @@ -0,0 +1,181 @@ +import { DEMO_MAPS, DemoMapConfig } from './demo-maps.model'; +import { TerrainType } from '../../../games/novarise/models/terrain-types.enum'; + +const MIN_GRID_SIZE = 5; +const MAX_GRID_SIZE = 30; + +/** BFS reachability check: can any exit be reached from a spawn point? */ +function canReachExit( + tiles: TerrainType[][], + gridSize: number, + spawn: { x: number; z: number }, + exits: { x: number; z: number }[], + spawns: { x: number; z: number }[] +): boolean { + const exitSet = new Set(exits.map(e => `${e.x},${e.z}`)); + const spawnSet = new Set(spawns.map(s => `${s.x},${s.z}`)); + const visited = new Set(); + const queue: { x: number; z: number }[] = [spawn]; + visited.add(`${spawn.x},${spawn.z}`); + + const WALL_TYPES = new Set([TerrainType.CRYSTAL, TerrainType.ABYSS]); + + while (queue.length > 0) { + const current = queue.shift()!; + if (exitSet.has(`${current.x},${current.z}`)) { + return true; + } + + const neighbors = [ + { x: current.x - 1, z: current.z }, + { x: current.x + 1, z: current.z }, + { x: current.x, z: current.z - 1 }, + { x: current.x, z: current.z + 1 }, + ]; + + for (const n of neighbors) { + const key = `${n.x},${n.z}`; + if (n.x < 0 || n.x >= gridSize || n.z < 0 || n.z >= gridSize) continue; + if (visited.has(key)) continue; + + const tileType = tiles[n.x][n.z]; + const isWall = WALL_TYPES.has(tileType); + const isSpawnOrExit = spawnSet.has(key) || exitSet.has(key); + + // Traversable if not a wall, or if it's a spawn/exit point + if (!isWall || isSpawnOrExit) { + visited.add(key); + queue.push(n); + } + } + } + return false; +} + +describe('DEMO_MAPS', () => { + it('should have at least 3 demo maps', () => { + expect(DEMO_MAPS.length).toBeGreaterThanOrEqual(3); + }); + + it('should have unique keys', () => { + const keys = DEMO_MAPS.map(m => m.key); + const uniqueKeys = new Set(keys); + expect(uniqueKeys.size).toBe(keys.length); + }); + + DEMO_MAPS.forEach((demo: DemoMapConfig) => { + describe(`"${demo.name}" (${demo.key})`, () => { + it('should have a non-empty name and description', () => { + expect(demo.name.length).toBeGreaterThan(0); + expect(demo.description.length).toBeGreaterThan(0); + }); + + it('should have a valid grid size', () => { + expect(demo.state.gridSize).toBeGreaterThanOrEqual(MIN_GRID_SIZE); + expect(demo.state.gridSize).toBeLessThanOrEqual(MAX_GRID_SIZE); + }); + + it('should have tiles array matching gridSize dimensions', () => { + const { gridSize, tiles } = demo.state; + expect(tiles.length).toBe(gridSize); + for (let x = 0; x < gridSize; x++) { + expect(tiles[x].length).toBe(gridSize); + } + }); + + it('should have heightMap matching gridSize dimensions', () => { + const { gridSize, heightMap } = demo.state; + expect(heightMap.length).toBe(gridSize); + for (let x = 0; x < gridSize; x++) { + expect(heightMap[x].length).toBe(gridSize); + } + }); + + it('should only contain valid TerrainType values in tiles', () => { + const validTypes = new Set(Object.values(TerrainType)); + const { gridSize, tiles } = demo.state; + for (let x = 0; x < gridSize; x++) { + for (let z = 0; z < gridSize; z++) { + expect(validTypes.has(tiles[x][z])) + .withContext(`Invalid terrain at tiles[${x}][${z}]: ${tiles[x][z]}`) + .toBeTrue(); + } + } + }); + + it('should have at least one spawn point', () => { + expect(demo.state.spawnPoints.length).toBeGreaterThanOrEqual(1); + }); + + it('should have at least one exit point', () => { + expect(demo.state.exitPoints.length).toBeGreaterThanOrEqual(1); + }); + + it('should have spawn points within grid bounds', () => { + const { gridSize, spawnPoints } = demo.state; + for (const sp of spawnPoints) { + expect(sp.x).toBeGreaterThanOrEqual(0); + expect(sp.x).toBeLessThan(gridSize); + expect(sp.z).toBeGreaterThanOrEqual(0); + expect(sp.z).toBeLessThan(gridSize); + } + }); + + it('should have exit points within grid bounds', () => { + const { gridSize, exitPoints } = demo.state; + for (const ep of exitPoints) { + expect(ep.x).toBeGreaterThanOrEqual(0); + expect(ep.x).toBeLessThan(gridSize); + expect(ep.z).toBeGreaterThanOrEqual(0); + expect(ep.z).toBeLessThan(gridSize); + } + }); + + it('should not overlap spawn and exit points', () => { + const { spawnPoints, exitPoints } = demo.state; + for (const sp of spawnPoints) { + for (const ep of exitPoints) { + const overlaps = sp.x === ep.x && sp.z === ep.z; + expect(overlaps) + .withContext(`Spawn (${sp.x},${sp.z}) overlaps exit (${ep.x},${ep.z})`) + .toBeFalse(); + } + } + }); + + it('should have spawn point tiles that are not walls', () => { + const { tiles, spawnPoints } = demo.state; + const wallTypes = new Set([TerrainType.CRYSTAL, TerrainType.ABYSS]); + for (const sp of spawnPoints) { + expect(wallTypes.has(tiles[sp.x][sp.z])) + .withContext(`Spawn tile at (${sp.x},${sp.z}) is a wall: ${tiles[sp.x][sp.z]}`) + .toBeFalse(); + } + }); + + it('should have exit point tiles that are not walls', () => { + const { tiles, exitPoints } = demo.state; + const wallTypes = new Set([TerrainType.CRYSTAL, TerrainType.ABYSS]); + for (const ep of exitPoints) { + expect(wallTypes.has(tiles[ep.x][ep.z])) + .withContext(`Exit tile at (${ep.x},${ep.z}) is a wall: ${tiles[ep.x][ep.z]}`) + .toBeFalse(); + } + }); + + it('should have a valid version string', () => { + expect(demo.state.version).toBe('2.0.0'); + }); + + it('should have a path from every spawn to at least one exit', () => { + const { tiles, gridSize, spawnPoints, exitPoints } = demo.state; + for (const sp of spawnPoints) { + const reachable = canReachExit(tiles, gridSize, sp, exitPoints, spawnPoints); + expect(reachable) + .withContext(`Spawn (${sp.x},${sp.z}) cannot reach any exit`) + .toBeTrue(); + } + }); + }); + }); +}); diff --git a/src/app/game/game-board/models/demo-maps.model.ts b/src/app/game/game-board/models/demo-maps.model.ts new file mode 100644 index 00000000..56c1899f --- /dev/null +++ b/src/app/game/game-board/models/demo-maps.model.ts @@ -0,0 +1,222 @@ +import { TerrainGridState } from '../../../games/novarise/features/terrain-editor/terrain-grid-state.interface'; +import { TerrainType } from '../../../games/novarise/models/terrain-types.enum'; + +/** + * Metadata for a bundled demo map displayed on the map-select screen. + * Separated from MapMetadata (which requires an id and timestamps from storage). + */ +export interface DemoMapConfig { + /** Unique key used for identification in UI and tests */ + readonly key: string; + /** Display name shown on the map card */ + readonly name: string; + /** Short description of the map's characteristics */ + readonly description: string; + /** The full terrain grid state, ready for MapBridgeService */ + readonly state: TerrainGridState; +} + +// --- Shorthand aliases for readability in tile grids --- +const B = TerrainType.BEDROCK; // Buildable ground (towers go here) +const M = TerrainType.MOSS; // Traversable path (enemies walk here) +const C = TerrainType.CRYSTAL; // Obstacle (wall) +const A = TerrainType.ABYSS; // Obstacle (pit) + +const DEMO_MAP_VERSION = '2.0.0'; + +/** + * Convert a row-major visual grid (rows[z][x]) to column-major tiles[x][z] + * so the layout in code reads like a top-down view of the map. + * + * Visual grid: rows[z][x] — z increases downward, x increases rightward + * Tiles array: tiles[x][z] — x is column index, z is row index + */ +function rowMajorToColumnMajor(rows: TerrainType[][]): TerrainType[][] { + const gridSize = rows.length; + const tiles: TerrainType[][] = []; + for (let x = 0; x < gridSize; x++) { + tiles[x] = []; + for (let z = 0; z < gridSize; z++) { + tiles[x][z] = rows[z][x]; + } + } + return tiles; +} + +/** Generate a flat height map (all zeros) for a given grid size. */ +function flatHeightMap(gridSize: number): number[][] { + const map: number[][] = []; + for (let x = 0; x < gridSize; x++) { + map[x] = []; + for (let z = 0; z < gridSize; z++) { + map[x][z] = 0; + } + } + return map; +} + +// ============================================================ +// CLASSIC (10x10) — Simple winding path, great for beginners +// ============================================================ +// Legend: S=Spawner, E=Exit, M=Path, B=Buildable, C=Crystal, A=Abyss +// +// 0 1 2 3 4 5 6 7 8 9 +// 0 B B B B B B B B B B +// 1 S M M M B B B B B B +// 2 B B B M B B B B B B +// 3 B B B M M M M B B B +// 4 B B B B B B M B B B +// 5 B C B B B B M M M B +// 6 B B B B B B B B M B +// 7 B B B M M M M M M B +// 8 B B B M B B B B B B +// 9 B B B M M M M M M E + +const CLASSIC_GRID_SIZE = 10; + +const CLASSIC_ROWS: TerrainType[][] = [ + /*z=0*/ [B, B, B, B, B, B, B, B, B, B], + /*z=1*/ [M, M, M, M, B, B, B, B, B, B], + /*z=2*/ [B, B, B, M, B, B, B, B, B, B], + /*z=3*/ [B, B, B, M, M, M, M, B, B, B], + /*z=4*/ [B, B, B, B, B, B, M, B, B, B], + /*z=5*/ [B, C, B, B, B, B, M, M, M, B], + /*z=6*/ [B, B, B, B, B, B, B, B, M, B], + /*z=7*/ [B, B, B, M, M, M, M, M, M, B], + /*z=8*/ [B, B, B, M, B, B, B, B, B, B], + /*z=9*/ [B, B, B, M, M, M, M, M, M, M], +]; + +const CLASSIC_STATE: TerrainGridState = { + gridSize: CLASSIC_GRID_SIZE, + tiles: rowMajorToColumnMajor(CLASSIC_ROWS), + heightMap: flatHeightMap(CLASSIC_GRID_SIZE), + spawnPoints: [{ x: 0, z: 1 }], // top-left edge + exitPoints: [{ x: 9, z: 9 }], // bottom-right corner + version: DEMO_MAP_VERSION, +}; + +// ============================================================ +// CROSSROADS (12x12) — Two spawners, two exits, crossing paths +// ============================================================ +// +// 0 1 2 3 4 5 6 7 8 9 A B +// 0 B B B B B S B B B B B B +// 1 B B B B B M B B B B B B +// 2 B B C B B M B B C B B B +// 3 B B B B B M B B B B B B +// 4 B B B B B M B B B B B B +// 5 S M M M M M M M M M M E +// 6 B B B B B M B B B B B B +// 7 B B B B B M B B B B B B +// 8 B B C B B M B B C B B B +// 9 B B B B B M B B B B B B +// A B B B B B M B B B B B B +// B B B B B B E B B B B B B + +const CROSSROADS_GRID_SIZE = 12; + +const CROSSROADS_ROWS: TerrainType[][] = [ + /*z=0 */ [B, B, B, B, B, M, B, B, B, B, B, B], + /*z=1 */ [B, B, B, B, B, M, B, B, B, B, B, B], + /*z=2 */ [B, B, C, B, B, M, B, B, C, B, B, B], + /*z=3 */ [B, B, B, B, B, M, B, B, B, B, B, B], + /*z=4 */ [B, B, B, B, B, M, B, B, B, B, B, B], + /*z=5 */ [M, M, M, M, M, M, M, M, M, M, M, M], + /*z=6 */ [B, B, B, B, B, M, B, B, B, B, B, B], + /*z=7 */ [B, B, B, B, B, M, B, B, B, B, B, B], + /*z=8 */ [B, B, C, B, B, M, B, B, C, B, B, B], + /*z=9 */ [B, B, B, B, B, M, B, B, B, B, B, B], + /*z=10*/ [B, B, B, B, B, M, B, B, B, B, B, B], + /*z=11*/ [B, B, B, B, B, M, B, B, B, B, B, B], +]; + +const CROSSROADS_STATE: TerrainGridState = { + gridSize: CROSSROADS_GRID_SIZE, + tiles: rowMajorToColumnMajor(CROSSROADS_ROWS), + heightMap: flatHeightMap(CROSSROADS_GRID_SIZE), + spawnPoints: [ + { x: 5, z: 0 }, // top center + { x: 0, z: 5 }, // left center + ], + exitPoints: [ + { x: 5, z: 11 }, // bottom center + { x: 11, z: 5 }, // right center + ], + version: DEMO_MAP_VERSION, +}; + +// ============================================================ +// FORTRESS (15x15) — Large open map, minimal path, lots of room +// ============================================================ +// +// 0 1 2 3 4 5 6 7 8 9 A B C D E +// 0 B B B B B B B B B B B B B B B +// 1 S M M B B B B B B B B B B B B +// 2 B B M B B B B A A B B B B B B +// 3 B B M M M B B B B B B B B B B +// 4 B B B B M B B B B B B B B B B +// 5 B B B B M M M M B B B B B B B +// 6 B B B B B B B M B B C B B B B +// 7 B A B B B B B M M M B B B B B +// 8 B B B B B B B B B M B B B B B +// 9 B B B B C B B B B M M M B B B +// 10 B B B B B B B B B B B M B B B +// 11 B B B B B B B B B B B M M M B +// 12 B B B B B B A B B B B B B M B +// 13 B B B B B B B B B B B B B M M +// 14 B B B B B B B B B B B B B B E + +const FORTRESS_GRID_SIZE = 15; + +const FORTRESS_ROWS: TerrainType[][] = [ + /*z=0 */ [B, B, B, B, B, B, B, B, B, B, B, B, B, B, B], + /*z=1 */ [M, M, M, B, B, B, B, B, B, B, B, B, B, B, B], + /*z=2 */ [B, B, M, B, B, B, B, A, A, B, B, B, B, B, B], + /*z=3 */ [B, B, M, M, M, B, B, B, B, B, B, B, B, B, B], + /*z=4 */ [B, B, B, B, M, B, B, B, B, B, B, B, B, B, B], + /*z=5 */ [B, B, B, B, M, M, M, M, B, B, B, B, B, B, B], + /*z=6 */ [B, B, B, B, B, B, B, M, B, B, C, B, B, B, B], + /*z=7 */ [B, A, B, B, B, B, B, M, M, M, B, B, B, B, B], + /*z=8 */ [B, B, B, B, B, B, B, B, B, M, B, B, B, B, B], + /*z=9 */ [B, B, B, B, C, B, B, B, B, M, M, M, B, B, B], + /*z=10*/ [B, B, B, B, B, B, B, B, B, B, B, M, B, B, B], + /*z=11*/ [B, B, B, B, B, B, B, B, B, B, B, M, M, M, B], + /*z=12*/ [B, B, B, B, B, B, A, B, B, B, B, B, B, M, B], + /*z=13*/ [B, B, B, B, B, B, B, B, B, B, B, B, B, M, M], + /*z=14*/ [B, B, B, B, B, B, B, B, B, B, B, B, B, B, M], +]; + +const FORTRESS_STATE: TerrainGridState = { + gridSize: FORTRESS_GRID_SIZE, + tiles: rowMajorToColumnMajor(FORTRESS_ROWS), + heightMap: flatHeightMap(FORTRESS_GRID_SIZE), + spawnPoints: [{ x: 0, z: 1 }], // top-left edge + exitPoints: [{ x: 14, z: 14 }], // bottom-right corner + version: DEMO_MAP_VERSION, +}; + +// ============================================================ +// Exported demo maps array +// ============================================================ + +export const DEMO_MAPS: readonly DemoMapConfig[] = [ + { + key: 'classic', + name: 'Classic', + description: 'A winding path with clear tower spots. Great for beginners.', + state: CLASSIC_STATE, + }, + { + key: 'crossroads', + name: 'Crossroads', + description: 'Two spawners, two exits, paths that cross. Demands strategy.', + state: CROSSROADS_STATE, + }, + { + key: 'fortress', + name: 'Fortress', + description: 'Wide open terrain with a long path. Build your defenses anywhere.', + state: FORTRESS_STATE, + }, +] as const; diff --git a/src/app/game/game-board/models/enemy.model.ts b/src/app/game/game-board/models/enemy.model.ts index c8ff506a..495399b7 100644 --- a/src/app/game/game-board/models/enemy.model.ts +++ b/src/app/game/game-board/models/enemy.model.ts @@ -20,6 +20,12 @@ export interface GridNode { parent?: GridNode; } +/** Snapshot returned by updateEnemies() for enemies that reached the exit. */ +export interface LeakedEnemyInfo { + id: string; + leakDamage: number; +} + export interface Enemy { id: string; type: EnemyType; diff --git a/src/app/game/game-board/models/game-board-tile.spec.ts b/src/app/game/game-board/models/game-board-tile.spec.ts new file mode 100644 index 00000000..c989b2a2 --- /dev/null +++ b/src/app/game/game-board/models/game-board-tile.spec.ts @@ -0,0 +1,338 @@ +import { GameBoardTile, BlockType } from './game-board-tile'; +import { TowerType } from './tower.model'; + +describe('GameBoardTile', () => { + + // --- Constructor --- + + describe('constructor', () => { + it('should assign all properties from constructor arguments', () => { + const tile = new GameBoardTile(3, 7, BlockType.BASE, true, true, 0, null); + + expect(tile.x).toBe(3); + expect(tile.y).toBe(7); + expect(tile.type).toBe(BlockType.BASE); + expect(tile.isTraversable).toBe(true); + expect(tile.isPurchasable).toBe(true); + expect(tile.cost).toBe(0); + expect(tile.towerType).toBeNull(); + }); + + it('should accept a TowerType for towerType parameter', () => { + const tile = new GameBoardTile(1, 2, BlockType.TOWER, false, false, 50, TowerType.SNIPER); + + expect(tile.towerType).toBe(TowerType.SNIPER); + expect(tile.cost).toBe(50); + }); + + it('should accept null for cost and towerType', () => { + const tile = new GameBoardTile(0, 0, BlockType.WALL, false, false, null, null); + + expect(tile.cost).toBeNull(); + expect(tile.towerType).toBeNull(); + }); + + it('should accept negative coordinates', () => { + const tile = new GameBoardTile(-1, -5, BlockType.BASE, true, true, 0, null); + + expect(tile.x).toBe(-1); + expect(tile.y).toBe(-5); + }); + + it('should accept zero coordinates', () => { + const tile = new GameBoardTile(0, 0, BlockType.BASE, true, true, 0, null); + + expect(tile.x).toBe(0); + expect(tile.y).toBe(0); + }); + + it('should store all six tower types correctly', () => { + const towerTypes = [ + TowerType.BASIC, + TowerType.SNIPER, + TowerType.SPLASH, + TowerType.SLOW, + TowerType.CHAIN, + TowerType.MORTAR, + ]; + + towerTypes.forEach(tt => { + const tile = new GameBoardTile(0, 0, BlockType.TOWER, false, false, 100, tt); + expect(tile.towerType).toBe(tt); + }); + }); + }); + + // --- Static Factory: createBase --- + + describe('createBase()', () => { + it('should create a tile with BlockType.BASE', () => { + const tile = GameBoardTile.createBase(2, 4); + expect(tile.type).toBe(BlockType.BASE); + }); + + it('should set the correct coordinates', () => { + const tile = GameBoardTile.createBase(5, 9); + expect(tile.x).toBe(5); + expect(tile.y).toBe(9); + }); + + it('should be traversable', () => { + const tile = GameBoardTile.createBase(0, 0); + expect(tile.isTraversable).toBe(true); + }); + + it('should be purchasable', () => { + const tile = GameBoardTile.createBase(0, 0); + expect(tile.isPurchasable).toBe(true); + }); + + it('should have a cost of 0', () => { + const tile = GameBoardTile.createBase(0, 0); + expect(tile.cost).toBe(0); + }); + + it('should have no tower type', () => { + const tile = GameBoardTile.createBase(0, 0); + expect(tile.towerType).toBeNull(); + }); + + it('should return a GameBoardTile instance', () => { + const tile = GameBoardTile.createBase(1, 1); + expect(tile instanceof GameBoardTile).toBe(true); + }); + }); + + // --- Static Factory: createSpawner --- + + describe('createSpawner()', () => { + it('should create a tile with BlockType.SPAWNER', () => { + const tile = GameBoardTile.createSpawner(0, 0); + expect(tile.type).toBe(BlockType.SPAWNER); + }); + + it('should set the correct coordinates', () => { + const tile = GameBoardTile.createSpawner(3, 6); + expect(tile.x).toBe(3); + expect(tile.y).toBe(6); + }); + + it('should not be traversable', () => { + const tile = GameBoardTile.createSpawner(0, 0); + expect(tile.isTraversable).toBe(false); + }); + + it('should not be purchasable', () => { + const tile = GameBoardTile.createSpawner(0, 0); + expect(tile.isPurchasable).toBe(false); + }); + + it('should have null cost', () => { + const tile = GameBoardTile.createSpawner(0, 0); + expect(tile.cost).toBeNull(); + }); + + it('should have no tower type', () => { + const tile = GameBoardTile.createSpawner(0, 0); + expect(tile.towerType).toBeNull(); + }); + }); + + // --- Static Factory: createExit --- + + describe('createExit()', () => { + it('should create a tile with BlockType.EXIT', () => { + const tile = GameBoardTile.createExit(0, 0); + expect(tile.type).toBe(BlockType.EXIT); + }); + + it('should set the correct coordinates', () => { + const tile = GameBoardTile.createExit(7, 2); + expect(tile.x).toBe(7); + expect(tile.y).toBe(2); + }); + + it('should not be traversable', () => { + const tile = GameBoardTile.createExit(0, 0); + expect(tile.isTraversable).toBe(false); + }); + + it('should not be purchasable', () => { + const tile = GameBoardTile.createExit(0, 0); + expect(tile.isPurchasable).toBe(false); + }); + + it('should have null cost', () => { + const tile = GameBoardTile.createExit(0, 0); + expect(tile.cost).toBeNull(); + }); + + it('should have no tower type', () => { + const tile = GameBoardTile.createExit(0, 0); + expect(tile.towerType).toBeNull(); + }); + }); + + // --- Static Factory: createWall --- + + describe('createWall()', () => { + it('should create a tile with BlockType.WALL', () => { + const tile = GameBoardTile.createWall(0, 0); + expect(tile.type).toBe(BlockType.WALL); + }); + + it('should set the correct coordinates', () => { + const tile = GameBoardTile.createWall(10, 14); + expect(tile.x).toBe(10); + expect(tile.y).toBe(14); + }); + + it('should not be traversable', () => { + const tile = GameBoardTile.createWall(0, 0); + expect(tile.isTraversable).toBe(false); + }); + + it('should not be purchasable', () => { + const tile = GameBoardTile.createWall(0, 0); + expect(tile.isPurchasable).toBe(false); + }); + + it('should have null cost', () => { + const tile = GameBoardTile.createWall(0, 0); + expect(tile.cost).toBeNull(); + }); + + it('should have no tower type', () => { + const tile = GameBoardTile.createWall(0, 0); + expect(tile.towerType).toBeNull(); + }); + }); + + // --- Shared Behavior: Non-base factories --- + + describe('non-base factory consistency', () => { + it('spawner, exit, and wall should all be non-traversable and non-purchasable', () => { + const factories = [ + GameBoardTile.createSpawner, + GameBoardTile.createExit, + GameBoardTile.createWall, + ]; + + factories.forEach(factory => { + const tile = factory(1, 1); + expect(tile.isTraversable).toBe(false); + expect(tile.isPurchasable).toBe(false); + expect(tile.cost).toBeNull(); + expect(tile.towerType).toBeNull(); + }); + }); + + it('only createBase should produce a traversable, purchasable tile', () => { + const base = GameBoardTile.createBase(0, 0); + const spawner = GameBoardTile.createSpawner(0, 0); + const exit = GameBoardTile.createExit(0, 0); + const wall = GameBoardTile.createWall(0, 0); + + expect(base.isTraversable).toBe(true); + expect(base.isPurchasable).toBe(true); + + [spawner, exit, wall].forEach(tile => { + expect(tile.isTraversable).toBe(false); + expect(tile.isPurchasable).toBe(false); + }); + }); + }); + + // --- BlockType enum values --- + + describe('BlockType', () => { + it('should have distinct numeric values for all block types', () => { + const values = new Set([ + BlockType.BASE, + BlockType.EXIT, + BlockType.SPAWNER, + BlockType.TOWER, + BlockType.WALL, + ]); + expect(values.size).toBe(5); + }); + }); + + // --- Factory-produced tiles have distinct types --- + + describe('factory type distinction', () => { + it('each factory should produce a tile with a unique BlockType', () => { + const base = GameBoardTile.createBase(0, 0); + const spawner = GameBoardTile.createSpawner(0, 0); + const exit = GameBoardTile.createExit(0, 0); + const wall = GameBoardTile.createWall(0, 0); + + const types = new Set([base.type, spawner.type, exit.type, wall.type]); + expect(types.size).toBe(4); + }); + + it('no factory produces BlockType.TOWER (towers are placed, not factory-created)', () => { + const base = GameBoardTile.createBase(0, 0); + const spawner = GameBoardTile.createSpawner(0, 0); + const exit = GameBoardTile.createExit(0, 0); + const wall = GameBoardTile.createWall(0, 0); + + [base, spawner, exit, wall].forEach(tile => { + expect(tile.type).not.toBe(BlockType.TOWER); + }); + }); + }); + + // --- Readonly properties --- + + describe('property immutability', () => { + it('all properties should be readonly (TypeScript enforced)', () => { + const tile = GameBoardTile.createBase(1, 2); + + // These properties are readonly at the type level. + // At runtime, verify they hold their initial values after construction. + expect(tile.x).toBe(1); + expect(tile.y).toBe(2); + expect(tile.type).toBe(BlockType.BASE); + expect(tile.isTraversable).toBe(true); + expect(tile.isPurchasable).toBe(true); + expect(tile.cost).toBe(0); + expect(tile.towerType).toBeNull(); + }); + }); + + // --- Edge cases --- + + describe('edge cases', () => { + it('should handle large coordinate values', () => { + const tile = GameBoardTile.createBase(9999, 9999); + expect(tile.x).toBe(9999); + expect(tile.y).toBe(9999); + }); + + it('should handle fractional coordinates', () => { + const tile = new GameBoardTile(1.5, 2.7, BlockType.BASE, true, true, 0, null); + expect(tile.x).toBe(1.5); + expect(tile.y).toBe(2.7); + }); + + it('should handle zero cost distinctly from null cost', () => { + const base = GameBoardTile.createBase(0, 0); + const spawner = GameBoardTile.createSpawner(0, 0); + + expect(base.cost).toBe(0); + expect(spawner.cost).toBeNull(); + expect(base.cost).not.toBe(spawner.cost); + }); + + it('two tiles at the same position are independent instances', () => { + const a = GameBoardTile.createBase(3, 4); + const b = GameBoardTile.createBase(3, 4); + + expect(a).not.toBe(b); + expect(a.x).toBe(b.x); + expect(a.y).toBe(b.y); + expect(a.type).toBe(b.type); + }); + }); +}); diff --git a/src/app/game/game-board/services/enemy.service.spec.ts b/src/app/game/game-board/services/enemy.service.spec.ts index ccc4833e..90a1a1f5 100644 --- a/src/app/game/game-board/services/enemy.service.spec.ts +++ b/src/app/game/game-board/services/enemy.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import * as THREE from 'three'; import { EnemyService } from './enemy.service'; import { GameBoardService } from '../game-board.service'; -import { EnemyType, ENEMY_STATS, MINI_SWARM_STATS } from '../models/enemy.model'; +import { EnemyType, ENEMY_STATS, MINI_SWARM_STATS, MIN_ENEMY_SPEED } from '../models/enemy.model'; import { GameModifier, GAME_MODIFIER_CONFIGS, mergeModifierEffects } from '../models/game-modifier.model'; import { BlockType, GameBoardTile } from '../models/game-board-tile'; import { StatusEffectType } from '../constants/status-effect.constants'; @@ -353,7 +353,7 @@ describe('EnemyService', () => { expect(hasMoved).toBe(true); }); - it('should return enemy IDs that reached exit', () => { + it('should return LeakedEnemyInfo for enemies that reached exit', () => { const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene); // Force enemy to last node @@ -361,7 +361,9 @@ describe('EnemyService', () => { const reachedExit = service.updateEnemies(0.1); - expect(reachedExit).toContain(enemy!.id); + expect(reachedExit.length).toBe(1); + expect(reachedExit[0].id).toBe(enemy!.id); + expect(reachedExit[0].leakDamage).toBe(ENEMY_STATS[EnemyType.BASIC].leakDamage); }); it('should handle multiple enemies simultaneously', () => { @@ -904,7 +906,7 @@ describe('EnemyService', () => { const reachedExit = service.updateEnemies(0.1); - expect(reachedExit).not.toContain(enemy.id); + expect(reachedExit.find(e => e.id === enemy.id)).toBeUndefined(); }); }); @@ -1427,6 +1429,124 @@ describe('EnemyService', () => { }); }); + // --- Sprint 15: Edge case tests --- + + describe('Shield absorption: exact damage === shield', () => { + it('should deplete shield to 0 and leave health unchanged', () => { + const enemy = service.spawnEnemy(EnemyType.SHIELDED, mockScene)!; + const maxShield = ENEMY_STATS[EnemyType.SHIELDED].maxShield!; + const baseHealth = ENEMY_STATS[EnemyType.SHIELDED].health; + + const result = service.damageEnemy(enemy.id, maxShield); + + expect(result.killed).toBe(false); + expect(enemy.shield).toBe(0); + expect(enemy.health).toBe(baseHealth); + }); + + it('should remove shield mesh when exact shield damage is dealt', () => { + const enemy = service.spawnEnemy(EnemyType.SHIELDED, mockScene)!; + const maxShield = ENEMY_STATS[EnemyType.SHIELDED].maxShield!; + + expect(enemy.mesh!.userData['shieldMesh']).toBeTruthy(); + + service.damageEnemy(enemy.id, maxShield); + + expect(enemy.mesh!.userData['shieldMesh']).toBeUndefined(); + }); + }); + + describe('Shield overflow: damage > shield carries to health', () => { + it('should apply exact remainder to health after shield breaks', () => { + const enemy = service.spawnEnemy(EnemyType.SHIELDED, mockScene)!; + const maxShield = ENEMY_STATS[EnemyType.SHIELDED].maxShield!; + const baseHealth = ENEMY_STATS[EnemyType.SHIELDED].health; + const overshoot = 15; + + service.damageEnemy(enemy.id, maxShield + overshoot); + + expect(enemy.shield).toBe(0); + expect(enemy.health).toBe(baseHealth - overshoot); + }); + + }); + + describe('MIN_ENEMY_SPEED floor', () => { + it('should floor speed at MIN_ENEMY_SPEED with near-zero multiplier', () => { + service.setModifierEffects({ enemySpeedMultiplier: 0.001 }, new Set()); + + const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene)!; + expect(enemy.speed).toBe(MIN_ENEMY_SPEED); + }); + + it('should floor speed at MIN_ENEMY_SPEED with zero multiplier', () => { + service.setModifierEffects({ enemySpeedMultiplier: 0 }, new Set()); + + const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene)!; + expect(enemy.speed).toBe(MIN_ENEMY_SPEED); + }); + + it('should not affect speed when multiplier keeps it above MIN_ENEMY_SPEED', () => { + service.setModifierEffects({ enemySpeedMultiplier: 0.5 }, new Set()); + + const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene)!; + const expected = ENEMY_STATS[EnemyType.BASIC].speed * 0.5; + expect(expected).toBeGreaterThan(MIN_ENEMY_SPEED); + expect(enemy.speed).toBeCloseTo(expected); + }); + }); + + describe('SPEED_DEMONS modifier selectivity', () => { + it('should NOT apply SPEED_DEMONS to HEAVY enemies', () => { + const mods = new Set([GameModifier.SPEED_DEMONS]); + const effects = mergeModifierEffects(mods); + service.setModifierEffects(effects, mods); + + const heavyEnemy = service.spawnEnemy(EnemyType.HEAVY, mockScene)!; + expect(heavyEnemy.speed).toBeCloseTo(ENEMY_STATS[EnemyType.HEAVY].speed); + }); + + it('should NOT apply SPEED_DEMONS to BOSS enemies', () => { + const mods = new Set([GameModifier.SPEED_DEMONS]); + const effects = mergeModifierEffects(mods); + service.setModifierEffects(effects, mods); + + const bossEnemy = service.spawnEnemy(EnemyType.BOSS, mockScene)!; + expect(bossEnemy.speed).toBeCloseTo(ENEMY_STATS[EnemyType.BOSS].speed); + }); + + it('should NOT apply SPEED_DEMONS to SHIELDED enemies', () => { + const mods = new Set([GameModifier.SPEED_DEMONS]); + const effects = mergeModifierEffects(mods); + service.setModifierEffects(effects, mods); + + const shieldedEnemy = service.spawnEnemy(EnemyType.SHIELDED, mockScene)!; + expect(shieldedEnemy.speed).toBeCloseTo(ENEMY_STATS[EnemyType.SHIELDED].speed); + }); + }); + + describe('Flying enemies', () => { + it('should set isFlying=true on spawned FLYING enemies', () => { + const enemy = service.spawnEnemy(EnemyType.FLYING, mockScene)!; + expect(enemy.isFlying).toBe(true); + }); + + it('should NOT set isFlying on non-flying enemies', () => { + const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene)!; + expect(enemy.isFlying).toBeFalsy(); + }); + + it('should use straight-line 2-node path for FLYING enemies', () => { + const enemy = service.spawnEnemy(EnemyType.FLYING, mockScene)!; + // Flying enemies bypass terrain — only start and end nodes + expect(enemy.path.length).toBe(2); + // Start at spawner (0,0) + expect(enemy.path[0]).toEqual(jasmine.objectContaining({ x: 0, y: 0 })); + // End at exit (9,9) + expect(enemy.path[1]).toEqual(jasmine.objectContaining({ x: 9, y: 9 })); + }); + }); + describe('enemy facing direction', () => { it('should rotate enemy mesh to face movement direction', () => { const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene)!; @@ -1501,9 +1621,71 @@ describe('EnemyService', () => { // Kill the boss service.damageEnemy(boss.id, boss.maxHealth + 100); service.updateEnemyAnimations(0.5); + }); + }); - // Crown should not have rotated - expect(crown.rotation.z).toBe(initialRotZ); + describe('Edge cases', () => { + it('should not throw when removing a non-existent enemy', () => { + expect(() => service.removeEnemy('does-not-exist', mockScene)).not.toThrow(); + }); + + it('should return an empty map when no enemies have been spawned', () => { + const enemies = service.getEnemies(); + expect(enemies.size).toBe(0); + }); + + it('should return null when no spawn points are configured', () => { + // Board with no spawner tiles — all base tiles + const noSpawnerBoard: GameBoardTile[][] = []; + for (let row = 0; row < 10; row++) { + noSpawnerBoard[row] = []; + for (let col = 0; col < 10; col++) { + if (row === 9 && col === 9) { + noSpawnerBoard[row][col] = GameBoardTile.createExit(row, col); + } else { + noSpawnerBoard[row][col] = GameBoardTile.createBase(row, col); + } + } + } + gameBoardService.getGameBoard.and.returnValue(noSpawnerBoard); + + const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene); + expect(enemy).toBeNull(); + expect(service.getEnemies().size).toBe(0); + }); + + it('should not throw or go negative when damaging an already-dead enemy', () => { + const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene)!; + + // Kill the enemy + const killResult = service.damageEnemy(enemy.id, enemy.maxHealth + 50); + expect(killResult.killed).toBe(true); + + // Damage the same enemy again — should be a safe no-op + expect(() => service.damageEnemy(enemy.id, 100)).not.toThrow(); + const secondResult = service.damageEnemy(enemy.id, 100); + expect(secondResult.killed).toBe(false); + expect(secondResult.spawnedEnemies.length).toBe(0); + }); + + it('should return null when no exit points are configured', () => { + // Board with a spawner but no exit tiles + const noExitBoard: GameBoardTile[][] = []; + for (let row = 0; row < 10; row++) { + noExitBoard[row] = []; + for (let col = 0; col < 10; col++) { + if (row === 0 && col === 0) { + noExitBoard[row][col] = GameBoardTile.createSpawner(row, col); + } else { + noExitBoard[row][col] = GameBoardTile.createBase(row, col); + } + } + } + gameBoardService.getGameBoard.and.returnValue(noExitBoard); + + const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene); + expect(enemy).toBeNull(); + expect(service.getEnemies().size).toBe(0); }); }); }); diff --git a/src/app/game/game-board/services/enemy.service.ts b/src/app/game/game-board/services/enemy.service.ts index 01863221..76e63383 100644 --- a/src/app/game/game-board/services/enemy.service.ts +++ b/src/app/game/game-board/services/enemy.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import * as THREE from 'three'; -import { Enemy, EnemyType, ENEMY_STATS, ENEMY_MESH_SEGMENTS, MINI_SWARM_MESH_SEGMENTS, GridNode, MINI_SWARM_STATS, FLYING_ENEMY_HEIGHT, MIN_ENEMY_SPEED } from '../models/enemy.model'; +import { Enemy, EnemyType, ENEMY_STATS, ENEMY_MESH_SEGMENTS, GridNode, MINI_SWARM_STATS, FLYING_ENEMY_HEIGHT, MIN_ENEMY_SPEED, LeakedEnemyInfo } from '../models/enemy.model'; import { GameBoardService } from '../game-board.service'; import { BlockType } from '../models/game-board-tile'; import { HEALTH_BAR_CONFIG, SHIELD_VISUAL_CONFIG, ENEMY_VISUAL_CONFIG } from '../constants/ui.constants'; @@ -144,18 +144,18 @@ export class EnemyService { /** * Update all enemies - move along paths */ - updateEnemies(deltaTime: number): string[] { + updateEnemies(deltaTime: number): LeakedEnemyInfo[] { if (deltaTime <= 0) return []; - const reachedExit: string[] = []; + const reachedExit: LeakedEnemyInfo[] = []; this.enemies.forEach(enemy => { // Skip dead enemies awaiting removal — prevents double-penalty if ordering changes if (enemy.health <= 0) return; if (enemy.pathIndex >= enemy.path.length - 1) { - // Enemy reached exit - reachedExit.push(enemy.id); + // Enemy reached exit — snapshot leakDamage before removal + reachedExit.push({ id: enemy.id, leakDamage: enemy.leakDamage }); return; } diff --git a/src/app/game/game-board/services/flying-enemy.spec.ts b/src/app/game/game-board/services/flying-enemy.spec.ts index f46fd3dd..9cec45ff 100644 --- a/src/app/game/game-board/services/flying-enemy.spec.ts +++ b/src/app/game/game-board/services/flying-enemy.spec.ts @@ -206,7 +206,7 @@ describe('Flying Enemy', () => { enemy.pathIndex = enemy.path.length - 1; const reachedExit = enemyService.updateEnemies(0.1); - expect(reachedExit).toContain(enemy.id); + expect(reachedExit.find(e => e.id === enemy.id)).toBeDefined(); }); }); diff --git a/src/app/game/game-board/services/game-state.service.spec.ts b/src/app/game/game-board/services/game-state.service.spec.ts index c42d7a4b..bc1db778 100644 --- a/src/app/game/game-board/services/game-state.service.spec.ts +++ b/src/app/game/game-board/services/game-state.service.spec.ts @@ -1020,4 +1020,191 @@ describe('GameStateService', () => { }); }); }); + + // --- Edge Cases --- + + describe('edge cases', () => { + + describe('setPhase idempotency', () => { + it('should handle setting the same phase without error', () => { + expect(service.getState().phase).toBe(GamePhase.SETUP); + service.setPhase(GamePhase.SETUP); + expect(service.getState().phase).toBe(GamePhase.SETUP); + }); + + it('should still emit when setting the same phase', (done) => { + let emitCount = 0; + service.getState$().subscribe(() => { + emitCount++; + if (emitCount === 2) { + done(); + } + }); + service.setPhase(GamePhase.SETUP); + }); + }); + + describe('startWave during DEFEAT', () => { + it('should be a no-op when called during DEFEAT phase', () => { + service.startWave(); // → COMBAT + service.loseLife(INITIAL_GAME_STATE.lives); // → DEFEAT + expect(service.getState().phase).toBe(GamePhase.DEFEAT); + const waveBefore = service.getState().wave; + + service.startWave(); + expect(service.getState().wave).toBe(waveBefore); + expect(service.getState().phase).toBe(GamePhase.DEFEAT); + }); + }); + + describe('loseLife with zero amount', () => { + it('should not change lives when amount is 0', () => { + const livesBefore = service.getState().lives; + service.loseLife(0); + expect(service.getState().lives).toBe(livesBefore); + }); + }); + + describe('reset restores all fields exhaustively', () => { + it('should restore every field to initial values after heavy mutation', () => { + // Mutate every field + service.setDifficulty(DifficultyLevel.HARD); + service.setEndlessMode(true); + service.setSpeed(3); + service.setModifiers(new Set([GameModifier.ARMORED_ENEMIES, GameModifier.FAST_ENEMIES])); + service.startWave(); + service.addElapsedTime(42); + service.addGold(999); + service.addScore(5000); + service.loseLife(3); + service.togglePause(); + + service.reset(); + + const state = service.getState(); + expect(state.phase).toBe(GamePhase.SETUP); + expect(state.wave).toBe(0); + expect(state.maxWaves).toBe(INITIAL_GAME_STATE.maxWaves); + expect(state.lives).toBe(INITIAL_GAME_STATE.lives); + expect(state.gold).toBe(INITIAL_GAME_STATE.gold); + expect(state.score).toBe(0); + expect(state.difficulty).toBe(DifficultyLevel.NORMAL); + expect(state.isEndless).toBeFalse(); + expect(state.highestWave).toBe(0); + expect(state.isPaused).toBeFalse(); + expect(state.gameSpeed).toBe(1); + expect(state.elapsedTime).toBe(0); + expect(state.activeModifiers.size).toBe(0); + }); + + it('should clear modifier effects after reset', () => { + service.setModifiers(new Set([GameModifier.ARMORED_ENEMIES])); + expect(service.getModifierEffects().enemyHealthMultiplier).toBe(2.0); + + service.reset(); + expect(Object.keys(service.getModifierEffects()).length).toBe(0); + expect(service.getModifierScoreMultiplier()).toBe(1.0); + }); + }); + + describe('endless mode highestWave tracking', () => { + it('should track highestWave incrementally across waves', () => { + service.setEndlessMode(true); + service.startWave(); + service.completeWave(10); + expect(service.getState().highestWave).toBe(1); + + service.startWave(); + service.completeWave(10); + expect(service.getState().highestWave).toBe(2); + + service.startWave(); + service.completeWave(10); + expect(service.getState().highestWave).toBe(3); + }); + }); + + describe('multiple startWave calls in rapid succession', () => { + it('should only advance wave once when called twice without completing', () => { + service.startWave(); // wave 1, COMBAT + service.startWave(); // no-op (already COMBAT) + service.startWave(); // no-op (already COMBAT) + expect(service.getState().wave).toBe(1); + expect(service.getState().phase).toBe(GamePhase.COMBAT); + }); + + it('should correctly advance after complete-start-start sequence', () => { + service.startWave(); // wave 1, COMBAT + service.completeWave(0); // INTERMISSION + service.startWave(); // wave 2, COMBAT + service.startWave(); // no-op + expect(service.getState().wave).toBe(2); + expect(service.getState().phase).toBe(GamePhase.COMBAT); + }); + }); + + describe('completeWave reward accumulation', () => { + it('should accumulate gold and score across multiple wave completions', () => { + const initialGold = service.getState().gold; + service.startWave(); + service.completeWave(100); + service.startWave(); + service.completeWave(200); + service.startWave(); + service.completeWave(300); + + expect(service.getState().gold).toBe(initialGold + 100 + 200 + 300); + expect(service.getState().score).toBe(100 + 200 + 300); + }); + }); + + describe('observable emits defensive copies of activeModifiers', () => { + it('should not allow mutation of emitted activeModifiers to affect service state', (done) => { + service.setModifiers(new Set([GameModifier.ARMORED_ENEMIES])); + let emitCount = 0; + service.getState$().subscribe(state => { + emitCount++; + if (emitCount === 1) { + // Try to mutate the emitted set + state.activeModifiers.add(GameModifier.FAST_ENEMIES); + // Service internal state should be unaffected + expect(service.getState().activeModifiers.has(GameModifier.FAST_ENEMIES)).toBeFalse(); + expect(service.getState().activeModifiers.size).toBe(1); + done(); + } + }); + }); + }); + + describe('setModifiers during SETUP but wave > 0', () => { + it('should be a no-op when phase is SETUP but wave is not 0', () => { + // Force phase back to SETUP after advancing a wave + service.startWave(); + service.completeWave(0); + service.setPhase(GamePhase.SETUP); + expect(service.getState().phase).toBe(GamePhase.SETUP); + expect(service.getState().wave).toBe(1); + + const mods = new Set([GameModifier.ARMORED_ENEMIES]); + service.setModifiers(mods); + expect(service.getState().activeModifiers.size).toBe(0); + }); + }); + + describe('setDifficulty during SETUP but wave > 0', () => { + it('should be a no-op when phase is SETUP but wave is not 0', () => { + service.startWave(); + service.completeWave(0); + service.setPhase(GamePhase.SETUP); + expect(service.getState().wave).toBe(1); + + const livesBefore = service.getState().lives; + const goldBefore = service.getState().gold; + service.setDifficulty(DifficultyLevel.EASY); + expect(service.getState().lives).toBe(livesBefore); + expect(service.getState().gold).toBe(goldBefore); + expect(service.getState().difficulty).toBe(DifficultyLevel.NORMAL); + }); + }); + }); }); diff --git a/src/app/game/game-board/services/gameplay-integration.spec.ts b/src/app/game/game-board/services/gameplay-integration.spec.ts index 2d719736..27a412b3 100644 --- a/src/app/game/game-board/services/gameplay-integration.spec.ts +++ b/src/app/game/game-board/services/gameplay-integration.spec.ts @@ -14,6 +14,8 @@ import { GamePhase, DifficultyLevel, DIFFICULTY_PRESETS, INITIAL_GAME_STATE } fr import { TOWER_CONFIGS, TowerType } from '../models/tower.model'; import { WAVE_DEFINITIONS } from '../models/wave.model'; import { GameBoardTile } from '../models/game-board-tile'; +import { EnemyType } from '../models/enemy.model'; +import { StatusEffectType } from '../constants/status-effect.constants'; import { calculateScoreBreakdown, DIFFICULTY_SCORE_MULTIPLIER } from '../models/score.model'; /** @@ -51,6 +53,7 @@ describe('Gameplay Integration', () => { let towerCombatService: TowerCombatService; let gameStatsService: GameStatsService; let gameBoardService: GameBoardService; + let statusEffectService: StatusEffectService; let audioService: AudioService; let scene: THREE.Scene; @@ -74,6 +77,7 @@ describe('Gameplay Integration', () => { towerCombatService = TestBed.inject(TowerCombatService); gameStatsService = TestBed.inject(GameStatsService); gameBoardService = TestBed.inject(GameBoardService); + statusEffectService = TestBed.inject(StatusEffectService); audioService = TestBed.inject(AudioService); scene = new THREE.Scene(); @@ -509,4 +513,55 @@ describe('Gameplay Integration', () => { expect(stats.towersBuilt).toBe(0); }); }); + + // ─── 8. Status effect cleanup on enemy removal ─── + + describe('status effect cleanup on enemy removal', () => { + it('should clear status effects when removeAllEffects is called before removeEnemy', () => { + const enemy = enemyService.spawnEnemy(EnemyType.BASIC, scene); + expect(enemy).not.toBeNull(); + + // Apply effects + statusEffectService.apply(enemy!.id, StatusEffectType.SLOW, 0); + statusEffectService.apply(enemy!.id, StatusEffectType.BURN, 0); + expect(statusEffectService.getEffects(enemy!.id).length).toBe(2); + + // Explicit cleanup before removal (matches game-board.component pattern) + statusEffectService.removeAllEffects(enemy!.id); + enemyService.removeEnemy(enemy!.id, scene); + + // Effects should be gone immediately — no stale entries + expect(statusEffectService.hasEffect(enemy!.id, StatusEffectType.SLOW)).toBe(false); + expect(statusEffectService.hasEffect(enemy!.id, StatusEffectType.BURN)).toBe(false); + expect(statusEffectService.getAllActiveEffects().size).toBe(0); + }); + + it('should restore original speed when effects are cleaned up before enemy removal', () => { + const enemy = enemyService.spawnEnemy(EnemyType.BASIC, scene); + expect(enemy).not.toBeNull(); + const originalSpeed = enemy!.speed; + + statusEffectService.apply(enemy!.id, StatusEffectType.SLOW, 0); + expect(enemy!.speed).toBeLessThan(originalSpeed); + + // removeAllEffects restores speed before the enemy is deleted from the map + statusEffectService.removeAllEffects(enemy!.id); + expect(enemy!.speed).toBe(originalSpeed); + }); + + it('should not leave stale entries for leaked enemies', () => { + const enemy = enemyService.spawnEnemy(EnemyType.BASIC, scene); + expect(enemy).not.toBeNull(); + + statusEffectService.apply(enemy!.id, StatusEffectType.POISON, 0); + + // Simulate leak: explicit cleanup then remove (matches component flow) + statusEffectService.removeAllEffects(enemy!.id); + enemyService.removeEnemy(enemy!.id, scene); + + // Verify getAllActiveEffects returns empty — no stale entry + const activeEffects = statusEffectService.getAllActiveEffects(); + expect(activeEffects.has(enemy!.id)).toBe(false); + }); + }); }); diff --git a/src/app/game/game-board/services/map-bridge.service.spec.ts b/src/app/game/game-board/services/map-bridge.service.spec.ts index ec8f61e7..817785f2 100644 --- a/src/app/game/game-board/services/map-bridge.service.spec.ts +++ b/src/app/game/game-board/services/map-bridge.service.spec.ts @@ -55,11 +55,12 @@ describe('MapBridgeService', () => { board.forEach(row => expect(row.length).toBe(10)); }); - it('should handle small grids (1x1)', () => { + it('should clamp small grids (1x1) to minimum size (5)', () => { const state = createMinimalState(1); const { board, width, height } = service.convertToGameBoard(state); - expect(width).toBe(1); - expect(height).toBe(1); + expect(width).toBe(5); + expect(height).toBe(5); + // Board is filled with BASE tiles (missing tile columns default to BASE) expect(board[0][0].type).toBe(BlockType.BASE); }); diff --git a/src/app/game/game-board/services/map-bridge.service.ts b/src/app/game/game-board/services/map-bridge.service.ts index 808cb2d3..dd8199e0 100644 --- a/src/app/game/game-board/services/map-bridge.service.ts +++ b/src/app/game/game-board/services/map-bridge.service.ts @@ -2,6 +2,9 @@ import { Injectable } from '@angular/core'; import { BlockType, GameBoardTile } from '../models/game-board-tile'; import { TerrainGridState, TerrainGridStateLegacy } from '../../../games/novarise/features/terrain-editor/terrain-grid-state.interface'; +const MIN_GRID_SIZE = 5; +const MAX_GRID_SIZE = 30; + /** @deprecated Use TerrainGridState directly. Kept as alias for backward compatibility. */ export type EditorMapState = TerrainGridState; @@ -69,7 +72,25 @@ export class MapBridgeService { * Handles both v2 (spawnPoints/exitPoints arrays) and v1 (single point) formats. */ convertToGameBoard(state: EditorMapState): ConvertedBoard { - const gridSize = state.gridSize; + if (!state) { + throw new Error('Cannot convert null/undefined map state to game board'); + } + + let gridSize = state.gridSize; + + if (typeof gridSize !== 'number' || !Number.isFinite(gridSize)) { + throw new Error(`Invalid gridSize: ${gridSize}`); + } + + if (gridSize < MIN_GRID_SIZE || gridSize > MAX_GRID_SIZE) { + console.warn(`gridSize ${gridSize} out of bounds [${MIN_GRID_SIZE}, ${MAX_GRID_SIZE}], clamping`); + gridSize = Math.max(MIN_GRID_SIZE, Math.min(MAX_GRID_SIZE, gridSize)); + } + + if (!Array.isArray(state.tiles)) { + throw new Error('Map tiles data is missing or invalid'); + } + const board: GameBoardTile[][] = []; // Build base board: game[row][col] maps to editor tiles[col][row] diff --git a/src/app/game/game-board/services/minimap.service.spec.ts b/src/app/game/game-board/services/minimap.service.spec.ts index 004d6a8d..48504454 100644 --- a/src/app/game/game-board/services/minimap.service.spec.ts +++ b/src/app/game/game-board/services/minimap.service.spec.ts @@ -39,12 +39,17 @@ describe('MinimapService', () => { expect(canvas!.height).toBe(MINIMAP_CONFIG.canvasSize); }); - it('should position canvas absolutely in bottom-left', () => { + it('should clean up existing canvas when init is called twice', () => { + service.init(container); + service.init(container); + const canvases = container.querySelectorAll('canvas'); + expect(canvases.length).toBe(1); + }); + + it('should apply the minimap-canvas CSS class for responsive positioning', () => { service.init(container); const canvas = container.querySelector('canvas') as HTMLCanvasElement; - expect(canvas.style.position).toBe('absolute'); - expect(canvas.style.bottom).toBe(`${MINIMAP_CONFIG.padding}px`); - expect(canvas.style.left).toBe(`${MINIMAP_CONFIG.padding}px`); + expect(canvas.classList.contains('minimap-canvas')).toBe(true); }); }); @@ -144,7 +149,7 @@ describe('MinimapService', () => { // Starts hidden (visible=false), toggle makes it visible service.toggleVisibility(); - expect(canvas.style.display).toBe('block'); + expect(canvas.style.display).toBe(''); // Toggle again hides it service.toggleVisibility(); @@ -169,5 +174,37 @@ describe('MinimapService', () => { service.cleanup(); expect(() => service.cleanup()).not.toThrow(); }); + + it('should reset visible flag so isVisible() returns false', () => { + service.init(container); + service.show(); + expect(service.isVisible()).toBe(true); + service.cleanup(); + expect(service.isVisible()).toBe(false); + }); + + it('should start canvas hidden after cleanup + init', () => { + service.init(container); + service.show(); + service.cleanup(); + service.init(container); + const canvas = container.querySelector('canvas') as HTMLCanvasElement; + expect(canvas.style.display).toBe('none'); + }); + + it('should start canvas hidden after full restart cycle: init → show → cleanup → init', () => { + service.init(container); + service.show(); + const firstCanvas = container.querySelector('canvas') as HTMLCanvasElement; + expect(firstCanvas.style.display).toBe(''); + + service.cleanup(); + expect(container.querySelector('canvas')).toBeNull(); + + service.init(container); + const newCanvas = container.querySelector('canvas') as HTMLCanvasElement; + expect(newCanvas.style.display).toBe('none'); + expect(service.isVisible()).toBe(false); + }); }); }); diff --git a/src/app/game/game-board/services/minimap.service.ts b/src/app/game/game-board/services/minimap.service.ts index d4f12347..31fe890a 100644 --- a/src/app/game/game-board/services/minimap.service.ts +++ b/src/app/game/game-board/services/minimap.service.ts @@ -21,6 +21,9 @@ export interface MinimapTerrainData { exitPoint?: { x: number; z: number }; } +/** CSS class applied to the minimap canvas for responsive positioning via stylesheet. */ +const MINIMAP_CSS_CLASS = 'minimap-canvas'; + @Injectable() export class MinimapService { private canvas: HTMLCanvasElement | null = null; @@ -30,18 +33,17 @@ export class MinimapService { /** * Creates the minimap canvas and appends it to the given container. + * Positioning and responsive sizing are handled by CSS (.minimap-canvas class + * in styles.css) so that media queries reliably control mobile layout. */ init(container: HTMLElement): void { + if (this.canvas) { + this.cleanup(); + } this.canvas = document.createElement('canvas'); this.canvas.width = MINIMAP_CONFIG.canvasSize; this.canvas.height = MINIMAP_CONFIG.canvasSize; - this.canvas.style.position = 'absolute'; - this.canvas.style.bottom = `${MINIMAP_CONFIG.padding}px`; - this.canvas.style.left = `${MINIMAP_CONFIG.padding}px`; - this.canvas.style.border = `${MINIMAP_CONFIG.borderWidth}px solid ${MINIMAP_CONFIG.borderColor}`; - this.canvas.style.borderRadius = '4px'; - this.canvas.style.zIndex = '100'; - this.canvas.style.pointerEvents = 'none'; + this.canvas.className = MINIMAP_CSS_CLASS; if (!this.visible) { this.canvas.style.display = 'none'; } @@ -133,7 +135,7 @@ export class MinimapService { toggleVisibility(): void { this.visible = !this.visible; if (this.canvas) { - this.canvas.style.display = this.visible ? 'block' : 'none'; + this.canvas.style.display = this.visible ? '' : 'none'; } } @@ -147,6 +149,7 @@ export class MinimapService { } this.canvas = null; this.ctx = null; + this.visible = false; this.lastUpdateTime = 0; } } diff --git a/src/app/game/game-board/services/player-profile.service.spec.ts b/src/app/game/game-board/services/player-profile.service.spec.ts index 9493c4dc..db99ea47 100644 --- a/src/app/game/game-board/services/player-profile.service.spec.ts +++ b/src/app/game/game-board/services/player-profile.service.spec.ts @@ -345,6 +345,16 @@ describe('PlayerProfileService', () => { expect(fresh.getProfile().achievements).toEqual([]); }); + it('caps achievements array to ACHIEVEMENTS.length on load', () => { + const oversized = Array.from({ length: 100 }, (_, i) => `achievement_${i}`); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ achievements: oversized }) + ); + const fresh = new PlayerProfileService(); + expect(fresh.getProfile().achievements.length).toBe(ACHIEVEMENTS.length); + }); + it('merges partial saved data with defaults', () => { localStorage.setItem( STORAGE_KEY, @@ -372,4 +382,67 @@ describe('PlayerProfileService', () => { expect(service.getProfile().achievements).not.toContain('fake_achievement'); }); }); + + // ── isUnlocked (pre-computed Set) ───────────────────────────────────────── + + describe('isUnlocked — pre-computed Set', () => { + it('returns false for all achievements on a fresh profile', () => { + for (const a of ACHIEVEMENTS) { + expect(service.isUnlocked(a.id)).toBe(false); + } + }); + + it('returns true after achievement is unlocked via recordGameEnd', () => { + service.recordGameEnd(makeStats({ isVictory: true })); + expect(service.isUnlocked('first_victory')).toBe(true); + }); + + it('returns false for achievements not yet unlocked', () => { + service.recordGameEnd(makeStats({ isVictory: true })); + expect(service.isUnlocked('veteran')).toBe(false); + }); + + it('resets to false for all achievements after reset()', () => { + service.recordGameEnd(makeStats({ isVictory: true })); + expect(service.isUnlocked('first_victory')).toBe(true); + service.reset(); + expect(service.isUnlocked('first_victory')).toBe(false); + }); + + it('reflects achievements loaded from localStorage', () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + totalGamesPlayed: 1, + totalVictories: 1, + achievements: ['first_victory', 'high_scorer'], + }) + ); + const fresh = new PlayerProfileService(); + expect(fresh.isUnlocked('first_victory')).toBe(true); + expect(fresh.isUnlocked('high_scorer')).toBe(true); + expect(fresh.isUnlocked('veteran')).toBe(false); + }); + + it('returns false for unknown achievement IDs', () => { + expect(service.isUnlocked('nonexistent_achievement')).toBe(false); + }); + }); + + // ── save failure logging ────────────────────────────────────────────────── + + describe('save failure logging', () => { + it('should log warning on QuotaExceededError', () => { + spyOn(localStorage, 'setItem').and.callFake(() => { + throw new DOMException('quota exceeded', 'QuotaExceededError'); + }); + spyOn(console, 'warn'); + + service.recordGameEnd(makeStats()); + + expect(console.warn).toHaveBeenCalledWith( + jasmine.stringContaining('quota exceeded') + ); + }); + }); }); diff --git a/src/app/game/game-board/services/player-profile.service.ts b/src/app/game/game-board/services/player-profile.service.ts index ecee10ec..2265b461 100644 --- a/src/app/game/game-board/services/player-profile.service.ts +++ b/src/app/game/game-board/services/player-profile.service.ts @@ -102,15 +102,25 @@ export const ACHIEVEMENTS: Achievement[] = [ @Injectable({ providedIn: 'root' }) export class PlayerProfileService { private profile: PlayerProfile; + private unlockedSet: ReadonlySet = new Set(); constructor() { this.profile = this.load(); + this.rebuildUnlockedSet(); } getProfile(): PlayerProfile { return { ...this.profile, achievements: [...this.profile.achievements] }; } + /** + * O(1) check whether an achievement is unlocked. + * Uses a pre-computed Set that is rebuilt whenever achievements change. + */ + isUnlocked(achievementId: string): boolean { + return this.unlockedSet.has(achievementId); + } + /** * Updates all stats based on game results, checks all achievement conditions, * and saves. Returns IDs of newly unlocked achievements. @@ -154,6 +164,7 @@ export class PlayerProfileService { } } + this.rebuildUnlockedSet(); this.save(); return newlyUnlocked; } @@ -163,19 +174,16 @@ export class PlayerProfileService { } getUnlockedAchievements(): Achievement[] { - return ACHIEVEMENTS.filter((a) => - this.profile.achievements.includes(a.id) - ); + return ACHIEVEMENTS.filter((a) => this.unlockedSet.has(a.id)); } getLockedAchievements(): Achievement[] { - return ACHIEVEMENTS.filter( - (a) => !this.profile.achievements.includes(a.id) - ); + return ACHIEVEMENTS.filter((a) => !this.unlockedSet.has(a.id)); } reset(): void { this.profile = { ...DEFAULT_PROFILE, achievements: [] }; + this.rebuildUnlockedSet(); try { localStorage.removeItem(PROFILE_STORAGE_KEY); } catch { @@ -192,7 +200,7 @@ export class PlayerProfileService { ...DEFAULT_PROFILE, ...parsed, achievements: Array.isArray(parsed.achievements) - ? [...parsed.achievements] + ? parsed.achievements.slice(0, ACHIEVEMENTS.length) : [], }; } catch { @@ -203,8 +211,16 @@ export class PlayerProfileService { private save(): void { try { localStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(this.profile)); - } catch { - // localStorage full or unavailable — silently fail + } catch (error) { + if (error instanceof DOMException && (error.name === 'QuotaExceededError' || error.code === 22)) { + console.warn('localStorage quota exceeded — player profile was not saved.'); + } else { + console.warn('Failed to save player profile — localStorage may be unavailable:', error); + } } } + + private rebuildUnlockedSet(): void { + this.unlockedSet = new Set(this.profile.achievements); + } } diff --git a/src/app/game/game-board/services/restart-integration.spec.ts b/src/app/game/game-board/services/restart-integration.spec.ts new file mode 100644 index 00000000..01223b4f --- /dev/null +++ b/src/app/game/game-board/services/restart-integration.spec.ts @@ -0,0 +1,489 @@ +import { TestBed } from '@angular/core/testing'; +import * as THREE from 'three'; + +import { GameStateService } from './game-state.service'; +import { WaveService } from './wave.service'; +import { EnemyService } from './enemy.service'; +import { TowerCombatService } from './tower-combat.service'; +import { StatusEffectService } from './status-effect.service'; +import { GameStatsService } from './game-stats.service'; +import { GameBoardService } from '../game-board.service'; +import { AudioService } from './audio.service'; +import { MinimapService } from './minimap.service'; + +import { GamePhase, DifficultyLevel, DIFFICULTY_PRESETS } from '../models/game-state.model'; +import { TowerType, TOWER_CONFIGS } from '../models/tower.model'; +import { EnemyType } from '../models/enemy.model'; +import { GameModifier } from '../models/game-modifier.model'; +import { StatusEffectType } from '../constants/status-effect.constants'; +import { GameBoardTile } from '../models/game-board-tile'; + +/** + * Build a minimal 5x5 board with: + * - Spawner at (0,0) + * - Exit at (4,4) + * - All other tiles are traversable BASE + */ +function buildMinimalBoard(): { board: GameBoardTile[][]; width: number; height: number } { + const width = 5; + const height = 5; + const board: GameBoardTile[][] = []; + + for (let row = 0; row < height; row++) { + board.push([]); + for (let col = 0; col < width; col++) { + board[row].push(GameBoardTile.createBase(row, col)); + } + } + + board[0][0] = GameBoardTile.createSpawner(0, 0); + board[4][4] = GameBoardTile.createExit(4, 4); + + return { board, width, height }; +} + +/** + * Simulate the restartGame() cleanup → re-init sequence at the service level. + * + * This mirrors game-board.component.ts restartGame() lines 623–674: + * 1. cleanupGameObjects() — enemy removal, towerCombat cleanup, minimap cleanup, statusEffect cleanup + * 2. Service resets — enemyService.reset(), waveService.reset(), gameStateService.reset(), gameStatsService.reset() + * 3. Board re-import + * + * We skip Three.js mesh/renderer operations since we're testing service state only. + */ +function simulateRestart( + enemyService: EnemyService, + towerCombatService: TowerCombatService, + waveService: WaveService, + gameStateService: GameStateService, + gameStatsService: GameStatsService, + minimapService: MinimapService, + gameBoardService: GameBoardService, + scene: THREE.Scene, +): void { + // Step 1: cleanupGameObjects() — remove enemies, cleanup combat, cleanup minimap + for (const id of Array.from(enemyService.getEnemies().keys())) { + enemyService.removeEnemy(id, scene); + } + towerCombatService.cleanup(scene); + minimapService.cleanup(); + + // Step 2: Service resets (matches component order) + enemyService.reset(scene); + waveService.reset(); + gameStateService.reset(); + gameStatsService.reset(); + + // Step 3: Re-import board + const { board, width, height } = buildMinimalBoard(); + gameBoardService.importBoard(board, width, height); +} + +describe('Restart Integration — clean state after restartGame()', () => { + let gameStateService: GameStateService; + let waveService: WaveService; + let enemyService: EnemyService; + let towerCombatService: TowerCombatService; + let gameStatsService: GameStatsService; + let gameBoardService: GameBoardService; + let statusEffectService: StatusEffectService; + let audioService: AudioService; + let minimapService: MinimapService; + let scene: THREE.Scene; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + GameStateService, + WaveService, + EnemyService, + TowerCombatService, + StatusEffectService, + GameStatsService, + GameBoardService, + AudioService, + MinimapService, + ] + }); + + gameStateService = TestBed.inject(GameStateService); + waveService = TestBed.inject(WaveService); + enemyService = TestBed.inject(EnemyService); + towerCombatService = TestBed.inject(TowerCombatService); + gameStatsService = TestBed.inject(GameStatsService); + gameBoardService = TestBed.inject(GameBoardService); + statusEffectService = TestBed.inject(StatusEffectService); + audioService = TestBed.inject(AudioService); + minimapService = TestBed.inject(MinimapService); + + scene = new THREE.Scene(); + + const { board, width, height } = buildMinimalBoard(); + gameBoardService.importBoard(board, width, height); + }); + + afterEach(() => { + enemyService.reset(scene); + towerCombatService.cleanup(scene); + waveService.reset(); + + scene.traverse(child => { + if (child instanceof THREE.Mesh) { + child.geometry.dispose(); + if (Array.isArray(child.material)) { + child.material.forEach(mat => mat.dispose()); + } else { + child.material.dispose(); + } + } + }); + scene.clear(); + }); + + // ─── Helper: dirty all services to simulate mid-game state ─── + + function dirtyGameState(): void { + // Advance to wave 3 with combat activity + gameStateService.startWave(); // wave 1, COMBAT + waveService.startWave(1, scene); + for (let i = 0; i < 60; i++) { + waveService.update(0.2, scene); + } + gameStateService.completeWave(50); + + gameStateService.startWave(); // wave 2 + waveService.startWave(2, scene); + for (let i = 0; i < 60; i++) { + waveService.update(0.2, scene); + } + gameStateService.completeWave(50); + + gameStateService.startWave(); // wave 3 + waveService.startWave(3, scene); + for (let i = 0; i < 60; i++) { + waveService.update(0.2, scene); + } + + // Spend gold and place a tower + gameStateService.spendGold(TOWER_CONFIGS[TowerType.BASIC].cost); + gameBoardService.placeTower(2, 2, TowerType.BASIC); + const towerMesh = new THREE.Group(); + towerCombatService.registerTower(2, 2, TowerType.BASIC, towerMesh); + + // Record stats + gameStatsService.recordTowerBuilt(); + gameStatsService.recordKill(TowerType.BASIC); + gameStatsService.recordDamage(100); + gameStatsService.recordGoldEarned(50); + gameStatsService.recordShot(); + } + + // ─── 1. Game state returns to initial values ─── + + describe('after restart, game state should be initial', () => { + it('should reset wave to 0', () => { + dirtyGameState(); + expect(gameStateService.getState().wave).toBeGreaterThan(0); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(gameStateService.getState().wave).toBe(0); + }); + + it('should reset phase to SETUP', () => { + dirtyGameState(); + expect(gameStateService.getState().phase).not.toBe(GamePhase.SETUP); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(gameStateService.getState().phase).toBe(GamePhase.SETUP); + }); + + it('should reset gold to starting value', () => { + dirtyGameState(); + const dirtyGold = gameStateService.getState().gold; + const startingGold = DIFFICULTY_PRESETS[DifficultyLevel.NORMAL].gold; + expect(dirtyGold).not.toBe(startingGold); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(gameStateService.getState().gold).toBe(startingGold); + }); + + it('should reset lives to starting value', () => { + dirtyGameState(); + gameStateService.loseLife(2); // lose some lives + const startingLives = DIFFICULTY_PRESETS[DifficultyLevel.NORMAL].lives; + expect(gameStateService.getState().lives).toBeLessThan(startingLives); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(gameStateService.getState().lives).toBe(startingLives); + }); + + it('should reset score to 0', () => { + dirtyGameState(); + expect(gameStateService.getState().score).toBeGreaterThan(0); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(gameStateService.getState().score).toBe(0); + }); + + it('should reset highestWave to 0', () => { + // highestWave only updates in endless mode + gameStateService.setEndlessMode(true); + dirtyGameState(); + // After dirtyGameState completes 2 waves in endless, highestWave should be set + expect(gameStateService.getState().highestWave).toBeGreaterThanOrEqual(1); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(gameStateService.getState().highestWave).toBe(0); + }); + + it('should reset isPaused to false', () => { + dirtyGameState(); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(gameStateService.getState().isPaused).toBe(false); + }); + + it('should reset gameSpeed to 1', () => { + dirtyGameState(); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(gameStateService.getState().gameSpeed).toBe(1); + }); + }); + + // ─── 2. Enemy service has no enemies ─── + + describe('after restart, enemy service should have no enemies', () => { + it('should have empty enemies map', () => { + dirtyGameState(); + expect(enemyService.getEnemies().size).toBeGreaterThan(0); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(enemyService.getEnemies().size).toBe(0); + }); + + it('should reset enemy counter so next enemy gets a fresh ID sequence', () => { + dirtyGameState(); + const enemiesBefore = enemyService.getEnemies().size; + expect(enemiesBefore).toBeGreaterThan(0); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + // Spawn a new enemy — its ID should start from the reset counter + const newEnemy = enemyService.spawnEnemy(EnemyType.BASIC, scene); + expect(newEnemy).not.toBeNull(); + expect(enemyService.getEnemies().size).toBe(1); + }); + }); + + // ─── 3. Tower combat service has no towers registered ─── + + describe('after restart, tower combat service should have no towers registered', () => { + it('should have empty placed towers', () => { + dirtyGameState(); + expect(towerCombatService.getPlacedTowers().size).toBeGreaterThan(0); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(towerCombatService.getPlacedTowers().size).toBe(0); + }); + + it('should not find previously placed tower by key', () => { + dirtyGameState(); + const towerKey = '2-2'; + expect(towerCombatService.getTower(towerKey)).toBeDefined(); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(towerCombatService.getTower(towerKey)).toBeUndefined(); + }); + }); + + // ─── 4. Wave service should not be spawning ─── + + describe('after restart, wave service should not be spawning', () => { + it('should not be actively spawning', () => { + // Start wave 1 with minimal updates so spawning is still active + gameStateService.startWave(); + waveService.startWave(1, scene); + waveService.update(0.1, scene); // Single small tick — spawning should still be in progress + expect(waveService.isSpawning()).toBeTrue(); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(waveService.isSpawning()).toBeFalse(); + }); + + it('should reset endless mode flag', () => { + gameStateService.setEndlessMode(true); + waveService.setEndlessMode(true); + dirtyGameState(); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(waveService.isSpawning()).toBeFalse(); + // Endless mode is reset by waveService.reset() + expect(gameStateService.getState().isEndless).toBe(false); + }); + }); + + // ─── 5. Status effect service should have no active effects ─── + + describe('after restart, status effect service should have no active effects', () => { + it('should have empty active effects map', () => { + // Spawn an enemy and apply effects + const enemy = enemyService.spawnEnemy(EnemyType.BASIC, scene); + expect(enemy).not.toBeNull(); + statusEffectService.apply(enemy!.id, StatusEffectType.SLOW, 0); + statusEffectService.apply(enemy!.id, StatusEffectType.BURN, 0); + expect(statusEffectService.getAllActiveEffects().size).toBeGreaterThan(0); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(statusEffectService.getAllActiveEffects().size).toBe(0); + }); + + it('should not report effects for previously affected enemies', () => { + const enemy = enemyService.spawnEnemy(EnemyType.BASIC, scene); + expect(enemy).not.toBeNull(); + const enemyId = enemy!.id; + statusEffectService.apply(enemyId, StatusEffectType.POISON, 0); + expect(statusEffectService.hasEffect(enemyId, StatusEffectType.POISON)).toBe(true); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(statusEffectService.hasEffect(enemyId, StatusEffectType.POISON)).toBe(false); + expect(statusEffectService.getEffects(enemyId).length).toBe(0); + }); + }); + + // ─── 6. Minimap should not be visible ─── + + describe('after restart, minimap should not be visible', () => { + it('should report not visible after restart', () => { + // Show minimap (simulates what happens when first wave starts) + minimapService.show(); + expect(minimapService.isVisible()).toBe(true); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + expect(minimapService.isVisible()).toBe(false); + }); + }); + + // ─── 7. Modifier state is cleared on restart ─── + + describe('after restart, modifier state should be cleared', () => { + it('should clear active modifiers from game state', () => { + // Set modifiers before playing + const modifiers = new Set([GameModifier.ARMORED_ENEMIES, GameModifier.FAST_ENEMIES]); + gameStateService.setModifiers(modifiers); + expect(gameStateService.getState().activeModifiers.size).toBe(2); + + dirtyGameState(); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + // gameStateService.reset() clears modifiers — they are NOT persisted across restart + expect(gameStateService.getState().activeModifiers.size).toBe(0); + }); + + it('should reset modifier score multiplier to default', () => { + const modifiers = new Set([GameModifier.ARMORED_ENEMIES, GameModifier.EXPENSIVE_TOWERS]); + gameStateService.setModifiers(modifiers); + expect(gameStateService.getModifierScoreMultiplier()).toBeGreaterThan(1.0); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + // After reset, no modifiers → multiplier is 1.0 + expect(gameStateService.getModifierScoreMultiplier()).toBe(1.0); + }); + }); + + // ─── 8. Game stats are fully reset ─── + + describe('after restart, game stats should be zeroed', () => { + it('should reset all stat counters to zero', () => { + dirtyGameState(); + const dirtyStats = gameStatsService.getStats(); + expect(dirtyStats.towersBuilt).toBeGreaterThan(0); + expect(dirtyStats.totalDamageDealt).toBeGreaterThan(0); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + const cleanStats = gameStatsService.getStats(); + expect(cleanStats.towersBuilt).toBe(0); + expect(cleanStats.towersSold).toBe(0); + expect(cleanStats.totalDamageDealt).toBe(0); + expect(cleanStats.totalGoldEarned).toBe(0); + expect(cleanStats.enemiesLeaked).toBe(0); + expect(cleanStats.shotsFired).toBe(0); + expect(cleanStats.killsByTowerType[TowerType.BASIC]).toBe(0); + }); + }); + + // ─── 9. Full cycle: play → restart → play again ─── + + describe('full cycle: play → restart → play again', () => { + it('should allow a clean second game after restart', () => { + // First game: advance to wave 3 + dirtyGameState(); + const wave3Gold = gameStateService.getState().gold; + expect(gameStateService.getState().wave).toBe(3); + + // Restart + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + // Second game: verify clean slate + expect(gameStateService.getState().wave).toBe(0); + expect(gameStateService.getState().phase).toBe(GamePhase.SETUP); + expect(enemyService.getEnemies().size).toBe(0); + expect(towerCombatService.getPlacedTowers().size).toBe(0); + expect(waveService.isSpawning()).toBeFalse(); + + // Start a new game — should work from wave 1 + gameStateService.startWave(); + expect(gameStateService.getState().wave).toBe(1); + expect(gameStateService.getState().phase).toBe(GamePhase.COMBAT); + + waveService.startWave(1, scene); + expect(waveService.isSpawning()).toBeTrue(); + + // Enemies should spawn fresh + for (let i = 0; i < 30; i++) { + waveService.update(0.2, scene); + } + expect(enemyService.getEnemies().size).toBeGreaterThan(0); + }); + + it('should allow tower placement after restart', () => { + dirtyGameState(); + + simulateRestart(enemyService, towerCombatService, waveService, gameStateService, gameStatsService, minimapService, gameBoardService, scene); + + // Place a tower in the new game + const cost = TOWER_CONFIGS[TowerType.SNIPER].cost; + expect(gameStateService.canAfford(cost)).toBeTrue(); + + const spent = gameStateService.spendGold(cost); + expect(spent).toBeTrue(); + + gameBoardService.placeTower(1, 1, TowerType.SNIPER); + const mesh = new THREE.Group(); + towerCombatService.registerTower(1, 1, TowerType.SNIPER, mesh); + + expect(towerCombatService.getTower('1-1')).toBeDefined(); + expect(towerCombatService.getTower('1-1')!.type).toBe(TowerType.SNIPER); + }); + }); +}); diff --git a/src/app/game/game-board/services/settings.service.spec.ts b/src/app/game/game-board/services/settings.service.spec.ts index d8e8347f..96979f9d 100644 --- a/src/app/game/game-board/services/settings.service.spec.ts +++ b/src/app/game/game-board/services/settings.service.spec.ts @@ -92,4 +92,33 @@ describe('SettingsService', () => { const fresh = service.get(); expect(fresh.audioMuted).toBe(false); // original unaffected }); + + describe('save failure logging', () => { + it('should log warning on QuotaExceededError', () => { + spyOn(localStorage, 'setItem').and.callFake(() => { + throw new DOMException('quota exceeded', 'QuotaExceededError'); + }); + spyOn(console, 'warn'); + + service.update({ audioMuted: true }); + + expect(console.warn).toHaveBeenCalledWith( + jasmine.stringContaining('quota exceeded') + ); + }); + + it('should log warning on generic storage failure', () => { + spyOn(localStorage, 'setItem').and.callFake(() => { + throw new Error('SecurityError'); + }); + spyOn(console, 'warn'); + + service.update({ audioMuted: true }); + + expect(console.warn).toHaveBeenCalledWith( + jasmine.stringContaining('Failed to save settings'), + jasmine.anything() + ); + }); + }); }); diff --git a/src/app/game/game-board/services/settings.service.ts b/src/app/game/game-board/services/settings.service.ts index cb404212..9536fc3e 100644 --- a/src/app/game/game-board/services/settings.service.ts +++ b/src/app/game/game-board/services/settings.service.ts @@ -51,8 +51,12 @@ export class SettingsService { private save(): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(this.settings)); - } catch { - // localStorage full or unavailable — silently fail + } catch (error) { + if (error instanceof DOMException && (error.name === 'QuotaExceededError' || error.code === 22)) { + console.warn('localStorage quota exceeded — settings were not saved. Free space by deleting unused maps.'); + } else { + console.warn('Failed to save settings — localStorage may be unavailable:', error); + } } } } diff --git a/src/app/game/game-board/services/status-effect.service.spec.ts b/src/app/game/game-board/services/status-effect.service.spec.ts index 3ff63717..d3293a2c 100644 --- a/src/app/game/game-board/services/status-effect.service.spec.ts +++ b/src/app/game/game-board/services/status-effect.service.spec.ts @@ -265,6 +265,26 @@ describe('StatusEffectService', () => { it('should be safe to call for unknown enemy', () => { expect(() => service.removeAllEffects('nonexistent')).not.toThrow(); }); + + it('should eagerly clear effects without waiting for next update() cycle', () => { + const enemy = createEnemy('e1', 100, 4); + enemyMap.set('e1', enemy); + + service.apply('e1', StatusEffectType.SLOW, 0); + service.apply('e1', StatusEffectType.BURN, 0); + + // Explicit cleanup — simulates what game-board.component does before removeEnemy() + service.removeAllEffects('e1'); + + // Remove enemy from map (simulates EnemyService.removeEnemy) + enemyMap.delete('e1'); + + // Effects should already be gone — no stale entry waiting for update() to detect + expect(service.hasEffect('e1', StatusEffectType.SLOW)).toBe(false); + expect(service.hasEffect('e1', StatusEffectType.BURN)).toBe(false); + expect(service.getEffects('e1')).toEqual([]); + expect(service.getAllActiveEffects().size).toBe(0); + }); }); // --- cleanup --- diff --git a/src/app/game/game-board/services/tower-combat.service.spec.ts b/src/app/game/game-board/services/tower-combat.service.spec.ts index 05e78fe5..ff6df64f 100644 --- a/src/app/game/game-board/services/tower-combat.service.spec.ts +++ b/src/app/game/game-board/services/tower-combat.service.spec.ts @@ -1131,6 +1131,136 @@ describe('TowerCombatService', () => { }); }); + // --- Sprint 15: Targeting strategy edge cases --- + + describe('targeting strategies: edge cases', () => { + it('nearest targeting picks closest enemy even when farther enemy has more health', () => { + service.registerTower(TOWER_ROW, TOWER_COL, TowerType.BASIC, new THREE.Group()); + // Default mode is 'nearest' + + // Close enemy with low health + const close = createEnemy('close', TOWER_WORLD_X + 0.3, TOWER_WORLD_Z, 30); + close.distanceTraveled = 1; + // Far enemy (within range=3) with high health + const far = createEnemy('far', TOWER_WORLD_X + 2.5, TOWER_WORLD_Z, 500); + far.distanceTraveled = 8; + enemyMap.set('close', close); + enemyMap.set('far', far); + + service.update(2.0, mockScene); + + expect(close.health).toBeLessThan(30); + expect(far.health).toBe(500); + }); + + it('first targeting picks enemy furthest along path regardless of distance to tower', () => { + service.registerTower(TOWER_ROW, TOWER_COL, TowerType.BASIC, new THREE.Group()); + const key = `${TOWER_ROW}-${TOWER_COL}`; + service.setTargetingMode(key, 'first'); + + // Closer to tower but early in path + const early = createEnemy('early', TOWER_WORLD_X + 0.2, TOWER_WORLD_Z, 1000); + early.distanceTraveled = 1; + // Farther from tower but further along path + const late = createEnemy('late', TOWER_WORLD_X + 2.5, TOWER_WORLD_Z, 1000); + late.distanceTraveled = 15; + enemyMap.set('early', early); + enemyMap.set('late', late); + + service.update(2.0, mockScene); + + // 'first' targets highest distanceTraveled — 'late' gets hit + expect(late.health).toBeLessThan(1000); + expect(early.health).toBe(1000); + }); + + it('strongest targeting picks enemy with highest current health', () => { + service.registerTower(TOWER_ROW, TOWER_COL, TowerType.BASIC, new THREE.Group()); + const key = `${TOWER_ROW}-${TOWER_COL}`; + service.setTargetingMode(key, 'strongest'); + + const weak = createEnemy('weak', TOWER_WORLD_X + 0.2, TOWER_WORLD_Z, 20); + const medium = createEnemy('medium', TOWER_WORLD_X + 1.0, TOWER_WORLD_Z, 150); + const strong = createEnemy('strong', TOWER_WORLD_X + 2.0, TOWER_WORLD_Z, 400); + enemyMap.set('weak', weak); + enemyMap.set('medium', medium); + enemyMap.set('strong', strong); + + service.update(2.0, mockScene); + + // 'strongest' picks the 400hp enemy + expect(strong.health).toBeLessThan(400); + expect(weak.health).toBe(20); + expect(medium.health).toBe(150); + }); + + }); + + describe('chain lightning: no duplicate hits', () => { + it('should not chain back to the primary target when two enemies are in range', () => { + service.registerTower(TOWER_ROW, TOWER_COL, TowerType.CHAIN, new THREE.Group()); + + const chainRange = TOWER_CONFIGS[TowerType.CHAIN].chainRange!; + const baseDamage = TOWER_CONFIGS[TowerType.CHAIN].damage; + + // Two enemies within chain range of each other + const e1 = createEnemy('e1', TOWER_WORLD_X, TOWER_WORLD_Z, 1000); + const e2 = createEnemy('e2', TOWER_WORLD_X + chainRange * 0.5, TOWER_WORLD_Z, 1000); + enemyMap.set('e1', e1); + enemyMap.set('e2', e2); + + service.update(1.0, mockScene); + + // e1 hit once (primary), e2 hit once (first bounce) + // e1 should NOT be hit again by chaining back from e2 + expect(1000 - e1.health).toBe(baseDamage); + expect(1000 - e2.health).toBe(Math.round(baseDamage * 0.7)); + }); + }); + + describe('selling tower during combat', () => { + it('should not crash when tower is unregistered between fire and projectile update', () => { + service.registerTower(TOWER_ROW, TOWER_COL, TowerType.BASIC, new THREE.Group()); + const key = `${TOWER_ROW}-${TOWER_COL}`; + + // Enemy at distance so projectile is in flight + const enemy = createEnemy('e1', TOWER_WORLD_X + 2, TOWER_WORLD_Z, 1000); + enemyMap.set('e1', enemy); + + // Fire — projectile launched + service.update(0.016, mockScene); + + // Sell the tower while projectile is in flight + service.unregisterTower(key); + expect(service.getTower(key)).toBeUndefined(); + + // Update should not throw — projectile still tracks target + expect(() => { + service.update(1.0, mockScene); + }).not.toThrow(); + }); + + it('should still damage enemy when tower is sold after projectile is fired', () => { + service.registerTower(TOWER_ROW, TOWER_COL, TowerType.BASIC, new THREE.Group()); + const key = `${TOWER_ROW}-${TOWER_COL}`; + + const enemy = createEnemy('e1', TOWER_WORLD_X + 1, TOWER_WORLD_Z, 1000); + enemyMap.set('e1', enemy); + + // Fire + service.update(0.016, mockScene); + + // Sell tower + service.unregisterTower(key); + + // Advance enough for projectile to reach target (dist=1, speed=8 for BASIC -> ~0.13s) + service.update(0.5, mockScene); + + // Projectile should have hit regardless of tower being removed + expect(enemy.health).toBeLessThan(1000); + }); + }); + // --- Chain Lightning Zigzag --- describe('chain lightning zigzag', () => { diff --git a/src/app/game/game-board/services/wave.service.spec.ts b/src/app/game/game-board/services/wave.service.spec.ts index a4f66e94..0d5a3826 100644 --- a/src/app/game/game-board/services/wave.service.spec.ts +++ b/src/app/game/game-board/services/wave.service.spec.ts @@ -3,7 +3,7 @@ import { WaveService } from './wave.service'; import { EnemyService } from './enemy.service'; import { GameBoardService } from '../game-board.service'; import { EnemyType } from '../models/enemy.model'; -import { ENDLESS_CONFIG, WAVE_DEFINITIONS } from '../models/wave.model'; +import { ENDLESS_CONFIG, WAVE_DEFINITIONS, ENDLESS_BASE_REWARD, ENDLESS_REWARD_SCALE_PER_WAVE } from '../models/wave.model'; import * as THREE from 'three'; describe('WaveService', () => { @@ -435,4 +435,110 @@ describe('WaveService', () => { expect(service.isSpawning()).toBeFalse(); }); }); + + // --- Sprint 15: Edge case tests --- + + describe('generateEndlessWave: specific wave validation', () => { + it('wave 11 should produce valid enemy types (no BOSS since 11 % 5 !== 0)', () => { + const wave = service.generateEndlessWave(11); + const nonBossEntries = wave.entries.filter(e => e.type !== EnemyType.BOSS); + expect(nonBossEntries.length).toBe(2); // primary + secondary + const validTypes = Object.values(EnemyType) as string[]; + for (const entry of wave.entries) { + expect(validTypes).toContain(entry.type); + expect(entry.count).toBeGreaterThan(0); + } + }); + + it('wave 20 should include a BOSS entry (20 % 5 === 0)', () => { + const wave = service.generateEndlessWave(20); + const bossEntry = wave.entries.find(e => e.type === EnemyType.BOSS); + expect(bossEntry).toBeDefined(); + expect(bossEntry!.count).toBe(1); + }); + + it('wave 50 should have significantly more enemies than wave 11', () => { + const wave11 = service.generateEndlessWave(11); + const wave50 = service.generateEndlessWave(50); + const count11 = wave11.entries.reduce((s, e) => s + e.count, 0); + const count50 = wave50.entries.reduce((s, e) => s + e.count, 0); + expect(count50).toBeGreaterThan(count11 * 2); + }); + }); + + describe('endless wave scaling multipliers', () => { + it('health and speed multipliers should increase linearly with wave number', () => { + const wave11HealthMult = ENDLESS_CONFIG.baseHealthMultiplier + ENDLESS_CONFIG.healthScalePerWave * 10; + const wave20HealthMult = ENDLESS_CONFIG.baseHealthMultiplier + ENDLESS_CONFIG.healthScalePerWave * 19; + expect(wave20HealthMult).toBeGreaterThan(wave11HealthMult); + expect(wave20HealthMult - wave11HealthMult).toBeCloseTo(ENDLESS_CONFIG.healthScalePerWave * 9); + + const wave11SpeedMult = ENDLESS_CONFIG.baseSpeedMultiplier + ENDLESS_CONFIG.speedScalePerWave * 10; + const wave50SpeedMult = ENDLESS_CONFIG.baseSpeedMultiplier + ENDLESS_CONFIG.speedScalePerWave * 49; + expect(wave50SpeedMult).toBeGreaterThan(wave11SpeedMult); + }); + }); + + describe('getMaxWaves', () => { + it('should return WAVE_DEFINITIONS.length regardless of endless mode', () => { + expect(service.getMaxWaves()).toBe(WAVE_DEFINITIONS.length); + expect(service.getMaxWaves()).toBe(10); + + service.setEndlessMode(true); + expect(service.getMaxWaves()).toBe(WAVE_DEFINITIONS.length); + }); + }); + + describe('spawn queue retry: SPAWN_MAX_RETRIES', () => { + it('should skip an enemy after 300 consecutive spawn failures', () => { + service.startWave(1, mockScene); + const initialRemaining = service.getRemainingToSpawn(); // 5 + + // All spawns fail + enemyServiceSpy.spawnEnemy.and.returnValue(null); + + // Each update call at sufficient interval triggers one spawn attempt. + // Need 300 consecutive failures to skip one enemy. + for (let i = 0; i < 300; i++) { + service.update(0.016, mockScene); + } + + // After 300 failures, the enemy should be skipped (remaining decremented) + expect(service.getRemainingToSpawn()).toBe(initialRemaining - 1); + }); + + it('should reset failure counter when spawn succeeds', () => { + service.startWave(1, mockScene); + + // First few spawns fail + enemyServiceSpy.spawnEnemy.and.returnValue(null); + for (let i = 0; i < 10; i++) { + service.update(0.016, mockScene); + } + + // Then spawn succeeds — counter resets + enemyServiceSpy.spawnEnemy.and.returnValue({ id: 'e1' } as any); + service.update(1.6, mockScene); // past spawn interval + const remaining = service.getRemainingToSpawn(); + + // Fail again — should need another full 300 failures to skip + enemyServiceSpy.spawnEnemy.and.returnValue(null); + service.update(0.016, mockScene); + // Remaining should not have changed after just 1 failure + expect(service.getRemainingToSpawn()).toBe(remaining); + }); + }); + + describe('wave reward scaling formula', () => { + it('should produce correct rewards: ENDLESS_BASE_REWARD + ENDLESS_REWARD_SCALE_PER_WAVE * (waveNumber - 1)', () => { + service.setEndlessMode(true); + + // Wave 11: 200 + 50 * 10 = 700 + expect(service.getWaveReward(11)).toBe(ENDLESS_BASE_REWARD + ENDLESS_REWARD_SCALE_PER_WAVE * 10); + expect(service.getWaveReward(11)).toBe(700); + + // Wave 50: 200 + 50 * 49 = 2650 + expect(service.getWaveReward(50)).toBe(ENDLESS_BASE_REWARD + ENDLESS_REWARD_SCALE_PER_WAVE * 49); + }); + }); }); diff --git a/src/app/game/game-board/services/wave.service.ts b/src/app/game/game-board/services/wave.service.ts index f3fc57b2..2bdd8468 100644 --- a/src/app/game/game-board/services/wave.service.ts +++ b/src/app/game/game-board/services/wave.service.ts @@ -4,11 +4,15 @@ import { EnemyType } from '../models/enemy.model'; import { WaveDefinition, WaveEntry, WAVE_DEFINITIONS, ENDLESS_CONFIG, ENDLESS_BASE_COUNT, ENDLESS_BASE_SPAWN_INTERVAL, ENDLESS_BASE_REWARD, ENDLESS_REWARD_SCALE_PER_WAVE, ENDLESS_BOSS_COUNT, ENDLESS_BOSS_SPAWN_INTERVAL } from '../models/wave.model'; import { EnemyService } from './enemy.service'; +/** Max consecutive spawn failures before skipping an enemy (5 seconds at 60Hz). */ +const SPAWN_MAX_RETRIES = 300; + interface SpawnQueue { type: EnemyType; spawnInterval: number; remaining: number; timeSinceLastSpawn: number; + consecutiveFailures: number; } // Enemy types that cycle in endless waves (excludes BOSS — added separately at intervals) @@ -120,7 +124,8 @@ export class WaveService { type: entry.type, spawnInterval: entry.spawnInterval, remaining: Math.round(entry.count * countMultiplier), - timeSinceLastSpawn: entry.spawnInterval // spawn first immediately + timeSinceLastSpawn: entry.spawnInterval, // spawn first immediately + consecutiveFailures: 0 })); this.active = true; @@ -142,8 +147,16 @@ export class WaveService { if (spawned) { queue.remaining--; queue.timeSinceLastSpawn = 0; + queue.consecutiveFailures = 0; + } else { + // Spawn failed (no valid path) — retry next tick, but skip after max retries + queue.consecutiveFailures++; + if (queue.consecutiveFailures >= SPAWN_MAX_RETRIES) { + console.warn(`Skipping ${queue.type} spawn after ${SPAWN_MAX_RETRIES} consecutive failures`); + queue.remaining--; + queue.consecutiveFailures = 0; + } } - // If spawn failed (no valid path), keep in queue and retry next tick } } diff --git a/src/app/game/game-board/styles/_game-mixins.scss b/src/app/game/game-board/styles/_game-mixins.scss new file mode 100644 index 00000000..5fe7fcdf --- /dev/null +++ b/src/app/game/game-board/styles/_game-mixins.scss @@ -0,0 +1,7 @@ +@mixin glass-panel($bg: rgba(0, 0, 0, 0.75)) { + background: $bg; + border-radius: var(--border-radius-md); + backdrop-filter: blur(0.625rem); + z-index: var(--z-index-overlay); + font-family: "Orbitron", sans-serif; +} diff --git a/src/app/game/game-board/utils/three-utils.spec.ts b/src/app/game/game-board/utils/three-utils.spec.ts new file mode 100644 index 00000000..ef70dae0 --- /dev/null +++ b/src/app/game/game-board/utils/three-utils.spec.ts @@ -0,0 +1,130 @@ +import * as THREE from 'three'; +import { disposeMaterial } from './three-utils'; + +describe('disposeMaterial', () => { + let materialsToCleanup: THREE.Material[]; + + beforeEach(() => { + materialsToCleanup = []; + }); + + afterEach(() => { + materialsToCleanup.forEach(m => m.dispose()); + materialsToCleanup = []; + }); + + function track(material: T): T { + materialsToCleanup.push(material); + return material; + } + + // --- Single Material --- + + it('should dispose a single MeshBasicMaterial', () => { + const material = track(new THREE.MeshBasicMaterial()); + spyOn(material, 'dispose').and.callThrough(); + + disposeMaterial(material); + + expect(material.dispose).toHaveBeenCalledTimes(1); + }); + + it('should dispose a single MeshStandardMaterial', () => { + const material = track(new THREE.MeshStandardMaterial()); + spyOn(material, 'dispose').and.callThrough(); + + disposeMaterial(material); + + expect(material.dispose).toHaveBeenCalledTimes(1); + }); + + it('should dispose a single MeshPhongMaterial', () => { + const material = track(new THREE.MeshPhongMaterial()); + spyOn(material, 'dispose').and.callThrough(); + + disposeMaterial(material); + + expect(material.dispose).toHaveBeenCalledTimes(1); + }); + + it('should dispose a single LineBasicMaterial', () => { + const material = track(new THREE.LineBasicMaterial()); + spyOn(material, 'dispose').and.callThrough(); + + disposeMaterial(material); + + expect(material.dispose).toHaveBeenCalledTimes(1); + }); + + // --- Material[] array --- + + it('should dispose every material in a Material[] array', () => { + const materials = [ + track(new THREE.MeshBasicMaterial()), + track(new THREE.MeshStandardMaterial()), + track(new THREE.MeshPhongMaterial()) + ]; + materials.forEach(m => spyOn(m, 'dispose').and.callThrough()); + + disposeMaterial(materials); + + materials.forEach(m => { + expect(m.dispose).toHaveBeenCalledTimes(1); + }); + }); + + it('should dispose a single-element array', () => { + const material = track(new THREE.MeshBasicMaterial()); + spyOn(material, 'dispose').and.callThrough(); + + disposeMaterial([material]); + + expect(material.dispose).toHaveBeenCalledTimes(1); + }); + + // --- Empty array --- + + it('should not throw when given an empty array', () => { + expect(() => disposeMaterial([])).not.toThrow(); + }); + + // --- Already-disposed material --- + + it('should not throw when disposing an already-disposed single material', () => { + const material = track(new THREE.MeshBasicMaterial()); + material.dispose(); + + expect(() => disposeMaterial(material)).not.toThrow(); + }); + + it('should not throw when disposing an array containing already-disposed materials', () => { + const m1 = track(new THREE.MeshBasicMaterial()); + const m2 = track(new THREE.MeshStandardMaterial()); + m1.dispose(); + + expect(() => disposeMaterial([m1, m2])).not.toThrow(); + }); + + // --- Correct branch selection --- + + it('should use Array.forEach path for arrays, not single dispose', () => { + const materials = [ + track(new THREE.MeshBasicMaterial()), + track(new THREE.MeshBasicMaterial()) + ]; + const spies = materials.map(m => spyOn(m, 'dispose').and.callThrough()); + + disposeMaterial(materials); + + spies.forEach(s => expect(s).toHaveBeenCalledTimes(1)); + }); + + it('should call dispose directly for a non-array material', () => { + const material = track(new THREE.MeshBasicMaterial()); + const spy = spyOn(material, 'dispose').and.callThrough(); + + disposeMaterial(material); + + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/game/game.module.ts b/src/app/game/game.module.ts index 8aa64b67..460afa45 100644 --- a/src/app/game/game.module.ts +++ b/src/app/game/game.module.ts @@ -3,11 +3,23 @@ import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { GameComponent } from './game.component'; import { GameBoardComponent } from './game-board/game-board.component'; +import { GameHUDComponent } from './game-board/components/game-hud/game-hud.component'; +import { GameResultsComponent } from './game-board/components/game-results/game-results.component'; +import { GameSetupComponent } from './game-board/components/game-setup/game-setup.component'; +import { TowerInfoPanelComponent } from './game-board/components/tower-info-panel/tower-info-panel.component'; +import { GameControlsComponent } from './game-board/components/game-controls/game-controls.component'; +import { TowerSelectionBarComponent } from './game-board/components/tower-selection-bar/tower-selection-bar.component'; import { GameBoardService } from './game-board/game-board.service'; @NgModule({ declarations: [ GameComponent, - GameBoardComponent + GameBoardComponent, + GameHUDComponent, + GameResultsComponent, + GameSetupComponent, + TowerInfoPanelComponent, + GameControlsComponent, + TowerSelectionBarComponent ], imports: [ CommonModule, diff --git a/src/app/game/map-select/map-select.component.html b/src/app/game/map-select/map-select.component.html index abba6761..3c1c2ff6 100644 --- a/src/app/game/map-select/map-select.component.html +++ b/src/app/game/map-select/map-select.component.html @@ -7,6 +7,18 @@

Select Map

+
+ +
+
+ BUILT-IN + {{ demo.name }} + {{ demo.description }} + {{ demo.state.gridSize }} × {{ demo.state.gridSize }} +
+
+
+
diff --git a/src/app/game/map-select/map-select.component.scss b/src/app/game/map-select/map-select.component.scss index cdf3ef14..855ce576 100644 --- a/src/app/game/map-select/map-select.component.scss +++ b/src/app/game/map-select/map-select.component.scss @@ -182,6 +182,43 @@ to { box-shadow: 0 0 8px rgba(255, 102, 102, 0.5); } } +// --- Demo map section --- +.demo-section { + margin-bottom: var(--spacing-xl); +} + +.demo-card { + border-color: var(--theme-purple) !important; + padding-top: var(--spacing-lg) !important; + + &:hover { + border-color: var(--theme-purple-lighter) !important; + box-shadow: 0 0 18px var(--theme-purple-glow) !important; + } +} + +.demo-badge { + position: absolute; + top: var(--spacing-sm); + right: var(--spacing-sm); + padding: 2px var(--spacing-sm); + border-radius: var(--border-radius-sm); + background: var(--theme-purple-a20); + border: var(--border-width-thin) solid var(--theme-purple); + color: var(--theme-purple-lighter); + font-family: 'Orbitron', sans-serif; + font-size: var(--font-size-3xs); + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.map-description { + font-size: var(--font-size-small); + color: var(--theme-purple-mid); + line-height: 1.4; +} + // --- Map card text --- .map-name { font-size: var(--font-size-base); diff --git a/src/app/game/map-select/map-select.component.spec.ts b/src/app/game/map-select/map-select.component.spec.ts index 4b0d2596..75a8f1f9 100644 --- a/src/app/game/map-select/map-select.component.spec.ts +++ b/src/app/game/map-select/map-select.component.spec.ts @@ -6,6 +6,7 @@ import { MapStorageService, MapMetadata } from '../../games/novarise/core/map-st import { MapBridgeService } from '../game-board/services/map-bridge.service'; import { TerrainGridState } from '../../games/novarise/features/terrain-editor/terrain-grid-state.interface'; import { TerrainType } from '../../games/novarise/models/terrain-types.enum'; +import { DEMO_MAPS } from '../game-board/models/demo-maps.model'; const MOCK_MAPS: MapMetadata[] = [ { id: 'map_1', name: 'First Map', createdAt: 1000, updatedAt: 2000, version: '1.0.0', gridSize: 25 }, @@ -29,13 +30,14 @@ describe('MapSelectComponent', () => { let routerSpy: jasmine.SpyObj; beforeEach(async () => { - mapStorageSpy = jasmine.createSpyObj('MapStorageService', ['getAllMaps', 'loadMap', 'deleteMap']); + mapStorageSpy = jasmine.createSpyObj('MapStorageService', ['getAllMaps', 'loadMap', 'deleteMap', 'validateMapPlayability']); mapBridgeSpy = jasmine.createSpyObj('MapBridgeService', ['setEditorMapState', 'clearEditorMap']); routerSpy = jasmine.createSpyObj('Router', ['navigate']); mapStorageSpy.getAllMaps.and.returnValue(MOCK_MAPS); mapStorageSpy.loadMap.and.returnValue(MOCK_TERRAIN_STATE); mapStorageSpy.deleteMap.and.returnValue(true); + mapStorageSpy.validateMapPlayability.and.returnValue({ playable: true }); routerSpy.navigate.and.returnValue(Promise.resolve(true)); await TestBed.configureTestingModule({ @@ -67,7 +69,7 @@ describe('MapSelectComponent', () => { it('should show map cards when maps are available', () => { fixture.detectChanges(); - const cards = fixture.nativeElement.querySelectorAll('.map-card'); + const cards = fixture.nativeElement.querySelectorAll('.map-card:not(.demo-card)'); expect(cards.length).toBe(2); }); @@ -82,7 +84,7 @@ describe('MapSelectComponent', () => { fixture.detectChanges(); const emptyState = fixture.nativeElement.querySelector('.empty-state'); expect(emptyState).toBeTruthy(); - const cards = fixture.nativeElement.querySelectorAll('.map-card'); + const cards = fixture.nativeElement.querySelectorAll('.map-card:not(.demo-card)'); expect(cards.length).toBe(0); }); @@ -108,6 +110,17 @@ describe('MapSelectComponent', () => { expect(routerSpy.navigate).not.toHaveBeenCalled(); }); + it('selectMap should not navigate when map fails playability validation', () => { + mapStorageSpy.validateMapPlayability.and.returnValue({ playable: false, error: 'Map has no spawn points' }); + spyOn(window, 'alert'); + fixture.detectChanges(); + component.selectMap(MOCK_MAPS[0]); + expect(mapStorageSpy.validateMapPlayability).toHaveBeenCalledOnceWith(MOCK_TERRAIN_STATE); + expect(window.alert).toHaveBeenCalledWith('This map cannot be played: Map has no spawn points'); + expect(mapBridgeSpy.setEditorMapState).not.toHaveBeenCalled(); + expect(routerSpy.navigate).not.toHaveBeenCalled(); + }); + it('goToEditor should navigate to /edit', () => { fixture.detectChanges(); component.goToEditor(); @@ -225,10 +238,94 @@ describe('MapSelectComponent', () => { describe('grid size display', () => { it('should display grid size on map cards', () => { fixture.detectChanges(); - const metaElements = fixture.nativeElement.querySelectorAll('.map-card .map-meta'); + const metaElements = fixture.nativeElement.querySelectorAll('.map-section:not(.demo-section) .map-card .map-meta'); expect(metaElements.length).toBe(2); expect(metaElements[0].textContent).toContain('25'); expect(metaElements[1].textContent).toContain('30'); }); }); + + describe('demo maps', () => { + it('should render demo map cards', () => { + fixture.detectChanges(); + const demoCards = fixture.nativeElement.querySelectorAll('.demo-card'); + expect(demoCards.length).toBe(DEMO_MAPS.length); + }); + + it('should show "Built-in Maps" section label', () => { + fixture.detectChanges(); + const demoSection = fixture.nativeElement.querySelector('.demo-section .section-label'); + expect(demoSection).toBeTruthy(); + expect(demoSection.textContent).toContain('Built-in Maps'); + }); + + it('should display demo map names', () => { + fixture.detectChanges(); + const names = fixture.nativeElement.querySelectorAll('.demo-card .map-name'); + expect(names.length).toBe(DEMO_MAPS.length); + for (let i = 0; i < DEMO_MAPS.length; i++) { + expect(names[i].textContent).toContain(DEMO_MAPS[i].name); + } + }); + + it('should display demo map descriptions', () => { + fixture.detectChanges(); + const descriptions = fixture.nativeElement.querySelectorAll('.demo-card .map-description'); + expect(descriptions.length).toBe(DEMO_MAPS.length); + for (let i = 0; i < DEMO_MAPS.length; i++) { + expect(descriptions[i].textContent).toContain(DEMO_MAPS[i].description); + } + }); + + it('should display demo map grid sizes', () => { + fixture.detectChanges(); + const metas = fixture.nativeElement.querySelectorAll('.demo-card .map-meta'); + expect(metas.length).toBe(DEMO_MAPS.length); + for (let i = 0; i < DEMO_MAPS.length; i++) { + expect(metas[i].textContent).toContain(String(DEMO_MAPS[i].state.gridSize)); + } + }); + + it('should show BUILT-IN badge on demo cards', () => { + fixture.detectChanges(); + const badges = fixture.nativeElement.querySelectorAll('.demo-card .demo-badge'); + expect(badges.length).toBe(DEMO_MAPS.length); + expect(badges[0].textContent).toContain('BUILT-IN'); + }); + + it('should NOT show edit or delete buttons on demo cards', () => { + fixture.detectChanges(); + const demoCards = fixture.nativeElement.querySelectorAll('.demo-card'); + for (let i = 0; i < demoCards.length; i++) { + const editBtn = demoCards[i].querySelector('.edit-btn'); + const deleteBtn = demoCards[i].querySelector('.delete-btn'); + expect(editBtn).toBeNull(); + expect(deleteBtn).toBeNull(); + } + }); + + it('should NOT show card-actions container on demo cards', () => { + fixture.detectChanges(); + const demoCards = fixture.nativeElement.querySelectorAll('.demo-card'); + for (let i = 0; i < demoCards.length; i++) { + const actions = demoCards[i].querySelector('.card-actions'); + expect(actions).toBeNull(); + } + }); + + it('selectDemoMap should set map state via bridge and navigate to /play', () => { + fixture.detectChanges(); + component.selectDemoMap(DEMO_MAPS[0]); + expect(mapBridgeSpy.setEditorMapState).toHaveBeenCalledOnceWith(DEMO_MAPS[0].state); + expect(routerSpy.navigate).toHaveBeenCalledOnceWith(['/play']); + }); + + it('clicking a demo card should call selectDemoMap', () => { + fixture.detectChanges(); + const demoCards = fixture.nativeElement.querySelectorAll('.demo-card'); + spyOn(component, 'selectDemoMap'); + demoCards[0].click(); + expect(component.selectDemoMap).toHaveBeenCalledOnceWith(DEMO_MAPS[0]); + }); + }); }); diff --git a/src/app/game/map-select/map-select.component.ts b/src/app/game/map-select/map-select.component.ts index ebf6682f..e9c80f2a 100644 --- a/src/app/game/map-select/map-select.component.ts +++ b/src/app/game/map-select/map-select.component.ts @@ -3,6 +3,7 @@ import { Router } from '@angular/router'; import { MapStorageService, MapMetadata } from '../../games/novarise/core/map-storage.service'; import { MapBridgeService } from '../game-board/services/map-bridge.service'; import { QUICK_PLAY_PARAM } from '../guards/game.guard'; +import { DEMO_MAPS, DemoMapConfig } from '../game-board/models/demo-maps.model'; const DELETE_CONFIRM_TIMEOUT_MS = 3000; @@ -13,6 +14,7 @@ const DELETE_CONFIRM_TIMEOUT_MS = 3000; }) export class MapSelectComponent implements OnInit, OnDestroy { maps: MapMetadata[] = []; + readonly demoMaps: readonly DemoMapConfig[] = DEMO_MAPS; deleteConfirmId: string | null = null; private deleteConfirmTimer: ReturnType | null = null; @@ -32,12 +34,19 @@ export class MapSelectComponent implements OnInit, OnDestroy { selectMap(map: MapMetadata): void { const mapData = this.mapStorage.loadMap(map.id); - if (mapData) { - this.mapBridge.setEditorMapState(mapData); - this.router.navigate(['/play']); - } else { + if (!mapData) { this.maps = this.maps.filter(m => m.id !== map.id); + return; + } + + const playability = this.mapStorage.validateMapPlayability(mapData); + if (!playability.playable) { + alert(`This map cannot be played: ${playability.error}`); + return; } + + this.mapBridge.setEditorMapState(mapData); + this.router.navigate(['/play']); } quickPlay(): void { @@ -45,6 +54,11 @@ export class MapSelectComponent implements OnInit, OnDestroy { this.router.navigate(['/play'], { queryParams: { [QUICK_PLAY_PARAM]: 'true' } }); } + selectDemoMap(demoMap: DemoMapConfig): void { + this.mapBridge.setEditorMapState(demoMap.state); + this.router.navigate(['/play']); + } + editMap(map: MapMetadata, event: Event): void { event.stopPropagation(); const mapData = this.mapStorage.loadMap(map.id); diff --git a/src/app/games/novarise/core/camera-control.service.ts b/src/app/games/novarise/core/camera-control.service.ts index 22f6b9e4..1b53a40f 100644 --- a/src/app/games/novarise/core/camera-control.service.ts +++ b/src/app/games/novarise/core/camera-control.service.ts @@ -54,9 +54,7 @@ export interface JoystickInput { y: number; } -@Injectable({ - providedIn: 'root' -}) +@Injectable() export class CameraControlService { private readonly defaultConfig: CameraConfig = { ...EDITOR_CAMERA_CONFIG }; diff --git a/src/app/games/novarise/core/edit-history.service.ts b/src/app/games/novarise/core/edit-history.service.ts index b9284e09..97d266ac 100644 --- a/src/app/games/novarise/core/edit-history.service.ts +++ b/src/app/games/novarise/core/edit-history.service.ts @@ -185,9 +185,7 @@ export class CompositeCommand implements EditCommand { /** * Service to manage edit history with undo/redo functionality */ -@Injectable({ - providedIn: 'root' -}) +@Injectable() export class EditHistoryService { private undoStack: EditCommand[] = []; private redoStack: EditCommand[] = []; diff --git a/src/app/games/novarise/core/editor-state.service.ts b/src/app/games/novarise/core/editor-state.service.ts index fb64589c..b97bfdb2 100644 --- a/src/app/games/novarise/core/editor-state.service.ts +++ b/src/app/games/novarise/core/editor-state.service.ts @@ -14,9 +14,7 @@ export interface EditorState { currentMapName: string; } -@Injectable({ - providedIn: 'root' -}) +@Injectable() export class EditorStateService { public readonly brushSizes = [1, 3, 5, 7]; diff --git a/src/app/games/novarise/core/map-storage.service.spec.ts b/src/app/games/novarise/core/map-storage.service.spec.ts index 1754ccbe..03e5436d 100644 --- a/src/app/games/novarise/core/map-storage.service.spec.ts +++ b/src/app/games/novarise/core/map-storage.service.spec.ts @@ -1,14 +1,28 @@ import { TestBed } from '@angular/core/testing'; import { MapStorageService, MapMetadata, SavedMap } from './map-storage.service'; import { TerrainGridState } from '../features/terrain-editor/terrain-grid-state.interface'; +import { TerrainType } from '../models/terrain-types.enum'; + +/** Build a valid NxN tile grid filled with BEDROCK. */ +function buildValidTiles(size: number): TerrainType[][] { + const tiles: TerrainType[][] = []; + for (let x = 0; x < size; x++) { + tiles[x] = []; + for (let z = 0; z < size; z++) { + tiles[x][z] = TerrainType.BEDROCK; + } + } + return tiles; +} function testMapData(overrides?: Record): TerrainGridState { + const gridSize = (overrides?.['gridSize'] as number) ?? 10; return { - gridSize: 25, - tiles: [], + gridSize, + tiles: buildValidTiles(gridSize), heightMap: [], - spawnPoints: [], - exitPoints: [], + spawnPoints: [{ x: 0, z: 0 }], + exitPoints: [{ x: gridSize - 1, z: gridSize - 1 }], version: '2.0.0', ...overrides } as TerrainGridState; @@ -48,7 +62,7 @@ describe('MapStorageService', () => { describe('saveMap', () => { it('should save a new map with generated ID', () => { const mapData = testMapData(); - const mapId = service.saveMap('Test Map', mapData); + const mapId = service.saveMap('Test Map', mapData)!; expect(mapId).toBeTruthy(); expect(mapId).toMatch(/^map_\d+_[a-z0-9]+$/); @@ -56,20 +70,20 @@ describe('MapStorageService', () => { it('should save map data to localStorage', () => { const mapData = testMapData(); - const mapId = service.saveMap('Test Map', mapData); + const mapId = service.saveMap('Test Map', mapData)!; const savedJson = localStorageMock['novarise_map_' + mapId]; expect(savedJson).toBeTruthy(); const savedMap: SavedMap = JSON.parse(savedJson); expect(savedMap.metadata.name).toBe('Test Map'); - expect(savedMap.data.gridSize).toBe(25); + expect(savedMap.data.gridSize).toBe(10); }); it('should update metadata with correct timestamps', () => { const mapData = testMapData(); const beforeSave = Date.now(); - const mapId = service.saveMap('Test Map', mapData); + const mapId = service.saveMap('Test Map', mapData)!; const afterSave = Date.now(); const savedJson = localStorageMock['novarise_map_' + mapId]; @@ -83,27 +97,27 @@ describe('MapStorageService', () => { it('should update existing map when ID is provided', () => { const mapData = testMapData(); - const mapId = service.saveMap('Original Name', mapData); + const mapId = service.saveMap('Original Name', mapData)!; const originalJson = localStorageMock['novarise_map_' + mapId]; const originalMap: SavedMap = JSON.parse(originalJson); const originalCreatedAt = originalMap.metadata.createdAt; // Wait a bit to ensure different timestamp - const updatedData = testMapData({ tiles: [1, 2, 3] }); - service.saveMap('Updated Name', updatedData, mapId); + const updatedData = testMapData({ gridSize: 8 }); + service.saveMap('Updated Name', updatedData, mapId!); const updatedJson = localStorageMock['novarise_map_' + mapId]; const updatedMap: SavedMap = JSON.parse(updatedJson); expect(updatedMap.metadata.name).toBe('Updated Name'); expect(updatedMap.metadata.createdAt).toBe(originalCreatedAt); - expect(updatedMap.data.tiles as unknown).toEqual([1, 2, 3] as unknown); + expect(updatedMap.data.gridSize).toBe(8); }); it('should set saved map as current map', () => { const mapData = testMapData(); - const mapId = service.saveMap('Test Map', mapData); + const mapId = service.saveMap('Test Map', mapData)!; expect(localStorageMock['novarise_current_map']).toBe(mapId); }); @@ -123,14 +137,14 @@ describe('MapStorageService', () => { describe('loadMap', () => { it('should return map data when map exists', () => { - const mapData = testMapData({ tiles: [[1, 2]] }); - const mapId = service.saveMap('Test Map', mapData); + const mapData = testMapData(); + const mapId = service.saveMap('Test Map', mapData)!; const loadedData = service.loadMap(mapId); expect(loadedData).toBeTruthy(); - expect(loadedData!.gridSize).toBe(25); - expect(loadedData!.tiles as unknown).toEqual([[1, 2]]); + expect(loadedData!.gridSize).toBe(10); + expect(loadedData!.tiles.length).toBe(10); }); it('should return null when map does not exist', () => { @@ -141,7 +155,7 @@ describe('MapStorageService', () => { it('should set loaded map as current map', () => { const mapData = testMapData(); - const mapId = service.saveMap('Test Map', mapData); + const mapId = service.saveMap('Test Map', mapData)!; // Clear current map localStorageMock['novarise_current_map'] = 'other_map'; @@ -158,6 +172,18 @@ describe('MapStorageService', () => { expect(loadedData).toBeNull(); }); + + it('should return null when parsed map has no data field', () => { + localStorageMock['novarise_map_empty'] = JSON.stringify({ metadata: { name: 'Bad' } }); + spyOn(console, 'warn'); + + const loadedData = service.loadMap('empty'); + + expect(loadedData).toBeNull(); + expect(console.warn).toHaveBeenCalledWith( + jasmine.stringContaining('has no data field') + ); + }); }); describe('getAllMaps', () => { @@ -178,11 +204,11 @@ describe('MapStorageService', () => { }); it('should return maps sorted by updated date (most recent first)', () => { - const mapId1 = service.saveMap('Map 1', testMapData()); + const mapId1 = service.saveMap('Map 1', testMapData())!; service.saveMap('Map 2', testMapData()); // Update first map to make it most recent - service.saveMap('Map 1 Updated', testMapData(), mapId1); + service.saveMap('Map 1 Updated', testMapData(), mapId1!); const maps = service.getAllMaps(); @@ -196,11 +222,23 @@ describe('MapStorageService', () => { expect(maps).toEqual([]); }); + + it('should return empty array when metadata JSON is not an array', () => { + localStorageMock['novarise_maps_metadata'] = JSON.stringify({ not: 'an array' }); + spyOn(console, 'warn'); + + const maps = service.getAllMaps(); + + expect(maps).toEqual([]); + expect(console.warn).toHaveBeenCalledWith( + jasmine.stringContaining('not an array') + ); + }); }); describe('getMapMetadata', () => { it('should return metadata for existing map', () => { - const mapId = service.saveMap('Test Map', testMapData()); + const mapId = service.saveMap('Test Map', testMapData())!; const metadata = service.getMapMetadata(mapId); @@ -218,7 +256,7 @@ describe('MapStorageService', () => { describe('deleteMap', () => { it('should delete existing map and return true', () => { - const mapId = service.saveMap('Test Map', testMapData()); + const mapId = service.saveMap('Test Map', testMapData())!; const result = service.deleteMap(mapId); @@ -233,7 +271,7 @@ describe('MapStorageService', () => { }); it('should remove map from metadata index', () => { - const mapId = service.saveMap('Test Map', testMapData()); + const mapId = service.saveMap('Test Map', testMapData())!; service.deleteMap(mapId); @@ -242,7 +280,7 @@ describe('MapStorageService', () => { }); it('should clear current map if deleted map was current', () => { - const mapId = service.saveMap('Test Map', testMapData()); + const mapId = service.saveMap('Test Map', testMapData())!; expect(localStorageMock['novarise_current_map']).toBe(mapId); service.deleteMap(mapId); @@ -253,7 +291,7 @@ describe('MapStorageService', () => { describe('getCurrentMapId', () => { it('should return current map ID when set', () => { - const mapId = service.saveMap('Test Map', testMapData()); + const mapId = service.saveMap('Test Map', testMapData())!; expect(service.getCurrentMapId()).toBe(mapId); }); @@ -265,13 +303,13 @@ describe('MapStorageService', () => { describe('loadCurrentMap', () => { it('should return current map data', () => { - const mapData = testMapData({ tiles: [[1]] }); + const mapData = testMapData(); service.saveMap('Test Map', mapData); const loadedData = service.loadCurrentMap(); expect(loadedData).toBeTruthy(); - expect(loadedData!.tiles as unknown).toEqual([[1]]); + expect(loadedData!.gridSize).toBe(10); }); it('should return null when no current map', () => { @@ -311,8 +349,8 @@ describe('MapStorageService', () => { describe('exportMapToJson', () => { it('should return JSON string for existing map', () => { - const mapData = testMapData({ tiles: [[1, 2, 3]] }); - const mapId = service.saveMap('Test Map', mapData); + const mapData = testMapData(); + const mapId = service.saveMap('Test Map', mapData)!; const json = service.exportMapToJson(mapId); @@ -337,9 +375,9 @@ describe('MapStorageService', () => { createdAt: Date.now(), updatedAt: Date.now(), version: '1.0.0', - gridSize: 25 + gridSize: 10 }, - data: testMapData({ tiles: [[1, 2]] }) + data: testMapData() }; const mapId = service.importMapFromJson(JSON.stringify(savedMap)); @@ -348,7 +386,7 @@ describe('MapStorageService', () => { expect(mapId).not.toBe('old_id'); // Should generate new ID const loadedData = service.loadMap(mapId!); - expect(loadedData!.tiles as unknown).toEqual([[1, 2]]); + expect(loadedData!.gridSize).toBe(10); }); it('should allow name override on import', () => { @@ -359,7 +397,7 @@ describe('MapStorageService', () => { createdAt: Date.now(), updatedAt: Date.now(), version: '1.0.0', - gridSize: 25 + gridSize: 10 }, data: testMapData() }; @@ -393,9 +431,9 @@ describe('MapStorageService', () => { it('should generate unique IDs for different maps', () => { const mapData = testMapData(); - const id1 = service.saveMap('Map 1', mapData); - const id2 = service.saveMap('Map 2', mapData); - const id3 = service.saveMap('Map 3', mapData); + const id1 = service.saveMap('Map 1', mapData)!; + const id2 = service.saveMap('Map 2', mapData)!; + const id3 = service.saveMap('Map 3', mapData)!; expect(id1).not.toBe(id2); expect(id2).not.toBe(id3); @@ -404,7 +442,7 @@ describe('MapStorageService', () => { it('should generate IDs with expected format', () => { const mapData = testMapData(); - const mapId = service.saveMap('Test Map', mapData); + const mapId = service.saveMap('Test Map', mapData)!; // Format: map_{timestamp}_{random} expect(mapId).toMatch(/^map_\d{13,}_[a-z0-9]{8,9}$/); @@ -420,9 +458,9 @@ describe('MapStorageService', () => { createdAt: Date.now(), updatedAt: Date.now(), version: '1.0.0', - gridSize: 25 + gridSize: 10 }, - data: testMapData({ tiles: [[1, 2, 3]] }) + data: testMapData() }; const result = service.validateMapJson(JSON.stringify(savedMap)); @@ -492,7 +530,7 @@ describe('MapStorageService', () => { it('should use "Unnamed Map" when metadata.name is missing', () => { const savedMap = { metadata: {}, // no name - data: { gridSize: 25, tiles: [] } + data: testMapData() }; const result = service.validateMapJson(JSON.stringify(savedMap)); @@ -535,7 +573,7 @@ describe('MapStorageService', () => { it('should create download link with correct filename', () => { const mapData = testMapData(); - const mapId = service.saveMap('My Test Map', mapData); + const mapId = service.saveMap('My Test Map', mapData)!; const result = service.downloadMapAsFile(mapId); @@ -546,7 +584,7 @@ describe('MapStorageService', () => { it('should sanitize filename by removing invalid characters', () => { const mapData = testMapData(); - const mapId = service.saveMap('Test<>:"/\\|?*Map', mapData); + const mapId = service.saveMap('Test<>:"/\\|?*Map', mapData)!; service.downloadMapAsFile(mapId); @@ -555,7 +593,7 @@ describe('MapStorageService', () => { it('should sanitize filename by replacing spaces with underscores', () => { const mapData = testMapData(); - const mapId = service.saveMap('My Map Name', mapData); + const mapId = service.saveMap('My Map Name', mapData)!; service.downloadMapAsFile(mapId); @@ -565,7 +603,7 @@ describe('MapStorageService', () => { it('should truncate long filenames', () => { const mapData = testMapData(); const longName = 'A'.repeat(100); - const mapId = service.saveMap(longName, mapData); + const mapId = service.saveMap(longName, mapData)!; service.downloadMapAsFile(mapId); @@ -575,7 +613,7 @@ describe('MapStorageService', () => { it('should use default filename when map name is empty', () => { const mapData = testMapData(); - const mapId = service.saveMap('', mapData); + const mapId = service.saveMap('', mapData)!; service.downloadMapAsFile(mapId); @@ -584,7 +622,7 @@ describe('MapStorageService', () => { it('should create blob URL and trigger download', () => { const mapData = testMapData(); - const mapId = service.saveMap('Test', mapData); + const mapId = service.saveMap('Test', mapData)!; service.downloadMapAsFile(mapId); @@ -595,7 +633,7 @@ describe('MapStorageService', () => { it('should clean up blob URL after download', () => { const mapData = testMapData(); - const mapId = service.saveMap('Test', mapData); + const mapId = service.saveMap('Test', mapData)!; service.downloadMapAsFile(mapId); @@ -604,7 +642,7 @@ describe('MapStorageService', () => { it('should append and remove link from document body', () => { const mapData = testMapData(); - const mapId = service.saveMap('Test', mapData); + const mapId = service.saveMap('Test', mapData)!; service.downloadMapAsFile(mapId); @@ -693,7 +731,7 @@ describe('MapStorageService', () => { createdAt: Date.now(), updatedAt: Date.now(), version: '1.0.0', - gridSize: 25 + gridSize: 10 }, data: testMapData() }; @@ -747,6 +785,37 @@ describe('MapStorageService', () => { }); }); + describe('promptFileImport file size limit', () => { + let mockInput: HTMLInputElement; + + beforeEach(() => { + mockInput = document.createElement('input'); + spyOn(document, 'createElement').and.callFake((tag: string) => { + if (tag === 'input') return mockInput; + return document.createElement.call(document, tag); + }); + spyOn(mockInput, 'click'); + }); + + it('should reject files larger than 10 MB', async () => { + // Create a mock file with size > 10MB (just mock the size property) + const mockFile = new File(['x'], 'huge.json', { type: 'application/json' }); + Object.defineProperty(mockFile, 'size', { value: 11 * 1024 * 1024 }); + spyOn(console, 'error'); + + const promise = service.promptFileImport(); + + Object.defineProperty(mockInput, 'files', { value: [mockFile] }); + if (mockInput.onchange) { + await (mockInput.onchange as Function)({ target: mockInput }); + } + + const result = await promise; + expect(result).toBeNull(); + expect(console.error).toHaveBeenCalledWith(jasmine.stringContaining('File too large')); + }); + }); + describe('importMapFromJson (edge cases)', () => { it('should return null when data has invalid gridSize type', () => { const invalidMap: SavedMap = { @@ -756,7 +825,7 @@ describe('MapStorageService', () => { createdAt: Date.now(), updatedAt: Date.now(), version: '1.0.0', - gridSize: 25 + gridSize: 10 }, data: testMapData({ gridSize: 'invalid' as unknown }) }; @@ -774,7 +843,7 @@ describe('MapStorageService', () => { createdAt: Date.now(), updatedAt: Date.now(), version: '1.0.0', - gridSize: 25 + gridSize: 10 }, data: testMapData() }; @@ -797,4 +866,375 @@ describe('MapStorageService', () => { expect(metadata!.name).toBe('Imported Map'); }); }); + + describe('validateMapJson (hardened)', () => { + it('should reject gridSize too small (< 5)', () => { + const savedMap = { + metadata: { name: 'Tiny' }, + data: { gridSize: 3, tiles: buildValidTiles(3), spawnPoints: [], exitPoints: [], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('Grid size must be between'); + expect(result.error).toContain('3'); + }); + + it('should reject gridSize too large (> 30)', () => { + const savedMap = { + metadata: { name: 'Huge' }, + data: { gridSize: 50, tiles: buildValidTiles(50), spawnPoints: [], exitPoints: [], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('Grid size must be between'); + expect(result.error).toContain('50'); + }); + + it('should reject jagged tile arrays (unequal column lengths)', () => { + const tiles = buildValidTiles(5); + tiles[2] = tiles[2].slice(0, 3); // make column 2 shorter + const savedMap = { + metadata: { name: 'Jagged' }, + data: { gridSize: 5, tiles, spawnPoints: [], exitPoints: [], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('column 2'); + expect(result.error).toContain('length 3'); + }); + + it('should reject tiles array length mismatch with gridSize', () => { + const savedMap = { + metadata: { name: 'Short' }, + data: { gridSize: 10, tiles: buildValidTiles(7), spawnPoints: [], exitPoints: [], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('Tiles array length (7)'); + expect(result.error).toContain('gridSize (10)'); + }); + + it('should reject invalid terrain type values', () => { + const tiles = buildValidTiles(5); + tiles[1][2] = 'lava' as TerrainType; + const savedMap = { + metadata: { name: 'BadTerrain' }, + data: { gridSize: 5, tiles, spawnPoints: [], exitPoints: [], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid terrain type'); + expect(result.error).toContain('lava'); + expect(result.error).toContain('[1][2]'); + }); + + it('should reject out-of-bounds spawn point coordinates', () => { + const savedMap = { + metadata: { name: 'OOB Spawn' }, + data: { gridSize: 5, tiles: buildValidTiles(5), spawnPoints: [{ x: 10, z: 0 }], exitPoints: [{ x: 4, z: 4 }], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('Spawn point'); + expect(result.error).toContain('out of bounds'); + }); + + it('should reject out-of-bounds exit point coordinates', () => { + const savedMap = { + metadata: { name: 'OOB Exit' }, + data: { gridSize: 5, tiles: buildValidTiles(5), spawnPoints: [{ x: 0, z: 0 }], exitPoints: [{ x: -1, z: 2 }], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('Exit point'); + expect(result.error).toContain('out of bounds'); + }); + + it('should reject excessive spawn points (> 4)', () => { + const spawnPoints = [ + { x: 0, z: 0 }, { x: 0, z: 1 }, { x: 0, z: 2 }, + { x: 0, z: 3 }, { x: 0, z: 4 } + ]; + const savedMap = { + metadata: { name: 'Too many spawns' }, + data: { gridSize: 5, tiles: buildValidTiles(5), spawnPoints, exitPoints: [{ x: 4, z: 4 }], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('Too many spawn points'); + }); + + it('should reject excessive exit points (> 4)', () => { + const exitPoints = [ + { x: 4, z: 0 }, { x: 4, z: 1 }, { x: 4, z: 2 }, + { x: 4, z: 3 }, { x: 4, z: 4 } + ]; + const savedMap = { + metadata: { name: 'Too many exits' }, + data: { gridSize: 5, tiles: buildValidTiles(5), spawnPoints: [{ x: 0, z: 0 }], exitPoints, version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('Too many exit points'); + }); + + it('should accept minimum valid grid (5x5)', () => { + const savedMap = { + metadata: { name: 'Minimum' }, + data: { gridSize: 5, tiles: buildValidTiles(5), spawnPoints: [{ x: 0, z: 0 }], exitPoints: [{ x: 4, z: 4 }], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(true); + }); + + it('should accept maximum valid grid (30x30)', () => { + const savedMap = { + metadata: { name: 'Maximum' }, + data: { gridSize: 30, tiles: buildValidTiles(30), spawnPoints: [{ x: 0, z: 0 }], exitPoints: [{ x: 29, z: 29 }], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(true); + }); + + it('should accept legacy single spawnPoint/exitPoint format', () => { + const savedMap = { + metadata: { name: 'Legacy' }, + data: { + gridSize: 5, + tiles: buildValidTiles(5), + spawnPoint: { x: 0, z: 0 }, + exitPoint: { x: 4, z: 4 }, + version: '1.0.0' + } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(true); + }); + + it('should reject legacy spawnPoint with out-of-bounds coordinates', () => { + const savedMap = { + metadata: { name: 'Legacy OOB' }, + data: { + gridSize: 5, + tiles: buildValidTiles(5), + spawnPoint: { x: 5, z: 0 }, + exitPoint: { x: 4, z: 4 }, + version: '1.0.0' + } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('Spawn point'); + expect(result.error).toContain('out of bounds'); + }); + + it('should reject legacy exitPoint with out-of-bounds coordinates', () => { + const savedMap = { + metadata: { name: 'Legacy OOB Exit' }, + data: { + gridSize: 5, + tiles: buildValidTiles(5), + spawnPoint: { x: 0, z: 0 }, + exitPoint: { x: 4, z: 99 }, + version: '1.0.0' + } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('Exit point'); + expect(result.error).toContain('out of bounds'); + }); + + it('should accept map with no spawn/exit points (empty arrays)', () => { + const savedMap = { + metadata: { name: 'No Points' }, + data: { gridSize: 5, tiles: buildValidTiles(5), spawnPoints: [], exitPoints: [], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(true); + }); + + it('should accept all four terrain types', () => { + const tiles = buildValidTiles(5); + tiles[0][0] = TerrainType.BEDROCK; + tiles[1][0] = TerrainType.CRYSTAL; + tiles[2][0] = TerrainType.MOSS; + tiles[3][0] = TerrainType.ABYSS; + const savedMap = { + metadata: { name: 'All Types' }, + data: { gridSize: 5, tiles, spawnPoints: [{ x: 0, z: 0 }], exitPoints: [{ x: 4, z: 4 }], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(true); + }); + + it('should reject non-array tiles column', () => { + const tiles = buildValidTiles(5); + (tiles as unknown[])[3] = 'not-an-array'; + const savedMap = { + metadata: { name: 'BadCol' }, + data: { gridSize: 5, tiles, spawnPoints: [], exitPoints: [], version: '2.0.0' } + }; + const result = service.validateMapJson(JSON.stringify(savedMap)); + expect(result.valid).toBe(false); + expect(result.error).toContain('column 3'); + expect(result.error).toContain('not an array'); + }); + }); + + describe('validateMapPlayability', () => { + it('should return not playable for null state', () => { + const result = service.validateMapPlayability(null as unknown as TerrainGridState); + expect(result.playable).toBe(false); + expect(result.error).toContain('missing'); + }); + + it('should return not playable for missing spawn points', () => { + const state = testMapData({ spawnPoints: [], exitPoints: [{ x: 9, z: 9 }] }); + const result = service.validateMapPlayability(state); + expect(result.playable).toBe(false); + expect(result.error).toBe('Map has no spawn points'); + }); + + it('should return not playable for missing exit points', () => { + const state = testMapData({ spawnPoints: [{ x: 0, z: 0 }], exitPoints: [] }); + const result = service.validateMapPlayability(state); + expect(result.playable).toBe(false); + expect(result.error).toBe('Map has no exit points'); + }); + + it('should return not playable when spawn and exit overlap', () => { + const state = testMapData({ spawnPoints: [{ x: 3, z: 3 }], exitPoints: [{ x: 3, z: 3 }] }); + const result = service.validateMapPlayability(state); + expect(result.playable).toBe(false); + expect(result.error).toContain('overlap'); + }); + + it('should return not playable for invalid gridSize', () => { + const state = testMapData({ gridSize: 2 }); + const result = service.validateMapPlayability(state); + expect(result.playable).toBe(false); + expect(result.error).toContain('Invalid grid size'); + }); + + it('should return not playable for tiles array mismatch', () => { + const state = testMapData(); + state.tiles = buildValidTiles(5); // tiles of 5 but gridSize of 10 + const result = service.validateMapPlayability(state); + expect(result.playable).toBe(false); + expect(result.error).toContain('incorrectly dimensioned'); + }); + + it('should return playable for valid map', () => { + const state = testMapData(); + const result = service.validateMapPlayability(state); + expect(result.playable).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should accept legacy single spawnPoint/exitPoint format', () => { + const state = { + gridSize: 10, + tiles: buildValidTiles(10), + heightMap: [], + spawnPoints: [] as { x: number; z: number }[], + exitPoints: [] as { x: number; z: number }[], + spawnPoint: { x: 0, z: 0 }, + exitPoint: { x: 9, z: 9 }, + version: '1.0.0' + } as unknown as TerrainGridState; + const result = service.validateMapPlayability(state); + expect(result.playable).toBe(true); + }); + + it('should detect overlap between any spawn/exit pair', () => { + const state = testMapData({ + spawnPoints: [{ x: 0, z: 0 }, { x: 5, z: 5 }], + exitPoints: [{ x: 9, z: 9 }, { x: 5, z: 5 }] + }); + const result = service.validateMapPlayability(state); + expect(result.playable).toBe(false); + expect(result.error).toContain('overlap at (5, 5)'); + }); + + it('should return playable for multiple distinct spawn and exit points', () => { + const state = testMapData({ + spawnPoints: [{ x: 0, z: 0 }, { x: 0, z: 1 }], + exitPoints: [{ x: 9, z: 8 }, { x: 9, z: 9 }] + }); + const result = service.validateMapPlayability(state); + expect(result.playable).toBe(true); + }); + }); + + describe('quota error handling', () => { + it('should return null when localStorage.setItem throws QuotaExceededError', () => { + const quotaError = new DOMException('quota exceeded', 'QuotaExceededError'); + (localStorage.setItem as jasmine.Spy).and.callFake(() => { + throw quotaError; + }); + spyOn(console, 'warn'); + + const mapId = service.saveMap('Test Map', testMapData()); + + expect(mapId).toBeNull(); + expect(console.warn).toHaveBeenCalledWith( + jasmine.stringContaining('quota exceeded') + ); + }); + + it('should return null for non-quota setItem failures', () => { + const genericError = new Error('SecurityError'); + (localStorage.setItem as jasmine.Spy).and.callFake(() => { + throw genericError; + }); + spyOn(console, 'error'); + + const mapId = service.saveMap('Test Map', testMapData()); + + expect(mapId).toBeNull(); + expect(console.error).toHaveBeenCalled(); + }); + + it('importMapFromJson should return null when save fails due to quota', () => { + const quotaError = new DOMException('quota exceeded', 'QuotaExceededError'); + (localStorage.setItem as jasmine.Spy).and.callFake(() => { + throw quotaError; + }); + spyOn(console, 'warn'); + + const savedMap: SavedMap = { + metadata: { + id: 'old_id', + name: 'Imported', + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + gridSize: 10 + }, + data: testMapData() + }; + + const mapId = service.importMapFromJson(JSON.stringify(savedMap)); + + expect(mapId).toBeNull(); + }); + + it('migrateOldFormat should return false when save fails', () => { + // Plant old-format data + localStorageMock['novarise_terrain'] = JSON.stringify(testMapData()); + + const quotaError = new DOMException('quota exceeded', 'QuotaExceededError'); + (localStorage.setItem as jasmine.Spy).and.callFake(() => { + throw quotaError; + }); + spyOn(console, 'warn'); + + const result = service.migrateOldFormat(); + + expect(result).toBe(false); + // Old data should NOT be removed since save failed + expect(localStorageMock['novarise_terrain']).toBeDefined(); + }); + }); }); diff --git a/src/app/games/novarise/core/map-storage.service.ts b/src/app/games/novarise/core/map-storage.service.ts index 5750a531..ec0a2a21 100644 --- a/src/app/games/novarise/core/map-storage.service.ts +++ b/src/app/games/novarise/core/map-storage.service.ts @@ -1,5 +1,16 @@ import { Injectable } from '@angular/core'; -import { TerrainGridState } from '../features/terrain-editor/terrain-grid-state.interface'; +import { TerrainGridState, TerrainGridStateLegacy } from '../features/terrain-editor/terrain-grid-state.interface'; +import { TerrainType } from '../models/terrain-types.enum'; + +const MIN_GRID_SIZE = 5; +const MAX_GRID_SIZE = 30; +const MAX_SPAWN_POINTS = 4; +const MAX_EXIT_POINTS = 4; + +const VALID_TERRAIN_VALUES = new Set(Object.values(TerrainType)); + +/** Maximum file size for map imports (10 MB). */ +const MAX_IMPORT_FILE_SIZE = 10 * 1024 * 1024; export interface MapMetadata { id: string; @@ -31,9 +42,9 @@ export class MapStorageService { * @param name Map name * @param data Grid state data * @param id Optional ID (generates new if not provided) - * @returns The saved map ID + * @returns The saved map ID, or null if the save failed (e.g. quota exceeded) */ - public saveMap(name: string, data: TerrainGridState, id?: string): string { + public saveMap(name: string, data: TerrainGridState, id?: string): string | null { const mapId = id || this.generateMapId(); const now = Date.now(); @@ -55,7 +66,12 @@ export class MapStorageService { try { localStorage.setItem(this.STORAGE_PREFIX + mapId, JSON.stringify(savedMap)); } catch (e) { - console.error('Failed to save map — localStorage may be full or unavailable:', e); + if (this.isQuotaError(e)) { + console.warn('localStorage quota exceeded — cannot save map. Free space by deleting unused maps.'); + } else { + console.error('Failed to save map — localStorage may be unavailable:', e); + } + return null; } // Update metadata index @@ -83,6 +99,10 @@ export class MapStorageService { try { const savedMap: SavedMap = JSON.parse(json); + if (!savedMap.data) { + console.warn(`Map "${id}" has no data field — treating as missing`); + return null; + } this.setCurrentMapId(id); return savedMap.data; } catch (e) { @@ -100,7 +120,12 @@ export class MapStorageService { if (!json) return []; try { - return JSON.parse(json); + const parsed: unknown = JSON.parse(json); + if (!Array.isArray(parsed)) { + console.warn('Maps metadata is not an array — resetting to empty'); + return []; + } + return parsed as MapMetadata[]; } catch (e) { console.error('Failed to parse maps metadata:', e); return []; @@ -139,7 +164,11 @@ export class MapStorageService { try { localStorage.setItem(this.METADATA_KEY, JSON.stringify(filtered)); } catch (e) { - console.error('Failed to update metadata index:', e); + if (this.isQuotaError(e)) { + console.warn('localStorage quota exceeded while updating metadata index after deletion.'); + } else { + console.error('Failed to update metadata index:', e); + } } // Clear current map if it was this one @@ -189,8 +218,11 @@ export class MapStorageService { try { const data = JSON.parse(oldData); // Save as "Imported Map" in new format - this.saveMap('Imported Map', data); - // Remove old key + const mapId = this.saveMap('Imported Map', data); + if (!mapId) { + return false; + } + // Remove old key only after successful save localStorage.removeItem(oldKey); return true; } catch (e) { @@ -258,7 +290,13 @@ export class MapStorageService { } const mapName = name || savedMap.metadata?.name || 'Imported Map'; - return this.saveMap(mapName, savedMap.data); + const mapId = this.saveMap(mapName, savedMap.data); + + if (!mapId) { + return null; + } + + return mapId; } catch (e) { console.error('Failed to import map:', e); return null; @@ -266,7 +304,9 @@ export class MapStorageService { } /** - * Validate JSON string is a valid Novarise map + * Validate JSON string is a valid Novarise map. + * Performs structural validation: grid bounds, tile dimensions, terrain types, + * spawn/exit point coordinates and counts. * @param json JSON string to validate * @returns Validation result with map name if valid */ @@ -283,10 +323,81 @@ export class MapStorageService { return { valid: false, error: 'Invalid or missing grid size' }; } + const gridSize = savedMap.data.gridSize; + + if (gridSize < MIN_GRID_SIZE || gridSize > MAX_GRID_SIZE) { + return { valid: false, error: `Grid size must be between ${MIN_GRID_SIZE} and ${MAX_GRID_SIZE}, got ${gridSize}` }; + } + if (!savedMap.data.tiles || !Array.isArray(savedMap.data.tiles)) { return { valid: false, error: 'Invalid or missing tiles data' }; } + // Validate tile array dimensions + if (savedMap.data.tiles.length !== gridSize) { + return { valid: false, error: `Tiles array length (${savedMap.data.tiles.length}) does not match gridSize (${gridSize})` }; + } + + for (let x = 0; x < gridSize; x++) { + const column = savedMap.data.tiles[x]; + if (!Array.isArray(column)) { + return { valid: false, error: `Tiles column ${x} is not an array` }; + } + if (column.length !== gridSize) { + return { valid: false, error: `Tiles column ${x} has length ${column.length}, expected ${gridSize}` }; + } + for (let z = 0; z < gridSize; z++) { + if (!VALID_TERRAIN_VALUES.has(column[z])) { + return { valid: false, error: `Invalid terrain type "${column[z]}" at tile [${x}][${z}]` }; + } + } + } + + // Validate spawn/exit points (v2 format: arrays) + const legacy = savedMap.data as unknown as TerrainGridStateLegacy; + const hasV2Spawn = Array.isArray(savedMap.data.spawnPoints); + const hasV2Exit = Array.isArray(savedMap.data.exitPoints); + const hasV1Spawn = !hasV2Spawn && legacy.spawnPoint != null; + const hasV1Exit = !hasV2Exit && legacy.exitPoint != null; + + // Validate spawn points + if (hasV2Spawn) { + if (savedMap.data.spawnPoints.length > MAX_SPAWN_POINTS) { + return { valid: false, error: `Too many spawn points (${savedMap.data.spawnPoints.length}), maximum is ${MAX_SPAWN_POINTS}` }; + } + for (const sp of savedMap.data.spawnPoints) { + if (typeof sp.x !== 'number' || typeof sp.z !== 'number' || + sp.x < 0 || sp.x >= gridSize || sp.z < 0 || sp.z >= gridSize) { + return { valid: false, error: `Spawn point (${sp.x}, ${sp.z}) is out of bounds [0, ${gridSize})` }; + } + } + } else if (hasV1Spawn) { + const sp = legacy.spawnPoint!; + if (typeof sp.x !== 'number' || typeof sp.z !== 'number' || + sp.x < 0 || sp.x >= gridSize || sp.z < 0 || sp.z >= gridSize) { + return { valid: false, error: `Spawn point (${sp.x}, ${sp.z}) is out of bounds [0, ${gridSize})` }; + } + } + + // Validate exit points + if (hasV2Exit) { + if (savedMap.data.exitPoints.length > MAX_EXIT_POINTS) { + return { valid: false, error: `Too many exit points (${savedMap.data.exitPoints.length}), maximum is ${MAX_EXIT_POINTS}` }; + } + for (const ep of savedMap.data.exitPoints) { + if (typeof ep.x !== 'number' || typeof ep.z !== 'number' || + ep.x < 0 || ep.x >= gridSize || ep.z < 0 || ep.z >= gridSize) { + return { valid: false, error: `Exit point (${ep.x}, ${ep.z}) is out of bounds [0, ${gridSize})` }; + } + } + } else if (hasV1Exit) { + const ep = legacy.exitPoint!; + if (typeof ep.x !== 'number' || typeof ep.z !== 'number' || + ep.x < 0 || ep.x >= gridSize || ep.z < 0 || ep.z >= gridSize) { + return { valid: false, error: `Exit point (${ep.x}, ${ep.z}) is out of bounds [0, ${gridSize})` }; + } + } + return { valid: true, name: savedMap.metadata?.name || 'Unnamed Map' @@ -296,6 +407,54 @@ export class MapStorageService { } } + /** + * Validate that a map is ready for gameplay (not just structurally valid). + * Checks for spawn/exit presence, coordinate validity, and tile dimensions. + * @param state TerrainGridState to validate + * @returns Playability result with error message if not playable + */ + public validateMapPlayability(state: TerrainGridState): { playable: boolean; error?: string } { + if (!state) { + return { playable: false, error: 'Map data is missing' }; + } + + if (typeof state.gridSize !== 'number' || state.gridSize < MIN_GRID_SIZE || state.gridSize > MAX_GRID_SIZE) { + return { playable: false, error: `Invalid grid size: ${state.gridSize}` }; + } + + if (!Array.isArray(state.tiles) || state.tiles.length !== state.gridSize) { + return { playable: false, error: 'Tiles array is missing or incorrectly dimensioned' }; + } + + // Resolve spawn/exit with legacy fallback + const legacy = state as unknown as TerrainGridStateLegacy; + const spawnPoints = (Array.isArray(state.spawnPoints) && state.spawnPoints.length > 0) + ? state.spawnPoints + : (legacy.spawnPoint ? [legacy.spawnPoint] : []); + const exitPoints = (Array.isArray(state.exitPoints) && state.exitPoints.length > 0) + ? state.exitPoints + : (legacy.exitPoint ? [legacy.exitPoint] : []); + + if (spawnPoints.length === 0) { + return { playable: false, error: 'Map has no spawn points' }; + } + + if (exitPoints.length === 0) { + return { playable: false, error: 'Map has no exit points' }; + } + + // Check spawn != exit (all combinations) + for (const sp of spawnPoints) { + for (const ep of exitPoints) { + if (sp.x === ep.x && sp.z === ep.z) { + return { playable: false, error: `Spawn and exit overlap at (${sp.x}, ${sp.z})` }; + } + } + } + + return { playable: true }; + } + /** * Create a file input and handle file selection for import * @returns Promise that resolves with imported map ID or null @@ -313,6 +472,12 @@ export class MapStorageService { return; } + if (file.size > MAX_IMPORT_FILE_SIZE) { + console.error(`File too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum is ${MAX_IMPORT_FILE_SIZE / 1024 / 1024} MB.`); + resolve(null); + return; + } + try { const json = await file.text(); const validation = this.validateMapJson(json); @@ -371,7 +536,11 @@ export class MapStorageService { try { localStorage.setItem(this.CURRENT_MAP_KEY, id); } catch (e) { - console.error('Failed to set current map ID:', e); + if (this.isQuotaError(e)) { + console.warn('localStorage quota exceeded while setting current map ID.'); + } else { + console.error('Failed to set current map ID:', e); + } } } @@ -393,7 +562,22 @@ export class MapStorageService { try { localStorage.setItem(this.METADATA_KEY, JSON.stringify(maps)); } catch (e) { - console.error('Failed to update metadata index:', e); + if (this.isQuotaError(e)) { + console.warn('localStorage quota exceeded while updating metadata index.'); + } else { + console.error('Failed to update metadata index:', e); + } + } + } + + /** + * Detect whether an error is a localStorage QuotaExceededError. + * Checks both the standard name property and the legacy code property. + */ + private isQuotaError(e: unknown): boolean { + if (e instanceof DOMException) { + return e.name === 'QuotaExceededError' || e.code === 22; } + return false; } } diff --git a/src/app/games/novarise/editor.module.ts b/src/app/games/novarise/editor.module.ts index 192d47da..7c4f2115 100644 --- a/src/app/games/novarise/editor.module.ts +++ b/src/app/games/novarise/editor.module.ts @@ -5,6 +5,9 @@ import { NovariseComponent } from './novarise.component'; import { EditControlsComponent } from './features/ui-controls/edit-controls.component'; import { MobileControlsModule } from './features/mobile-controls'; import { PathValidationService } from './core/path-validation.service'; +import { EditHistoryService } from './core/edit-history.service'; +import { CameraControlService } from './core/camera-control.service'; +import { EditorStateService } from './core/editor-state.service'; @NgModule({ declarations: [ @@ -17,7 +20,10 @@ import { PathValidationService } from './core/path-validation.service'; RouterModule.forChild([{ path: '', component: NovariseComponent }]) ], providers: [ - PathValidationService + PathValidationService, + EditHistoryService, + CameraControlService, + EditorStateService ] }) export class EditorModule {} diff --git a/src/app/games/novarise/novarise-navigation.spec.ts b/src/app/games/novarise/novarise-navigation.spec.ts index 7b539cf3..2a971040 100644 --- a/src/app/games/novarise/novarise-navigation.spec.ts +++ b/src/app/games/novarise/novarise-navigation.spec.ts @@ -5,6 +5,9 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NovariseComponent } from './novarise.component'; import { MapStorageService } from './core/map-storage.service'; import { PathValidationService } from './core/path-validation.service'; +import { EditHistoryService } from './core/edit-history.service'; +import { CameraControlService } from './core/camera-control.service'; +import { EditorStateService } from './core/editor-state.service'; import { TerrainGridState } from './features/terrain-editor/terrain-grid-state.interface'; import { TerrainType } from './models/terrain-types.enum'; @@ -95,7 +98,10 @@ describe('NovariseComponent Navigation', () => { imports: [RouterTestingModule], providers: [ { provide: MapStorageService, useValue: mockMapStorageService }, - PathValidationService + PathValidationService, + EditHistoryService, + CameraControlService, + EditorStateService ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/games/novarise/novarise.component.html b/src/app/games/novarise/novarise.component.html index 7504d771..24151f26 100644 --- a/src/app/games/novarise/novarise.component.html +++ b/src/app/games/novarise/novarise.component.html @@ -5,6 +5,24 @@
+ +
+
+

Unable to Start Editor

+

{{ initError }}

+

Try using a modern browser with WebGL support, or check your graphics drivers.

+ +
+
+ + +
+
+
+

Initializing...

+
+
+
diff --git a/src/app/games/novarise/novarise.component.scss b/src/app/games/novarise/novarise.component.scss index 50aeb7d1..d8c1cd79 100644 --- a/src/app/games/novarise/novarise.component.scss +++ b/src/app/games/novarise/novarise.component.scss @@ -93,6 +93,102 @@ } } +.loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.95); + z-index: var(--z-index-modal); +} + +.loading-content { + text-align: center; +} + +.loading-spinner { + width: 2.5rem; + height: 2.5rem; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--theme-purple-light); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto var(--spacing-md); +} + +.loading-text { + font-family: 'Orbitron', sans-serif; + font-size: var(--font-size-small); + color: var(--theme-purple-mid); + letter-spacing: 0.1rem; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .loading-spinner { + animation: none; + border-top-color: var(--theme-purple-light); + opacity: 0.6; + } +} + +.init-error-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.95); + z-index: var(--z-index-modal); + color: var(--text-color); + font-family: 'Orbitron', sans-serif; +} + +.init-error-content { + text-align: center; + max-width: 28rem; + padding: var(--spacing-xl); + + h2 { + color: var(--game-defeat); + font-size: var(--font-size-title); + margin-bottom: var(--spacing-md); + } + + p { + color: var(--text-muted); + margin-bottom: var(--spacing-sm); + font-family: sans-serif; + line-height: 1.4; + } + + .init-error-hint { + font-size: var(--font-size-small); + color: var(--text-dim); + } +} + +.init-error-btn { + margin-top: var(--spacing-lg); + padding: var(--spacing-sm) var(--spacing-xl); + background: var(--theme-purple); + color: var(--text-color); + border: none; + border-radius: var(--border-radius-lg); + font-family: 'Orbitron', sans-serif; + font-size: var(--font-size-small); + cursor: pointer; + transition: opacity var(--transition-fast); + + &:hover { + opacity: 0.85; + } +} + // Mobile responsive adjustments @media (max-width: 768px) { .game-container { diff --git a/src/app/games/novarise/novarise.component.spec.ts b/src/app/games/novarise/novarise.component.spec.ts index b0ae661d..b49f679c 100644 --- a/src/app/games/novarise/novarise.component.spec.ts +++ b/src/app/games/novarise/novarise.component.spec.ts @@ -4,6 +4,8 @@ import { RouterTestingModule } from '@angular/router/testing'; import { NovariseComponent } from './novarise.component'; import { MapStorageService } from './core/map-storage.service'; import { CameraControlService, JoystickInput, MovementInput, RotationInput } from './core/camera-control.service'; +import { EditHistoryService } from './core/edit-history.service'; +import { EditorStateService } from './core/editor-state.service'; import { PathValidationService } from './core/path-validation.service'; import { MapTemplateService } from './core/map-template.service'; import { JoystickEvent } from './features/mobile-controls'; @@ -51,7 +53,10 @@ describe('NovariseComponent', () => { imports: [RouterTestingModule], providers: [ { provide: MapStorageService, useValue: mockMapStorageService }, - PathValidationService + PathValidationService, + EditHistoryService, + CameraControlService, + EditorStateService ], schemas: [NO_ERRORS_SCHEMA] // Ignore unknown elements like app-virtual-joystick }).compileComponents(); @@ -66,6 +71,18 @@ describe('NovariseComponent', () => { t.scene = { remove: () => {} }; } + describe('Loading State', () => { + beforeEach(() => { + fixture = TestBed.createComponent(NovariseComponent); + component = fixture.componentInstance; + mockThreeJsFields(component); + }); + + it('isLoading should default to true before initialization', () => { + expect(component.isLoading).toBeTrue(); + }); + }); + describe('Joystick State Initialization', () => { beforeEach(() => { fixture = TestBed.createComponent(NovariseComponent); @@ -346,6 +363,47 @@ describe('NovariseComponent', () => { }); }); + describe('Save Map', () => { + beforeEach(() => { + fixture = TestBed.createComponent(NovariseComponent); + component = fixture.componentInstance; + mockThreeJsFields(component); + }); + + it('should alert failure when saveMap returns null', () => { + // Mock terrainGrid.exportState() + (component as any).terrainGrid = { + exportState: () => ({ gridSize: 10, tiles: [], heightMap: [], spawnPoints: [{ x: 0, z: 0 }], exitPoints: [{ x: 9, z: 9 }], version: '2.0.0' }), + dispose: () => {} + }; + (component as any).currentMapName = 'Test Map'; + mockMapStorageService.saveMap.and.returnValue(null); + mockMapStorageService.getCurrentMapId.and.returnValue('old_id'); + spyOn(window, 'prompt').and.returnValue('Test Map'); + spyOn(window, 'alert'); + + (component as any).saveGridState(); + + expect(window.alert).toHaveBeenCalledWith(jasmine.stringContaining('Failed to save')); + }); + + it('should alert success when saveMap returns an ID', () => { + (component as any).terrainGrid = { + exportState: () => ({ gridSize: 10, tiles: [], heightMap: [], spawnPoints: [{ x: 0, z: 0 }], exitPoints: [{ x: 9, z: 9 }], version: '2.0.0' }), + dispose: () => {} + }; + (component as any).currentMapName = 'Test Map'; + mockMapStorageService.saveMap.and.returnValue('map_123'); + mockMapStorageService.getCurrentMapId.and.returnValue(null); + spyOn(window, 'prompt').and.returnValue('My Map'); + spyOn(window, 'alert'); + + (component as any).saveGridState(); + + expect(window.alert).toHaveBeenCalledWith(jasmine.stringContaining('saved successfully')); + }); + }); + describe('Map Templates', () => { beforeEach(() => { fixture = TestBed.createComponent(NovariseComponent); diff --git a/src/app/games/novarise/novarise.component.ts b/src/app/games/novarise/novarise.component.ts index dac04db4..c1477f84 100644 --- a/src/app/games/novarise/novarise.component.ts +++ b/src/app/games/novarise/novarise.component.ts @@ -14,6 +14,7 @@ import { CameraControlService, MovementInput, RotationInput, JoystickInput } fro import { EditorStateService, EditMode, BrushTool } from './core/editor-state.service'; import { MapBridgeService } from '../../game/game-board/services/map-bridge.service'; import { disposeMaterial } from '../../game/game-board/utils/three-utils'; +import { MOBILE_CONFIG } from '../../game/game-board/constants/mobile.constants'; import { JoystickEvent } from './features/mobile-controls'; import { EDITOR_SCENE_CONFIG, @@ -132,6 +133,10 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { // Title display public title = 'Novarise'; + // Init error state + public initError: string | null = null; + public isLoading = true; + // Map templates public templates: MapTemplate[] = []; @@ -163,43 +168,52 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { } ngAfterViewInit(): void { - this.initializeScene(); - this.initializeCamera(); - this.initializeLights(); - this.addSkybox(); - this.initializeParticles(); - this.initializeRenderer(); - this.initializePostProcessing(); - this.initializeControls(); + try { + this.initializeScene(); + this.initializeCamera(); + this.initializeLights(); + this.addSkybox(); + this.initializeParticles(); + this.initializeRenderer(); + this.initializePostProcessing(); + this.initializeControls(); - // Initialize terrain grid - this.terrainGrid = new TerrainGrid(this.scene, 25); + // Initialize terrain grid + this.terrainGrid = new TerrainGrid(this.scene, 25); - // Add helpers for spatial reference - this.addHelpers(); + // Add helpers for spatial reference + this.addHelpers(); - // Create brush indicator for crisp visual feedback - this.createBrushIndicator(); + // Create brush indicator for crisp visual feedback + this.createBrushIndicator(); - // Initialize brush preview system - this.updateBrushPreview(); + // Initialize brush preview system + this.updateBrushPreview(); - // Create spawn/exit markers for tower defense - this.createSpawnExitMarkers(); + // Create spawn/exit markers for tower defense + this.createSpawnExitMarkers(); - // Initialize camera rotation to match initial camera view - this.initializeCameraRotation(); + // Initialize camera rotation to match initial camera view + this.initializeCameraRotation(); - // Load map templates for the editor UI - this.templates = this.mapTemplateService.getTemplates(); + // Load map templates for the editor UI + this.templates = this.mapTemplateService.getTemplates(); - // Try to migrate old format and load current map - this.mapStorage.migrateOldFormat(); - this.tryLoadCurrentMap(); + // Try to migrate old format and load current map + this.mapStorage.migrateOldFormat(); + this.tryLoadCurrentMap(); - this.setupInteraction(); - this.setupKeyboardControls(); - this.animate(); + this.setupInteraction(); + this.setupKeyboardControls(); + this.animate(); + this.isLoading = false; + } catch (error) { + this.initError = error instanceof Error + ? error.message + : 'Failed to initialize editor renderer'; + this.isLoading = false; + console.error('Editor initialization failed:', error); + } } private initializeScene(): void { @@ -228,8 +242,17 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { } private initializeRenderer(): void { + const testCanvas = document.createElement('canvas'); + const gl = testCanvas.getContext('webgl') || testCanvas.getContext('experimental-webgl'); + if (!gl) { + throw new Error('WebGL is not supported by your browser'); + } + // Release the test WebGL context to free the browser context slot + (gl as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext(); + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); - this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, EDITOR_RENDERER_CONFIG.maxPixelRatio)); // Cap at 2 for performance + const mobileMaxRatio = window.innerWidth <= MOBILE_CONFIG.breakpoint ? MOBILE_CONFIG.maxPixelRatio : EDITOR_RENDERER_CONFIG.maxPixelRatio; + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, mobileMaxRatio)); // Initial size using proper viewport calculation const { width, height } = this.getViewportSize(); @@ -284,15 +307,17 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { const renderPass = new RenderPass(this.scene, this.camera); this.composer.addPass(renderPass); - // Reduced bloom for better visibility + // Skip bloom on phones — too expensive for low-end GPUs const { width, height } = this.getViewportSize(); - this.bloomPass = new UnrealBloomPass( - new THREE.Vector2(width, height), - EDITOR_POST_PROCESSING.bloom.strength, // Reduced strength - EDITOR_POST_PROCESSING.bloom.radius, // Reduced radius - EDITOR_POST_PROCESSING.bloom.threshold // Higher threshold - only brightest elements - ); - this.composer.addPass(this.bloomPass); + if (window.innerWidth > MOBILE_CONFIG.phoneBreakpoint) { + this.bloomPass = new UnrealBloomPass( + new THREE.Vector2(width, height), + EDITOR_POST_PROCESSING.bloom.strength, + EDITOR_POST_PROCESSING.bloom.radius, + EDITOR_POST_PROCESSING.bloom.threshold + ); + this.composer.addPass(this.bloomPass); + } // Lighter vignette for better edge visibility const vignetteShader = { @@ -348,6 +373,10 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { directionalLight1.shadow.camera.bottom = -dl1Cfg.shadowCameraExtent!; directionalLight1.shadow.mapSize.width = dl1Cfg.shadowMapSize!; directionalLight1.shadow.mapSize.height = dl1Cfg.shadowMapSize!; + if (window.innerWidth <= MOBILE_CONFIG.breakpoint) { + directionalLight1.shadow.mapSize.width = Math.min(directionalLight1.shadow.mapSize.width, MOBILE_CONFIG.maxShadowMapSize); + directionalLight1.shadow.mapSize.height = Math.min(directionalLight1.shadow.mapSize.height, MOBILE_CONFIG.maxShadowMapSize); + } this.scene.add(directionalLight1); // Second directional light from opposite angle @@ -437,7 +466,9 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { } private initializeParticles(): void { - const particleCount = EDITOR_PARTICLES.count; + const particleCount = window.innerWidth <= MOBILE_CONFIG.breakpoint + ? Math.floor(EDITOR_PARTICLES.count / MOBILE_CONFIG.particleDivisor) + : EDITOR_PARTICLES.count; const positions = new Float32Array(particleCount * 3); const colors = new Float32Array(particleCount * 3); @@ -503,6 +534,7 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + this.camera.updateMatrixWorld(); this.raycaster.setFromCamera(this.mouse, this.camera); const tileMeshes = this.terrainGrid.getTileMeshes(); const intersects = this.raycaster.intersectObjects(tileMeshes); @@ -581,6 +613,7 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { this.mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1; + this.camera.updateMatrixWorld(); this.raycaster.setFromCamera(this.mouse, this.camera); const tileMeshes = this.terrainGrid.getTileMeshes(); const intersects = this.raycaster.intersectObjects(tileMeshes); @@ -598,6 +631,7 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { this.mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1; + this.camera.updateMatrixWorld(); this.raycaster.setFromCamera(this.mouse, this.camera); const tileMeshes = this.terrainGrid.getTileMeshes(); const intersects = this.raycaster.intersectObjects(tileMeshes); @@ -1141,6 +1175,10 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { // Save (will update if ID exists, create new if not) const savedId = this.mapStorage.saveMap(mapName, state, currentId || undefined); + if (!savedId) { + alert('Failed to save map — storage may be full. Try deleting unused maps.'); + return; + } this.currentMapName = mapName; alert(`Map "${mapName}" saved successfully!`); @@ -1718,6 +1756,7 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { } private animate = (): void => { + if (!this.renderer || this.initError) return; this.animationFrameId = requestAnimationFrame(this.animate); // Update camera movement @@ -1782,77 +1821,86 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { if (window.visualViewport) { window.visualViewport.removeEventListener('resize', this.resizeHandler); } - const canvas = this.renderer.domElement; - canvas.removeEventListener('mousemove', this.mousemoveHandler); - canvas.removeEventListener('mousedown', this.mouseDownHandler); - canvas.removeEventListener('mouseup', this.mouseUpHandler); - canvas.removeEventListener('mouseleave', this.mouseUpHandler); - canvas.removeEventListener('touchstart', this.touchStartHandler); - canvas.removeEventListener('touchmove', this.touchMoveHandler); - canvas.removeEventListener('touchend', this.touchEndHandler); - canvas.removeEventListener('touchcancel', this.touchEndHandler); + + if (this.renderer) { + const canvas = this.renderer.domElement; + canvas.removeEventListener('mousemove', this.mousemoveHandler); + canvas.removeEventListener('mousedown', this.mouseDownHandler); + canvas.removeEventListener('mouseup', this.mouseUpHandler); + canvas.removeEventListener('mouseleave', this.mouseUpHandler); + canvas.removeEventListener('touchstart', this.touchStartHandler); + canvas.removeEventListener('touchmove', this.touchMoveHandler); + canvas.removeEventListener('touchend', this.touchEndHandler); + canvas.removeEventListener('touchcancel', this.touchEndHandler); + } // Snapshot terrain state for the game to consume on /play navigation // and auto-save to localStorage to prevent data loss if (this.terrainGrid) { const state = this.terrainGrid.exportState(); this.mapBridge.setEditorMapState(state); - this.mapStorage.saveMap( - this.currentMapName, - state, - this.mapStorage.getCurrentMapId() || undefined - ); + try { + this.mapStorage.saveMap( + this.currentMapName, + state, + this.mapStorage.getCurrentMapId() || undefined + ); + } catch (error) { + console.warn('Auto-save failed on destroy:', error); + } } // Clear undo/redo history to prevent stale closures referencing disposed TerrainGrid this.editHistory.clear(); // Clean up brush preview meshes - this.brushPreviewMeshes.forEach(mesh => { - this.scene.remove(mesh); - mesh.geometry.dispose(); - disposeMaterial(mesh.material); - }); - this.brushPreviewMeshes = []; - - // Clean up rectangle preview meshes - this.clearRectanglePreview(); + if (this.scene) { + this.brushPreviewMeshes.forEach(mesh => { + this.scene.remove(mesh); + mesh.geometry.dispose(); + disposeMaterial(mesh.material); + }); + this.brushPreviewMeshes = []; - // Clean up brush indicator - if (this.brushIndicator) { - this.scene.remove(this.brushIndicator); - this.brushIndicator.geometry.dispose(); - disposeMaterial(this.brushIndicator.material); - } + // Clean up rectangle preview meshes + this.clearRectanglePreview(); - // Clean up spawn/exit markers - for (const marker of this.spawnMarkers) { - this.scene.remove(marker); - marker.geometry.dispose(); - disposeMaterial(marker.material); - } - this.spawnMarkers = []; - for (const marker of this.exitMarkers) { - this.scene.remove(marker); - marker.geometry.dispose(); - disposeMaterial(marker.material); - } - this.exitMarkers = []; + // Clean up brush indicator + if (this.brushIndicator) { + this.scene.remove(this.brushIndicator); + this.brushIndicator.geometry.dispose(); + disposeMaterial(this.brushIndicator.material); + } - // Clean up particles - if (this.particles) { - this.scene.remove(this.particles); - this.particles.geometry.dispose(); - disposeMaterial(this.particles.material); - this.particles = null; - } + // Clean up spawn/exit markers + for (const marker of this.spawnMarkers) { + this.scene.remove(marker); + marker.geometry.dispose(); + disposeMaterial(marker.material); + } + this.spawnMarkers = []; + for (const marker of this.exitMarkers) { + this.scene.remove(marker); + marker.geometry.dispose(); + disposeMaterial(marker.material); + } + this.exitMarkers = []; + + // Clean up particles + if (this.particles) { + this.scene.remove(this.particles); + this.particles.geometry.dispose(); + disposeMaterial(this.particles.material); + this.particles = null; + } - // Clean up skybox - if (this.skybox) { - this.scene.remove(this.skybox); - this.skybox.geometry.dispose(); - disposeMaterial(this.skybox.material); - this.skybox = undefined; + // Clean up skybox + if (this.skybox) { + this.scene.remove(this.skybox); + this.skybox.geometry.dispose(); + disposeMaterial(this.skybox.material); + this.skybox = undefined; + } } if (this.terrainGrid) { @@ -1876,6 +1924,8 @@ export class NovariseComponent implements AfterViewInit, OnDestroy { this.composer.renderTarget2.dispose(); } - this.renderer.dispose(); + if (this.renderer) { + this.renderer.dispose(); + } } } diff --git a/src/assets/icons/icon.svg b/src/assets/icons/icon.svg new file mode 100644 index 00000000..7d7362dd --- /dev/null +++ b/src/assets/icons/icon.svg @@ -0,0 +1,5 @@ + + + + NOVARISE + diff --git a/src/index.html b/src/index.html index e2d6a641..913ff050 100644 --- a/src/index.html +++ b/src/index.html @@ -8,6 +8,9 @@ + + + diff --git a/src/manifest.webmanifest b/src/manifest.webmanifest new file mode 100644 index 00000000..66bcfee0 --- /dev/null +++ b/src/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Novarise Tower Defense", + "short_name": "Novarise", + "description": "A sci-fi tower defense game built with Three.js", + "start_url": "/", + "display": "fullscreen", + "orientation": "any", + "background_color": "#0a0514", + "theme_color": "#6a4a8a", + "icons": [ + { + "src": "assets/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "categories": ["games", "entertainment"] +} diff --git a/src/styles.css b/src/styles.css index 898a5235..44fc42ba 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3,12 +3,9 @@ /* Foundation - Maximum Contrast (Dark Mode) */ --bg-color: #000000; --text-color: #FFFFFF; - --text-area-color: #0a0a0a; - /* Borders - Glowing Structure */ --border-color: rgba(255, 255, 255, 0.2); --border-color-strong: rgba(255, 255, 255, 0.45); - --border-color-subtle: rgba(255, 255, 255, 0.1); /* Theme - Purple Accent */ --theme-purple: #6a4a8a; @@ -46,10 +43,6 @@ /* Game-specific colors */ --game-primary: #444444; - --game-secondary: #666666; - --game-accent-orange: #FF9A66; - --game-success: #00AA00; - --game-error: #AA0000; /* Game state colors */ --game-health: #ff4444; @@ -96,14 +89,6 @@ /* Misc UI colors */ --score-multiplier: #88aaff; - --text-disabled: #8a8a9a; - - /* Rainbow effect colors */ - --rainbow-red: #FF0000; - --rainbow-orange: #FFA500; - --rainbow-yellow: #FFFF00; - --rainbow-green: #008000; - --rainbow-gold: gold; /* Panel backgrounds (glass panels with colored tints) */ --panel-blue-bg: rgba(20, 60, 120, 0.3); @@ -118,8 +103,6 @@ --text-muted: rgba(255, 255, 255, 0.6); --text-dim: rgba(255, 255, 255, 0.5); --text-faint: rgba(255, 255, 255, 0.4); - --text-subtle: rgba(255, 255, 255, 0.45); - --text-dimmer: rgba(255, 255, 255, 0.55); /* Typography Scale */ --font-size-hero: 3rem; @@ -149,18 +132,11 @@ --border-radius-md: 0.375rem; /* 6px */ --border-radius-lg: 0.5rem; /* 8px */ - /* Shadow Sizes */ - --shadow-sm: 0 0 0.625rem; /* 10px */ - --shadow-md: 0 0 1.25rem; /* 20px */ - --shadow-lg: 0 0 2.5rem; /* 40px */ - /* Transition Durations */ --transition-fast: 0.2s; --transition-medium: 0.3s; - --transition-slow: 1s; /* Z-Index Scale */ - --z-index-base: 1; --z-index-overlay: 10; --z-index-modal: 100; } @@ -230,3 +206,69 @@ p { white-space: nowrap; border-width: 0; } + +:focus-visible { + outline: 2px solid var(--theme-purple-light); + outline-offset: 2px; +} + +:focus:not(:focus-visible) { + outline: none; +} + +.skip-link { + position: absolute; + left: -999px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; + z-index: 999; +} + +.skip-link:focus { + position: fixed; + top: var(--spacing-sm); + left: var(--spacing-sm); + width: auto; + height: auto; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--nav-bg); + color: var(--theme-purple-light); + border: 2px solid var(--theme-purple); + border-radius: var(--border-radius-md); + font-family: 'Orbitron', sans-serif; + font-size: var(--font-size-small); + z-index: 10000; +} + +/* Minimap canvas — dynamically created, positioned via CSS for responsive control */ +.minimap-canvas { + position: absolute; + bottom: 12px; + left: 12px; + width: 150px; + height: 150px; + border: 2px solid rgba(138, 92, 246, 0.5); + border-radius: 4px; + z-index: 5; + pointer-events: none; +} + +@media (max-width: 480px) { + .minimap-canvas { + bottom: 0.5rem; + left: 0.5rem; + width: 80px; + height: 80px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +}