replaced files

This commit is contained in:
Vasyl Kazakov
2026-06-18 16:47:23 +03:00
parent 7cc63dbba9
commit ce3f254bc1
49 changed files with 141 additions and 181 deletions
@@ -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,80 @@
import { Object3D, Vector3 } from "three";
import { Character } from "../Character/Character";
import { MoveC } from "../Movement/MoveC";
import { RotationC } from "../Movement/RotationC";
import { PhysicsBody } from "../../core/PhysicsC";
import { PlayerStateMachine } from "./PlayerStateMachine";
import { PlayerStateType } from "./PlayerStateType";
import { BaseAnimation } from "../../Enums/BaseAnimation";
import { COMBAT_EXIT_FADE } from "./combatConstants";
export class PlayerContext {
stateMachine!: PlayerStateMachine;
readonly character: Character;
readonly movement: MoveC;
readonly rotation: RotationC;
readonly physics: PhysicsBody;
readonly container: Object3D;
isAutoAttacking = false;
pendingCombatExit = false;
/**
* Set when the player releases the joystick next to an attackable prop. Tells
* RunState to hold its current animation instead of dropping to Idle for a
* frame, so the upcoming Run -> Loot crossfade stays clean (no Idle blink).
*/
pendingCombatEntry = false;
skipStateEnterAnimation = false;
onStrike: (() => void) | null = null;
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();
}
/**
* Спільний вихід з бою: скидає бойові прапорці, ховає биту і переходить
* у 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);
}
}
@@ -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>;
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);
}
get currentState(): PlayerStateType {
return this.currentStateType;
}
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,83 @@
import { BaseAnimation } from "../../../Enums/BaseAnimation";
import {
ATTACK_STRIKE_MARKS,
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";
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) {
context.finishCombatExit();
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)) {
context.finishCombatExit();
}
}
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;
}
}
@@ -0,0 +1,117 @@
import { Vector3 } from "three";
import { BaseAnimation } from "../../../Enums/BaseAnimation";
import { Vector3CToT, Vector3TToC } from "../../../utils/Helper";
import { FollowCameraC } from "../../../Camera/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;
/**
* Stuck detection works on a time window of NET displacement instead of per-frame
* speed: cannon's penetration recovery jitters the body back and forth against a
* wall, so an instantaneous speed check randomly spikes above any threshold and
* misses the stuck case. Net travel over a window averages that jitter out.
*/
const STUCK_WINDOW = 0.15;
/** Min real travel over a window; below this (while pushing) = stuck against a wall. */
const STUCK_MIN_DISTANCE = 0.04;
/** Fade for the run<->idle swap while staying in the Run state. */
const STUCK_ANIM_FADE = 0.15;
export class RunState implements IPlayerState {
private timeScale = MIN_RUN_TIME_SCALE;
private readonly windowStartPos = new Vector3();
private readonly curPos = new Vector3();
private windowTimer = 0;
private blocked = false;
enter(context: PlayerContext): void {
this.timeScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
this.blocked = false;
this.windowTimer = 0;
this.windowStartPos.copy(Vector3CToT(context.physics.getPhysicsBody().position));
if (context.character.isPlayingAnimation(BaseAnimation.Run)) {
context.character.setCurrentTimeScale(this.timeScale);
return;
}
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) {
// An auto-attack is about to start (stopped next to a prop): hold the
// current animation and let GatherC switch us straight to Loot. Dropping
// to Idle for a frame here is what produced the stop->attack blink.
if (context.pendingCombatEntry) {
context.zeroVelocity();
return;
}
// While blocked the Idle clip is already playing, so let IdleState skip its
// own playAnimation(Idle) - re-triggering it resets the same clip to frame 0
// and causes a 1-frame pop right before the auto-attack crossfade.
if (this.blocked) context.skipStateEnterAnimation = true;
context.stateMachine.setState(PlayerStateType.Idle);
return;
}
FollowCameraC.inputDirection.copy(direction).normalize();
context.rotation.setTargetDirection(direction);
const body = context.physics.getPhysicsBody();
// Keep driving velocity every frame even while blocked, so sliding along the
// wall (or the wall clearing) resumes movement on its own.
body.velocity.copy(Vector3TToC(direction));
body.wakeUp();
this.updateStuckState(context, deltaTime);
if (this.blocked) return;
const targetScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
const t = Math.min(deltaTime * RUN_TIME_SCALE_LERP, 1);
this.timeScale += (targetScale - this.timeScale) * t;
context.character.setCurrentTimeScale(this.timeScale);
}
/**
* Samples net body travel over {@link STUCK_WINDOW}. If the player keeps pushing
* but barely moves, we swap the run animation to Idle (staying in Run state) so
* it resumes instantly once the body slides free.
*/
private updateStuckState(context: PlayerContext, deltaTime: number): void {
this.windowTimer += deltaTime;
if (this.windowTimer < STUCK_WINDOW) return;
this.curPos.copy(Vector3CToT(context.physics.getPhysicsBody().position));
const moved = this.curPos.distanceTo(this.windowStartPos);
const isStuck = moved < STUCK_MIN_DISTANCE;
this.windowStartPos.copy(this.curPos);
this.windowTimer = 0;
if (isStuck === this.blocked) return;
this.blocked = isStuck;
if (isStuck) {
context.character.crossFadeToAnimation(BaseAnimation.Idle, false, STUCK_ANIM_FADE);
} else {
this.timeScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
context.character.crossFadeToAnimation(BaseAnimation.Run, false, STUCK_ANIM_FADE);
context.character.setCurrentTimeScale(this.timeScale);
}
}
}
@@ -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);
}
}