From 852e1ec963dfc7a7db4f9119a18310926f6e9b01 Mon Sep 17 00:00:00 2001 From: Vasyl Kazakov Date: Thu, 18 Jun 2026 12:45:48 +0300 Subject: [PATCH] new animation, refactor, fixes --- src/configUIParams/globalSettings.ts | 6 +- src/controllers/Map/DepositZoneC.ts | 7 +- src/controllers/Map/GatherC.ts | 10 ++- src/controllers/Map/InteractiveZoneC.ts | 65 +++++++++++++++ src/controllers/Map/Map.ts | 5 ++ src/controllers/Map/PhysicsTriggerC.ts | 3 +- src/controllers/Map/PropHpBar.ts | 14 ++++ src/controllers/Map/ToolZoneC.ts | 46 +++++++++++ src/controllers/PhysicsC.ts | 82 ++++++++++--------- .../Presets/Character/Character.ts | 3 + src/controllers/Presets/Player.ts | 44 +++++----- .../Presets/PlayerState/PlayerContext.ts | 31 +++++++ .../Presets/PlayerState/PlayerStateMachine.ts | 2 +- .../Presets/PlayerState/states/LootState.ts | 23 +----- .../Presets/PlayerState/states/RunState.ts | 68 ++++++++++++++- src/controllers/Resources/ResourceDepositC.ts | 61 ++++++++++++-- src/controllers/Resources/ResourceFlyC.ts | 46 +++++++++-- .../Resources/ResourceScreenFly.ts | 32 ++++++-- src/controllers/Timers/TickScheduler.ts | 63 ++++++++++++++ src/css/ui.css | 10 +-- src/templateConfig/gameRedirectedCb.ts | 12 ++- stats.html | 2 +- 22 files changed, 519 insertions(+), 116 deletions(-) create mode 100644 src/controllers/Map/ToolZoneC.ts create mode 100644 src/controllers/Timers/TickScheduler.ts diff --git a/src/configUIParams/globalSettings.ts b/src/configUIParams/globalSettings.ts index 1c57233..23e058c 100644 --- a/src/configUIParams/globalSettings.ts +++ b/src/configUIParams/globalSettings.ts @@ -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, diff --git a/src/controllers/Map/DepositZoneC.ts b/src/controllers/Map/DepositZoneC.ts index 7722e13..a999da9 100644 --- a/src/controllers/Map/DepositZoneC.ts +++ b/src/controllers/Map/DepositZoneC.ts @@ -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({}); } } diff --git a/src/controllers/Map/GatherC.ts b/src/controllers/Map/GatherC.ts index 0c0c1bf..3afae6b 100644 --- a/src/controllers/Map/GatherC.ts +++ b/src/controllers/Map/GatherC.ts @@ -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(); } diff --git a/src/controllers/Map/InteractiveZoneC.ts b/src/controllers/Map/InteractiveZoneC.ts index 01e19ed..850314f 100644 --- a/src/controllers/Map/InteractiveZoneC.ts +++ b/src/controllers/Map/InteractiveZoneC.ts @@ -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(); + 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(); + } + static getFillRatio() { return Math.min(this.deposited / FILL_TARGET, 1); } diff --git a/src/controllers/Map/Map.ts b/src/controllers/Map/Map.ts index 2cf27a9..ff4ad6d 100644 --- a/src/controllers/Map/Map.ts +++ b/src/controllers/Map/Map.ts @@ -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) { diff --git a/src/controllers/Map/PhysicsTriggerC.ts b/src/controllers/Map/PhysicsTriggerC.ts index 452f01f..c7cdce7 100644 --- a/src/controllers/Map/PhysicsTriggerC.ts +++ b/src/controllers/Map/PhysicsTriggerC.ts @@ -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; diff --git a/src/controllers/Map/PropHpBar.ts b/src/controllers/Map/PropHpBar.ts index 19948e5..8bc3b34 100644 --- a/src/controllers/Map/PropHpBar.ts +++ b/src/controllers/Map/PropHpBar.ts @@ -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(); } diff --git a/src/controllers/Map/ToolZoneC.ts b/src/controllers/Map/ToolZoneC.ts new file mode 100644 index 0000000..6971500 --- /dev/null +++ b/src/controllers/Map/ToolZoneC.ts @@ -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; + } +} diff --git a/src/controllers/PhysicsC.ts b/src/controllers/PhysicsC.ts index 32d6ccc..63812e7 100644 --- a/src/controllers/PhysicsC.ts +++ b/src/controllers/PhysicsC.ts @@ -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(); - bbox.getSize(size); + 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)); - // if you need custom size - // if (col_group === PhysicsLayer.wall) { - // size.x = size.z = 1; - // size.y = 1; - // } + bbox.getCenter(bodyPosition); + mesh.localToWorld(bodyPosition); + threeObj.getWorldQuaternion(bodyQuaternion); - threeObj.quaternion.copy(oldQuaternion); + 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); + + 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); diff --git a/src/controllers/Presets/Character/Character.ts b/src/controllers/Presets/Character/Character.ts index ec37ed8..85ea62d 100644 --- a/src/controllers/Presets/Character/Character.ts +++ b/src/controllers/Presets/Character/Character.ts @@ -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. diff --git a/src/controllers/Presets/Player.ts b/src/controllers/Presets/Player.ts index e907bd9..314ea22 100644 --- a/src/controllers/Presets/Player.ts +++ b/src/controllers/Presets/Player.ts @@ -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); } } diff --git a/src/controllers/Presets/PlayerState/PlayerContext.ts b/src/controllers/Presets/PlayerState/PlayerContext.ts index 54b4325..c25926e 100644 --- a/src/controllers/Presets/PlayerState/PlayerContext.ts +++ b/src/controllers/Presets/PlayerState/PlayerContext.ts @@ -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); + } } diff --git a/src/controllers/Presets/PlayerState/PlayerStateMachine.ts b/src/controllers/Presets/PlayerState/PlayerStateMachine.ts index 907ad72..1ec0de7 100644 --- a/src/controllers/Presets/PlayerState/PlayerStateMachine.ts +++ b/src/controllers/Presets/PlayerState/PlayerStateMachine.ts @@ -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)!; diff --git a/src/controllers/Presets/PlayerState/states/LootState.ts b/src/controllers/Presets/PlayerState/states/LootState.ts index 6007c9a..0be9646 100644 --- a/src/controllers/Presets/PlayerState/states/LootState.ts +++ b/src/controllers/Presets/PlayerState/states/LootState.ts @@ -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); - } } diff --git a/src/controllers/Presets/PlayerState/states/RunState.ts b/src/controllers/Presets/PlayerState/states/RunState.ts index ec003a7..7d4ef39 100644 --- a/src/controllers/Presets/PlayerState/states/RunState.ts +++ b/src/controllers/Presets/PlayerState/states/RunState.ts @@ -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); + } + } } diff --git a/src/controllers/Resources/ResourceDepositC.ts b/src/controllers/Resources/ResourceDepositC.ts index 9c0845d..1a02188 100644 --- a/src/controllers/Resources/ResourceDepositC.ts +++ b/src/controllers/Resources/ResourceDepositC.ts @@ -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(); + private static activePickups = new Set(); 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(); } } diff --git a/src/controllers/Resources/ResourceFlyC.ts b/src/controllers/Resources/ResourceFlyC.ts index 61fed45..b8df592 100644 --- a/src/controllers/Resources/ResourceFlyC.ts +++ b/src/controllers/Resources/ResourceFlyC.ts @@ -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(); + private static activePickups = new Set(); 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); }, diff --git a/src/controllers/Resources/ResourceScreenFly.ts b/src/controllers/Resources/ResourceScreenFly.ts index bea22fb..57928dd 100644 --- a/src/controllers/Resources/ResourceScreenFly.ts +++ b/src/controllers/Resources/ResourceScreenFly.ts @@ -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); } diff --git a/src/controllers/Timers/TickScheduler.ts b/src/controllers/Timers/TickScheduler.ts new file mode 100644 index 0000000..b47deea --- /dev/null +++ b/src/controllers/Timers/TickScheduler.ts @@ -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 | null = null; + private static calls = new Set(); + + /** `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(); + } + } + } +} diff --git a/src/css/ui.css b/src/css/ui.css index 49ba7be..8fbcbd6 100644 --- a/src/css/ui.css +++ b/src/css/ui.css @@ -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 { diff --git a/src/templateConfig/gameRedirectedCb.ts b/src/templateConfig/gameRedirectedCb.ts index 1fc74e8..0432987 100644 --- a/src/templateConfig/gameRedirectedCb.ts +++ b/src/templateConfig/gameRedirectedCb.ts @@ -1 +1,11 @@ -export const gameRedirectedCb : (() => void) | undefined = undefined \ No newline at end of file +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(); +}; diff --git a/stats.html b/stats.html index a18ed10..e19d6f6 100644 --- a/stats.html +++ b/stats.html @@ -4929,7 +4929,7 @@ var drawChart = (function (exports) {