new animation, refactor, fixes
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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({});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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).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.getSize(size);
|
||||||
|
bbox.getCenter(bodyPosition);
|
||||||
|
|
||||||
// if you need custom size
|
shape = new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2));
|
||||||
// if (col_group === PhysicsLayer.wall) {
|
}
|
||||||
// size.x = size.z = 1;
|
|
||||||
// size.y = 1;
|
|
||||||
// }
|
|
||||||
|
|
||||||
threeObj.quaternion.copy(oldQuaternion);
|
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user