Add physics/combat foundation, resource pickup flow, VFX polish, and HUD

- Cannon-es colliders for map/props/breakable crates (BreakablePropC,
  PhysicsC), AoE bat combat with facing-cone gating (PlayerC), camera
  follow with obstacle avoidance and facing-lock look-ahead
  (CameraFollowC).
- Resource pickups: scatter/bounce/hit-flash/sparkle-burst lifecycle with
  pooled sparkle VFX (ResourceC, SparkleFxC), flying to a screen-projected
  HUD anchor and on into the Pay Zone with perspective-corrected sizing
  (HudC, PayZoneC).
- Designer VFX playback via three.quarks for crate hit/destroy
  (PropVfxC), plus a procedural weapon-swing trail (WeaponTrailC).
- Resource-counter HUD UI (ui.css, images.ts) with CSS-driven mount/bump
  animations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Oleksandr Vlasiuk
2026-08-12 18:52:40 +03:00
parent fe1192f4c9
commit 4a22a9b140
20 changed files with 3617 additions and 187 deletions
+294
View File
@@ -0,0 +1,294 @@
import { AdditiveBlending, Color, 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";
// A "Lootable" crate in the map glb is authored as:
// Wooden_Box_XXX (root)
// BoxCollider.NNN <- the obstacle/hit box, also in the generic /collider/i list
// Wooden_Box_States_XXX
// Wooden_Box_XXX_S1 <- least damaged
// Wooden_Box_XXX_S2
// Wooden_Box_XXX_S3 <- most damaged/rubble
// 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.
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
// A same-geometry clone rendered on top with additive blending, opacity
// animated for the hit-flash — see buildStageVisual for why lerping the
// real material's own color doesn't work here.
flashMesh: Mesh | null;
}
export interface BreakableProp {
root: Object3D;
colliderNode: Object3D;
baseRotation: Euler;
stages: StageVisual[];
stageIndex: number;
hitsOnStage: number;
physicsBody: PhysicsBody;
hitTween: Tween<{ t: number }> | null;
}
const STAGE_NUMBER_PATTERN = /_S(\d+)$/i;
const COLLIDER_NAME_PATTERN = /collider/i;
const STATES_GROUP_PATTERN = /_states/i;
const SHADOW_MESH_PATTERN = /shadow/i;
// Two hits to knock a crate from one damage stage to the next (or to
// destroy it, on its last stage).
const HITS_PER_STAGE = 2;
// A hit that only advances a damage stage drops a small trickle; the hit
// that finally destroys the crate dumps out most of its loot at once.
const STAGE_HIT_RESOURCE_RANGE: [number, number] = [1, 2];
const DESTROY_RESOURCE_RANGE: [number, number] = [2, 5];
// Hit feedback: a brief white flash (in, then out) plus a lean away from
// wherever the hit came from, springing back to its resting angle.
const HIT_FLASH_UP_MS = 90;
const HIT_FLASH_DOWN_MS = 150;
const HIT_TILT_ANGLE = -0.16; // radians, ~7°
const FLASH_COLOR = new Color(0xffffff);
// Where the spark burst spawns, relative to the crate's own pivot: nudged
// toward the attacker (opposite hitDirection) so it sits near the surface
// facing them rather than dead-center inside the crate, and up to roughly
// where the bat actually connects rather than at the ground.
const CONTACT_POINT_INSET = 0.3;
const CONTACT_POINT_HEIGHT = 0.6;
// Disappear-on-destroy: sinks straight down into the ground while toppling
// away from the hit that destroyed it, instead of shrinking in place.
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
export class BreakablePropC {
private static byColliderNode = new Map<Object3D, BreakableProp>();
private static onDestroyed: ((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.
static init(
lootableRoot: Object3D | undefined,
onDestroyed: (colliderNode: Object3D) => void
): Set<Object3D> {
this.byColliderNode.clear();
this.onDestroyed = onDestroyed;
if (lootableRoot) {
for (const crateRoot of lootableRoot.children) {
const prop = this.buildProp(crateRoot);
if (prop) this.byColliderNode.set(prop.colliderNode, prop);
}
}
return new Set(this.byColliderNode.keys());
}
static getByColliderNode(node: Object3D): BreakableProp | undefined {
return this.byColliderNode.get(node);
}
// HITS_PER_STAGE hits advance exactly one stage; past the last stage that
// exists for this crate, the hit that would've been one more advance
// fully destroys it instead. Every hit plays feedback; a stage advance
// (or the destroying hit) also drops resources — more on that final hit.
// `hitDirection` (attacker -> target, XZ, normalized) drives which way
// the crate leans on a non-destroying hit.
static hit(prop: BreakableProp, hitDirection: Vector3) {
prop.hitsOnStage++;
if (prop.hitsOnStage < HITS_PER_STAGE) {
this.playHitFeedback(prop, hitDirection);
return;
}
prop.hitsOnStage = 0;
// 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.
const dropOrigin = prop.root.getWorldPosition(new Vector3()).add(new Vector3(0, 0.5, 0));
const nextIndex = prop.stageIndex + 1;
if (nextIndex < prop.stages.length) {
prop.stages[prop.stageIndex].node.visible = false;
prop.stages[nextIndex].node.visible = true;
prop.stageIndex = nextIndex;
ResourceC.spawnBurstInRange(dropOrigin, ...STAGE_HIT_RESOURCE_RANGE);
this.playHitFeedback(prop, hitDirection);
return;
}
ResourceC.spawnBurstInRange(dropOrigin, ...DESTROY_RESOURCE_RANGE);
this.destroy(prop, hitDirection);
}
private static buildProp(root: Object3D): BreakableProp | null {
const colliderNode = root.children.find((child) => COLLIDER_NAME_PATTERN.test(child.name));
const statesGroup = root.children.find((child) => STATES_GROUP_PATTERN.test(child.name));
if (!colliderNode || !statesGroup) return null;
const stageNodes = [...statesGroup.children].sort(
(a, b) => this.stageNumber(a) - this.stageNumber(b)
);
if (stageNodes.length === 0) return null;
const stages: StageVisual[] = stageNodes.map((node, index) => {
node.visible = index === 0;
return this.buildStageVisual(node);
});
const physicsBody = new PhysicsBody(
colliderNode,
false,
0,
PhysicsLayer.Wall,
PhysicsLayer.Player | PhysicsLayer.Enemy
);
return {
root,
colliderNode,
baseRotation: root.rotation.clone(),
stages,
stageIndex: 0,
hitsOnStage: 0,
physicsBody,
hitTween: null,
};
}
// The crate mesh uses an unlit MeshBasicMaterial with color already at
// pure white (confirmed by logging it — the texture supplies all the
// actual color, the tint is just left at its default) — lerping that
// color toward FLASH_COLOR (also white) is a no-op, which is why the
// flash was invisible despite the tween running. Instead, a same-geometry
// clone is layered on top with additive blending: animating ITS opacity
// reads as a flash regardless of what color/texture the base mesh has.
private static buildStageVisual(node: Object3D): StageVisual {
const mesh = node.children.find(
(child): child is Mesh => child instanceof Mesh && !SHADOW_MESH_PATTERN.test(child.name)
);
if (!mesh) return { node, mesh: null, flashMesh: null };
const flashMesh = mesh.clone();
flashMesh.material = new MeshBasicMaterial({
color: FLASH_COLOR,
transparent: true,
opacity: 0,
blending: AdditiveBlending,
depthWrite: false,
});
// Shadows off — this is a same-shape overlay that's invisible almost
// all the time, not a real surface; TestSceneC's later
// setShadowsStateForChildren(map, true, true) would otherwise flip
// these back on (see disableFlashShadows, called after that).
flashMesh.castShadow = false;
flashMesh.receiveShadow = false;
node.add(flashMesh);
return { node, mesh, flashMesh };
}
// TestSceneC's setShadowsStateForChildren(map, true, true) runs after
// BreakablePropC.init() and force-enables shadows on every mesh under the
// map, including the flash overlays built above — call this right after
// that to turn them back off, so the flash meshes don't double the
// currently-visible crates' shadow-casting cost for no visual benefit.
static disableFlashShadows() {
for (const prop of this.byColliderNode.values()) {
for (const stage of prop.stages) {
if (!stage.flashMesh) continue;
stage.flashMesh.castShadow = false;
stage.flashMesh.receiveShadow = false;
}
}
}
private static stageNumber(stageNode: Object3D): number {
const match = STAGE_NUMBER_PATTERN.exec(stageNode.name);
return match ? Number(match[1]) : 0;
}
// Kills any feedback still playing from a previous hit before starting a
// new one — always relative to the crate's original resting rotation
// (captured once at build time), never the current one, so interrupting
// mid-lean can't drift the crate further off-angle with each hit.
private static playHitFeedback(prop: BreakableProp, hitDirection: Vector3) {
prop.hitTween?.stop();
const contactPoint = prop.root.getWorldPosition(new Vector3());
contactPoint.addScaledVector(hitDirection, -CONTACT_POINT_INSET);
contactPoint.y += CONTACT_POINT_HEIGHT;
PropVfxC.spawnHit(contactPoint);
const stage = prop.stages[prop.stageIndex];
const flashMaterial = stage.flashMesh?.material as MeshBasicMaterial | undefined;
const tiltX = -hitDirection.z * HIT_TILT_ANGLE;
const tiltZ = hitDirection.x * HIT_TILT_ANGLE;
const apply = ({ t }: { t: number }) => {
if (flashMaterial) flashMaterial.opacity = t;
prop.root.rotation.set(
prop.baseRotation.x + tiltX * t,
prop.baseRotation.y,
prop.baseRotation.z + tiltZ * t
);
};
const flashIn = new Tween({ t: 0 }).to({ t: 1 }, HIT_FLASH_UP_MS).easing(Easing.Quadratic.Out).onUpdate(apply);
const flashOut = new Tween({ t: 1 }).to({ t: 0 }, HIT_FLASH_DOWN_MS).easing(Easing.Quadratic.In).onUpdate(apply);
// chain() only tells flashIn to call flashOut.start() on completion —
// it does NOT add flashOut to any group. Without adding it here too,
// flashOut marks itself "playing" but nothing ever calls its update(),
// so it just freezes at t=1 (fully flashed/tilted) forever.
flashIn.chain(flashOut);
TweenC.add(flashIn);
TweenC.add(flashOut);
flashIn.start();
prop.hitTween = flashIn;
}
private static destroy(prop: BreakableProp, hitDirection: Vector3) {
prop.physicsBody.destroy();
this.byColliderNode.delete(prop.colliderNode);
this.onDestroyed?.(prop.colliderNode);
prop.hitTween?.stop();
PropVfxC.spawnDestroy(prop.root.getWorldPosition(new Vector3()));
this.playDisappearAnimation(prop, hitDirection);
}
// Fully gone, not just its collider — sinks straight down into the ground
// while toppling away from the hit that destroyed it (same lean
// convention as playHitFeedback), only hidden once fully submerged.
private static playDisappearAnimation(prop: BreakableProp, hitDirection: Vector3) {
const root = prop.root;
const baseY = root.position.y;
const tiltX = -hitDirection.z * DESTROY_TILT_ANGLE;
const tiltZ = hitDirection.x * DESTROY_TILT_ANGLE;
const sink = new Tween({ t: 0 })
.to({ t: 1 }, DESTROY_SINK_MS)
.easing(Easing.Quadratic.In)
.onUpdate(({ t }) => {
root.position.y = baseY - DESTROY_SINK_DEPTH * t;
root.rotation.set(
prop.baseRotation.x + tiltX * t,
prop.baseRotation.y,
prop.baseRotation.z + tiltZ * t
);
})
.onComplete(() => (root.visible = false));
TweenC.add(sink);
sink.start();
}
}
+95 -80
View File
@@ -1,79 +1,121 @@
import { Object3D, Raycaster, Vector3 } from "three";
import { Object3D, Vector3 } from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
// How fast the camera closes the gap to its target position/look point, per
// second. Higher = snappier, lower = floatier. Framerate-independent (see
// the exponential smoothing in update()), so the feel is the same at 30fps
// and 60fps instead of drifting with frame time.
// How fast the camera closes the gap to its target position, per second.
// Higher = snappier, lower = floatier. Framerate-independent (see the
// exponential smoothing in update()), so the feel is the same at 30fps and
// 60fps instead of drifting with frame time.
const POSITION_DAMPING = 5;
const LOOK_DAMPING = 8;
// Keeps the camera a bit off any obstacle it lands on, and never lets it
// collapse onto the target itself.
const OBSTACLE_SKIN = 0.4;
const MIN_DISTANCE = 1.5;
// How far (world units) the camera's target position pans in the direction
// the character is actually TRANSLATING — a fixed-length nudge, not scaled
// by speed, so it's the full offset the instant they're moving at all and
// zero the instant they stop. Deliberately derived from position deltas,
// not facing/quaternion: that was the earlier version, and even damped, a
// vector tied to facing rotates right along with a stationary turn (e.g.
// pivoting to face a crate) and drags the camera along an arc. A vector
// tied to actual movement can't do that — turning in place is zero
// movement, so it's zero look-ahead, however the character is turning.
//
// Bigger than the old facing-based version's 1.2 — that version's visible
// "shift" came from two effects stacked together (the position nudge AND
// camera.lookAt() re-aiming toward the shifted point every frame); with
// rotation now completely fixed, only the position nudge is left, so it
// has to move further on its own to read as a comparable shift from a
// camera sitting ~12.7 units away (the (0,9,-9) rig offset).
const LOOK_AHEAD_DISTANCE = 1.5;
// How far (world units) the whole camera rig — position and look-at point
// alike — pans toward wherever the character is currently facing, to open
// up more of the space ahead of them instead of centering them dead-on.
// Matches PlayerC's own forward convention (+z at rest); the two aren't
// coupled in code, but there's only one character to stay in sync with.
const LOOK_AHEAD_DISTANCE = 1.2;
// Smooths transitions in and out of the look-ahead pan (starting/stopping)
// so it doesn't snap. Separate from, and slower than, the position damping
// below — see update(). Slower than before (was 2) per feedback: the ease
// itself needed to read more gradual.
const LOOK_AHEAD_DAMPING = 1.2;
// The character's body can spin at up to 540°/s (PlayerC.MAX_TURN_SPEED),
// which would yank the look-ahead point around just as fast if it followed
// the raw facing direction. Smoothing the direction itself, separately from
// (and slower than) the position/look damping below, is what actually makes
// the pan gentle regardless of how fast the character turns.
const LOOK_AHEAD_DAMPING = 2;
const FORWARD_AXIS = new Vector3(0, 0, 1);
// Below this much squared movement in a single frame, treat the character
// as stationary — guards against floating-point noise flickering a tiny
// direction in and out while genuinely standing still.
const MOVEMENT_EPSILON_SQ = 0.0005 * 0.0005;
export class CameraFollowC {
private static target: Object3D | null = null;
private static offset = new Vector3();
private static obstacles: Object3D[] = [];
private static eyeHeight = 0;
private static smoothedLookAt = new Vector3();
// The actual (x,y,z) offset, not a direction — see update() for why that
// distinction matters.
private static smoothedLookAhead = FORWARD_AXIS.clone().multiplyScalar(LOOK_AHEAD_DISTANCE);
private static raycaster = new Raycaster();
private static previousTargetPosition = new Vector3();
// The last direction actually walked in, held indefinitely — see update()
// for why this must never get reset to zero on its own.
private static desiredLookAhead = new Vector3();
private static smoothedLookAhead = new Vector3();
// Injected (avoids importing PlayerC directly) — see setFacingTargetGetter.
private static facingTargetGetter: (() => Vector3 | null) | null = null;
static init(target: Object3D, offset: Vector3, obstacles: Object3D[] = [], eyeHeight = 0) {
static init(target: Object3D, offset: Vector3) {
this.target = target;
this.offset = offset.clone();
this.obstacles = obstacles;
this.eyeHeight = eyeHeight;
this.smoothedLookAt.copy(target.position);
this.previousTargetPosition.copy(target.position);
this.desiredLookAhead.set(0, 0, 0);
this.smoothedLookAhead.set(0, 0, 0);
// The camera's angle is set once, here, from the actual offset geometry
// — not left at whatever camera_rotation_p/_l happens to hold, since
// that config value was never actually tuned to work standalone (the
// old per-frame lookAt() always overwrote it immediately). update()
// below never touches rotation again after this — camera.position is
// the only thing it ever moves, in a straight line toward a single
// target point (see chat: no facing-based pivoting, just two points
// and a line between them).
const camera = CameraC_internal.getCamera();
camera.position.copy(target.position).add(this.offset);
camera.lookAt(target.position);
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
}
// Lets the look-ahead react to the character locking onto a nearby
// breakable while stopped (see PlayerC.getFacingTarget) — that rotation
// carries zero movement, so it would otherwise be invisible to the
// movement-only look-ahead below. Deliberately fed a fixed world POINT,
// not the character's facing/quaternion: a point doesn't rotate as the
// body turns to face it, so easing toward it is a single linear shift,
// not the arc/orbit a facing-tied vector produced (see chat).
static setFacingTargetGetter(getter: () => Vector3 | null) {
this.facingTargetGetter = getter;
}
private static update(delta: number) {
if (!this.target) return;
const camera = CameraC_internal.getCamera();
const targetPosition = this.target.position;
const facing = FORWARD_AXIS.clone().applyQuaternion(this.target.quaternion);
facing.y = 0;
facing.normalize();
const desiredLookAhead = facing.multiplyScalar(LOOK_AHEAD_DISTANCE);
const movement = targetPosition.clone().sub(this.previousTargetPosition);
this.previousTargetPosition.copy(targetPosition);
movement.y = 0;
const facingTarget = this.facingTargetGetter ? this.facingTargetGetter() : null;
if (facingTarget) {
// Locked onto a nearby breakable — pan toward that fixed point
// instead of a movement delta (there isn't one; the character is
// stopped). See setFacingTargetGetter for why a point is safe here
// where facing/quaternion wasn't.
const toTarget = facingTarget.clone().sub(targetPosition);
toTarget.y = 0;
if (toTarget.lengthSq() > 1e-6) {
this.desiredLookAhead.copy(toTarget.normalize().multiplyScalar(LOOK_AHEAD_DISTANCE));
}
} else if (movement.lengthSq() > MOVEMENT_EPSILON_SQ) {
// Only updates the held direction while actually translating — when
// the character stops (and isn't locked onto a target either), this
// is simply skipped, so the pan holds at wherever it last settled
// instead of easing back to center (see chat: "камера після зсуву не
// має повертатись назад... тільки якщо він змінить напрям"). It only
// moves again once movement resumes in a different direction.
this.desiredLookAhead.copy(movement).normalize().multiplyScalar(LOOK_AHEAD_DISTANCE);
}
// Damping the raw offset vector (not a unit direction re-normalized
// every frame) is what keeps this a straight-line move through the
// character on a direction change, instead of an arc: re-normalizing
// pins the vector's length at LOOK_AHEAD_DISTANCE the whole time, which
// forces it to sweep around a circle of that radius as the direction
// changes. Left as plain (x,y,z) lerp, it can shrink through zero and
// grow back out the other way — a straight line, not a curve.
const lookAheadT = 1 - Math.exp(-LOOK_AHEAD_DAMPING * delta);
this.smoothedLookAhead.lerp(desiredLookAhead, lookAheadT);
const lookAhead = this.smoothedLookAhead;
this.smoothedLookAhead.lerp(this.desiredLookAhead, lookAheadT);
const desiredPosition = targetPosition.clone().add(this.offset).add(lookAhead);
this.avoidObstacles(targetPosition, desiredPosition);
const desiredPosition = targetPosition.clone().add(this.offset).add(this.smoothedLookAhead);
// Exponential (framerate-independent) damping instead of a fixed lerp
// factor per frame — a fixed factor changes speed with the frame rate
@@ -81,35 +123,8 @@ export class CameraFollowC {
const positionT = 1 - Math.exp(-POSITION_DAMPING * delta);
camera.position.lerp(desiredPosition, positionT);
const lookT = 1 - Math.exp(-LOOK_DAMPING * delta);
this.smoothedLookAt.lerp(targetPosition.clone().add(lookAhead), lookT);
camera.lookAt(this.smoothedLookAt);
}
// Only pulls the camera in when an obstacle would actually hide the
// character. The check ray starts at (roughly) eye height instead of the
// character's center/feet, so something that doesn't reach that high never
// triggers a zoom — the character is still visible over it. When it does
// trigger, the ray's own hit distance (measured from the character, not
// from the camera) is what places the camera, so it lands right next to
// the obstacle on the character's side instead of collapsing onto the
// character whenever they aren't standing flush against it.
private static avoidObstacles(targetPosition: Vector3, desired: Vector3) {
if (this.obstacles.length === 0) return;
const eyePosition = targetPosition.clone().add(new Vector3(0, this.eyeHeight, 0));
const toDesired = desired.clone().sub(eyePosition);
const distance = toDesired.length();
if (distance < 1e-4) return;
const direction = toDesired.divideScalar(distance);
this.raycaster.set(eyePosition, direction);
this.raycaster.far = distance;
const hits = this.raycaster.intersectObjects(this.obstacles, true);
if (hits.length === 0) return;
const safeDistance = Math.max(hits[0].distance - OBSTACLE_SKIN, MIN_DISTANCE);
desired.copy(eyePosition).addScaledVector(direction, safeDistance);
// Rotation is deliberately never touched here (no lookAt) — this is a
// linear-shift follow camera, not an orbiting one. The angle is fixed
// once in init() and never changes afterward.
}
}
+118
View File
@@ -0,0 +1,118 @@
import { Vector3 } from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
import { resourceCounterBgSrc } from "../resources/images/images";
// How far in front of the camera (world units, along the screen ray) to
// place the projected anchor point — getWorldAnchorPosition() below finds
// the *direction* from the real DOM position via screen-to-NDC + unproject,
// but a single 2D screen point maps to a whole ray in 3D, so a depth still
// has to be chosen. Roughly matches the distance the old hand-picked local
// offset sat at, so the flight arcs read about the same as before.
const ANCHOR_DISTANCE = 2.5;
// Purely a prototype HUD — one counter for the one resource type that
// exists (wood). No install-banner-style config UI wiring; just a fixed
// DOM overlay mounted into the SDK's #ui container.
export class HudC {
private static initialized = false;
private static plateEl: HTMLElement;
private static countEl: 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());
}
// Where collected resources fly to, and where PayZoneC's deposit flight
// into the Pay Zone originates from — the plate's own right edge,
// projected into 3D via the real DOM position (see chat: "дістати
// позицію правого края юайки"), not a hand-picked local offset off the
// camera like before. Recomputed from scratch on every call rather than
// cached: it's only called once per resource-fly event (not per frame),
// cheap enough, and this way it stays correct through window
// resize/orientation changes for free.
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;
// NDC relative to the actual canvas box, not window.innerWidth/Height —
// the SDK letterboxes/centers the canvas into a fixed 9:16 area (see
// #ui/#editor in main.css), so the canvas's own bounding rect is the
// only thing guaranteed to line up with the camera's projection.
const ndcX = ((screenX - canvasRect.left) / canvasRect.width) * 2 - 1;
const ndcY = -((screenY - canvasRect.top) / canvasRect.height) * 2 + 1;
const camera = CameraC_internal.getCamera();
const point = new Vector3(ndcX, ndcY, 0.5).unproject(camera);
const direction = point.sub(camera.position).normalize();
return camera.position.clone().addScaledVector(direction, ANCHOR_DISTANCE);
}
// The displayed number is a live balance, not a lifetime total — it
// should rise as resources are collected and fall as they're delivered
// into the Pay Zone (see chat). Injected rather than importing
// ResourceC/PayZoneC directly, since PayZoneC already imports HudC (for
// getWorldAnchorPosition) and importing back would cycle.
static setBalanceGetter(getBalance: () => number) {
this.balanceGetter = getBalance;
}
// Layout/styling lives in src/css/ui.css (.resource-hud/__plate/__count) —
// only the background image itself is set here, since its actual path
// (base64 in a real build, a bare path in dev) is only known via the
// asset import, not something a static CSS file can reference.
//
// Two nested divs, not one: .resource-hud positions/sizes and plays the
// one-shot mount animation; .resource-hud__plate (background + number
// together) is the separate element the update-bump animates — see the
// comment in ui.css for why these can't share one element without the
// two animations fighting each other.
private static buildDom() {
const holder = document.createElement("div");
holder.className = "resource-hud";
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";
count.textContent = "0";
plate.appendChild(count);
holder.appendChild(plate);
const uiRoot = document.getElementById("ui") ?? document.body;
uiRoot.appendChild(holder);
this.plateEl = plate;
this.countEl = count;
}
private static update() {
if (!this.balanceGetter) return;
const count = this.balanceGetter();
if (count === this.lastDisplayedCount) return;
this.lastDisplayedCount = count;
this.countEl.textContent = String(count);
// The actual "pop" is a CSS animation on the whole plate (.is-bumping,
// see ui.css), not just the digit — this only toggles the class,
// forcing a reflow in between so it restarts cleanly even if the count
// changes again before the previous bump finished (e.g. PayZoneC
// draining resources every 0.3s).
this.plateEl.classList.remove("is-bumping");
void this.plateEl.offsetWidth;
this.plateEl.classList.add("is-bumping");
}
}
+244
View File
@@ -0,0 +1,244 @@
import { Box3, Mesh, MeshBasicMaterial, Object3D, PlaneGeometry, Vector3 } from "three";
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 { HudC } from "./HudC";
// How many resources need to actually make it into the zone before it's
// done and disappears — what happens after that is intentionally out of
// scope for now (see chat).
const RESOURCES_TO_CLOSE_ZONE = 20;
// While standing in the zone with resources still to deliver, one flies in
// every DEPOSIT_INTERVAL seconds — a steady drain rather than a single
// instant dump.
const DEPOSIT_INTERVAL = 0.3;
const DEPOSIT_FLIGHT_DURATION_MS = 500;
// Redundant safety-net cleanup delay past the tween's own duration — see
// the note in playDepositFlight on why this exists.
const DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS = 300;
const PAY_ZONE_SHRINK_MS = 400;
// Extra multiplier on top of RESOURCE_ICON_BASE_SCALE (see ResourceC), just
// for this deposit flight — tune this, not the shared constant, if only
// the fly-to-Pay-Zone token needs to look smaller/bigger (see chat: "де я
// можу зменшити розмір ресурсу під час анімації заповнення пей зони?").
const DEPOSIT_FLIGHT_SCALE_MULTIPLIER = 0.3;
// Fill overlay: a flat quad laid over the zone's own footprint, growing
// from its near edge (fixed, closest to the camera) toward the far edge as
// deposits come in — a ground-level wipe, not a rising column (see chat:
// two earlier attempts — a horizontal X wipe read as "right to left", then
// a rising box read as "water filling a pool" — both wrong; this reads as
// "bottom to top" on screen because +Z, this game's forward/"away from
// camera" direction, projects toward the top of the screen under this
// isometric-ish camera).
const FILL_COLOR = 0x5ce65c;
const FILL_OPACITY = 0.55;
// Sits just above the zone's own ground-level quad so it doesn't z-fight
// with it, but still reads as "on the ground" rather than floating.
const FILL_Y_OFFSET = 0.02;
// Reuses the map's existing (already-visible) "UI_Interactive_Zone_02"
// marker as both the pay zone's location and its visual — see CLAUDE.md.
// ResourceC.getCollectedCount() keeps counting resources the moment
// they're picked up near whatever crate dropped them (unrelated to this
// zone); this controller separately drains that total into the zone,
// resource by resource, only while the player is standing inside it.
export class PayZoneC {
private static payZoneNode: Object3D | null = null;
private static target: Object3D | null = null;
// The zone's actual world-space footprint (an XZ rectangle), computed
// once from its real geometry rather than guessed as a fixed radius — a
// hardcoded radius previously undershot the visible dashed square, so
// standing anywhere but right on its center pivot silently never
// counted as "inside".
private static zoneBounds: Box3 | null = null;
private static closed = false;
private static deposited = 0;
private static depositCooldown = 0;
private static fillMesh: Mesh | null = null;
static init(payZoneNode: Object3D | undefined, target: Object3D) {
this.payZoneNode = payZoneNode ?? null;
this.target = target;
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.updateFillVisual();
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
}
// A unit quad, later scaled/positioned per-frame in updateFillVisual — a
// fixed width (full X span of the zone) but a depth (world Z) that grows
// from 0 up to the zone's full Z span as `deposited` approaches
// RESOURCES_TO_CLOSE_ZONE.
private static buildFillMesh(bounds: Box3): Mesh {
const mesh = new Mesh(
new PlaneGeometry(1, 1),
new MeshBasicMaterial({
color: FILL_COLOR,
transparent: true,
opacity: FILL_OPACITY,
depthWrite: false,
})
);
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;
}
// Grows the fill quad from the zone's min-Z edge (fixed, nearest the
// camera) toward its max-Z edge as `fraction` (0..1) increases — scaling
// a centered quad also moves its center, so the Z position is re-derived
// each time to keep that near edge pinned in place instead of drifting.
private static updateFillVisual() {
if (!this.fillMesh || !this.zoneBounds) return;
const fraction = this.closed ? 0 : Math.min(this.deposited / RESOURCES_TO_CLOSE_ZONE, 1);
const depth = this.zoneBounds.max.z - this.zoneBounds.min.z;
const filledDepth = depth * fraction;
this.fillMesh.scale.y = filledDepth; // local Y — maps to world Z after the rotation above
this.fillMesh.position.z = this.zoneBounds.min.z + filledDepth / 2;
}
static getTargetWorldPosition(): Vector3 {
return this.payZoneNode ? this.payZoneNode.getWorldPosition(new Vector3()) : new Vector3();
}
// How many resources have actually been delivered so far — 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 {
return this.deposited;
}
// XZ containment against the zone's real footprint (ignores Y — the
// zone lies flat on the ground, height doesn't matter).
static isPlayerInside(playerPosition: Vector3): boolean {
if (!this.zoneBounds) return false;
return (
playerPosition.x >= this.zoneBounds.min.x &&
playerPosition.x <= this.zoneBounds.max.x &&
playerPosition.z >= this.zoneBounds.min.z &&
playerPosition.z <= this.zoneBounds.max.z
);
}
private static update(delta: number) {
if (this.closed || !this.payZoneNode || !this.target) return;
if (this.depositCooldown > 0) this.depositCooldown -= delta;
const backlog = ResourceC.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(HudC.getWorldAnchorPosition(), this.payZoneNode);
this.updateFillVisual();
if (this.deposited >= RESOURCES_TO_CLOSE_ZONE) {
this.closed = true;
console.log(`[PayZoneC] zone full (${this.deposited} delivered) — closing`);
this.playDisappear(this.payZoneNode);
}
}
// One resource, flying from the HUD counter (see chat: "від лічильника
// до пей зони") into the zone itself — the player only needs to be
// standing in the zone to trigger it, not to be the flight's origin.
//
// Traced via temporary logging (see chat): occasionally one of these
// tweens never fires onComplete and freezes mid-flight forever — a
// visible board-textured icon just hanging in the air (user report:
// "текстура доски... постійно статично відображається"). Couldn't pin
// down why inside tween.js/TweenC (a single, non-chained tween, added to
// the group before .start() — the usual "chain() doesn't register the
// second link" bug from Day 6 doesn't apply here). Rather than keep
// digging, added a hard, redundant cleanup: a plain timeout that removes
// the mesh regardless of whether the tween ever completes. Calling
// removeFromScene twice (once from onComplete, once from the timeout, if
// both fire) is harmless — the second call just finds no parent to
// detach from.
private static playDepositFlight(from: Vector3, payZoneNode: Object3D) {
const mesh = ResourceC.createVisual();
const to = payZoneNode.getWorldPosition(new Vector3());
mesh.position.copy(from);
ThreeC.addToScene(mesh);
// No rotation (see chat: "ротейту не має бути") — same as the
// ground-to-HUD flight in ResourceC, this is position/scale only.
//
// Same perspective-compensation trick as ResourceC's toTarget phase,
// just in reverse: this flight starts right at the HUD anchor (close
// to the camera, see HudC.getWorldAnchorPosition) and moves out to the
// Pay Zone (typically much farther away), so without this it would
// visibly *shrink* as it recedes — pure perspective, not an actual
// size change. Scaling by the ratio of current to starting
// camera-distance cancels that, keeping it the one size the whole
// flight (see chat: "розмір має бути таким самим як і в цій
// анімації"). Anchored to RESOURCE_ICON_BASE_SCALE, not the mesh's own
// default (1) — this token is a fresh ResourceC.createVisual(), not one
// of ResourceC's own bounced-and-shrunk FlyingResources, so without
// this it started noticeably larger than the resource that was just
// sitting at that same HUD icon a moment ago (see chat: "дуже великий,
// требя його уніфікувати").
const cameraPosition = CameraC_internal.getCamera().position;
const startDistance = cameraPosition.distanceTo(from);
const flight = new Tween({ t: 0 })
.to({ t: 1 }, DEPOSIT_FLIGHT_DURATION_MS)
.easing(Easing.Quadratic.InOut)
.onUpdate(({ t }) => {
mesh.position.lerpVectors(from, to, t);
const currentDistance = cameraPosition.distanceTo(mesh.position);
const perspectiveScale = startDistance > 1e-6 ? currentDistance / startDistance : 1;
mesh.scale.setScalar(perspectiveScale * RESOURCE_ICON_BASE_SCALE * DEPOSIT_FLIGHT_SCALE_MULTIPLIER);
})
.onComplete(() => ThreeC.removeFromScene(mesh));
TweenC.add(flight);
flight.start();
setTimeout(() => ThreeC.removeFromScene(mesh), DEPOSIT_FLIGHT_DURATION_MS + DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS);
}
// Shrinks the zone's own marker AND the fill overlay together, as one
// piece — the fill previously just popped invisible instantly once the
// marker's shrink finished (see chat: "прогрес заповненості не
// зменшується разом з пей зоною"). Scaling the fill quad down from
// whatever it currently is (full, since this only runs once the zone is
// done) shrinks it toward its own center in lockstep with the marker.
private static playDisappear(node: Object3D) {
const fillMesh = this.fillMesh;
const fillBaseScaleX = fillMesh?.scale.x ?? 0;
const fillBaseScaleY = fillMesh?.scale.y ?? 0;
const shrink = new Tween({ s: 1 })
.to({ s: 0 }, PAY_ZONE_SHRINK_MS)
.easing(Easing.Quadratic.In)
.onUpdate(({ s }) => {
node.scale.setScalar(s);
fillMesh?.scale.set(fillBaseScaleX * s, fillBaseScaleY * s, 1);
})
.onComplete(() => {
node.visible = false;
if (fillMesh) fillMesh.visible = false;
});
TweenC.add(shrink);
shrink.start();
}
}
+27 -27
View File
@@ -3,8 +3,8 @@ import {
Physics_internal,
UpdateController,
} from "@hitplay/playable_template";
import { Box3, Object3D, Vector3 } from "three";
import { Body, Box, Quaternion, Sphere, Vec3 } from "cannon-es";
import { Box3, Mesh, Object3D, Quaternion, Vector3 } from "three";
import { Body, Box, Sphere, Vec3 } from "cannon-es";
export enum PhysicsLayer {
Player = 1,
@@ -27,28 +27,19 @@ export class PhysicsBody {
) {
let isPlayer = col_group === PhysicsLayer.Player;
let oldQuaternion = threeObj.quaternion.clone();
let nullQuaternion = new Quaternion();
threeObj.quaternion.copy(nullQuaternion);
let bbox = new Box3().setFromObject(threeObj);
let size = new Vector3();
bbox.getSize(size);
// if you need custom size
// if (col_group === PhysicsLayer.wall) {
// size.x = size.z = 1;
// size.y = 1;
// }
threeObj.quaternion.copy(oldQuaternion);
// Local (unrotated) bounding size scaled by world scale, combined with
// the world quaternion below, instead of a world-space Box3 with just
// this object's own rotation zeroed out — colliders here are nested
// under rotated parents (e.g. a Lootable crate's root), and a Box3
// still bakes in every ancestor's rotation, so it comes out skewed/
// oversized for anything not axis-aligned all the way up the chain.
let size = isPlayer ? new Vector3() : PhysicsBody.getLocalSize(threeObj);
let worldScale = threeObj.getWorldScale(new Vector3());
size.multiply(worldScale);
this.body = new Body({
isTrigger: trigger,
mass: mass,
//shape: shape,
shape: isPlayer
? new Sphere(player_sphere)
: new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2)),
@@ -57,15 +48,10 @@ export class PhysicsBody {
});
let worldPos = threeObj.getWorldPosition(new Vector3());
this.body.position.set(worldPos.x, worldPos.y, worldPos.z);
this.body.quaternion.setFromEuler(
threeObj.rotation.x,
threeObj.rotation.y,
threeObj.rotation.z,
"XYZ"
);
let worldQuat = threeObj.getWorldQuaternion(new Quaternion());
this.body.quaternion.set(worldQuat.x, worldQuat.y, worldQuat.z, worldQuat.w);
// if you need sync three obj and physics body
// if (isEnemy) {
@@ -81,6 +67,20 @@ export class PhysicsBody {
return this;
}
// Mesh geometry's own bounding box lives in local, unrotated mesh space —
// exactly what an oriented Box shape needs before the world quaternion
// above places and rotates it. Falls back to a Box3 for non-mesh objects
// (e.g. an empty group), which is only correct if nothing up its parent
// chain is rotated.
private static getLocalSize(threeObj: Object3D): Vector3 {
const mesh = threeObj as Mesh;
if (mesh.geometry) {
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
return mesh.geometry.boundingBox!.getSize(new Vector3());
}
return new Box3().setFromObject(threeObj).getSize(new Vector3());
}
disablePhysicsPair() {
if (this.pair) {
this.pair.destroyed = true;
+280 -68
View File
@@ -11,10 +11,25 @@ import {
} from "three";
import { JoystickC, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
import { BreakableProp, BreakablePropC } from "./BreakablePropC";
import { WeaponTrailC } from "./WeaponTrailC";
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
// Rate-limited ramps (units/second²) instead of snapping straight to
// MOVE_SPEED — reaching full speed takes MOVE_SPEED/ACCELERATION seconds
// (~0.33s), and stopping takes MOVE_SPEED/DECELERATION (~0.2s). Braking
// faster than starting is the usual game-feel choice: it keeps stops feeling
// responsive while movement still has some weight to it.
const ACCELERATION = 12;
const DECELERATION = 20;
function moveTowards(current: number, target: number, maxDelta: number): number {
if (Math.abs(target - current) <= maxDelta) return target;
return current + Math.sign(target - current) * maxDelta;
}
// Collision box used for movement — a rough silhouette of the rigged model
// (bbox is ~0.9 x 1.72 x 1.0), not its exact bounds.
const PLAYER_SIZE = new Vector3(0.8, 1.7, 0.8);
@@ -34,28 +49,62 @@ const MIN_OBSTACLE_HEIGHT = 0.5;
// correction and a full reversal at the same rate, which reads as smooth.
const MAX_TURN_SPEED = Math.PI * 3; // ~540°/s
// A near-full reversal (stick flicked to roughly the opposite direction)
// snaps the facing instantly instead of turning gradually at MAX_TURN_SPEED
// — rotating through a slow half-circle reads as sluggish for a direction
// flip specifically. Smaller corrections still use the capped gradual turn.
// ~150°, not a full 180°, so a nearly-opposite flick still snaps too.
const OPPOSITE_TURN_THRESHOLD = Math.PI * (150 / 180);
const ANIMATION_FADE = 0.2; // seconds, crossfade between idle/walk/attack
// How far off-center (in the XZ plane) an obstacle can be from the
// character's forward direction and still count as "facing it".
// dot(forward, toObstacle) > this — 0.5 is a 120°-wide cone (60° each side).
const FACING_DOT_THRESHOLD = 0.5;
// dot(forward, toObstacle) > this — -0.5 is a 240°-wide cone (120° each side).
const FACING_DOT_THRESHOLD = -0.5;
// tryMove() rejects the whole step that would overlap an obstacle, so the
// player always stops just short of actually touching it (up to one frame's
// movement worth of gap) — Box3.intersectsBox on the exact collision box
// would basically never see contact. This extra margin, checked only for
// the attack/interaction range (not movement), covers that gap.
const INTERACTION_REACH = 0.4;
const INTERACTION_REACH = 0.6; // +50%
// The attack loops continuously (see updateCombat) — the "Loot" clip's swing
// actually connects twice per loop (two distinct bat impacts), so each loop
// lands two damage pulses, not one, timed to these fractions of the clip
// rather than right at the loop seam so the bat visually connects first.
// Tune these against the actual clip if the impacts don't line up visually.
const HIT_TIME_FRACTIONS = [0.40,0.60];
const UP_AXIS = new Vector3(0, 1, 0);
const FORWARD_AXIS = new Vector3(0, 0, 1);
interface Obstacle {
node: Object3D;
box: Box3;
}
interface NearbyObstacle {
node: Object3D;
center: Vector3;
}
interface ZoneTarget {
center: Vector3;
prop: BreakableProp;
}
export class PlayerC {
static object: Object3D;
private static moveInput = new Vector2();
private static obstacles: Box3[] = [];
// Last non-zero input direction (normalized) — kept around so
// deceleration still has a direction to coast along after the stick is
// released and moveInput snaps to zero.
private static moveDirection = new Vector2(0, 1);
private static currentSpeed = 0;
private static obstacles: Obstacle[] = [];
private static facingRotation = new Quaternion();
private static mixer: AnimationMixer;
@@ -64,6 +113,17 @@ export class PlayerC {
private static attackAction: AnimationAction;
private static currentAction: AnimationAction;
private static isAttacking = false;
private static attackElapsed = 0;
private static nextHitTime = 0;
// Counts up across the whole continuous attack (not reset per loop) —
// used to pick which of HIT_TIME_FRACTIONS is next and which loop it
// falls in, so timing stays correct across loop seams.
private static hitsThisAttack = 0;
// The point the character is currently locked onto facing (a nearby
// breakable it's stopped next to), or null while just moving/idling with
// nothing to face. Exposed for CameraFollowC — see getFacingTarget().
private static facingTarget: Vector3 | null = null;
private static pistol: Object3D;
private static batInHand: Object3D;
@@ -79,14 +139,20 @@ export class PlayerC {
ThreeC.addToScene(this.object);
this.obstacles = colliders
.map((collider) => new Box3().setFromObject(collider))
.filter((box) => box.max.y - box.min.y >= MIN_OBSTACLE_HEIGHT);
.map((collider) => ({ node: collider, box: new Box3().setFromObject(collider) }))
.filter((obstacle) => obstacle.box.max.y - obstacle.box.min.y >= MIN_OBSTACLE_HEIGHT);
this.bindJoystick();
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
}
// 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);
}
private static createCharacter() {
const character = ThreeC.getObject("character");
@@ -119,6 +185,16 @@ export class PlayerC {
this.equipPistol();
}
// The in-hand bat prop's own node — WeaponTrailC samples its world
// position every frame while attacking to build the swing trail. Its
// pivot is whatever the artist rigged it to (likely the grip, not the
// business end) — this hands over the raw node rather than guessing a
// local tip offset; retune with a local-space offset in WeaponTrailC if
// the trail visibly anchors at the handle instead of the bat's tip.
static getWeaponAnchor(): Object3D {
return this.batInHand;
}
// Exactly one of {pistol, bat} is ever in hand, and the other is stowed —
// the pistol has no separate "in hand" prop to show (see PlayerC chat
// notes), so its own single mesh just toggles at its holster spot instead.
@@ -150,6 +226,7 @@ export class PlayerC {
this.idleAction = this.mixer.clipAction(idleClip);
this.walkAction = this.mixer.clipAction(walkClip);
this.attackAction = this.mixer.clipAction(attackClip);
// Loops continuously while attacking — see updateCombat().
this.attackAction.setLoop(LoopRepeat, Infinity);
this.currentAction = this.idleAction;
@@ -178,44 +255,21 @@ export class PlayerC {
}
private static update(delta: number) {
const inputMagnitude = this.moveInput.length();
// 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);
if (inputMagnitude > 0) {
// Matches the position mapping below (-x -> x, y -> z): the angle a
// movement vector needs to rotate the character's default +z-facing
// front to point the same way. x is negated because the camera now
// looks toward +Z (see TestSceneC's CAMERA_OFFSET) — that's a 180°
// yaw from the default view, which mirrors world +X to screen-left,
// so moving/facing needs the same mirror to keep "stick right" mean
// "screen right".
const angle = Math.atan2(-this.moveInput.x, this.moveInput.y);
this.facingRotation.setFromAxisAngle(UP_AXIS, angle);
const step = MOVE_SPEED * delta;
// Move one axis at a time so a wall blocking one direction still lets
// the player slide along it, instead of getting fully stuck.
this.tryMove(-this.moveInput.x * step, 0);
this.tryMove(0, this.moveInput.y * step);
}
// Turn toward the last movement direction and hold it while idle,
// rather than resetting to face forward the moment input stops.
// rotateTowards caps the step at MAX_TURN_SPEED * delta radians instead
// of interpolating a percentage of the remaining angle. Moving or
// turning away from the obstacle is exactly what breaks updateCombat()'s
// facing check below — no separate "walked/turned away" case needed.
this.object.quaternion.rotateTowards(this.facingRotation, MAX_TURN_SPEED * delta);
this.updateCombat();
this.updateCombat(delta, stopped);
if (!this.isAttacking) {
if (inputMagnitude > 0) {
// Match the walk cycle's playback speed to how hard the stick is
// pushed, not just whether it's pushed — otherwise a small nudge
// still plays the animation at full speed while the character
// barely moves, and the feet visibly skate across the ground.
this.walkAction.timeScale = inputMagnitude;
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
// freeze into Idle the instant input stops while the character is
// still coasting to a stop, or run at full pace during the initial
// ramp-up while barely moving.
this.walkAction.timeScale = this.currentSpeed / MOVE_SPEED;
this.playAction(this.walkAction);
} else {
this.playAction(this.idleAction);
@@ -223,45 +277,203 @@ export class PlayerC {
}
}
// Attacking requires standing next to a destructible AND facing it — pure
// proximity (e.g. backing into a crate) shouldn't swing the bat. Re-reads
// both conditions every frame, so walking or turning away — or the
// obstacle later being removed by a destruction system — stops it
// automatically, with no separate "stop" case to maintain.
private static updateCombat() {
const obstacleCenter = this.getFacingObstacleCenter();
const isAttacking = obstacleCenter !== null;
// Stick-driven movement, facing, and turning — runs every frame regardless
// of combat state (moving is what cancels an attack, see updateCombat, so
// 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 {
const inputMagnitude = this.moveInput.length();
if (isAttacking === this.isAttacking) return;
this.isAttacking = isAttacking;
// 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.
if (inputMagnitude > 0) {
this.moveDirection.copy(this.moveInput).divideScalar(inputMagnitude);
if (isAttacking) {
this.equipBat();
this.playAction(this.attackAction);
} else {
this.equipPistol();
// Direct world-space direction -> angle: the angle a movement vector
// needs to rotate the character's default +z-facing front to point
// the same way. x is negated because the camera now looks toward +Z
// (see TestSceneC's CAMERA_OFFSET) — that's a 180° yaw from the
// default view, which mirrors world +X to screen-left, so facing needs
// 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);
}
// Ramp actual speed toward the stick's target instead of snapping to it:
// moveDirection (not moveInput) drives the step below, so releasing the
// stick keeps coasting along the last heading while DECELERATION brings
// this to zero, instead of stopping dead the instant input hits zero.
const targetSpeed = inputMagnitude * MOVE_SPEED;
const rate = targetSpeed > this.currentSpeed ? ACCELERATION : DECELERATION;
this.currentSpeed = moveTowards(this.currentSpeed, targetSpeed, rate * delta);
if (this.currentSpeed > 0) {
const step = this.currentSpeed * delta;
// Move one axis at a time so a wall blocking one direction still lets
// the player slide along it, instead of getting fully stuck.
this.tryMove(-this.moveDirection.x * step, 0);
this.tryMove(0, this.moveDirection.y * step);
}
// 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.
const stopped = inputMagnitude === 0 && this.currentSpeed <= 0;
if (stopped && nearbyObstacle) {
this.faceTowards(nearbyObstacle.center);
this.facingTarget = nearbyObstacle.center;
} else {
this.facingTarget = null;
}
// Turn toward the current facing target (obstacle or movement) and hold
// it while idle, rather than resetting to face forward the moment input
// stops. rotateTowards caps the step at MAX_TURN_SPEED * delta radians
// instead of interpolating a percentage of the remaining angle — except
// for a near-180° reversal, which snaps immediately (see chat).
if (this.object.quaternion.angleTo(this.facingRotation) > OPPOSITE_TURN_THRESHOLD) {
this.object.quaternion.copy(this.facingRotation);
} else {
this.object.quaternion.rotateTowards(this.facingRotation, MAX_TURN_SPEED * delta);
}
return stopped;
}
// Returns the center of the destructible obstacle the player is touching
// AND facing, or null if there isn't one.
private static getFacingObstacleCenter(): Vector3 | null {
// The point the character is currently locked onto facing (a nearby
// breakable it's stopped next to), or null otherwise — CameraFollowC uses
// this to pan toward the same point, since it's a fixed world position
// (not a rotation), so it can't produce the arc/orbit motion a
// facing/quaternion-driven look-ahead did (see CameraFollowC chat notes).
static getFacingTarget(): Vector3 | null {
return this.facingTarget;
}
private static faceTowards(point: Vector3) {
const toPoint = point.clone().sub(this.object.position);
toPoint.y = 0;
if (toPoint.lengthSq() < 1e-6) return; // standing right on top of it — keep whatever we're already facing
const angle = Math.atan2(toPoint.x, toPoint.z);
this.facingRotation.setFromAxisAngle(UP_AXIS, angle);
}
// The attack loops continuously and hits *every* breakable target in the
// zone each cycle (not just the one the character turned to face) — it
// keeps going, unattended, until the zone is empty. The only things that
// stop it early are the player moving again or running out of targets;
// there's no commitment to "finish the swing" the way a discrete attack
// would have.
private static updateCombat(delta: number, stopped: boolean) {
const zoneTargets = this.findBreakableTargetsInZone();
if (this.isAttacking) {
if (!stopped || zoneTargets.length === 0) {
this.isAttacking = false;
WeaponTrailC.setActive(false);
this.equipPistol();
return;
}
this.attackElapsed += delta;
if (this.attackElapsed >= this.nextHitTime) {
zoneTargets.forEach((target) => BreakablePropC.hit(target.prop, this.hitDirectionTo(target.center)));
this.hitsThisAttack++;
this.nextHitTime = this.hitTimeFor(this.hitsThisAttack);
}
return;
}
if (!stopped || zoneTargets.length === 0) return;
const nearest = this.nearestZoneTarget(zoneTargets);
if (!this.isFacing(nearest.center)) return;
this.isAttacking = true;
WeaponTrailC.setActive(true);
this.attackElapsed = 0;
this.hitsThisAttack = 0;
this.nextHitTime = this.hitTimeFor(0);
this.equipBat();
this.playAction(this.attackAction);
}
// 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.
private static hitTimeFor(hitIndex: number): number {
const duration = this.attackAction.getClip().duration;
const loopsCompleted = Math.floor(hitIndex / HIT_TIME_FRACTIONS.length);
const fraction = HIT_TIME_FRACTIONS[hitIndex % HIT_TIME_FRACTIONS.length];
return duration * (loopsCompleted + fraction);
}
private static nearestZoneTarget(targets: ZoneTarget[]): ZoneTarget {
return targets.reduce((closest, target) =>
this.object.position.distanceToSquared(target.center) <
this.object.position.distanceToSquared(closest.center)
? target
: closest
);
}
// Every breakable prop within interaction range AND within the frontal
// swing arc (same cone as isFacing, see chat: hitting crates standing
// behind the player didn't make sense for a forward bat swing) —
// "unlimited targets in the hit zone" per the design, just not a full
// 360° one. Non-breakable obstacles (walls) are excluded: there's
// nothing for the attack to do to them.
private static findBreakableTargetsInZone(): ZoneTarget[] {
const bounds = this.getCollisionBounds(this.object.position).expandByScalar(INTERACTION_REACH);
const touching = this.obstacles.find((box) => box.intersectsBox(bounds));
if (!touching) return null;
const targets: ZoneTarget[] = [];
const center = touching.getCenter(new Vector3());
for (const obstacle of this.obstacles) {
if (!obstacle.box.intersectsBox(bounds)) continue;
const prop = BreakablePropC.getByColliderNode(obstacle.node);
if (!prop) continue;
const center = obstacle.box.getCenter(new Vector3());
if (!this.isFacing(center)) continue;
targets.push({ center, prop });
}
const toObstacle = center.clone().sub(this.object.position);
toObstacle.y = 0;
if (toObstacle.lengthSq() < 1e-6) return center; // standing right on top of it — count as facing
return targets;
}
toObstacle.normalize();
// Returns the nearest obstacle (breakable or not) within interaction
// range, purely so idle facing (see updateMovement) has something to turn
// toward — unrelated to which targets an attack actually hits.
private static findNearbyObstacle(): NearbyObstacle | null {
const bounds = this.getCollisionBounds(this.object.position).expandByScalar(INTERACTION_REACH);
const touching = this.obstacles.find((obstacle) => obstacle.box.intersectsBox(bounds));
return touching ? { node: touching.node, center: touching.box.getCenter(new Vector3()) } : null;
}
// Attacker -> target, flattened to XZ and normalized — how BreakablePropC
// decides which way a hit crate should lean. Zero vector (standing right
// on top of the target) is a valid, harmless result: no lean either way.
private static hitDirectionTo(point: Vector3): Vector3 {
const direction = point.clone().sub(this.object.position);
direction.y = 0;
if (direction.lengthSq() > 1e-6) direction.normalize();
return direction;
}
private static isFacing(point: Vector3): boolean {
const toPoint = point.clone().sub(this.object.position);
toPoint.y = 0;
if (toPoint.lengthSq() < 1e-6) return true; // standing right on top of it
toPoint.normalize();
const forward = FORWARD_AXIS.clone().applyQuaternion(this.object.quaternion);
forward.y = 0;
forward.normalize();
return forward.dot(toObstacle) > FACING_DOT_THRESHOLD ? center : null;
return forward.dot(toPoint) > FACING_DOT_THRESHOLD;
}
private static tryMove(dx: number, dz: number) {
@@ -270,7 +482,7 @@ export class PlayerC {
nextPosition.z += dz;
const nextBounds = this.getCollisionBounds(nextPosition);
const blocked = this.obstacles.some((box) => box.intersectsBox(nextBounds));
const blocked = this.obstacles.some((obstacle) => obstacle.box.intersectsBox(nextBounds));
if (blocked) return;
this.object.position.copy(nextPosition);
+90
View File
@@ -0,0 +1,90 @@
import { Object3D, Vector3 } from "three";
import { BatchedRenderer, QuarksLoader, QuarksUtil } from "three.quarks";
import { Template3d, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
import vfxLootableHitJson from "../resources/vfx/VFX_Lootable_Hit.json";
import vfxLootableDestroyJson from "../resources/vfx/VFX_Lootable_Destroy.json";
// Real designer-authored VFX for breakable props — found sitting unused in
// temp/ (exported from the quarks.art editor as plain Object3D.toJSON()
// groups, each a couple of ParticleEmitter children with their own tuned
// shape/speed/color/behaviors, referencing real textures like
// "novalines.webp" for the ray-streak look). Replaces the earlier
// hand-built SparkFxC, whose particles read as a soft dot-blob rather than
// the rays the reference screenshots called for — no amount of tuning a
// from-scratch system was going to match a real authored asset anyway.
//
// Loaded via plain Vite JSON imports + three.quarks' own QuarksLoader
// directly, NOT the SDK's quarksLoader()/ConvertToBase64WhenRelease
// (the pattern meshes.ts/images.ts use): quarksLoader() hardcodes
// `base64String.split(",")[1]` then atob() on it, which assumes it's
// always handed an actual base64 data: URI — true only once the build-time
// AST plugin rewrites the ConvertToBase64WhenRelease() call; in plain
// `vite dev` that call is a no-op passthrough returning a bare path
// string, which has no comma and would break the split/atob chain. A
// static JSON import sidesteps that entirely — Vite inlines JSON content
// into the bundle natively in every mode (dev, build, and the single-file
// export), no fetch, no base64 round-trip, no dev/build inconsistency.
const CLEANUP_DELAY_MS = 1500; // covers the longest emitter's duration+life in either prefab
export class PropVfxC {
private static renderer: BatchedRenderer | null = null;
private static hitTemplate: Object3D | null = null;
private static destroyTemplate: Object3D | null = null;
static init() {
if (this.renderer) return;
this.renderer = new BatchedRenderer();
ThreeC.addToScene(this.renderer);
UpdateController.Instance.onUpdate.addDelegate((delta) => this.renderer!.update(delta));
// QuarksLoader.parse() is synchronous — the JSON already embeds every
// texture as a base64 data: URI (see the "images" array in the
// exported files), so there's no network fetch to wait on.
const loader = new QuarksLoader(Template3d.manager);
this.hitTemplate = loader.parse(vfxLootableHitJson);
this.destroyTemplate = loader.parse(vfxLootableDestroyJson);
// "Ground_Dirt" renders as a raw Mesh (RenderMode.Mesh) using a plain
// PlaneGeometry (XY-plane, normal +Z) — unlike its "BreakingDust"
// sibling in the same prefab, its own node has no compensating rotation
// baked in, so unrotated it stands upright on its edge instead of lying
// on the ground. -90° about local X maps that local Z normal to world
// +Y (same convention as PayZoneC's ground-fill quad). Per-particle
// startRotation spins around the emitter's own local Z (three.quarks
// hardcodes UP=(0,0,1) for Mesh-mode rotation, ignoring the JSON's own
// "axis" field), so this also keeps that spin flat around the
// corrected world-up axis rather than tumbling the decal out of plane.
const groundDirt = this.destroyTemplate.getObjectByName("Ground_Dirt");
if (groundDirt) groundDirt.rotation.x = -Math.PI / 2;
}
static spawnHit(position: Vector3) {
this.spawn(this.hitTemplate, position);
}
static spawnDestroy(position: Vector3) {
this.spawn(this.destroyTemplate, position);
}
// Each call clones the loaded template (so concurrent bursts don't share
// particle state), positions it at the world-space point, and forces
// autoDestroy so every emitter in it removes itself once its particles
// die — same self-cleanup contract as the old SparkFxC. The clone's root
// Group itself doesn't get removed by that (only its ParticleEmitter
// children do, once each one's particles finish), so it's swept from the
// scene on a plain timeout sized to the prefabs' own durations.
private static spawn(template: Object3D | null, position: Vector3) {
if (!template || !this.renderer) return;
const instance = template.clone();
instance.position.copy(position);
QuarksUtil.setAutoDestroy(instance, true);
QuarksUtil.addToBatchRenderer(instance, this.renderer);
ThreeC.addToScene(instance);
QuarksUtil.play(instance);
setTimeout(() => ThreeC.removeFromScene(instance), CLEANUP_DELAY_MS);
}
}
+391
View File
@@ -0,0 +1,391 @@
import {
AdditiveBlending,
BoxGeometry,
Color,
Mesh,
MeshBasicMaterial,
MeshStandardMaterial,
Object3D,
Vector3,
} from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
import { SparkleFxC } from "./SparkleFxC";
// The map ships an actual wood-icon quad ("UI_Wood", textured with
// Icon_Wood) sitting unused in the scene — TestSceneC hands it in via
// setPickupTemplate() and every spawned resource is a clone of it. This
// placeholder cube only covers the case where that node is ever missing.
const PICKUP_SIZE = 0.28;
// Resources pop out of a hit target, scatter a short, fixed distance, land,
// bounce twice, then fly on to the HUD counter (see HudC) — the player
// never has to walk over and trigger any of this (see chat: "персонажу не
// потрібно підходити для збору"). Carrying the collected total on into the
// Pay Zone is a separate, later mechanic — see PayZoneC, which drains
// ResourceC.getCollectedCount() on its own.
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 where it lands — decreasing height/duration
// each time, index 0 first. On the second (last) bounce's touchdown, the
// resource flashes white (like BreakablePropC's crate hit-flash) and
// shrinks while a sparkle burst plays around it (see chat: exact reference
// look). This shrink is intentional and stays (see chat: "Ти прибрав зміну
// розміру при баунсі... я тобі писав не про це" — a later, unrelated
// request to remove scaling from the toTarget *flight* got misread as
// "remove scaling everywhere" and briefly took this out too; it's back).
// The only place scale must NOT change is the toTarget flight itself —
// see that phase below, which deliberately never touches scale, so
// whatever value this phase lands on just carries through unchanged.
const BOUNCE_HEIGHTS = [0.4, 0.22];
const BOUNCE_DURATIONS = [0.26, 0.2];
// The shrink ramps in quickly (SHRINK_RAMP_DURATION) and then just holds —
// it does NOT ease back out to the original size (see chat: "стискання
// без повернення до початкового розміру"). Uniform shrink, not a
// squash-and-widen — an earlier version widened X/Z while flattening Y,
// which read as the resource *growing* (see chat: "мають навпаки
// зменшуватись"). The flash fades out on its own, longer, slower timeline
// (see chat: "нехай флеш буде довшим") — decoupled from the shrink so
// lengthening one doesn't drag the other out too.
const SHRINK_RAMP_DURATION = 0.22;
const FLASH_DURATION = 0.45;
// Must be >= FLASH_DURATION or the flash gets cut off mid-fade before the
// resource flies off — a little slack on top so the fully-faded state
// actually reads for a beat first.
const IMPACT_HOLD_DURATION = 0.55;
const SHRINK_AMOUNT = 0.15; // fraction shrunk, held once reached
const FLASH_COLOR = new Color(0xffffff);
// The world-space scale a resource settles at after the bounce shrink and
// carries all the way to the HUD (see toTarget's perspective compensation
// below, which holds this constant in apparent/on-screen size). Exported
// so PayZoneC's deposit-flight token — a separate, fresh mesh, not one of
// these FlyingResources — can start from the same baseline instead of its
// own default (1), which read as noticeably larger (see chat: "розмір...
// дуже великий, треба уніфікувати").
export const RESOURCE_ICON_BASE_SCALE = 1 - SHRINK_AMOUNT;
// Once settled, a resource flies on to wherever setFlyTarget() points (the
// HUD counter, see HudC). Without a target set, it's counted immediately
// instead (no flight) — see update() below.
//
// An accelerating (ease-in) version of this was tried and reverted (see
// chat: "зникають раніше часу") — CONVERGE_START_FRACTION triggers the
// shrink-to-nothing off raw elapsed time `t`, not spatial progress, and
// ease-in makes spatial progress lag well behind `t` (at t=0.7 an eased-in
// t² curve has only covered half the distance), so the resource visibly
// vanished while still far short of the target. Smoothstep keeps those two
// closely matched enough that convergence reads as "arriving", not
// "disappearing early".
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 — 0.7 means the last 30% of the flight.
const CONVERGE_START_FRACTION = 0.7;
// Resources dropped by the same hit shouldn't all launch toward the HUD in
// the same instant — each one already bounces on identical timing
// constants, so without this they'd all reach "toTarget" together and fly
// as one visual clump (see chat: "не мають летіти однією кучею"). Instead,
// each resource in a burst waits STAGGER_INTERVAL longer than the previous
// one before launching — see spawnOne's staggerDelay param and its use in
// the impactHold exit condition below.
const STAGGER_INTERVAL = 0.12;
function randomInt(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min + 1));
}
// Same trick as BreakablePropC's hit-flash: a same-geometry clone layered
// on top with additive blending, opacity animated for the flash — lerping
// an unlit mesh's own (already-white) material color is a silent no-op
// (see BreakablePropC/CLAUDE.md for why). Collected up front, before
// anything mutates the tree, so adding overlay children mid-traversal
// can't make traverse() walk into the 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 mesh's own local transform (relative to ITS parent) —
// parenting the overlay under the mesh itself, one level deeper than
// that, would reinterpret those same numbers in the mesh's local space
// and displace the overlay instead of overlaying it exactly on top.
// Reset to identity so it sits precisely where its new parent (mesh)
// already is.
overlay.position.set(0, 0, 0);
overlay.rotation.set(0, 0, 0);
overlay.scale.set(1, 1, 1);
overlay.material = new MeshBasicMaterial({
color: FLASH_COLOR,
transparent: true,
opacity: 0,
blending: AdditiveBlending,
depthWrite: false,
});
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
}
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;
static getCollectedCount() {
return this.collected;
}
// The real wood-icon node from the map (see TestSceneC.createMap) — every
// spawned pickup is a clone of this rather than the fallback cube.
static setPickupTemplate(template: Object3D) {
this.pickupTemplate = template;
}
// Where settled resources fly to before being counted — the HUD counter
// (see HudC.getWorldAnchorPosition).
static setFlyTarget(getTarget: () => Vector3) {
this.flyTarget = getTarget;
}
// A standalone clone of the pickup visual, for callers (PayZoneC) that
// need to fly a "resource" token somewhere outside of the normal
// spawn/scatter/collect lifecycle below. No flash overlays — those are
// only built for the actual bounce-and-collect resources below.
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);
}
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,
});
}
private static createPickupMesh(): Object3D {
if (this.pickupTemplate) {
const clone = this.pickupTemplate.clone();
// The template itself is hidden in its resting spot in the map (see
// TestSceneC) — clone() copies that visible:false too, so force it
// back on regardless of which order those two happen in.
clone.visible = true;
return clone;
}
const mesh = new Mesh(
new BoxGeometry(PICKUP_SIZE, PICKUP_SIZE, PICKUP_SIZE),
new MeshStandardMaterial({ color: 0x8a5a34 })
);
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);
// simple 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);
// Ease in toward the shrink and stay there — no easing back out to
// the original size (see chat).
const shrink = shrinkT * shrinkT * (3 - 2 * shrinkT) * SHRINK_AMOUNT;
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;
}
// The stagger delay (see STAGGER_INTERVAL) just extends this wait
// per-resource — the shrink/flash above are already fully settled
// by IMPACT_HOLD_DURATION regardless, so waiting longer here only
// delays the *launch*, not any of the visible impact feedback.
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 of the flight
// Re-sampled every frame, not snapshotted once at launch — the
// camera (and so the HUD anchor's world position, see
// HudC.getWorldAnchorPosition) can keep moving during the ~0.5s
// flight (player walking, camera follow), so a one-time snapshot
// would leave the resource flying toward a stale point that no
// longer lines up with the icon (see chat: "втрачають потрібну
// позицію і летять не туди"). Re-fetching each frame means it always
// curves toward wherever the icon actually is *right now*.
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 flight target sits much closer to the camera than where
// resources spawn (see HudC.getWorldAnchorPosition — it's pinned a
// fixed, short distance in front of the camera), so without this the
// resource would visibly *grow* as it approaches — pure perspective
// foreshortening from closing distance, not any actual change to
// world-space scale (see chat: "ресурс не має змінюватись в розмірі
// в залежності від відстані"). Scaling by the ratio of current to
// starting camera-distance exactly cancels that: apparent (on-screen)
// size stays pinned to whatever it already was leaving the bounce,
// no matter how close it gets.
const cameraPosition = CameraC_internal.getCamera().position;
const currentDistance = cameraPosition.distanceTo(resource.mesh.position);
const perspectiveScale =
resource.startDistanceFromCamera > 1e-6 ? currentDistance / resource.startDistanceFromCamera : 1;
resource.mesh.scale.setScalar(resource.baseScale * perspectiveScale);
// Over the last stretch of the flight, brightens right as it reaches
// the target — reads as being absorbed into the counter's icon,
// rather than just popping out of existence mid-air (see chat: "має
// наприкінці збиратись в одиницю ресурсу і... залітати на місце
// іконки"). 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++;
console.log(`[ResourceC] gathered wood (${this.collected} total)`);
ThreeC.removeFromScene(resource.mesh);
}
}
+173
View File
@@ -0,0 +1,173 @@
import {
AdditiveBlending,
Color,
DoubleSide,
Mesh,
MeshBasicMaterial,
Shape,
ShapeGeometry,
Vector3,
} from "three";
import { CameraC_internal, TweenC } from "@hitplay/playable_template";
import { Easing, Tween } from "@tweenjs/tween.js";
import { ThreeC } from "./ThreeC";
const SPARKLE_COUNT = 5;
const SPARKLE_SCATTER_RADIUS = 0.28; // world units around the burst point
const SPARKLE_OUTER_RADIUS = 0.07;
const SPARKLE_INNER_RADIUS = 0.02;
const SPARKLE_COLOR = new Color(0xfff6d8); // warm white — a "reward" twinkle, not a colored magic effect
const POP_IN_MS = 80;
const HOLD_MS = 90;
const FADE_OUT_MS = 220;
// Sized for the worst realistic case, not the common one: a crate can drop
// several resources at once (see ResourceC), each bouncing independently
// but on identical timing constants, so their second-bounce bursts land
// within the same frame or two of each other — up to
// (max resources per hit) * SPARKLE_COUNT stars alive simultaneously.
const POOL_SIZE = 30;
// A small 4-point twinkle star (outer/inner radius alternating around the
// ring) — the classic "sparkle" shape, not a designer sprite/atlas asset
// (none exists yet for this, see PropVfxC for the kind of VFX that does
// have one). Built once and shared read-only across every spawned star.
function buildStarShape(): Shape {
const points = 4;
const shape = new Shape();
for (let i = 0; i < points * 2; i++) {
const radius = i % 2 === 0 ? SPARKLE_OUTER_RADIUS : SPARKLE_INNER_RADIUS;
const angle = (i / (points * 2)) * Math.PI * 2;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
if (i === 0) shape.moveTo(x, y);
else shape.lineTo(x, y);
}
shape.closePath();
return shape;
}
const starGeometry = new ShapeGeometry(buildStarShape());
interface StarSlot {
mesh: Mesh;
material: MeshBasicMaterial;
inUse: boolean;
}
// A short "twinkle" burst of a handful of star-shaped quads around a point
// — used for the second bounce of a landed resource (see ResourceC), but
// not tied to that specifically, so it's its own small reusable effect.
//
// Pooled (see chat: "давай зробимо пул") — every burst used to `new Mesh` +
// `new MeshBasicMaterial` per star and throw them away ~390ms later via
// removeFromScene(). Multiple resources bouncing in from the same crate
// hit fire their bursts within a frame or two of each other, so that
// pattern meant a dozen-plus create+GC cycles clustered into under half a
// second — exactly the "small VFX objects churning" case object pooling
// exists for (see Day 7 brief: "recommended to use pools for VFX"). Now a
// fixed set of star meshes is built once at init() and reused: spawnBurst
// just grabs whichever slots aren't currently animating.
export class SparkleFxC {
private static pool: StarSlot[] = [];
private static initialized = false;
static init() {
if (this.initialized) return;
this.initialized = true;
for (let i = 0; i < POOL_SIZE; i++) {
const material = new MeshBasicMaterial({
color: SPARKLE_COLOR,
transparent: true,
opacity: 0,
blending: AdditiveBlending,
depthWrite: false,
side: DoubleSide,
toneMapped: false,
});
const mesh = new Mesh(starGeometry, material);
mesh.visible = false;
mesh.scale.setScalar(0);
mesh.renderOrder = 10; // draws after the resource/flash it bursts around
ThreeC.addToScene(mesh);
this.pool.push({ mesh, material, inUse: false });
}
}
static spawnBurst(position: Vector3) {
const cameraPosition = CameraC_internal.getCamera().getWorldPosition(new Vector3());
let spawned = 0;
for (const slot of this.pool) {
if (slot.inUse) continue;
this.activate(slot, position, cameraPosition);
spawned++;
if (spawned >= SPARKLE_COUNT) return;
}
// Not a hard failure — just fewer stars than usual this burst — but
// worth knowing about if it ever actually happens, rather than a
// silently smaller effect (see Day 7 workflow guidance on silent caps).
if (spawned < SPARKLE_COUNT) {
console.debug(`[SparkleFxC] pool exhausted — spawned ${spawned}/${SPARKLE_COUNT} stars`);
}
}
private static activate(slot: StarSlot, position: Vector3, cameraPosition: Vector3) {
slot.inUse = true;
const { mesh, material } = slot;
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * SPARKLE_SCATTER_RADIUS;
const offset = new Vector3(
Math.cos(angle) * radius,
Math.random() * SPARKLE_SCATTER_RADIUS * 0.6,
Math.sin(angle) * radius
);
mesh.position.copy(position).add(offset);
// Faces the actual camera position rather than a shared fixed
// quaternion — cheap since it only runs once per activation, and stays
// correct even though each star sits at a slightly different offset
// around the burst point.
mesh.lookAt(cameraPosition);
mesh.scale.setScalar(0);
mesh.visible = true;
material.opacity = 0;
const popIn = new Tween({ t: 0 })
.to({ t: 1 }, POP_IN_MS)
.easing(Easing.Back.Out)
.onUpdate(({ t }) => {
mesh.scale.setScalar(Math.max(t, 0));
material.opacity = Math.min(t, 1);
});
const fadeOut = new Tween({ t: 1 })
.to({ t: 0 }, FADE_OUT_MS)
.delay(HOLD_MS)
.easing(Easing.Quadratic.In)
.onUpdate(({ t }) => {
mesh.scale.setScalar(t);
material.opacity = t;
})
.onComplete(() => {
mesh.visible = false;
slot.inUse = false;
});
// Both links added to TweenC individually — .chain() only tells
// popIn to call fadeOut.start() on completion, it doesn't register
// fadeOut for update ticks on its own (the same gotcha BreakablePropC
// hit with its hit-flash chain — see CLAUDE.md).
popIn.chain(fadeOut);
TweenC.add(popIn);
TweenC.add(fadeOut);
popIn.start();
}
}
+88 -10
View File
@@ -1,25 +1,55 @@
import { Object3D, Vector3 } from "three";
import { ThreeC } from "./ThreeC";
import { PlayerC, PLAYER_HEIGHT } from "./PlayerC";
import { PlayerC } from "./PlayerC";
import { CameraFollowC } from "./CameraFollowC";
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
import { BreakablePropC } from "./BreakablePropC";
import { ResourceC } from "./ResourceC";
import { PayZoneC } from "./PayZoneC";
import { HudC } from "./HudC";
import { PropVfxC } from "./PropVfxC";
import { WeaponTrailC } from "./WeaponTrailC";
import { SparkleFxC } from "./SparkleFxC";
// 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
// "behind" flipped from +Z to -Z to keep looking the same way they walk.
const CAMERA_OFFSET = new Vector3(0, 9, -9);
// Obstacle checks look from roughly head height rather than the player's
// feet (position.y is anchored at the feet), so short obstacles don't
// falsely count as hiding the player. 0.9 of the full height keeps the ray
// just under the very top edge, avoiding edge-grazing false negatives.
const CAMERA_EYE_HEIGHT = PLAYER_HEIGHT * 0.9;
export class TestSceneC {
static init() {
const colliders = this.createMap();
PropVfxC.init();
SparkleFxC.init();
const { colliders, payZoneNode } = this.createMap();
PlayerC.init(colliders);
CameraFollowC.init(PlayerC.object, CAMERA_OFFSET, colliders, CAMERA_EYE_HEIGHT);
CameraFollowC.init(PlayerC.object, CAMERA_OFFSET);
// Lets the camera's look-ahead react when the player locks facing onto
// a nearby breakable while stopped (a pure in-place rotation with no
// movement of its own) — see CameraFollowC.setFacingTargetGetter.
CameraFollowC.setFacingTargetGetter(() => PlayerC.getFacingTarget());
// Must come after CameraFollowC.init(): the trail's screen-facing
// ribbon captures the camera's view direction once at init time,
// relying on the fact that CameraFollowC's rotation is frozen for good
// 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);
// 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.
HudC.init();
ResourceC.setFlyTarget(() => HudC.getWorldAnchorPosition());
HudC.setBalanceGetter(() => ResourceC.getCollectedCount() - PayZoneC.getDepositedCount());
}
private static createMap() {
@@ -35,10 +65,58 @@ export class TestSceneC {
}
});
// The map ships a real wood-icon quad, just sitting unused in the scene
// (textured with Icon_Wood) — hand it to ResourceC as the pickup visual
// instead of the generic placeholder cube, and hide the static instance
// since it's now a template to clone, not scenery.
const woodIcon = map.getObjectByName("UI_Wood");
if (woodIcon) {
ResourceC.setPickupTemplate(woodIcon);
woodIcon.visible = false;
}
// Leftover art scaffolding near the Pay Zone — a second wood-icon quad
// stacked on it, an unused "tool zone" plate, and a rotated
// Background/Foreground/Middleground billboard group (likely a fake
// install-button mockup, floating above ground nearby) — always
// visible by default, unused by any code, just cluttering the scene.
// ⚠️ Named "UI_Wood.001" (with a dot) in the source glb, but whatever
// loads it here strips the dot — the live scene has it as "UI_Wood001".
// getObjectByName("UI_Wood.001") always silently returned undefined,
// so this never actually hid anything (confirmed by traversing the live
// scene — see chat). This is why it kept showing up no matter what else
// got hidden/removed.
const woodIconAlt = map.getObjectByName("UI_Wood001");
if (woodIconAlt) woodIconAlt.visible = false;
const toolZone = map.getObjectByName("UI_Tool_Zone");
if (toolZone) toolZone.visible = false;
const installMockup = map.getObjectByName("UI");
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);
});
for (const collider of colliders) {
if (propColliders.has(collider)) continue;
new PhysicsBody(collider, false, 0, PhysicsLayer.Wall, PhysicsLayer.Player | PhysicsLayer.Enemy);
}
ThreeC.setShadowsStateForChildren(map, true, true);
// Must run after the line above — it force-enables shadows on every
// mesh under the map, including BreakablePropC's flash overlays, which
// this turns back off (see disableFlashShadows).
BreakablePropC.disableFlashShadows();
ThreeC.addToScene(map);
return colliders;
return { colliders, payZoneNode: map.getObjectByName("UI_Interactive_Zone_02") };
}
}
+256
View File
@@ -0,0 +1,256 @@
import {
AdditiveBlending,
Box3,
BufferAttribute,
BufferGeometry,
Color,
DoubleSide,
Mesh,
MeshBasicMaterial,
Object3D,
Vector3,
} from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC";
// How long (seconds) a sampled point stays visible before fully fading —
// short and snappy on purpose (see Day 7 "keep effects short"). Governs the
// actual trail length in time, independent of framerate. Shortened from an
// earlier 0.15 — the longer tail read as too smooth/flowing for a bat swing
// (see chat: "не таким плавним").
const TRAIL_LIFETIME = 0.30;
// Safety cap on buffered points — real length is TRAIL_LIFETIME (above),
// this just sizes the backing arrays generously for high-refresh displays
// (120Hz * 0.1s = 12 samples) with headroom.
const MAX_POINTS = 24;
// Full ribbon width at the freshest point, in world units — shrinks toward
// zero as a point ages (see FADE_POWER below for the curve), so the trail
// tapers to a point at its tail instead of ending in a hard rectangle.
const TRAIL_WIDTH = 0.35;
// fade = (1 - age/TRAIL_LIFETIME) ^ FADE_POWER — 1 (linear) reads as a
// smooth, even gradient; a higher power holds close to full width/color
// near the head and then drops off sharply toward the tail, which reads as
// snappier/choppier rather than a silky ribbon (see chat: "не таким
// плавним"). Applied to both the per-point width and color intensity below.
const FADE_POWER = 4.5;
// Pale, slightly cool white — reads as air/wind displacement rather than a
// colored magic effect. Additive blending + fading the color itself toward
// black (not a separate alpha channel) is the same cheap trick
// BreakablePropC's hit-flash overlay uses, no custom shader needed.
const TRAIL_COLOR = new Color(0xdff3ff);
// Lowered from an earlier 0.85 — the trail read as too opaque/solid (see
// chat: "прозорішим").
const MATERIAL_OPACITY = 0.15;
const UP_AXIS = new Vector3(0, 1, 0);
const FALLBACK_TANGENT = new Vector3(0, 0, 1);
// How far back from the bat's very tip the trail actually samples, as a
// fraction of the half-length along the bat's own length axis — 0 = exact
// tip corner, 1 = box center. See chat: "не на самий кінець бити".
const TIP_INSET_FRACTION = 0.25;
interface TrailPoint {
position: Vector3;
age: number;
}
// A hand-rolled screen-facing ribbon that follows the weapon anchor's world
// position while active, visualizing the swing's arc — not a three.quarks
// prefab, because this needs to track an *animated bone-driven* point every
// frame rather than play a fixed, designer-authored burst (see PropVfxC for
// that other kind of VFX). Same category of trick as BreakablePropC's
// flashMesh: plain geometry + additive blending, no custom shader.
export class WeaponTrailC {
private static weapon: Object3D | null = null;
private static points: TrailPoint[] = [];
private static active = false;
private static mesh: Mesh;
private static geometry: BufferGeometry;
// Captured once, after CameraFollowC has set its permanently-frozen look
// angle (see CameraFollowC — the camera's rotation never changes again
// after init) — the ribbon's width faces this fixed direction instead of
// re-querying the camera every frame.
private static viewDirection = new Vector3();
// Where on the bat the trail actually samples, in the weapon node's own
// local space — see computeLocalTipOffset. The node itself is pivoted at
// the grip (see chat: the trail was following the hand, not the bat's
// business end), so sampling weapon.getWorldPosition() directly anchored
// the ribbon at the palm instead of the tip.
private static localTipOffset = new Vector3();
static init(weapon: Object3D) {
this.weapon = weapon;
this.localTipOffset = this.computeLocalTipOffset(weapon);
CameraC_internal.getCamera().getWorldDirection(this.viewDirection);
this.geometry = this.buildGeometry();
const material = new MeshBasicMaterial({
vertexColors: true,
transparent: true,
opacity: MATERIAL_OPACITY,
blending: AdditiveBlending,
depthWrite: false,
side: DoubleSide,
toneMapped: false,
});
this.mesh = new Mesh(this.geometry, material);
this.mesh.visible = false;
ThreeC.addToScene(this.mesh);
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
}
// Toggled by PlayerC alongside isAttacking — the trail samples the
// weapon's position every frame while true, and keeps aging out whatever
// points it already has once false (so a swing that just ended still
// trails off naturally instead of vanishing mid-air).
static setActive(active: boolean) {
this.active = active;
}
// The weapon node's own pivot is wherever the artist rigged it (the grip,
// so it can be parented to the hand bone) — not necessarily "the tip".
// Rather than guess a local offset, derive it from the actual geometry:
// union the local-space bounding box of every mesh under the node, then
// take the far end of its longest axis (the bat's length) as the tip,
// centered on the other two axes. Computed once at init — a rigid prop's
// own local transform relative to its parent bone doesn't change with
// animation, so this is stable regardless of when in the swing it runs.
private static computeLocalTipOffset(root: Object3D): Vector3 {
root.updateWorldMatrix(true, true);
const box = new Box3();
const corner = new Vector3();
root.traverse((child) => {
if (!(child instanceof Mesh)) return;
child.geometry.computeBoundingBox();
const meshBox = child.geometry.boundingBox;
if (!meshBox) return;
for (const x of [meshBox.min.x, meshBox.max.x]) {
for (const y of [meshBox.min.y, meshBox.max.y]) {
for (const z of [meshBox.min.z, meshBox.max.z]) {
corner.set(x, y, z);
child.localToWorld(corner);
root.worldToLocal(corner);
box.expandByPoint(corner);
}
}
}
});
if (box.isEmpty()) return new Vector3();
const size = box.getSize(new Vector3());
const lengthAxis: "x" | "y" | "z" =
size.x >= size.y && size.x >= size.z ? "x" : size.y >= size.z ? "y" : "z";
const tip = box.getCenter(new Vector3());
const centerValue = tip[lengthAxis];
const farValue = Math.abs(box.max[lengthAxis]) > Math.abs(box.min[lengthAxis]) ? box.max[lengthAxis] : box.min[lengthAxis];
tip[lengthAxis] = farValue + (centerValue - farValue) * TIP_INSET_FRACTION;
return tip;
}
private static sampleWeaponTip(): Vector3 {
this.weapon!.updateWorldMatrix(true, false);
return this.weapon!.localToWorld(this.localTipOffset.clone());
}
private static buildGeometry(): BufferGeometry {
const segments = MAX_POINTS - 1;
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array(segments * 6 * 3), 3));
geometry.setAttribute("color", new BufferAttribute(new Float32Array(segments * 6 * 3), 3));
geometry.setDrawRange(0, 0);
return geometry;
}
private static update(delta: number) {
if (!this.weapon) return;
for (const point of this.points) point.age += delta;
this.points = this.points.filter((point) => point.age < TRAIL_LIFETIME);
if (this.active) {
this.points.push({ position: this.sampleWeaponTip(), age: 0 });
if (this.points.length > MAX_POINTS) this.points.shift();
}
this.rebuildGeometry();
}
private static rebuildGeometry() {
const n = this.points.length;
this.mesh.visible = n >= 2;
if (n < 2) {
this.geometry.setDrawRange(0, 0);
return;
}
// One pass to derive each point's own tangent/perpendicular/fade first,
// then a second pass to stitch adjacent points into quads — sharing a
// point's edge between its two neighboring segments keeps the ribbon
// continuous instead of faceted at every sample.
const lefts: Vector3[] = [];
const rights: Vector3[] = [];
const fades: number[] = [];
for (let i = 0; i < n; i++) {
const point = this.points[i];
const forward =
i < n - 1
? this.points[i + 1].position.clone().sub(point.position)
: point.position.clone().sub(this.points[i - 1].position);
if (forward.lengthSq() < 1e-8) forward.copy(FALLBACK_TANGENT);
forward.normalize();
const perp = new Vector3().crossVectors(forward, this.viewDirection);
if (perp.lengthSq() < 1e-8) perp.crossVectors(forward, UP_AXIS);
perp.normalize();
const fade = Math.pow(Math.max(0, 1 - point.age / TRAIL_LIFETIME), FADE_POWER);
const halfWidth = (TRAIL_WIDTH / 2) * fade;
lefts.push(point.position.clone().addScaledVector(perp, halfWidth));
rights.push(point.position.clone().addScaledVector(perp, -halfWidth));
fades.push(fade);
}
const positions = this.geometry.attributes.position.array as Float32Array;
const colors = this.geometry.attributes.color.array as Float32Array;
let vertex = 0;
const write = (position: Vector3, fade: number) => {
const base = vertex * 3;
positions[base] = position.x;
positions[base + 1] = position.y;
positions[base + 2] = position.z;
colors[base] = TRAIL_COLOR.r * fade;
colors[base + 1] = TRAIL_COLOR.g * fade;
colors[base + 2] = TRAIL_COLOR.b * fade;
vertex++;
};
for (let i = 0; i < n - 1; i++) {
write(lefts[i], fades[i]);
write(rights[i], fades[i]);
write(lefts[i + 1], fades[i + 1]);
write(rights[i], fades[i]);
write(lefts[i + 1], fades[i + 1]);
write(rights[i + 1], fades[i + 1]);
}
this.geometry.attributes.position.needsUpdate = true;
this.geometry.attributes.color.needsUpdate = true;
this.geometry.setDrawRange(0, vertex);
this.geometry.computeBoundingSphere();
}
}
+98
View File
@@ -2,3 +2,101 @@
/* flex-basis: 60%; */
flex-grow: 1;
}
/* HudC — resource counter plate (dark background + diagonal wood-plank
icon baked into resourceCounterBgSrc, see images.ts/temp/Tool_15.webp).
184x71 source aspect ratio (~2.59:1) — aspect-ratio below derives height
from width automatically, so the two can never drift out of sync the way
two independently-tuned vh values could if only one got changed later.
background-image itself is set from HudC.ts (the asset path/base64 is
only known at runtime via the build's asset pipeline), everything else
lives here.
Split into two elements on purpose: .resource-hud only positions/sizes
and plays the one-shot mount animation; .resource-hud__plate (background
+ number together) is what the update-bump animates. Doing both on the
same element would fight each other — toggling a class that swaps
.resource-hud's own `animation-name` away from resource-hud-enter and
back again makes the browser replay resource-hud-enter every time
(a value change, even back to the same value, restarts a CSS animation),
so every resource pickup would re-trigger the fade/slide-in on top of
the bump. Two elements means two independent animation lifecycles. */
.resource-hud {
/* Single source of truth for the plate's scale — width below and the
count's font-size (see .resource-hud__count) both derive from this one
value, so resizing the whole HUD is a one-line change instead of
hunting down every place a size was independently tuned in vh. */
--hud-width: 9vh;
position: absolute;
top: 6%;
right: 10%;
width: var(--hud-width);
aspect-ratio: 184 / 71;
pointer-events: none;
z-index: 50;
/* Runs once as soon as the element mounts — no JS timing/opacity code
needed, the browser drives it (Day 8: "use built-in animations instead
of changing elements through code"). */
animation: resource-hud-enter 400ms ease-out;
}
@keyframes resource-hud-enter {
from {
opacity: 0;
transform: translateX(1.5rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.resource-hud__plate {
position: relative;
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-size: 100% 100%;
}
/* Toggled by HudC whenever the displayed number actually changes — a pure
CSS "pop" of the whole plate (background + number together), not a
JS-driven tween (Day 8 technical note) and not just the digit (see
chat: "не тільки на каунтер"). HudC removes and re-adds this class
(forcing a reflow in between) each time, which is what lets the
animation restart cleanly even if it retriggers before the previous run
finished — e.g. PayZoneC draining one resource every 0.3s while the
player stands in the zone. */
.resource-hud__plate.is-bumping {
animation: resource-hud-bump 220ms ease-out;
}
@keyframes resource-hud-bump {
0% {
transform: scale(1);
}
40% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
.resource-hud__count {
position: absolute;
left: 16%;
top: 46%;
width: 46%;
transform: translateY(-50%);
text-align: center;
color: #ffffff;
font-weight: bold;
/* Scales with --hud-width (set on .resource-hud, inherited down through
.resource-hud__plate) instead of a fixed vh value, so it tracks
whatever the plate is currently sized to — ratio matches the 2.1vh
that read well at the original 12vh plate width (2.1/12 ≈ 0.175). */
font-size: calc(var(--hud-width) * 0.175);
font-family: gameFont, sans-serif;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
+8
View File
@@ -0,0 +1,8 @@
import { ConvertToBase64WhenRelease } from "@hitplay/ads_common";
// Designer-authored resource-counter plate (dark rounded plate with a
// diagonal wood-plank icon already baked into the art, see temp/Tool_15.webp
// and the reference screenshots alongside it) — replaces HudC's own
// CSS-drawn pill background; the plank icon is part of this image, not a
// separate <img>.
export const resourceCounterBgSrc = ConvertToBase64WhenRelease("./resource_counter_bg.webp");
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ import {
Template,
Template3d,
ThreeC_internal,
TweenC,
FilterScene,
InstallBanner,
} from "@hitplay/playable_template";
@@ -25,6 +26,7 @@ export const beforeResourcesLoadedCb = () => {
ThreeC.setupDirectionalLight();
Physics_internal.init(new Vec3(0, -9.81, 0));
TweenC.init();
// zone is omitted on purpose: JoystickC auto-creates a full-screen zone
// when none is given, so the joystick can appear wherever the player taps