From bfb8090846d8017f95157f1748aab93503ad7c2c Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Thu, 12 Feb 2026 00:24:37 +0100 Subject: [PATCH 01/26] feat(mobile): Implement mobile UI --- .github/copilot-instructions.md | 63 - .gitignore | 2 + README.md | 12 + docs/mobile/MOBILE-ACTION-GRID-CATALOG.md | 179 ++ docs/mobile/MOBILE-ARCHITECTURE.md | 171 ++ docs/mobile/MOBILE-FEATURE-MATRIX.md | 110 ++ .../mobile/MOBILE-GESTURE-HAPTIC-QA-MATRIX.md | 96 + docs/mobile/MOBILE-GESTURES-HAPTICS.md | 93 + jest.config.ts | 6 +- src/client/ClientGameRunner.ts | 13 + src/client/HostLobbyModal.ts | 47 +- src/client/InputHandler.ts | 15 +- src/client/Main.ts | 31 +- src/client/SinglePlayerModal.ts | 31 +- src/client/Transport.ts | 23 +- src/client/TutorialManager.ts | 10 + src/client/graphics/layers/HeadsUpMessage.ts | 69 +- src/client/graphics/layers/RadialMenu.ts | 5 + src/client/graphics/layers/SpawnTimer.ts | 10 +- src/client/graphics/layers/StructureLayer.ts | 85 +- .../graphics/layers/TechUnlockNotification.ts | 3 + src/client/graphics/layers/TutorialToast.ts | 3 + .../graphics/layers/TutorialTriggers.ts | 35 +- src/client/graphics/layers/WinModal.ts | 100 +- src/client/index.html | 63 +- src/client/mobile/MobileActionGrid.ts | 1629 +++++++++++++++++ src/client/mobile/MobileDetector.ts | 137 ++ src/client/mobile/MobileTopBar.ts | 445 +++++ src/client/mobile/MobileUI.ts | 988 ++++++++++ src/client/mobile/MobileUIActionUtils.ts | 26 + src/client/mobile/MobileUIButtons.ts | 107 ++ src/client/mobile/MobileUIEventBindings.ts | 20 + src/client/mobile/MobileUIEventSetup.ts | 133 ++ src/client/mobile/MobileUIInteractions.ts | 341 ++++ src/client/mobile/MobileUIMapStack.ts | 154 ++ .../mobile/MobileUIOverlayCoordinator.ts | 55 + src/client/mobile/MobileUIStateSync.ts | 127 ++ src/client/mobile/MobileUIStats.ts | 41 + src/client/mobile/MobileUIStyles.ts | 341 ++++ src/client/mobile/MobileViewportProfile.ts | 247 +++ .../mobile/components/MobileResearchPanel.ts | 691 +++++++ .../mobile/components/MobileSettingsPanel.ts | 541 ++++++ src/client/mobile/gestures/GestureDetector.ts | 331 ++++ .../overlays/MobileAllianceNotifications.ts | 491 +++++ src/client/mobile/overlays/MobileAttackBar.ts | 630 +++++++ .../mobile/overlays/MobileChatEmojiBar.ts | 256 +++ .../mobile/overlays/MobileEconomyOverlay.ts | 809 ++++++++ .../mobile/overlays/MobileEventsDisplay.ts | 578 ++++++ src/client/mobile/overlays/MobileHelpModal.ts | 575 ++++++ .../mobile/overlays/MobileIntelSidebar.ts | 770 ++++++++ .../mobile/overlays/MobilePlayerToast.ts | 571 ++++++ .../overlays/MobileResearchPriorityModal.ts | 374 ++++ .../overlays/MobileResearchPriorityToast.ts | 233 +++ .../mobile/overlays/MobileResearchSidebar.ts | 208 +++ .../mobile/overlays/MobileSettingsSidebar.ts | 205 +++ .../mobile/overlays/MobileTechUnlockToast.ts | 283 +++ src/client/mobile/overlays/MobileWinModal.ts | 405 ++++ src/client/mobile/utils/HapticFeedback.ts | 123 ++ src/client/mobile/utils/Icons.ts | 80 + src/client/mobile/utils/OverlayPositioning.ts | 57 + src/core/execution/ExecutionManager.ts | 4 +- .../execution/UpgradeStructureExecution.ts | 7 +- src/core/worker/WorkerClient.ts | 26 +- tests/mobile/GestureDetector.test.ts | 90 + tests/mobile/MobileActionGrid.test.ts | 113 ++ tests/mobile/MobileDetector.test.ts | 93 + tests/mobile/MobileResearchPanel.test.ts | 68 + tests/mobile/MobileUIStateSync.test.ts | 177 ++ tests/mobile/MobileViewportProfile.test.ts | 88 + 69 files changed, 14823 insertions(+), 120 deletions(-) delete mode 100644 .github/copilot-instructions.md create mode 100644 docs/mobile/MOBILE-ACTION-GRID-CATALOG.md create mode 100644 docs/mobile/MOBILE-ARCHITECTURE.md create mode 100644 docs/mobile/MOBILE-FEATURE-MATRIX.md create mode 100644 docs/mobile/MOBILE-GESTURE-HAPTIC-QA-MATRIX.md create mode 100644 docs/mobile/MOBILE-GESTURES-HAPTICS.md create mode 100644 src/client/mobile/MobileActionGrid.ts create mode 100644 src/client/mobile/MobileDetector.ts create mode 100644 src/client/mobile/MobileTopBar.ts create mode 100644 src/client/mobile/MobileUI.ts create mode 100644 src/client/mobile/MobileUIActionUtils.ts create mode 100644 src/client/mobile/MobileUIButtons.ts create mode 100644 src/client/mobile/MobileUIEventBindings.ts create mode 100644 src/client/mobile/MobileUIEventSetup.ts create mode 100644 src/client/mobile/MobileUIInteractions.ts create mode 100644 src/client/mobile/MobileUIMapStack.ts create mode 100644 src/client/mobile/MobileUIOverlayCoordinator.ts create mode 100644 src/client/mobile/MobileUIStateSync.ts create mode 100644 src/client/mobile/MobileUIStats.ts create mode 100644 src/client/mobile/MobileUIStyles.ts create mode 100644 src/client/mobile/MobileViewportProfile.ts create mode 100644 src/client/mobile/components/MobileResearchPanel.ts create mode 100644 src/client/mobile/components/MobileSettingsPanel.ts create mode 100644 src/client/mobile/gestures/GestureDetector.ts create mode 100644 src/client/mobile/overlays/MobileAllianceNotifications.ts create mode 100644 src/client/mobile/overlays/MobileAttackBar.ts create mode 100644 src/client/mobile/overlays/MobileChatEmojiBar.ts create mode 100644 src/client/mobile/overlays/MobileEconomyOverlay.ts create mode 100644 src/client/mobile/overlays/MobileEventsDisplay.ts create mode 100644 src/client/mobile/overlays/MobileHelpModal.ts create mode 100644 src/client/mobile/overlays/MobileIntelSidebar.ts create mode 100644 src/client/mobile/overlays/MobilePlayerToast.ts create mode 100644 src/client/mobile/overlays/MobileResearchPriorityModal.ts create mode 100644 src/client/mobile/overlays/MobileResearchPriorityToast.ts create mode 100644 src/client/mobile/overlays/MobileResearchSidebar.ts create mode 100644 src/client/mobile/overlays/MobileSettingsSidebar.ts create mode 100644 src/client/mobile/overlays/MobileTechUnlockToast.ts create mode 100644 src/client/mobile/overlays/MobileWinModal.ts create mode 100644 src/client/mobile/utils/HapticFeedback.ts create mode 100644 src/client/mobile/utils/Icons.ts create mode 100644 src/client/mobile/utils/OverlayPositioning.ts create mode 100644 tests/mobile/GestureDetector.test.ts create mode 100644 tests/mobile/MobileActionGrid.test.ts create mode 100644 tests/mobile/MobileDetector.test.ts create mode 100644 tests/mobile/MobileResearchPanel.test.ts create mode 100644 tests/mobile/MobileUIStateSync.test.ts create mode 100644 tests/mobile/MobileViewportProfile.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 4ac57a101..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,63 +0,0 @@ -# Terratomic – AI coding agent guide - -This repo is a TypeScript, ESM-first mono-app: a browser client built with Webpack talking to a clustered Node server. The shared game logic lives in `src/core` and runs in both environments. - -## Architecture map - -- Client (entry: `src/client/Main.ts`) - - Lit-based web components, PIXI rendering, Tailwind styles. - - Dev server on port 9000; proxies to backend and worker websockets. - - Example: joining games and UI wiring lives in `src/client/*Modal.ts`, networking/events in `src/client/Transport.ts`. -- Core shared logic (`src/core`) - - Game model, executors, schemas, pathfinding, validations. - - Example: `src/core/GameRunner.ts` drives turns and filters updates (e.g., submarine stealth via `filterUpdatesForClient`). - - Lightweight event system: `src/core/EventBus.ts`. -- Server (entry: `src/server/Server.ts`) - - Node cluster master/worker split (`Master.ts`, `Worker.ts`), game coordination in `GameManager.ts` and websocket handling in `GameServer.ts`/`Client.ts`. - - Config comes from `src/core/configuration/ConfigLoader` and `Config` enums; `GAME_ENV=dev|prod` gates behavior. - - Gatekeeping/rate-limits live under `src/server/gatekeeper` (lint-ignored for now). -- Assets & data - - Static assets in `resources/` copied to `static/` on build (maps under `resources/maps` are excluded). - - Proprietary images (optional) in `proprietary/images` also copied to `static/images`. - -## Dev, build, test - -- Install and run locally - - `npm install` - - `npm run dev` starts client (9000 with proxies to server) and server workers with `GAME_ENV=dev`. -- Server-only - - `npm run start:server-dev` (dev env); `npm run start:server` (prod env, no proxies). -- Bundles - - `npm run build-dev` | `npm run build-prod`. Outputs to `static/` with content-hashed filenames. Entry is `src/client/Main.ts`. -- Utilities - - Generate terrain maps: `npm run build-map` (TS loader via ts-node/esm). - - Perf micro-benchmarks: `npm run perf` runs `tests/perf/*.ts` via tsx. -- Tests & lint - - `npm test` uses Jest + @swc/jest with ESM TS. Coverage thresholds set in `jest.config.ts`. - - `npm run lint` (ESLint flat config + Prettier). Lint-staged + Husky enforce fixes on commit. - -## Conventions and pitfalls specific to this repo - -- ESM everywhere - - `"type": "module"`; use `import`/`export` only—no `require`/`module.exports`. - - Jest maps extensioned imports: `"^(\\.{1,2}/.*)\\.js$": "$1"`. In TS source, import without `.js` and let tooling handle it. -- Shared code boundaries - - Code in `src/core` must stay isomorphic (no DOM/Node-only APIs). Server/client specifics live in `src/server` or `src/client`. -- Networking - - Client dev server proxies: `/socket` → backend (ws:3000), `/w0|/w1|/w2` → worker processes (ws/http ports 3001–3003). Keep these paths stable when adding endpoints. -- Build wiring - - Webpack copies `resources/**` to `static/` (except `resources/maps/**`) and `proprietary/images/**` to `static/images/`. Add new static assets under these roots. -- Logging/telemetry - - Server uses Winston; optional OpenTelemetry exporters are present—prefer existing `OtelResource.ts` if instrumenting. - -## Good starting points (examples) - -- Add a new UI module: place a Lit element under `src/client/components/`, import it in `Main.ts`, wire events via `EventBus`. -- Extend game logic: add execution under `src/core/execution/**` and surface view updates through `GameUpdates` consumed by `GameRunner`. -- Add a server API: implement handler in `src/server/GameServer.ts` (or worker), ensure proxy path is listed in `webpack.config.js` devServer `proxy`. - -## Licensing notes - -- Code: AGPLv3 (see `LICENSE`). -- Assets in `resources/`: CC BY-SA 4.0; keep attribution; do not relicense. -- `proprietary/` has separate terms; see `CLA.md` and `proprietary/LICENSE`. diff --git a/.gitignore b/.gitignore index 70b86f8b1..083cb5fe8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ out/ static/ coverage/ TODO.txt +PROGRESS.md resources/images/.DS_Store resources/.DS_Store .env* @@ -13,3 +14,4 @@ errors.txt Project_Info.md commands.sh gemini/ +.github/copilot-instructions.md diff --git a/README.md b/README.md index 96b46df1a..6742640b1 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,18 @@ Until then, open issues, submit pull requests, or join the discussion [on Discor - `src/scripts` – Dev or build-time scripts - `resources/` – Static assets (flags, fonts, icons, maps, sprites, images) - `tests/` – Unit and integration tests for client, core logic, and utilities +- `docs/mobile/` – Mobile UI documentation (see below) + +--- + +## 📱 Mobile UI Docs + +Touch-first mobile layer built with Lit web components under `src/client/mobile/`. + +- [Architecture](docs/mobile/MOBILE-ARCHITECTURE.md) — component map, activation lifecycle, event bus, file inventory +- [Feature Matrix](docs/mobile/MOBILE-FEATURE-MATRIX.md) — desktop → mobile parity table, HUD layout, z-index map +- [Gestures & Haptics](docs/mobile/MOBILE-GESTURES-HAPTICS.md) — gesture detection config, haptic patterns, usage by area +- [Action Grid Catalog](docs/mobile/MOBILE-ACTION-GRID-CATALOG.md) — tile category resolution, full action tables per context --- diff --git a/docs/mobile/MOBILE-ACTION-GRID-CATALOG.md b/docs/mobile/MOBILE-ACTION-GRID-CATALOG.md new file mode 100644 index 000000000..f8a4f4cbd --- /dev/null +++ b/docs/mobile/MOBILE-ACTION-GRID-CATALOG.md @@ -0,0 +1,179 @@ +# Mobile Action Grid Catalog + +> Last updated: 2026-02-15 + +Complete action inventory for `MobileActionGrid`, sourced directly from code. + +Related docs: [Architecture](MOBILE-ARCHITECTURE.md) · [Feature Matrix](MOBILE-FEATURE-MATRIX.md) · [Gestures & Haptics](MOBILE-GESTURES-HAPTICS.md) + +--- + +## Category Resolution + +`MobileActionGrid.determineTileCategory()` maps each tile tap to one of these categories: + +| Category | Condition | +| ------------------------- | -------------------------------------------------------------- | +| `spawn-phase` | Game in spawn phase | +| `own-land` | Own tile, land (includes shore — shore uses same land actions) | +| `own-shore` | Own tile, shoreline (delegates to own-land actions) | +| `own-water` | Own tile, water | +| `enemy-can-attack` | Enemy/player tile, `canAttack` is true | +| `enemy-can-boat-attack` | Enemy/player land tile, reachable by transport ship only | +| `enemy-no-attack` | Enemy/player tile, no ground or boat attack possible | +| `neutral-can-attack` | Neutral tile, ocean (ship building) or land with `canAttack` | +| `neutral-can-boat-attack` | Neutral land tile, reachable by transport ship only | + +Own-land/own-shore/own-water also append a **Stack Mode toggle** at the end of the actions list. + +--- + +## Actions by Category + +### Spawn Phase + +| Action | ID | Priority | Condition | +| ---------- | ------- | -------- | ----------------- | +| Spawn Here | `spawn` | high | Unowned land tile | + +--- + +### Own Land / Own Shore + +All unlocked land structures. Disabled (greyed) if gold insufficient. Research-locked structures hidden until prerequisite unlocked. + +| Action | ID | Priority | Requirement | +| --------------- | ---------------------- | -------- | ------------------------------ | +| Port | `build:Port` | high | Nearby ocean shore (≤10 tiles) | +| City | `build:City` | high | — | +| Factory | `build:Factory` | high | — | +| Defense Post | `build:DefensePost` | normal | — | +| Airfield | `build:Airfield` | high | — | +| Hospital | `build:Hospital` | normal | `HospitalResearch` | +| Missile Silo | `build:MissileSilo` | normal | `NuclearFission` | +| Research Lab | `build:ResearchLab` | normal | — | +| Academy | `build:Academy` | normal | — | +| SAM Launcher | `build:SAMLauncher` | normal | — | +| Doomsday Device | `build:DoomsdayDevice` | normal | `DoomsdayDeviceResearch` | +| Artillery | `build:Artillery` | normal | Factory + `ArtilleryResearch` | +| Fighter Jet | `build:FighterJet` | normal | Airfield + `JetEngines` | +| Stack Mode | `mode:stack-toggle` | — | Always (last item) | + +Port only appears when a BFS within 10 tiles finds an owned ocean-shore tile. + +--- + +### Own Water + +| Action | ID | Priority | Requirement | +| ----------- | ------------------- | -------- | -------------------------- | +| Port | `build:Port` | high | Nearby ocean shore (≤10) | +| Warship | `build:Warship` | high | Owns a Port | +| Submarine | `build:Submarine` | high | Port + `SubmarineResearch` | +| Fighter Jet | `build:FighterJet` | normal | Airfield + `JetEngines` | +| Stack Mode | `mode:stack-toggle` | — | Always (last item) | + +--- + +### Enemy — Can Ground Attack + +| Action | ID | Priority | Requirement | +| ---------------- | -------------------------- | -------- | ----------------------------------- | +| Ground Attack | `attack:ground` | high | Troops > 0 | +| Paratroopers | `attack:airstrike` | normal | Airfield + `JetEngines` | +| Bomber Run | `attack:bomber` | normal | Airfield + at war | +| Fighter Jet | `build:FighterJet` | normal | Airfield + `JetEngines` | +| Request Peace | `diplomacy:request-peace` | normal | At war with target | +| Break Alliance | `diplomacy:break-alliance` | normal | Allied with target | +| Propose Alliance | `diplomacy:propose-ally` | normal | Neutral relationship | +| Declare War | `attack:declare-war` | normal | Not at war | +| Atom Bomb | `attack:nuke-atom` | normal | Silo + `NuclearFission` + 5K gold | +| H-Bomb | `attack:nuke-hbomb` | normal | Silo + `ThermonuclearStaging` + 15K | +| MIRV | `attack:nuke-mirv` | normal | Silo + `MIRVTechnology` + 50K | + +--- + +### Enemy — Can Boat Attack + +| Action | ID | Priority | Requirement | +| ---------------- | -------------------------- | -------- | ----------------------------------- | +| Naval Assault | `attack:naval` | high | Troops > 0 | +| Paratroopers | `attack:airstrike` | normal | Airfield + `JetEngines` | +| Bomber Run | `attack:bomber` | normal | Airfield + at war | +| Request Peace | `diplomacy:request-peace` | normal | At war with target | +| Break Alliance | `diplomacy:break-alliance` | normal | Allied with target | +| Propose Alliance | `diplomacy:propose-ally` | normal | Neutral relationship | +| Declare War | `attack:declare-war` | normal | Not at war | +| Atom Bomb | `attack:nuke-atom` | normal | Silo + `NuclearFission` + 5K gold | +| H-Bomb | `attack:nuke-hbomb` | normal | Silo + `ThermonuclearStaging` + 15K | +| MIRV | `attack:nuke-mirv` | normal | Silo + `MIRVTechnology` + 50K | + +--- + +### Enemy — No Attack Possible + +Diplomacy + air + nukes only. Peace and alliance are promoted to high priority. + +| Action | ID | Priority | Requirement | +| ---------------- | -------------------------- | -------- | ----------------------------------- | +| Paratroopers | `attack:airstrike` | **high** | Airfield + `JetEngines` | +| Bomber Run | `attack:bomber` | **high** | Airfield + at war | +| Request Peace | `diplomacy:request-peace` | **high** | At war with target | +| Propose Alliance | `diplomacy:propose-ally` | **high** | Neutral relationship | +| Break Alliance | `diplomacy:break-alliance` | normal | Allied with target | +| Declare War | `attack:declare-war` | normal | Not at war | +| Atom Bomb | `attack:nuke-atom` | normal | Silo + `NuclearFission` + 5K gold | +| H-Bomb | `attack:nuke-hbomb` | normal | Silo + `ThermonuclearStaging` + 15K | +| MIRV | `attack:nuke-mirv` | normal | Silo + `MIRVTechnology` + 50K | + +--- + +### Neutral — Can Attack (Land) + +| Action | ID | Priority | Requirement | +| ------ | --------------- | -------- | ----------- | +| Attack | `attack:ground` | high | Troops > 0 | + +--- + +### Neutral — Can Attack (Ocean) + +| Action | ID | Priority | Requirement | +| ----------- | ------------------ | -------- | -------------------------- | +| Port | `build:Port` | high | Nearby ocean shore (≤10) | +| Warship | `build:Warship` | high | Owns a Port | +| Submarine | `build:Submarine` | high | Port + `SubmarineResearch` | +| Fighter Jet | `build:FighterJet` | normal | Airfield + `JetEngines` | + +--- + +### Neutral — Boat Attack + +| Action | ID | Priority | Requirement | +| ------------- | -------------- | -------- | ----------- | +| Naval Assault | `attack:naval` | high | Troops > 0 | + +--- + +## Grid Layout + +- Bottom-anchored sheet, max 60vh, auto-fill grid with 65px min column width +- High-priority items sorted first +- Top row items auto-expand to fill incomplete rows (percentage-based column spans) +- Disabled tiles: greyed + reason text +- Locked tiles: hidden (research-locked structures not shown until prerequisite met) +- Nukes: only shown when silo + research + gold requirements are all met +- Diplomacy: exactly one of peace/alliance/break-alliance shown based on current relationship; declare-war shown when not at war +- 300ms debounce prevents accidental backdrop closure +- Haptic: `TAP` on valid action, `ERROR` on disabled/locked + +--- + +## Stack Mode + +When stack mode is toggled ON: + +- Action grid collapses to a single full-width "Stack ON" toggle button +- Map taps upgrade the nearest stackable structure within hit radius (28px screen, 72px sticky) +- Stackable types: City, Port, Airfield, Hospital, Academy, ResearchLab, Factory, MissileSilo, SAMLauncher +- Sticky targeting remembers the last upgraded structure for easier repeated taps diff --git a/docs/mobile/MOBILE-ARCHITECTURE.md b/docs/mobile/MOBILE-ARCHITECTURE.md new file mode 100644 index 000000000..81e91b05b --- /dev/null +++ b/docs/mobile/MOBILE-ARCHITECTURE.md @@ -0,0 +1,171 @@ +# Mobile UI Architecture + +> Last updated: 2026-02-16 + +Quick-reference architecture map for the mobile UI layer. All source lives under `src/client/mobile/`. + +Related docs: [Feature Matrix](MOBILE-FEATURE-MATRIX.md) · [Gestures & Haptics](MOBILE-GESTURES-HAPTICS.md) · [Action Grid Catalog](MOBILE-ACTION-GRID-CATALOG.md) · [Responsive Scaling Plan](MOBILE-RESPONSIVE-SCALING-PLAN.md) + +--- + +## Component Map + +``` +MobileUI (orchestrator, 873 lines) +├── MobileDetector — device / orientation / safe-area detection +├── MobileTopBar — fixed top status bar (population, gold, clock, settings) +├── MobileActionGrid — bottom-sheet context-aware action tiles +├── MobileUIStyles — injected global mobile CSS payload +├── MobileUIButtons — factory for tab/zoom control buttons +├── MobileUIEventSetup — centralized listener registration for UI controls +├── MobileUIStateSync — game tick → topbar/tab/trade-indicator synchronization +├── MobileUIOverlayCoordinator — overlay positioning + per-tick update orchestration +├── MobileUIInteractions — attack/diplomacy/spawn/chat/emoji/donation handlers +├── MobileUIMapStack — screen→tile conversion + stack upgrade targeting helpers +├── MobileUIStats — trade-income parsing from tick updates +├── MobileUIActionUtils — build-action parsing + bomber target utility +├── MobileUIEventBindings — small shared event-binding helper wrappers +├── MobileViewportProfile — viewport class/orientation profiling + responsive token generation +│ +├── gestures/ +│ └── GestureDetector — touch state machine (tap, long-press, drag, pinch, edge-swipe) +│ +├── overlays/ +│ ├── MobileEconomyOverlay — full-screen economy panel (investment sliders, troop/attack ratios) +│ ├── MobileIntelSidebar — left-slide sidebar (Players leaderboard + Teams + Events tabs) +│ ├── MobilePlayerToast — slide-down player info toast (long-press trigger) +│ ├── MobileAttackBar — HUD bubbles for active attacks/boats/paratroopers/trade income +│ ├── MobileChatEmojiBar — HUD bubbles for chat + emoji messages +│ ├── MobileAllianceNotifications — alliance request + extension warning notifications +│ ├── MobileEventsDisplay — events log (embedded in Intel sidebar Events tab) +│ ├── MobileResearchSidebar — right-slide sidebar hosting MobileResearchPanel +│ ├── MobileResearchPriorityModal — research category priority picker +│ ├── MobileResearchPriorityToast — confirmation toast after priority selection +│ ├── MobileSettingsSidebar — right-slide sidebar hosting MobileSettingsPanel +│ ├── MobileTechUnlockToast — tech unlock notification toast +│ └── MobileWinModal — mobile-first game-over / victory sheet +│ +├── components/ +│ ├── MobileResearchPanel — full research tech tree with category tabs +│ └── MobileSettingsPanel — settings toggles, replay controls, exit game +│ +└── utils/ + ├── HapticFeedback — navigator.vibrate() wrapper (TAP / LONG_PRESS / ERROR / SUCCESS / WARNING) + ├── Icons — unit type → icon path mappings + └── OverlayPositioning — shared attack-bar anchoring helpers for toasts/modals +``` + +--- + +## Activation + +Mobile UI activates when `MobileDetector.isMobile()` returns `true` (screen width, UA, touch capability). `MobileUI` is instantiated in `Main.ts` and stored on `window.__MOBILE_UI__`. `ClientGameRunner` wires it to the game canvas and renderer. + +When active, `MobileUI` now computes a responsive viewport profile (`compact`/`regular`/`large` + `portrait`/`landscape`) and publishes mobile sizing tokens on `document.body` (for example ActionGrid tile/spacing/height caps). This keeps `430x932` as the no-regression reference profile while applying compact-landscape reductions to smaller viewports. + +Key lifecycle calls: + +| Method | Caller | Purpose | +| ------------------------------ | ------------------ | ------------------------------------------- | +| `setActive(true/false)` | `Main.ts` | Attach/detach components, toggle CSS class | +| `initializeGestureDetection()` | `ClientGameRunner` | Bind gesture detector to canvas | +| `setTransformHandler()` | `ClientGameRunner` | Screen↔world coordinate conversion | +| `updateGameState(game)` | `ClientGameRunner` | Propagate live `GameView` to all components | + +When active, `body.mobile-ui-enabled` hides all desktop HUD elements (radial menu, control panels, desktop top bar, etc.). + +Winner/game-over flow on mobile is handled by `MobileWinModal`; desktop `WinModal` skips processing while mobile UI is active. + +--- + +## EventBus Events (mobile → server) + +| Event | Source | +| ---------------------------------- | ------------------------------ | +| `SendSpawnIntentEvent` | Tap unowned land (spawn phase) | +| `BuildUnitIntentEvent` | Action grid build tile | +| `SendUpgradeStructureIntentEvent` | Stack mode tap | +| `SendAttackIntentEvent` | Ground attack action | +| `SendBoatAttackIntentEvent` | Naval assault action | +| `SendParatrooperAttackIntentEvent` | Paratrooper action | +| `SendBomberIntentEvent` | Bomber run action | +| `SendAllianceRequestIntentEvent` | Propose alliance action | +| `SendBreakAllianceIntentEvent` | Break alliance action | +| `SendPeaceRequestIntentEvent` | Request peace action | +| `SendDeclareWarIntentEvent` | Declare war action | +| `SendEmojiIntentEvent` | Player toast → emoji table | +| `SendDonateTroopsIntentEvent` | Player toast donate troops | +| `SendDonateGoldIntentEvent` | Player toast donate gold | +| `ToggleUpgradeModeEvent` | Stack mode toggle | +| `ZoomEvent` | Pinch gesture / zoom buttons | +| `DragEvent` | Drag gesture (map pan) | +| `CenterCameraEvent` | Center zoom button | + +--- + +## Interaction Flows + +### Tap → Action Grid → Intent + +1. `GestureDetector` fires `tap` → `MobileUI.handleMapTap()` converts screen→tile +2. `MobileActionGrid.showForTile()` resolves tile category and renders actions +3. User taps an action tile → `MobileUI.handleActionSelected()` routes by prefix (`build:`, `attack:`, `diplomacy:`, `spawn`, `mode:`) +4. Intent event emitted on `EventBus` → `Transport` → server + +### Sidebar Access + +| Trigger | Opens | +| ------------------------- | ----------------------------- | +| Economy tab button | MobileEconomyOverlay | +| Intel tab button | MobileIntelSidebar (left) | +| Research tab button | MobileResearchSidebar (right) | +| TopBar settings icon | MobileSettingsSidebar (right) | +| Edge swipe from left | MobileIntelSidebar | +| Edge swipe from right | MobileResearchSidebar | +| Long-press on player tile | MobilePlayerToast | +| Long-press on other tile | MobileEconomyOverlay | + +### Stack Mode + +Toggle in action grid enables upgrade-mode: subsequent taps upgrade the nearest stackable structure (City, Port, Factory, etc.) using sticky targeting. + +--- + +## File Inventory + +| File | Lines | +| ----------------------------------------- | ----- | +| `MobileActionGrid.ts` | 1449 | +| `MobileUI.ts` | 873 | +| `MobileTopBar.ts` | 394 | +| `MobileDetector.ts` | 120 | +| `MobileUIStyles.ts` | 322 | +| `MobileUIInteractions.ts` | 286 | +| `MobileUIMapStack.ts` | 131 | +| `MobileUIEventSetup.ts` | 113 | +| `MobileUIStateSync.ts` | 112 | +| `MobileUIButtons.ts` | 99 | +| `MobileUIStats.ts` | 32 | +| `MobileUIActionUtils.ts` | 23 | +| `MobileUIEventBindings.ts` | 19 | +| `MobileViewportProfile.ts` | 232 | +| `gestures/GestureDetector.ts` | 287 | +| `components/MobileResearchPanel.ts` | 604 | +| `components/MobileSettingsPanel.ts` | 486 | +| `overlays/MobileEconomyOverlay.ts` | 729 | +| `overlays/MobileIntelSidebar.ts` | 677 | +| `overlays/MobileAttackBar.ts` | 556 | +| `overlays/MobileEventsDisplay.ts` | 515 | +| `overlays/MobilePlayerToast.ts` | 501 | +| `overlays/MobileAllianceNotifications.ts` | 430 | +| `overlays/MobileResearchPriorityModal.ts` | 326 | +| `overlays/MobileTechUnlockToast.ts` | 246 | +| `overlays/MobileChatEmojiBar.ts` | 225 | +| `overlays/MobileResearchPriorityToast.ts` | 209 | +| `overlays/MobileResearchSidebar.ts` | 187 | +| `overlays/MobileSettingsSidebar.ts` | 184 | +| `overlays/MobileWinModal.ts` | 358 | +| `utils/HapticFeedback.ts` | 107 | +| `utils/Icons.ts` | 70 | +| `utils/OverlayPositioning.ts` | 46 | +| `MobileUIOverlayCoordinator.ts` | 48 | diff --git a/docs/mobile/MOBILE-FEATURE-MATRIX.md b/docs/mobile/MOBILE-FEATURE-MATRIX.md new file mode 100644 index 000000000..f3823f0aa --- /dev/null +++ b/docs/mobile/MOBILE-FEATURE-MATRIX.md @@ -0,0 +1,110 @@ +# Mobile Feature Matrix + +> Last updated: 2026-02-15 + +Desktop → mobile parity overview. All source under `src/client/mobile/`. + +Related docs: [Architecture](MOBILE-ARCHITECTURE.md) · [Gestures & Haptics](MOBILE-GESTURES-HAPTICS.md) · [Action Grid Catalog](MOBILE-ACTION-GRID-CATALOG.md) · [QA Matrix](MOBILE-GESTURE-HAPTIC-QA-MATRIX.md) + +--- + +## Responsive Scaling Implementation Status + +Implemented responsive behavior (ActionGrid-first scope): + +- **Baseline lock:** iPhone 14 Pro Max emulation `430x932` remains no-regression reference (portrait + landscape). +- **Compact landscape phones:** ActionGrid uses tighter token set (tile/icon/text sizing + max-height reduction) to preserve map visibility. +- **Regular landscape phones:** ActionGrid applies medium compaction to avoid oversized grid coverage. +- **Large tablet profiles:** ActionGrid receives a tablet readability bump (slightly larger tiles/icons/text vs phone compact profiles). +- **Tablet activation:** iPad Air/Pro class devices now enter mobile UI path reliably (including iPadOS desktop-style UA cases). + +Primary implementation files: + +- `src/client/mobile/MobileViewportProfile.ts` +- `src/client/mobile/MobileUI.ts` +- `src/client/mobile/MobileActionGrid.ts` +- `src/client/mobile/MobileDetector.ts` + +--- + +## Desktop → Mobile Feature Parity + +Note: `MobileUI` now acts primarily as an orchestrator; most action routing, event setup, and game-tick sync policies are delegated to helper modules (`MobileUIInteractions`, `MobileUIEventSetup`, `MobileUIStateSync`, etc.). + +| Desktop Feature | Mobile Equivalent | Trigger | Status | +| --------------------------- | --------------------------------------------- | ---------------------------------------------- | ------------- | +| Radial/Context Menu | `MobileActionGrid` (bottom sheet) | Tap any tile | ✅ Working | +| Responsive ActionGrid Scale | Token-driven profile scaling | Auto from viewport class + orientation | ✅ Working | +| Ground Attack | ActionGrid `attack:ground` | Tap enemy tile → "Ground Attack" | ✅ Working | +| Boat / Naval Assault | ActionGrid `attack:naval` | Tap enemy tile → "Naval Assault" | ✅ Working | +| Paratroopers | ActionGrid `attack:airstrike` | Tap enemy tile → "Paratroopers" | ✅ Working | +| Bomber Run | ActionGrid `attack:bomber` | Tap enemy tile → "Bomber Run" | ✅ Working | +| Alliance Request | ActionGrid `diplomacy:propose-ally` | Tap enemy tile → "Propose Alliance" | ✅ Working | +| Break Alliance | ActionGrid `diplomacy:break-alliance` | Tap allied tile → "Break Alliance" | ✅ Working | +| Request Peace | ActionGrid `diplomacy:request-peace` | Tap enemy-at-war tile → "Request Peace" | ✅ Working | +| Declare War | ActionGrid `attack:declare-war` | Tap enemy tile → "Declare War" | ✅ Working | +| Spawn | Direct tap (spawn phase) | Tap unclaimed land | ✅ Working | +| Build Structures | ActionGrid `build:*` tiles | Tap own tile → grid shows buildable structures | ✅ Working | +| Build Nukes | ActionGrid `attack:nuke-*` | Tap enemy tile (requires silo + research) | ✅ Working | +| Build Naval Units | ActionGrid `build:Warship`, `build:Submarine` | Tap own water tile (requires Port) | ✅ Working | +| Build Fighter Jet | ActionGrid `build:FighterJet` | Tap own tile (requires Airfield + Jet Engines) | ✅ Working | +| Build Artillery | ActionGrid `build:Artillery` | Tap own tile (requires Factory + research) | ✅ Working | +| Stack/Upgrade Structures | ActionGrid stack mode toggle | Toggle in grid, tap structures to upgrade | ✅ Working | +| Troop/Worker Ratio | `MobileEconomyOverlay` slider | Economy overlay or long-press map | ✅ Working | +| Attack Ratio | `MobileEconomyOverlay` slider | Economy overlay | ✅ Working | +| Investment Sliders | `MobileEconomyOverlay` | Economy overlay (production/road/research) | ✅ Working | +| Population & Gold | `MobileTopBar` | Always visible at top | ✅ Working | +| Game Clock | `MobileTopBar` | Always visible (counts after spawn phase) | ✅ Working | +| Leaderboard | `MobileIntelSidebar` (Players tab) | Intel tab button or edge swipe left | ✅ Working | +| Team Leaderboard | `MobileIntelSidebar` (Teams tab) | Intel sidebar → Teams tab | ✅ Working | +| Player Info | `MobilePlayerToast` | Long-press any player-owned tile | ✅ Working | +| Events Log | `MobileEventsDisplay` (in Intel sidebar) | Intel sidebar → Events tab | ✅ Working | +| Chat | Opens desktop `chat-modal` | Player toast → chat button | ✅ Working | +| Emoji | Opens desktop `emoji-table` | Player toast → emoji button or ActionGrid | ✅ Working | +| Donate Troops | Player toast → donate troops | Long-press player tile → donate button | ✅ Working | +| Donate Gold | Player toast → donate gold | Long-press player tile → donate button | ✅ Working | +| Trade Income Indicator | `MobileAttackBar` (trade bubble) | Auto-shown on trade income ticks | ✅ Working | +| Attack Notifications | `MobileAttackBar` (attack bubbles) | Auto-shown on active attacks | ✅ Working | +| Chat/Emoji Bubbles | `MobileChatEmojiBar` | Auto-shown on incoming chat/emoji | ✅ Working | +| Alliance Notifications | `MobileAllianceNotifications` | Auto-shown on alliance requests/warnings | ✅ Working | +| Tech Unlock Notification | `MobileTechUnlockToast` | Auto-shown on tech unlock | ✅ Working | +| Research Toggle | `MobileResearchSidebar` | Research tab button or edge swipe right | ✅ Working | +| Research Priority Selection | `MobileResearchPriorityModal` | Research panel interaction | ✅ Working | +| Options / Settings | `MobileSettingsSidebar` | TopBar settings icon | ✅ Working | +| Zoom In/Out | Zoom +/- buttons (left side) | Tap zoom buttons | ✅ Working | +| Center Camera | Center button (left side) | Tap center button | ✅ Working | +| Pan / Zoom (touch) | `GestureDetector` drag + pinch | Touch drag / pinch | ✅ Working | +| Replay Panel | `MobileSettingsPanel` (replay controls) | Settings sidebar | ✅ Working | +| Win / Game Over Modal | `MobileWinModal` | Auto-shown on death/win updates | ✅ Working | +| Alternate View (Space) | — | — | ❌ Not ported | +| Multi-Build Mode | — | — | ❌ Not ported | + +--- + +## HUD Layout (top → bottom) + +| Layer | Z-Index | Component | +| --------------- | ------- | ---------------------------------------- | +| Top bar | 1650 | `MobileTopBar` | +| Attack bar | 1760 | `MobileAttackBar` | +| Chat/emoji bar | 1758 | `MobileChatEmojiBar` | +| Alliance notes | 1500 | `MobileAllianceNotifications` | +| Tab buttons | 1700 | Economy / Intel / Research tabs | +| Zoom buttons | 1700 | +/−/center (left side) | +| Action grid | 2000 | `MobileActionGrid` (bottom) | +| Player toast | 2500 | `MobilePlayerToast` | +| Sidebars | 3000 | Intel / Research / Settings | +| Economy overlay | 1800 | `MobileEconomyOverlay` | +| Tech toasts | 4050+ | `MobileTechUnlockToast` / priority modal | + +--- + +## Tab Buttons (right side) + +Three fixed buttons appear during gameplay (hidden during spawn phase): + +| Button | Position | Color | Opens | +| -------- | -------- | ------ | ----------------------- | +| Economy | Top | Gold | `MobileEconomyOverlay` | +| Research | Middle | Purple | `MobileResearchSidebar` | +| Intel | Bottom | Blue | `MobileIntelSidebar` | diff --git a/docs/mobile/MOBILE-GESTURE-HAPTIC-QA-MATRIX.md b/docs/mobile/MOBILE-GESTURE-HAPTIC-QA-MATRIX.md new file mode 100644 index 000000000..0b1a1cbc9 --- /dev/null +++ b/docs/mobile/MOBILE-GESTURE-HAPTIC-QA-MATRIX.md @@ -0,0 +1,96 @@ +# Mobile Gesture + Haptic QA Matrix + +> Last updated: 2026-02-15 + +Manual verification matrix for mobile touch interactions and haptic feedback parity. + +Related docs: [Gestures & Haptics](MOBILE-GESTURES-HAPTICS.md) · [Architecture](MOBILE-ARCHITECTURE.md) · [Feature Matrix](MOBILE-FEATURE-MATRIX.md) + +--- + +## Test Devices + +| Device | OS Version | Browser | Tester | Date | Result | +| ------------- | ---------- | ------- | ------ | ---- | ---------- | +| Android phone | | Chrome | | | ⏳ Pending | +| iPhone | | Safari | | | ⏳ Pending | +| iPad mini | | Safari | | | ⏳ Pending | +| iPad Air/Pro | | Safari | | | ⏳ Pending | + +--- + +## Responsive Viewport Matrix + +| Profile | Expected Behavior | Android | iOS | Notes | +| ---------------------------- | ---------------------------------------------------------------------------------- | ------- | --- | ----- | +| Baseline `430x932` portrait | No regression from prior accepted sizing | ⏳ | ⏳ | | +| Baseline `430x932` landscape | No regression from prior accepted sizing | ⏳ | ⏳ | | +| Compact phone landscape | ActionGrid appears in compact mode; map remains comfortably usable | ⏳ | ⏳ | | +| Regular phone landscape | ActionGrid medium compaction; no oversized coverage | ⏳ | ⏳ | | +| iPad mini | Mobile UI path active; ActionGrid readable and not tiny | ⏳ | ⏳ | | +| iPad Air/Pro | Mobile UI path active (not desktop fallback); ActionGrid readable with tablet bump | ⏳ | ⏳ | | + +--- + +## Gesture Matrix + +| Area | Gesture | Expected Behavior | Android | iOS | Notes | +| ---------- | --------------------- | ----------------------------------------------------------------------- | ------- | --- | ----- | +| Map canvas | Tap | Select tile / open action grid / spawn in spawn-phase | ⏳ | ⏳ | | +| Map canvas | Long press | Player tile opens `MobilePlayerToast`; other tiles open economy overlay | ⏳ | ⏳ | | +| Map canvas | Drag | Pans map smoothly with no stuck touch state | ⏳ | ⏳ | | +| Map canvas | Pinch | Zooms in/out around gesture center | ⏳ | ⏳ | | +| Edge left | Swipe from left edge | Toggles Intel sidebar | ⏳ | ⏳ | | +| Edge right | Swipe from right edge | Toggles Research sidebar | ⏳ | ⏳ | | + +--- + +## Haptic Matrix + +| Area | Interaction | Expected Haptic | Android | iOS | Notes | +| ------------------------- | --------------------------------------- | ------------------------------- | ------- | --- | ----- | +| Gesture detector | Tap | light (`custom(10)`) | ⏳ | ⏳ | | +| Gesture detector | Long press | medium (`custom(50)`) | ⏳ | ⏳ | | +| Gesture detector | Edge swipe | light (`custom(25)`) | ⏳ | ⏳ | | +| Action grid | Enabled action tile | `TAP` | ⏳ | ⏳ | | +| Action grid | Disabled tile | `ERROR` | ⏳ | ⏳ | | +| Stack mode | Toggle on/off | `TAP` | ⏳ | ⏳ | | +| Stack mode | Missed upgrade target | `ERROR` | ⏳ | ⏳ | | +| Attack bar | Focus incoming attack bubble | `TAP` | ⏳ | ⏳ | | +| Attack bar | Cancel outgoing/boat/paratrooper bubble | `TAP` | ⏳ | ⏳ | | +| Chat/emoji bar | Tap bubble to focus sender | `TAP` | ⏳ | ⏳ | | +| Alliance notifications | Accept / Renew | `SUCCESS` | ⏳ | ⏳ | | +| Alliance notifications | Reject / Dismiss | `TAP` | ⏳ | ⏳ | | +| Player toast | Chat/emoji open | `TAP` | ⏳ | ⏳ | | +| Player toast | Donate confirm actions | `SUCCESS` | ⏳ | ⏳ | | +| Win modal (mobile layout) | Keep / Save / Copy / Download / Discord | `TAP` or `SUCCESS` on exit/copy | ⏳ | ⏳ | | + +--- + +## Win Modal Mobile Verification + +| Check | Expected | Android | iOS | Notes | +| ----------- | ---------------------------------------------------------------------- | ------- | --- | ----- | +| Layout mode | Modal uses mobile layout when `body.mobile-ui-enabled` is active | ⏳ | ⏳ | | +| Placement | Anchored near bottom with safe-area padding, not centered desktop card | ⏳ | ⏳ | | +| Buttons | 44px+ touch targets, stacked vertically | ⏳ | ⏳ | | +| Actions | Exit/Keep/Replay/Discord actions are all tappable and stable | ⏳ | ⏳ | | + +--- + +## Regression Spot Checks + +| Area | Check | Android | iOS | Notes | +| ------------- | -------------------------------------------------------------------------------- | ------- | --- | ----- | +| Top overlays | Attack bar + chat/emoji bar do not overlap incorrectly after orientation changes | ⏳ | ⏳ | | +| Sidebars | Intel/Research/Settings open and close with no scroll lock leaks | ⏳ | ⏳ | | +| Performance | No obvious frame hitching during rapid gesture input | ⏳ | ⏳ | | +| Accessibility | Tap targets are reachable and not clipped by safe areas | ⏳ | ⏳ | | + +--- + +## Pass Criteria + +- All gesture checks pass on Android Chrome and iOS Safari. +- Haptic checks pass wherever browser/hardware supports vibration APIs. +- Any non-parity behavior is documented with repro steps and severity. diff --git a/docs/mobile/MOBILE-GESTURES-HAPTICS.md b/docs/mobile/MOBILE-GESTURES-HAPTICS.md new file mode 100644 index 000000000..f0089d801 --- /dev/null +++ b/docs/mobile/MOBILE-GESTURES-HAPTICS.md @@ -0,0 +1,93 @@ +# Mobile Gestures & Haptics + +> Last updated: 2026-02-15 + +Current gesture detection and haptic feedback behavior, sourced from code. + +Related docs: [Architecture](MOBILE-ARCHITECTURE.md) · [Feature Matrix](MOBILE-FEATURE-MATRIX.md) · [Action Grid Catalog](MOBILE-ACTION-GRID-CATALOG.md) · [QA Matrix](MOBILE-GESTURE-HAPTIC-QA-MATRIX.md) + +--- + +## Responsive Note (Scaling Implementation) + +- Gesture and haptic semantics are unchanged by the responsive scaling rollout. +- ActionGrid visual size/density now adapts by viewport profile (compact/regular/large + portrait/landscape). +- Profile-to-token mapping is defined in `src/client/mobile/MobileViewportProfile.ts` and applied at runtime by `MobileUI`. +- Tablet support in the mobile path now includes iPad desktop-style user-agent detection in `MobileDetector`. + +--- + +## Gesture Detection + +Source: `src/client/mobile/gestures/GestureDetector.ts` (287 lines) + +| Gesture | Detection | Fires | Action in MobileUI | +| ---------------- | ----------------------------------------------------- | ------------------ | ----------------------------------------------------- | +| Tap | Single touch, `<200ms`, movement `<10px` | `tap` | Tile selection → ActionGrid or spawn | +| Long press | Hold `600ms`, movement `<10px` (cancelled on drag) | `long-press` | Player toast (player tile) or economy overlay (other) | +| Drag | Movement `>10px`, incremental delta | `drag(dx, dy)` | Map pan via `DragEvent` | +| Pinch | 2+ fingers, scale from initial distance | `pinch(scale)` | Map zoom via `ZoomEvent` | +| Edge swipe left | Start `<20px` from left edge, `>50px` right, `<150ms` | `edge-swipe-left` | Toggle Intel sidebar | +| Edge swipe right | Start `<20px` from right edge, `>50px` left, `<150ms` | `edge-swipe-right` | Toggle Research sidebar | + +### Gesture Configuration + +| Constant | Value | +| ------------------------- | -------- | +| `LONG_PRESS_DURATION` | 600ms | +| `MOVEMENT_THRESHOLD` | 10px | +| `EDGE_THRESHOLD` | 20px | +| `PALM_RADIUS_THRESHOLD` | 30px | +| `EDGE_SWIPE_MIN_VELOCITY` | 150 px/s | + +Palm rejection filters touches with `radiusX` or `radiusY` > 30px (iOS contact-area data). + +--- + +## Haptic Feedback + +Source: `src/client/mobile/utils/HapticFeedback.ts` (107 lines) + +Centralized `navigator.vibrate()` wrapper with enable/disable toggle. + +### Patterns + +| Pattern | Duration | Semantic | +| ------------ | -------- | ------------------------------------ | +| `TAP` | 10ms | Button taps, toggles, menu opens | +| `LONG_PRESS` | 50ms | Long-press trigger confirmation | +| `SUCCESS` | 15ms | Build/attack/diplomacy confirmations | +| `WARNING` | 30ms | Warnings, confirmations | +| `ERROR` | 100ms | Invalid actions, locked tiles | + +Also supports `custom(duration)` and `pattern(number[])` for special cases. + +### Current Haptic Usage by Area + +| Area | Trigger | Pattern | +| ---------------------------- | ------------------------------- | --------------- | +| GestureDetector | Tap | custom(10) | +| GestureDetector | Long press | custom(50) | +| GestureDetector | Edge swipe | custom(25) | +| ActionGrid | Valid tile tap | `TAP` | +| ActionGrid | Disabled/locked tile tap | `ERROR` | +| ActionGrid | Backdrop close | `TAP` | +| MobileUI build actions | Successful build | `SUCCESS` | +| MobileUI attack actions | Successful attack intent | `SUCCESS` | +| MobileUI diplomacy actions | Alliance/peace/war confirmation | `SUCCESS` | +| MobileUI diplomacy actions | Chat/emoji/view-player opens | `TAP` | +| MobileUI stack mode | Stack toggle / miss | `TAP`/`ERROR` | +| MobileUI map tap | Stack upgrade success | `SUCCESS` | +| TopBar | Settings/stats tap | `TAP` | +| EconomyOverlay | Lock toggles | `TAP` | +| Intel/Settings/Research bars | Open/tab/player-select | `TAP` | +| Settings panel | Save replay | `SUCCESS` | +| Player toast | Confirm actions | `SUCCESS` | +| Player toast | Dismiss/light actions | `TAP` | +| Alliance notifications | Accept/reject | `SUCCESS`/`TAP` | +| Chat/emoji bubble bar | Focus sender bubble | `TAP` | +| Attack bar | Focus incoming attack bubble | `TAP` | +| Attack bar | Cancel outgoing attack bubble | `TAP` | +| Mobile win modal | Keep/save/download/discord | `TAP` | +| Mobile win modal | Exit / copy replay | `SUCCESS` | +| Lobby flows | No haptic | — | diff --git a/jest.config.ts b/jest.config.ts index 5628b26fe..43a473ff7 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,10 +13,12 @@ export default { "^bad-words$": "/tests/__mocks__/bad-words.ts", }, transform: { - "^.+\\.tsx?$": ["@swc/jest"], + "^.+\\.[tj]sx?$": ["@swc/jest"], }, - transformIgnorePatterns: ["node_modules/(?!(node:)/)"], + transformIgnorePatterns: [ + "node_modules/(?!(node:|lit|lit-html|lit-element|@lit)/)", + ], collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"], coverageThreshold: { global: { diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index fbd598873..16a13397d 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -54,6 +54,8 @@ import { import { createCanvas } from "./Utils"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; import { WinModal } from "./graphics/layers/WinModal"; +import { MobileDetector } from "./mobile/MobileDetector"; +import type { MobileUI } from "./mobile/MobileUI"; import { AVAILABLE_STATS, computeStatValue } from "./stats/StatDefinitions"; import statsStore from "./stats/StatsStore"; import { PerformanceMetrics } from "./utilities/PerformanceMetrics"; @@ -173,6 +175,17 @@ export async function createClientGame( const canvas = createCanvas(); const gameRenderer = createRenderer(canvas, gameView, eventBus); + if (MobileDetector.isMobile()) { + const mobileUI = (window as Window & { __MOBILE_UI__?: MobileUI }) + .__MOBILE_UI__; + if (mobileUI) { + mobileUI.setActive(true); + mobileUI.setTransformHandler(gameRenderer.transformHandler); + mobileUI.initializeGestureDetection(canvas); + mobileUI.updateGameState(gameView); + } + } + console.log( `creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`, ); diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index b5c3a0a47..0b549c296 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -108,15 +108,54 @@ export class HostLobbyModal extends LitElement { @media (max-width: 1024px) { .sp-layout { grid-template-columns: 1fr; + grid-template-rows: auto auto; height: auto; - max-height: 85vh; - overflow-y: auto; + max-height: none; + overflow: visible; } .sp-layout.with-chat { grid-template-columns: 1fr; + grid-template-rows: auto auto auto; } .sp-map-col { - height: 40vh; + height: auto; + min-height: 0; + flex-shrink: 1; + overflow: visible; + } + .sp-scroll-area { + overflow-y: visible; + padding-right: 0; + padding-bottom: 8px; + max-height: none; + } + .sp-settings-col { + height: auto; + min-height: 0; + overflow: visible; + padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); + } + .sp-settings-scroll { + overflow-y: visible; + padding-right: 0; + padding-bottom: 0; + min-height: 0; + max-height: none; + } + .sp-player-area { + max-height: none; + min-height: 0; + margin-top: 12px; + padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); + } + .team-scroll-wrapper { + max-height: none; + overflow-y: visible; + padding-right: 0; + } + .sp-chat-col { + max-height: none; + overflow: visible; } } @@ -636,7 +675,7 @@ export class HostLobbyModal extends LitElement { title=${translateText("host_modal.title")} max-width="1600px" max-height="85vh" - content-overflow="hidden" + content-overflow="auto" >
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index c8a3d993f..bcb64719e 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -546,7 +546,10 @@ export class InputHandler { // Touch parity with the old behavior: short tap opens context menu. if (event.pointerType === "touch") { - this.eventBus.emit(new ContextMenuEvent(upX, upY)); + // Don't open context menu when mobile UI is active + if (!document.body.classList.contains("mobile-ui-enabled")) { + this.eventBus.emit(new ContextMenuEvent(upX, upY)); + } event.preventDefault(); return; } @@ -555,7 +558,10 @@ export class InputHandler { if (!this.userSettings.leftClickOpensMenu() || event.shiftKey) { this.eventBus.emit(new MouseUpEvent(upX, upY)); } else { - this.eventBus.emit(new ContextMenuEvent(upX, upY)); + // Don't open context menu when mobile UI is active + if (!document.body.classList.contains("mobile-ui-enabled")) { + this.eventBus.emit(new ContextMenuEvent(upX, upY)); + } } } @@ -642,7 +648,10 @@ export class InputHandler { // Not in build state → open radial menu event.preventDefault(); - this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); + // Don't open context menu when mobile UI is active + if (!document.body.classList.contains("mobile-ui-enabled")) { + this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); + } } private getPinchDistance(): number { diff --git a/src/client/Main.ts b/src/client/Main.ts index 29f6598c2..eaac2b63c 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -38,6 +38,9 @@ import { OButton } from "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import "./graphics/layers/TutorialToast"; import { isLoggedIn } from "./jwt"; +import { MobileDetector } from "./mobile/MobileDetector"; +import { MobileUI } from "./mobile/MobileUI"; +import { MobileHelpModal } from "./mobile/overlays/MobileHelpModal"; import "./styles.css"; import { applyUiPalette, getUiPalette } from "./theme/UiPaletteLoader"; import { initializeUiScaleFromStorage } from "./uiScale"; @@ -96,6 +99,8 @@ class Client { private menuMusic: HTMLAudioElement | null = null; // Track whether the UI is currently on the main menu (not in-game) private isOnMainMenu = true; + // Mobile UI system (always initialized, activates on mobile devices) + private mobileUI: MobileUI; constructor() {} @@ -121,6 +126,13 @@ class Client { "dark-mode-changed", this.handleDarkModeChangedEvent, ); + + // Always initialize mobile UI (it will auto-detect and activate when needed) + // This ensures it's available even if device emulation is enabled after page load + console.log("[Main] Initializing mobile UI system"); + this.mobileUI = new MobileUI(this.eventBus); + this.mobileUI.setActive(false); // Hidden in lobby by default + // Prepare main menu background music this.setupMenuMusic(); // Sync menu music with persisted mute state and react to changes @@ -255,6 +267,8 @@ class Client { window.addEventListener("beforeunload", () => { console.log("Browser is closing"); + // Clean up mobile UI + this.mobileUI.destroy(); if (this.gameStop !== null) { this.gameStop(); } @@ -287,10 +301,18 @@ class Client { const hlpModal = document.querySelector("help-modal") as HelpModal; hlpModal instanceof HelpModal; + const mobileHelpModal = document.querySelector( + "mobile-help-modal", + ) as MobileHelpModal; + mobileHelpModal instanceof MobileHelpModal; const helpButton = document.getElementById("help-button"); if (helpButton === null) throw new Error("Missing help-button"); helpButton.addEventListener("click", () => { - hlpModal.open(); + if (MobileDetector.isMobile()) { + mobileHelpModal.open(); + } else { + hlpModal.open(); + } }); const rankingsModal = document.querySelector( @@ -542,6 +564,11 @@ class Client { console.log("Closing modals"); // We're leaving the main menu and entering the game this.isOnMainMenu = false; + if (MobileDetector.isMobile()) { + this.mobileUI.setActive(true); + } else { + this.mobileUI.setActive(false); + } // Pause menu music when the game is loading/starting this.menuMusic?.pause(); document.getElementById("settings-button")?.classList.add("hidden"); @@ -558,6 +585,7 @@ class Client { "game-starting-modal", "top-bar", "help-modal", + "mobile-help-modal", "user-setting", "language-modal", "news-modal", @@ -608,6 +636,7 @@ class Client { this.publicLobby.leaveLobby(); // We're back on the main menu; allow music again this.isOnMainMenu = true; + this.mobileUI.setActive(false); document .getElementById("quick-toggle-container") ?.classList.remove("hidden"); diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 06e60229c..ab35ce0bb 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -85,12 +85,35 @@ export class SinglePlayerModal extends LitElement { @media (max-width: 1024px) { .sp-layout { grid-template-columns: 1fr; + grid-template-rows: auto auto; height: auto; - max-height: 80vh; - overflow-y: auto; + max-height: none; + overflow: visible; } .sp-map-col { - height: 40vh; /* Fixed height for maps on mobile */ + height: auto; + min-height: 0; + flex-shrink: 1; + overflow: visible; + } + .sp-scroll-area { + overflow-y: visible; + padding-right: 0; + padding-bottom: 8px; + max-height: none; + } + .sp-settings-col { + height: auto; + min-height: 0; + overflow: visible; + padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); + } + .sp-settings-scroll { + overflow-y: visible; + padding-right: 0; + padding-bottom: 0; + min-height: 0; + max-height: none; } } @@ -391,7 +414,7 @@ export class SinglePlayerModal extends LitElement { title=${translateText("single_modal.title")} max-width="1600px" max-height="85vh" - content-overflow="hidden" + content-overflow="auto" >
diff --git a/src/client/Transport.ts b/src/client/Transport.ts index e7ce1a71c..fe20ff0ac 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -123,6 +123,10 @@ export class SendResearchTreeSelectIntentEvent implements GameEvent { constructor(public readonly techId: string) {} } +export class SendResearchTreeSelectBatchIntentEvent implements GameEvent { + constructor(public readonly techIds: string[]) {} +} + export class SendTargetPlayerIntentEvent implements GameEvent { constructor(public readonly targetID: PlayerID) {} } @@ -362,6 +366,9 @@ export class Transport { this.eventBus.on(SendResearchTreeSelectIntentEvent, (e) => this.onSendResearchTreeSelectIntent(e), ); + this.eventBus.on(SendResearchTreeSelectBatchIntentEvent, (e) => + this.onSendResearchTreeSelectBatchIntent(e), + ); this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e)); @@ -775,14 +782,14 @@ export class Transport { } console.log( - `[Transport] Sending build_unit intent for ${event.unit} at tile ${event.tile}, stackCount=${stackCount}`, + `[Transport] Sending build_unit intent for ${event.unit} at tile ${event.tile}, stackCount=${stackCount ?? 1}`, ); this.sendIntent({ type: "build_unit", clientID: this.lobbyConfig.clientID, unit: event.unit, tile: event.tile, - targetLevel: stackCount, // Renamed semantically but keeping wire format for now + targetLevel: stackCount ?? undefined, // Renamed semantically but keeping wire format for now bomberLevel, }); } @@ -815,6 +822,18 @@ export class Transport { }); } + private onSendResearchTreeSelectBatchIntent( + event: SendResearchTreeSelectBatchIntentEvent, + ) { + for (const techId of event.techIds) { + this.sendIntent({ + type: "research_tree_select", + clientID: this.lobbyConfig.clientID, + techId, + }); + } + } + private onPauseGameEvent(event: PauseGameEvent) { if (!this.isLocal) { console.log(`cannot pause multiplayer games`); diff --git a/src/client/TutorialManager.ts b/src/client/TutorialManager.ts index ecdd0f27e..7c88c70ca 100644 --- a/src/client/TutorialManager.ts +++ b/src/client/TutorialManager.ts @@ -30,6 +30,11 @@ export class TutorialManager { force: boolean = false, highlightTarget?: string, ): void { + // Tutorials are disabled entirely while mobile UI is active + if (this.isMobileUIActive()) { + return; + } + // Check if tutorials are enabled if (!this.settings.tutorialEnabled() && !force) { return; @@ -205,6 +210,11 @@ export class TutorialManager { this.settings.resetTutorialProgress(); } } + + private isMobileUIActive(): boolean { + if (typeof document === "undefined") return false; + return document.body.classList.contains("mobile-ui-enabled"); + } } // Export singleton instance diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index b5a2c888d..acb3c65ad 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -1,4 +1,4 @@ -import { LitElement, html } from "lit"; +import { LitElement, css, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { GameView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; @@ -15,6 +15,50 @@ export class HeadsUpMessage extends LitElement implements Layer { private settings = new UserSettings(); + static styles = css` + .heads-up-container { + position: fixed; + left: 0; + right: 0; + z-index: 1600; + display: flex; + justify-content: center; + padding: 0 16px; + } + + /* Desktop positioning */ + body:not(.mobile-ui-enabled) .heads-up-container { + top: 20px; + } + + /* Mobile positioning - below spawn timer */ + body.mobile-ui-enabled .heads-up-container { + top: calc(env(safe-area-inset-top, 0px) + 54px); + } + + .heads-up-message { + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: white; + border-radius: 8px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + user-select: none; + } + + /* Desktop larger styling */ + body:not(.mobile-ui-enabled) .heads-up-message { + font-size: 20px; + padding: 12px 24px; + border-radius: 12px; + } + `; + createRenderRoot() { return this; } @@ -37,15 +81,22 @@ export class HeadsUpMessage extends LitElement implements Layer { return html``; } + // Inject styles into document head if not already present + if (!document.querySelector("style[data-heads-up-message]")) { + const styleEl = document.createElement("style"); + styleEl.setAttribute("data-heads-up-message", ""); + styleEl.textContent = HeadsUpMessage.styles.cssText; + document.head.appendChild(styleEl); + } + return html` -
e.preventDefault()} - > - ${translateText("heads_up_message.choose_spawn")} +
+
e.preventDefault()} + > + ${translateText("heads_up_message.choose_spawn")} +
`; } diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index e0496ba1f..28a5e5c81 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -374,6 +374,11 @@ export class RadialMenu implements Layer { } private onContextMenu(event: ContextMenuEvent) { + // Don't open radial menu when mobile UI is active + if (document.body.classList.contains("mobile-ui-enabled")) { + return; + } + if (this.lastClosed + 200 > new Date().getTime()) return; if (this.isVisible) { this.hideRadialMenu(); diff --git a/src/client/graphics/layers/SpawnTimer.ts b/src/client/graphics/layers/SpawnTimer.ts index 742f12dde..7201d39eb 100644 --- a/src/client/graphics/layers/SpawnTimer.ts +++ b/src/client/graphics/layers/SpawnTimer.ts @@ -1,5 +1,6 @@ import { GameMode, Team } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; +import { MobileDetector } from "../../mobile/MobileDetector"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; @@ -60,6 +61,7 @@ export class SpawnTimer implements Layer { const barHeight = 10; const barWidth = this.transformHandler.width(); + const barY = this.getBarYOffset(); let x = 0; let filledRatio = 0; @@ -68,12 +70,18 @@ export class SpawnTimer implements Layer { const segmentWidth = barWidth * ratio; context.fillStyle = this.colors[i]; - context.fillRect(x, 0, segmentWidth, barHeight); + context.fillRect(x, barY, segmentWidth, barHeight); x += segmentWidth; filledRatio += ratio; } } + + private getBarYOffset(): number { + if (!document.body.classList.contains("mobile-ui-enabled")) return 0; + const safeAreaTop = MobileDetector.getSafeAreaInsets().top; + return safeAreaTop + 44; + } } function sumIterator(values: MapIterator) { diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 6ea28fb0a..d5641fd12 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -573,9 +573,12 @@ export class StructureLayer implements Layer { if (this.hoveredStructure && this.hoveredStructure.id() === unit.id()) { this.updateLabels(); } - // If stack count changed and we're in upgrade mode, re-render texture so highlight state updates + if (prevLevel !== serverStackCount && this.isMobileUIEnabled()) { + this.updateLabels(); + this.shouldRedraw = true; + } + // If stack count changed, refresh texture when upgrade highlights may change if (prevLevel !== serverStackCount && this.upgradeMode) { - // Refresh texture so highlight state updates based on new level const target = this.renders.find((r) => r.unit.id() === unit.id()); if (target) { target.pixiSprite.texture = this.createTexture(unit); @@ -878,6 +881,13 @@ export class StructureLayer implements Layer { ctx.fill(); } + private isMobileUIEnabled(): boolean { + return ( + typeof document !== "undefined" && + document.body.classList.contains("mobile-ui-enabled") + ); + } + private shouldHighlight(unit: UnitView): boolean { if (!this.upgradeMode) return false; const me = this.game.myPlayer(); @@ -1262,6 +1272,77 @@ export class StructureLayer implements Layer { } } + if (this.isMobileUIEnabled()) { + const mobileBadgeStyleCache = new Map(); + for (const r of this.renders) { + const u = r.unit; + if (!u.isActive()) continue; + if (u.type() === UnitType.Construction) continue; + const stackCount = u.stackCount(); + if (stackCount <= 1) continue; + + const tile = u.tile(); + const worldX = this.game.x(tile); + const worldY = this.game.y(tile); + const screenPos = this.transformHandler.worldToScreenCoordinates( + new Cell(worldX, worldY), + ); + const shape: BgShape = + STRUCTURE_BG_SHAPES[u.type() as UnitType] ?? "circle"; + const iconDim = ICON_SIZES[shape] ?? ICON_SIZE; + const iconScale = this.iconScreenScale(); + const labelScale = iconScale * ICON_TEXTURE_QUALITY; + + const fontSize = Math.round(iconDim * labelScale * 0.24); + const baseColorStr = this.relationshipColorHexStr(u); + const baseRaw = baseColorStr.replace(/^#/, ""); + const baseFill = parseInt(baseRaw, 16); + const styleKey = `${fontSize}:${baseFill}`; + let style = mobileBadgeStyleCache.get(styleKey); + if (!style) { + style = new PIXI.TextStyle({ + fontFamily: + "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + fontSize, + fontWeight: "600", + fill: baseFill, + align: "center", + }); + mobileBadgeStyleCache.set(styleKey, style); + } + const t = new PIXI.Text(String(stackCount), style); + + const paddingX = Math.max(2, Math.round(fontSize * 0.45)); + const paddingY = Math.max(1, Math.round(fontSize * 0.3)); + const badgeW = t.width + paddingX * 2; + const badgeH = t.height + paddingY * 2; + const badgeX = Math.round(screenPos.x - badgeW / 2); + const badgeY = Math.round( + screenPos.y - + (iconDim * labelScale) / 2 - + badgeH - + Math.round(1 * labelScale), + ); + + const bg = new PIXI.Graphics(); + bg.roundRect( + badgeX, + badgeY, + badgeW, + badgeH, + Math.min(14, fontSize), + ).fill({ + color: 0x000000, + alpha: 0.55, + }); + this.labelContainer.addChild(bg); + + t.x = badgeX + Math.round((badgeW - t.width) / 2); + t.y = badgeY + Math.round((badgeH - t.height) / 2); + this.labelContainer.addChild(t); + } + } + // 2) In upgrade mode, show UPGRADE PRICE BELOW for all upgradeable structures owned by me if (this.upgradeMode) { const me = this.game.myPlayer(); diff --git a/src/client/graphics/layers/TechUnlockNotification.ts b/src/client/graphics/layers/TechUnlockNotification.ts index 6e4071d46..7cb1d7e17 100644 --- a/src/client/graphics/layers/TechUnlockNotification.ts +++ b/src/client/graphics/layers/TechUnlockNotification.ts @@ -55,6 +55,9 @@ export class TechUnlockNotification extends LitElement implements Layer { } notificationQueue.onShow((notification) => { if (notification.type === "tech") { + if (document.body.classList.contains("mobile-ui-enabled")) { + return; + } this.showTechNotification(notification.payload); } }); diff --git a/src/client/graphics/layers/TutorialToast.ts b/src/client/graphics/layers/TutorialToast.ts index c804cfeeb..c4fbbf978 100644 --- a/src/client/graphics/layers/TutorialToast.ts +++ b/src/client/graphics/layers/TutorialToast.ts @@ -49,6 +49,9 @@ export class TutorialToast extends LitElement implements Layer { notificationQueue.onShow((notification) => { if (notification.type === "tutorial") { + if (document.body.classList.contains("mobile-ui-enabled")) { + return; + } this.showTutorialTip(notification.payload); } }); diff --git a/src/client/graphics/layers/TutorialTriggers.ts b/src/client/graphics/layers/TutorialTriggers.ts index 0553cc5e9..6c8658500 100644 --- a/src/client/graphics/layers/TutorialTriggers.ts +++ b/src/client/graphics/layers/TutorialTriggers.ts @@ -2,6 +2,9 @@ import { EventBus } from "../../../core/EventBus"; import { PlayerType, UnitType } from "../../../core/game/Game"; import { GameUpdateType, PlayerUpdate } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; +import { MobileDetector } from "../../mobile/MobileDetector"; +import "../../mobile/overlays/MobileResearchPriorityModal"; +import type { MobileResearchPriorityModal } from "../../mobile/overlays/MobileResearchPriorityModal"; import "../../ResearchPriorityModal"; import type { ResearchPriorityModal } from "../../ResearchPriorityModal"; import { tutorialManager } from "../../TutorialManager"; @@ -302,14 +305,36 @@ export class TutorialTriggers implements Layer { // Small delay to let the game UI settle after spawn phase transition setTimeout(() => { - const modal = document.querySelector( + const shouldUseMobileModal = + document.body.classList.contains("mobile-ui-enabled") || + MobileDetector.isMobile(); + + if (shouldUseMobileModal) { + let mobileModal = document.querySelector( + "mobile-research-priority-modal", + ) as MobileResearchPriorityModal | null; + + if (!mobileModal) { + mobileModal = document.createElement( + "mobile-research-priority-modal", + ) as MobileResearchPriorityModal; + document.body.appendChild(mobileModal); + } + + mobileModal.game = this.game; + mobileModal.eventBus = this.eventBus; + mobileModal.open(); + return; + } + + const desktopModal = document.querySelector( "research-priority-modal", ) as ResearchPriorityModal | null; - if (modal) { - modal.game = this.game; - modal.eventBus = this.eventBus; - modal.open(); + if (desktopModal) { + desktopModal.game = this.game; + desktopModal.eventBus = this.eventBus; + desktopModal.open(); } }, 500); } diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index e783a1dd1..73255d71c 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -5,6 +5,7 @@ import { EventBus } from "../../../core/EventBus"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; import { GameRecord } from "../../../core/Schemas"; +import { HapticFeedback } from "../../mobile/utils/HapticFeedback"; import { encodeReplay, isCompressionSupported } from "../../ReplayCodec"; import { SendWinnerEvent } from "../../Transport"; import { Layer } from "./Layer"; @@ -61,11 +62,24 @@ export class WinModal extends LitElement implements Layer { backdrop-filter: blur(5px); color: var(--ui-text-default); width: 350px; + max-width: calc(100vw - 24px); transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out; } + .win-modal.mobile { + left: 12px; + right: 12px; + top: auto; + bottom: calc(env(safe-area-inset-bottom, 0px) + 12px); + width: auto; + max-width: none; + transform: none; + border-radius: 12px; + padding: 18px; + } + .win-modal.visible { display: block; animation: fadeIn 0.3s ease-out; @@ -103,6 +117,10 @@ export class WinModal extends LitElement implements Layer { gap: 10px; } + .win-modal.mobile .button-container { + flex-direction: column; + } + .win-modal button { flex: 1; padding: 12px; @@ -117,6 +135,11 @@ export class WinModal extends LitElement implements Layer { transform 0.1s ease; } + .win-modal.mobile button { + min-height: 44px; + font-size: 15px; + } + .win-modal button:hover { background: var(--ui-primary-hover); transform: translateY(-1px); @@ -145,18 +168,28 @@ export class WinModal extends LitElement implements Layer { @media (max-width: 768px) { .win-modal { - width: 90%; - max-width: 300px; + width: calc(100vw - 24px); + max-width: none; padding: 20px; + left: 12px; + right: 12px; + top: auto; + bottom: calc(env(safe-area-inset-bottom, 0px) + 12px); + transform: none; + border-radius: 12px; } .win-modal h2 { - font-size: 26px; + font-size: 22px; } .win-modal button { - padding: 10px; - font-size: 14px; + min-height: 44px; + font-size: 15px; + } + + .button-container { + flex-direction: column; } } @@ -238,14 +271,22 @@ export class WinModal extends LitElement implements Layer { } render() { + const isMobileLayout = + typeof document !== "undefined" && + document.body.classList.contains("mobile-ui-enabled"); + return html` -
+

${this._title || ""}

-
@@ -253,7 +294,7 @@ export class WinModal extends LitElement implements Layer { ${this.gameRecord ? html`
-
@@ -268,12 +309,12 @@ export class WinModal extends LitElement implements Layer { ? html`

${translateText("win_modal.encoding_replay")}

` : html`
- -
@@ -284,8 +325,7 @@ export class WinModal extends LitElement implements Layer {