replaced files
This commit is contained in:
@@ -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 (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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user