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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user