new animation, refactor, fixes

This commit is contained in:
Vasyl Kazakov
2026-06-18 12:45:48 +03:00
parent 930ed00c95
commit 852e1ec963
22 changed files with 519 additions and 116 deletions
+3 -3
View File
@@ -88,7 +88,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
values: [
30,
150,
33
30
]
},
{
@@ -106,7 +106,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[
0,
15,
8
11
],
[
-10,
@@ -125,7 +125,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[
-360,
360,
7
0
],
[
-360,
+4 -3
View File
@@ -40,8 +40,9 @@ export class DepositZoneC {
}
private static update() {
// Межі рахуються один раз у init(): зона статична, а пульсація її масштабу
// (під час депозиту) не повинна впливати на детекцію входу.
this.getPlayerPosition(this.playerPosition);
refreshInteractiveZoneBounds(this.zone!, this.bounds);
this.updatePlayerInside(this.playerPosition);
}
@@ -60,12 +61,12 @@ export class DepositZoneC {
this.playerInside = inside;
if (inside) {
console.log(`[DepositZoneC] enter deposit zone: ${INTERACTIVE_ZONE_NAME}`);
// console.log(`[DepositZoneC] enter deposit zone: ${INTERACTIVE_ZONE_NAME}`);
this.onPlayerEnter.Invoke({});
return;
}
console.log(`[DepositZoneC] exit deposit zone: ${INTERACTIVE_ZONE_NAME}`);
// console.log(`[DepositZoneC] exit deposit zone: ${INTERACTIVE_ZONE_NAME}`);
this.onPlayerExit.Invoke({});
}
}
+8 -2
View File
@@ -18,13 +18,13 @@ export class GatherC {
const prop = PropRegistry.get(payload.lootableObject);
if (!prop || prop.isBroken) return;
console.log(`[GatherC] enter gather zone: ${payload.lootableObject.name}`);
// console.log(`[GatherC] enter gather zone: ${payload.lootableObject.name}`);
this.activeProps.add(prop);
this.scheduleAutoAttackCheck();
});
PhysicsTriggerC.onTriggerExit.addDelegate((payload) => {
console.log(`[GatherC] exit gather zone: ${payload.lootableObject.name}`);
// console.log(`[GatherC] exit gather zone: ${payload.lootableObject.name}`);
const prop = PropRegistry.get(payload.lootableObject);
if (prop) this.activeProps.delete(prop);
@@ -35,6 +35,9 @@ export class GatherC {
});
JoystickC.onJoysticEnd.addDelegate(() => {
// Joystick release is a fresh signal (unlike the 1-frame-stale movement
// vector), so committing here lets RunState skip the Idle frame before Loot.
if (this.getAttackableProps().length > 0) Player.markCombatPending(true);
this.scheduleAutoAttackCheck();
});
@@ -86,6 +89,9 @@ export class GatherC {
private static handleRemainingTargets() {
const props = this.getAttackableProps();
if (props.length === 0) {
// No target left -> release the pending-entry hold so RunState can fall
// back to Idle normally instead of freezing the current animation.
Player.markCombatPending(false);
if (Player.isAutoAttackActive() || Player.isCombatBusy()) {
Player.stopAutoAttack();
}
+65
View File
@@ -1,3 +1,4 @@
import { UpdateController } from "@24tools/playable_template";
import { Color, Material, Mesh, Object3D, Vector3 } from "three";
import { ResourceScreenFly } from "../Resources/ResourceScreenFly";
import {
@@ -9,10 +10,19 @@ import {
const FILL_TARGET = 10;
const FILL_COLOR = new Color(0x2db83a);
/** Амплітуда «поглинаючого» імпульсу (частка від базового масштабу). 0.05 = 5%. */
const PULSE_AMPLITUDE = 0.05;
/** Тривалість одного імпульсу, сек: базовий розмір → пік → базовий. */
const PULSE_DURATION = 0.18;
/** Значення таймера, коли імпульс неактивний. */
const PULSE_IDLE = Infinity;
export class InteractiveZoneC {
private static zoneRoot: Object3D | null = null;
private static deposited = 0;
private static baseColors = new WeakMap<Material, Color>();
private static baseScale = new Vector3(1, 1, 1);
private static pulseTime = PULSE_IDLE;
static init(mapObject: Object3D) {
const zone = findInteractiveZoneFromMap(mapObject);
@@ -22,7 +32,41 @@ export class InteractiveZoneC {
}
this.zoneRoot = zone;
this.baseScale.copy(zone.scale);
this.setupMaterials(zone);
UpdateController.Instance.onUpdate.addDelegate((delta: number) => {
this.updatePulse(delta);
});
}
/** Один «поглинаючий» імпульс зони — викликати на приліт кожного ресурсу. */
static pulse() {
this.pulseTime = 0;
}
/** Повертає зону до базового розміру і гасить імпульс (teardown/зупинка). */
static resetPulse() {
this.pulseTime = PULSE_IDLE;
if (this.zoneRoot) this.zoneRoot.scale.copy(this.baseScale);
}
private static updatePulse(delta: number) {
if (!this.zoneRoot || this.pulseTime === PULSE_IDLE) return;
this.pulseTime += delta;
if (this.pulseTime >= PULSE_DURATION) {
this.resetPulse();
return;
}
const env = Math.sin(Math.PI * (this.pulseTime / PULSE_DURATION));
const k = 1 + PULSE_AMPLITUDE * env;
this.zoneRoot.scale.set(
this.baseScale.x * k,
this.baseScale.y * k,
this.baseScale.z * k,
);
}
static getWorldCenter(out = new Vector3()) {
@@ -39,6 +83,27 @@ export class InteractiveZoneC {
this.refreshFillVisual();
}
/** Звільняє клоновані матеріали зони (виклик на teardown playable). */
static dispose() {
if (!this.zoneRoot) return;
this.resetPulse();
this.zoneRoot.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh) return;
const material = mesh.material;
if (Array.isArray(material)) {
material.forEach((entry) => entry.dispose());
} else if (material) {
material.dispose();
}
});
this.baseColors = new WeakMap<Material, Color>();
}
static getFillRatio() {
return Math.min(this.deposited / FILL_TARGET, 1);
}
+5
View File
@@ -7,6 +7,7 @@ import { findDamageStateLayers } from "./PropDamageLayers";
import { resolvePropType } from "../Resources/PropDropTable";
import { InteractiveZoneC } from "./InteractiveZoneC";
import { ToolZoneC } from "./ToolZoneC";
const MAP_PHYSICS_LAYERS = ["Colliders", "Lootable"];
const GATHER_TRIGGER_PADDING = 0.5;
@@ -27,11 +28,15 @@ export class Map {
PhysicsTriggerC.init();
this.buildPhysics(mapObject);
InteractiveZoneC.init(mapObject);
ToolZoneC.init(mapObject);
}
private static hideReferenceHpUi(mapObject: Object3D) {
const ui = mapObject.getObjectByName("UI");
if (ui) ui.visible = false;
const woodUi = mapObject.getObjectByName("UI_Wood001");
if (woodUi) woodUi.visible = false;
}
private static buildPhysics(mapObject: Object3D) {
+2 -1
View File
@@ -4,8 +4,9 @@ import {
} from "@24tools/playable_template";
import { Box3, Object3D, Vector3 } from "three";
import { Body } from "cannon-es";
import { PLAYER_COLLIDER_RADIUS } from "../PhysicsC";
const PLAYER_RADIUS = 0.5;
const PLAYER_RADIUS = PLAYER_COLLIDER_RADIUS;
export type TriggerEventPayload = {
lootableObject: Object3D;
+14
View File
@@ -154,6 +154,20 @@ export class PropHpBar {
}
destroy() {
// Матеріали створюються в prepareMeshes/toVisibleMaterial для кожного бару,
// тож звільняємо їх при руйнуванні пропа. Геометрія спільна з prefab.
this.root.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh) return;
const material = mesh.material;
if (Array.isArray(material)) {
material.forEach((entry) => entry.dispose());
} else if (material) {
(material as Material).dispose();
}
});
this.root.removeFromParent();
}
+46
View File
@@ -0,0 +1,46 @@
import { Object3D, Vector3 } from "three";
import { ResourceScreenFly, ScreenPoint } from "../Resources/ResourceScreenFly";
const TOOL_ZONE_NAME = "UI_Tool_Zone";
export class ToolZoneC {
private static zone: Object3D | null = null;
/** Світова позиція зони статична (рухається лише камера), тож кешуємо її один раз. */
private static worldPos = new Vector3();
static init(mapObject: Object3D) {
this.zone = this.findZone(mapObject);
if (!this.zone) {
console.warn(`Tool zone not found: ${TOOL_ZONE_NAME}`);
return;
}
this.zone.updateWorldMatrix(true, false);
this.zone.getWorldPosition(this.worldPos);
}
/** Жива екранна точка зони: світова позиція стала, проєкція оновлюється під рух камери. */
static getScreenPoint(): ScreenPoint {
return ResourceScreenFly.worldToScreen(this.worldPos);
}
static dispose() {
this.zone = null;
}
private static findZone(mapObject: Object3D): Object3D | null {
let sceneRoot: Object3D = mapObject;
while (sceneRoot.parent) {
sceneRoot = sceneRoot.parent;
}
let found: Object3D | null = null;
sceneRoot.traverse((child) => {
if (!found && child.name === TOOL_ZONE_NAME) {
found = child;
}
});
return found;
}
}
+45 -37
View File
@@ -3,8 +3,8 @@ import {
Physics_internal,
UpdateController,
} from "@24tools/playable_template";
import { Box3, Object3D, Vector3 } from "three";
import { Body, Box, Quaternion, Sphere, Vec3 } from "cannon-es";
import { Box3, Mesh, Object3D, Quaternion as ThreeQuaternion, Vector3 } from "three";
import { Body, Box, Shape, Sphere, Vec3 } from "cannon-es";
export enum PhysicsLayer {
Player = 1,
@@ -13,6 +13,9 @@ export enum PhysicsLayer {
Enemy = 8,
}
/** Єдиний радіус сферичного колайдера гравця (sphere body + тригерна зона). */
export const PLAYER_COLLIDER_RADIUS = 0.5;
export class PhysicsBody {
private body: Body;
private pair: PhysicsObjPair | null = null;
@@ -23,58 +26,63 @@ export class PhysicsBody {
mass: number,
col_group: PhysicsLayer,
col_mask: PhysicsLayer,
player_sphere: number = 0.3
player_sphere: number = PLAYER_COLLIDER_RADIUS
) {
let isPlayer = col_group === PhysicsLayer.Player;
const isPlayer = col_group === PhysicsLayer.Player;
let oldQuaternion = threeObj.quaternion.clone();
threeObj.updateWorldMatrix(true, false);
let nullQuaternion = new Quaternion();
threeObj.quaternion.copy(nullQuaternion);
const bodyPosition = new Vector3();
const bodyQuaternion = new ThreeQuaternion();
let shape: Shape;
let bbox = new Box3().setFromObject(threeObj);
const mesh = threeObj as Mesh;
if (isPlayer) {
shape = new Sphere(player_sphere);
threeObj.getWorldPosition(bodyPosition);
// Sphere orientation is irrelevant -> keep identity quaternion.
} else if (mesh.isMesh && mesh.geometry) {
// Build a tight ORIENTED box from the mesh's LOCAL geometry bounds.
// The old world-space AABB inflated the box whenever a parent was rotated
// and shifted it up when the mesh pivot wasn't at the geometry center.
mesh.geometry.computeBoundingBox();
const bbox = mesh.geometry.boundingBox!;
let size = new Vector3();
const size = new Vector3();
bbox.getSize(size).multiply(threeObj.getWorldScale(new Vector3()));
size.set(Math.abs(size.x), Math.abs(size.y), Math.abs(size.z));
bbox.getCenter(bodyPosition);
mesh.localToWorld(bodyPosition);
threeObj.getWorldQuaternion(bodyQuaternion);
shape = new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2));
} else {
// Fallback for non-mesh objects: world-space AABB at its center.
const bbox = new Box3().setFromObject(threeObj);
const size = new Vector3();
bbox.getSize(size);
bbox.getCenter(bodyPosition);
// if you need custom size
// if (col_group === PhysicsLayer.wall) {
// size.x = size.z = 1;
// size.y = 1;
// }
threeObj.quaternion.copy(oldQuaternion);
shape = new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2));
}
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)),
shape,
collisionFilterGroup: col_group,
collisionFilterMask: col_mask,
});
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"
this.body.position.set(bodyPosition.x, bodyPosition.y, bodyPosition.z);
this.body.quaternion.set(
bodyQuaternion.x,
bodyQuaternion.y,
bodyQuaternion.z,
bodyQuaternion.w
);
// if you need sync three obj and physics body
// if (isEnemy) {
// let pair = new PhysicsObjPair(threeObj, this.body);
// PhysicsC_Instance.addPhysicsPair(pair);
// this.pair = pair;
// }
Physics_internal.physicsWorld &&
Physics_internal.physicsWorld.addBody(this.body);
@@ -67,6 +67,9 @@ export class Character {
this.tool2 = child;
this.tool2FullScale.copy(child.scale);
}
if (child.name === "Bullet") {
child.visible = false;
}
});
// Resting state: bat sits on the back (Tool_2), hand tool (Tool_1) hidden.
+25 -19
View File
@@ -3,8 +3,7 @@ import { Character } from "./Character/Character";
import { ResourcesType } from "./Enums/ResourcesType";
import { MeshType } from "./Enums/MeshType";
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
import { BaseAnimation } from "./Enums/BaseAnimation";
import { PhysicsBody, PhysicsLayer } from "../PhysicsC";
import { PhysicsBody, PhysicsLayer, PLAYER_COLLIDER_RADIUS } from "../PhysicsC";
import { Object3D, Vector3 } from "three";
import { ThreeC } from "../ThreeC";
import { PlayerInput } from "./Input/PlayerInput";
@@ -21,7 +20,6 @@ import { IdleState } from "./PlayerState/states/IdleState";
import { RunState } from "./PlayerState/states/RunState";
import { TurnToTargetState } from "./PlayerState/states/TurnToTargetState";
import { LootState } from "./PlayerState/states/LootState";
import { COMBAT_EXIT_FADE } from "./PlayerState/combatConstants";
export class Player {
private static inited = false;
@@ -110,6 +108,10 @@ export class Player {
return this.context.isAutoAttacking;
}
static markCombatPending(pending: boolean) {
this.context.pendingCombatEntry = pending;
}
static startAutoAttack(
onStrike: () => void,
_onComplete: () => void,
@@ -117,6 +119,7 @@ export class Player {
) {
if (this.context.isAutoAttacking) return;
this.context.pendingCombatEntry = false;
this.context.isAutoAttacking = true;
this.context.onStrike = onStrike;
this.context.combatTargetWorldPosition.copy(targetWorldPosition);
@@ -134,9 +137,10 @@ export class Player {
this.context.isAutoAttacking = false;
this.context.onStrike = null;
this.context.pendingCombatExit = false;
this.context.pendingCombatEntry = false;
if (needsExit) {
this.finishCombatExit();
this.context.finishCombatExit();
}
PropHpUIC.hideAll();
@@ -156,7 +160,7 @@ export class Player {
}
private static initPhysics() {
const playerColliderRadius = 0.2;
const playerColliderRadius = PLAYER_COLLIDER_RADIUS;
this.physics = new PhysicsBody(
this.container,
false,
@@ -165,21 +169,13 @@ export class Player {
PhysicsLayer.Wall | PhysicsLayer.Trigger,
playerColliderRadius,
);
}
private static finishCombatExit() {
this.context.pendingCombatExit = false;
this.context.character.setDefaultWeapons();
this.context.syncRotation();
if (this.isMoving()) {
this.stateMachine.setState(PlayerStateType.Run);
return;
}
this.context.character.crossFadeToAnimation(BaseAnimation.Idle, false, COMBAT_EXIT_FADE);
this.context.skipStateEnterAnimation = true;
this.stateMachine.setState(PlayerStateType.Idle);
// Top-down movement: the player never moves vertically. Locking the Y
// translation makes sphere-vs-box contacts resolve purely horizontally
// (slide along the crate) instead of popping the player up onto it -
// there's no gravity to bring it back down, so any vertical nudge would
// otherwise stick. Lets us use a normal collider radius safely.
this.physics.getPhysicsBody().linearFactor.set(1, 0, 1);
}
private static update(delta: number) {
@@ -196,7 +192,17 @@ export class Player {
private static syncVisual(delta: number) {
const lerpSpeed = 10;
// Below this gap we snap instead of lerping, otherwise the exponential
// ease keeps creeping for a few frames after the body stops -> visible
// slide when the player releases the joystick against a wall.
const snapDistance = 0.01;
const targetPos = Vector3CToT(this.physics.getPhysicsBody().position);
if (this.container.position.distanceToSquared(targetPos) <= snapDistance * snapDistance) {
this.container.position.copy(targetPos);
return;
}
this.container.position.lerp(targetPos, delta * lerpSpeed);
}
}
@@ -4,6 +4,9 @@ import { MoveC } from "../Movment/MoveC";
import { RotationC } from "../Movment/RotationC";
import { PhysicsBody } from "../../PhysicsC";
import { PlayerStateMachine } from "./PlayerStateMachine";
import { PlayerStateType } from "./PlayerStateType";
import { BaseAnimation } from "../Enums/BaseAnimation";
import { COMBAT_EXIT_FADE } from "./combatConstants";
export class PlayerContext {
stateMachine!: PlayerStateMachine;
@@ -16,6 +19,12 @@ export class PlayerContext {
isAutoAttacking = false;
pendingCombatExit = false;
/**
* Set when the player releases the joystick next to an attackable prop. Tells
* RunState to hold its current animation instead of dropping to Idle for a
* frame, so the upcoming Run -> Loot crossfade stays clean (no Idle blink).
*/
pendingCombatEntry = false;
skipStateEnterAnimation = false;
onStrike: (() => void) | null = null;
@@ -46,4 +55,26 @@ export class PlayerContext {
syncRotation() {
this.rotation.syncToCurrentFacing();
}
/**
* Спільний вихід з бою: скидає бойові прапорці, ховає биту і переходить
* у Run (якщо гравець рухається) або Idle. Використовується і `Player`,
* і `LootState`, щоб логіка не дублювалась.
*/
finishCombatExit() {
this.pendingCombatExit = false;
this.isAutoAttacking = false;
this.onStrike = null;
this.character.setDefaultWeapons();
this.syncRotation();
if (this.isMoving()) {
this.stateMachine.setState(PlayerStateType.Run);
return;
}
this.character.crossFadeToAnimation(BaseAnimation.Idle, false, COMBAT_EXIT_FADE);
this.skipStateEnterAnimation = true;
this.stateMachine.setState(PlayerStateType.Idle);
}
}
@@ -25,7 +25,7 @@ export class PlayerStateMachine {
setState(stateType: PlayerStateType) {
if (this.currentStateType === stateType) return;
console.log(`[PlayerState] ${this.currentStateType} -> ${stateType}`);
// console.log(`[PlayerState] ${this.currentStateType} -> ${stateType}`);
this.activeState.exit(this.context);
this.activeState = this.states.get(stateType)!;
@@ -1,7 +1,6 @@
import { BaseAnimation } from "../../Enums/BaseAnimation";
import {
ATTACK_STRIKE_MARKS,
COMBAT_EXIT_FADE,
LOOT_ENTER_FADE,
LOOT_EXIT_AFTER_FIRST_STRIKE,
LOOT_EXIT_AFTER_SECOND_STRIKE,
@@ -9,7 +8,6 @@ import {
} from "../combatConstants";
import { IPlayerState } from "../IPlayerState";
import { PlayerContext } from "../PlayerContext";
import { PlayerStateType } from "../PlayerStateType";
export class LootState implements IPlayerState {
private strikeMarkIndex = 0;
@@ -43,7 +41,7 @@ export class LootState implements IPlayerState {
if (normalizedTime < this.prevStrikeNormTime) {
if (context.pendingCombatExit) {
this.finishCombatExit(context);
context.finishCombatExit();
return;
}
this.strikeMarkIndex = 0;
@@ -59,7 +57,7 @@ export class LootState implements IPlayerState {
}
if (context.pendingCombatExit && this.canExitLootNow(normalizedTime)) {
this.finishCombatExit(context);
context.finishCombatExit();
}
}
@@ -82,21 +80,4 @@ export class LootState implements IPlayerState {
return BaseAnimation.Idle;
}
private finishCombatExit(context: PlayerContext) {
context.pendingCombatExit = false;
context.isAutoAttacking = false;
context.onStrike = null;
context.character.setDefaultWeapons();
context.syncRotation();
if (context.isMoving()) {
context.stateMachine.setState(PlayerStateType.Run);
return;
}
context.character.crossFadeToAnimation(BaseAnimation.Idle, false, COMBAT_EXIT_FADE);
context.skipStateEnterAnimation = true;
context.stateMachine.setState(PlayerStateType.Idle);
}
}
@@ -1,5 +1,6 @@
import { Vector3 } from "three";
import { BaseAnimation } from "../../Enums/BaseAnimation";
import { Vector3TToC } from "../../Helper";
import { Vector3CToT, Vector3TToC } from "../../Helper";
import { FollowCameraC } from "../../Movment/CameraMovment/FollowCamera";
import { RUN_ENTER_FADE } from "../combatConstants";
import { IPlayerState } from "../IPlayerState";
@@ -11,11 +12,32 @@ const MIN_RUN_TIME_SCALE = 0.3;
/** How fast the run anim speed catches up to the joystick tilt. */
const RUN_TIME_SCALE_LERP = 10;
/**
* Stuck detection works on a time window of NET displacement instead of per-frame
* speed: cannon's penetration recovery jitters the body back and forth against a
* wall, so an instantaneous speed check randomly spikes above any threshold and
* misses the stuck case. Net travel over a window averages that jitter out.
*/
const STUCK_WINDOW = 0.15;
/** Min real travel over a window; below this (while pushing) = stuck against a wall. */
const STUCK_MIN_DISTANCE = 0.04;
/** Fade for the run<->idle swap while staying in the Run state. */
const STUCK_ANIM_FADE = 0.15;
export class RunState implements IPlayerState {
private timeScale = MIN_RUN_TIME_SCALE;
private readonly windowStartPos = new Vector3();
private readonly curPos = new Vector3();
private windowTimer = 0;
private blocked = false;
enter(context: PlayerContext): void {
this.timeScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
this.blocked = false;
this.windowTimer = 0;
this.windowStartPos.copy(Vector3CToT(context.physics.getPhysicsBody().position));
if (context.character.isPlayingAnimation(BaseAnimation.Run)) {
context.character.setCurrentTimeScale(this.timeScale);
return;
@@ -32,6 +54,17 @@ export class RunState implements IPlayerState {
const direction = context.movement.Direction;
if (direction.lengthSq() === 0) {
// An auto-attack is about to start (stopped next to a prop): hold the
// current animation and let GatherC switch us straight to Loot. Dropping
// to Idle for a frame here is what produced the stop->attack blink.
if (context.pendingCombatEntry) {
context.zeroVelocity();
return;
}
// While blocked the Idle clip is already playing, so let IdleState skip its
// own playAnimation(Idle) - re-triggering it resets the same clip to frame 0
// and causes a 1-frame pop right before the auto-attack crossfade.
if (this.blocked) context.skipStateEnterAnimation = true;
context.stateMachine.setState(PlayerStateType.Idle);
return;
}
@@ -40,12 +73,45 @@ export class RunState implements IPlayerState {
context.rotation.setTargetDirection(direction);
const body = context.physics.getPhysicsBody();
// Keep driving velocity every frame even while blocked, so sliding along the
// wall (or the wall clearing) resumes movement on its own.
body.velocity.copy(Vector3TToC(direction));
body.wakeUp();
this.updateStuckState(context, deltaTime);
if (this.blocked) return;
const targetScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
const t = Math.min(deltaTime * RUN_TIME_SCALE_LERP, 1);
this.timeScale += (targetScale - this.timeScale) * t;
context.character.setCurrentTimeScale(this.timeScale);
}
/**
* Samples net body travel over {@link STUCK_WINDOW}. If the player keeps pushing
* but barely moves, we swap the run animation to Idle (staying in Run state) so
* it resumes instantly once the body slides free.
*/
private updateStuckState(context: PlayerContext, deltaTime: number): void {
this.windowTimer += deltaTime;
if (this.windowTimer < STUCK_WINDOW) return;
this.curPos.copy(Vector3CToT(context.physics.getPhysicsBody().position));
const moved = this.curPos.distanceTo(this.windowStartPos);
const isStuck = moved < STUCK_MIN_DISTANCE;
this.windowStartPos.copy(this.curPos);
this.windowTimer = 0;
if (isStuck === this.blocked) return;
this.blocked = isStuck;
if (isStuck) {
context.character.crossFadeToAnimation(BaseAnimation.Idle, false, STUCK_ANIM_FADE);
} else {
this.timeScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
context.character.crossFadeToAnimation(BaseAnimation.Run, false, STUCK_ANIM_FADE);
context.character.setCurrentTimeScale(this.timeScale);
}
}
}
+52 -9
View File
@@ -1,20 +1,25 @@
import { UpdateController } from "@24tools/playable_template";
import { Group } from "@tweenjs/tween.js";
import { Object3D } from "three";
import { GatherC } from "../Map/GatherC";
import { DepositZoneC } from "../Map/DepositZoneC";
import { InteractiveZoneC } from "../Map/InteractiveZoneC";
import { ToolZoneC } from "../Map/ToolZoneC";
import { Player } from "../Presets/Player";
import { ThreeC } from "../ThreeC";
import { TickScheduler, ScheduledCall } from "../Timers/TickScheduler";
import { ResourceInventoryC } from "./ResourceInventoryC";
import { ResourceScreenFly } from "./ResourceScreenFly";
import { ResourceType } from "./ResourceType";
import { ResourceUIC } from "./ResourceUIC";
/** Screen-space flight duration (icon → deposit zone). */
const DEPOSIT_FLY_MS = 520;
/** Pickup scale at the end of the flight (1 → this value). */
/** Screen-space flight duration (icon → deposit zone). Сповільнено для ефекту «поглинання». */
const DEPOSIT_FLY_MS = 700;
/** Pickup scale at the start of the flight near the UI icon (this value → 1 at the zone). */
const DEPOSIT_FLY_SCALE = 0.35;
/** Delay between starting each pickup in the chain (overlapping flights). */
const CHAIN_DEPOSIT_STAGGER_MS = 70;
/** Delay between starting each pickup in the chain (overlapping flights).
* Задає й ритм прильотів → частоту «поглинаючих» імпульсів зони. */
const CHAIN_DEPOSIT_STAGGER_MS = 130;
export class ResourceDepositC {
private static inited = false;
@@ -22,6 +27,8 @@ export class ResourceDepositC {
private static isDepositing = false;
private static activeFlights = 0;
private static pendingLaunches = 0;
private static scheduledCalls = new Set<ScheduledCall>();
private static activePickups = new Set<Object3D>();
static init() {
if (this.inited) return;
@@ -44,6 +51,36 @@ export class ResourceDepositC {
});
}
/** Скасовує відкладені виклики, твіни та прибирає активні pickups (teardown). */
static teardown() {
for (const call of this.scheduledCalls) {
TickScheduler.cancel(call);
}
this.scheduledCalls.clear();
this.tweenGroup.removeAll();
for (const pickup of this.activePickups) {
ThreeC.removeFromScene(pickup);
ResourceScreenFly.disposePickup(pickup);
}
this.activePickups.clear();
this.isDepositing = false;
this.activeFlights = 0;
this.pendingLaunches = 0;
InteractiveZoneC.resetPulse();
}
/** Відкладений виклик через ігровий тікер (із трекінгом для teardown). */
private static delay(delayMs: number, callback: () => void) {
const handle = TickScheduler.schedule(delayMs / 1000, () => {
this.scheduledCalls.delete(handle);
callback();
});
this.scheduledCalls.add(handle);
}
static tryStart() {
if (this.isDepositing || !this.canDeposit()) return;
this.startDepositChain();
@@ -102,7 +139,7 @@ export class ResourceDepositC {
}
if (this.pendingLaunches > 0) {
window.setTimeout(() => this.scheduleChainStep(), CHAIN_DEPOSIT_STAGGER_MS);
this.delay(CHAIN_DEPOSIT_STAGGER_MS, () => this.scheduleChainStep());
} else {
this.tryFinishChain();
}
@@ -110,7 +147,6 @@ export class ResourceDepositC {
private static launchOneDeposit(type: ResourceType) {
const iconCenter = ResourceUIC.getIconCenter(type);
const zoneScreen = InteractiveZoneC.getScreenCenter();
if (!iconCenter) return false;
const pickup = ResourceScreenFly.createPickup(type);
@@ -119,22 +155,26 @@ export class ResourceDepositC {
const startScreen = ResourceScreenFly.screenPointFromClient(
iconCenter.x,
iconCenter.y,
zoneScreen.z,
ToolZoneC.getScreenPoint().z,
);
this.activeFlights += 1;
this.activePickups.add(pickup);
ResourceScreenFly.flyAlongScreen(
pickup,
startScreen,
zoneScreen,
() => ToolZoneC.getScreenPoint(),
this.tweenGroup,
DEPOSIT_FLY_MS,
DEPOSIT_FLY_SCALE,
1,
() => {
this.activePickups.delete(pickup);
ResourceInventoryC.remove(type, 1);
ResourceUIC.refresh(type);
InteractiveZoneC.addDeposit(1);
InteractiveZoneC.pulse();
this.activeFlights -= 1;
this.tryFinishChain();
@@ -147,5 +187,8 @@ export class ResourceDepositC {
private static tryFinishChain() {
if (this.activeFlights > 0) return;
this.isDepositing = false;
// Підхопити ресурси, що долетіли в іконку вже під час ланцюжка:
// їхні onChanged ігнорувались, поки isDepositing === true.
this.tryStart();
}
}
+41 -5
View File
@@ -2,6 +2,7 @@ import { UpdateController } from "@24tools/playable_template";
import { Easing, Group, Tween } from "@tweenjs/tween.js";
import { Object3D, Vector3 } from "three";
import { ThreeC } from "../ThreeC";
import { TickScheduler, ScheduledCall } from "../Timers/TickScheduler";
import { ResourceInventoryC } from "./ResourceInventoryC";
import { ResourceScreenFly } from "./ResourceScreenFly";
import { ResourceType } from "./ResourceType";
@@ -37,6 +38,8 @@ export class ResourceFlyC {
private static tweenGroup = new Group();
private static flyQueue: FlyQueueItem[] = [];
private static isDrainingFlyQueue = false;
private static scheduledCalls = new Set<ScheduledCall>();
private static activePickups = new Set<Object3D>();
static init() {
if (this.inited) return;
@@ -49,6 +52,34 @@ export class ResourceFlyC {
});
}
/** Скасовує всі відкладені виклики, твіни та прибирає активні pickups (teardown). */
static teardown() {
for (const call of this.scheduledCalls) {
TickScheduler.cancel(call);
}
this.scheduledCalls.clear();
this.tweenGroup.removeAll();
for (const pickup of this.activePickups) {
ThreeC.removeFromScene(pickup);
ResourceScreenFly.disposePickup(pickup);
}
this.activePickups.clear();
this.flyQueue.length = 0;
this.isDrainingFlyQueue = false;
}
/** Відкладений виклик через ігровий тікер (із трекінгом для teardown). */
private static delay(delayMs: number, callback: () => void) {
const handle = TickScheduler.schedule(delayMs / 1000, () => {
this.scheduledCalls.delete(handle);
callback();
});
this.scheduledCalls.add(handle);
}
private static hideTemplateMeshes() {
Object.values(ResourceType).forEach((type) => {
const template = ThreeC.getObject(type);
@@ -60,9 +91,9 @@ export class ResourceFlyC {
}
static launch(origin: Vector3, type: ResourceType, index: number, count: number) {
window.setTimeout(() => {
this.delay(index * SPAWN_STAGGER_MS, () => {
this.startPickup(origin, type, index, count);
}, index * SPAWN_STAGGER_MS);
});
}
private static startPickup(origin: Vector3, type: ResourceType, index: number, count: number) {
@@ -80,6 +111,7 @@ export class ResourceFlyC {
pickup.position.copy(spawnPos);
ResourceScreenFly.orientToCamera(pickup);
ThreeC.addToScene(pickup);
this.activePickups.add(pickup);
const ejectTween = new Tween(pickup.position)
.to({ x: peakPos.x, y: peakPos.y, z: peakPos.z }, EJECT_MS)
@@ -119,9 +151,9 @@ export class ResourceFlyC {
private static playBounces(pickup: Object3D, landPos: Vector3, type: ResourceType) {
const runBounce = (bounceIndex: number) => {
if (bounceIndex >= BOUNCE_HEIGHTS.length) {
window.setTimeout(() => {
this.delay(REST_AFTER_BOUNCE_MS, () => {
this.enqueueFlyToUI(pickup, type);
}, REST_AFTER_BOUNCE_MS);
});
return;
}
@@ -173,7 +205,7 @@ export class ResourceFlyC {
this.flyToUI(item.pickup, item.type);
if (this.flyQueue.length > 0) {
window.setTimeout(() => this.drainFlyChainStep(), CHAIN_FLY_STAGGER_MS);
this.delay(CHAIN_FLY_STAGGER_MS, () => this.drainFlyChainStep());
} else {
this.isDrainingFlyQueue = false;
}
@@ -182,7 +214,9 @@ export class ResourceFlyC {
private static flyToUI(pickup: Object3D, type: ResourceType) {
const iconCenter = ResourceUIC.getIconCenter(type);
if (!iconCenter) {
this.activePickups.delete(pickup);
ThreeC.removeFromScene(pickup);
ResourceScreenFly.disposePickup(pickup);
return;
}
@@ -199,8 +233,10 @@ export class ResourceFlyC {
endScreen,
this.tweenGroup,
FLY_DURATION_MS,
1,
FLY_SCALE,
() => {
this.activePickups.delete(pickup);
ResourceInventoryC.add(type, 1);
ResourceUIC.refresh(type);
},
+25 -7
View File
@@ -58,13 +58,15 @@ export class ResourceScreenFly {
static flyAlongScreen(
pickup: Object3D,
startScreen: ScreenPoint,
endScreen: ScreenPoint,
endScreen: ScreenPoint | (() => ScreenPoint),
tweenGroup: Group,
durationMs: number,
startScale: number,
endScale: number,
onComplete: () => void,
) {
const startScale = pickup.scale.clone();
const resolveEnd = typeof endScreen === "function" ? endScreen : () => endScreen;
const baseScale = pickup.scale.clone();
pickup.position.copy(this.screenToWorld(startScreen));
this.orientToCamera(pickup);
ThreeC.addToScene(pickup);
@@ -75,18 +77,19 @@ export class ResourceScreenFly {
.to({ t: 1 }, durationMs)
.easing(Easing.Cubic.InOut)
.onUpdate(() => {
const screen = this.lerpScreenPoint(startScreen, endScreen, state.t);
const screen = this.lerpScreenPoint(startScreen, resolveEnd(), state.t);
pickup.position.copy(this.screenToWorld(screen));
const scale = this.lerp(1, endScale, state.t);
const scale = this.lerp(startScale, endScale, state.t);
pickup.scale.set(
startScale.x * scale,
startScale.y * scale,
startScale.z * scale,
baseScale.x * scale,
baseScale.y * scale,
baseScale.z * scale,
);
this.orientToCamera(pickup);
})
.onComplete(() => {
ThreeC.removeFromScene(pickup);
this.disposePickup(pickup);
onComplete();
});
@@ -94,6 +97,21 @@ export class ResourceScreenFly {
flyTween.start(performance.now());
}
/** Звільняє клоновані матеріали pickup-а. Геометрія спільна з GLTF — не чіпаємо. */
static disposePickup(pickup: Object3D) {
pickup.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh) return;
const material = mesh.material;
if (Array.isArray(material)) {
material.forEach((entry) => entry.dispose());
} else if (material) {
(material as Material).dispose();
}
});
}
static orientToCamera(object: Object3D) {
object.quaternion.copy(CameraC.camera.quaternion);
}
+63
View File
@@ -0,0 +1,63 @@
import { Delegate, UpdateController } from "@24tools/playable_template";
export type ScheduledCall = {
remaining: number;
callback: () => void;
cancelled: boolean;
};
/**
* Відкладені виклики, керовані ігровим тікером (delta з `UpdateController`),
* а не `window.setTimeout`. Перевага: затримки можна скасувати на teardown і
* вони не спрацюють після завершення playable.
*
* Прим.: коли дійде черга до уніфікації часу (план, п.2) — масштаб `TimeC.TimeScale`
* додається саме тут, в одному місці.
*/
export class TickScheduler {
private static updateDelegate: Delegate<number> | null = null;
private static calls = new Set<ScheduledCall>();
/** `delaySeconds` — затримка в секундах ігрового часу. */
static schedule(delaySeconds: number, callback: () => void): ScheduledCall {
this.ensureUpdate();
const call: ScheduledCall = {
remaining: delaySeconds,
callback,
cancelled: false,
};
this.calls.add(call);
return call;
}
static cancel(call: ScheduledCall | null | undefined) {
if (!call) return;
call.cancelled = true;
this.calls.delete(call);
}
private static ensureUpdate() {
if (this.updateDelegate) return;
this.updateDelegate = UpdateController.Instance.onUpdate.addDelegate((delta) =>
this.update(delta),
);
}
private static update(delta: number) {
if (this.calls.size === 0) return;
for (const call of [...this.calls]) {
if (call.cancelled) {
this.calls.delete(call);
continue;
}
call.remaining -= delta;
if (call.remaining <= 0) {
this.calls.delete(call);
call.callback();
}
}
}
}
+5 -5
View File
@@ -193,7 +193,7 @@
}
.hud-invasion-title {
font-size: 2.6vh;
font-size: 5vh;
}
.hud-progress {
@@ -211,12 +211,12 @@
}
.hud-avatar {
width: 10vh;
width: 15vh;
}
.resource-counter {
width: 12vh;
height: 4vh;
width: 15vh;
height: 6vh;
padding: 0 7.5vh 0 2.5vh;
}
@@ -227,7 +227,7 @@
}
.resource-count {
font-size: 2.2vh;
font-size:3.5vh;
}
.hud-bottom-left {
+11 -1
View File
@@ -1 +1,11 @@
export const gameRedirectedCb : (() => void) | undefined = undefined
import { ResourceFlyC } from "../controllers/Resources/ResourceFlyC";
import { ResourceDepositC } from "../controllers/Resources/ResourceDepositC";
import { InteractiveZoneC } from "../controllers/Map/InteractiveZoneC";
import { ToolZoneC } from "../controllers/Map/ToolZoneC";
export const gameRedirectedCb: (() => void) | undefined = () => {
ResourceFlyC.teardown();
ResourceDepositC.teardown();
InteractiveZoneC.dispose();
ToolZoneC.dispose();
};
+1 -1
View File
File diff suppressed because one or more lines are too long