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: [ values: [
30, 30,
150, 150,
33 30
] ]
}, },
{ {
@@ -106,7 +106,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[ [
0, 0,
15, 15,
8 11
], ],
[ [
-10, -10,
@@ -125,7 +125,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[ [
-360, -360,
360, 360,
7 0
], ],
[ [
-360, -360,
+4 -3
View File
@@ -40,8 +40,9 @@ export class DepositZoneC {
} }
private static update() { private static update() {
// Межі рахуються один раз у init(): зона статична, а пульсація її масштабу
// (під час депозиту) не повинна впливати на детекцію входу.
this.getPlayerPosition(this.playerPosition); this.getPlayerPosition(this.playerPosition);
refreshInteractiveZoneBounds(this.zone!, this.bounds);
this.updatePlayerInside(this.playerPosition); this.updatePlayerInside(this.playerPosition);
} }
@@ -60,12 +61,12 @@ export class DepositZoneC {
this.playerInside = inside; this.playerInside = inside;
if (inside) { if (inside) {
console.log(`[DepositZoneC] enter deposit zone: ${INTERACTIVE_ZONE_NAME}`); // console.log(`[DepositZoneC] enter deposit zone: ${INTERACTIVE_ZONE_NAME}`);
this.onPlayerEnter.Invoke({}); this.onPlayerEnter.Invoke({});
return; return;
} }
console.log(`[DepositZoneC] exit deposit zone: ${INTERACTIVE_ZONE_NAME}`); // console.log(`[DepositZoneC] exit deposit zone: ${INTERACTIVE_ZONE_NAME}`);
this.onPlayerExit.Invoke({}); this.onPlayerExit.Invoke({});
} }
} }
+8 -2
View File
@@ -18,13 +18,13 @@ export class GatherC {
const prop = PropRegistry.get(payload.lootableObject); const prop = PropRegistry.get(payload.lootableObject);
if (!prop || prop.isBroken) return; 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.activeProps.add(prop);
this.scheduleAutoAttackCheck(); this.scheduleAutoAttackCheck();
}); });
PhysicsTriggerC.onTriggerExit.addDelegate((payload) => { 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); const prop = PropRegistry.get(payload.lootableObject);
if (prop) this.activeProps.delete(prop); if (prop) this.activeProps.delete(prop);
@@ -35,6 +35,9 @@ export class GatherC {
}); });
JoystickC.onJoysticEnd.addDelegate(() => { 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(); this.scheduleAutoAttackCheck();
}); });
@@ -86,6 +89,9 @@ export class GatherC {
private static handleRemainingTargets() { private static handleRemainingTargets() {
const props = this.getAttackableProps(); const props = this.getAttackableProps();
if (props.length === 0) { 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()) { if (Player.isAutoAttackActive() || Player.isCombatBusy()) {
Player.stopAutoAttack(); Player.stopAutoAttack();
} }
+65
View File
@@ -1,3 +1,4 @@
import { UpdateController } from "@24tools/playable_template";
import { Color, Material, Mesh, Object3D, Vector3 } from "three"; import { Color, Material, Mesh, Object3D, Vector3 } from "three";
import { ResourceScreenFly } from "../Resources/ResourceScreenFly"; import { ResourceScreenFly } from "../Resources/ResourceScreenFly";
import { import {
@@ -9,10 +10,19 @@ import {
const FILL_TARGET = 10; const FILL_TARGET = 10;
const FILL_COLOR = new Color(0x2db83a); 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 { export class InteractiveZoneC {
private static zoneRoot: Object3D | null = null; private static zoneRoot: Object3D | null = null;
private static deposited = 0; private static deposited = 0;
private static baseColors = new WeakMap<Material, Color>(); private static baseColors = new WeakMap<Material, Color>();
private static baseScale = new Vector3(1, 1, 1);
private static pulseTime = PULSE_IDLE;
static init(mapObject: Object3D) { static init(mapObject: Object3D) {
const zone = findInteractiveZoneFromMap(mapObject); const zone = findInteractiveZoneFromMap(mapObject);
@@ -22,7 +32,41 @@ export class InteractiveZoneC {
} }
this.zoneRoot = zone; this.zoneRoot = zone;
this.baseScale.copy(zone.scale);
this.setupMaterials(zone); 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()) { static getWorldCenter(out = new Vector3()) {
@@ -39,6 +83,27 @@ export class InteractiveZoneC {
this.refreshFillVisual(); 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() { static getFillRatio() {
return Math.min(this.deposited / FILL_TARGET, 1); 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 { resolvePropType } from "../Resources/PropDropTable";
import { InteractiveZoneC } from "./InteractiveZoneC"; import { InteractiveZoneC } from "./InteractiveZoneC";
import { ToolZoneC } from "./ToolZoneC";
const MAP_PHYSICS_LAYERS = ["Colliders", "Lootable"]; const MAP_PHYSICS_LAYERS = ["Colliders", "Lootable"];
const GATHER_TRIGGER_PADDING = 0.5; const GATHER_TRIGGER_PADDING = 0.5;
@@ -27,11 +28,15 @@ export class Map {
PhysicsTriggerC.init(); PhysicsTriggerC.init();
this.buildPhysics(mapObject); this.buildPhysics(mapObject);
InteractiveZoneC.init(mapObject); InteractiveZoneC.init(mapObject);
ToolZoneC.init(mapObject);
} }
private static hideReferenceHpUi(mapObject: Object3D) { private static hideReferenceHpUi(mapObject: Object3D) {
const ui = mapObject.getObjectByName("UI"); const ui = mapObject.getObjectByName("UI");
if (ui) ui.visible = false; if (ui) ui.visible = false;
const woodUi = mapObject.getObjectByName("UI_Wood001");
if (woodUi) woodUi.visible = false;
} }
private static buildPhysics(mapObject: Object3D) { private static buildPhysics(mapObject: Object3D) {
+2 -1
View File
@@ -4,8 +4,9 @@ import {
} from "@24tools/playable_template"; } from "@24tools/playable_template";
import { Box3, Object3D, Vector3 } from "three"; import { Box3, Object3D, Vector3 } from "three";
import { Body } from "cannon-es"; 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 = { export type TriggerEventPayload = {
lootableObject: Object3D; lootableObject: Object3D;
+14
View File
@@ -154,6 +154,20 @@ export class PropHpBar {
} }
destroy() { 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(); 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, Physics_internal,
UpdateController, UpdateController,
} from "@24tools/playable_template"; } from "@24tools/playable_template";
import { Box3, Object3D, Vector3 } from "three"; import { Box3, Mesh, Object3D, Quaternion as ThreeQuaternion, Vector3 } from "three";
import { Body, Box, Quaternion, Sphere, Vec3 } from "cannon-es"; import { Body, Box, Shape, Sphere, Vec3 } from "cannon-es";
export enum PhysicsLayer { export enum PhysicsLayer {
Player = 1, Player = 1,
@@ -13,6 +13,9 @@ export enum PhysicsLayer {
Enemy = 8, Enemy = 8,
} }
/** Єдиний радіус сферичного колайдера гравця (sphere body + тригерна зона). */
export const PLAYER_COLLIDER_RADIUS = 0.5;
export class PhysicsBody { export class PhysicsBody {
private body: Body; private body: Body;
private pair: PhysicsObjPair | null = null; private pair: PhysicsObjPair | null = null;
@@ -23,58 +26,63 @@ export class PhysicsBody {
mass: number, mass: number,
col_group: PhysicsLayer, col_group: PhysicsLayer,
col_mask: 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(); const bodyPosition = new Vector3();
threeObj.quaternion.copy(nullQuaternion); 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); 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 bbox.getCenter(bodyPosition);
// if (col_group === PhysicsLayer.wall) { mesh.localToWorld(bodyPosition);
// size.x = size.z = 1; threeObj.getWorldQuaternion(bodyQuaternion);
// size.y = 1;
// }
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({ this.body = new Body({
isTrigger: trigger, isTrigger: trigger,
mass: mass, mass: mass,
//shape: shape, shape,
shape: isPlayer
? new Sphere(player_sphere)
: new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2)),
collisionFilterGroup: col_group, collisionFilterGroup: col_group,
collisionFilterMask: col_mask, collisionFilterMask: col_mask,
}); });
let worldPos = threeObj.getWorldPosition(new Vector3()); this.body.position.set(bodyPosition.x, bodyPosition.y, bodyPosition.z);
this.body.quaternion.set(
this.body.position.set(worldPos.x, worldPos.y, worldPos.z); bodyQuaternion.x,
bodyQuaternion.y,
this.body.quaternion.setFromEuler( bodyQuaternion.z,
threeObj.rotation.x, bodyQuaternion.w
threeObj.rotation.y,
threeObj.rotation.z,
"XYZ"
); );
// 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 &&
Physics_internal.physicsWorld.addBody(this.body); Physics_internal.physicsWorld.addBody(this.body);
@@ -67,6 +67,9 @@ export class Character {
this.tool2 = child; this.tool2 = child;
this.tool2FullScale.copy(child.scale); 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. // 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 { ResourcesType } from "./Enums/ResourcesType";
import { MeshType } from "./Enums/MeshType"; import { MeshType } from "./Enums/MeshType";
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader"; import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
import { BaseAnimation } from "./Enums/BaseAnimation"; import { PhysicsBody, PhysicsLayer, PLAYER_COLLIDER_RADIUS } from "../PhysicsC";
import { PhysicsBody, PhysicsLayer } from "../PhysicsC";
import { Object3D, Vector3 } from "three"; import { Object3D, Vector3 } from "three";
import { ThreeC } from "../ThreeC"; import { ThreeC } from "../ThreeC";
import { PlayerInput } from "./Input/PlayerInput"; import { PlayerInput } from "./Input/PlayerInput";
@@ -21,7 +20,6 @@ import { IdleState } from "./PlayerState/states/IdleState";
import { RunState } from "./PlayerState/states/RunState"; import { RunState } from "./PlayerState/states/RunState";
import { TurnToTargetState } from "./PlayerState/states/TurnToTargetState"; import { TurnToTargetState } from "./PlayerState/states/TurnToTargetState";
import { LootState } from "./PlayerState/states/LootState"; import { LootState } from "./PlayerState/states/LootState";
import { COMBAT_EXIT_FADE } from "./PlayerState/combatConstants";
export class Player { export class Player {
private static inited = false; private static inited = false;
@@ -110,6 +108,10 @@ export class Player {
return this.context.isAutoAttacking; return this.context.isAutoAttacking;
} }
static markCombatPending(pending: boolean) {
this.context.pendingCombatEntry = pending;
}
static startAutoAttack( static startAutoAttack(
onStrike: () => void, onStrike: () => void,
_onComplete: () => void, _onComplete: () => void,
@@ -117,6 +119,7 @@ export class Player {
) { ) {
if (this.context.isAutoAttacking) return; if (this.context.isAutoAttacking) return;
this.context.pendingCombatEntry = false;
this.context.isAutoAttacking = true; this.context.isAutoAttacking = true;
this.context.onStrike = onStrike; this.context.onStrike = onStrike;
this.context.combatTargetWorldPosition.copy(targetWorldPosition); this.context.combatTargetWorldPosition.copy(targetWorldPosition);
@@ -134,9 +137,10 @@ export class Player {
this.context.isAutoAttacking = false; this.context.isAutoAttacking = false;
this.context.onStrike = null; this.context.onStrike = null;
this.context.pendingCombatExit = false; this.context.pendingCombatExit = false;
this.context.pendingCombatEntry = false;
if (needsExit) { if (needsExit) {
this.finishCombatExit(); this.context.finishCombatExit();
} }
PropHpUIC.hideAll(); PropHpUIC.hideAll();
@@ -156,7 +160,7 @@ export class Player {
} }
private static initPhysics() { private static initPhysics() {
const playerColliderRadius = 0.2; const playerColliderRadius = PLAYER_COLLIDER_RADIUS;
this.physics = new PhysicsBody( this.physics = new PhysicsBody(
this.container, this.container,
false, false,
@@ -165,21 +169,13 @@ export class Player {
PhysicsLayer.Wall | PhysicsLayer.Trigger, PhysicsLayer.Wall | PhysicsLayer.Trigger,
playerColliderRadius, playerColliderRadius,
); );
}
private static finishCombatExit() { // Top-down movement: the player never moves vertically. Locking the Y
this.context.pendingCombatExit = false; // translation makes sphere-vs-box contacts resolve purely horizontally
this.context.character.setDefaultWeapons(); // (slide along the crate) instead of popping the player up onto it -
this.context.syncRotation(); // there's no gravity to bring it back down, so any vertical nudge would
// otherwise stick. Lets us use a normal collider radius safely.
if (this.isMoving()) { this.physics.getPhysicsBody().linearFactor.set(1, 0, 1);
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);
} }
private static update(delta: number) { private static update(delta: number) {
@@ -196,7 +192,17 @@ export class Player {
private static syncVisual(delta: number) { private static syncVisual(delta: number) {
const lerpSpeed = 10; 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); 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); this.container.position.lerp(targetPos, delta * lerpSpeed);
} }
} }
@@ -4,6 +4,9 @@ import { MoveC } from "../Movment/MoveC";
import { RotationC } from "../Movment/RotationC"; import { RotationC } from "../Movment/RotationC";
import { PhysicsBody } from "../../PhysicsC"; import { PhysicsBody } from "../../PhysicsC";
import { PlayerStateMachine } from "./PlayerStateMachine"; import { PlayerStateMachine } from "./PlayerStateMachine";
import { PlayerStateType } from "./PlayerStateType";
import { BaseAnimation } from "../Enums/BaseAnimation";
import { COMBAT_EXIT_FADE } from "./combatConstants";
export class PlayerContext { export class PlayerContext {
stateMachine!: PlayerStateMachine; stateMachine!: PlayerStateMachine;
@@ -16,6 +19,12 @@ export class PlayerContext {
isAutoAttacking = false; isAutoAttacking = false;
pendingCombatExit = 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; skipStateEnterAnimation = false;
onStrike: (() => void) | null = null; onStrike: (() => void) | null = null;
@@ -46,4 +55,26 @@ export class PlayerContext {
syncRotation() { syncRotation() {
this.rotation.syncToCurrentFacing(); 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) { setState(stateType: PlayerStateType) {
if (this.currentStateType === stateType) return; if (this.currentStateType === stateType) return;
console.log(`[PlayerState] ${this.currentStateType} -> ${stateType}`); // console.log(`[PlayerState] ${this.currentStateType} -> ${stateType}`);
this.activeState.exit(this.context); this.activeState.exit(this.context);
this.activeState = this.states.get(stateType)!; this.activeState = this.states.get(stateType)!;
@@ -1,7 +1,6 @@
import { BaseAnimation } from "../../Enums/BaseAnimation"; import { BaseAnimation } from "../../Enums/BaseAnimation";
import { import {
ATTACK_STRIKE_MARKS, ATTACK_STRIKE_MARKS,
COMBAT_EXIT_FADE,
LOOT_ENTER_FADE, LOOT_ENTER_FADE,
LOOT_EXIT_AFTER_FIRST_STRIKE, LOOT_EXIT_AFTER_FIRST_STRIKE,
LOOT_EXIT_AFTER_SECOND_STRIKE, LOOT_EXIT_AFTER_SECOND_STRIKE,
@@ -9,7 +8,6 @@ import {
} from "../combatConstants"; } from "../combatConstants";
import { IPlayerState } from "../IPlayerState"; import { IPlayerState } from "../IPlayerState";
import { PlayerContext } from "../PlayerContext"; import { PlayerContext } from "../PlayerContext";
import { PlayerStateType } from "../PlayerStateType";
export class LootState implements IPlayerState { export class LootState implements IPlayerState {
private strikeMarkIndex = 0; private strikeMarkIndex = 0;
@@ -43,7 +41,7 @@ export class LootState implements IPlayerState {
if (normalizedTime < this.prevStrikeNormTime) { if (normalizedTime < this.prevStrikeNormTime) {
if (context.pendingCombatExit) { if (context.pendingCombatExit) {
this.finishCombatExit(context); context.finishCombatExit();
return; return;
} }
this.strikeMarkIndex = 0; this.strikeMarkIndex = 0;
@@ -59,7 +57,7 @@ export class LootState implements IPlayerState {
} }
if (context.pendingCombatExit && this.canExitLootNow(normalizedTime)) { if (context.pendingCombatExit && this.canExitLootNow(normalizedTime)) {
this.finishCombatExit(context); context.finishCombatExit();
} }
} }
@@ -82,21 +80,4 @@ export class LootState implements IPlayerState {
return BaseAnimation.Idle; 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 { BaseAnimation } from "../../Enums/BaseAnimation";
import { Vector3TToC } from "../../Helper"; import { Vector3CToT, Vector3TToC } from "../../Helper";
import { FollowCameraC } from "../../Movment/CameraMovment/FollowCamera"; import { FollowCameraC } from "../../Movment/CameraMovment/FollowCamera";
import { RUN_ENTER_FADE } from "../combatConstants"; import { RUN_ENTER_FADE } from "../combatConstants";
import { IPlayerState } from "../IPlayerState"; 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. */ /** How fast the run anim speed catches up to the joystick tilt. */
const RUN_TIME_SCALE_LERP = 10; 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 { export class RunState implements IPlayerState {
private timeScale = MIN_RUN_TIME_SCALE; 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 { enter(context: PlayerContext): void {
this.timeScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE); 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)) { if (context.character.isPlayingAnimation(BaseAnimation.Run)) {
context.character.setCurrentTimeScale(this.timeScale); context.character.setCurrentTimeScale(this.timeScale);
return; return;
@@ -32,6 +54,17 @@ export class RunState implements IPlayerState {
const direction = context.movement.Direction; const direction = context.movement.Direction;
if (direction.lengthSq() === 0) { 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); context.stateMachine.setState(PlayerStateType.Idle);
return; return;
} }
@@ -40,12 +73,45 @@ export class RunState implements IPlayerState {
context.rotation.setTargetDirection(direction); context.rotation.setTargetDirection(direction);
const body = context.physics.getPhysicsBody(); 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.velocity.copy(Vector3TToC(direction));
body.wakeUp(); body.wakeUp();
this.updateStuckState(context, deltaTime);
if (this.blocked) return;
const targetScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE); const targetScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
const t = Math.min(deltaTime * RUN_TIME_SCALE_LERP, 1); const t = Math.min(deltaTime * RUN_TIME_SCALE_LERP, 1);
this.timeScale += (targetScale - this.timeScale) * t; this.timeScale += (targetScale - this.timeScale) * t;
context.character.setCurrentTimeScale(this.timeScale); 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 { UpdateController } from "@24tools/playable_template";
import { Group } from "@tweenjs/tween.js"; import { Group } from "@tweenjs/tween.js";
import { Object3D } from "three";
import { GatherC } from "../Map/GatherC"; import { GatherC } from "../Map/GatherC";
import { DepositZoneC } from "../Map/DepositZoneC"; import { DepositZoneC } from "../Map/DepositZoneC";
import { InteractiveZoneC } from "../Map/InteractiveZoneC"; import { InteractiveZoneC } from "../Map/InteractiveZoneC";
import { ToolZoneC } from "../Map/ToolZoneC";
import { Player } from "../Presets/Player"; import { Player } from "../Presets/Player";
import { ThreeC } from "../ThreeC";
import { TickScheduler, ScheduledCall } from "../Timers/TickScheduler";
import { ResourceInventoryC } from "./ResourceInventoryC"; import { ResourceInventoryC } from "./ResourceInventoryC";
import { ResourceScreenFly } from "./ResourceScreenFly"; import { ResourceScreenFly } from "./ResourceScreenFly";
import { ResourceType } from "./ResourceType"; import { ResourceType } from "./ResourceType";
import { ResourceUIC } from "./ResourceUIC"; import { ResourceUIC } from "./ResourceUIC";
/** Screen-space flight duration (icon → deposit zone). */ /** Screen-space flight duration (icon → deposit zone). Сповільнено для ефекту «поглинання». */
const DEPOSIT_FLY_MS = 520; const DEPOSIT_FLY_MS = 700;
/** Pickup scale at the end of the flight (1 → this value). */ /** Pickup scale at the start of the flight near the UI icon (this value → 1 at the zone). */
const DEPOSIT_FLY_SCALE = 0.35; const DEPOSIT_FLY_SCALE = 0.35;
/** Delay between starting each pickup in the chain (overlapping flights). */ /** Delay between starting each pickup in the chain (overlapping flights).
const CHAIN_DEPOSIT_STAGGER_MS = 70; * Задає й ритм прильотів → частоту «поглинаючих» імпульсів зони. */
const CHAIN_DEPOSIT_STAGGER_MS = 130;
export class ResourceDepositC { export class ResourceDepositC {
private static inited = false; private static inited = false;
@@ -22,6 +27,8 @@ export class ResourceDepositC {
private static isDepositing = false; private static isDepositing = false;
private static activeFlights = 0; private static activeFlights = 0;
private static pendingLaunches = 0; private static pendingLaunches = 0;
private static scheduledCalls = new Set<ScheduledCall>();
private static activePickups = new Set<Object3D>();
static init() { static init() {
if (this.inited) return; 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() { static tryStart() {
if (this.isDepositing || !this.canDeposit()) return; if (this.isDepositing || !this.canDeposit()) return;
this.startDepositChain(); this.startDepositChain();
@@ -102,7 +139,7 @@ export class ResourceDepositC {
} }
if (this.pendingLaunches > 0) { if (this.pendingLaunches > 0) {
window.setTimeout(() => this.scheduleChainStep(), CHAIN_DEPOSIT_STAGGER_MS); this.delay(CHAIN_DEPOSIT_STAGGER_MS, () => this.scheduleChainStep());
} else { } else {
this.tryFinishChain(); this.tryFinishChain();
} }
@@ -110,7 +147,6 @@ export class ResourceDepositC {
private static launchOneDeposit(type: ResourceType) { private static launchOneDeposit(type: ResourceType) {
const iconCenter = ResourceUIC.getIconCenter(type); const iconCenter = ResourceUIC.getIconCenter(type);
const zoneScreen = InteractiveZoneC.getScreenCenter();
if (!iconCenter) return false; if (!iconCenter) return false;
const pickup = ResourceScreenFly.createPickup(type); const pickup = ResourceScreenFly.createPickup(type);
@@ -119,22 +155,26 @@ export class ResourceDepositC {
const startScreen = ResourceScreenFly.screenPointFromClient( const startScreen = ResourceScreenFly.screenPointFromClient(
iconCenter.x, iconCenter.x,
iconCenter.y, iconCenter.y,
zoneScreen.z, ToolZoneC.getScreenPoint().z,
); );
this.activeFlights += 1; this.activeFlights += 1;
this.activePickups.add(pickup);
ResourceScreenFly.flyAlongScreen( ResourceScreenFly.flyAlongScreen(
pickup, pickup,
startScreen, startScreen,
zoneScreen, () => ToolZoneC.getScreenPoint(),
this.tweenGroup, this.tweenGroup,
DEPOSIT_FLY_MS, DEPOSIT_FLY_MS,
DEPOSIT_FLY_SCALE, DEPOSIT_FLY_SCALE,
1,
() => { () => {
this.activePickups.delete(pickup);
ResourceInventoryC.remove(type, 1); ResourceInventoryC.remove(type, 1);
ResourceUIC.refresh(type); ResourceUIC.refresh(type);
InteractiveZoneC.addDeposit(1); InteractiveZoneC.addDeposit(1);
InteractiveZoneC.pulse();
this.activeFlights -= 1; this.activeFlights -= 1;
this.tryFinishChain(); this.tryFinishChain();
@@ -147,5 +187,8 @@ export class ResourceDepositC {
private static tryFinishChain() { private static tryFinishChain() {
if (this.activeFlights > 0) return; if (this.activeFlights > 0) return;
this.isDepositing = false; 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 { Easing, Group, Tween } from "@tweenjs/tween.js";
import { Object3D, Vector3 } from "three"; import { Object3D, Vector3 } from "three";
import { ThreeC } from "../ThreeC"; import { ThreeC } from "../ThreeC";
import { TickScheduler, ScheduledCall } from "../Timers/TickScheduler";
import { ResourceInventoryC } from "./ResourceInventoryC"; import { ResourceInventoryC } from "./ResourceInventoryC";
import { ResourceScreenFly } from "./ResourceScreenFly"; import { ResourceScreenFly } from "./ResourceScreenFly";
import { ResourceType } from "./ResourceType"; import { ResourceType } from "./ResourceType";
@@ -37,6 +38,8 @@ export class ResourceFlyC {
private static tweenGroup = new Group(); private static tweenGroup = new Group();
private static flyQueue: FlyQueueItem[] = []; private static flyQueue: FlyQueueItem[] = [];
private static isDrainingFlyQueue = false; private static isDrainingFlyQueue = false;
private static scheduledCalls = new Set<ScheduledCall>();
private static activePickups = new Set<Object3D>();
static init() { static init() {
if (this.inited) return; 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() { private static hideTemplateMeshes() {
Object.values(ResourceType).forEach((type) => { Object.values(ResourceType).forEach((type) => {
const template = ThreeC.getObject(type); const template = ThreeC.getObject(type);
@@ -60,9 +91,9 @@ export class ResourceFlyC {
} }
static launch(origin: Vector3, type: ResourceType, index: number, count: number) { static launch(origin: Vector3, type: ResourceType, index: number, count: number) {
window.setTimeout(() => { this.delay(index * SPAWN_STAGGER_MS, () => {
this.startPickup(origin, type, index, count); this.startPickup(origin, type, index, count);
}, index * SPAWN_STAGGER_MS); });
} }
private static startPickup(origin: Vector3, type: ResourceType, index: number, count: number) { private static startPickup(origin: Vector3, type: ResourceType, index: number, count: number) {
@@ -80,6 +111,7 @@ export class ResourceFlyC {
pickup.position.copy(spawnPos); pickup.position.copy(spawnPos);
ResourceScreenFly.orientToCamera(pickup); ResourceScreenFly.orientToCamera(pickup);
ThreeC.addToScene(pickup); ThreeC.addToScene(pickup);
this.activePickups.add(pickup);
const ejectTween = new Tween(pickup.position) const ejectTween = new Tween(pickup.position)
.to({ x: peakPos.x, y: peakPos.y, z: peakPos.z }, EJECT_MS) .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) { private static playBounces(pickup: Object3D, landPos: Vector3, type: ResourceType) {
const runBounce = (bounceIndex: number) => { const runBounce = (bounceIndex: number) => {
if (bounceIndex >= BOUNCE_HEIGHTS.length) { if (bounceIndex >= BOUNCE_HEIGHTS.length) {
window.setTimeout(() => { this.delay(REST_AFTER_BOUNCE_MS, () => {
this.enqueueFlyToUI(pickup, type); this.enqueueFlyToUI(pickup, type);
}, REST_AFTER_BOUNCE_MS); });
return; return;
} }
@@ -173,7 +205,7 @@ export class ResourceFlyC {
this.flyToUI(item.pickup, item.type); this.flyToUI(item.pickup, item.type);
if (this.flyQueue.length > 0) { if (this.flyQueue.length > 0) {
window.setTimeout(() => this.drainFlyChainStep(), CHAIN_FLY_STAGGER_MS); this.delay(CHAIN_FLY_STAGGER_MS, () => this.drainFlyChainStep());
} else { } else {
this.isDrainingFlyQueue = false; this.isDrainingFlyQueue = false;
} }
@@ -182,7 +214,9 @@ export class ResourceFlyC {
private static flyToUI(pickup: Object3D, type: ResourceType) { private static flyToUI(pickup: Object3D, type: ResourceType) {
const iconCenter = ResourceUIC.getIconCenter(type); const iconCenter = ResourceUIC.getIconCenter(type);
if (!iconCenter) { if (!iconCenter) {
this.activePickups.delete(pickup);
ThreeC.removeFromScene(pickup); ThreeC.removeFromScene(pickup);
ResourceScreenFly.disposePickup(pickup);
return; return;
} }
@@ -199,8 +233,10 @@ export class ResourceFlyC {
endScreen, endScreen,
this.tweenGroup, this.tweenGroup,
FLY_DURATION_MS, FLY_DURATION_MS,
1,
FLY_SCALE, FLY_SCALE,
() => { () => {
this.activePickups.delete(pickup);
ResourceInventoryC.add(type, 1); ResourceInventoryC.add(type, 1);
ResourceUIC.refresh(type); ResourceUIC.refresh(type);
}, },
+25 -7
View File
@@ -58,13 +58,15 @@ export class ResourceScreenFly {
static flyAlongScreen( static flyAlongScreen(
pickup: Object3D, pickup: Object3D,
startScreen: ScreenPoint, startScreen: ScreenPoint,
endScreen: ScreenPoint, endScreen: ScreenPoint | (() => ScreenPoint),
tweenGroup: Group, tweenGroup: Group,
durationMs: number, durationMs: number,
startScale: number,
endScale: number, endScale: number,
onComplete: () => void, 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)); pickup.position.copy(this.screenToWorld(startScreen));
this.orientToCamera(pickup); this.orientToCamera(pickup);
ThreeC.addToScene(pickup); ThreeC.addToScene(pickup);
@@ -75,18 +77,19 @@ export class ResourceScreenFly {
.to({ t: 1 }, durationMs) .to({ t: 1 }, durationMs)
.easing(Easing.Cubic.InOut) .easing(Easing.Cubic.InOut)
.onUpdate(() => { .onUpdate(() => {
const screen = this.lerpScreenPoint(startScreen, endScreen, state.t); const screen = this.lerpScreenPoint(startScreen, resolveEnd(), state.t);
pickup.position.copy(this.screenToWorld(screen)); 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( pickup.scale.set(
startScale.x * scale, baseScale.x * scale,
startScale.y * scale, baseScale.y * scale,
startScale.z * scale, baseScale.z * scale,
); );
this.orientToCamera(pickup); this.orientToCamera(pickup);
}) })
.onComplete(() => { .onComplete(() => {
ThreeC.removeFromScene(pickup); ThreeC.removeFromScene(pickup);
this.disposePickup(pickup);
onComplete(); onComplete();
}); });
@@ -94,6 +97,21 @@ export class ResourceScreenFly {
flyTween.start(performance.now()); 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) { static orientToCamera(object: Object3D) {
object.quaternion.copy(CameraC.camera.quaternion); 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 { .hud-invasion-title {
font-size: 2.6vh; font-size: 5vh;
} }
.hud-progress { .hud-progress {
@@ -211,12 +211,12 @@
} }
.hud-avatar { .hud-avatar {
width: 10vh; width: 15vh;
} }
.resource-counter { .resource-counter {
width: 12vh; width: 15vh;
height: 4vh; height: 6vh;
padding: 0 7.5vh 0 2.5vh; padding: 0 7.5vh 0 2.5vh;
} }
@@ -227,7 +227,7 @@
} }
.resource-count { .resource-count {
font-size: 2.2vh; font-size:3.5vh;
} }
.hud-bottom-left { .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