diff --git a/CLAUDE.md b/CLAUDE.md index cc0350f..3c8242f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,243 +1,285 @@ -# MyGamePlayable — контекст проєкту +# MyGamePlayable — Project Instructions -Playable ad на three.js + пропрієтарний SDK HitPlay. Цей файл вантажиться в -кожну сесію — тримай його **інструкцією, а не щоденником**. +## Context rules + +This file contains persistent project rules and non-obvious constraints, not history. + +**Keep this file under 200 lines.** + +Add only: + +* rules that affect future implementation decisions; +* non-obvious SDK/library/toolchain pitfalls; +* structural conventions; +* public SDK APIs that are not obvious from code. + +Do not add: + +* development history, dates, or completed tasks; +* test/build results; +* current tuning values; +* information obvious from nearby code; +* temporary project state. + +If knowledge is specific to one subsystem, prefer `docs/.md`. +If it explains why a decision was made, put the detailed history in `docs/worklog/`. + +When a pitfall is fixed by a utility, replace the explanation with one short rule +pointing to that utility. + +Worklog files: +`docs/worklog/NN-short-english-name.md` +Update `docs/worklog/README.md` when adding one. --- -## ПРАВИЛА ВЕДЕННЯ ЦЬОГО ФАЙЛУ +## Stack -**Стеля — 300 рядків.** Перевищив — перенеси найстаріше й уже усталене в -`docs/worklog/`. Історія розробки живе там, не тут. +* Three.js `0.185` via `@hitplay/playable_template` + `@hitplay/ads_common`. +* Physics: `cannon-es`. +* Tweens: `@tweenjs/tween.js` via SDK `TweenC`. +* VFX: `three.quarks`, designer `.json` prefabs. +* Audio: `howler`. +* Input: `nipplejs`. +* Debug UI: `lil-gui`. +* Analytics: `bytebrew-web-sdk`. +* Toolchain: Vite 6 + TypeScript 7. -### Що ДОДАВАТИ сюди +### Runtime rules -- правило або конвенцію, що змінює майбутні рішення («використовуй утиліту X, - не пиши формулу заново»); -- граблі, які **вкусять ще раз** (поведінка SDK/бібліотеки/тулчейна, а не - разовий баг у власному коді); -- зміну структури: новий контролер, нова утиліта, новий конфіг-параметр; -- нову команду або публічний API SDK, який не очевидний з коду. - -### Що НЕ додавати - -- «сьогодні зробив…», «Day N», хронологію, цитати з чату; -- «перевірено в браузері / білд пройшов» — це не знання, це подія; -- підібрані числа (`CAMERA_OFFSET`, тривалості, відсотки) — вони живуть у коді - й там же змінюються; сюди — тільки *де* важіль, не його поточне значення; -- переказ того, що й так видно з коду за 10 секунд. - -### Куди що писати - -| Знання | Місце | -|---|---| -| Пояснює один файл/рядок | коментар у коді | -| Перетинає кілька файлів або є політикою проєкту | `CLAUDE.md` | -| «Чому ми дійшли саме до цього» | `docs/worklog/` | - -### Коли граблі усунені в корені - -Якщо гочтя закрита утилітою — **заміни розповідь одним рядком** з посиланням на -утиліту. Приклад: `.chain()`-баг → `src/utils/tweenChain.ts`. Довга версія -лишається в `docs/worklog/02-day6-tween-animations.md`. - -### Новий запис у worklog - -`docs/worklog/NN-коротка-англійська-назва.md`, `NN` — наступний номер. -Додай рядок у `docs/worklog/README.md`. Один H1 на файл. +* `UpdateController.Instance.onUpdate` is the only update event. `delta` is seconds. +* SDK calls `world.fixedStep()` every frame via `Physics_internal.update`. + Never call `step()` / `fixedStep()` manually. +* Physics initialization: `Physics_internal.init(...)` in `beforeResourcesLoadedCb.ts`. +* Call `TweenC.init()` exactly once. It is already called in + `beforeResourcesLoadedCb.ts`. +* Use `ThreeC.removeFromScene(obj)` to remove spawned meshes. --- -## Стек +## Commands -- **three.js `0.185`** (НЕ Babylon), обгорнутий у SDK `@hitplay/playable_template` - + `@hitplay/ads_common` (приватні пакети HitPlay). -- Фізика: `cannon-es`. **SDK сам кличе `world.fixedStep()` щокадру** - (`Physics_internal.update`) — свій `step()`/`fixedStep()` НЕ викликати. - Ініт — `Physics_internal.init(...)` у `beforeResourcesLoadedCb.ts`. -- Твіни: `@tweenjs/tween.js` через SDK-обгортку `TweenC`. **`TweenC.init()` - треба покликати один раз** (є в `beforeResourcesLoadedCb.ts`), інакше - `TweenC.add()` тихо нічого не анімує. -- VFX: `three.quarks` — дизайнерські префаби з quarks.art (`.json`). -- Звук `howler`, ввід `nipplejs`, дебаг-панель `lil-gui`, аналітика `bytebrew-web-sdk`. -- Тулчейн: Vite 6 + TypeScript 7. +| Command | Purpose | +| ---------------- | --------------------------------------------------------- | +| `npm start` | Vite dev server | +| `npm run export` | Single-file HTML → `export/output_export_browser_en.html` | +| `npm run build` | 24ADS platform zip → `zip/` | +| `npm run update` | Update HitPlay SDK packages | -`UpdateController.Instance.onUpdate` — **єдиний** update-івент, `delta` в секундах. -`ThreeC.removeFromScene(obj)` — видалення спавнених мешів. +Verify changes with `npx vite build`. -## Команди +Use `stats.html` when investigating bundle-size growth. -| Команда | Що робить | -|---|---| -| `npm start` | vite dev-сервер | -| `npm run export` | single-file HTML → `export/output_export_browser_en.html` | -| `npm run build` | zip для платформи 24ADS → `zip/` | -| `npm run update` | оновити `@hitplay/ads_common` + `playable_template` | +--- -Перевірка змін — `npx vite build` (у проєкті прийнято верифікувати саме білдом). -`stats.html` — rollup-visualizer, дивитись коли розпухає бандл. +## Structure -## Структура - -``` -src/index.ts точка входу, Template.initConfig({...}) -src/controllers/*C.ts одна система = один файл, суфікс C, плоский список -src/configUIParams/ категорії конфігу, які рендерить платформа 24ADS -src/templateConfig/*Cb.ts лайфсайкл-колбеки SDK -src/utils/ спільні формули (див. нижче) -src/resources/ meshes/images/fonts/vfx/sounds -src/css/ main.css, ui.css, health-bar.css -docs/worklog/ архів історії розробки +```text +src/index.ts entry point / Template.initConfig() +src/controllers/*C.ts one system per file, flat structure +src/configUIParams/ 24ADS config categories +src/templateConfig/*Cb.ts SDK lifecycle callbacks +src/utils/ shared utilities +src/resources/ meshes/images/fonts/VFX/sounds +src/css/ main.css, ui.css, health-bar.css +docs/worklog/ development history ``` -Підпапки в `controllers/` **свідомо не зроблені** — SDK сам плаский, вигоди нуль. +Do not create controller subdirectories. The SDK uses a flat structure and +there is no benefit in mirroring artificial subfolders. -### Утиліти — використовувати, не переписувати +--- -- `perspectiveScale.ts` — компенсація перспективи при польоті до/від камери. -- `screenWorldProjection.ts` — world↔screen в обидва боки. -- `tweenChain.ts` — коректна реєстрація ланцюжка твінів. -- `flashOverlayMaterial.ts` — адитивний флеш-оверлей. +## Utilities — reuse, don't reimplement -### DI-патерн +* `perspectiveScale.ts` — perspective compensation for flight toward/away from camera. +* `screenWorldProjection.ts` — world↔screen projection. +* `tweenChain.ts` — safe tween-chain registration. +* `flashOverlayMaterial.ts` — additive hit-flash overlay. +* `pickupFlight.ts` — shared drop → scatter → bounce → fly-to-target animation, + driven by both `ResourceC` (wood) and `ZombiePartC` (zombie-part chunks). + Never write a second one. -Контролери не імпортують одне одного напряму, коли це створить цикл — інжектиться -геттер/колбек, а зв'язує `TestSceneC`: -`ResourceC.setFlyTarget()`, `HudC.setBalanceGetter()`, -`CameraFollowC.setFacingTargetGetter()`, `BreakablePropC.onHit/onDestroyed`. +`HealthBarC` is shared by crates, zombies, and player. Do not create another +health-bar implementation. -## Конфіг платформи ↔ код +Two weapon progressions (bat level from wood, pistol level from zombie-part +chunks), each with its own fixed Pay Zone (`PayZoneC`, instance-based — no +relocation), and crate respawn: see `docs/progression.md`. -Платформа 24ADS рендерить ліву панель із категорій. Частина — наші -(`src/configUIParams/`), частина — стандартні від SDK. +`HealthBarOptions.variant` controls colors via `health-bar.css`. +`follow` re-samples the anchor every frame for moving owners. +Health bars appear on first hit and hide after `HIDE_DELAY_S`; full-HP entities +do not show a bar. -| Вкладка на платформі | Джерело | -|---|---| -| Analytics params | `analytics.ts` | -| Install Banner Settings | `installBanner.ts` | -| **Scene settings** | `globalSettings.ts` (id категорії — `global`) | -| Gameplay tuning | `gameplay.ts` | -| Audio Settings | `sounds.ts` | -| Screen Orientation | `orientation.ts` | -| Filter scene, Redirect Settings, Loader Settings, Font Settings | SDK, не наш код | +--- -Читати значення в коді — `Template.getCategory("")[""]` -(приклад — `DebugConfigC.init()`). +## Dependency injection -**Scene settings** (`globalSettings.ts`): `light_intensity`, `light_color`, -`camera_fov_p/l`, `camera_position_p/l`, `camera_rotation_p/l`. +Controllers must not directly import each other when that would create a cycle. +Inject getters/callbacks instead and wire them from `TestSceneC`. -**Формат `configUIParams` ≠ формат рантайму.** У визначенні кожна вісь масивного -параметра — це `[min, max, default]`; у грі `Template.getCategory()` віддає -готовий `[x, y, z]`. Не переплутати: `[-20, 70, -6]` — це не координати. +Existing DI examples: -**Камера: рівно один писар на `camera.rotation` — `CameraFollowC`.** -`camera_position_*` — це офсет **відносно гравця** (не абсолютна позиція, камера -слідкує), `camera_rotation_*` — фіксований кут у градусах, `camera_fov_*` — FOV. -Розподіл власності: FOV застосовує `CameraC.setCamera()`, риг — -`CameraFollowC.applyRigFromConfig()`. Не повертати position/rotation у -`CameraC.setCamera()`: він перезапускається на кожну зміну будь-якого `camera_*` -поля, і два писарі на кут ламали камеру назавжди до кінця сесії. -`CAMERA_OFFSET` у `TestSceneC` лишився тільки як фолбек. +* `ResourceC.setFlyTarget()` +* `HudC.setBalanceGetter()` +* `CameraFollowC.setFacingTargetGetter()` +* `BreakablePropC.onHit / onDestroyed / onRespawned` +* `ZombieC.init(bounds, getPlayerPosition, damagePlayer, isPlayerAlive)` +* `ZombieInvasionHudC.setOnComplete()` +* `ZombiePartC.setFlyTarget()` / `PistolHudC.setLevelGetter()` -**Gameplay tuning** (`gameplay.ts`) дублює те, що дев-панель `DebugConfigC` -(клавіша `C`, тільки lil-gui, не потрапляє в білд) показує локально. Панель -сідить свої дефолти з цієї категорії при старті — один раз, без live-sync. +`ZombieInvasionHudC.setOnComplete()` must be called **before** `init()`; +`init()` immediately starts its countdown. -## Живі граблі +--- -Кожна вже кусала. Довгі розбори — в `docs/worklog/`. +## Platform config -**glTF-завантажувач стирає крапки з імен нодів.** У сирому GLB `UI_Wood.001`, у -живій сцені — `UI_Wood001`. `getObjectByName()` тихо повертає `undefined`, гард -`if (node)` завжди хибний, код «виглядає правильним». Перевіряти реальне ім'я -через `scene.traverse`, не через GLB JSON. +24ADS renders config categories from `src/configUIParams/` plus SDK categories. -**Матеріали шаряться між усіма нодами з GLB.** Хочеш змінити матеріал одного -об'єкта — клонуй його один раз при білді, інакше зачепиш усі 18 крейтів. +| Platform tab | Source | +| --------------------------------------- | ------------------------------ | +| Analytics params | `analytics.ts` | +| Install Banner Settings | `installBanner.ts` | +| Scene settings | `globalSettings.ts` (`global`) | +| Gameplay tuning | `gameplay.ts` | +| Audio Settings | `sounds.ts` | +| Screen Orientation | `orientation.ts` | +| Filter scene / Redirect / Loader / Font | SDK | -**`material.color` на `MeshBasicMaterial` не дає флешу.** Матеріал unlit, колір -множиться на текстуру, дефолт уже білий — лерп «білий → білий» невидимий. -Для hit-flash — окремий адитивний оверлей-меш (`flashOverlayMaterial.ts`). +Read runtime values with: -**«Чи гравець у зоні X» — тільки через `Box3().setFromObject(node)`.** Радіус на -око зі скріншота не збігається з реальним футпринтом (і центр може бути -несиметричний відносно пивота). - -**Перспектива сама змінює видимий розмір.** Об'єкт, що летить до камери, -«росте» без жодного дотику до `.scale`. Треба явно компенсувати — -`perspectiveScale.ts`. - -**Ціль польоту пере-семплювати щокадру.** Камера рухається разом з гравцем; -взята один раз позиція HUD-анкера застигає й політ промахується. - -**`tween.onComplete` зрідка не спрацьовує** (причина всередині tween.js не -знайдена) — для критичного клінапу дублювати `setTimeout` на тривалість + запас. -`removeFromScene` двічі — нешкідливо. - -**screen↔world рахувати від `renderer.domElement.getBoundingClientRect()`**, не -від `window.innerWidth/Height` — SDK рендерить у letterboxed 9:16 бокс. - -**nipplejs `force` не обмежений радіусом зони** — за краєм кільця росте далі, -хоч точка вже запінена. Клампити один раз при записі, не в кожному `calc()`. - -**`Math.atan2` і CSS `conic-gradient` мають різний нуль** (схід vs північ) — -різниця рівно 90°. - -**Дві CSS-анімації на одному елементі не живуть разом** — перемикання класу, -що чіпає `animation-name`, перезапускає анімацію. Розділяти на вкладені div. - -**Анімувати RGB, не альфу**, якщо треба «згасання до базового кольору» — інакше -на нулі отримаєш прозору дірку замість кольору. - -**У CSS — `--lvh`/`--lvw`, не сирі `vh`/`vw`/`vmin`** (ротація/адаптивність). - -**Експорт — це gzip-блоб, а не текст. НЕ грепати `export/*.html` напряму.** -`npm run export` пакує весь бандл у base64+gzip (`decompressString("H4sIA...")`, -розпаковується `pako` в рантаймі). Будь-який пошук по цьому файлу дасть **0 -збігів** — для всього, включно з класами самого SDK. Прочитати цей нуль як -«purgecss вирізав наш CSS» — легка й дуже дорога помилка (перевірено: після -розпаковки всі кастомні класи й `@font-face` на місці, кількість збігається з -`vite build`). Перевіряти тільки після gunzip. - -**purgecss справді вирізає `.bannerButton`** — але саме його, точково (клас -призначається всередині мінифікованого SDK-бандла). У розпакованому експорті: -`.bannerButton{` — 0, `.bannerLogo{` — 1, `.bannerButtonText{` — 1. Не писати -CSS-правила під `.bannerButton`, це мертвий код. На решту класів не поширюється. - -**`Template.setUpFont()` працює тільки коли `!release`** — тобто шрифт через -`customFont.ts`/`formFontFamily` реєструється для **прев'ю платформи**, а не для -експорту. У реальному білді шрифт малює наш власний `@font-face` з `main.css` -(інлайн base64). Це два незалежні механізми на дві різні поверхні — потрібні -обидва, і поломка одного не видна з іншого. - -**Інлайн ассетів:** картинки — `ConvertToBase64WhenRelease("./file.webp")`, і -викликати обов'язково **з файлу в тій самій директорії**, що й ассет. Шрифти — -нічого не треба, `assetsInlineLimit` у конфігу SDK інлайнить `url()` у CSS сам. - -## Ігрові дані - -`ZombiePunk_Map.glb`, група **`Lootable`** → ~18 × `Wooden_Box_XXX`: +```ts +Template.getCategory("")[""] ``` -Wooden_Box_XXX - BoxCollider.NNN obstacle-бокс - Wooden_Box_States_XXX - Wooden_Box_XXX_S1..S3 стейджі пошкодження, не в усіх є всі три -``` -Крейти можуть стартувати вже пошкодженими (тільки S2/S3) — `BreakablePropC` -рахує стартовий HP з реальних номерів `_SN`, не з припущення про 3 стейджі. -`UI_Wood` — реальна іконка ресурсу (текстура `Icon_Wood`), клонується на кожен -спавн. `UI_Interactive_Zone_02` — Pay Zone. `UI_Tool_Zone`, `UI_Wood001`, група -`UI` — приховані як зайва декорація. +### Config format -## Поточний стан +`configUIParams` array parameters use `[min, max, default]` per axis. +Runtime `Template.getCategory()` returns the actual `[x, y, z]` value. + +Never interpret `[min, max, default]` as runtime coordinates. + +### Camera ownership + +There must be exactly one writer of `camera.rotation`: `CameraFollowC`. + +* `CameraC.setCamera()` owns FOV only. +* `CameraFollowC.applyRigFromConfig()` owns camera position/rotation. +* `camera_position_*` is a player-relative offset, not an absolute position. +* `camera_rotation_*` is a fixed angle in degrees. +* `camera_fov_*` is FOV. +* Do not write camera position/rotation from `CameraC.setCamera()`. + `setCamera()` can run again when any `camera_*` config changes. +* `TestSceneC.CAMERA_OFFSET` is fallback only. + +### Gameplay tuning + +`gameplay.ts` is the source for platform tuning defaults. +`DebugConfigC` mirrors these values locally in lil-gui (`C` key, dev-only). +The debug panel copies defaults once at startup; it does not live-sync. + +--- + +## Critical SDK / runtime pitfalls + +* GLTF loader removes dots from node names: + `UI_Wood.001` → `UI_Wood001`. + Inspect runtime names with `scene.traverse()`, not GLB JSON. + +* GLB materials are shared between nodes. Clone a material before modifying + one instance. + +* `MeshBasicMaterial.color` cannot produce a visible white→white hit flash. + Use `src/utils/flashOverlayMaterial.ts`. + +* Zone checks must use `new Box3().setFromObject(node)`, not visual/radius estimates. + +* Perspective changes apparent object size. Use `perspectiveScale.ts` when compensating + for flight toward/away from the camera. + +* Flight targets must be re-sampled every frame because the camera follows the player. + +* Do not rely solely on tween `onComplete` for critical cleanup. + Use a timeout fallback with a small safety margin. + `removeFromScene()` is safe to call twice. + +* screen↔world projection must use + `renderer.domElement.getBoundingClientRect()`, never `window.innerWidth/Height`. + SDK uses a letterboxed 9:16 viewport. + +* `nipplejs.force` can exceed the visual joystick radius. + Clamp it when storing input, not inside every calculation. + +* `Math.atan2` and CSS `conic-gradient` use different zero directions. + Account for the 90° offset. + +* Do not stack independent CSS animations by changing `animation-name` on the + same element. Use nested elements. + +* To fade to a base color, animate RGB rather than alpha. + +* Use `--lvh` / `--lvw`; avoid raw `vh` / `vw` / `vmin`. + +--- + +## Export / CSS / fonts + +* Export HTML is gzip+base64, not searchable source. + Never grep `export/*.html` to verify bundled content. + Inspect the decompressed payload instead. + +* `.bannerButton` is purged from the release export. + Do not add CSS targeting it. + `.bannerLogo` and `.bannerButtonText` remain. + +* `Template.setUpFont()` works only when `!release`. + It is for platform preview. + Release uses our own `@font-face` from `main.css`. + +* Release images must use: + `ConvertToBase64WhenRelease("./file.webp")` + from the same directory as the asset. + +* CSS fonts are inlined automatically by the configured `assetsInlineLimit`. + +--- + +## Asset rules + +`ZombiePunk_Map.glb`: + +* `Lootable` contains approximately 18 `Wooden_Box_XXX` crates. +* Crates may start at S2/S3. `BreakablePropC` must derive initial HP from + actual `_SN` nodes; never assume three stages. +* `UI_Wood` is the resource icon and must be cloned per spawn. +* `UI_Interactive_Zone_02` is the pistol's Pay Zone marker (halved in scale); + the bat's own Pay Zone is a runtime clone of it, not a second authored node + — see `TestSceneC.createPayZoneNodes`. +* `UI_Tool_Zone`, `UI_Wood001`, and `UI` are hidden decorative nodes. + +`ZombiePunk_Character.glb`: + +* Existing clips include `Idle`, `SlowWalk`, `Loot`, `WeaponTakeOut`, + `PistolShoot`, `WeaponPutDown`, `Death`, + `PistolBack/Forward/Left/Right`. +* Do not invent character states before checking existing clips. +* `Character_Pistol` is a skinned mesh (`skin:0`), not a bone prop. + Skeleton animation moves it with the hand. +* Bat uses `Tool_1` / `Tool_2` visibility switching. +* `Bullet` is a separate hidden rig. + +--- + +## Change discipline + +* Prefer minimal, focused changes. +* Do not refactor unrelated code. +* Reuse existing utilities and controllers before creating new ones. +* Preserve the existing DI pattern. +* Before adding a new SDK workaround, check `src/utils/` and this file. +* When a new persistent rule is discovered, update this file only if it affects + future decisions; otherwise document it in the appropriate source file or + `docs/worklog/`. -- Working tree брудний, багато незакомічених змін. -- `DebugConfigC.ts` і `configUIParams/gameplay.ts` — нові, ще не в git. -- **Відомі борги** (відкладені свідомо, за рішенням користувача): - - `ui.css` / `health-bar.css` — досі на `vh`, без landscape-медіазапиту; - - VFX-емітер `StretchSmoke` у `VFX_Lootable_Hit` вилітає далеко від крейта - (дизайнерський ассет тюнили під інший масштаб сцени) — рішення за - користувачем: лишити / притлумити / прибрати емітер; - - Destroy-VFX підключений, але візуально не підтверджений. diff --git a/docs/progression.md b/docs/progression.md new file mode 100644 index 0000000..0be28af --- /dev/null +++ b/docs/progression.md @@ -0,0 +1,76 @@ +# Progression + +Two independent weapon progressions run at the same time, each fed by its own +resource and its own fixed Pay Zone. They are not related and must not be +merged into one counter. + +## Bat level — `BatLevelC` + +* Currency: wood, dropped by crates (`ResourceC`, spawned by `BreakablePropC`) + and delivered into the bat's own Pay Zone. +* Displayed by `WeaponHudC` (the bottom-left octagon). +* Added to the bat's damage per crate hit (`PlayerC.updateCombat` -> + `BreakablePropC.hit`), on top of the debug panel's own base `hit_damage`. +* Visual payoff: that Pay Zone's reward pedestal shows a floating bat + (`PlayerC.createBatDisplay`). + +## Pistol level — `PistolLevelC` + +* Currency: green zombie-part chunks, dropped by dying zombies + (`ZombiePartC`, spawned by `ZombieC`) and delivered into the pistol's own + Pay Zone. +* Displayed by `PistolHudC`, stacked directly below `WeaponHudC`'s octagon. +* Added to the pistol's base damage per shot (see `PlayerC.fireAt`). +* Visual payoff: `WeaponAuraC`, an additive `Sprite` glow on the drawn pistol + that grows with the level and flattens out at its own `MAX_EFFECT_LEVEL`. + A `Sprite` specifically because `Character_Pistol` is a `SkinnedMesh` (a + same-geometry overlay would need re-binding to the skeleton) and because a + light would do nothing against unlit materials. +* That Pay Zone's reward pedestal shows a floating pistol + (`PlayerC.createPistolDisplay`). + +Both `createBatDisplay`/`createPistolDisplay` share one private helper in +`PlayerC` (`createWeaponDisplay`) that rebuilds a static, unskinned copy of +whichever prop's geometry — never a `.clone()` of the live mesh, since +`Character_Pistol` is skinned and would otherwise stay bound to the +character's own skeleton and follow the player's hand around the map. + +## Pay Zones + +Both Pay Zones are `PayZoneC` instances, constructed in `TestSceneC` with the +resource/level pair they each belong to (`getCollectedCount`, +`createDepositVisual`, `onFilled`, `buildRewardIcon`). Filling one raises only +its own level, then it shrinks away, pauses, and grows back **in the same +spot** — there is no relocation and no randomness. + +* The map ships one interactive-zone marker (`UI_Interactive_Zone_02`). It is + scaled down by `PAY_ZONE_SCALE` and used as-is for the pistol zone; the bat + zone is a plain clone of it, offset alongside by its own width plus + `PAY_ZONE_GAP` (see `TestSceneC.createPayZoneNodes`). +* `PayZoneC.deposited` is a **lifetime** total per instance and must never be + reset — each HUD's displayed balance is `collected - deposited`, so + zeroing it would make the counter jump. Per-round progress is + `deposited - zoneBaseline`. +* The marker nodes are nested in the map's own hierarchy, so the bat zone's + clone is positioned via `parent.worldToLocal()` rather than a world delta + written straight onto `position` — same reasoning as the crate respawn + path below. +* Each zone's centre also carries a reward preview: a static copy of the + matching weapon (see above) plus a projected DOM `+1 LVL` label. + +## Crate respawn + +Destroyed crates come back after `RESPAWN_DELAY_MS` (30 s) at their original +spot — without it a long run strips the map of wood and the bat progression +dies with it. + +The same collider **nodes** are reused across the whole break/respawn cycle; +only the `PhysicsBody` is rebuilt. `BreakablePropC.init` therefore takes both an +`onDestroyed` and an `onRespawned` callback, and `PlayerC.removeObstacle` / +`PlayerC.addObstacle` are mirrors of each other. + +`respawn()` calls `root.updateWorldMatrix(true, true)` after restoring the +resting transform. `Box3.setFromObject` only refreshes the node it is handed, +using its parent's *existing* `matrixWorld` — without the explicit update, the +obstacle box would be sampled at the sunk, toppled transform the crate was left +in when it broke. diff --git a/src/configUIParams/gameplay.ts b/src/configUIParams/gameplay.ts index 149953f..9c48819 100644 --- a/src/configUIParams/gameplay.ts +++ b/src/configUIParams/gameplay.ts @@ -35,7 +35,9 @@ export const gameplay: ConfigUiParamsCategories[] = [ id: "zombie_invasion_timer_s", name: "Zombie Invasion timer (s)", type: "int", - values: [5, 300, 120], + // Also how long the player gets before the zombie wave spawns — the + // bar filling is what triggers it (see ZombieC.startWave). + values: [5, 300, 30], }, ], }, diff --git a/src/controllers/BatLevelC.ts b/src/controllers/BatLevelC.ts new file mode 100644 index 0000000..4531786 --- /dev/null +++ b/src/controllers/BatLevelC.ts @@ -0,0 +1,34 @@ +// Bat level: a second, separate progression from the pistol's own (see +// PistolLevelC). Bat levels come from filling the wood Pay Zone (crates drop +// wood, see ResourceC / PayZoneC); pistol levels come from filling the +// zombie-part Pay Zone instead. +// +// Starts at 0 — that's the number the bottom-left octagon plate has always +// shown as a hardcoded placeholder (see WeaponHudC), so the first delivery +// reads as "LVL 0 -> LVL 1". +// +// Added to the bat's damage per crate hit (see PlayerC.updateCombat -> +// BreakablePropC.hit), on top of the debug panel's own base hit_damage. +const DAMAGE_PER_LEVEL = 10; + +// State only, and deliberately imports nothing: PayZoneC raises it, WeaponHudC +// displays it, PlayerC reads its damage bonus, and any import back into those +// would cycle. +export class BatLevelC { + private static level = 0; + + static getLevel(): number { + return this.level; + } + + static getBonusDamage(): number { + return this.level * DAMAGE_PER_LEVEL; + } + + // One filled wood Pay Zone = one bat level. There is no cap: the zone + // reopens in place every time it's filled, so this can keep climbing for as + // long as the run lasts. + static levelUp() { + this.level++; + } +} diff --git a/src/controllers/BreakablePropC.ts b/src/controllers/BreakablePropC.ts index f9568b7..862e003 100644 --- a/src/controllers/BreakablePropC.ts +++ b/src/controllers/BreakablePropC.ts @@ -1,10 +1,10 @@ -import { Euler, Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three"; +import { Box3, Euler, Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three"; import { Easing, Tween } from "@tweenjs/tween.js"; import { TweenC } from "@hitplay/playable_template"; import { PhysicsBody, PhysicsLayer } from "./PhysicsC"; import { ResourceC } from "./ResourceC"; import { PropVfxC } from "./PropVfxC"; -import { HealthBarC } from "./HealthBarC"; +import { HealthBarC, HealthBarOptions } from "./HealthBarC"; import { DebugConfigC } from "./DebugConfigC"; import { chainAndRegisterTweens } from "../utils/tweenChain"; import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial"; @@ -19,6 +19,11 @@ import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial"; // Not every crate has all three stages — some start pre-damaged (S2/S3 only) // or pre-broken (S3 only) as level-design variety, so "hit" always just // advances to the next stage that exists, whatever that is. +// How far above the crate's own real bounding-box top (world units, not a +// guessed pixel offset) its health bar sits. Lived in HealthBarC while that +// file still knew what a crate was; now each owner supplies its own anchor. +const HEALTH_BAR_HEIGHT_OFFSET = 0.2; + interface StageVisual { node: Object3D; // the "_S1"/"_S2"/"_S3" group — toggled visible per stage mesh: Mesh | null; // the actual textured mesh inside it, for the hit-flash @@ -32,6 +37,10 @@ export interface BreakableProp { root: Object3D; colliderNode: Object3D; baseRotation: Euler; + // Resting transform, captured once at build time — both the destroy sink and + // the respawn rise move the root off it, so neither can be used to derive + // "where does this crate belong" once it has been broken even once. + basePosition: Vector3; stages: StageVisual[]; stageIndex: number; // HP is the authoritative damage/destroy state — stageIndex above is a @@ -41,6 +50,10 @@ export interface BreakableProp { // Always MAX_HP for a fresh (S1-start) crate; lower for ones that start // pre-damaged (S2/S3-only) — see buildProp for the derivation. maxHp: number; + // The HP this particular crate opened the run with (below maxHp for the + // pre-damaged ones) — what a respawn restores it to, so a crate that started + // as rubble doesn't come back as a pristine full-HP box. + startHp: number; // How many global damage stages a FRESH version of this crate would have // (derived from its own last stage's "_SN" number, which is always the // deepest/final rubble stage — see stageNumber) and which one of those @@ -49,8 +62,11 @@ export interface BreakableProp { // (possibly truncated) stages array. totalPossibleStages: number; startStageNumber: number; + // Recreated on every respawn, since destroy() removes the old one from the + // physics world outright. physicsBody: PhysicsBody; hitTween: Tween<{ t: number }> | null; + respawnTimeout: ReturnType | null; } const STAGE_NUMBER_PATTERN = /_S(\d+)$/i; @@ -89,19 +105,41 @@ const DESTROY_SINK_MS = 250; const DESTROY_SINK_DEPTH = 1.2; // world units — enough to fully submerge below the visible ground const DESTROY_TILT_ANGLE = 0.6; // radians, ~34° — a pronounced topple, not a subtle lean +// A broken crate comes back, whole, after this long — the map holds ~18 of +// them, so without this a long run eventually strips it bare and the wood +// economy (and with it the Pay Zone / weapon levelling) dries up. +const RESPAWN_DELAY_MS = 30000; + +// Rises back out of the ground it sank into, mirroring the destroy sink, with +// the hit-flash overlay fading off over the same window so it reads as +// materialising rather than as an elevator. +const RESPAWN_RISE_MS = 350; +const RESPAWN_FLASH_MAX_OPACITY = 0.5; +// Safety margin for the rise's timeout fallback — tween onComplete is not +// reliable enough in this project to be the only thing restoring the resting +// transform (see CLAUDE.md). +const RESPAWN_CLEANUP_MARGIN_MS = 200; + export class BreakablePropC { + // Only the crates that are currently standing — a destroyed one is removed + // and put back by its respawn timer (see destroy/respawn). private static byColliderNode = new Map(); private static onDestroyed: ((colliderNode: Object3D) => void) | null = null; + private static onRespawned: ((colliderNode: Object3D) => void) | null = null; // Returns the collider nodes it took ownership of, so the caller (which // already built a generic /collider/i list for movement/camera raycasts) - // knows not to also give those the same treatment as plain walls. + // knows not to also give those the same treatment as plain walls. Crates come + // BACK after RESPAWN_DELAY_MS, so that set stays valid for the whole run — + // the same collider nodes are reused, never rebuilt. static init( lootableRoot: Object3D | undefined, - onDestroyed: (colliderNode: Object3D) => void + onDestroyed: (colliderNode: Object3D) => void, + onRespawned: (colliderNode: Object3D) => void ): Set { this.byColliderNode.clear(); this.onDestroyed = onDestroyed; + this.onRespawned = onRespawned; if (lootableRoot) { for (const crateRoot of lootableRoot.children) { @@ -118,16 +156,37 @@ export class BreakablePropC { } // Every hit takes the debug panel's configured damage (default 10, see - // DebugConfigC) off currentHp; the visual stage is derived from the - // remaining HP fraction (see below), and reaching 0 HP destroys it. + // DebugConfigC) plus `bonusDamage` (the bat's own level bonus — see + // BatLevelC.getBonusDamage, injected by the caller rather than imported + // here to avoid a cycle) off currentHp; the visual stage is derived from + // the remaining HP fraction (see below), and reaching 0 HP destroys it. // Every hit plays feedback + updates HealthBarC; a stage advance (or the // destroying hit) also drops resources. `hitDirection` (attacker -> // target, XZ, normalized) drives which way the crate leans/topples. - static hit(prop: BreakableProp, hitDirection: Vector3) { - const damage = DebugConfigC.getHitDamage(); + // Crates never move, so the bar's anchor is sampled once (no `follow`) — + // HealthBarC only re-projects the fixed point as the camera moves. Real + // geometry, not a guessed offset: the crate's own world bounding box top, + // centred on X/Z, plus a small gap. + private static healthBarOptions(prop: BreakableProp): HealthBarOptions { + return { + variant: "prop", + maxHp: prop.maxHp, + getWorldTop: () => { + const box = new Box3().setFromObject(prop.root); + return new Vector3( + (box.min.x + box.max.x) / 2, + box.max.y + HEALTH_BAR_HEIGHT_OFFSET, + (box.min.z + box.max.z) / 2 + ); + }, + }; + } + + static hit(prop: BreakableProp, hitDirection: Vector3, bonusDamage: number = 0) { + const damage = DebugConfigC.getHitDamage() + bonusDamage; const previousHp = prop.currentHp; prop.currentHp = Math.max(0, prop.currentHp - damage); - HealthBarC.onHit(prop, previousHp, prop.currentHp, damage); + HealthBarC.onHit(prop, this.healthBarOptions(prop), previousHp, prop.currentHp, damage); // A bit above the root's ground-level pivot, so the burst visually // comes from around the crate's body rather than the floor under it. @@ -194,14 +253,17 @@ export class BreakablePropC { root, colliderNode, baseRotation: root.rotation.clone(), + basePosition: root.position.clone(), stages, stageIndex: 0, currentHp, maxHp: MAX_HP, + startHp: currentHp, totalPossibleStages, startStageNumber, physicsBody, hitTween: null, + respawnTimeout: null, }; } @@ -286,6 +348,93 @@ export class BreakablePropC { HealthBarC.onDestroyed(prop); PropVfxC.spawnDestroy(prop.root.getWorldPosition(new Vector3())); this.playDisappearAnimation(prop, hitDirection); + + // Guarded: a crate can only be destroyed while it's standing, so there + // should never be a timer already pending here — but re-arming one would + // leak the old timeout and respawn the crate twice, and the second respawn + // would leave a second physics body in the world for the same collider. + if (prop.respawnTimeout !== null) clearTimeout(prop.respawnTimeout); + prop.respawnTimeout = setTimeout(() => this.respawn(prop), RESPAWN_DELAY_MS); + } + + // Puts a destroyed crate back, whole, at its original spot: resting transform, + // starting HP, first stage visible, a fresh physics body, and back into the + // lookup so hits find it again. The caller-side obstacle list is restored + // through onRespawned (the mirror of onDestroyed). + private static respawn(prop: BreakableProp) { + prop.respawnTimeout = null; + + // Position and rotation only — scale is never touched by the destroy path, + // and forcing it to 1 here would resize any crate the artist didn't author + // at unit scale. + prop.root.position.copy(prop.basePosition); + prop.root.rotation.copy(prop.baseRotation); + prop.root.visible = true; + + // The transform above has to reach matrixWorld before anything measures the + // crate. Box3.setFromObject only refreshes the node it's given (using its + // parent's EXISTING matrixWorld), so the collider box PlayerC.addObstacle + // samples via onRespawned below would otherwise come out at the sunk, + // toppled transform this crate was left in when it broke. + prop.root.updateWorldMatrix(true, true); + + prop.currentHp = prop.startHp; + prop.stageIndex = 0; + for (let i = 0; i < prop.stages.length; i++) { + prop.stages[i].node.visible = i === 0; + // The destroying hit's flash was cut off mid-fade (destroy() stops the + // tween), so any stage could still be carrying a stale opacity. + const flashMaterial = prop.stages[i].flashMesh?.material as MeshBasicMaterial | undefined; + if (flashMaterial) flashMaterial.opacity = 0; + } + + prop.hitTween = null; + prop.physicsBody = new PhysicsBody( + prop.colliderNode, + false, + 0, + PhysicsLayer.Wall, + PhysicsLayer.Player | PhysicsLayer.Enemy + ); + + this.byColliderNode.set(prop.colliderNode, prop); + // Before the rise animation below, not after: the crate is solid from the + // moment it's back, and the obstacle box is sampled from the resting + // transform that's already been restored above. + this.onRespawned?.(prop.colliderNode); + + this.playRespawnAnimation(prop); + } + + // Mirrors playDisappearAnimation: rises back out of the ground it sank into, + // with the hit-flash overlay fading off over the same window. + private static playRespawnAnimation(prop: BreakableProp) { + const root = prop.root; + const baseY = prop.basePosition.y; + const flashMaterial = prop.stages[prop.stageIndex].flashMesh?.material as MeshBasicMaterial | undefined; + + const settle = () => { + root.position.y = baseY; + if (flashMaterial) flashMaterial.opacity = 0; + }; + + root.position.y = baseY - DESTROY_SINK_DEPTH; + + const rise = new Tween({ t: 0 }) + .to({ t: 1 }, RESPAWN_RISE_MS) + .easing(Easing.Quadratic.Out) + .onUpdate(({ t }) => { + root.position.y = baseY - DESTROY_SINK_DEPTH * (1 - t); + if (flashMaterial) flashMaterial.opacity = RESPAWN_FLASH_MAX_OPACITY * (1 - t); + }) + .onComplete(settle); + + TweenC.add(rise); + rise.start(); + + // Redundant safety net — a crate left frozen halfway underground (or lit up + // white) would be far worse than one that snaps the last few frames. + setTimeout(settle, RESPAWN_RISE_MS + RESPAWN_CLEANUP_MARGIN_MS); } // Fully gone, not just its collider — sinks straight down into the ground diff --git a/src/controllers/BulletC.ts b/src/controllers/BulletC.ts new file mode 100644 index 0000000..f08d459 --- /dev/null +++ b/src/controllers/BulletC.ts @@ -0,0 +1,106 @@ +import { Object3D, Vector3 } from "three"; +import { UpdateController } from "@hitplay/playable_template"; +import { ThreeC } from "./ThreeC"; + +// Fast enough that the round reads as a tracer rather than a thrown pebble, +// slow enough to actually be visible over the ~4-8 unit gap the player +// shoots across (~0.15s of flight). +const SPEED = 45; + +// Safety net: a bullet whose target dies and stops being tracked mid-flight +// still has a fixed destination, but a mis-aimed one would otherwise fly +// forever. Nothing should ever reach this — it exists so a leak can't. +const MAX_LIFETIME_S = 2; + +// Distance from the destination at which the bullet is considered to have +// landed. One frame's travel at SPEED is ~0.75 units at 60fps, so anything +// smaller than that would be stepped straight over every frame. +const HIT_DISTANCE = 0.8; + +interface Bullet { + root: Object3D; + direction: Vector3; + distanceLeft: number; + elapsed: number; + onArrive: (() => void) | null; +} + +// Flying pistol rounds, cloned from the `Bullet` rig that ships inside +// ZombiePunk_Character.glb (a bullet mesh + a tapering trail of circles + a +// glow, modelled pointing along +Z with the trail behind it — which is +// exactly what Object3D.lookAt orients, so no corrective rotation is needed). +// +// Purely a travelling visual with a callback on arrival: PlayerC hands over +// the damage as the `onArrive` payload, so a zombie visibly takes the hit +// when the round reaches it rather than the instant the trigger is pulled. +export class BulletC { + private static initialized = false; + private static template: Object3D | null = null; + private static bullets: Bullet[] = []; + + // `template` is the `Bullet` node out of the character glb. It's detached + // from the character (not just hidden in place) so clones don't inherit the + // rig's own transform — every clone is positioned in world space instead. + static init(template: Object3D) { + if (this.initialized) return; + this.initialized = true; + + template.removeFromParent(); + template.visible = true; + this.template = template; + + UpdateController.Instance.onUpdate.addDelegate((delta: number) => this.update(delta)); + } + + static spawn(from: Vector3, to: Vector3, onArrive: (() => void) | null = null) { + if (!this.template) return; + + const direction = to.clone().sub(from); + const distance = direction.length(); + if (distance < 1e-4) { + // Muzzle already on top of the target — nothing to fly, but the hit + // still has to land or the shot would silently do nothing. + onArrive?.(); + return; + } + direction.divideScalar(distance); + + const root = this.template.clone(true); + root.position.copy(from); + root.lookAt(to); + ThreeC.addToScene(root); + + this.bullets.push({ root, direction, distanceLeft: distance, elapsed: 0, onArrive }); + } + + // Wipes every round in flight without firing their arrival callbacks — used + // when the run ends (player death), where a bullet landing after the fact + // would damage a zombie during the game-over screen. + static clear() { + for (const bullet of this.bullets) ThreeC.removeFromScene(bullet.root); + this.bullets.length = 0; + } + + private static update(delta: number) { + // Iterated backwards: arrived bullets splice themselves out of this same + // array inside the loop. + for (let i = this.bullets.length - 1; i >= 0; i--) { + const bullet = this.bullets[i]; + const step = SPEED * delta; + + bullet.root.position.addScaledVector(bullet.direction, step); + bullet.distanceLeft -= step; + bullet.elapsed += delta; + + const arrived = bullet.distanceLeft <= HIT_DISTANCE; + if (!arrived && bullet.elapsed < MAX_LIFETIME_S) continue; + + // The lifetime expiry path deliberately still fires onArrive: the + // damage must land exactly once per shot, and dropping it silently + // would make shots randomly do nothing if anything ever stalls. + bullet.onArrive?.(); + ThreeC.removeFromScene(bullet.root); + this.bullets.splice(i, 1); + } + } +} diff --git a/src/controllers/GameOverC.ts b/src/controllers/GameOverC.ts new file mode 100644 index 0000000..476e2c9 --- /dev/null +++ b/src/controllers/GameOverC.ts @@ -0,0 +1,95 @@ +// Seconds of red-veil screen before the run restarts. Counted down on +// screen so the restart never feels like a crash. +const RESTART_DELAY_S = 3; + +// Red veil + countdown shown when the player dies, then a restart. +// +// Mounted into #ui (NOT #ui-safe-area): the veil has to cover the whole +// letterboxed game area edge to edge, and #ui-safe-area is deliberately inset +// from it by --ui-safe-margin, which would leave an unpainted frame around +// the outside. +export class GameOverC { + private static shown = false; + private static intervalId: ReturnType | null = null; + + static isShown(): boolean { + return this.shown; + } + + static show() { + if (this.shown) return; + this.shown = true; + + const overlay = document.createElement("div"); + overlay.className = "game-over"; + + const title = document.createElement("div"); + title.className = "game-over__title"; + const titleText = document.createElement("span"); + // .gamefont-x — the global font-width squeeze lives on an inline-block + // child everywhere else in this project (see main.css), same here. + titleText.className = "gamefont-x"; + titleText.textContent = "YOU DIED"; + title.appendChild(titleText); + + const counter = document.createElement("div"); + counter.className = "game-over__counter"; + counter.appendChild(this.buildCounterText(`${RESTART_DELAY_S}`)); + + overlay.appendChild(title); + overlay.appendChild(counter); + + const root = document.getElementById("ui") ?? document.body; + root.appendChild(overlay); + + let remaining = RESTART_DELAY_S; + this.intervalId = setInterval(() => { + remaining--; + if (remaining > 0) { + // A brand-new element each tick, not just new textContent: a CSS + // animation only replays if its element is fresh (or its + // animation-name actually changes — see the two-elements note in + // ui.css), so re-texting in place would pop once for the first + // number and then sit still for the rest. + counter.replaceChildren(this.buildCounterText(`${remaining}`)); + return; + } + + if (this.intervalId !== null) clearInterval(this.intervalId); + this.intervalId = null; + this.restart(); + }, 1000); + } + + // Two nested elements, not one: .gamefont-x carries the project-wide font + // squeeze as a `transform`, and the pop animation below also animates + // `transform` — on a single element the animation would win and the digit + // would visibly un-squeeze for the length of every pop, then snap back. + // The outer wrapper animates, the inner span keeps the squeeze, and the two + // transforms compose. Same split as .zombie-invasion__title and + // .health-bar__damage. + private static buildCounterText(value: string): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "game-over__counter-value"; + + const span = document.createElement("span"); + span.className = "gamefont-x"; + span.textContent = value; + + wrapper.appendChild(span); + return wrapper; + } + + // A full page reload, not a state reset: every controller in this project + // is a static singleton that builds its world once at init (crate stages, + // spawned resources, the invasion timer, the DOM widgets), so a real + // in-place reset would mean adding a teardown path to each one. Reloading + // re-runs the same inline bundle in the export, so it works there as well + // as in dev. + // + // ⚠️ If an ad network's container ever refuses the reload, this one line is + // the whole thing to swap out for a per-controller reset. + private static restart() { + window.location.reload(); + } +} diff --git a/src/controllers/HealthBarC.ts b/src/controllers/HealthBarC.ts index 39b140d..9783897 100644 --- a/src/controllers/HealthBarC.ts +++ b/src/controllers/HealthBarC.ts @@ -1,20 +1,15 @@ -import { Box3, Camera, Vector3 } from "three"; +import { Camera, Vector3 } from "three"; import { CameraC_internal, UpdateController } from "@hitplay/playable_template"; import { ThreeC } from "./ThreeC"; -import type { BreakableProp } from "./BreakablePropC"; import { projectWorldPointToScreen } from "../utils/screenWorldProjection"; import { DebugConfigC } from "./DebugConfigC"; -// If a bar's crate stops getting hit and the player walks away before it's -// destroyed, the bar lingers this long before fading out — reset to 0 on -// every hit, see onHit. +// Every bar (crate, zombie, the player's own) appears on its first hit and +// lingers this long after the last one before fading out — reset to 0 on +// every hit, see onHit. Nothing shows a bar at full, untouched HP. const HIDE_DELAY_S = 2; const FADE_OUT_MS = 250; -// How far above the crate's own real bounding-box top (world units, not a -// guessed pixel offset — see getOrCreateBar) the bar sits. -const BAR_HEIGHT_OFFSET = 0.2; - // Kept in sync with health-bar.css's health-bar-damage-punch/-float // animation durations, so the popup element is removed right as whichever // CSS animation is active finishes, not before/after (see @@ -30,14 +25,32 @@ const DAMAGE_POPUP_FLOAT_LIFETIME_MS = 700; const DAMAGE_ROTATE_MIN_DEG = 10; const DAMAGE_ROTATE_MAX_DEG = 50; +// Drives the colour scheme only (see health-bar.css) — the numbers stay +// white on every variant. +// prop blue fill / white just-lost (crates) +// enemy red fill / yellow just-lost (zombies) +// player green fill / yellow just-lost +export type HealthBarVariant = "prop" | "enemy" | "player"; + +export interface HealthBarOptions { + variant: HealthBarVariant; + maxHp: number; + // World point the bar hovers over. Called once at creation for stationary + // owners (crates), and every frame when `follow` is set — a zombie or the + // player moves, so a cached point would leave its bar behind on the map. + getWorldTop: () => Vector3; + follow?: boolean; +} + interface BarInstance { - prop: BreakableProp; + key: object; + options: HealthBarOptions; container: HTMLElement; - whiteEl: HTMLElement; - blueEl: HTMLElement; - // Cached once at bar creation, not recomputed per frame — crates never - // move once placed, so only the CAMERA'S projection of this fixed world - // point needs updating every frame, not the point itself. + lostEl: HTMLElement; + fillEl: HTMLElement; + // Recomputed per frame only when options.follow is set; otherwise sampled + // once at creation — crates never move, so only the CAMERA'S projection of + // this fixed world point needs updating every frame, not the point itself. worldTop: Vector3; idleTime: number; hiding: boolean; @@ -50,16 +63,21 @@ interface BarInstance { damagePopupTimeout: ReturnType | null; } -// World-tracking DOM health bars for breakable crates: a bordered -// rectangular bar (blue = current HP, black = already-lost HP, white = HP -// lost on the hit that just landed, catching down to black after a beat), -// plus a floating "-10" popup on every hit. Pure DOM+CSS (styling in +// World-tracking DOM health bars: a bordered rectangular bar (coloured fill = +// current HP, black = already-lost HP, a second contrasting fill = HP lost on +// the hit that just landed, catching down to black after a beat), plus a +// floating "-N" popup on every hit. Pure DOM+CSS (styling in // src/css/health-bar.css) — only the per-frame world->screen repositioning // and the hit-triggered width changes are JS. +// +// Keyed by an opaque `object` rather than a concrete type: crates +// (BreakableProp), zombies (Zombie) and the player all use the same bars, and +// this file has no reason to know any of those types — everything it needs +// arrives through HealthBarOptions. export class HealthBarC { private static initialized = false; private static root: HTMLElement; - private static bars = new Map(); + private static bars = new Map(); static init() { if (this.initialized) return; @@ -70,39 +88,38 @@ export class HealthBarC { } // previousHp/currentHp are both already post-clamp (0..maxHp) — the - // caller (BreakablePropC.hit) applies the damage before calling this; - // this only drives the DISPLAY of what just happened. - static onHit(prop: BreakableProp, previousHp: number, currentHp: number, damage: number) { - const bar = this.getOrCreateBar(prop); + // caller applies the damage before calling this; this only drives the + // DISPLAY of what just happened. + static onHit(key: object, options: HealthBarOptions, previousHp: number, currentHp: number, damage: number) { + const bar = this.getOrCreateBar(key, options); bar.idleTime = 0; this.cancelHide(bar); - const previousFraction = (previousHp / prop.maxHp) * 100; - const currentFraction = (currentHp / prop.maxHp) * 100; + const previousFraction = (previousHp / options.maxHp) * 100; + const currentFraction = (currentHp / options.maxHp) * 100; - // The three-color effect is pure CSS transition timing, not a per-frame - // JS animation (see health-bar.css): white first SNAPS to the pre-hit - // value with no transition, then both white and blue are set to the - // post-hit value — white with a built-in transition-delay so it - // visibly lags behind blue for a moment (the "just lost, still white" - // beat) before catching down to become part of the black background - // underneath. Blue is layered on top of white so it never gets hidden - // by white's lag. - bar.whiteEl.style.transitionDuration = "0s"; - bar.whiteEl.style.transitionDelay = "0s"; - bar.whiteEl.style.width = `${previousFraction}%`; - void bar.whiteEl.offsetWidth; // force reflow: commit the instant snap above before re-enabling the transition below + // The three-colour effect is pure CSS transition timing, not a per-frame + // JS animation (see health-bar.css): the "just lost" layer first SNAPS to + // the pre-hit value with no transition, then both it and the fill are set + // to the post-hit value — the lost layer with a built-in transition-delay + // so it visibly lags behind (the "just lost, still bright" beat) before + // catching down to become part of the black background underneath. The + // fill is layered on top of it so it never gets hidden by that lag. + bar.lostEl.style.transitionDuration = "0s"; + bar.lostEl.style.transitionDelay = "0s"; + bar.lostEl.style.width = `${previousFraction}%`; + void bar.lostEl.offsetWidth; // force reflow: commit the instant snap above before re-enabling the transition below - bar.whiteEl.style.transitionDuration = ""; - bar.whiteEl.style.transitionDelay = ""; - bar.whiteEl.style.width = `${currentFraction}%`; - bar.blueEl.style.width = `${currentFraction}%`; + bar.lostEl.style.transitionDuration = ""; + bar.lostEl.style.transitionDelay = ""; + bar.lostEl.style.width = `${currentFraction}%`; + bar.fillEl.style.width = `${currentFraction}%`; this.spawnDamagePopup(bar, damage); } - static onDestroyed(prop: BreakableProp) { - const bar = this.bars.get(prop); + static onDestroyed(key: object) { + const bar = this.bars.get(key); if (!bar) return; this.removeBar(bar); } @@ -114,6 +131,7 @@ export class HealthBarC { const canvasRect = ThreeC.renderer.domElement.getBoundingClientRect(); for (const bar of this.bars.values()) { + if (bar.options.follow) bar.worldTop.copy(bar.options.getWorldTop()); this.positionBar(bar, camera, canvasRect); if (bar.hiding) continue; @@ -128,12 +146,12 @@ export class HealthBarC { bar.container.style.top = `${y}px`; } - private static getOrCreateBar(prop: BreakableProp): BarInstance { - const existing = this.bars.get(prop); + private static getOrCreateBar(key: object, options: HealthBarOptions): BarInstance { + const existing = this.bars.get(key); if (existing) return existing; const container = document.createElement("div"); - container.className = "health-bar"; + container.className = `health-bar health-bar--${options.variant}`; const border = document.createElement("div"); border.className = "health-bar__border"; @@ -141,33 +159,25 @@ export class HealthBarC { const track = document.createElement("div"); track.className = "health-bar__track"; - const white = document.createElement("div"); - white.className = "health-bar__white"; + const lost = document.createElement("div"); + lost.className = "health-bar__lost"; - const blue = document.createElement("div"); - blue.className = "health-bar__blue"; + const fill = document.createElement("div"); + fill.className = "health-bar__fill"; - track.appendChild(white); - track.appendChild(blue); + track.appendChild(lost); + track.appendChild(fill); border.appendChild(track); container.appendChild(border); this.root.appendChild(container); - // Real geometry, not a guessed offset — the crate's own world bounding - // box top, centered on X/Z, plus a small fixed gap above it. - const box = new Box3().setFromObject(prop.root); - const worldTop = new Vector3((box.min.x + box.max.x) / 2, box.max.y + BAR_HEIGHT_OFFSET, (box.min.z + box.max.z) / 2); - - const fraction = (prop.currentHp / prop.maxHp) * 100; - white.style.width = `${fraction}%`; - blue.style.width = `${fraction}%`; - const bar: BarInstance = { - prop, + key, + options, container, - whiteEl: white, - blueEl: blue, - worldTop, + lostEl: lost, + fillEl: fill, + worldTop: options.getWorldTop().clone(), idleTime: 0, hiding: false, removeTimeout: null, @@ -175,13 +185,12 @@ export class HealthBarC { damagePopupTimeout: null, }; - this.bars.set(prop, bar); + this.bars.set(key, bar); // Positioned synchronously right away — otherwise the container sits at // CSS's default position:fixed placement (top-left of the viewport, // since .health-bar sets no left/top) until the next update() tick, and - // flashes there for a frame before jumping to its real spot above the - // crate. + // flashes there for a frame before jumping to its real spot. this.positionBar(bar, CameraC_internal.getCamera(), ThreeC.renderer.domElement.getBoundingClientRect()); return bar; @@ -206,8 +215,9 @@ export class HealthBarC { private static removeBar(bar: BarInstance) { if (bar.removeTimeout !== null) clearTimeout(bar.removeTimeout); + if (bar.damagePopupTimeout !== null) clearTimeout(bar.damagePopupTimeout); bar.container.remove(); - this.bars.delete(bar.prop); + this.bars.delete(bar.key); } private static spawnDamagePopup(bar: BarInstance, damage: number) { diff --git a/src/controllers/PayZoneC.ts b/src/controllers/PayZoneC.ts index 41929a7..b3e9a27 100644 --- a/src/controllers/PayZoneC.ts +++ b/src/controllers/PayZoneC.ts @@ -2,9 +2,9 @@ import { Box3, Mesh, MeshBasicMaterial, Object3D, PlaneGeometry, RingGeometry, V import { CameraC_internal, TweenC, UpdateController } from "@hitplay/playable_template"; import { Easing, Tween } from "@tweenjs/tween.js"; import { ThreeC } from "./ThreeC"; -import { RESOURCE_ICON_BASE_SCALE, ResourceC } from "./ResourceC"; -import { PlayerC } from "./PlayerC"; +import { PICKUP_SETTLED_SCALE } from "../utils/pickupFlight"; import { perspectiveCompensationScale } from "../utils/perspectiveScale"; +import { projectWorldPointToScreen } from "../utils/screenWorldProjection"; import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial"; import { DebugConfigC } from "./DebugConfigC"; @@ -25,14 +25,37 @@ const DEPOSIT_FLIGHT_DURATION_MS = 280; const DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS = 200; const PAY_ZONE_SHRINK_MS = 400; +const PAY_ZONE_GROW_MS = 450; -// Extra multiplier on top of RESOURCE_ICON_BASE_SCALE, local to this deposit +// Filling the zone raises its level and reopens the SAME zone in place — the +// gap between it shrinking away and reappearing is deliberate — a beat of +// empty ground makes it read as "that round's done" rather than an instant +// refill. +const REOPEN_DELAY_MS = PAY_ZONE_SHRINK_MS + 300; + +// The reward pedestal in the middle of the zone: a static copy of whatever +// weapon this zone's currency levels up (see PlayerC.createPistolDisplay / +// createBatDisplay), floating and slowly turning, with a "+1 LVL" label +// under it — built from the character rig's own mesh, so no new art is +// needed. +const REWARD_ICON_SIZE = 0.75; // largest axis, world units +const REWARD_ICON_HEIGHT = 1.05; // above the zone's ground level +const REWARD_ICON_SPIN_SPEED = 1.1; // radians/second +const REWARD_ICON_BOB_SPEED = 2.2; +const REWARD_ICON_BOB_HEIGHT = 0.07; +// A slight lean so the spin shows the prop's profile rather than an edge-on +// silhouette all the way round. +const REWARD_ICON_TILT = 0.35; +// Where the DOM label is projected from, relative to the zone's ground. +const REWARD_LABEL_HEIGHT = 0.4; + +// Extra multiplier on top of PICKUP_SETTLED_SCALE, local to this deposit // flight only. This flight now starts at the player's own head, roughly the // same camera distance as its destination (the camera rigidly follows the // player), so perspectiveCompensationScale barely changes its value over // the flight — unlike when this flight used to start at the HUD anchor // right next to the camera, where that term grew a lot on its own. Retune -// this constant (not RESOURCE_ICON_BASE_SCALE) if only this flight's token +// this constant (not PICKUP_SETTLED_SCALE) if only this flight's token // needs to look bigger/smaller. const DEPOSIT_FLIGHT_SCALE_MULTIPLIER = 1.3; @@ -66,39 +89,97 @@ const FILL_OPACITY = 0.55; // Sits just above the zone's own ground-level quad to avoid z-fighting. const FILL_Y_OFFSET = 0.02; -// Reuses the map's existing "UI_Interactive_Zone_02" marker as both the pay -// zone's location and its visual. ResourceC.getCollectedCount() counts -// resources the moment they're picked up, unrelated to this zone; this -// controller separately drains that total into the zone, one at a time, -// only while the player is standing inside it. +export interface PayZoneOptions { + // The zone's marker/visual node — undefined leaves this instance entirely + // inert (no fill mesh, no reward pedestal, no deposits), same fallback the + // old static version had for a missing "UI_Interactive_Zone_02". + node: Object3D | undefined; + target: Object3D; + // Where a deposit flight launches from — the player's own head, re-sampled + // per deposit rather than derived from `target` here, so this file doesn't + // need to know the player's height (see PlayerC.getHeadWorldPosition). + getDepositOrigin: () => Vector3; + // This zone's own resource's lifetime collected count (see + // ResourceC.getCollectedCount / ZombiePartC.getCollectedCount) — the + // backlog waiting to be deposited is this minus getDepositedCount(). + getCollectedCount: () => number; + // A fresh visual for one flying deposit token — see + // ResourceC.createVisual / ZombiePartC.createVisual. + createDepositVisual: () => Object3D; + // The zone filled up — raise whichever level this currency buys (see + // BatLevelC.levelUp / PistolLevelC.levelUp). + onFilled: () => void; + // Builds the reward pedestal's floating prop at the given target size, or + // null if the source mesh isn't available (see + // PlayerC.createBatDisplay / createPistolDisplay). + buildRewardIcon: (targetSize: number) => Object3D | null; +} + +// One deliverable zone: stand inside it and whatever this instance's resource +// backlog is (see options.getCollectedCount) drains in a steady trickle, +// filling a ground overlay. A full zone raises this instance's own level +// (options.onFilled), then shrinks away, pauses, and grows back **in the same +// spot** — there is no relocation and no randomness; TestSceneC owns where +// each instance's zone actually sits (see its own PAY_ZONE_SCALE/ +// PAY_ZONE_GAP). +// +// Two independent instances exist (see TestSceneC): one draining wood into +// BatLevelC, one draining zombie-part chunks into PistolLevelC. export class PayZoneC { - private static payZoneNode: Object3D | null = null; - private static target: Object3D | null = null; + private payZoneNode: Object3D | null; + private target: Object3D; + private getDepositOrigin: () => Vector3; + private getCollectedCount: () => number; + private createDepositVisual: () => Object3D; + private onFilled: () => void; + private buildRewardIcon: (targetSize: number) => Object3D | null; + // The zone's real world-space XZ footprint, computed from its actual // geometry rather than a guessed radius. - private static zoneBounds: Box3 | null = null; - private static closed = false; - private static deposited = 0; - private static depositCooldown = 0; - private static fillMesh: Mesh | null = null; + private zoneBounds: Box3 | null = null; + private closed = false; + // Lifetime total of resources delivered into THIS zone — the matching HUD's + // live balance is `collected - deposited`, so this can never be reset + // without the counter jumping. Per-round progress is measured against + // zoneBaseline below instead. + private deposited = 0; + private zoneBaseline = 0; + private depositCooldown = 0; + private fillMesh: Mesh | null = null; + // The reward-pedestal prop in the middle of the zone, and its "+1 LVL" DOM + // label. Null when there was no source mesh to build it from. + private rewardIcon: Object3D | null = null; + private rewardLabel: HTMLElement | null = null; + private rewardElapsed = 0; + // The node's resting local scale, captured at init — what a reopened zone + // grows back up to. + private baseScale = new Vector3(1, 1, 1); - static init(payZoneNode: Object3D | undefined, target: Object3D) { - this.payZoneNode = payZoneNode ?? null; - this.target = target; + constructor(options: PayZoneOptions) { + this.payZoneNode = options.node ?? null; + this.target = options.target; + this.getDepositOrigin = options.getDepositOrigin; + this.getCollectedCount = options.getCollectedCount; + this.createDepositVisual = options.createDepositVisual; + this.onFilled = options.onFilled; + this.buildRewardIcon = options.buildRewardIcon; + } + + init() { this.zoneBounds = this.payZoneNode ? new Box3().setFromObject(this.payZoneNode) : null; - this.closed = false; - this.deposited = 0; - this.depositCooldown = 0; - this.fillMesh = this.zoneBounds ? this.buildFillMesh(this.zoneBounds) : null; + this.baseScale = this.payZoneNode ? this.payZoneNode.scale.clone() : new Vector3(1, 1, 1); + if (this.zoneBounds) this.buildFillMesh(); this.updateFillVisual(); + this.buildRewardDisplay(); UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta)); } // A unit quad, scaled/positioned per-frame in updateFillVisual — fixed // width (full X span of the zone), depth (world Z) grows from 0 to the - // zone's full Z span as `deposited` approaches RESOURCES_TO_CLOSE_ZONE. - private static buildFillMesh(bounds: Box3): Mesh { + // zone's full Z span as this zone's own progress approaches + // RESOURCES_TO_CLOSE_ZONE. + private buildFillMesh() { const mesh = new Mesh( new PlaneGeometry(1, 1), new MeshBasicMaterial({ @@ -109,20 +190,27 @@ export class PayZoneC { }) ); mesh.rotation.x = -Math.PI / 2; // lie flat on the ground, facing up - mesh.position.y = bounds.min.y + FILL_Y_OFFSET; - mesh.position.x = (bounds.min.x + bounds.max.x) / 2; - mesh.scale.x = bounds.max.x - bounds.min.x; ThreeC.addToScene(mesh); - return mesh; + this.fillMesh = mesh; + this.placeFillMesh(); + } + + // Pins the fill quad's fixed dimensions (width, ground height) to the zone's + // current footprint. + private placeFillMesh() { + if (!this.fillMesh || !this.zoneBounds) return; + this.fillMesh.position.y = this.zoneBounds.min.y + FILL_Y_OFFSET; + this.fillMesh.position.x = (this.zoneBounds.min.x + this.zoneBounds.max.x) / 2; + this.fillMesh.scale.x = this.zoneBounds.max.x - this.zoneBounds.min.x; } // Grows the fill quad from the zone's min-Z edge toward its max-Z edge — // scaling a centered quad also moves its center, so the Z position is // re-derived each time to keep that near edge pinned instead of drifting. - private static updateFillVisual() { + private updateFillVisual() { if (!this.fillMesh || !this.zoneBounds) return; - const fraction = this.closed ? 0 : Math.min(this.deposited / RESOURCES_TO_CLOSE_ZONE, 1); + const fraction = this.closed ? 0 : Math.min(this.zoneProgress() / RESOURCES_TO_CLOSE_ZONE, 1); const depth = this.zoneBounds.max.z - this.zoneBounds.min.z; const filledDepth = depth * fraction; @@ -130,16 +218,23 @@ export class PayZoneC { this.fillMesh.position.z = this.zoneBounds.min.z + filledDepth / 2; } - // HudC reads this (via an injected getter, not a direct import, to avoid - // a HudC <-> PayZoneC import cycle) to show a live balance that rises on - // collection and falls as resources leave for the Pay Zone. - static getDepositedCount(): number { + // The matching HUD reads this (via an injected getter, not a direct import) + // to show a live balance that rises on collection and falls as resources + // leave for this Pay Zone. + getDepositedCount(): number { return this.deposited; } + // How much has gone into the CURRENT round, as opposed to the lifetime + // total above — reset by reopening (see reopen), which advances the + // baseline instead of zeroing the total. + private zoneProgress(): number { + return this.deposited - this.zoneBaseline; + } + // XZ containment against the zone's real footprint (ignores Y — the // zone lies flat on the ground). - static isPlayerInside(playerPosition: Vector3): boolean { + private isPlayerInside(playerPosition: Vector3): boolean { if (!this.zoneBounds) return false; return ( playerPosition.x >= this.zoneBounds.min.x && @@ -149,33 +244,70 @@ export class PayZoneC { ); } - private static update(delta: number) { + private update(delta: number) { + // Runs even while the zone is closed (it hides the pedestal in that + // case), so it can't be left mid-spin over empty ground during the + // close/reopen beat. + this.updateRewardDisplay(delta); + if (this.closed || !this.payZoneNode || !this.target) return; if (this.depositCooldown > 0) this.depositCooldown -= delta; - const backlog = ResourceC.getCollectedCount() - this.deposited; + const backlog = this.getCollectedCount() - this.deposited; if (backlog <= 0) return; if (this.depositCooldown > 0) return; if (!this.isPlayerInside(this.target.position)) return; this.depositCooldown = DEPOSIT_INTERVAL; this.deposited++; - this.playDepositFlight(PlayerC.getHeadWorldPosition()); + this.playDepositFlight(this.getDepositOrigin()); this.updateFillVisual(); - if (this.deposited >= RESOURCES_TO_CLOSE_ZONE) { - this.closed = true; - this.playDisappear(this.payZoneNode); - } + if (this.zoneProgress() >= RESOURCES_TO_CLOSE_ZONE) this.close(); + } + + // The zone is full: pay out this instance's own level immediately (the + // "+1 LVL" on the pedestal has been promising it the whole time), shrink + // the zone away, and reopen it in the same spot. + private close() { + if (!this.payZoneNode) return; + + this.closed = true; + this.onFilled(); + this.playDisappear(this.payZoneNode); + + // A plain timeout, not the shrink tween's onComplete: tween completion is + // not dependable enough in this project to hang it on (see CLAUDE.md). + // The shrink's own onComplete only hides things. + setTimeout(() => this.reopen(), REOPEN_DELAY_MS); + } + + // Reopens the SAME zone in place — no relocation, no randomness. The + // lifetime `deposited` total is untouched — only the baseline this round + // measures its own progress against moves up, so the HUD's live balance + // can't jump (see zoneProgress). + private reopen() { + if (!this.payZoneNode) return; + + this.zoneBaseline = this.deposited; + this.closed = false; + this.depositCooldown = 0; + + this.placeFillMesh(); + this.updateFillVisual(); + if (this.fillMesh) this.fillMesh.visible = true; + + this.payZoneNode.visible = true; + this.playAppear(this.payZoneNode); } // One resource, flying from the player's head into the zone's real // geometric center — the player only needs to be standing in the zone to // trigger it, they're also the flight's origin. - private static playDepositFlight(from: Vector3) { + private playDepositFlight(from: Vector3) { if (!this.zoneBounds) return; - const mesh = ResourceC.createVisual(); + const mesh = this.createDepositVisual(); // The zone's real geometric center, not the marker node's own pivot // (not guaranteed to sit exactly in the middle of the footprint). const to = new Vector3( @@ -201,7 +333,7 @@ export class PayZoneC { mesh.position.y += Math.sin(t * Math.PI) * DEPOSIT_FLIGHT_ARC_HEIGHT; const perspectiveScale = perspectiveCompensationScale(cameraPosition, mesh.position, startDistance); - mesh.scale.setScalar(perspectiveScale * RESOURCE_ICON_BASE_SCALE * DEPOSIT_FLIGHT_SCALE_MULTIPLIER); + mesh.scale.setScalar(perspectiveScale * PICKUP_SETTLED_SCALE * DEPOSIT_FLIGHT_SCALE_MULTIPLIER); }) .onComplete(() => { ThreeC.removeFromScene(mesh); @@ -216,7 +348,7 @@ export class PayZoneC { setTimeout(() => ThreeC.removeFromScene(mesh), DEPOSIT_FLIGHT_DURATION_MS + DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS); } - private static playLandingRipple(position: Vector3) { + private playLandingRipple(position: Vector3) { const mesh = new Mesh(rippleGeometry, createFlashOverlayMaterial()); mesh.rotation.x = -Math.PI / 2; // lie flat on the ground, facing up mesh.position.copy(position); @@ -239,9 +371,93 @@ export class PayZoneC { ripple.start(); } + // The floating reward prop + "+1 LVL" label advertising what this zone pays + // out. Built once, never rebuilt (there's no relocation to rebuild for). + private buildRewardDisplay() { + if (!this.zoneBounds) return; + + this.rewardIcon = this.buildRewardIcon(REWARD_ICON_SIZE); + if (this.rewardIcon) { + this.rewardIcon.rotation.z = REWARD_ICON_TILT; + ThreeC.addToScene(this.rewardIcon); + } + + // Positioned every frame from a projected world point (see + // updateRewardDisplay), the same technique the health bars use — styling + // lives in ui.css (.pay-zone-reward). + const label = document.createElement("div"); + label.className = "pay-zone-reward"; + + // Text in its own inline-block child (.gamefont-x, see main.css) so the + // global font-width squeeze doesn't touch this element's own positioning. + const labelText = document.createElement("span"); + labelText.className = "gamefont-x"; + labelText.textContent = "+1 LVL"; + label.appendChild(labelText); + + (document.getElementById("ui-safe-area") ?? document.body).appendChild(label); + this.rewardLabel = label; + } + + // Spin + bob the prop over the zone's center and keep the label pinned + // under it. Both are hidden outright while the zone is closed, i.e. for the + // beat between it filling up and reopening. + private updateRewardDisplay(delta: number) { + if (!this.rewardIcon && !this.rewardLabel) return; + + const visible = !this.closed && this.zoneBounds !== null; + + if (this.rewardIcon) this.rewardIcon.visible = visible; + if (this.rewardLabel) this.rewardLabel.style.display = visible ? "" : "none"; + if (!visible || !this.zoneBounds) return; + + this.rewardElapsed += delta; + + const center = this.zoneBounds.getCenter(new Vector3()); + const groundY = this.zoneBounds.min.y; + + if (this.rewardIcon) { + this.rewardIcon.position.set( + center.x, + groundY + REWARD_ICON_HEIGHT + Math.sin(this.rewardElapsed * REWARD_ICON_BOB_SPEED) * REWARD_ICON_BOB_HEIGHT, + center.z + ); + this.rewardIcon.rotation.y += delta * REWARD_ICON_SPIN_SPEED; + } + + if (this.rewardLabel) { + const canvasRect = ThreeC.renderer.domElement.getBoundingClientRect(); + const worldPoint = new Vector3(center.x, groundY + REWARD_LABEL_HEIGHT, center.z); + const { x, y } = projectWorldPointToScreen(CameraC_internal.getCamera(), canvasRect, worldPoint); + this.rewardLabel.style.left = `${x}px`; + this.rewardLabel.style.top = `${y}px`; + } + } + + // Grows the reopened zone back up to its resting scale — the mirror of + // playDisappear below. The fill quad is left out of it: it's empty at this + // point (zero depth), so there is nothing to scale. + private playAppear(node: Object3D) { + const base = this.baseScale; + node.scale.set(0, 0, 0); + + const grow = new Tween({ s: 0 }) + .to({ s: 1 }, PAY_ZONE_GROW_MS) + .easing(Easing.Back.Out) + .onUpdate(({ s }) => node.scale.set(base.x * s, base.y * s, base.z * s)) + .onComplete(() => node.scale.copy(base)); + + TweenC.add(grow); + grow.start(); + + // Safety net, same reasoning as everywhere else in this file: a zone left + // frozen at a fraction of its size would be both wrong and hard to spot. + setTimeout(() => node.scale.copy(base), PAY_ZONE_GROW_MS + DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS); + } + // Shrinks the zone's own marker AND the fill overlay together, as one // piece, toward their own centers in lockstep. - private static playDisappear(node: Object3D) { + private playDisappear(node: Object3D) { const fillMesh = this.fillMesh; const fillBaseScaleX = fillMesh?.scale.x ?? 0; const fillBaseScaleY = fillMesh?.scale.y ?? 0; diff --git a/src/controllers/PistolHudC.ts b/src/controllers/PistolHudC.ts new file mode 100644 index 0000000..9c4d003 --- /dev/null +++ b/src/controllers/PistolHudC.ts @@ -0,0 +1,98 @@ +import { UpdateController } from "@hitplay/playable_template"; +import { weaponHudBgSrc } from "../resources/images/images"; + +// Flat single-colour silhouette, generated as an SVG data URI at runtime +// rather than a shipped asset — no dedicated pistol icon exists yet (compare +// weapon_icon_bat.webp, the bat's own), and a plain silhouette reads cleanly +// at this size without one. +const PISTOL_ICON_SVG = + '' + + '' + + '' + // slide/barrel + '' + // rear sight + '' + // front sight + '' + // grip + '' + // trigger guard + "" + + ""; +const pistolIconSrc = `data:image/svg+xml;utf8,${encodeURIComponent(PISTOL_ICON_SVG)}`; + +// Bottom-left "pistol level" plate, stacked directly BELOW the bat's own +// octagon (see WeaponHudC) — the pistol's progression is its own currency +// (zombie-part chunks, see ZombiePartC / PistolLevelC), separate from the +// wood-fed bat level the plate above it shows. +// +// Reuses WeaponHudC's own octagon background art: it's a generic frame, not +// bat-specific, so the pair reads as two matched medallions rather than two +// unrelated widgets. +export class PistolHudC { + private static initialized = false; + private static plateEl: HTMLElement; + private static levelTextEl: HTMLElement; + private static lastDisplayedLevel = -1; + private static levelGetter: (() => number) | null = null; + + // Injected rather than importing PistolLevelC directly, matching the rest + // of the HUD widgets in this project (WeaponHudC.setLevelGetter). + static setLevelGetter(getLevel: () => number) { + this.levelGetter = getLevel; + } + + static init() { + if (this.initialized) return; + this.initialized = true; + this.buildDom(); + + UpdateController.Instance.onUpdate.addDelegate(() => this.update()); + } + + // Layout/styling lives in src/css/ui.css (.pistol-hud/__plate/__icon/__level) + // — same two-nested-divs split as WeaponHudC, and for the same reason: the + // outer element owns the one-shot mount animation, the inner plate owns the + // per-upgrade bump. + private static buildDom() { + const holder = document.createElement("div"); + holder.className = "pistol-hud"; + + const plate = document.createElement("div"); + plate.className = "pistol-hud__plate"; + plate.style.backgroundImage = `url(${weaponHudBgSrc})`; + + const icon = document.createElement("img"); + icon.className = "pistol-hud__icon"; + icon.src = pistolIconSrc; + + const level = document.createElement("span"); + level.className = "pistol-hud__level"; + + const levelText = document.createElement("span"); + levelText.className = "gamefont-x"; + levelText.textContent = "LVL 0"; + level.appendChild(levelText); + + plate.appendChild(icon); + plate.appendChild(level); + holder.appendChild(plate); + + const uiRoot = document.getElementById("ui-safe-area") ?? document.body; + uiRoot.appendChild(holder); + + this.plateEl = plate; + this.levelTextEl = levelText; + } + + private static update() { + if (!this.levelGetter) return; + const level = this.levelGetter(); + if (level === this.lastDisplayedLevel) return; + + const isFirstPaint = this.lastDisplayedLevel === -1; + this.lastDisplayedLevel = level; + this.levelTextEl.textContent = `LVL ${level}`; + + if (isFirstPaint) return; + this.plateEl.classList.remove("is-upgrading"); + void this.plateEl.offsetWidth; + this.plateEl.classList.add("is-upgrading"); + } +} diff --git a/src/controllers/PistolLevelC.ts b/src/controllers/PistolLevelC.ts new file mode 100644 index 0000000..b8a6841 --- /dev/null +++ b/src/controllers/PistolLevelC.ts @@ -0,0 +1,33 @@ +// Pistol level: a second, separate progression from the bat's own (see +// BatLevelC). Pistol levels come from filling the zombie-part Pay Zone +// (zombies drop the parts, see ZombiePartC / PayZoneC); bat levels come from +// filling the wood Pay Zone instead. +// +// Starts at 0, matching the bottom-left "LVL 0" placeholder convention shared +// with BatLevelC/WeaponHudC. +// +// Added to the pistol's base damage per shot (see PlayerC.fireAt) and drives +// the in-world upgrade glow on the drawn pistol (see WeaponAuraC). +const DAMAGE_PER_LEVEL = 10; + +// State only, and deliberately imports nothing: PayZoneC raises it, PistolHudC +// displays it, WeaponAuraC reads it for its glow intensity, PlayerC reads its +// damage bonus, and any import back into those would cycle. +export class PistolLevelC { + private static level = 0; + + static getLevel(): number { + return this.level; + } + + static getBonusDamage(): number { + return this.level * DAMAGE_PER_LEVEL; + } + + // One filled zombie-part Pay Zone = one pistol level. Uncapped, same + // reasoning as BatLevelC (WeaponAuraC caps the visual glow instead, see its + // own MAX_EFFECT_LEVEL). + static levelUp() { + this.level++; + } +} diff --git a/src/controllers/PistolResourceHudC.ts b/src/controllers/PistolResourceHudC.ts new file mode 100644 index 0000000..f3131d8 --- /dev/null +++ b/src/controllers/PistolResourceHudC.ts @@ -0,0 +1,92 @@ +import { Vector3 } from "three"; +import { CameraC_internal, UpdateController } from "@hitplay/playable_template"; +import { ThreeC } from "./ThreeC"; +import { resourceCounterBgSrc } from "../resources/images/images"; +import { projectScreenPointToWorldRay } from "../utils/screenWorldProjection"; + +// See HudC's identical constant — same reasoning, this is a second instance +// of the same anchor technique for a second counter. +const ANCHOR_DISTANCE = 2.5; + +// Second resource counter, stacked directly below HudC's own wood counter +// (see .resource-hud--pistol in ui.css). The zombie-part chunks dropped by +// kills (see ZombiePartC, spent on the pistol's own progression — see +// PistolLevelC / its Pay Zone) get the exact same fly-to-a-HUD-counter +// treatment wood does, just their own plate and their own live balance. +// +// Reuses HudC's plate art rather than shipping a second bespoke asset — a +// CSS hue-rotate on the plate (see ui.css) is what reads it as a different +// currency. +export class PistolResourceHudC { + private static initialized = false; + private static plateEl: HTMLElement; + private static countTextEl: HTMLElement; + private static lastDisplayedCount = -1; + private static balanceGetter: (() => number) | null = null; + + static init() { + if (this.initialized) return; + this.initialized = true; + + this.buildDom(); + + UpdateController.Instance.onUpdate.addDelegate(() => this.update()); + } + + // Same technique as HudC.getWorldAnchorPosition — see its own comment. + static getWorldAnchorPosition(): Vector3 { + const canvasRect = ThreeC.renderer.domElement.getBoundingClientRect(); + const plateRect = this.plateEl.getBoundingClientRect(); + + const screenX = plateRect.right; + const screenY = plateRect.top + plateRect.height / 2; + + const camera = CameraC_internal.getCamera(); + const { origin, direction } = projectScreenPointToWorldRay(camera, canvasRect, screenX, screenY); + return origin.addScaledVector(direction, ANCHOR_DISTANCE); + } + + // Injected rather than importing ZombiePartC/PayZoneC directly, matching + // HudC.setBalanceGetter. + static setBalanceGetter(getBalance: () => number) { + this.balanceGetter = getBalance; + } + + private static buildDom() { + const holder = document.createElement("div"); + holder.className = "resource-hud resource-hud--pistol"; + + const plate = document.createElement("div"); + plate.className = "resource-hud__plate"; + plate.style.backgroundImage = `url(${resourceCounterBgSrc})`; + + const count = document.createElement("span"); + count.className = "resource-hud__count"; + + const countText = document.createElement("span"); + countText.className = "gamefont-x"; + countText.textContent = "0"; + count.appendChild(countText); + + plate.appendChild(count); + holder.appendChild(plate); + + const uiRoot = document.getElementById("ui-safe-area") ?? document.body; + uiRoot.appendChild(holder); + + this.plateEl = plate; + this.countTextEl = countText; + } + + private static update() { + if (!this.balanceGetter) return; + const count = this.balanceGetter(); + if (count === this.lastDisplayedCount) return; + this.lastDisplayedCount = count; + this.countTextEl.textContent = String(count); + + this.plateEl.classList.remove("is-bumping"); + void this.plateEl.offsetWidth; + this.plateEl.classList.add("is-bumping"); + } +} diff --git a/src/controllers/PlayerC.ts b/src/controllers/PlayerC.ts index 8b2ae77..ba21a4a 100644 --- a/src/controllers/PlayerC.ts +++ b/src/controllers/PlayerC.ts @@ -3,7 +3,9 @@ import { AnimationClip, AnimationMixer, Box3, + LoopOnce, LoopRepeat, + Mesh, Object3D, Quaternion, Vector2, @@ -13,6 +15,12 @@ import { JoystickC, UpdateController } from "@hitplay/playable_template"; import { ThreeC } from "./ThreeC"; import { BreakableProp, BreakablePropC } from "./BreakablePropC"; import { WeaponTrailC } from "./WeaponTrailC"; +import { Zombie, ZombieC } from "./ZombieC"; +import { BulletC } from "./BulletC"; +import { HealthBarC, HealthBarOptions } from "./HealthBarC"; +import { GameOverC } from "./GameOverC"; +import { BatLevelC } from "./BatLevelC"; +import { PistolLevelC } from "./PistolLevelC"; const MOVE_SPEED = 4; // world units per second const START_POSITION = { x: 1, y: 0, z: -8 }; // start of the road, the other end, feet on the ground @@ -77,9 +85,64 @@ const INTERACTION_REACH = 0.6; // +50% // Tune these against the actual clip if the impacts don't line up visually. const HIT_TIME_FRACTIONS = [0.40,0.60]; +// Radius around the player inside which an approaching zombie makes him pull +// the pistol and open fire. Smaller than ZombieC's MIN_SPAWN_DISTANCE so +// nothing can ever spawn already inside it — the approach is always visible +// before the draw triggers. +const SHOOT_RANGE = 8; + +// Per the brief: 5 shots to kill a 100 HP zombie at pistol level 0. Every +// pistol level adds a flat bonus on top (see PistolLevelC.getBonusDamage), so +// this is the floor, not the whole number — see fireAt(). +const PISTOL_DAMAGE = 20; + +// Point in each firing cycle (fraction of the 0.76s PistolShoot clip, which +// is what sets the cadence) where the round actually leaves the barrel. The +// cadence is deliberately independent of whichever clip is currently playing: +// while walking, the animation is one of the PistolBack/Forward/Left/Right +// aim-walk cycles instead, and the fire rate must not change just because the +// player started moving. +const SHOT_TIME_FRACTION = 0.3; + +// The character's max HP. +const PLAYER_BASE_MAX_HP = 100; + +// How far above the character's head its own health bar floats. Well clear of +// the head rather than resting on it — this bar sits over the one thing on +// screen that's always in the middle of the action. +const HEALTH_BAR_HEIGHT_OFFSET = 0.9; + +// Both weapon-swap clips run at this multiple of their authored speed. The +// draw is 1.12s and the put-away 1.2s as authored, which is a long time to +// stand there while a wave closes in. Applied as timeScale on the actions AND +// divided out of the durations the state machine waits on (see +// updateGunCombat) — change it in one place and both stay in step. +const WEAPON_SWAP_SPEED = 2; + +// How far along the line of fire, from the hand attachment point, rounds are +// spawned. Has to clear the character's own silhouette: the Bullet rig trails +// ~1 unit of tapering circles BEHIND the round itself, so spawning right at +// the hand drapes that trail across the body and reads as the shot coming out +// of the character rather than the gun. The pistol is a skinned mesh with no +// usable node transform of its own (see CLAUDE.md), so a hand node plus this +// offset is the available handle. +const MUZZLE_FORWARD_OFFSET = 0.75; + const UP_AXIS = new Vector3(0, 1, 0); const FORWARD_AXIS = new Vector3(0, 0, 1); +// stowed — pistol holstered, normal idle/walk/melee behaviour +// drawing — WeaponTakeOut playing, not yet firing +// shooting — firing on a fixed cadence; the clip underneath is PistolShoot +// when standing still and one of the PistolBack/Forward/Left/ +// Right aim-walk cycles when moving +// holstering— WeaponPutDown playing after the last target left the zone +// +// Movement does NOT interrupt any of this: the whole point of the four +// directional aim clips is that the character keeps shooting while walking. +// Only losing the target ends a firing run. +type GunState = "stowed" | "drawing" | "shooting" | "holstering"; + interface Obstacle { node: Object3D; box: Box3; @@ -111,8 +174,32 @@ export class PlayerC { private static idleAction: AnimationAction; private static walkAction: AnimationAction; private static attackAction: AnimationAction; + private static takeOutAction: AnimationAction; + private static shootAction: AnimationAction; + private static putDownAction: AnimationAction; + private static deathAction: AnimationAction; + // Aim-walk cycles, indexed by which way the character is moving relative to + // the direction it's aiming — see pickAimWalkAction(). + private static aimWalkForwardAction: AnimationAction; + private static aimWalkBackAction: AnimationAction; + private static aimWalkLeftAction: AnimationAction; + private static aimWalkRightAction: AnimationAction; private static currentAction: AnimationAction; private static isAttacking = false; + + private static currentHp = PLAYER_BASE_MAX_HP; + private static isDead = false; + // The bone rounds are spawned from — see MUZZLE_FORWARD_OFFSET. + private static gunHand: Object3D | null = null; + + private static gunState: GunState = "stowed"; + // Time spent in the current one-shot gun clip (drawing/holstering) — those + // two are driven by their own clip duration rather than an event. + private static gunElapsed = 0; + // Time since the current firing run started, and when the next shot lands. + private static shootElapsed = 0; + private static nextShotTime = 0; + private static shotsFired = 0; private static attackElapsed = 0; private static nextHitTime = 0; // Counts up across the whole continuous attack (not reset per loop) — @@ -144,22 +231,109 @@ export class PlayerC { this.bindJoystick(); + // No bar is created here on purpose — like the crates', it appears on the + // first hit taken (HealthBarC.onHit creates it lazily) and fades out again + // after HIDE_DELAY_S of not being hit. Nothing to show while at full HP. UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta)); } + static isAlive(): boolean { + return !this.isDead; + } + + // Called by ZombieC when one of them lands a hit (10 damage a second while + // it's on top of the player). + static takeDamage(amount: number) { + if (this.isDead) return; + + const previousHp = this.currentHp; + this.currentHp = Math.max(0, this.currentHp - amount); + HealthBarC.onHit(this, this.healthBarOptions(), previousHp, this.currentHp, amount); + + if (this.currentHp === 0) this.die(); + } + + private static die() { + this.isDead = true; + + // Drop every in-flight bit of combat state at once: a queued melee swing, + // the gun state machine, the weapon trail, and any rounds still travelling + // (those would otherwise damage a zombie during the game-over screen). + this.isAttacking = false; + this.gunState = "stowed"; + WeaponTrailC.setActive(false); + BulletC.clear(); + + // Stops the body from coasting on under the death animation. + this.moveInput.set(0, 0); + this.currentSpeed = 0; + + this.playAction(this.deathAction, true); + GameOverC.show(); + } + + // `follow` — the anchor moves with the character, so it's re-sampled every + // frame rather than cached at creation the way a crate's is. Deliberately + // NOT `persistent`: the bar is only wanted while damage is actually coming + // in, so it auto-hides again like every other bar in the game. + private static healthBarOptions(): HealthBarOptions { + return { + variant: "player", + maxHp: PLAYER_BASE_MAX_HP, + follow: true, + getWorldTop: () => + this.object.position.clone().add(new Vector3(0, PLAYER_HEIGHT + HEALTH_BAR_HEIGHT_OFFSET, 0)), + }; + } + // Called when a breakable prop's collider is torn down (see // BreakablePropC.destroy) so it stops blocking movement. static removeObstacle(node: Object3D) { this.obstacles = this.obstacles.filter((obstacle) => obstacle.node !== node); } + // The other half of removeObstacle: a destroyed crate that has respawned (see + // BreakablePropC's respawn timer) blocks movement again. + // + // The box is sampled NOW and cached, exactly like init() does — so the caller + // must have already put the crate back at its resting transform before + // calling this, even if a rise-out-of-the-ground animation is still playing + // over it. That's deliberate: the crate is solid from the instant it's back, + // not once the animation finishes. + static addObstacle(node: Object3D) { + if (this.obstacles.some((obstacle) => obstacle.node === node)) return; + + const box = new Box3().setFromObject(node); + if (box.max.y - box.min.y < MIN_OBSTACLE_HEIGHT) return; + + this.obstacles.push({ node, box }); + } + + // Whether anything solid stands inside `bounds` — used by PayZoneC to keep a + // relocated Pay Zone off crates and walls. Reuses this list precisely because + // it's already filtered down to real obstacles (the map's thin ground slab + // excluded, see MIN_OBSTACLE_HEIGHT) and kept live as crates break and + // respawn. + static isAreaBlocked(bounds: Box3): boolean { + return this.obstacles.some((obstacle) => obstacle.box.intersectsBox(bounds)); + } + private static createCharacter() { const character = ThreeC.getObject("character"); - // "Bullet" is a separate rig bundled in the same file for the (not yet - // implemented) shooting animations — hide it until it's actually used. + // "Bullet" is a separate rig bundled in the same glb — a round plus its + // trail and glow. Handed to BulletC as the template to clone per shot, + // which also detaches it from the character (otherwise the prop would + // just sit floating at the rig's origin). const bullet = character.getObjectByName("Bullet"); - if (bullet) bullet.visible = false; + if (bullet) BulletC.init(bullet); + + // Where fired rounds spawn from. "Weapon_Hand" is the rig's own in-hand + // weapon attachment point (it's what the bat prop Tool_1 is parented to), + // so it sits exactly where a held weapon does — closer to the real muzzle + // than the wrist joint, which is a hand's width further back up the arm. + // The pistol itself is skinned and has no node transform to read. + this.gunHand = character.getObjectByName("Weapon_Hand") ?? character.getObjectByName("Wrist_R") ?? null; ThreeC.setShadowsStateForChildren(character, true, false); @@ -201,6 +375,58 @@ export class PlayerC { return this.object.position.clone().add(new Vector3(0, PLAYER_HEIGHT, 0)); } + // A static, display-only copy of a weapon prop, for showing it outside the + // character's hands (see PayZoneC's reward pedestals — one per weapon's own + // Pay Zone). Not a clone() of the real thing: Character_Pistol is a + // SkinnedMesh (see CLAUDE.md), so a clone would stay bound to the + // character's skeleton and follow HIS hand around the map. This rebuilds it + // as a plain Mesh from the source's own geometry instead — safe for the bat + // props too, even though they aren't skinned themselves. + // + // Two fixes the raw geometry needs: skinned geometry is authored wherever the + // bind pose put it (nowhere near its own origin), hence center(); and its + // authored size has nothing to do with how big it should read as a floating + // prop, hence normalizing the largest axis to `targetSize`. The material is + // cloned because GLB materials are shared between nodes (CLAUDE.md) and this + // copy must not be able to affect the source mesh in the player's hands. + private static createWeaponDisplay(source: Object3D, targetSize: number): Object3D | null { + // The bat props may be a Group wrapping the actual mesh rather than a bare + // Mesh like the pistol — find whichever one it is. + const mesh = source instanceof Mesh ? source : source.getObjectByProperty("type", "Mesh"); + if (!(mesh instanceof Mesh)) return null; + + const geometry = mesh.geometry.clone(); + geometry.center(); + geometry.computeBoundingBox(); + + const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material; + const built = new Mesh(geometry, material.clone()); + + const size = geometry.boundingBox!.getSize(new Vector3()); + const largestAxis = Math.max(size.x, size.y, size.z); + if (largestAxis > 1e-6) built.scale.setScalar(targetSize / largestAxis); + + return built; + } + + static createPistolDisplay(targetSize: number): Object3D | null { + return this.createWeaponDisplay(this.pistol, targetSize); + } + + static createBatDisplay(targetSize: number): Object3D | null { + return this.createWeaponDisplay(this.batOnBack, targetSize); + } + + // Where the weapon-upgrade glow sits (see WeaponAuraC), or null when it + // shouldn't show. Only while the pistol is actually drawn: holstered, it's + // skinned to the hip with no node transform of its own to track, so there is + // no honest position to put a glow at — and the upgrade is worth showing off + // when the gun is up anyway. + static getPistolAuraAnchor(): Vector3 | null { + if (this.gunState === "stowed" || !this.gunHand) return null; + return this.gunHand.getWorldPosition(new Vector3()); + } + // Exactly one of {pistol, bat} is ever in hand, and the other is stowed — // the pistol has no separate "in hand" prop, so its own single mesh just // toggles at its holster spot instead. @@ -224,10 +450,24 @@ export class PlayerC { const idleClip = AnimationClip.findByName(clips, "Idle"); const walkClip = AnimationClip.findByName(clips, "SlowWalk"); const attackClip = AnimationClip.findByName(clips, "Loot"); + const takeOutClip = AnimationClip.findByName(clips, "WeaponTakeOut"); + const shootClip = AnimationClip.findByName(clips, "PistolShoot"); + const putDownClip = AnimationClip.findByName(clips, "WeaponPutDown"); + const deathClip = AnimationClip.findByName(clips, "Death"); + const aimForwardClip = AnimationClip.findByName(clips, "PistolForward"); + const aimBackClip = AnimationClip.findByName(clips, "PistolBack"); + const aimLeftClip = AnimationClip.findByName(clips, "PistolLeft"); + const aimRightClip = AnimationClip.findByName(clips, "PistolRight"); if (!idleClip || !walkClip || !attackClip) { throw new Error("character mesh is missing the Idle/SlowWalk/Loot animation clips"); } + if (!takeOutClip || !shootClip || !putDownClip || !deathClip) { + throw new Error("character mesh is missing the WeaponTakeOut/PistolShoot/WeaponPutDown/Death animation clips"); + } + if (!aimForwardClip || !aimBackClip || !aimLeftClip || !aimRightClip) { + throw new Error("character mesh is missing the PistolForward/Back/Left/Right animation clips"); + } this.idleAction = this.mixer.clipAction(idleClip); this.walkAction = this.mixer.clipAction(walkClip); @@ -235,20 +475,72 @@ export class PlayerC { // Loops continuously while attacking — see updateCombat(). this.attackAction.setLoop(LoopRepeat, Infinity); + // Draw and put-away play exactly once and HOLD their last pose + // (clampWhenFinished) — without it the mixer snaps back to the clip's + // first frame the instant it ends, which would yank the pistol back to + // the holster for the frames between the draw finishing and the shooting + // loop's crossfade taking over. + this.takeOutAction = this.mixer.clipAction(takeOutClip); + this.takeOutAction.setLoop(LoopOnce, 1); + this.takeOutAction.clampWhenFinished = true; + this.takeOutAction.timeScale = WEAPON_SWAP_SPEED; + + this.putDownAction = this.mixer.clipAction(putDownClip); + this.putDownAction.setLoop(LoopOnce, 1); + this.putDownAction.clampWhenFinished = true; + this.putDownAction.timeScale = WEAPON_SWAP_SPEED; + + // Death holds its final pose too — the body must stay down under the + // game-over veil, not ping back to the clip's first frame. + this.deathAction = this.mixer.clipAction(deathClip); + this.deathAction.setLoop(LoopOnce, 1); + this.deathAction.clampWhenFinished = true; + + // Loops for as long as a target stays in the zone — see updateGunCombat(). + this.shootAction = this.mixer.clipAction(shootClip); + this.shootAction.setLoop(LoopRepeat, Infinity); + + // The four aim-walk cycles: pistol up, walking in each direction relative + // to where the character is aiming. What makes shooting-while-moving + // possible at all — without them, moving would have to cancel the gun. + this.aimWalkForwardAction = this.mixer.clipAction(aimForwardClip); + this.aimWalkBackAction = this.mixer.clipAction(aimBackClip); + this.aimWalkLeftAction = this.mixer.clipAction(aimLeftClip); + this.aimWalkRightAction = this.mixer.clipAction(aimRightClip); + for (const action of [ + this.aimWalkForwardAction, + this.aimWalkBackAction, + this.aimWalkLeftAction, + this.aimWalkRightAction, + ]) { + action.setLoop(LoopRepeat, Infinity); + } + this.currentAction = this.idleAction; this.currentAction.play(); } - private static playAction(action: AnimationAction) { - if (this.currentAction === action) return; + // `restart` re-triggers a clip that's already current — needed for the + // one-shot gun clips, which can legitimately be re-entered (draw -> + // holster -> draw) while still being the action last faded in. Without it + // the early-out below would leave the previous run's clamped final pose + // frozen on screen. + private static playAction(action: AnimationAction, restart = false) { + if (this.currentAction === action && !restart) return; action.reset().fadeIn(ANIMATION_FADE).play(); - this.currentAction.fadeOut(ANIMATION_FADE); + // Guarded: fading an action out against itself would cancel the fade-in + // just started above. + if (this.currentAction !== action) this.currentAction.fadeOut(ANIMATION_FADE); this.currentAction = action; } private static bindJoystick() { JoystickC.onJoysticMove.addDelegate(({ data }) => { + // Input is dropped outright once dead — update() already early-returns, + // but without this the stick would keep writing moveInput and the body + // would lurch back into motion the instant a restart re-enabled it. + if (this.isDead) return; // nipplejs clamps the drag to a circle (not per-axis) whenever // `follow: false` — see JoystickC.init in beforeResourcesLoadedCb — // so `vector` is already length <= 1 and diagonals aren't faster. @@ -261,14 +553,36 @@ export class PlayerC { } private static update(delta: number) { + // Everything below is frozen once dead — the Death clip is driven by the + // shared mixer (see ThreeC.addAnimMixer), not from here, so it still + // plays out over a body that no longer moves, aims or fires. + if (this.isDead) return; + + // Zombies outrank crates: a live threat in the zone takes over both the + // facing lock and the combat slot, so the character can't be caught + // whacking a crate while one walks into him. + const zombieTarget = ZombieC.findNearestAlive(this.object.position, SHOOT_RANGE); + // Movement always runs, attacking or not — moving is what interrupts an // attack (see updateCombat), so it can never be locked out. const nearbyObstacle = this.findNearbyObstacle(); - const stopped = this.updateMovement(delta, nearbyObstacle); + const stopped = this.updateMovement(delta, nearbyObstacle, zombieTarget); - this.updateCombat(delta, stopped); + // A swing already in progress is cut short the moment a zombie enters the + // zone. Without this the melee run would keep isAttacking latched (it + // only clears itself when the player moves or the crate dies), the draw + // below would stay blocked on it, and the character would happily keep + // whacking a crate while being surrounded. + if (zombieTarget && this.isAttacking) this.cancelMeleeAttack(); - if (!this.isAttacking) { + this.updateGunCombat(delta, zombieTarget); + + // Melee only gets a turn with the pistol fully stowed AND the zone clear + // — otherwise a crate in reach would fight the shooting loop for the same + // animation slot every frame. + if (this.gunState === "stowed" && !zombieTarget) this.updateCombat(delta, stopped); + + if (!this.isAttacking && this.gunState === "stowed") { if (this.currentSpeed > 0) { // Match the walk cycle's playback speed to actual current speed, not // just whether the stick is pushed — otherwise the animation would @@ -288,9 +602,19 @@ export class PlayerC { // it can't be the thing gated off during one). Returns whether the // character is now fully stopped (no input AND speed decayed to zero), // the bar updateCombat uses to allow an attack to start or continue. - private static updateMovement(delta: number, nearbyObstacle: NearbyObstacle | null): boolean { + private static updateMovement( + delta: number, + nearbyObstacle: NearbyObstacle | null, + zombieTarget: Zombie | null + ): boolean { const inputMagnitude = this.moveInput.length(); + // While the pistol is out and something is being aimed at, facing belongs + // to the target, not the stick — that separation of "where I'm walking" + // from "where I'm pointing" is exactly what the four directional aim-walk + // clips exist to render. + const aiming = this.gunState !== "stowed" && zombieTarget !== null; + // Movement direction tracks the stick independently of facing below, so // walking around/past an obstacle in range still works even though // facing itself may be locked onto it once stopped. @@ -305,8 +629,10 @@ export class PlayerC { // the same mirror to keep "stick right" mean "screen right". Facing // follows the raw stick immediately — it's intent, not momentum, so it // shouldn't wait on the speed ramp below. - const angle = Math.atan2(-this.moveInput.x, this.moveInput.y); - this.facingRotation.setFromAxisAngle(UP_AXIS, angle); + if (!aiming) { + const angle = Math.atan2(-this.moveInput.x, this.moveInput.y); + this.facingRotation.setFromAxisAngle(UP_AXIS, angle); + } } // Ramp actual speed toward the stick's target instead of snapping to it: @@ -329,8 +655,17 @@ export class PlayerC { // Only take over facing once fully stopped (no input AND speed decayed // to zero) — otherwise the face would snap onto the obstacle mid-stride // or mid-coast while the body is still visibly heading somewhere else. + // A zombie being aimed at outranks a crate for the facing lock: the + // shooting stance has to actually point at what it's shooting. Re-read + // every frame (not latched at draw time) so the aim tracks a target + // that's still walking toward the player, and switches to the next one + // the moment the current one dies. Unlike the crate lock below, this one + // is NOT gated on being stopped — aiming while walking is the point. const stopped = inputMagnitude === 0 && this.currentSpeed <= 0; - if (stopped && nearbyObstacle) { + if (aiming && zombieTarget) { + this.faceTowards(zombieTarget.root.position); + this.facingTarget = zombieTarget.root.position; + } else if (stopped && nearbyObstacle) { this.faceTowards(nearbyObstacle.center); this.facingTarget = nearbyObstacle.center; } else { @@ -380,15 +715,16 @@ export class PlayerC { if (this.isAttacking) { if (!stopped || zoneTargets.length === 0) { - this.isAttacking = false; - WeaponTrailC.setActive(false); - this.equipPistol(); + this.cancelMeleeAttack(); return; } this.attackElapsed += delta; if (this.attackElapsed >= this.nextHitTime) { - zoneTargets.forEach((target) => BreakablePropC.hit(target.prop, this.hitDirectionTo(target.center))); + const bonusDamage = BatLevelC.getBonusDamage(); + zoneTargets.forEach((target) => + BreakablePropC.hit(target.prop, this.hitDirectionTo(target.center), bonusDamage) + ); this.hitsThisAttack++; this.nextHitTime = this.hitTimeFor(this.hitsThisAttack); } @@ -409,6 +745,164 @@ export class PlayerC { this.playAction(this.attackAction); } + // Drops out of the melee loop and stows the bat. Called both from the + // attack's own end conditions (moved / nothing left to hit) and from + // update() when a zombie takes priority over whatever crate was in reach. + // Leaves no animation of its own — the caller's next step picks one. + private static cancelMeleeAttack() { + this.isAttacking = false; + WeaponTrailC.setActive(false); + this.equipPistol(); + } + + // Pistol combat, run every frame ahead of the melee logic. `target` is the + // nearest living zombie inside SHOOT_RANGE (null if the zone is clear), and + // `stopped` is the same "fully at rest" bar melee uses — the character has + // to plant his feet to shoot, exactly like he does to swing. + // + // Note the asymmetry between the two ways firing can end: losing the target + // plays the put-away clip (a beat of "threat handled"), while the player + // moving drops the whole state instantly so walking never waits on an + // animation. + private static updateGunCombat(delta: number, target: Zombie | null) { + switch (this.gunState) { + case "stowed": + // isAttacking guards a mid-swing melee run: let it finish its own + // cancel path (see updateCombat) rather than yanking the bat out of + // the character's hands on the same frame. + if (target && !this.isAttacking) this.startDrawing(); + return; + + case "drawing": + if (!target) { + this.startHolstering(); + return; + } + + // Divided by WEAPON_SWAP_SPEED to match the action's own timeScale — + // the clip's authored duration is not how long it now takes to play. + this.gunElapsed += delta; + if (this.gunElapsed >= this.takeOutAction.getClip().duration / WEAPON_SWAP_SPEED) this.startShooting(); + return; + + case "shooting": + if (!target) { + this.startHolstering(); + return; + } + + this.shootElapsed += delta; + if (this.shootElapsed >= this.nextShotTime) { + this.fireAt(target); + this.shotsFired++; + this.nextShotTime = this.shotTimeFor(this.shotsFired); + } + + this.updateAimAnimation(); + return; + + case "holstering": + // A fresh target arriving mid-put-away goes straight back to the + // draw instead of finishing the animation first — with a wave still + // coming, standing there completing a holster would be a free hit. + if (target) { + this.startDrawing(); + return; + } + + this.gunElapsed += delta; + if (this.gunElapsed >= this.putDownAction.getClip().duration / WEAPON_SWAP_SPEED) this.gunState = "stowed"; + return; + } + } + + // Spawns the round; the damage rides along and lands when it arrives (see + // BulletC), so a zombie visibly takes the hit as the bullet reaches it + // rather than the instant the trigger is pulled. + private static fireAt(target: Zombie) { + const to = ZombieC.getAimPoint(target); + // Damage is resolved HERE, at the muzzle, not inside the arrival callback: + // the level could tick up while the round is still in the air, and a shot + // should land the damage it was fired with. + const damage = PISTOL_DAMAGE + PistolLevelC.getBonusDamage(); + BulletC.spawn(this.getMuzzleWorldPosition(to), to, () => ZombieC.damage(target, damage)); + } + + // The right wrist bone's live world position, nudged toward the target so + // rounds leave the gun rather than the forearm. Falls back to chest height + // on the character's own origin if the bone is ever missing — a slightly + // wrong muzzle is better than no bullets at all. + private static getMuzzleWorldPosition(target: Vector3): Vector3 { + if (!this.gunHand) return this.object.position.clone().add(new Vector3(0, PLAYER_HEIGHT * 0.75, 0)); + + const muzzle = this.gunHand.getWorldPosition(new Vector3()); + const forward = target.clone().sub(muzzle); + if (forward.lengthSq() > 1e-6) muzzle.addScaledVector(forward.normalize(), MUZZLE_FORWARD_OFFSET); + return muzzle; + } + + // Standing still -> the stationary PistolShoot stance. Moving -> whichever + // of the four aim-walk cycles matches the direction of travel RELATIVE TO + // where the character is aiming (it's facing the zombie, not its heading). + private static updateAimAnimation() { + if (this.currentSpeed <= 0) { + this.playAction(this.shootAction); + return; + } + + this.playAction(this.pickAimWalkAction()); + } + + private static pickAimWalkAction(): AnimationAction { + // World-space heading, then rotated into the character's own frame by the + // inverse of its facing. The x negation matches the one in + // updateMovement's facing math — same camera-yaw mirror, and the two must + // agree or the chosen clip would be mirrored against actual travel. + const worldMove = new Vector3(-this.moveDirection.x, 0, this.moveDirection.y); + const localMove = worldMove.applyQuaternion(this.object.quaternion.clone().invert()); + + if (Math.abs(localMove.z) >= Math.abs(localMove.x)) { + return localMove.z > 0 ? this.aimWalkForwardAction : this.aimWalkBackAction; + } + + // Facing +Z with +Y up makes the character's own right hand point at + // world -X (right = forward × up = Z × Y = -X), so a POSITIVE local x is + // movement to its left. If the strafe clips ever read mirrored on screen, + // this pair is the single thing to swap. + return localMove.x > 0 ? this.aimWalkLeftAction : this.aimWalkRightAction; + } + + private static startDrawing() { + this.gunState = "drawing"; + this.gunElapsed = 0; + // Bat away, pistol shown — the pistol mesh is skinned to the rig, so the + // draw clip carries it from the holster to the hand on its own. + this.equipPistol(); + this.playAction(this.takeOutAction, true); + } + + private static startShooting() { + this.gunState = "shooting"; + this.shootElapsed = 0; + this.shotsFired = 0; + this.nextShotTime = this.shotTimeFor(0); + this.playAction(this.shootAction, true); + } + + private static startHolstering() { + this.gunState = "holstering"; + this.gunElapsed = 0; + this.playAction(this.putDownAction, true); + } + + // Time (seconds into the firing run) the Nth shot lands — one per loop of + // the clip, same shape as hitTimeFor() below but with a single fraction + // instead of a cycle of them. + private static shotTimeFor(shotIndex: number): number { + const duration = this.shootAction.getClip().duration; + return duration * (shotIndex + SHOT_TIME_FRACTION); + } + // Time (seconds since the attack started) the Nth hit lands — cycles // through HIT_TIME_FRACTIONS within each loop of the clip, advancing a // full extra loop every time the cycle wraps. diff --git a/src/controllers/ResourceC.ts b/src/controllers/ResourceC.ts index 96510ae..c4043d1 100644 --- a/src/controllers/ResourceC.ts +++ b/src/controllers/ResourceC.ts @@ -1,9 +1,5 @@ -import { BoxGeometry, Mesh, MeshBasicMaterial, MeshStandardMaterial, Object3D, Vector3 } from "three"; -import { CameraC_internal, UpdateController } from "@hitplay/playable_template"; -import { ThreeC } from "./ThreeC"; -import { SparkleFxC } from "./SparkleFxC"; -import { perspectiveCompensationScale } from "../utils/perspectiveScale"; -import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial"; +import { BoxGeometry, Mesh, MeshStandardMaterial, Object3D, Vector3 } from "three"; +import { PickupFlight } from "../utils/pickupFlight"; // Fallback only — TestSceneC normally hands in the map's real wood-icon // quad via setPickupTemplate(), and every spawned resource clones that. @@ -13,94 +9,22 @@ const PICKUP_SIZE = 0.28; // bounce twice, then fly on to the HUD counter (see HudC) — no walk-over // pickup trigger. Carrying the collected total into the Pay Zone is a // separate mechanic (see PayZoneC, which drains getCollectedCount() itself). -const SCATTER_MIN_DISTANCE = 0.5; -const SCATTER_MAX_DISTANCE = 1.2; -const SCATTER_ARC_HEIGHT = 0.6; -const FLY_DURATION = 0.45; // seconds to reach the scattered spot - -// Two bounces off the ground, decreasing height/duration, index 0 first. -const BOUNCE_HEIGHTS = [0.4, 0.22]; -const BOUNCE_DURATIONS = [0.26, 0.2]; - -// On touchdown of the last bounce: shrink ramps in and HOLDS (does not ease -// back to full size), uniform (not squash/widen — that read as growing). -// Flash fades on its own, longer, decoupled timeline so tuning one doesn't -// drag the other. -const SHRINK_RAMP_DURATION = 0.22; -const FLASH_DURATION = 0.45; -// Must stay >= FLASH_DURATION or the flash gets cut off mid-fade. -const IMPACT_HOLD_DURATION = 0.55; -const SHRINK_AMOUNT = 0.15; // fraction shrunk, held once reached - -// The scale a resource settles at after the bounce shrink, carried all the -// way to the HUD (toTarget's perspective compensation holds this constant -// in apparent size). Exported so PayZoneC's deposit-flight token — a -// separate, fresh mesh — starts from the same baseline instead of its own -// default (1), which read as noticeably larger. -export const RESOURCE_ICON_BASE_SCALE = 1 - SHRINK_AMOUNT; - -const TO_TARGET_DURATION = 0.5; -const TO_TARGET_ARC_HEIGHT = 1; -// Fraction of the toTarget flight (0..1) where the arrival brighten (see -// update()) starts ramping in. -const CONVERGE_START_FRACTION = 0.7; - -// Extra hold time added per-resource in a burst so they don't all launch -// toward the HUD in the same instant and read as one clump. -const STAGGER_INTERVAL = 0.12; - -function randomInt(min: number, max: number): number { - return min + Math.floor(Math.random() * (max - min + 1)); -} - -// Collected up front, before anything mutates the tree, so adding overlay -// children mid-traversal can't make traverse() walk into overlays it just -// added. -function buildFlashOverlays(root: Object3D): Mesh[] { - const meshes: Mesh[] = []; - root.traverse((child) => { - if (child instanceof Mesh) meshes.push(child); - }); - - return meshes.map((mesh) => { - const overlay = mesh.clone(); - // clone() copies the mesh's own local transform (relative to ITS - // parent) — parenting one level deeper under the mesh itself would - // reinterpret those numbers in the mesh's local space and displace the - // overlay instead of exactly overlaying it. Reset to identity instead. - overlay.position.set(0, 0, 0); - overlay.rotation.set(0, 0, 0); - overlay.scale.set(1, 1, 1); - overlay.material = createFlashOverlayMaterial(); - overlay.castShadow = false; - overlay.receiveShadow = false; - mesh.add(overlay); - return overlay; - }); -} - -type Phase = "scatter" | "bounce" | "impactHold" | "toTarget"; - -interface FlyingResource { - mesh: Object3D; - flashOverlays: Mesh[]; - phase: Phase; - age: number; - from: Vector3; - to: Vector3; - groundY: number; // the y level the two bounces oscillate around - bounceIndex: number; // which of BOUNCE_HEIGHTS/BOUNCE_DURATIONS is playing - staggerDelay: number; // extra hold time before launching to the HUD — see STAGGER_INTERVAL - baseScale: number; // scale.x when toTarget starts — see the perspective compensation there - startDistanceFromCamera: number; // camera distance when toTarget starts — see toTarget phase -} - +// +// The whole scatter/bounce/fly animation lives in utils/pickupFlight.ts and is +// shared with the zombie-part chunks (see ZombiePartC) — this file only owns +// the wood visual, the collected count, and where they fly to. export class ResourceC { - private static flying: FlyingResource[] = []; - private static collected = 0; - private static initialized = false; private static pickupTemplate: Object3D | null = null; private static flyTarget: (() => Vector3) | null = null; + private static collected = 0; + + private static flight = new PickupFlight({ + createMesh: () => this.createPickupMesh(), + getTarget: () => this.flyTarget?.() ?? null, + onCollected: () => { + this.collected++; + }, + }); static getCollectedCount() { return this.collected; @@ -117,45 +41,18 @@ export class ResourceC { } // A standalone clone of the pickup visual for callers (PayZoneC) that fly - // a "resource" token outside the normal spawn/scatter/collect lifecycle - // below — no flash overlays, those are only built for the real ones. + // a "resource" token outside the normal spawn/scatter/collect lifecycle — + // no flash overlays, those are only built for the real ones. static createVisual(): Object3D { return this.createPickupMesh(); } static spawnBurst(origin: Vector3, count: number) { - this.ensureUpdating(); - for (let i = 0; i < count; i++) this.spawnOne(origin, i * STAGGER_INTERVAL); + this.flight.spawnBurst(origin, count); } static spawnBurstInRange(origin: Vector3, min: number, max: number) { - this.spawnBurst(origin, randomInt(min, max)); - } - - private static spawnOne(origin: Vector3, staggerDelay: number) { - const angle = Math.random() * Math.PI * 2; - const distance = SCATTER_MIN_DISTANCE + Math.random() * (SCATTER_MAX_DISTANCE - SCATTER_MIN_DISTANCE); - const target = origin - .clone() - .add(new Vector3(Math.cos(angle) * distance, 0, Math.sin(angle) * distance)); - - const mesh = this.createPickupMesh(); - mesh.position.copy(origin); - ThreeC.addToScene(mesh); - - this.flying.push({ - mesh, - flashOverlays: buildFlashOverlays(mesh), - phase: "scatter", - age: 0, - from: origin.clone(), - to: target, - groundY: 0, - bounceIndex: 0, - staggerDelay, - baseScale: 1, - startDistanceFromCamera: 1, - }); + this.flight.spawnBurstInRange(origin, min, max); } private static createPickupMesh(): Object3D { @@ -174,143 +71,4 @@ export class ResourceC { mesh.castShadow = true; return mesh; } - - private static ensureUpdating() { - if (this.initialized) return; - this.initialized = true; - UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta)); - } - - private static update(delta: number) { - for (let i = this.flying.length - 1; i >= 0; i--) { - const resource = this.flying[i]; - resource.age += delta; - - if (resource.phase === "scatter") { - if (resource.age <= FLY_DURATION) { - const t = resource.age / FLY_DURATION; - const eased = 1 - (1 - t) * (1 - t); // easeOutQuad outward - resource.mesh.position.lerpVectors(resource.from, resource.to, eased); - // Up-then-down arc on top of the outward lerp, so it "pops" - // rather than sliding flat along the ground. - resource.mesh.position.y = resource.from.y + Math.sin(t * Math.PI) * SCATTER_ARC_HEIGHT; - continue; - } - - resource.phase = "bounce"; - resource.age = 0; - resource.bounceIndex = 0; - resource.groundY = resource.to.y; - continue; - } - - if (resource.phase === "bounce") { - const duration = BOUNCE_DURATIONS[resource.bounceIndex]; - const height = BOUNCE_HEIGHTS[resource.bounceIndex]; - - if (resource.age <= duration) { - const t = resource.age / duration; - resource.mesh.position.y = resource.groundY + Math.sin(t * Math.PI) * height; - continue; - } - - resource.mesh.position.y = resource.groundY; - - if (resource.bounceIndex < BOUNCE_HEIGHTS.length - 1) { - resource.bounceIndex++; - resource.age = 0; - continue; - } - - // Last bounce just touched down — flash/sparkle, once. - SparkleFxC.spawnBurst(resource.mesh.getWorldPosition(new Vector3())); - resource.phase = "impactHold"; - resource.age = 0; - continue; - } - - if (resource.phase === "impactHold") { - const shrinkT = Math.min(resource.age / SHRINK_RAMP_DURATION, 1); - const shrink = shrinkT * shrinkT * (3 - 2 * shrinkT) * SHRINK_AMOUNT; // ease in, then hold - resource.mesh.scale.setScalar(1 - shrink); - - const flashT = Math.min(resource.age / FLASH_DURATION, 1); - const flashOpacity = 1 - flashT; - for (const overlay of resource.flashOverlays) { - (overlay.material as MeshBasicMaterial).opacity = flashOpacity; - } - - // staggerDelay only extends this wait, per resource — the - // shrink/flash above are already fully settled by - // IMPACT_HOLD_DURATION, so this only delays the launch. - if (resource.age < IMPACT_HOLD_DURATION + resource.staggerDelay) continue; - - for (const overlay of resource.flashOverlays) { - (overlay.material as MeshBasicMaterial).opacity = 0; - } - - if (!this.flyTarget) { - this.collect(resource); - this.flying.splice(i, 1); - continue; - } - - resource.phase = "toTarget"; - resource.age = 0; - resource.from = resource.mesh.position.clone(); - resource.baseScale = resource.mesh.scale.x; - resource.startDistanceFromCamera = CameraC_internal.getCamera().position.distanceTo(resource.from); - continue; - } - - // phase === "toTarget" - if (!this.flyTarget) { - this.collect(resource); - this.flying.splice(i, 1); - continue; - } - - const t = Math.min(resource.age / TO_TARGET_DURATION, 1); - const eased = t * t * (3 - 2 * t); // smoothstep — eases in and out - // Re-sampled every frame, not snapshotted at launch: the HUD anchor - // can keep moving during the flight (camera follow), so a stale - // one-time snapshot would miss the icon. An accelerating (ease-in) - // curve was tried here and reverted — it desyncs spatial progress - // from the raw elapsed time CONVERGE_START_FRACTION uses below, - // making the resource vanish well short of the target. - resource.to = this.flyTarget(); - resource.mesh.position.lerpVectors(resource.from, resource.to, eased); - resource.mesh.position.y += Math.sin(t * Math.PI) * TO_TARGET_ARC_HEIGHT * (1 - t); - - // The target sits much closer to the camera than where resources - // spawn, so without compensation the resource would visibly grow as - // it approaches (perspective foreshortening, not a real scale - // change) — see perspectiveCompensationScale. - const cameraPosition = CameraC_internal.getCamera().position; - const perspectiveScale = perspectiveCompensationScale( - cameraPosition, - resource.mesh.position, - resource.startDistanceFromCamera - ); - resource.mesh.scale.setScalar(resource.baseScale * perspectiveScale); - - // Brightens over the last stretch, reading as absorbed into the - // counter icon rather than popping out mid-air. Reuses the same - // flashOverlays the bounce-impact flash used, idle since then. - const convergeT = Math.max(0, (t - CONVERGE_START_FRACTION) / (1 - CONVERGE_START_FRACTION)); - for (const overlay of resource.flashOverlays) { - (overlay.material as MeshBasicMaterial).opacity = convergeT; - } - - if (t >= 1) { - this.collect(resource); - this.flying.splice(i, 1); - } - } - } - - private static collect(resource: FlyingResource) { - this.collected++; - ThreeC.removeFromScene(resource.mesh); - } } diff --git a/src/controllers/TestSceneC.ts b/src/controllers/TestSceneC.ts index 8e04117..152def3 100644 --- a/src/controllers/TestSceneC.ts +++ b/src/controllers/TestSceneC.ts @@ -1,19 +1,26 @@ -import { Object3D, Vector3 } from "three"; +import { Box3, Object3D, Vector3 } from "three"; import { ThreeC } from "./ThreeC"; import { PlayerC } from "./PlayerC"; import { CameraFollowC } from "./CameraFollowC"; import { PhysicsBody, PhysicsLayer } from "./PhysicsC"; import { BreakablePropC } from "./BreakablePropC"; import { ResourceC } from "./ResourceC"; +import { ZombiePartC } from "./ZombiePartC"; import { PayZoneC } from "./PayZoneC"; import { HudC } from "./HudC"; +import { PistolResourceHudC } from "./PistolResourceHudC"; import { WeaponHudC } from "./WeaponHudC"; +import { BatLevelC } from "./BatLevelC"; +import { PistolHudC } from "./PistolHudC"; +import { PistolLevelC } from "./PistolLevelC"; +import { WeaponAuraC } from "./WeaponAuraC"; import { ZombieInvasionHudC } from "./ZombieInvasionHudC"; import { PropVfxC } from "./PropVfxC"; import { WeaponTrailC } from "./WeaponTrailC"; import { SparkleFxC } from "./SparkleFxC"; import { HealthBarC } from "./HealthBarC"; import { DebugConfigC } from "./DebugConfigC"; +import { ZombieC } from "./ZombieC"; // Above and behind the player, along the road's forward (+Z) direction — // player now spawns at the other end of the road, walking toward +Z, so @@ -26,6 +33,14 @@ import { DebugConfigC } from "./DebugConfigC"; // black screen. export const CAMERA_OFFSET = new Vector3(0, 8, -6); +// The map ships one interactive-zone marker ("UI_Interactive_Zone_02"), +// halved in footprint and used as-is for the pistol's own Pay Zone; the bat's +// is a clone of it, placed next to it (see createMap) — two fixed zones, no +// relocation, replacing the old single randomly-relocating one. +const PAY_ZONE_SCALE = 0.5; +// World units of clear ground between the two zones' footprints. +const PAY_ZONE_GAP = 1.5; + export class TestSceneC { static init() { DebugConfigC.init(); @@ -33,7 +48,7 @@ export class TestSceneC { SparkleFxC.init(); HealthBarC.init(); - const { colliders, payZoneNode } = this.createMap(); + const { colliders, batZoneNode, pistolZoneNode, mapBounds } = this.createMap(); PlayerC.init(colliders); CameraFollowC.init(PlayerC.object, CAMERA_OFFSET); @@ -48,31 +63,85 @@ export class TestSceneC { // right after its own init (see CameraFollowC — it never rotates again). WeaponTrailC.init(PlayerC.getWeaponAnchor()); - // Reuses the map's own "UI_Interactive_Zone_02" marker as the Pay - // Zone's position/visual (see PayZoneC) — drains ResourceC's collected - // total into the zone while the player stands in it. Must come after - // PlayerC.init(): PlayerC.object doesn't exist yet inside createMap(). - PayZoneC.init(payZoneNode, PlayerC.object); + // Two fixed Pay Zones, side by side (see createMap) — no relocation, no + // randomness. Each drains a different resource into a different level: + // wood into the bat's, zombie-part chunks into the pistol's. Must come + // after PlayerC.init(): PlayerC.object doesn't exist yet inside + // createMap(). + const batPayZone = new PayZoneC({ + node: batZoneNode, + target: PlayerC.object, + getDepositOrigin: () => PlayerC.getHeadWorldPosition(), + getCollectedCount: () => ResourceC.getCollectedCount(), + createDepositVisual: () => ResourceC.createVisual(), + onFilled: () => BatLevelC.levelUp(), + buildRewardIcon: (size) => PlayerC.createBatDisplay(size), + }); + batPayZone.init(); - // HUD counter (top-right) — collected resources fly to it instead of - // just vanishing, and it's also where PayZoneC's deposit-into-the-zone - // flight originates from. The displayed number is collected minus - // delivered — rises on collection, falls as PayZoneC drains it into - // the zone. + const pistolPayZone = new PayZoneC({ + node: pistolZoneNode, + target: PlayerC.object, + getDepositOrigin: () => PlayerC.getHeadWorldPosition(), + getCollectedCount: () => ZombiePartC.getCollectedCount(), + createDepositVisual: () => ZombiePartC.createVisual(), + onFilled: () => PistolLevelC.levelUp(), + buildRewardIcon: (size) => PlayerC.createPistolDisplay(size), + }); + pistolPayZone.init(); + + // Wood HUD counter (top-right) — collected resources fly to it instead of + // just vanishing, and it's also where the bat Pay Zone's deposit flight + // originates from. The displayed number is collected minus delivered — + // rises on collection, falls as the zone drains it. HudC.init(); ResourceC.setFlyTarget(() => HudC.getWorldAnchorPosition()); - HudC.setBalanceGetter(() => ResourceC.getCollectedCount() - PayZoneC.getDepositedCount()); + HudC.setBalanceGetter(() => ResourceC.getCollectedCount() - batPayZone.getDepositedCount()); - // Bottom-left weapon/level plate — static visual, independent of - // everything above (see WeaponHudC). + // Second HUD counter, stacked below the wood one — same treatment for the + // zombie-part chunks that feed the pistol zone instead. + PistolResourceHudC.init(); + ZombiePartC.setFlyTarget(() => PistolResourceHudC.getWorldAnchorPosition()); + PistolResourceHudC.setBalanceGetter(() => ZombiePartC.getCollectedCount() - pistolPayZone.getDepositedCount()); + + // Bottom-left octagon plate — the live BAT level, raised by the wood Pay + // Zone (see BatLevelC). WeaponHudC.init(); + WeaponHudC.setLevelGetter(() => BatLevelC.getLevel()); - // Top-center "ZOMBIE INVASION" timer bar — also fully independent, - // starts its own timer slide immediately on init (see - // ZombieInvasionHudC). Duration comes from DebugConfigC (itself seeded - // from the platform's own configUIParams, see gameplay.ts) rather than - // ZombieInvasionHudC's own hardcoded default, so a platform-configured - // value actually takes effect in a real build. + // Stacked directly below it — the live PISTOL level, raised by the + // zombie-part Pay Zone instead (see PistolLevelC), plus the matching + // in-world glow on the drawn pistol. The glow's anchor is a getter + // because PlayerC's holstered pistol has no position of its own to track + // — see getPistolAuraAnchor. + PistolHudC.init(); + PistolHudC.setLevelGetter(() => PistolLevelC.getLevel()); + WeaponAuraC.init(() => PlayerC.getPistolAuraAnchor()); + + // Placeholder enemies (primitive figures, no real zombie models exist + // yet). Dormant until the invasion timer fills, and then endless — the + // wave that timer releases keeps being reinforced for the rest of the run + // (see ZombieC's REINFORCEMENT_INTERVAL_S / MAX_ALIVE). + // Gets the player's position through a getter rather than importing + // PlayerC, because PlayerC imports ZombieC to find and shoot targets and + // the pair would otherwise cycle. + ZombieC.init( + mapBounds, + () => PlayerC.object.position, + (amount) => PlayerC.takeDamage(amount), + () => PlayerC.isAlive() + ); + + // Top-center "ZOMBIE INVASION" timer bar — starts its own timer slide + // immediately on init (see ZombieInvasionHudC). Duration comes from + // DebugConfigC (itself seeded from the platform's own configUIParams, see + // gameplay.ts) rather than ZombieInvasionHudC's own hardcoded default, so + // a platform-configured value actually takes effect in a real build. + // + // setOnComplete MUST precede init(): init() starts the countdown on the + // spot, and the bar owns no knowledge of zombies itself — the bar filling + // is simply what releases the (from then on endless) invasion. + ZombieInvasionHudC.setOnComplete(() => ZombieC.startWave()); ZombieInvasionHudC.init(DebugConfigC.getZombieInvasionTimerS()); } @@ -117,13 +186,25 @@ export class TestSceneC { if (installMockup) installMockup.visible = false; // "Lootable" crates own their collider bodies themselves (so they can be - // torn down again when a crate breaks) — everything else just gets a - // plain static wall body. - const propColliders = BreakablePropC.init(map.getObjectByName("Lootable"), (colliderNode) => { - const index = colliders.indexOf(colliderNode); - if (index !== -1) colliders.splice(index, 1); - PlayerC.removeObstacle(colliderNode); - }); + // torn down again when a crate breaks, and rebuilt when it respawns) — + // everything else just gets a plain static wall body. + // + // The two callbacks are mirrors of each other: a broken crate stops blocking + // movement, and the same crate blocks again when its respawn timer fires + // (see BreakablePropC's RESPAWN_DELAY_MS). The collider NODES themselves are + // reused across that cycle, never rebuilt. + const propColliders = BreakablePropC.init( + map.getObjectByName("Lootable"), + (colliderNode) => { + const index = colliders.indexOf(colliderNode); + if (index !== -1) colliders.splice(index, 1); + PlayerC.removeObstacle(colliderNode); + }, + (colliderNode) => { + if (!colliders.includes(colliderNode)) colliders.push(colliderNode); + PlayerC.addObstacle(colliderNode); + } + ); for (const collider of colliders) { if (propColliders.has(collider)) continue; @@ -138,6 +219,50 @@ export class TestSceneC { ThreeC.addToScene(map); - return { colliders, payZoneNode: map.getObjectByName("UI_Interactive_Zone_02") }; + // Real world extents of the loaded map, measured — not a guessed radius + // (see CLAUDE.md: footprints come from Box3.setFromObject, never from + // eyeballing a screenshot). ZombieC clamps its spawn ring into this so + // nobody walks in from beyond the ground mesh. + const mapBounds = new Box3().setFromObject(map); + + const { batZoneNode, pistolZoneNode } = this.createPayZoneNodes(map); + + return { colliders, batZoneNode, pistolZoneNode, mapBounds }; + } + + // The pistol zone keeps the map's own authored marker (shrunk in place); + // the bat zone is a plain clone of it, offset alongside — no new art, and + // no random placement (see PayZoneC — both zones now stay put for the + // whole run). + private static createPayZoneNodes(map: Object3D): { + batZoneNode: Object3D | undefined; + pistolZoneNode: Object3D | undefined; + } { + const pistolZoneNode = map.getObjectByName("UI_Interactive_Zone_02"); + if (!pistolZoneNode) return { batZoneNode: undefined, pistolZoneNode: undefined }; + + pistolZoneNode.scale.multiplyScalar(PAY_ZONE_SCALE); + // Box3.setFromObject below reads matrixWorld directly — the scale change + // above has to reach it first (see BreakablePropC.respawn for the same + // requirement after a transform change). + pistolZoneNode.updateWorldMatrix(true, true); + + const batZoneNode = pistolZoneNode.clone(); + pistolZoneNode.parent?.add(batZoneNode); + + // Offset purely along world X by the (now-halved) zone's own width plus a + // gap — PayZoneC's fill quad and footprint checks are already world-axis- + // aligned (see its own notes), so this doesn't need to account for any + // local rotation on the marker itself. + const size = new Box3().setFromObject(pistolZoneNode).getSize(new Vector3()); + const targetWorld = pistolZoneNode + .getWorldPosition(new Vector3()) + .add(new Vector3(size.x + PAY_ZONE_GAP, 0, 0)); + + const parent = batZoneNode.parent; + batZoneNode.position.copy(parent ? parent.worldToLocal(targetWorld) : targetWorld); + batZoneNode.updateWorldMatrix(true, true); + + return { batZoneNode, pistolZoneNode }; } } diff --git a/src/controllers/WeaponAuraC.ts b/src/controllers/WeaponAuraC.ts new file mode 100644 index 0000000..10f8812 --- /dev/null +++ b/src/controllers/WeaponAuraC.ts @@ -0,0 +1,125 @@ +import { AdditiveBlending, CanvasTexture, Sprite, SpriteMaterial, Vector3 } from "three"; +import { UpdateController } from "@hitplay/playable_template"; +import { ThreeC } from "./ThreeC"; +import { PistolLevelC } from "./PistolLevelC"; + +// A soft additive glow riding on the drawn pistol, one visible sign that the +// pistol has been upgraded (see PistolLevelC / its Pay Zone). Level 0 shows +// nothing at all; every level after that is a bit bigger and a bit brighter, +// flattening out at MAX_EFFECT_LEVEL so a long run can't end in a white blob. +// +// A Sprite, not a mesh overlay or a light: +// * Character_Pistol is a SkinnedMesh, so a same-geometry overlay clone would +// have to be re-bound to the character's skeleton to deform with it — a +// Sprite sidesteps skinning entirely. +// * A PointLight would do nothing if the character's materials turn out to be +// unlit (the crates' are — see CLAUDE.md), and costs a shader recompile. +// SpriteMaterial is always camera-facing and ignores scene lighting, so this +// looks the same regardless of what the rig's own materials are. +const BASE_SIZE = 0.34; // world units at level 1 +const SIZE_PER_LEVEL = 0.07; +const BASE_OPACITY = 0.16; // at level 1 — deliberately faint +const OPACITY_PER_LEVEL = 0.08; +// Where both ramps stop. Past this the pistol keeps levelling (it's uncapped, +// see PistolLevelC) but the glow doesn't grow any further. +const MAX_EFFECT_LEVEL = 6; + +// Very slow, very shallow breathe on top of the level-driven brightness — just +// enough to read as energy rather than a decal stuck to the gun. Scaled by +// level too, so a level-1 glow is nearly steady. +const PULSE_SPEED = 2.6; +const PULSE_DEPTH = 0.18; // fraction of the current opacity + +// Warm gold, matching "upgraded/valuable" rather than the green used for +// resources and the exp orbs. +const GLOW_COLOR = 0xffcc66; + +// Radial gradient, opaque at the middle and fully transparent at the rim, +// painted once into a small canvas. Small on purpose: it's a blurry blob, so +// texture resolution is invisible here and 64px keeps the base64 export cost at +// nothing (it's generated at runtime, not shipped as an asset). +const TEXTURE_SIZE = 64; + +function createGlowTexture(): CanvasTexture { + const canvas = document.createElement("canvas"); + canvas.width = TEXTURE_SIZE; + canvas.height = TEXTURE_SIZE; + + const context = canvas.getContext("2d")!; + const half = TEXTURE_SIZE / 2; + const gradient = context.createRadialGradient(half, half, 0, half, half, half); + // Three stops, not two: a plain linear falloff reads as a hard-edged disc. + // The middle stop is what gives it a soft core with a long faint halo. + gradient.addColorStop(0, "rgba(255, 255, 255, 1)"); + gradient.addColorStop(0.35, "rgba(255, 255, 255, 0.45)"); + gradient.addColorStop(1, "rgba(255, 255, 255, 0)"); + + context.fillStyle = gradient; + context.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE); + + return new CanvasTexture(canvas); +} + +export class WeaponAuraC { + private static initialized = false; + private static sprite: Sprite; + private static material: SpriteMaterial; + private static getAnchor: () => Vector3 | null = () => null; + private static elapsed = 0; + + // `getAnchor` returns where the glow should sit right now, or null when it + // shouldn't show at all — PlayerC returns the gun-hand bone's world position + // while the pistol is actually drawn, and null while it's holstered (the + // holstered pistol is skinned to the hip and has no node position of its own + // to track, see PlayerC.getPistolAuraAnchor). + static init(getAnchor: () => Vector3 | null) { + if (this.initialized) return; + this.initialized = true; + + this.getAnchor = getAnchor; + + this.material = new SpriteMaterial({ + map: createGlowTexture(), + color: GLOW_COLOR, + transparent: true, + opacity: 0, + blending: AdditiveBlending, + // Additive glow must never occlude anything, and must not be occluded by + // the gun/hand it's meant to be wrapped around. + depthWrite: false, + depthTest: false, + }); + + this.sprite = new Sprite(this.material); + this.sprite.visible = false; + // Renders after the scene proper — with depthTest off, draw order is the + // only thing left deciding what ends up on top. + this.sprite.renderOrder = 10; + ThreeC.addToScene(this.sprite); + + UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta)); + } + + private static update(delta: number) { + if (!this.initialized) return; + + const level = PistolLevelC.getLevel(); + const anchor = level > 0 ? this.getAnchor() : null; + + this.sprite.visible = anchor !== null; + if (!anchor) return; + + this.elapsed += delta; + + // Level 1 is one step in, not zero steps — an upgrade that produced no + // visible change at all would defeat the point. + const steps = Math.min(level, MAX_EFFECT_LEVEL) - 1; + const size = BASE_SIZE + steps * SIZE_PER_LEVEL; + const opacity = BASE_OPACITY + steps * OPACITY_PER_LEVEL; + const pulseDepth = PULSE_DEPTH * (steps / Math.max(1, MAX_EFFECT_LEVEL - 1)); + + this.sprite.position.copy(anchor); + this.sprite.scale.setScalar(size); + this.material.opacity = opacity * (1 - pulseDepth + pulseDepth * Math.sin(this.elapsed * PULSE_SPEED)); + } +} diff --git a/src/controllers/WeaponHudC.ts b/src/controllers/WeaponHudC.ts index 7731888..3f36d4d 100644 --- a/src/controllers/WeaponHudC.ts +++ b/src/controllers/WeaponHudC.ts @@ -1,31 +1,54 @@ +import { UpdateController } from "@hitplay/playable_template"; import { weaponHudBgSrc, weaponIconBatSrc } from "../resources/images/images"; -// Bottom-left "weapon level" plate (octagon background + weapon icon + -// "LVL" text) — purely a visual recreation of the reference. No real -// weapon-leveling system exists in this project, so "LVL 0" is a hardcoded -// label, not backed by any progression state. +// Bottom-left "bat level" plate (octagon background + bat icon + "LVL" +// text) — a visual recreation of the reference, backed by real progression: +// the number is the BAT level, raised by filling the wood Pay Zone (see +// BatLevelC / PayZoneC), separate from the pistol's own level shown in the +// widget stacked below it (see PistolHudC). // // Always shows the bat icon (Tool_2), regardless of which weapon PlayerC // currently has equipped (pistol vs bat, see PlayerC.equipPistol/equipBat) // — not wired to PlayerC's live equip state. export class WeaponHudC { private static initialized = false; + private static plateEl: HTMLElement; + private static levelTextEl: HTMLElement; + private static lastDisplayedLevel = -1; + private static levelGetter: (() => number) | null = null; + + // Injected rather than importing BatLevelC directly, matching the rest of + // the HUD widgets in this project (HudC.setBalanceGetter, + // PistolHudC.setLevelGetter). + static setLevelGetter(getLevel: () => number) { + this.levelGetter = getLevel; + } static init() { if (this.initialized) return; this.initialized = true; this.buildDom(); + + UpdateController.Instance.onUpdate.addDelegate(() => this.update()); } - // Layout/styling lives in src/css/ui.css (.weapon-hud/__icon/__level) — - // only the two background-driven image sources are set here, same + // Layout/styling lives in src/css/ui.css (.weapon-hud/__plate/__icon/__level) + // — only the two background-driven image sources are set here, same // reasoning as HudC's resource-counter plate (asset paths are only known // via the build's asset pipeline, not something a static CSS file can // reference). + // + // Two nested divs, not one, for exactly the reason spelled out in ui.css for + // .resource-hud: the outer element owns the one-shot mount animation, the + // inner plate owns the upgrade bump. Sharing one element would make every + // upgrade replay the mount animation as well. private static buildDom() { const holder = document.createElement("div"); holder.className = "weapon-hud"; - holder.style.backgroundImage = `url(${weaponHudBgSrc})`; + + const plate = document.createElement("div"); + plate.className = "weapon-hud__plate"; + plate.style.backgroundImage = `url(${weaponHudBgSrc})`; const icon = document.createElement("img"); icon.className = "weapon-hud__icon"; @@ -42,10 +65,32 @@ export class WeaponHudC { levelText.textContent = "LVL 0"; level.appendChild(levelText); - holder.appendChild(icon); - holder.appendChild(level); + plate.appendChild(icon); + plate.appendChild(level); + holder.appendChild(plate); const uiRoot = document.getElementById("ui-safe-area") ?? document.body; uiRoot.appendChild(holder); + + this.plateEl = plate; + this.levelTextEl = levelText; + } + + private static update() { + if (!this.levelGetter) return; + const level = this.levelGetter(); + if (level === this.lastDisplayedLevel) return; + + const isFirstPaint = this.lastDisplayedLevel === -1; + this.lastDisplayedLevel = level; + this.levelTextEl.textContent = `LVL ${level}`; + + // No pop on the first paint — that's just the plate showing its starting + // level. Same remove/reflow/add dance HudC uses, so a second upgrade + // landing quickly still restarts the animation cleanly. + if (isFirstPaint) return; + this.plateEl.classList.remove("is-upgrading"); + void this.plateEl.offsetWidth; + this.plateEl.classList.add("is-upgrading"); } } diff --git a/src/controllers/ZombieC.ts b/src/controllers/ZombieC.ts new file mode 100644 index 0000000..8fc6082 --- /dev/null +++ b/src/controllers/ZombieC.ts @@ -0,0 +1,618 @@ +import { + Box3, + BoxGeometry, + CapsuleGeometry, + Group, + Mesh, + MeshStandardMaterial, + Object3D, + SphereGeometry, + Vector3, +} from "three"; +import { UpdateController } from "@hitplay/playable_template"; +import { ThreeC } from "./ThreeC"; +import { HealthBarC, HealthBarOptions } from "./HealthBarC"; +import { ZombiePartC } from "./ZombiePartC"; + +// The opening wave, spawned in a staggered trickle rather than all at once — +// eight figures popping into existence on the same frame reads as a glitch, +// one every SPAWN_INTERVAL_S reads as "they keep coming". +const WAVE_SIZE = 8; +const SPAWN_INTERVAL_S = 0.7; + +// The invasion never ends: once the opening wave is out, reinforcements keep +// arriving one at a time, forever, at this slower cadence. Slower than +// SPAWN_INTERVAL_S on purpose — a permanent 0.7s drip would out-produce the +// player's kill rate within seconds and turn into an unplayable wall. +const REINFORCEMENT_INTERVAL_S = 10; + +// Hard cap on how many living zombies exist at once. Spawning is paused (not +// cancelled — the timer just retries a cadence later) while at the cap. This +// is what keeps the endless stream survivable AND keeps applySeparation's +// O(n²) pass over the crowd bounded. +const MAX_ALIVE = 14; + +// Where a zombie appears: on a ring of this radius around the player, at a +// random angle, then clamped into the map's own XZ bounds (see +// randomSpawnPoint). The clamp is what makes them come "from the edge of the +// map" — the map is far longer on Z (~53 units) than it is wide (~37), so on +// the narrow sides the ring lands outside the ground and gets pulled back to +// the actual edge, while on the long side the ring radius is what applies. +const SPAWN_DISTANCE = 15; + +// A spawn is re-rolled if clamping pulled it closer than this. Deliberately +// larger than PlayerC's SHOOT_RANGE: a zombie must never materialize already +// inside the player's firing zone, or the pistol draw would trigger with no +// approach to watch first. +const MIN_SPAWN_DISTANCE = 10; + +// Keeps spawns off the very lip of the ground mesh, where a figure would be +// half-hanging over the edge. +const MAP_EDGE_MARGIN = 1.5; + +// Slower than the player's MOVE_SPEED (4) on purpose — the player has to be +// able to walk away from them, otherwise the shooting zone can never clear. +const MOVE_SPEED = 1.6; + +// Zombies stop this far from the player instead of walking into (and through) +// him — there is no zombie<->player collision, this distance is what stands in +// for it. +const STOP_DISTANCE = 1.3; + +// Cheap mutual repulsion so a crowd converging on one point doesn't collapse +// into a single overlapping stack. O(n²) over WAVE_SIZE items — irrelevant at +// this count, revisit only if waves get much bigger. +const SEPARATION_DISTANCE = 0.9; +const SEPARATION_PUSH = 1.4; + +// Both fixed by the brief: 100 HP against PlayerC's 20 damage per shot = five +// shots per zombie. +export const ZOMBIE_MAX_HP = 100; + +// 10 damage once per second, per zombie — applied as a discrete tick rather +// than a continuous 10*delta drain so it produces the same readable "-10" +// popup the crates do. Two zombies on you is 20/s, which is what makes a +// crowd actually dangerous. +const ATTACK_DAMAGE = 10; +const ATTACK_INTERVAL_S = 1; + +// Slightly beyond STOP_DISTANCE, so a zombie that has settled at its stopping +// distance is reliably inside its own attack range instead of hovering right +// on the boundary and flickering in and out of it. +const ATTACK_RANGE = 1.7; + +// Short forward jab on each attack tick, decaying back — cheap stand-in for +// an attack animation these primitive figures don't have. +const LUNGE_DISTANCE = 0.28; +const LUNGE_DURATION_S = 0.25; + +// How far above the figure's own head the health bar floats. +const HEALTH_BAR_HEIGHT = 2.05; + +// Emissive red pulse on hit, decaying back to black. Emissive (not `color`) +// on a MeshStandardMaterial — same reason flashOverlayMaterial.ts exists for +// the crates, except here we own the material outright, so the cheap channel +// is available and no overlay mesh is needed. +const HIT_FLASH_S = 0.16; +const HIT_FLASH_COLOR = 0xff3a2a; + +// Fall over, sink, shrink, then get removed. Driven by a plain accumulator in +// update() rather than a tween: tween.onComplete is documented as unreliable +// in this project (see CLAUDE.md), and cleanup here MUST happen or the scene +// leaks meshes. +const DEATH_DURATION_S = 0.7; +const DEATH_FALL_FRACTION = 0.45; // of DEATH_DURATION_S spent tipping over +const DEATH_SINK = 0.35; // world units the corpse sinks into the ground + +// Shambling bob — a vertical sine on the figure, not on the root, so it can't +// fight the death animation's own transform of the same node. +const BOB_SPEED = 7; +const BOB_HEIGHT = 0.06; + +// Walk cycle: legs swing in antiphase around their hip pivots, arms +// counter-swing by a smaller amount. Not driven by BOB_SPEED (which is the +// body's own vertical shamble at double the stride rate) — a stride is one +// full sine period, so this is deliberately its own, slower rate. +const STRIDE_SPEED = 4.6; // radians/second of the swing sine +const LEG_SWING = 0.55; // radians, peak swing from vertical +const ARM_SWING = 0.2; + +// The swing amplitude fades in/out over roughly a fifth of a second instead of +// snapping on and off, so a zombie arriving at STOP_DISTANCE settles into its +// standing pose rather than freezing mid-stride on one frame. +const STRIDE_BLEND_SPEED = 5; + +// Zombie-part chunks dropped per kill (see ZombiePartC — they fly to +// PistolResourceHudC and feed PistolLevelC). Spawned from chest height rather +// than the feet-anchored root, so they burst out of the body as it starts +// falling. +const ZOMBIE_PART_DROP_MIN = 1; +const ZOMBIE_PART_DROP_MAX = 3; +const ZOMBIE_PART_DROP_HEIGHT = 0.9; + +export interface Zombie { + root: Object3D; + // The visual figure, one level below root: root owns position + facing, + // this owns bob and the death tip-over. Keeping them on separate nodes is + // what lets lookAt() (which overwrites the whole quaternion) coexist with a + // death rotation instead of clobbering it every frame. + figure: Group; + // Limb pivots, one level below `figure`: each holds its limb mesh offset so + // that rotating the pivot swings the limb from the hip/shoulder instead of + // spinning it around its own middle. See createZombie. + legLeft: Group; + legRight: Group; + armLeft: Group; + armRight: Group; + material: MeshStandardMaterial; + maxHp: number; + currentHp: number; + dying: boolean; + deathElapsed: number; + flashElapsed: number; + bobPhase: number; + // Walk cycle: `stridePhase` runs the swing sine, `strideAmount` (0..1) is + // its amplitude, eased toward 1 while actually walking and toward 0 once + // stopped at the player — see updateWalkCycle. + stridePhase: number; + strideAmount: number; + // Counts up to ATTACK_INTERVAL_S while in range of the player; starts full + // so the first hit lands the moment a zombie arrives rather than a second + // after it. + attackCooldown: number; + lungeElapsed: number; +} + +// Geometry is identical for every zombie, so it's built once and shared; +// only the material is per-instance (each one flashes independently on hit). +let sharedGeometry: { + leg: BoxGeometry; + torso: CapsuleGeometry; + head: SphereGeometry; + arm: BoxGeometry; +} | null = null; + +function getSharedGeometry() { + if (!sharedGeometry) { + sharedGeometry = { + leg: new BoxGeometry(0.18, 0.6, 0.18), + torso: new CapsuleGeometry(0.26, 0.3, 4, 10), + head: new SphereGeometry(0.21, 14, 10), + arm: new BoxGeometry(0.14, 0.14, 0.62), + }; + } + return sharedGeometry; +} + +// Placeholder enemies: primitive-built humanoid figures standing in for real +// zombie models that don't exist yet. Everything about them is deliberately +// simple — no physics body, no navigation, no attack on the player — because +// the only thing they need to support right now is "something approaches, the +// player shoots it, it dies". +// +// Deliberately does NOT import PlayerC: PlayerC imports THIS file (to find +// and damage targets), so the player's position arrives through an injected +// getter instead, same DI pattern as ResourceC.setFlyTarget etc. +export class ZombieC { + private static initialized = false; + private static zombies: Zombie[] = []; + private static getPlayerPosition: () => Vector3 = () => new Vector3(); + private static damagePlayer: (amount: number) => void = () => {}; + private static isPlayerAlive: () => boolean = () => true; + private static mapBounds = new Box3(); + + private static waveActive = false; + private static pendingSpawns = 0; + private static spawnTimer = 0; + + // mapBounds is the map's real world bounding box (Box3.setFromObject), + // not a guessed radius — spawn points are clamped into it so nobody walks + // in out of the void beyond the ground mesh. + // + // The player arrives entirely through callbacks (position / take damage / + // still alive) rather than an import: PlayerC imports THIS file to find and + // shoot targets, so the pair would otherwise cycle. + static init( + mapBounds: Box3, + getPlayerPosition: () => Vector3, + damagePlayer: (amount: number) => void, + isPlayerAlive: () => boolean + ) { + if (this.initialized) return; + this.initialized = true; + + this.mapBounds = mapBounds; + this.getPlayerPosition = getPlayerPosition; + this.damagePlayer = damagePlayer; + this.isPlayerAlive = isPlayerAlive; + + UpdateController.Instance.onUpdate.addDelegate((delta: number) => this.update(delta)); + } + + // Wired to the ZOMBIE INVASION timer finishing — see + // ZombieInvasionHudC.setOnComplete. Idempotent: a second call while a wave + // is already running is ignored rather than stacking two waves (the debug + // panel can restart the timer, which would otherwise fire this again). + static startWave() { + if (this.waveActive) return; + this.waveActive = true; + this.pendingSpawns = WAVE_SIZE; + // First zombie appears immediately, not one interval late — the timer + // hitting zero and nothing happening for a beat reads as a bug. + this.spawnTimer = SPAWN_INTERVAL_S; + } + + static isWaveActive(): boolean { + return this.waveActive; + } + + // Nearest living zombie within `radius` of `point`, or null. PlayerC calls + // this every frame to decide whether to draw the pistol and who to aim at — + // dying zombies are excluded so the last shot doesn't keep being spent on a + // corpse mid-fall. + static findNearestAlive(point: Vector3, radius: number): Zombie | null { + let nearest: Zombie | null = null; + let nearestDistanceSq = radius * radius; + + for (const zombie of this.zombies) { + if (zombie.dying) continue; + const distanceSq = zombie.root.position.distanceToSquared(point); + if (distanceSq > nearestDistanceSq) continue; + nearest = zombie; + nearestDistanceSq = distanceSq; + } + + return nearest; + } + + // Where a shot should visually land — chest height, not the feet-anchored + // root position. + static getAimPoint(zombie: Zombie): Vector3 { + return zombie.root.position.clone().add(new Vector3(0, 1.1, 0)); + } + + static damage(zombie: Zombie, amount: number) { + if (zombie.dying) return; + + const previousHp = zombie.currentHp; + zombie.currentHp = Math.max(0, zombie.currentHp - amount); + zombie.flashElapsed = 0; + zombie.material.emissive.setHex(HIT_FLASH_COLOR); + + HealthBarC.onHit(zombie, this.healthBarOptions(zombie), previousHp, zombie.currentHp, amount); + + if (zombie.currentHp === 0) { + zombie.dying = true; + zombie.deathElapsed = 0; + // Dropped as soon as it starts dying rather than when the corpse is + // finally removed — a full bar-with-0-HP hovering over a falling body + // for most of a second reads as a bug. + HealthBarC.onDestroyed(zombie); + // Same timing reasoning for the loot: the chunks burst out as the body + // starts to fall, not once it has finished sinking. + ZombiePartC.spawnBurstInRange( + zombie.root.position.clone().add(new Vector3(0, ZOMBIE_PART_DROP_HEIGHT, 0)), + ZOMBIE_PART_DROP_MIN, + ZOMBIE_PART_DROP_MAX + ); + } + } + + // `follow` — unlike the crates', these bars track a moving owner, so the + // anchor is re-sampled every frame instead of cached at creation. + private static healthBarOptions(zombie: Zombie): HealthBarOptions { + return { + variant: "enemy", + maxHp: zombie.maxHp, + follow: true, + getWorldTop: () => zombie.root.position.clone().add(new Vector3(0, HEALTH_BAR_HEIGHT, 0)), + }; + } + + private static update(delta: number) { + if (!this.waveActive) return; + + this.updateSpawning(delta); + + const playerPosition = this.getPlayerPosition(); + + // Iterated backwards: dead zombies splice themselves out of this same + // array inside the loop. + for (let i = this.zombies.length - 1; i >= 0; i--) { + const zombie = this.zombies[i]; + + this.updateHitFlash(zombie, delta); + + if (zombie.dying) { + if (this.updateDeath(zombie, delta)) this.zombies.splice(i, 1); + continue; + } + + this.updateApproach(zombie, playerPosition, delta); + } + } + + // Spawning never stops once the wave is out — the opening WAVE_SIZE arrives + // at the fast SPAWN_INTERVAL_S cadence, then reinforcements keep trickling in + // at REINFORCEMENT_INTERVAL_S for the rest of the run, capped at MAX_ALIVE + // living zombies at a time. + private static updateSpawning(delta: number) { + // Nothing new arrives once the player is down — the game-over veil is up + // and a restart is a few seconds away, so a crowd still building behind it + // is pure wasted work. + if (!this.isPlayerAlive()) return; + + const interval = this.pendingSpawns > 0 ? SPAWN_INTERVAL_S : REINFORCEMENT_INTERVAL_S; + + this.spawnTimer += delta; + if (this.spawnTimer < interval) return; + + this.spawnTimer = 0; + // At the cap the timer has already been reset, so this retries one interval + // later rather than spawning the instant a slot frees up — kills feel like + // they buy breathing room instead of instantly summoning a replacement. + if (this.countAlive() >= MAX_ALIVE) return; + + if (this.pendingSpawns > 0) this.pendingSpawns--; + this.spawn(); + } + + // Dying zombies don't count against MAX_ALIVE: their corpses linger for + // DEATH_DURATION_S and holding a slot for something the player already + // killed would read as the stream stalling. + private static countAlive(): number { + let alive = 0; + for (const zombie of this.zombies) { + if (!zombie.dying) alive++; + } + return alive; + } + + private static spawn() { + const zombie = this.createZombie(); + zombie.root.position.copy(this.randomSpawnPoint(this.getPlayerPosition())); + ThreeC.addToScene(zombie.root); + this.zombies.push(zombie); + } + + // Random angle on a ring around the player, clamped into the map. Retried a + // few times because the clamp can pull a point much closer than intended + // (the player starts near the map's -Z end, so anything spawned "behind" + // him lands right on top of him) — a re-roll picks a different angle, and + // in practice lands on the open, camera-facing side of the map. + private static randomSpawnPoint(playerPosition: Vector3): Vector3 { + const minX = this.mapBounds.min.x + MAP_EDGE_MARGIN; + const maxX = this.mapBounds.max.x - MAP_EDGE_MARGIN; + const minZ = this.mapBounds.min.z + MAP_EDGE_MARGIN; + const maxZ = this.mapBounds.max.z - MAP_EDGE_MARGIN; + + let fallback = new Vector3(); + + for (let attempt = 0; attempt < 8; attempt++) { + const angle = Math.random() * Math.PI * 2; + const point = new Vector3( + playerPosition.x + Math.cos(angle) * SPAWN_DISTANCE, + 0, + playerPosition.z + Math.sin(angle) * SPAWN_DISTANCE + ); + + point.x = Math.min(Math.max(point.x, minX), maxX); + point.z = Math.min(Math.max(point.z, minZ), maxZ); + + if (attempt === 0) fallback = point.clone(); + if (point.distanceTo(playerPosition) >= MIN_SPAWN_DISTANCE) return point; + } + + return fallback; + } + + private static updateApproach(zombie: Zombie, playerPosition: Vector3, delta: number) { + const toPlayer = playerPosition.clone().sub(zombie.root.position); + toPlayer.y = 0; + const distance = toPlayer.length(); + + this.updateAttack(zombie, distance, delta); + + const walking = distance > STOP_DISTANCE; + + if (distance > 1e-4) { + // lookAt orients +Z at the target, which is the axis the figure below + // is modelled facing (arms reach along +Z) — same convention PlayerC + // uses for its own container. + const facingTarget = zombie.root.position.clone().add(toPlayer); + zombie.root.lookAt(facingTarget); + + if (walking) { + toPlayer.divideScalar(distance); // normalize, reusing the length already computed + zombie.root.position.addScaledVector(toPlayer, MOVE_SPEED * delta); + } + } + + this.applySeparation(zombie, delta); + this.updateWalkCycle(zombie, walking, delta); + + zombie.bobPhase += delta * BOB_SPEED; + zombie.figure.position.y = Math.abs(Math.sin(zombie.bobPhase)) * BOB_HEIGHT; + + // Jab forward and ease back. Sits on figure.position.z (local +Z is the + // direction root.lookAt aimed at the player), so it reads as a lunge at + // whoever it's facing without touching the root's own position. + if (zombie.lungeElapsed < LUNGE_DURATION_S) { + zombie.lungeElapsed += delta; + const progress = Math.min(1, zombie.lungeElapsed / LUNGE_DURATION_S); + // Out and back within the one duration — sin(pi * t) peaks at the + // halfway point and returns to zero at the end. + zombie.figure.position.z = Math.sin(Math.PI * progress) * LUNGE_DISTANCE; + } else { + zombie.figure.position.z = 0; + } + } + + // 10 damage a second while standing on the player. The cooldown only ticks + // up while actually in range, so a zombie that has just shuffled into + // contact hits immediately (it starts full) and one that gets pushed away + // resumes rather than restarting its wind-up. + private static updateAttack(zombie: Zombie, distance: number, delta: number) { + if (distance > ATTACK_RANGE || !this.isPlayerAlive()) return; + + zombie.attackCooldown += delta; + if (zombie.attackCooldown < ATTACK_INTERVAL_S) return; + + zombie.attackCooldown = 0; + zombie.lungeElapsed = 0; + this.damagePlayer(ATTACK_DAMAGE); + } + + // Legs swing in antiphase around their hips, arms counter-swing — the actual + // "they walk" read, on top of the body's vertical shamble. The phase only + // advances while moving, so a zombie stopped on top of the player holds a + // still stance instead of marching in place; the amplitude eases in and out + // separately (STRIDE_BLEND_SPEED) so neither transition snaps. + private static updateWalkCycle(zombie: Zombie, walking: boolean, delta: number) { + if (walking) zombie.stridePhase += delta * STRIDE_SPEED; + + const target = walking ? 1 : 0; + const step = STRIDE_BLEND_SPEED * delta; + zombie.strideAmount = + Math.abs(target - zombie.strideAmount) <= step + ? target + : zombie.strideAmount + Math.sign(target - zombie.strideAmount) * step; + + const swing = Math.sin(zombie.stridePhase) * zombie.strideAmount; + + zombie.legLeft.rotation.x = swing * LEG_SWING; + zombie.legRight.rotation.x = -swing * LEG_SWING; + // Opposite sign to the leg on the same side — a same-side arm and leg + // swinging together is the one thing that instantly reads as "broken + // walk cycle" rather than "shambling". + zombie.armLeft.rotation.x = -swing * ARM_SWING; + zombie.armRight.rotation.x = swing * ARM_SWING; + } + + private static applySeparation(zombie: Zombie, delta: number) { + for (const other of this.zombies) { + if (other === zombie || other.dying) continue; + + const away = zombie.root.position.clone().sub(other.root.position); + away.y = 0; + const distance = away.length(); + if (distance >= SEPARATION_DISTANCE || distance < 1e-4) continue; + + // Push strength ramps up as they get closer, so touching shoulders + // nudges gently and a near-total overlap separates hard. + const strength = (SEPARATION_DISTANCE - distance) / SEPARATION_DISTANCE; + away.divideScalar(distance); + zombie.root.position.addScaledVector(away, strength * SEPARATION_PUSH * delta); + } + } + + private static updateHitFlash(zombie: Zombie, delta: number) { + if (zombie.flashElapsed >= HIT_FLASH_S) return; + + zombie.flashElapsed += delta; + const remaining = Math.max(0, 1 - zombie.flashElapsed / HIT_FLASH_S); + zombie.material.emissive.setHex(HIT_FLASH_COLOR).multiplyScalar(remaining); + } + + // Returns true once the corpse has been torn down and should leave the + // array. + private static updateDeath(zombie: Zombie, delta: number): boolean { + zombie.deathElapsed += delta; + const progress = Math.min(1, zombie.deathElapsed / DEATH_DURATION_S); + + // Tips over face-down within the first stretch, then keeps sinking and + // shrinking for the remainder. + const fallProgress = Math.min(1, progress / DEATH_FALL_FRACTION); + zombie.figure.rotation.x = -(Math.PI / 2) * fallProgress; + zombie.figure.position.y = -DEATH_SINK * progress; + zombie.figure.scale.setScalar(1 - 0.25 * progress); + + if (progress < 1) return false; + + ThreeC.removeFromScene(zombie.root); + zombie.material.dispose(); + return true; + } + + private static createZombie(): Zombie { + const geometry = getSharedGeometry(); + + // Per-instance: hit flash writes to `emissive`, and a shared material + // would make every zombie on the map flash on every hit — the same + // shared-material trap the GLB crates have (see CLAUDE.md). + const material = new MeshStandardMaterial({ color: 0x5f9e4a, roughness: 0.85, metalness: 0 }); + + const figure = new Group(); + + // Each limb is a pivot Group at the joint with the mesh hung off it, not a + // bare mesh — a box's own origin is its center, so rotating the mesh + // directly would spin the leg around its knee. The pivot sits at the + // hip/shoulder and the mesh is offset by half its length, which is what + // makes updateWalkCycle's rotation.x read as a swing. + const legLeft = this.createLimb(geometry.leg, material, -0.14, 0.6, 0, new Vector3(0, -0.3, 0)); + const legRight = this.createLimb(geometry.leg, material, 0.14, 0.6, 0, new Vector3(0, -0.3, 0)); + + // Capsule is 0.82 tall overall (0.3 body + 2 * 0.26 caps), sitting on top + // of the 0.6-tall legs. + const torso = new Mesh(geometry.torso, material); + torso.position.set(0, 1.01, 0); + + const head = new Mesh(geometry.head, material); + head.position.set(0, 1.57, 0); + + // Reaching forward along +Z — the axis root.lookAt() aims at the player, + // so the arms always point at whoever they're shambling toward. The + // shoulder pivot is at the body, the arm mesh reaches out from it. + const armLeft = this.createLimb(geometry.arm, material, -0.32, 1.25, 0, new Vector3(0, 0, 0.3)); + const armRight = this.createLimb(geometry.arm, material, 0.32, 1.25, 0, new Vector3(0, 0, 0.3)); + + figure.add(legLeft, legRight, torso, head, armLeft, armRight); + + const root = new Object3D(); + root.add(figure); + ThreeC.setShadowsStateForChildren(root, true, false); + + return { + root, + figure, + legLeft, + legRight, + armLeft, + armRight, + material, + maxHp: ZOMBIE_MAX_HP, + currentHp: ZOMBIE_MAX_HP, + dying: false, + deathElapsed: 0, + flashElapsed: HIT_FLASH_S, // starts "already faded", so no flash on spawn + // Random phase so a crowd doesn't bob in perfect lockstep. + bobPhase: Math.random() * Math.PI * 2, + // Same reasoning for the stride: a crowd walking in perfect step reads + // as a marching formation, not a horde. + stridePhase: Math.random() * Math.PI * 2, + strideAmount: 1, // spawns mid-approach, so already walking + attackCooldown: ATTACK_INTERVAL_S, + lungeElapsed: LUNGE_DURATION_S, // starts "already finished" + }; + } + + // A limb mesh hung off a pivot Group placed at its joint — see the note in + // createZombie for why the mesh can't just be rotated in place. + private static createLimb( + geometry: BoxGeometry, + material: MeshStandardMaterial, + jointX: number, + jointY: number, + jointZ: number, + meshOffset: Vector3 + ): Group { + const pivot = new Group(); + pivot.position.set(jointX, jointY, jointZ); + + const mesh = new Mesh(geometry, material); + mesh.position.copy(meshOffset); + pivot.add(mesh); + + return pivot; + } +} diff --git a/src/controllers/ZombieInvasionHudC.ts b/src/controllers/ZombieInvasionHudC.ts index a424f0a..7bb30bd 100644 --- a/src/controllers/ZombieInvasionHudC.ts +++ b/src/controllers/ZombieInvasionHudC.ts @@ -6,11 +6,11 @@ import { zombieHeadIconSrc } from "../resources/images/images"; // duplicated as a magic number in ui.css. Exported so DebugConfigC's timer // slider (see restartTimer) starts at this same default instead of // duplicating the number. -export const TIMER_DURATION_S = 120; +export const TIMER_DURATION_S = 30; // Last stretch of the timer where the bar starts pulsing + flashing red to -// warn the player it's about to fill. 0.2 = last 20%, i.e. the last 24s of -// a 120s timer. +// warn the player it's about to fill. 0.2 = last 20%, i.e. the last 6s of a +// 30s timer. const WARNING_FRACTION = 0.2; // Top-center "ZOMBIE INVASION" timer bar (title + a zombie-head marker @@ -18,9 +18,10 @@ const WARNING_FRACTION = 0.2; // the track is a plain CSS pill, not a designer asset like HudC/WeaponHudC's // backgrounds. // -// Once the head reaches the left edge (full), nothing else happens here on -// purpose — no completion callback, no loop/reset. Purely the visual, wired -// up once at init and left alone after that. +// The bar itself still owns nothing but the visual. What happens when it +// fills is injected from outside via setOnComplete (TestSceneC wires it to +// ZombieC.startWave) — this file deliberately doesn't know that zombies +// exist, same DI pattern as ResourceC.setFlyTarget etc. export class ZombieInvasionHudC { private static initialized = false; private static barEl: HTMLElement; @@ -28,12 +29,20 @@ export class ZombieInvasionHudC { private static flashEl: HTMLElement; private static headEl: HTMLElement; private static warningTimeout: ReturnType | null = null; + private static completeTimeout: ReturnType | null = null; + private static onComplete: (() => void) | null = null; // durationS defaults to TIMER_DURATION_S but is normally passed in // explicitly by TestSceneC, sourced from DebugConfigC.getZombieInvasionTimerS() // (itself seeded from the platform's own configUIParams) — not read from // DebugConfigC directly in here, since DebugConfigC already imports this // file for restartTimer/TIMER_DURATION_S and importing back would cycle. + // Must be called BEFORE init(): init() starts the countdown immediately, so + // a callback registered afterwards would miss a very short timer. + static setOnComplete(callback: () => void) { + this.onComplete = callback; + } + static init(durationS: number = TIMER_DURATION_S) { if (this.initialized) return; this.initialized = true; @@ -49,6 +58,7 @@ export class ZombieInvasionHudC { // from init() above. static restartTimer(durationS: number) { if (this.warningTimeout !== null) clearTimeout(this.warningTimeout); + if (this.completeTimeout !== null) clearTimeout(this.completeTimeout); this.barEl.classList.remove("is-almost-full"); this.flashEl.classList.remove("is-almost-full"); @@ -131,5 +141,12 @@ export class ZombieInvasionHudC { this.barEl.classList.add("is-almost-full"); this.flashEl.classList.add("is-almost-full"); }, warningDelayMs); + + // A timeout, not the fill's `transitionend`: transitions don't fire at + // all if the element is display:none or the tab was backgrounded mid-run, + // and "the wave never starts" is a far worse failure than firing a frame + // early. The bar keeps pulsing red afterwards on purpose — the invasion + // has arrived, so the alarm state staying on is correct. + this.completeTimeout = setTimeout(() => this.onComplete?.(), durationS * 1000); } } diff --git a/src/controllers/ZombiePartC.ts b/src/controllers/ZombiePartC.ts new file mode 100644 index 0000000..48f5ca3 --- /dev/null +++ b/src/controllers/ZombiePartC.ts @@ -0,0 +1,101 @@ +import { IcosahedronGeometry, Mesh, MeshStandardMaterial, Vector3 } from "three"; +import { PickupFlight } from "../utils/pickupFlight"; + +// Low-poly, jagged chunk radius/squash — reads as a torn-off piece by +// silhouette alone, instantly distinguishable from the flat wood-plank icon +// at the same on-screen size, same reasoning the old exp crystal followed +// before this file replaced it. +// +// The squash is baked into the GEOMETRY, not the mesh scale — PickupFlight +// writes a uniform scale.setScalar() every frame for its impact shrink and +// perspective compensation, which would flatten a non-uniform mesh scale. +const CHUNK_RADIUS = 0.16; +const CHUNK_SQUASH = new Vector3(1, 0.7, 1.15); + +// Dull, fleshy green rather than the old exp crystal's glowing teal — this is +// meat, not a magic pickup. A little emissive so it still reads clearly +// against the map's own lighting. +const CHUNK_COLOR = 0x3c7a2e; +const CHUNK_EMISSIVE = 0x1f4d16; +const CHUNK_EMISSIVE_INTENSITY = 0.6; + +const SPIN_SPEED = 2.4; +// Fixed tilt off vertical so the spin actually reads as a tumbling chunk +// rather than a shape rotating around its own axis of symmetry. +const CHUNK_TILT = 0.4; + +// Geometry and material are identical for every chunk, so both are built once +// and shared — nothing here mutates the material (PickupFlight's flash lives +// on per-chunk overlay clones with their own material), so sharing is safe. +let sharedGeometry: IcosahedronGeometry | null = null; +let sharedMaterial: MeshStandardMaterial | null = null; + +function getSharedGeometry(): IcosahedronGeometry { + if (!sharedGeometry) { + sharedGeometry = new IcosahedronGeometry(CHUNK_RADIUS, 0); + sharedGeometry.scale(CHUNK_SQUASH.x, CHUNK_SQUASH.y, CHUNK_SQUASH.z); + } + return sharedGeometry; +} + +function getSharedMaterial(): MeshStandardMaterial { + if (!sharedMaterial) { + sharedMaterial = new MeshStandardMaterial({ + color: CHUNK_COLOR, + emissive: CHUNK_EMISSIVE, + emissiveIntensity: CHUNK_EMISSIVE_INTENSITY, + roughness: 0.9, + metalness: 0, + }); + } + return sharedMaterial; +} + +// Green zombie-part chunks dropped by dying zombies (see ZombieC) — the +// currency for the PISTOL's own progression (see PistolLevelC and its Pay +// Zone), separate from the wood crates drop for the bat's (see ResourceC / +// BatLevelC). Uses the exact same scatter/bounce/impact/fly animation wood +// does — shared via utils/pickupFlight.ts — and, like wood, flies to a HUD +// counter rather than to the player: see PistolResourceHudC. +export class ZombiePartC { + private static flyTarget: (() => Vector3) | null = null; + private static collected = 0; + + private static flight = new PickupFlight({ + createMesh: () => this.createChunkMesh(), + getTarget: () => this.flyTarget?.() ?? null, + onCollected: () => { + this.collected++; + }, + spinSpeed: SPIN_SPEED, + }); + + static getCollectedCount() { + return this.collected; + } + + // Where settled chunks fly to before being counted — see + // PistolResourceHudC.getWorldAnchorPosition. + static setFlyTarget(getTarget: () => Vector3) { + this.flyTarget = getTarget; + } + + // A standalone clone of the pickup visual for the pistol Pay Zone's + // deposit-flight token — same reasoning as ResourceC.createVisual. + static createVisual() { + return this.createChunkMesh(); + } + + static spawnBurstInRange(origin: Vector3, min: number, max: number) { + this.flight.spawnBurstInRange(origin, min, max); + } + + private static createChunkMesh(): Mesh { + const mesh = new Mesh(getSharedGeometry(), getSharedMaterial()); + mesh.rotation.z = CHUNK_TILT; + // No shadow: a self-lit chunk casting a hard shadow reads as a solid + // prop, and there are up to three of these per zombie in a wave. + mesh.castShadow = false; + return mesh; + } +} diff --git a/src/css/health-bar.css b/src/css/health-bar.css index 098675b..52c66b6 100644 --- a/src/css/health-bar.css +++ b/src/css/health-bar.css @@ -1,5 +1,10 @@ -/* World-tracking crate health bars: bordered bar, blue = current HP, black - = already-lost HP, white = HP lost on the hit that just landed. +/* World-tracking health bars, shared by crates, zombies and the player: + bordered bar, coloured fill = current HP, black = already-lost HP, a + second contrasting fill = HP lost on the hit that just landed. + + The two fill layers are named for their ROLE (__fill / __lost), not their + colour, because the colour is per-variant — see the .health-bar--* blocks + below. Damage numbers stay white on every variant. Positioned every frame in JS (HealthBarC.update()) via world->screen projection, not laid out by normal CSS flow — position:fixed + raw canvas-rect px, the same technique HudC uses for the opposite direction @@ -61,32 +66,56 @@ overflow: hidden; } -.health-bar__white, -.health-bar__blue { +.health-bar__lost, +.health-bar__fill { position: absolute; top: 0; left: 0; height: 100%; - /* The three-color effect (see HealthBarC.onHit): white snaps instantly to - the pre-hit fraction (transition-duration forced to 0s from JS for that - one frame), then both animate to the post-hit fraction — white with a - transition-delay so it visibly lags behind blue for a moment before - catching down and disappearing under the black track underneath. */ + /* The three-colour effect (see HealthBarC.onHit): __lost snaps instantly + to the pre-hit fraction (transition-duration forced to 0s from JS for + that one frame), then both animate to the post-hit fraction — __lost + with a transition-delay so it visibly lags behind the fill for a moment + before catching down and disappearing under the black track. */ transition: width 260ms ease-out; } -.health-bar__white { +.health-bar__lost { background: #ffffff; transition-delay: 220ms; } -.health-bar__blue { - /* Layered on top of white so white's lag never covers the current-HP - fill — only ever reveals it from the right as blue catches down. */ +.health-bar__fill { + /* Layered on top of __lost so the lag never covers the current-HP fill — + it only ever reveals it from the right as the fill catches down. */ z-index: 1; background: #2ea3ff; } +/* Zombies — red current HP, yellow for the chunk just shot off. */ +.health-bar--enemy .health-bar__fill { + background: #e0342a; +} + +.health-bar--enemy .health-bar__lost { + background: #ffd23f; +} + +/* The player's own bar: green so it can never be confused with an enemy's at + a glance, same yellow just-lost flash. Wider than the world bars above — + it's the one the player actually has to read mid-fight. */ +.health-bar--player { + --health-bar-width: calc(var(--lvh, 100vh) * 0.1); +} + +.health-bar--player .health-bar__fill { + background: #46c65a; +} + +.health-bar--player .health-bar__lost { + background: #ffd23f; +} + .health-bar__damage { position: absolute; left: 50%; diff --git a/src/css/ui.css b/src/css/ui.css index d53de8f..a28e16a 100644 --- a/src/css/ui.css +++ b/src/css/ui.css @@ -165,9 +165,14 @@ } /* WeaponHudC — bottom-left octagon plate (ZombiePunk_Icon-Bottom.webp) + - weapon icon (Tool_2.webp, the bat) + "LVL 0" text. Square source art, so - aspect-ratio:1 instead of a second fixed dimension — same reasoning as - .resource-hud above. */ + weapon icon (Tool_2.webp, the bat) + "LVL N" text, N being the live weapon + level (raised by filling the wood Pay Zone — see BatLevelC). Square source art, + so aspect-ratio:1 instead of a second fixed dimension — same reasoning as + .resource-hud above. + + Split into outer + __plate for the same reason .resource-hud is: the outer + element plays the one-shot mount animation, the plate plays the per-upgrade + bump, and one shared element would replay the mount on every upgrade. */ .weapon-hud { /* Single source of truth for the plate's scale, same pattern as --hud-width on .resource-hud — the icon size and level text's @@ -179,13 +184,41 @@ bottom: 26%; width: var(--weapon-hud-width); aspect-ratio: 1; - background-repeat: no-repeat; - background-size: 100% 100%; pointer-events: none; z-index: 50; animation: weapon-hud-enter 400ms ease-out; } +.weapon-hud__plate { + position: relative; + width: 100%; + height: 100%; + background-repeat: no-repeat; + background-size: 100% 100%; +} + +/* Toggled by WeaponHudC whenever the weapon level actually changes (not on the + first paint) — a pop plus a warm rim flare, matching the gold of the weapon's + own upgrade glow in-world (see WeaponAuraC's GLOW_COLOR). */ +.weapon-hud__plate.is-upgrading { + animation: weapon-hud-upgrade 460ms ease-out; +} + +@keyframes weapon-hud-upgrade { + 0% { + transform: scale(1); + filter: drop-shadow(0 0 0 rgba(255, 204, 102, 0)); + } + 35% { + transform: scale(1.2); + filter: drop-shadow(0 0 calc(var(--weapon-hud-width) * 0.2) rgba(255, 204, 102, 0.9)); + } + 100% { + transform: scale(1); + filter: drop-shadow(0 0 0 rgba(255, 204, 102, 0)); + } +} + /* Landscape override — see house convention rule 4. Mirrors the portrait values above as a starting point; retune once seen live. */ @media (orientation: landscape) { @@ -233,6 +266,88 @@ text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8); } +/* PistolHudC — pistol level plate, stacked directly BELOW the .weapon-hud + octagon (mirror image of where the old character-level widget used to sit + above it — same tuned offsets, sign-flipped). Reuses .weapon-hud's own + background art and keyframes: it's a generic octagon frame, not + bat-specific, so the pair reads as two matched medallions. */ +.pistol-hud { + --pistol-hud-width: calc(var(--lvh, 100vh) * 0.08); + position: absolute; + left: 0%; + bottom: calc(26% - var(--lvh, 100vh) * 0.092); + width: var(--pistol-hud-width); + aspect-ratio: 1; + pointer-events: none; + z-index: 50; + animation: weapon-hud-enter 400ms ease-out; +} + +/* Landscape override — see house convention rule 4. Mirrors .weapon-hud's own + landscape values the same way portrait does above. */ +@media (orientation: landscape) { + .pistol-hud { + --pistol-hud-width: calc(var(--lvh, 100vh) * 0.15); + left: 0%; + bottom: calc(28% - var(--lvh, 100vh) * 0.168); + } +} + +.pistol-hud__plate { + position: relative; + width: 100%; + height: 100%; + background-repeat: no-repeat; + background-size: 100% 100%; +} + +/* Same upgrade pop as .weapon-hud__plate — see weapon-hud-upgrade above. */ +.pistol-hud__plate.is-upgrading { + animation: weapon-hud-upgrade 460ms ease-out; +} + +.pistol-hud__icon { + position: absolute; + top: 0; + left: 50%; + width: 72%; + aspect-ratio: 1; + transform: translateX(-50%); +} + +.pistol-hud__level { + position: absolute; + bottom: 8%; + left: 2%; + width: 100%; + text-align: center; + color: #ffffff; + font-weight: normal; + font-size: calc(var(--pistol-hud-width) * 0.2); + font-family: gameFont, sans-serif; + /* em, not px — see .resource-hud__count's identical note above. */ + text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8); +} + +/* PistolResourceHudC — second resource counter, stacked directly below + HudC's own wood counter. Reuses the exact same plate art + (resource_counter_bg.webp) rather than shipping a second bespoke asset — + the hue-rotate below is what reads it as a different currency (the + zombie-part resource is new, the plate isn't). */ +.resource-hud--pistol { + top: calc(15.7% + var(--lvh, 100vh) * 0.05); +} + +@media (orientation: landscape) { + .resource-hud--pistol { + top: calc(5% + var(--lvh, 100vh) * 0.08); + } +} + +.resource-hud--pistol .resource-hud__plate { + filter: hue-rotate(140deg) saturate(1.35); +} + /* ZombieInvasionHudC — top-center title + timer bar (see reference screenshot, temp/ResourceBackground_Wood.webp). No track/bar art was supplied (only the head icon), so unlike .resource-hud/.weapon-hud @@ -244,7 +359,13 @@ /* --lvh, not a raw vh — see house convention rule 3 above. */ --zombie-invasion-width: calc(var(--lvh, 100vh) * 0.27); position: absolute; - top: 0%; + /* Не 0%: #ui-safe-area's own 3.5% inset alone still put the title under + an iPhone's Dynamic Island / notch (~5.5% of screen height on a 16 Pro, + and this widget is the only one that starts at the very top edge). + `env(safe-area-inset-*)` is NOT the fix here — it reports 0 unless the + viewport meta carries `viewport-fit=cover`, and index.html's meta is + SDK-owned/marked do-not-edit. A flat percentage is the honest knob. */ + top: 6%; left: 50%; transform: translateX(-50%); width: var(--zombie-invasion-width); @@ -257,7 +378,11 @@ @media (orientation: landscape) { .zombie-invasion { --zombie-invasion-width: calc(var(--lvh, 100vh) * 0.47); - top: 0%; + /* Smaller bump than portrait's 6%: under the orientation lock the + logical top maps to a PHYSICAL side edge, so the island only ever + eats into one of the two rotation directions — and vertical room is + scarcer here. */ + top: 4%; } } @@ -372,6 +497,132 @@ filter: drop-shadow(0 0 calc(var(--zombie-invasion-width) * 0.01) rgba(0, 0, 0, 0.6)); } +/* PayZoneC — the "+1 LVL" label under the floating pistol in the middle of the + Pay Zone (the pistol itself is a 3D mesh on the canvas, not DOM). + + Positioned every frame in JS from a projected world point (position:fixed + + raw canvas-rect px), the same technique the health bars use — see + health-bar.css's header note. z-index sits with them, BELOW the HUD widgets' + 50: this is a world-space label, so a HUD plate must always win the stacking + fight against it. */ +.pay-zone-reward { + position: fixed; + /* Centred horizontally on the projected point and hanging below it — the + point itself is just under the pistol. */ + transform: translate(-50%, 0); + pointer-events: none; + z-index: 40; + color: #ffffff; + font-family: gameFont, sans-serif; + font-size: calc(var(--lvh, 100vh) * 0.024); + white-space: nowrap; + -webkit-text-stroke: calc(var(--lvh, 100vh) * 0.0018 * var(--game-font-stroke-width)) #000000; + paint-order: stroke fill; + text-shadow: 0 0.05em 0.12em rgba(0, 0, 0, 0.8); + /* A slow breathe, so it reads as an offer rather than as static scenery. + On the wrapper, not the .gamefont-x child — that child's font squeeze is + itself a transform, and the two would fight on one element (same split as + .game-over__counter-value). */ + animation: pay-zone-reward-pulse 1.6s ease-in-out infinite; +} + +@keyframes pay-zone-reward-pulse { + 0%, + 100% { + opacity: 0.82; + transform: translate(-50%, 0) scale(1); + } + 50% { + opacity: 1; + transform: translate(-50%, 0) scale(1.07); + } +} + +/* GameOverC — red veil over the whole play area after the player dies, with + a "YOU DIED" title and a 3..2..1 countdown to the restart. + + Mounted into #ui, not #ui-safe-area: this one has to reach the actual + edges of the letterboxed game area, and #ui-safe-area is inset from them + by --ui-safe-margin (see its own note above), which would leave an + unpainted frame around the veil. + + Animates the veil's own RGB up from transparent via opacity on the whole + layer — the element carries no content of its own, so there's no risk of + the "transparent hole instead of a colour" trap that made animating alpha + the wrong move on the health bars. */ +.game-over { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: calc(var(--lvh, 100vh) * 0.02); + background: rgba(150, 12, 8, 0.45); + /* Above every HUD widget (z-index 50) — the run is over, nothing + underneath is still interactive or worth reading. */ + z-index: 200; + /* Stays click-through: nothing here is a button, and the restart is on a + timer, so swallowing taps would only make a dead run feel frozen. */ + pointer-events: none; + animation: game-over-fade-in 350ms ease-out; +} + +@keyframes game-over-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.game-over__title { + color: #ffffff; + font-family: gameFont, sans-serif; + font-weight: bold; + font-size: calc(var(--lvh, 100vh) * 0.062); + -webkit-text-stroke: calc(var(--lvh, 100vh) * 0.0025 * var(--game-font-stroke-width)) #000000; + paint-order: stroke fill; + text-shadow: 0 0.05em 0.12em rgba(0, 0, 0, 0.7); +} + +.game-over__counter { + color: #ffffff; + font-family: gameFont, sans-serif; + font-size: calc(var(--lvh, 100vh) * 0.095); + -webkit-text-stroke: calc(var(--lvh, 100vh) * 0.003 * var(--game-font-stroke-width)) #000000; + paint-order: stroke fill; + text-shadow: 0 0.05em 0.12em rgba(0, 0, 0, 0.7); +} + +/* A wrapper around the .gamefont-x span, not the span itself: that span's + font squeeze is a `transform`, and so is this pop — on one shared element + the animation would override the squeeze for its whole duration and snap + back at the end. Nested, the two transforms compose. + GameOverC replaces this element outright on every tick, which is what + makes the pop replay per number (a CSS animation doesn't restart just + because textContent changed). */ +.game-over__counter-value { + display: inline-block; + animation: game-over-counter-pop 600ms ease-out; +} + +@keyframes game-over-counter-pop { + 0% { + transform: scale(1.6); + opacity: 0.4; + } + 35% { + transform: scale(1); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + /* InstallBanner (SDK component — @hitplay/playable_template's InstallBanner.ts, wired up in beforeResourcesLoadedCb.ts) — a light visual restyle only, NOT a rebuild: it has real click -> diff --git a/src/utils/pickupFlight.ts b/src/utils/pickupFlight.ts new file mode 100644 index 0000000..09029c6 --- /dev/null +++ b/src/utils/pickupFlight.ts @@ -0,0 +1,308 @@ +import { Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three"; +import { CameraC_internal, UpdateController } from "@hitplay/playable_template"; +import { ThreeC } from "../controllers/ThreeC"; +import { SparkleFxC } from "../controllers/SparkleFxC"; +import { perspectiveCompensationScale } from "./perspectiveScale"; +import { createFlashOverlayMaterial } from "./flashOverlayMaterial"; + +// The shared "something popped out of a killed thing and flew somewhere" +// animation: pop out of the origin, scatter a short random distance, land, +// bounce twice, flash/sparkle on touchdown, then fly on to a target that is +// re-sampled every frame. +// +// Extracted from ResourceC so a second pickup type (see ZombiePartC) gets +// exactly the same feel instead of a second, slowly-diverging copy of this +// state machine. Everything type-specific — the mesh, where it flies to, what +// happens when it arrives — is injected via PickupFlightOptions; the +// timings/distances below are deliberately NOT configurable, because "the +// exp drop should look like the resource drop" is the whole point. +// +// One instance per pickup type. Each registers its own onUpdate delegate, +// lazily, on the first spawn. + +const SCATTER_MIN_DISTANCE = 0.5; +const SCATTER_MAX_DISTANCE = 1.2; +const SCATTER_ARC_HEIGHT = 0.6; +const FLY_DURATION = 0.45; // seconds to reach the scattered spot + +// Two bounces off the ground, decreasing height/duration, index 0 first. +const BOUNCE_HEIGHTS = [0.4, 0.22]; +const BOUNCE_DURATIONS = [0.26, 0.2]; + +// On touchdown of the last bounce: shrink ramps in and HOLDS (does not ease +// back to full size), uniform (not squash/widen — that read as growing). +// Flash fades on its own, longer, decoupled timeline so tuning one doesn't +// drag the other. +const SHRINK_RAMP_DURATION = 0.22; +const FLASH_DURATION = 0.45; +// Must stay >= FLASH_DURATION or the flash gets cut off mid-fade. +const IMPACT_HOLD_DURATION = 0.55; + +// Fraction shrunk on impact, held once reached. +const PICKUP_SHRINK_AMOUNT = 0.15; + +// Scale a pickup settles at after that impact shrink. Exported because callers +// that fly a matching token OUTSIDE this state machine (see PayZoneC's deposit +// flights, shared by every resource type that has its own Pay Zone) have to +// start from the same settled scale or it reads as a different, larger object. +export const PICKUP_SETTLED_SCALE = 1 - PICKUP_SHRINK_AMOUNT; + +const TO_TARGET_DURATION = 0.5; +const TO_TARGET_ARC_HEIGHT = 1; +// Fraction of the toTarget flight (0..1) where the arrival brighten (see +// update()) starts ramping in. +const CONVERGE_START_FRACTION = 0.7; + +// Extra hold time added per-pickup in a burst so they don't all launch +// toward the target in the same instant and read as one clump. +const STAGGER_INTERVAL = 0.12; + +export interface PickupFlightOptions { + // A fresh visual per spawned pickup — cloned template, primitive mesh, + // whatever the caller wants. Called once per pickup. + createMesh: () => Object3D; + // Where the pickup flies to after it has settled, re-sampled every frame + // during the flight (both the HUD anchor and the player keep moving under + // a following camera, so a snapshot taken at launch would miss). Returning + // null collects the pickup on the spot instead of flying it anywhere. + getTarget: () => Vector3 | null; + // The pickup reached its target (or there was none) — count it, level up, + // whatever. The mesh is torn down by this driver either way. + onCollected: () => void; + // Optional idle spin around Y, radians/second. The driver only ever writes + // position and (uniform) scale, so a caller-owned rotation is safe. + spinSpeed?: number; +} + +function randomInt(min: number, max: number): number { + return min + Math.floor(Math.random() * (max - min + 1)); +} + +// Collected up front, before anything mutates the tree, so adding overlay +// children mid-traversal can't make traverse() walk into overlays it just +// added. +function buildFlashOverlays(root: Object3D): Mesh[] { + const meshes: Mesh[] = []; + root.traverse((child) => { + if (child instanceof Mesh) meshes.push(child); + }); + + return meshes.map((mesh) => { + const overlay = mesh.clone(); + // clone() copies the mesh's own local transform (relative to ITS + // parent) — parenting one level deeper under the mesh itself would + // reinterpret those numbers in the mesh's local space and displace the + // overlay instead of exactly overlaying it. Reset to identity instead. + overlay.position.set(0, 0, 0); + overlay.rotation.set(0, 0, 0); + overlay.scale.set(1, 1, 1); + overlay.material = createFlashOverlayMaterial(); + overlay.castShadow = false; + overlay.receiveShadow = false; + mesh.add(overlay); + return overlay; + }); +} + +type Phase = "scatter" | "bounce" | "impactHold" | "toTarget"; + +interface FlyingPickup { + mesh: Object3D; + flashOverlays: Mesh[]; + phase: Phase; + age: number; + from: Vector3; + to: Vector3; + groundY: number; // the y level the two bounces oscillate around + bounceIndex: number; // which of BOUNCE_HEIGHTS/BOUNCE_DURATIONS is playing + staggerDelay: number; // extra hold time before launching to the target — see STAGGER_INTERVAL + baseScale: number; // scale.x when toTarget starts — see the perspective compensation there + startDistanceFromCamera: number; // camera distance when toTarget starts — see toTarget phase +} + +export class PickupFlight { + private flying: FlyingPickup[] = []; + private updating = false; + + constructor(private readonly options: PickupFlightOptions) {} + + spawnBurst(origin: Vector3, count: number) { + this.ensureUpdating(); + for (let i = 0; i < count; i++) this.spawnOne(origin, i * STAGGER_INTERVAL); + } + + spawnBurstInRange(origin: Vector3, min: number, max: number) { + this.spawnBurst(origin, randomInt(min, max)); + } + + private spawnOne(origin: Vector3, staggerDelay: number) { + const angle = Math.random() * Math.PI * 2; + const distance = SCATTER_MIN_DISTANCE + Math.random() * (SCATTER_MAX_DISTANCE - SCATTER_MIN_DISTANCE); + const target = origin + .clone() + .add(new Vector3(Math.cos(angle) * distance, 0, Math.sin(angle) * distance)); + + const mesh = this.options.createMesh(); + mesh.position.copy(origin); + ThreeC.addToScene(mesh); + + this.flying.push({ + mesh, + flashOverlays: buildFlashOverlays(mesh), + phase: "scatter", + age: 0, + from: origin.clone(), + to: target, + groundY: 0, + bounceIndex: 0, + staggerDelay, + baseScale: 1, + startDistanceFromCamera: 1, + }); + } + + private ensureUpdating() { + if (this.updating) return; + this.updating = true; + UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta)); + } + + private update(delta: number) { + const spinSpeed = this.options.spinSpeed ?? 0; + + for (let i = this.flying.length - 1; i >= 0; i--) { + const pickup = this.flying[i]; + pickup.age += delta; + if (spinSpeed !== 0) pickup.mesh.rotation.y += spinSpeed * delta; + + if (pickup.phase === "scatter") { + if (pickup.age <= FLY_DURATION) { + const t = pickup.age / FLY_DURATION; + const eased = 1 - (1 - t) * (1 - t); // easeOutQuad outward + pickup.mesh.position.lerpVectors(pickup.from, pickup.to, eased); + // Up-then-down arc on top of the outward lerp, so it "pops" + // rather than sliding flat along the ground. + pickup.mesh.position.y = pickup.from.y + Math.sin(t * Math.PI) * SCATTER_ARC_HEIGHT; + continue; + } + + pickup.phase = "bounce"; + pickup.age = 0; + pickup.bounceIndex = 0; + pickup.groundY = pickup.to.y; + continue; + } + + if (pickup.phase === "bounce") { + const duration = BOUNCE_DURATIONS[pickup.bounceIndex]; + const height = BOUNCE_HEIGHTS[pickup.bounceIndex]; + + if (pickup.age <= duration) { + const t = pickup.age / duration; + pickup.mesh.position.y = pickup.groundY + Math.sin(t * Math.PI) * height; + continue; + } + + pickup.mesh.position.y = pickup.groundY; + + if (pickup.bounceIndex < BOUNCE_HEIGHTS.length - 1) { + pickup.bounceIndex++; + pickup.age = 0; + continue; + } + + // Last bounce just touched down — flash/sparkle, once. + SparkleFxC.spawnBurst(pickup.mesh.getWorldPosition(new Vector3())); + pickup.phase = "impactHold"; + pickup.age = 0; + continue; + } + + if (pickup.phase === "impactHold") { + const shrinkT = Math.min(pickup.age / SHRINK_RAMP_DURATION, 1); + const shrink = shrinkT * shrinkT * (3 - 2 * shrinkT) * PICKUP_SHRINK_AMOUNT; // ease in, then hold + pickup.mesh.scale.setScalar(1 - shrink); + + const flashT = Math.min(pickup.age / FLASH_DURATION, 1); + const flashOpacity = 1 - flashT; + for (const overlay of pickup.flashOverlays) { + (overlay.material as MeshBasicMaterial).opacity = flashOpacity; + } + + // staggerDelay only extends this wait, per pickup — the + // shrink/flash above are already fully settled by + // IMPACT_HOLD_DURATION, so this only delays the launch. + if (pickup.age < IMPACT_HOLD_DURATION + pickup.staggerDelay) continue; + + for (const overlay of pickup.flashOverlays) { + (overlay.material as MeshBasicMaterial).opacity = 0; + } + + const target = this.options.getTarget(); + if (!target) { + this.collect(pickup); + this.flying.splice(i, 1); + continue; + } + + pickup.phase = "toTarget"; + pickup.age = 0; + pickup.from = pickup.mesh.position.clone(); + pickup.baseScale = pickup.mesh.scale.x; + pickup.startDistanceFromCamera = CameraC_internal.getCamera().position.distanceTo(pickup.from); + continue; + } + + // phase === "toTarget" + // Re-sampled every frame, not snapshotted at launch: the target can + // keep moving during the flight (camera follow, or a target that IS the + // player), so a stale one-time snapshot would miss it. + const target = this.options.getTarget(); + if (!target) { + this.collect(pickup); + this.flying.splice(i, 1); + continue; + } + + const t = Math.min(pickup.age / TO_TARGET_DURATION, 1); + const eased = t * t * (3 - 2 * t); // smoothstep — eases in and out + // An accelerating (ease-in) curve was tried here and reverted — it + // desyncs spatial progress from the raw elapsed time + // CONVERGE_START_FRACTION uses below, making the pickup vanish well + // short of the target. + pickup.to = target; + pickup.mesh.position.lerpVectors(pickup.from, pickup.to, eased); + pickup.mesh.position.y += Math.sin(t * Math.PI) * TO_TARGET_ARC_HEIGHT * (1 - t); + + // The target usually sits much closer to the camera than where pickups + // spawn, so without compensation it would visibly grow as it + // approaches (perspective foreshortening, not a real scale change) — + // see perspectiveCompensationScale. + const cameraPosition = CameraC_internal.getCamera().position; + const perspectiveScale = perspectiveCompensationScale( + cameraPosition, + pickup.mesh.position, + pickup.startDistanceFromCamera + ); + pickup.mesh.scale.setScalar(pickup.baseScale * perspectiveScale); + + // Brightens over the last stretch, reading as absorbed into the target + // rather than popping out mid-air. Reuses the same flashOverlays the + // bounce-impact flash used, idle since then. + const convergeT = Math.max(0, (t - CONVERGE_START_FRACTION) / (1 - CONVERGE_START_FRACTION)); + for (const overlay of pickup.flashOverlays) { + (overlay.material as MeshBasicMaterial).opacity = convergeT; + } + + if (t >= 1) { + this.collect(pickup); + this.flying.splice(i, 1); + } + } + } + + private collect(pickup: FlyingPickup) { + this.options.onCollected(); + ThreeC.removeFromScene(pickup.mesh); + } +} diff --git a/stats.html b/stats.html index a85365d..5b44c59 100644 --- a/stats.html +++ b/stats.html @@ -4929,7 +4929,7 @@ var drawChart = (function (exports) {