fixed bugs

This commit is contained in:
Vasyl Kazakov
2026-06-17 17:06:13 +03:00
parent adf4e4b9bf
commit 930ed00c95
20 changed files with 627 additions and 415 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[
-360,
360,
0
2
],
[
-360,
-2
View File
@@ -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(),
() => {},
+3 -4
View File
@@ -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);
+13 -8
View File
@@ -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) {
+99 -11
View File
@@ -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) {
+82 -74
View File
@@ -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);
-273
View File
@@ -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 (01) 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 (01) 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);
}
}
+61 -17
View File
@@ -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;
}
}
+22 -19
View File
@@ -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();
},
);
}
+12
View File
@@ -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;
+7 -4
View File
@@ -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;
}