some changes
This commit is contained in:
@@ -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/<topic>.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("<id категорії>")["<id параметра>"]`
|
||||
(приклад — `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("<categoryId>")["<parameterId>"]
|
||||
```
|
||||
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 підключений, але візуально не підтверджений.
|
||||
|
||||
@@ -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.
|
||||
@@ -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],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setTimeout> | 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<Object3D, BreakableProp>();
|
||||
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<Object3D> {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setInterval> | 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();
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setTimeout> | 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<BreakableProp, BarInstance>();
|
||||
private static bars = new Map<object, BarInstance>();
|
||||
|
||||
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) {
|
||||
|
||||
+264
-48
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">' +
|
||||
'<g fill="#e8e8e8">' +
|
||||
'<rect x="14" y="25" width="38" height="9" rx="2"/>' + // slide/barrel
|
||||
'<rect x="45" y="19" width="7" height="7" rx="1"/>' + // rear sight
|
||||
'<rect x="16" y="17" width="7" height="7" rx="1"/>' + // front sight
|
||||
'<path d="M20 34 L29 34 L29 52 L23 52 L18 45 Z"/>' + // grip
|
||||
'<path d="M29 34 L35 34 L35 42 L31 47 L27 47 Z"/>' + // trigger guard
|
||||
"</g>" +
|
||||
"</svg>";
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+511
-17
@@ -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.
|
||||
|
||||
+19
-261
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+154
-29
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
private static completeTimeout: ReturnType<typeof setTimeout> | 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+42
-13
@@ -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%;
|
||||
|
||||
+258
-7
@@ -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 ->
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user