fixed bugs
This commit is contained in:
@@ -67,7 +67,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
[
|
||||
-360,
|
||||
360,
|
||||
0
|
||||
2
|
||||
],
|
||||
[
|
||||
-360,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { EasyEvent, JoystickC, UpdateController } from "@24tools/playable_templa
|
||||
import { Vector3 } from "three";
|
||||
import { PhysicsTriggerC } from "./PhysicsTriggerC";
|
||||
import { PropC, PropRegistry } from "./PropC";
|
||||
import { PropHpUIC } from "./PropHpUIC";
|
||||
import { Player } from "../Presets/Player";
|
||||
|
||||
export class GatherC {
|
||||
@@ -120,7 +119,6 @@ export class GatherC {
|
||||
if (Player.isMoving() || Player.isCombatBusy()) return;
|
||||
|
||||
const targetPosition = this.getTargetPosition(props);
|
||||
PropHpUIC.showFor(props);
|
||||
Player.startAutoAttack(
|
||||
() => this.onAttackStrike(),
|
||||
() => {},
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import { Box3, Object3D, Vector3 } from "three";
|
||||
import { Body } from "cannon-es";
|
||||
|
||||
const PLAYER_RADIUS = 0.3;
|
||||
const PLAYER_RADIUS = 0.5;
|
||||
|
||||
export type TriggerEventPayload = {
|
||||
lootableObject: Object3D;
|
||||
@@ -53,7 +53,7 @@ export class PhysicsTriggerC {
|
||||
lootableObject,
|
||||
triggerObject,
|
||||
center,
|
||||
radius: Math.max(size.x, size.y, size.z) / 2,
|
||||
radius: Math.max(size.x, size.z) / 2,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,9 +90,8 @@ export class PhysicsTriggerC {
|
||||
|
||||
for (const trigger of this.triggers) {
|
||||
const dx = playerBody.position.x - trigger.center.x;
|
||||
const dy = playerBody.position.y - trigger.center.y;
|
||||
const dz = playerBody.position.z - trigger.center.z;
|
||||
const distanceSq = dx * dx + dy * dy + dz * dz;
|
||||
const distanceSq = dx * dx + dz * dz;
|
||||
const isInside = distanceSq <= (trigger.radius + PLAYER_RADIUS) ** 2;
|
||||
const wasInside = this.activeTriggers.has(trigger);
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ type FillPart = {
|
||||
node: Object3D;
|
||||
baseScaleX: number;
|
||||
basePosX: number;
|
||||
/** Local mesh min.x (negative); keeps left edge fixed when scaling. */
|
||||
anchorMinX: number;
|
||||
/** Local mesh max.x; keeps right edge fixed when scaling. */
|
||||
anchorMaxX: number;
|
||||
};
|
||||
|
||||
export class PropHpBar {
|
||||
@@ -59,6 +59,7 @@ export class PropHpBar {
|
||||
|
||||
private displayedHealth: number;
|
||||
private visible = false;
|
||||
private hasTakenDamage = false;
|
||||
|
||||
static getPrefab(): Object3D | null {
|
||||
const fromScene = ThreeC.getObject(HP_BAR_MESH);
|
||||
@@ -108,6 +109,8 @@ export class PropHpBar {
|
||||
}
|
||||
|
||||
onDamage() {
|
||||
this.hasTakenDamage = true;
|
||||
|
||||
if (!this.visible) {
|
||||
this.setVisible(true);
|
||||
return;
|
||||
@@ -137,6 +140,9 @@ export class PropHpBar {
|
||||
}
|
||||
|
||||
setVisible(value: boolean) {
|
||||
// Once the prop has been hit, keep its bar visible until it breaks.
|
||||
if (!value && this.hasTakenDamage) return;
|
||||
|
||||
this.visible = value;
|
||||
this.root.visible = value;
|
||||
|
||||
@@ -164,11 +170,10 @@ export class PropHpBar {
|
||||
const clamped = Math.max(0, Math.min(1, ratio));
|
||||
part.node.scale.x = part.baseScaleX * clamped;
|
||||
part.node.position.x =
|
||||
part.basePosX + part.anchorMinX * part.baseScaleX * (1 - clamped);
|
||||
part.basePosX + part.anchorMaxX * part.baseScaleX * (1 - clamped);
|
||||
}
|
||||
|
||||
private createFillPart(node: Object3D): FillPart {
|
||||
const meshMinX = this.getMeshMinX(node);
|
||||
const isForeground = node.name.includes(NODE_FOREGROUND);
|
||||
|
||||
node.renderOrder = isForeground
|
||||
@@ -179,16 +184,16 @@ export class PropHpBar {
|
||||
node,
|
||||
baseScaleX: node.scale.x,
|
||||
basePosX: node.position.x,
|
||||
anchorMinX: meshMinX,
|
||||
anchorMaxX: this.getMeshMaxX(node),
|
||||
};
|
||||
}
|
||||
|
||||
private getMeshMinX(node: Object3D) {
|
||||
private getMeshMaxX(node: Object3D) {
|
||||
const mesh = node as Mesh;
|
||||
if (!mesh.isMesh || !mesh.geometry) return -0.935;
|
||||
if (!mesh.isMesh || !mesh.geometry) return 0;
|
||||
|
||||
mesh.geometry.computeBoundingBox();
|
||||
return mesh.geometry.boundingBox?.min.x ?? -0.935;
|
||||
return mesh.geometry.boundingBox?.max.x ?? 0;
|
||||
}
|
||||
|
||||
private getHealthRatio(health: number) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { EasyEvent } from "@24tools/playable_template";
|
||||
import { EasyEvent, UpdateController } from "@24tools/playable_template";
|
||||
import { AnimationAction, AnimationClip, AnimationMixer, LoopOnce, LoopRepeat, Object3D, Vector3 } from "three";
|
||||
import { clone } from "three/examples/jsm/utils/SkeletonUtils";
|
||||
import { ThreeC } from "../../ThreeC";
|
||||
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
|
||||
import { Easing, Group, Tween } from "@tweenjs/tween.js";
|
||||
|
||||
/** Duration of the bat pop-in / pop-out scale animation. */
|
||||
const WEAPON_SWAP_MS = 140;
|
||||
/** Near-zero scale used instead of exact 0 to keep normals/shadows valid. */
|
||||
const HIDDEN_SCALE = 0.001;
|
||||
|
||||
export class Character {
|
||||
tObj: Object3D;
|
||||
@@ -14,6 +20,14 @@ export class Character {
|
||||
onAnimLoop: EasyEvent<{}> = new EasyEvent<{}>();
|
||||
onAnimFinish: EasyEvent<{}> = new EasyEvent<{}>();
|
||||
|
||||
private weaponTweens = new Group();
|
||||
private tool1?: Object3D;
|
||||
private tool2?: Object3D;
|
||||
private tool1FullScale = new Vector3(1, 1, 1);
|
||||
private tool2FullScale = new Vector3(1, 1, 1);
|
||||
private tool1Tween: Tween<Vector3> | null = null;
|
||||
private tool2Tween: Tween<Vector3> | null = null;
|
||||
|
||||
constructor(prefab: GLTF, start_position = new Vector3()) {
|
||||
const tObj = clone(prefab.scene);
|
||||
|
||||
@@ -35,18 +49,35 @@ export class Character {
|
||||
|
||||
ThreeC.addAnimMixer(animMixer);
|
||||
|
||||
this.cacheWeapons();
|
||||
UpdateController.Instance.onUpdate.addDelegate(() => {
|
||||
this.weaponTweens.update(performance.now());
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
set AnimationSpeed(timeScale: number) {
|
||||
if (this.curClipAction) {
|
||||
this.curClipAction.timeScale = timeScale;
|
||||
private cacheWeapons() {
|
||||
this.tObj.traverse((child) => {
|
||||
if (child.name === "Tool_1") {
|
||||
this.tool1 = child;
|
||||
this.tool1FullScale.copy(child.scale);
|
||||
}
|
||||
if (child.name === "Tool_2") {
|
||||
this.tool2 = child;
|
||||
this.tool2FullScale.copy(child.scale);
|
||||
}
|
||||
});
|
||||
|
||||
set AnimationWeight(weight: number) {
|
||||
if (this.curClipAction) {
|
||||
this.curClipAction.weight = weight;
|
||||
// Resting state: bat sits on the back (Tool_2), hand tool (Tool_1) hidden.
|
||||
// Snapping here avoids a startup pop when setDefaultWeapons runs on init.
|
||||
if (this.tool1) {
|
||||
this.tool1.scale.set(HIDDEN_SCALE, HIDDEN_SCALE, HIDDEN_SCALE);
|
||||
this.tool1.visible = false;
|
||||
}
|
||||
if (this.tool2) {
|
||||
this.tool2.scale.copy(this.tool2FullScale);
|
||||
this.tool2.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,15 +90,72 @@ export class Character {
|
||||
}
|
||||
|
||||
setDefaultWeapons() {
|
||||
this.setObjectVisible("Tool_1", false);
|
||||
this.setObjectVisible("Tool_2", true);
|
||||
this.setObjectVisible("Character_Pistol", false);
|
||||
// Bat goes back to the spine: shrink the hand tool, grow the back tool.
|
||||
this.swapTool(false);
|
||||
}
|
||||
|
||||
setBatEquipped(equipped: boolean) {
|
||||
this.setObjectVisible("Tool_1", equipped);
|
||||
this.setObjectVisible("Tool_2", !equipped);
|
||||
this.setObjectVisible("Character_Pistol", false);
|
||||
this.swapTool(equipped);
|
||||
}
|
||||
|
||||
/** equipped=true -> bat appears in hands (Tool_1) and vanishes from back (Tool_2). */
|
||||
private swapTool(equipped: boolean) {
|
||||
this.tool1Tween = this.animateToolScale(
|
||||
this.tool1,
|
||||
this.tool1FullScale,
|
||||
equipped,
|
||||
this.tool1Tween,
|
||||
);
|
||||
this.tool2Tween = this.animateToolScale(
|
||||
this.tool2,
|
||||
this.tool2FullScale,
|
||||
!equipped,
|
||||
this.tool2Tween,
|
||||
);
|
||||
}
|
||||
|
||||
private animateToolScale(
|
||||
tool: Object3D | undefined,
|
||||
fullScale: Vector3,
|
||||
appear: boolean,
|
||||
activeTween: Tween<Vector3> | null,
|
||||
): Tween<Vector3> | null {
|
||||
if (!tool) return null;
|
||||
|
||||
// Interruption: kill any in-flight tween for this node before retargeting,
|
||||
// otherwise the scale can get stuck mid-animation on rapid combat toggles.
|
||||
if (activeTween) {
|
||||
this.weaponTweens.remove(activeTween);
|
||||
activeTween.stop();
|
||||
}
|
||||
|
||||
const target = appear
|
||||
? { x: fullScale.x, y: fullScale.y, z: fullScale.z }
|
||||
: { x: HIDDEN_SCALE, y: HIDDEN_SCALE, z: HIDDEN_SCALE };
|
||||
|
||||
if (appear) {
|
||||
tool.visible = true;
|
||||
}
|
||||
|
||||
const tween = new Tween(tool.scale, this.weaponTweens)
|
||||
.to(target, WEAPON_SWAP_MS)
|
||||
.easing(appear ? Easing.Quadratic.Out : Easing.Quadratic.In)
|
||||
.onComplete(() => {
|
||||
if (!appear) {
|
||||
tool.visible = false;
|
||||
}
|
||||
})
|
||||
.start(performance.now());
|
||||
|
||||
return tween;
|
||||
}
|
||||
|
||||
setCurrentTimeScale(scale: number) {
|
||||
if (this.curClipAction) {
|
||||
this.curClipAction.timeScale = scale;
|
||||
}
|
||||
}
|
||||
|
||||
isPlayingAnimation(anim_id: number) {
|
||||
|
||||
@@ -9,18 +9,26 @@ import { Object3D, Vector3 } from "three";
|
||||
import { ThreeC } from "../ThreeC";
|
||||
import { PlayerInput } from "./Input/PlayerInput";
|
||||
import { MoveC } from "./Movment/MoveC";
|
||||
import { Vector3CToT, Vector3TToC } from "./Helper";
|
||||
import { Vector3CToT } from "./Helper";
|
||||
import { RotationC } from "./Movment/RotationC";
|
||||
import { FollowCameraC } from "./Movment/CameraMovment/FollowCamera";
|
||||
import { PlayerCombat } from "./PlayerCombat";
|
||||
import { PhysicsTriggerC } from "../Map/PhysicsTriggerC";
|
||||
import { PropHpUIC } from "../Map/PropHpUIC";
|
||||
import { PlayerContext } from "./PlayerState/PlayerContext";
|
||||
import { PlayerStateMachine } from "./PlayerState/PlayerStateMachine";
|
||||
import { PlayerStateType } from "./PlayerState/PlayerStateType";
|
||||
import { IdleState } from "./PlayerState/states/IdleState";
|
||||
import { RunState } from "./PlayerState/states/RunState";
|
||||
import { TurnToTargetState } from "./PlayerState/states/TurnToTargetState";
|
||||
import { LootState } from "./PlayerState/states/LootState";
|
||||
import { COMBAT_EXIT_FADE } from "./PlayerState/combatConstants";
|
||||
|
||||
export class Player {
|
||||
private static inited = false;
|
||||
private static isRunning = false;
|
||||
|
||||
private static updateDelegate: Delegate<number>;
|
||||
private static context: PlayerContext;
|
||||
private static stateMachine: PlayerStateMachine;
|
||||
|
||||
private static container: Object3D = new Object3D();
|
||||
private static input: PlayerInput;
|
||||
@@ -40,7 +48,6 @@ export class Player {
|
||||
|
||||
this.container.position.copy(this.spawnPosition);
|
||||
this.character.setDefaultWeapons();
|
||||
this.character.playAnimation(BaseAnimation.Idle);
|
||||
this.container.add(this.character.tObj);
|
||||
ThreeC.addToScene(this.container);
|
||||
|
||||
@@ -53,24 +60,35 @@ export class Player {
|
||||
this.movement = new MoveC(this.input, moveSpeed);
|
||||
this.rotation = new RotationC(this.container, rotationSpeed);
|
||||
|
||||
PlayerCombat.init({
|
||||
this.context = new PlayerContext({
|
||||
character: this.character,
|
||||
getPhysicsBody: () => this.physics.getPhysicsBody(),
|
||||
isMoving: () => this.isMoving(),
|
||||
onStopRunning: () => this.stopRunning(),
|
||||
onResumeRunning: () => this.resumeRunning(),
|
||||
onSetTurnTarget: (worldPosition) => this.rotation.setTargetWorldPosition(worldPosition),
|
||||
onUpdateRotation: (delta) => this.rotation.update(delta),
|
||||
isRotationComplete: () => this.rotation.isComplete(),
|
||||
onSyncRotation: () => this.syncRotationToFacing(),
|
||||
movement: this.movement,
|
||||
rotation: this.rotation,
|
||||
physics: this.physics,
|
||||
container: this.container,
|
||||
});
|
||||
|
||||
this.stateMachine = new PlayerStateMachine(
|
||||
this.context,
|
||||
new Map<PlayerStateType, IdleState | RunState | TurnToTargetState | LootState>([
|
||||
[PlayerStateType.Idle, new IdleState()],
|
||||
[PlayerStateType.Run, new RunState()],
|
||||
[PlayerStateType.TurnToTarget, new TurnToTargetState()],
|
||||
[PlayerStateType.Loot, new LootState()],
|
||||
]),
|
||||
);
|
||||
this.context.stateMachine = this.stateMachine;
|
||||
|
||||
this.updateDelegate = new Delegate<number>((delta) => this.update(delta));
|
||||
UpdateController.Instance.onUpdate.addListener(this.updateDelegate);
|
||||
|
||||
FollowCameraC.init(this.container);
|
||||
}
|
||||
|
||||
static get currentState(): PlayerStateType {
|
||||
return this.stateMachine.currentState;
|
||||
}
|
||||
|
||||
static getWorldPosition() {
|
||||
return this.container.getWorldPosition(new Vector3());
|
||||
}
|
||||
@@ -79,113 +97,103 @@ export class Player {
|
||||
return this.movement.Direction.lengthSq() > 0;
|
||||
}
|
||||
|
||||
static isInCombat() {
|
||||
const state = this.currentState;
|
||||
return state === PlayerStateType.TurnToTarget || state === PlayerStateType.Loot;
|
||||
}
|
||||
|
||||
static isCombatBusy() {
|
||||
return PlayerCombat.isBusy;
|
||||
return this.isInCombat() || this.context.pendingCombatExit;
|
||||
}
|
||||
|
||||
static isAutoAttackActive() {
|
||||
return PlayerCombat.isAutoAttackActive;
|
||||
return this.context.isAutoAttacking;
|
||||
}
|
||||
|
||||
static startAutoAttack(
|
||||
onStrike: () => void,
|
||||
onComplete: () => void,
|
||||
_onComplete: () => void,
|
||||
targetWorldPosition: Vector3,
|
||||
) {
|
||||
PlayerCombat.startAutoAttack(onStrike, onComplete, targetWorldPosition);
|
||||
if (this.context.isAutoAttacking) return;
|
||||
|
||||
this.context.isAutoAttacking = true;
|
||||
this.context.onStrike = onStrike;
|
||||
this.context.combatTargetWorldPosition.copy(targetWorldPosition);
|
||||
this.rotation.setTargetWorldPosition(targetWorldPosition);
|
||||
this.character.setBatEquipped(true);
|
||||
this.stateMachine.setState(PlayerStateType.Loot);
|
||||
}
|
||||
|
||||
static stopAutoAttack() {
|
||||
PlayerCombat.stopAutoAttack();
|
||||
const needsExit = this.context.isAutoAttacking
|
||||
|| this.currentState === PlayerStateType.TurnToTarget
|
||||
|| this.currentState === PlayerStateType.Loot
|
||||
|| this.context.pendingCombatExit;
|
||||
|
||||
this.context.isAutoAttacking = false;
|
||||
this.context.onStrike = null;
|
||||
this.context.pendingCombatExit = false;
|
||||
|
||||
if (needsExit) {
|
||||
this.finishCombatExit();
|
||||
}
|
||||
|
||||
PropHpUIC.hideAll();
|
||||
}
|
||||
|
||||
static retargetAutoAttack(targetWorldPosition: Vector3) {
|
||||
PlayerCombat.retargetAutoAttack(targetWorldPosition);
|
||||
if (!this.context.isAutoAttacking) return;
|
||||
|
||||
this.context.combatTargetWorldPosition.copy(targetWorldPosition);
|
||||
this.context.zeroVelocity();
|
||||
this.rotation.setTargetWorldPosition(targetWorldPosition);
|
||||
}
|
||||
|
||||
static playAttack(onStrike: () => void) {
|
||||
PlayerCombat.playAttack(onStrike);
|
||||
this.context.onStrike = onStrike;
|
||||
this.stateMachine.setState(PlayerStateType.Loot);
|
||||
}
|
||||
|
||||
private static initPhysics() {
|
||||
const playerColliderRadius = 0.2;
|
||||
this.physics = new PhysicsBody(
|
||||
this.container,
|
||||
false,
|
||||
1,
|
||||
PhysicsLayer.Player,
|
||||
PhysicsLayer.Wall | PhysicsLayer.Trigger,
|
||||
playerColliderRadius,
|
||||
);
|
||||
}
|
||||
|
||||
private static startRunning() {
|
||||
if (PlayerCombat.isBusy) return;
|
||||
if (this.isRunning) return;
|
||||
if (this.character.isPlayingAnimation(BaseAnimation.Run)) {
|
||||
this.isRunning = true;
|
||||
private static finishCombatExit() {
|
||||
this.context.pendingCombatExit = false;
|
||||
this.context.character.setDefaultWeapons();
|
||||
this.context.syncRotation();
|
||||
|
||||
if (this.isMoving()) {
|
||||
this.stateMachine.setState(PlayerStateType.Run);
|
||||
return;
|
||||
}
|
||||
this.character.playAnimation(BaseAnimation.Run);
|
||||
this.isRunning = true;
|
||||
}
|
||||
|
||||
private static resumeRunning() {
|
||||
if (PlayerCombat.isBusy) return;
|
||||
this.isRunning = true;
|
||||
if (this.character.isPlayingAnimation(BaseAnimation.Run)) return;
|
||||
this.character.crossFadeToAnimation(BaseAnimation.Run, false, 0.15);
|
||||
}
|
||||
|
||||
private static stopRunning() {
|
||||
if (!this.isRunning) return;
|
||||
if (!PlayerCombat.isBusy) {
|
||||
this.character.playAnimation(BaseAnimation.Idle);
|
||||
}
|
||||
this.animationValue = 1;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
private static set animationValue(value: number) {
|
||||
this.character.AnimationSpeed = value;
|
||||
this.character.AnimationWeight = value * 12.5 + 87.5;
|
||||
this.context.character.crossFadeToAnimation(BaseAnimation.Idle, false, COMBAT_EXIT_FADE);
|
||||
this.context.skipStateEnterAnimation = true;
|
||||
this.stateMachine.setState(PlayerStateType.Idle);
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
this.movement.update(delta);
|
||||
|
||||
if (this.isMoving()) {
|
||||
PlayerCombat.cancelOnMoveInput();
|
||||
}
|
||||
|
||||
if (PlayerCombat.update(delta)) {
|
||||
this.syncVisual(delta);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = this.movement.Direction;
|
||||
const weight = this.movement.Weight;
|
||||
const isMoving = direction.lengthSq() > 0;
|
||||
|
||||
if (isMoving) {
|
||||
this.startRunning();
|
||||
this.animationValue = weight;
|
||||
FollowCameraC.inputDirection.copy(direction).normalize();
|
||||
this.rotation.setTargetDirection(direction);
|
||||
} else {
|
||||
this.stopRunning();
|
||||
if (this.isMoving() && (this.context.isAutoAttacking || this.currentState === PlayerStateType.TurnToTarget)) {
|
||||
this.stopAutoAttack();
|
||||
}
|
||||
|
||||
this.stateMachine.update(delta);
|
||||
this.rotation.update(delta);
|
||||
|
||||
this.physics.getPhysicsBody().velocity.copy(Vector3TToC(direction));
|
||||
this.physics.getPhysicsBody().wakeUp();
|
||||
this.syncVisual(delta);
|
||||
}
|
||||
|
||||
private static syncRotationToFacing() {
|
||||
this.rotation.syncToCurrentFacing();
|
||||
}
|
||||
|
||||
private static syncVisual(delta: number) {
|
||||
const lerpSpeed = 10;
|
||||
const targetPos = Vector3CToT(this.physics.getPhysicsBody().position);
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
import { Vector3 } from "three";
|
||||
import { Body } from "cannon-es";
|
||||
import { Character } from "./Character/Character";
|
||||
import { BaseAnimation } from "./Enums/BaseAnimation";
|
||||
|
||||
type PlayerCombatDeps = {
|
||||
character: Character;
|
||||
getPhysicsBody: () => Body;
|
||||
isMoving: () => boolean;
|
||||
onStopRunning: () => void;
|
||||
onResumeRunning: () => void;
|
||||
onSetTurnTarget: (worldPosition: Vector3) => void;
|
||||
onUpdateRotation: (delta: number) => void;
|
||||
isRotationComplete: () => boolean;
|
||||
onSyncRotation: () => void;
|
||||
};
|
||||
|
||||
export class PlayerCombat {
|
||||
private static deps: PlayerCombatDeps;
|
||||
|
||||
private static isAttacking = false;
|
||||
private static isAutoAttacking = false;
|
||||
private static isTurningToTarget = false;
|
||||
private static isBatEquipped = false;
|
||||
private static isLootLoopActive = false;
|
||||
private static pendingCombatExit = false;
|
||||
private static attackPhase: "none" | "attacking" = "none";
|
||||
|
||||
private static onStrike: (() => void) | null = null;
|
||||
private static onTurnComplete: (() => void) | null = null;
|
||||
private static strikeMarkIndex = 0;
|
||||
private static prevStrikeNormTime = 0;
|
||||
|
||||
private static readonly turnIdleFade = 0.1;
|
||||
private static readonly lootEnterFade = 0.25;
|
||||
private static readonly combatExitFade = 0.8;
|
||||
/** Normalized clip time (0–1) when the bat crosses the target: R→L, then L→R. */
|
||||
private static readonly attackStrikeMarks = [0.38, 0.72];
|
||||
/** After 1st hit (R→L) — safe exit if combat ends before 2nd hit (L→R). */
|
||||
private static readonly lootExitAfterFirstStrike = 0.5;
|
||||
/** After 2nd hit (L→R) — follow-through before idle. */
|
||||
private static readonly lootExitAfterSecondStrike = 0.78;
|
||||
/** End of clip when exiting before any strike (e.g. left the zone). */
|
||||
private static readonly lootExitEndOfCycle = 0.9;
|
||||
|
||||
static init(deps: PlayerCombatDeps) {
|
||||
this.deps = deps;
|
||||
}
|
||||
|
||||
static get isBusy() {
|
||||
return this.isAttacking || this.isTurningToTarget || this.pendingCombatExit;
|
||||
}
|
||||
|
||||
static get isAutoAttackActive() {
|
||||
return this.isAutoAttacking;
|
||||
}
|
||||
|
||||
static startAutoAttack(
|
||||
onStrike: () => void,
|
||||
_onComplete: () => void,
|
||||
targetWorldPosition: Vector3,
|
||||
) {
|
||||
if (this.isAutoAttacking) return;
|
||||
|
||||
this.isAutoAttacking = true;
|
||||
this.deps.onStopRunning();
|
||||
this.beginTurnToTarget(targetWorldPosition, () => this.playAttack(onStrike));
|
||||
}
|
||||
|
||||
static retargetAutoAttack(targetWorldPosition: Vector3) {
|
||||
if (!this.isAutoAttacking) return;
|
||||
|
||||
this.deps.onSetTurnTarget(targetWorldPosition);
|
||||
this.deps.getPhysicsBody().velocity.set(0, 0, 0);
|
||||
|
||||
if (this.isTurningToTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onTurnComplete = () => {
|
||||
this.deps.onSyncRotation();
|
||||
};
|
||||
this.isTurningToTarget = true;
|
||||
}
|
||||
|
||||
static stopAutoAttack() {
|
||||
const wasLooting = this.isLootLoopActive && this.attackPhase === "attacking";
|
||||
const needsExit = this.isAutoAttacking
|
||||
|| this.isTurningToTarget
|
||||
|| this.isAttacking
|
||||
|| this.pendingCombatExit
|
||||
|| this.isBatEquipped;
|
||||
|
||||
this.isAutoAttacking = false;
|
||||
this.onStrike = null;
|
||||
this.cancelTurnToTarget();
|
||||
|
||||
if (wasLooting) {
|
||||
this.pendingCombatExit = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsExit) {
|
||||
this.finishCombatExit();
|
||||
}
|
||||
}
|
||||
|
||||
static playAttack(onStrike: () => void) {
|
||||
this.onStrike = onStrike;
|
||||
this.isAttacking = true;
|
||||
|
||||
this.deps.getPhysicsBody().velocity.set(0, 0, 0);
|
||||
|
||||
if (!this.isBatEquipped) {
|
||||
this.equipBat();
|
||||
}
|
||||
|
||||
this.playBatAttack();
|
||||
}
|
||||
|
||||
static update(delta: number): boolean {
|
||||
if (this.isTurningToTarget) {
|
||||
this.deps.getPhysicsBody().velocity.set(0, 0, 0);
|
||||
this.updateTurnToTarget(delta);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isAttacking) {
|
||||
this.deps.getPhysicsBody().velocity.set(0, 0, 0);
|
||||
this.updateAttackStrikes();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static cancelOnMoveInput() {
|
||||
if (this.isAutoAttacking || this.isTurningToTarget) {
|
||||
this.stopAutoAttack();
|
||||
}
|
||||
}
|
||||
|
||||
private static beginTurnToTarget(targetWorldPosition: Vector3, onComplete: () => void) {
|
||||
this.deps.onSetTurnTarget(targetWorldPosition);
|
||||
|
||||
this.onTurnComplete = onComplete;
|
||||
this.isTurningToTarget = true;
|
||||
this.deps.getPhysicsBody().velocity.set(0, 0, 0);
|
||||
this.deps.character.playAnimation(BaseAnimation.Idle, false, this.turnIdleFade);
|
||||
}
|
||||
|
||||
private static cancelTurnToTarget() {
|
||||
if (!this.isTurningToTarget) return;
|
||||
|
||||
this.isTurningToTarget = false;
|
||||
this.onTurnComplete = null;
|
||||
this.deps.onSyncRotation();
|
||||
}
|
||||
|
||||
private static updateTurnToTarget(delta: number) {
|
||||
this.deps.onUpdateRotation(delta);
|
||||
|
||||
if (!this.deps.isRotationComplete()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isTurningToTarget = false;
|
||||
this.deps.onSyncRotation();
|
||||
|
||||
const callback = this.onTurnComplete;
|
||||
this.onTurnComplete = null;
|
||||
callback?.();
|
||||
}
|
||||
|
||||
private static playBatAttack() {
|
||||
if (this.isLootLoopActive && this.isAutoAttacking) return;
|
||||
|
||||
this.startLootLoop();
|
||||
}
|
||||
|
||||
private static startLootLoop() {
|
||||
const animId = this.getBatAttackAnimation();
|
||||
if (animId !== BaseAnimation.Loot) {
|
||||
this.attackPhase = "attacking";
|
||||
this.deps.character.playAnimation(animId, false, this.lootEnterFade);
|
||||
return;
|
||||
}
|
||||
|
||||
this.attackPhase = "attacking";
|
||||
this.strikeMarkIndex = 0;
|
||||
this.prevStrikeNormTime = 0;
|
||||
this.isLootLoopActive = true;
|
||||
this.deps.character.playAnimation(BaseAnimation.Loot, false, this.lootEnterFade);
|
||||
}
|
||||
|
||||
private static updateAttackStrikes() {
|
||||
if (this.attackPhase !== "attacking") return;
|
||||
|
||||
const action = this.deps.character.curClipAction;
|
||||
if (!action) return;
|
||||
|
||||
const clipDuration = action.getClip().duration;
|
||||
if (clipDuration <= 0) return;
|
||||
|
||||
const normalizedTime = action.time / clipDuration;
|
||||
|
||||
if (normalizedTime < this.prevStrikeNormTime) {
|
||||
if (this.pendingCombatExit) {
|
||||
this.finishCombatExit();
|
||||
return;
|
||||
}
|
||||
this.strikeMarkIndex = 0;
|
||||
}
|
||||
this.prevStrikeNormTime = normalizedTime;
|
||||
|
||||
while (
|
||||
this.strikeMarkIndex < this.attackStrikeMarks.length &&
|
||||
normalizedTime >= this.attackStrikeMarks[this.strikeMarkIndex]
|
||||
) {
|
||||
this.onStrike?.();
|
||||
this.strikeMarkIndex++;
|
||||
}
|
||||
|
||||
if (this.pendingCombatExit && this.canExitLootNow(normalizedTime)) {
|
||||
this.finishCombatExit();
|
||||
}
|
||||
}
|
||||
|
||||
private static canExitLootNow(normalizedTime: number) {
|
||||
if (this.strikeMarkIndex >= 2) {
|
||||
return normalizedTime >= this.lootExitAfterSecondStrike;
|
||||
}
|
||||
|
||||
if (this.strikeMarkIndex >= 1) {
|
||||
return normalizedTime >= this.lootExitAfterFirstStrike;
|
||||
}
|
||||
|
||||
return normalizedTime >= this.lootExitEndOfCycle;
|
||||
}
|
||||
|
||||
private static getBatAttackAnimation() {
|
||||
if (this.deps.character.animationList.length > BaseAnimation.Loot) {
|
||||
return BaseAnimation.Loot;
|
||||
}
|
||||
|
||||
return BaseAnimation.Idle;
|
||||
}
|
||||
|
||||
private static equipBat() {
|
||||
this.isBatEquipped = true;
|
||||
this.deps.character.setBatEquipped(true);
|
||||
}
|
||||
|
||||
private static finishCombatExit() {
|
||||
this.pendingCombatExit = false;
|
||||
this.attackPhase = "none";
|
||||
this.isAttacking = false;
|
||||
this.strikeMarkIndex = 0;
|
||||
this.prevStrikeNormTime = 0;
|
||||
this.isLootLoopActive = false;
|
||||
this.isBatEquipped = false;
|
||||
this.deps.character.setDefaultWeapons();
|
||||
this.deps.onSyncRotation();
|
||||
|
||||
if (this.deps.isMoving()) {
|
||||
this.deps.onResumeRunning();
|
||||
return;
|
||||
}
|
||||
|
||||
this.deps.character.crossFadeToAnimation(BaseAnimation.Idle, false, this.combatExitFade);
|
||||
this.deps.onStopRunning();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PlayerContext } from "./PlayerContext";
|
||||
|
||||
export interface IPlayerState {
|
||||
enter(context: PlayerContext): void;
|
||||
exit(context: PlayerContext): void;
|
||||
update(context: PlayerContext, deltaTime: number): void;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { Character } from "../Character/Character";
|
||||
import { MoveC } from "../Movment/MoveC";
|
||||
import { RotationC } from "../Movment/RotationC";
|
||||
import { PhysicsBody } from "../../PhysicsC";
|
||||
import { PlayerStateMachine } from "./PlayerStateMachine";
|
||||
|
||||
export class PlayerContext {
|
||||
stateMachine!: PlayerStateMachine;
|
||||
|
||||
readonly character: Character;
|
||||
readonly movement: MoveC;
|
||||
readonly rotation: RotationC;
|
||||
readonly physics: PhysicsBody;
|
||||
readonly container: Object3D;
|
||||
|
||||
isAutoAttacking = false;
|
||||
pendingCombatExit = false;
|
||||
skipStateEnterAnimation = false;
|
||||
|
||||
onStrike: (() => void) | null = null;
|
||||
readonly combatTargetWorldPosition = new Vector3();
|
||||
|
||||
constructor(opts: {
|
||||
character: Character;
|
||||
movement: MoveC;
|
||||
rotation: RotationC;
|
||||
physics: PhysicsBody;
|
||||
container: Object3D;
|
||||
}) {
|
||||
this.character = opts.character;
|
||||
this.movement = opts.movement;
|
||||
this.rotation = opts.rotation;
|
||||
this.physics = opts.physics;
|
||||
this.container = opts.container;
|
||||
}
|
||||
|
||||
isMoving() {
|
||||
return this.movement.Direction.lengthSq() > 0;
|
||||
}
|
||||
|
||||
zeroVelocity() {
|
||||
this.physics.getPhysicsBody().velocity.set(0, 0, 0);
|
||||
}
|
||||
|
||||
syncRotation() {
|
||||
this.rotation.syncToCurrentFacing();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PlayerContext } from "./PlayerContext";
|
||||
import { IPlayerState } from "./IPlayerState";
|
||||
import { PlayerStateType } from "./PlayerStateType";
|
||||
|
||||
export class PlayerStateMachine {
|
||||
private activeState: IPlayerState;
|
||||
private currentStateType: PlayerStateType;
|
||||
private readonly states: Map<PlayerStateType, IPlayerState>;
|
||||
|
||||
get currentState(): PlayerStateType {
|
||||
return this.currentStateType;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly context: PlayerContext,
|
||||
states: Map<PlayerStateType, IPlayerState>,
|
||||
initialState: PlayerStateType = PlayerStateType.Idle,
|
||||
) {
|
||||
this.states = states;
|
||||
this.activeState = states.get(initialState)!;
|
||||
this.currentStateType = initialState;
|
||||
this.activeState.enter(this.context);
|
||||
}
|
||||
|
||||
setState(stateType: PlayerStateType) {
|
||||
if (this.currentStateType === stateType) return;
|
||||
|
||||
console.log(`[PlayerState] ${this.currentStateType} -> ${stateType}`);
|
||||
|
||||
this.activeState.exit(this.context);
|
||||
this.activeState = this.states.get(stateType)!;
|
||||
this.currentStateType = stateType;
|
||||
this.activeState.enter(this.context);
|
||||
}
|
||||
|
||||
update(deltaTime: number) {
|
||||
this.activeState.update(this.context, deltaTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum PlayerStateType {
|
||||
Idle = "idle",
|
||||
Run = "run",
|
||||
TurnToTarget = "turn_to_target",
|
||||
Loot = "loot",
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** idle/run -> turn (blend into facing pose before the swing). */
|
||||
export const TURN_IDLE_FADE = 0.1;
|
||||
/** turn -> loot (windup into the bat swing). */
|
||||
export const LOOT_ENTER_FADE = 0.3;
|
||||
/** loot/combat -> idle (settle back after combat). */
|
||||
export const COMBAT_EXIT_FADE = 0.3;
|
||||
/** any -> run (start moving). */
|
||||
export const RUN_ENTER_FADE = 0.2;
|
||||
|
||||
/** Normalized clip time (0–1) when the bat crosses the target: R→L, then L→R. */
|
||||
export const ATTACK_STRIKE_MARKS = [0.48, 0.72];
|
||||
/** After 1st hit (R→L) — safe exit if combat ends before 2nd hit (L→R). */
|
||||
export const LOOT_EXIT_AFTER_FIRST_STRIKE = 0.5;
|
||||
/** After 2nd hit (L→R) — follow-through before idle. */
|
||||
export const LOOT_EXIT_AFTER_SECOND_STRIKE = 0.78;
|
||||
/** End of clip when exiting before any strike (e.g. left the zone). */
|
||||
export const LOOT_EXIT_END_OF_CYCLE = 0.95;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BaseAnimation } from "../../Enums/BaseAnimation";
|
||||
import { IPlayerState } from "../IPlayerState";
|
||||
import { PlayerContext } from "../PlayerContext";
|
||||
import { PlayerStateType } from "../PlayerStateType";
|
||||
|
||||
export class IdleState implements IPlayerState {
|
||||
enter(context: PlayerContext): void {
|
||||
if (context.skipStateEnterAnimation) {
|
||||
context.skipStateEnterAnimation = false;
|
||||
return;
|
||||
}
|
||||
|
||||
context.character.playAnimation(BaseAnimation.Idle);
|
||||
}
|
||||
|
||||
exit(_context: PlayerContext): void {}
|
||||
|
||||
update(context: PlayerContext, _deltaTime: number): void {
|
||||
if (context.isMoving()) {
|
||||
context.stateMachine.setState(PlayerStateType.Run);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = context.physics.getPhysicsBody();
|
||||
body.velocity.set(0, 0, 0);
|
||||
body.wakeUp();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { BaseAnimation } from "../../Enums/BaseAnimation";
|
||||
import {
|
||||
ATTACK_STRIKE_MARKS,
|
||||
COMBAT_EXIT_FADE,
|
||||
LOOT_ENTER_FADE,
|
||||
LOOT_EXIT_AFTER_FIRST_STRIKE,
|
||||
LOOT_EXIT_AFTER_SECOND_STRIKE,
|
||||
LOOT_EXIT_END_OF_CYCLE,
|
||||
} from "../combatConstants";
|
||||
import { IPlayerState } from "../IPlayerState";
|
||||
import { PlayerContext } from "../PlayerContext";
|
||||
import { PlayerStateType } from "../PlayerStateType";
|
||||
|
||||
export class LootState implements IPlayerState {
|
||||
private strikeMarkIndex = 0;
|
||||
private prevStrikeNormTime = 0;
|
||||
|
||||
enter(context: PlayerContext): void {
|
||||
context.zeroVelocity();
|
||||
|
||||
context.character.setBatEquipped(true);
|
||||
this.strikeMarkIndex = 0;
|
||||
this.prevStrikeNormTime = 0;
|
||||
|
||||
const animId = this.getBatAttackAnimation(context);
|
||||
context.character.crossFadeToAnimation(animId, false, LOOT_ENTER_FADE);
|
||||
}
|
||||
|
||||
exit(_context: PlayerContext): void {}
|
||||
|
||||
update(context: PlayerContext, _deltaTime: number): void {
|
||||
context.zeroVelocity();
|
||||
|
||||
context.rotation.setTargetWorldPosition(context.combatTargetWorldPosition);
|
||||
|
||||
const action = context.character.curClipAction;
|
||||
if (!action) return;
|
||||
|
||||
const clipDuration = action.getClip().duration;
|
||||
if (clipDuration <= 0) return;
|
||||
|
||||
const normalizedTime = action.time / clipDuration;
|
||||
|
||||
if (normalizedTime < this.prevStrikeNormTime) {
|
||||
if (context.pendingCombatExit) {
|
||||
this.finishCombatExit(context);
|
||||
return;
|
||||
}
|
||||
this.strikeMarkIndex = 0;
|
||||
}
|
||||
this.prevStrikeNormTime = normalizedTime;
|
||||
|
||||
while (
|
||||
this.strikeMarkIndex < ATTACK_STRIKE_MARKS.length &&
|
||||
normalizedTime >= ATTACK_STRIKE_MARKS[this.strikeMarkIndex]
|
||||
) {
|
||||
context.onStrike?.();
|
||||
this.strikeMarkIndex++;
|
||||
}
|
||||
|
||||
if (context.pendingCombatExit && this.canExitLootNow(normalizedTime)) {
|
||||
this.finishCombatExit(context);
|
||||
}
|
||||
}
|
||||
|
||||
private canExitLootNow(normalizedTime: number) {
|
||||
if (this.strikeMarkIndex >= 2) {
|
||||
return normalizedTime >= LOOT_EXIT_AFTER_SECOND_STRIKE;
|
||||
}
|
||||
|
||||
if (this.strikeMarkIndex >= 1) {
|
||||
return normalizedTime >= LOOT_EXIT_AFTER_FIRST_STRIKE;
|
||||
}
|
||||
|
||||
return normalizedTime >= LOOT_EXIT_END_OF_CYCLE;
|
||||
}
|
||||
|
||||
private getBatAttackAnimation(context: PlayerContext) {
|
||||
if (context.character.animationList.length > BaseAnimation.Loot) {
|
||||
return BaseAnimation.Loot;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BaseAnimation } from "../../Enums/BaseAnimation";
|
||||
import { Vector3TToC } from "../../Helper";
|
||||
import { FollowCameraC } from "../../Movment/CameraMovment/FollowCamera";
|
||||
import { RUN_ENTER_FADE } from "../combatConstants";
|
||||
import { IPlayerState } from "../IPlayerState";
|
||||
import { PlayerContext } from "../PlayerContext";
|
||||
import { PlayerStateType } from "../PlayerStateType";
|
||||
|
||||
/** Anim playback never drops below this, so legs don't freeze at tiny joystick tilts. */
|
||||
const MIN_RUN_TIME_SCALE = 0.3;
|
||||
/** How fast the run anim speed catches up to the joystick tilt. */
|
||||
const RUN_TIME_SCALE_LERP = 10;
|
||||
|
||||
export class RunState implements IPlayerState {
|
||||
private timeScale = MIN_RUN_TIME_SCALE;
|
||||
|
||||
enter(context: PlayerContext): void {
|
||||
this.timeScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
|
||||
if (context.character.isPlayingAnimation(BaseAnimation.Run)) {
|
||||
context.character.setCurrentTimeScale(this.timeScale);
|
||||
return;
|
||||
}
|
||||
context.character.crossFadeToAnimation(BaseAnimation.Run, false, RUN_ENTER_FADE);
|
||||
context.character.setCurrentTimeScale(this.timeScale);
|
||||
}
|
||||
|
||||
exit(context: PlayerContext): void {
|
||||
context.character.setCurrentTimeScale(1);
|
||||
}
|
||||
|
||||
update(context: PlayerContext, deltaTime: number): void {
|
||||
const direction = context.movement.Direction;
|
||||
|
||||
if (direction.lengthSq() === 0) {
|
||||
context.stateMachine.setState(PlayerStateType.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
FollowCameraC.inputDirection.copy(direction).normalize();
|
||||
context.rotation.setTargetDirection(direction);
|
||||
|
||||
const body = context.physics.getPhysicsBody();
|
||||
body.velocity.copy(Vector3TToC(direction));
|
||||
body.wakeUp();
|
||||
|
||||
const targetScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
|
||||
const t = Math.min(deltaTime * RUN_TIME_SCALE_LERP, 1);
|
||||
this.timeScale += (targetScale - this.timeScale) * t;
|
||||
context.character.setCurrentTimeScale(this.timeScale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BaseAnimation } from "../../Enums/BaseAnimation";
|
||||
import { TURN_IDLE_FADE } from "../combatConstants";
|
||||
import { IPlayerState } from "../IPlayerState";
|
||||
import { PlayerContext } from "../PlayerContext";
|
||||
import { PlayerStateType } from "../PlayerStateType";
|
||||
|
||||
export class TurnToTargetState implements IPlayerState {
|
||||
enter(context: PlayerContext): void {
|
||||
context.rotation.setTargetWorldPosition(context.combatTargetWorldPosition);
|
||||
context.zeroVelocity();
|
||||
context.character.playAnimation(BaseAnimation.Idle, false, TURN_IDLE_FADE);
|
||||
}
|
||||
|
||||
exit(_context: PlayerContext): void {}
|
||||
|
||||
update(context: PlayerContext, _deltaTime: number): void {
|
||||
context.zeroVelocity();
|
||||
|
||||
if (!context.rotation.isComplete()) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.syncRotation();
|
||||
context.stateMachine.setState(PlayerStateType.Loot);
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,19 @@ import { ResourceScreenFly } from "./ResourceScreenFly";
|
||||
import { ResourceType } from "./ResourceType";
|
||||
import { ResourceUIC } from "./ResourceUIC";
|
||||
|
||||
/** Screen-space flight duration (icon → deposit zone). */
|
||||
const DEPOSIT_FLY_MS = 520;
|
||||
/** Pickup scale at the end of the flight (1 → this value). */
|
||||
const DEPOSIT_FLY_SCALE = 0.35;
|
||||
const CHAIN_DEPOSIT_DELAY_MS = 140;
|
||||
/** Delay between starting each pickup in the chain (overlapping flights). */
|
||||
const CHAIN_DEPOSIT_STAGGER_MS = 70;
|
||||
|
||||
export class ResourceDepositC {
|
||||
private static inited = false;
|
||||
private static tweenGroup = new Group();
|
||||
private static isProcessing = false;
|
||||
private static isDepositing = false;
|
||||
private static activeFlights = 0;
|
||||
private static pendingLaunches = 0;
|
||||
|
||||
static init() {
|
||||
if (this.inited) return;
|
||||
@@ -40,8 +45,8 @@ export class ResourceDepositC {
|
||||
}
|
||||
|
||||
static tryStart() {
|
||||
if (!this.canDeposit()) return;
|
||||
this.processNext();
|
||||
if (this.isDepositing || !this.canDeposit()) return;
|
||||
this.startDepositChain();
|
||||
}
|
||||
|
||||
private static canDeposit() {
|
||||
@@ -61,23 +66,55 @@ export class ResourceDepositC {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static processNext() {
|
||||
if (this.isProcessing) return;
|
||||
private static getDepositableCount() {
|
||||
let total = 0;
|
||||
for (const type of Object.values(ResourceType)) {
|
||||
total += ResourceInventoryC.get(type);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
const type = this.getDepositableType();
|
||||
if (!type || !this.canDeposit()) {
|
||||
this.isProcessing = false;
|
||||
private static startDepositChain() {
|
||||
this.isDepositing = true;
|
||||
this.pendingLaunches = this.getDepositableCount();
|
||||
this.scheduleChainStep();
|
||||
}
|
||||
|
||||
private static scheduleChainStep() {
|
||||
if (!this.isDepositing) return;
|
||||
|
||||
if (this.pendingLaunches <= 0 || !this.canDeposit()) {
|
||||
this.tryFinishChain();
|
||||
return;
|
||||
}
|
||||
|
||||
const type = this.getDepositableType();
|
||||
if (!type) {
|
||||
this.tryFinishChain();
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingLaunches -= 1;
|
||||
|
||||
if (!this.launchOneDeposit(type)) {
|
||||
this.tryFinishChain();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pendingLaunches > 0) {
|
||||
window.setTimeout(() => this.scheduleChainStep(), CHAIN_DEPOSIT_STAGGER_MS);
|
||||
} else {
|
||||
this.tryFinishChain();
|
||||
}
|
||||
}
|
||||
|
||||
private static launchOneDeposit(type: ResourceType) {
|
||||
const iconCenter = ResourceUIC.getIconCenter(type);
|
||||
const zoneScreen = InteractiveZoneC.getScreenCenter();
|
||||
if (!iconCenter) return;
|
||||
if (!iconCenter) return false;
|
||||
|
||||
const pickup = ResourceScreenFly.createPickup(type);
|
||||
if (!pickup) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
if (!pickup) return false;
|
||||
|
||||
const startScreen = ResourceScreenFly.screenPointFromClient(
|
||||
iconCenter.x,
|
||||
@@ -85,6 +122,8 @@ export class ResourceDepositC {
|
||||
zoneScreen.z,
|
||||
);
|
||||
|
||||
this.activeFlights += 1;
|
||||
|
||||
ResourceScreenFly.flyAlongScreen(
|
||||
pickup,
|
||||
startScreen,
|
||||
@@ -97,11 +136,16 @@ export class ResourceDepositC {
|
||||
ResourceUIC.refresh(type);
|
||||
InteractiveZoneC.addDeposit(1);
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.isProcessing = false;
|
||||
this.processNext();
|
||||
}, CHAIN_DEPOSIT_DELAY_MS);
|
||||
this.activeFlights -= 1;
|
||||
this.tryFinishChain();
|
||||
},
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static tryFinishChain() {
|
||||
if (this.activeFlights > 0) return;
|
||||
this.isDepositing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,14 @@ const BOUNCE_HEIGHTS = [0.22, 0.09];
|
||||
const BOUNCE_UP_MS = 200;
|
||||
const BOUNCE_DOWN_MS = 180;
|
||||
const REST_AFTER_BOUNCE_MS = 400;
|
||||
/** Screen-space flight duration (world pickup → UI icon). */
|
||||
const FLY_DURATION_MS = 520;
|
||||
const CHAIN_FLY_DELAY_MS = 140;
|
||||
/** Pickup scale at the end of the flight (1 → this value). */
|
||||
const FLY_SCALE = 0.35;
|
||||
const STAGGER_MS = 70;
|
||||
/** Delay between starting each fly-to-UI after bounce (overlapping flights). */
|
||||
const CHAIN_FLY_STAGGER_MS = 70;
|
||||
/** Delay between spawning each ground pickup from a loot burst. */
|
||||
const SPAWN_STAGGER_MS = 70;
|
||||
const GROUND_LIFT = 0.08;
|
||||
|
||||
type FlyQueueItem = {
|
||||
@@ -32,7 +36,7 @@ export class ResourceFlyC {
|
||||
private static inited = false;
|
||||
private static tweenGroup = new Group();
|
||||
private static flyQueue: FlyQueueItem[] = [];
|
||||
private static isProcessingFlyQueue = false;
|
||||
private static isDrainingFlyQueue = false;
|
||||
|
||||
static init() {
|
||||
if (this.inited) return;
|
||||
@@ -58,7 +62,7 @@ export class ResourceFlyC {
|
||||
static launch(origin: Vector3, type: ResourceType, index: number, count: number) {
|
||||
window.setTimeout(() => {
|
||||
this.startPickup(origin, type, index, count);
|
||||
}, index * STAGGER_MS);
|
||||
}, index * SPAWN_STAGGER_MS);
|
||||
}
|
||||
|
||||
private static startPickup(origin: Vector3, type: ResourceType, index: number, count: number) {
|
||||
@@ -153,32 +157,32 @@ export class ResourceFlyC {
|
||||
|
||||
private static enqueueFlyToUI(pickup: Object3D, type: ResourceType) {
|
||||
this.flyQueue.push({ pickup, type });
|
||||
this.processFlyQueue();
|
||||
if (!this.isDrainingFlyQueue) {
|
||||
this.drainFlyChainStep();
|
||||
}
|
||||
}
|
||||
|
||||
private static processFlyQueue() {
|
||||
if (this.isProcessingFlyQueue || this.flyQueue.length === 0) return;
|
||||
|
||||
this.isProcessingFlyQueue = true;
|
||||
private static drainFlyChainStep() {
|
||||
const item = this.flyQueue.shift();
|
||||
if (!item) {
|
||||
this.isProcessingFlyQueue = false;
|
||||
this.isDrainingFlyQueue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.flyToUI(item.pickup, item.type, () => {
|
||||
window.setTimeout(() => {
|
||||
this.isProcessingFlyQueue = false;
|
||||
this.processFlyQueue();
|
||||
}, CHAIN_FLY_DELAY_MS);
|
||||
});
|
||||
this.isDrainingFlyQueue = true;
|
||||
this.flyToUI(item.pickup, item.type);
|
||||
|
||||
if (this.flyQueue.length > 0) {
|
||||
window.setTimeout(() => this.drainFlyChainStep(), CHAIN_FLY_STAGGER_MS);
|
||||
} else {
|
||||
this.isDrainingFlyQueue = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static flyToUI(pickup: Object3D, type: ResourceType, onComplete: () => void) {
|
||||
private static flyToUI(pickup: Object3D, type: ResourceType) {
|
||||
const iconCenter = ResourceUIC.getIconCenter(type);
|
||||
if (!iconCenter) {
|
||||
ThreeC.removeFromScene(pickup);
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -199,7 +203,6 @@ export class ResourceFlyC {
|
||||
() => {
|
||||
ResourceInventoryC.add(type, 1);
|
||||
ResourceUIC.refresh(type);
|
||||
onComplete();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,18 @@ canvas {
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
#joystick_zone .joystick .back {
|
||||
background: transparent !important;
|
||||
border: 0.35vh solid #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#joystick_zone .joystick .front {
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
border: 0.2vh solid #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
#videoPlayable {
|
||||
height: 100vh;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ui.is-pre-gameplay .hud-invasion-title,
|
||||
#ui.is-pre-gameplay .hud-progress,
|
||||
#ui.is-pre-gameplay .hud-top-right {
|
||||
#ui.is-pre-gameplay .hud-top-right,
|
||||
#ui.is-pre-gameplay .hud-bottom-left {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -65,8 +66,8 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 0.35vh solid rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 0.35vh solid #ffffff;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -79,7 +80,9 @@
|
||||
margin-left: -4vh;
|
||||
margin-top: -4vh;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 0.2vh solid #ffffff;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
box-sizing: border-box;
|
||||
animation: pre-gameplay-joystick-knob 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user