blacktrigram 0.7.111 → 0.7.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/assets/index.css +0 -1
- package/lib/audio/AudioManager.js +22 -20
- package/lib/audio/AudioManager.js.map +1 -1
- package/lib/audio/AudioPool.js +6 -4
- package/lib/audio/AudioPool.js.map +1 -1
- package/lib/audio/VariantSelector.js +12 -8
- package/lib/audio/VariantSelector.js.map +1 -1
- package/lib/components/screens/intro/IntroScreen3D.js +1 -1
- package/lib/components/screens/training/TrainingScreen3D.js +4 -3
- package/lib/components/screens/training/TrainingScreen3D.js.map +1 -1
- package/lib/components/shared/mobile/HapticController.js +4 -2
- package/lib/components/shared/mobile/HapticController.js.map +1 -1
- package/lib/components/shared/mobile/StanceWheelPure.js +1 -1
- package/lib/components/shared/mobile/StanceWheelPure.js.map +1 -1
- package/lib/components/shared/three/effects/VitalPointMarkers3D.js +7 -5
- package/lib/components/shared/three/effects/VitalPointMarkers3D.js.map +1 -1
- package/lib/components/shared/three/optimization/AdaptiveQuality.js.map +1 -1
- package/lib/components/shared/three/ui/VitalPointOverlayControlsHtml.js +7 -5
- package/lib/components/shared/three/ui/VitalPointOverlayControlsHtml.js.map +1 -1
- package/lib/components/shared/ui/SplashScreen.js +2 -2
- package/lib/components/shared/ui/VitalPointOverlayControlsPure.js +7 -5
- package/lib/components/shared/ui/VitalPointOverlayControlsPure.js.map +1 -1
- package/lib/components/shared/ui/VolumeControl.js +4 -2
- package/lib/components/shared/ui/VolumeControl.js.map +1 -1
- package/lib/hooks/useSkeletalAnimation.js +6 -4
- package/lib/hooks/useSkeletalAnimation.js.map +1 -1
- package/lib/hooks/useTouchControls.js +18 -17
- package/lib/hooks/useTouchControls.js.map +1 -1
- package/lib/systems/animation/builders/AnimationBuilder.js +6 -2
- package/lib/systems/animation/builders/AnimationBuilder.js.map +1 -1
- package/lib/systems/animation/builders/PunchPhaseApplicator.js +8 -4
- package/lib/systems/animation/builders/PunchPhaseApplicator.js.map +1 -1
- package/lib/systems/animation/core/AnimationStateMachine.js +39 -37
- package/lib/systems/animation/core/AnimationStateMachine.js.map +1 -1
- package/lib/systems/bodypart/MovementPenaltySystem.js.map +1 -1
- package/lib/systems/combat/LimbExposureSystem.js.map +1 -1
- package/lib/systems/physics/MovementPhysics.js +25 -24
- package/lib/systems/physics/MovementPhysics.js.map +1 -1
- package/lib/utils/deviceDetection.js +14 -13
- package/lib/utils/deviceDetection.js.map +1 -1
- package/lib/utils/inputSystem.js +4 -2
- package/lib/utils/inputSystem.js.map +1 -1
- package/package.json +6 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MovementPhysics.js","names":[],"sources":["../../../src/systems/physics/MovementPhysics.ts"],"sourcesContent":["/**\n * Physics-based movement system for realistic combat movement.\n *\n * **Korean**: 이동 물리 시스템 (Movement Physics System)\n *\n * This module implements realistic physics-based player movement with proper\n * acceleration/deceleration, foot-wide step precision, and stance-based speed\n * modifiers for authentic Korean martial arts combat feel.\n *\n * ## Features\n *\n * - **Realistic Acceleration**: 0 to 2m/s in 0.5 seconds (4.0 m/s²)\n * - **Realistic Deceleration**: 2m/s to 0 in 0.3 seconds (6.67 m/s²)\n * - **Foot-wide Steps**: Discrete 30cm movement increments for tactical positioning\n * - **Stance Modifiers**: 8 trigram stances with different speed characteristics\n * - **Injury Integration**: Movement speed reduced by leg damage (10-50%)\n *\n * @module systems/physics/MovementPhysics\n * @category Physics System\n * @korean 이동물리\n */\n\nimport { TrigramStance } from \"@/types/common\";\nimport { BASE_MOVEMENT_ACCELERATION } from \"@/types/physicsConstants\";\nimport type { MovementArenaBounds } from \"@/types/PhysicsTypes\";\nimport * as THREE from \"three\";\n\n/**\n * Movement input from keyboard/gamepad controls.\n *\n * **Korean**: 이동 입력 (Movement Input)\n *\n * @category Physics System\n * @korean 이동입력\n */\nexport interface MovementInput {\n /** Forward/backward input (-1 to 1, where 1 is forward) */\n readonly forward: number;\n /** Lateral left/right input (-1 to 1, where 1 is right) */\n readonly lateral: number;\n /** Whether sprint/run key is held */\n readonly isRunning: boolean;\n /** Whether any movement input is active */\n readonly isMoving: boolean;\n /** Whether to use tactical step mode (30cm grid) */\n readonly useTacticalSteps: boolean;\n}\n\n/**\n * Complete movement state for physics calculations.\n *\n * **Korean**: 이동 상태 (Movement State)\n *\n * Contains position, velocity, and current movement parameters.\n * All vectors are mutable for performance (updated in-place during physics loop).\n *\n * @category Physics System\n * @korean 이동상태\n */\nexport interface MovementState {\n /**\n * Current position in 3D space (meters).\n *\n * NOTE: This is a readonly reference to a mutable THREE.Vector3.\n * The physics engine intentionally mutates the vector in-place\n * (e.g. via position.add(...)) for performance. The readonly\n * modifier prevents reassignment of the Vector3 instance, not\n * mutation of its components.\n */\n readonly position: THREE.Vector3;\n /**\n * Current velocity vector (m/s).\n *\n * NOTE: This is a readonly reference to a mutable THREE.Vector3.\n * The physics engine updates this vector in-place each frame.\n * Callers must not reassign the velocity reference, but may pass\n * it to APIs that read or modify its components.\n */\n readonly velocity: THREE.Vector3;\n /** Current acceleration magnitude (m/s²) */\n acceleration: number;\n /** Maximum speed for current state (m/s) */\n maxSpeed: number;\n /** Current Eight Trigram stance */\n readonly currentStance: TrigramStance;\n /** Leg injury percentage (0-1, where 1 is fully injured) */\n legInjuryFactor: number;\n}\n\n/**\n * Stance-based speed modifiers for Eight Trigram system.\n *\n * **Korean**: 팔괘 속도 배수 (Eight Trigram Speed Multipliers)\n *\n * Each trigram stance has unique movement characteristics based on\n * traditional Korean martial arts philosophy:\n *\n * - ☰ 건 (Geon/Heaven): 100% - Balanced, standard speed\n * - ☱ 태 (Tae/Lake): 110% - Fluid movement, flowing techniques\n * - ☲ 리 (Li/Fire): 120% - Aggressive, fast attacks\n * - ☳ 진 (Jin/Thunder): 115% - Explosive power\n * - ☴ 손 (Son/Wind): 125% - Continuous motion, fastest stance\n * - ☵ 감 (Gam/Water): 105% - Adaptive, slightly faster than neutral\n * - ☶ 간 (Gan/Mountain): 80% - Solid defense, slower movement\n * - ☷ 곤 (Gon/Earth): 85% - Grounded, stable but slower\n *\n * @korean 자세속도배수\n */\nexport const STANCE_SPEED_MODIFIERS: Record<TrigramStance, number> = {\n [TrigramStance.GEON]: 1.0, // Heaven: balanced\n [TrigramStance.TAE]: 1.1, // Lake: fluid\n [TrigramStance.LI]: 1.2, // Fire: aggressive\n [TrigramStance.JIN]: 1.15, // Thunder: explosive\n [TrigramStance.SON]: 1.25, // Wind: fastest\n [TrigramStance.GAM]: 1.05, // Water: adaptive\n [TrigramStance.GAN]: 0.8, // Mountain: defensive\n [TrigramStance.GON]: 0.85, // Earth: grounded\n};\n\n/**\n * Physics-based movement engine for combat.\n *\n * **Korean**: 이동 물리 엔진 (Movement Physics Engine)\n *\n * Implements realistic acceleration, deceleration, and stance-based movement\n * for authentic Korean martial arts combat. Supports both continuous movement\n * and tactical foot-wide steps for precise positioning.\n *\n * @example\n * ```typescript\n * const physics = new MovementPhysics();\n *\n * const state: MovementState = {\n * position: new THREE.Vector3(0, 0, 0),\n * velocity: new THREE.Vector3(0, 0, 0),\n * acceleration: 0,\n * maxSpeed: 2.0,\n * currentStance: TrigramStance.GEON,\n * legInjuryFactor: 0,\n * };\n *\n * const input: MovementInput = {\n * forward: 1.0,\n * lateral: 0,\n * isRunning: false,\n * isMoving: true,\n * useTacticalSteps: false,\n * };\n *\n * // In game loop at 60fps\n * physics.updateMovement(state, input, deltaTime);\n * ```\n *\n * @category Physics System\n * @korean 이동물리엔진\n */\nexport class MovementPhysics {\n /**\n * Base acceleration rate (m/s²)\n * Achieves 0 to 6m/s in 0.2 seconds (instant-response combat movement)\n * Increased from 12.0 to 30.0 for arcade-style responsiveness\n *\n * Imported from physicsConstants.ts to maintain consistency across systems.\n *\n * **Korean**: 기본 가속도 (Base Acceleration)\n */\n private readonly BASE_ACCELERATION = BASE_MOVEMENT_ACCELERATION;\n\n /**\n * Base deceleration rate (m/s²)\n * Achieves 6m/s to 0 in 0.3 seconds (responsive combat stopping)\n *\n * **Korean**: 기본 감속도 (Base Deceleration)\n */\n private readonly BASE_DECELERATION = 20.0;\n\n /**\n * Foot-wide step size (meters)\n * Standard Korean martial arts step is approximately 30cm\n *\n * **Korean**: 보법 거리 (Step Distance)\n */\n private readonly STEP_SIZE = 0.3;\n\n /**\n * Base walking speed (m/s)\n * Optimized for responsive combat movement - crosses 14m arena in ~2.3s\n *\n * **Korean**: 기본 걷기 속도 (Base Walking Speed)\n */\n private readonly BASE_WALK_SPEED = 6.0;\n\n /**\n * Base running speed (m/s)\n * Sprint speed for rapid repositioning - crosses 14m arena in ~1.4s\n *\n * **Korean**: 기본 달리기 속도 (Base Running Speed)\n */\n private readonly BASE_RUN_SPEED = 10.0;\n\n /**\n * Reference arena size for speed calibration (meters).\n * All speeds are calibrated for a 10m arena.\n *\n * **Korean**: 기준 경기장 크기 (Reference Arena Size)\n */\n private readonly REFERENCE_ARENA_SIZE = 10.0;\n\n /**\n * Current arena width in meters for arena-aware speed scaling.\n *\n * **Korean**: 현재 경기장 너비 (Current Arena Width)\n */\n private _arenaWidthMeters: number = 10.0;\n\n // LATERAL_SPEED removed - now using state.maxSpeed for all directions\n // This ensures speed override applies to lateral movement too\n\n /**\n * Override for max speed from external speed modifier systems.\n *\n * **Korean**: 최대속도 재정의 (Max Speed Override)\n */\n private _overrideMaxSpeed: number | null = null;\n\n /**\n * Override for acceleration from external speed modifier systems.\n *\n * **Korean**: 가속도 재정의 (Acceleration Override)\n */\n private _overrideAcceleration: number | null = null;\n\n /**\n * Cached arena speed scale to avoid repeated calculations.\n *\n * **Korean**: 캐시된 경기장 속도 배수 (Cached Arena Speed Scale)\n */\n private _cachedArenaSpeedScale: number = 1.0;\n\n // Temporary vectors to avoid allocations in update loop\n private readonly tempTargetVelocity = new THREE.Vector3();\n private readonly tempMovement = new THREE.Vector3();\n private readonly tempDirection = new THREE.Vector3();\n private readonly tempTargetDirection = new THREE.Vector3();\n\n /**\n * Create a new MovementPhysics instance.\n *\n * **Korean**: 이동 물리 생성 (Create Movement Physics)\n *\n * @param arenaWidthMeters - Width of the arena in meters (default: 10m, min: 1m)\n * @throws {Error} If arenaWidthMeters is not a positive number\n *\n * @example\n * ```typescript\n * // Default 10m arena (1.0x speed scale)\n * const physics = new MovementPhysics();\n *\n * // Small 6m arena (0.7x speed scale)\n * const smallPhysics = new MovementPhysics(6.0);\n *\n * // Large 14m arena (1.3x speed scale)\n * const largePhysics = new MovementPhysics(14.0);\n * ```\n *\n */\n constructor(arenaWidthMeters: number = 10.0) {\n if (arenaWidthMeters <= 0 || !Number.isFinite(arenaWidthMeters)) {\n throw new Error(\n `Arena width must be a positive finite number, got: ${arenaWidthMeters}`,\n );\n }\n this._arenaWidthMeters = arenaWidthMeters;\n this._cachedArenaSpeedScale = this.calculateArenaSpeedScale();\n }\n\n /**\n * Calculate arena-aware speed scaling factor.\n *\n * **Korean**: 경기장 크기 기반 속도 배수 (Arena-Based Speed Multiplier)\n *\n * Scales movement speed proportionally to arena size to maintain consistent\n * gameplay feel across different screen resolutions. Smaller arenas get\n * slightly slower speeds, larger arenas get slightly faster speeds.\n *\n * Formula: scaleFactor = arenaWidth / referenceArenaSize\n * Clamped to [0.7, 1.3] range for balanced gameplay\n *\n * Examples:\n * - 6m arena: 0.7x speed (70% of base)\n * - 10m arena: 1.0x speed (baseline)\n * - 14m arena: 1.3x speed (130% of base)\n *\n * @returns Speed multiplier (0.7 to 1.3)\n *\n * @korean 경기장속도배수\n */\n private calculateArenaSpeedScale(): number {\n const rawScale = this._arenaWidthMeters / this.REFERENCE_ARENA_SIZE;\n // Clamp to reasonable range to maintain gameplay balance\n return Math.max(0.7, Math.min(1.3, rawScale));\n }\n\n /**\n * Update player movement based on input and physics.\n *\n * **Korean**: 이동 업데이트 (Update Movement)\n *\n * Called every frame (60fps) to update player position based on\n * current velocity, acceleration, and input. Applies stance modifiers\n * and injury penalties automatically.\n *\n * @param state - Current movement state (modified in-place)\n * @param input - Current movement input from controls\n * @param deltaTime - Time since last update (seconds)\n * @param bounds - Optional arena bounds for clamping position (meters)\n *\n * @korean 이동업데이트\n */\n public updateMovement(\n state: MovementState,\n input: MovementInput,\n deltaTime: number,\n bounds?: MovementArenaBounds,\n ): void {\n // Use cached arena-aware speed scaling\n const arenaSpeedScale = this._cachedArenaSpeedScale;\n\n // Calculate stance speed modifier\n const stanceModifier = this.getStanceSpeedModifier(state.currentStance);\n\n // Calculate injury penalty (0-50% speed reduction)\n const injuryPenalty = 1.0 - state.legInjuryFactor * 0.5;\n\n // Calculate base target speed (walking or running)\n const baseSpeed = input.isRunning\n ? this.BASE_RUN_SPEED\n : this.BASE_WALK_SPEED;\n\n // Apply all modifiers including arena scaling to get final max speed (or use override)\n state.maxSpeed =\n this._overrideMaxSpeed ??\n baseSpeed * arenaSpeedScale * stanceModifier * injuryPenalty;\n\n // Use override acceleration if set, otherwise use base\n const currentAcceleration =\n this._overrideAcceleration ?? this.BASE_ACCELERATION;\n\n // Calculate target velocity based on input direction\n // forward > 0 = moving in positive Z direction (toward bottom of screen)\n // forward < 0 = moving in negative Z direction (toward top of screen)\n // ✅ REMOVED backward multiplier: All directions use full speed for responsive gameplay\n // The backward penalty should be applied contextually by the combat system\n // based on player facing direction vs movement direction\n // ✅ FIX: Both lateral and forward now use state.maxSpeed (which includes all modifiers)\n // This ensures consistent speed in all movement directions and includes arena scaling\n this.tempTargetVelocity.set(\n input.lateral * state.maxSpeed,\n 0,\n input.forward * state.maxSpeed,\n );\n\n // Apply acceleration or deceleration\n if (input.isMoving) {\n // Accelerate toward target velocity with realistic direction changes\n const currentSpeed = state.velocity.length();\n const targetSpeed = this.tempTargetVelocity.length();\n\n if (currentSpeed < targetSpeed) {\n // Check if direction change is needed\n if (currentSpeed > 0.001 && targetSpeed > 0.001) {\n // Current movement direction\n this.tempDirection.copy(state.velocity).normalize();\n // Desired movement direction (reuse temp vector to avoid allocation)\n this.tempTargetDirection.copy(this.tempTargetVelocity).normalize();\n const directionDot = this.tempDirection.dot(this.tempTargetDirection);\n\n if (directionDot < 0) {\n // Moving in opposite direction: decelerate first before reversing\n const velocityDelta = this.BASE_DECELERATION * deltaTime;\n const newSpeed = Math.max(currentSpeed - velocityDelta, 0);\n\n if (newSpeed > 0.001) {\n state.velocity.copy(this.tempDirection.multiplyScalar(newSpeed));\n } else {\n // Fully stopped; can now start accelerating in new direction\n state.velocity.set(0, 0, 0);\n }\n state.acceleration = -this.BASE_DECELERATION;\n } else if (directionDot < 0.7) {\n // Perpendicular direction change (e.g., forward to strafe): moderate deceleration\n const blendedAccel = currentAcceleration * 0.6; // Reduced acceleration for sharp turns\n const velocityDelta = blendedAccel * deltaTime;\n const newSpeed = Math.min(\n currentSpeed + velocityDelta,\n targetSpeed,\n );\n this.tempDirection.copy(this.tempTargetVelocity).normalize();\n state.velocity.copy(this.tempDirection.multiplyScalar(newSpeed));\n state.acceleration = blendedAccel;\n } else {\n // Same or similar direction: full acceleration\n this.tempDirection.copy(this.tempTargetVelocity).normalize();\n const velocityDelta = currentAcceleration * deltaTime;\n const newSpeed = Math.min(\n currentSpeed + velocityDelta,\n targetSpeed,\n );\n state.velocity.copy(this.tempDirection.multiplyScalar(newSpeed));\n state.acceleration = currentAcceleration;\n }\n } else {\n // Very low speed: safe to accelerate directly toward target\n this.tempDirection.copy(this.tempTargetVelocity).normalize();\n const velocityDelta = currentAcceleration * deltaTime;\n const newSpeed = Math.min(currentSpeed + velocityDelta, targetSpeed);\n state.velocity.copy(this.tempDirection.multiplyScalar(newSpeed));\n state.acceleration = currentAcceleration;\n }\n } else {\n // Already at or above target speed: snap to target velocity\n state.velocity.copy(this.tempTargetVelocity);\n state.acceleration = 0;\n }\n } else {\n // Decelerate to stop\n const currentSpeed = state.velocity.length();\n if (currentSpeed > 0.01) {\n this.tempDirection.copy(state.velocity).normalize();\n const velocityDelta = this.BASE_DECELERATION * deltaTime;\n const newSpeed = Math.max(currentSpeed - velocityDelta, 0);\n state.velocity.copy(this.tempDirection.multiplyScalar(newSpeed));\n } else {\n state.velocity.set(0, 0, 0);\n }\n state.acceleration = -this.BASE_DECELERATION;\n }\n\n // Calculate movement delta for this frame\n this.tempMovement.copy(state.velocity).multiplyScalar(deltaTime);\n\n // Apply tactical step quantization if enabled\n if (input.useTacticalSteps) {\n // Quantize to 30cm grid steps\n this.tempMovement.x =\n Math.round(this.tempMovement.x / this.STEP_SIZE) * this.STEP_SIZE;\n this.tempMovement.z =\n Math.round(this.tempMovement.z / this.STEP_SIZE) * this.STEP_SIZE;\n }\n\n // Update position\n state.position.add(this.tempMovement);\n\n // Apply arena bounds clamping if bounds provided\n if (bounds) {\n // Check if position exceeded boundaries\n const exceededMinX = state.position.x < bounds.minX;\n const exceededMaxX = state.position.x > bounds.maxX;\n const exceededMinZ = state.position.z < bounds.minZ;\n const exceededMaxZ = state.position.z > bounds.maxZ;\n\n // Clamp position to arena boundaries\n state.position.x = Math.max(bounds.minX, Math.min(bounds.maxX, state.position.x));\n state.position.z = Math.max(bounds.minZ, Math.min(bounds.maxZ, state.position.z));\n\n // Zero velocity component if exceeded boundary (smooth stopping)\n if (exceededMinX || exceededMaxX) {\n state.velocity.x = 0;\n }\n if (exceededMinZ || exceededMaxZ) {\n state.velocity.z = 0;\n }\n }\n }\n\n /**\n * Get speed modifier for a specific trigram stance.\n *\n * **Korean**: 자세 속도 배수 가져오기 (Get Stance Speed Modifier)\n *\n * @param stance - Eight Trigram stance\n * @returns Speed multiplier (0.8 to 1.25)\n *\n * @korean 자세속도배수\n */\n public getStanceSpeedModifier(stance: TrigramStance): number {\n return STANCE_SPEED_MODIFIERS[stance];\n }\n\n /**\n * Calculate movement penalty from leg injury.\n *\n * **Korean**: 부상 이동 페널티 (Injury Movement Penalty)\n *\n * Leg damage reduces movement speed by 10-50% based on injury severity.\n *\n * @param legHealthPercentage - Remaining leg health (0-1)\n * @returns Injury factor (0 = no injury, 1 = maximum injury)\n *\n * @korean 부상페널티\n */\n public calculateInjuryPenalty(legHealthPercentage: number): number {\n // Injury penalty scales from 0% (healthy) to 50% (critical)\n // Clamp between 0 and 1\n const healthFactor = Math.max(0, Math.min(1, legHealthPercentage));\n return 1.0 - healthFactor;\n }\n\n /**\n * Get maximum speed for current state configuration.\n *\n * **Korean**: 최대 속도 계산 (Calculate Maximum Speed)\n *\n * @param isRunning - Whether running (vs walking)\n * @param stance - Current trigram stance\n * @param legInjuryFactor - Leg injury severity (0-1)\n * @returns Maximum speed in m/s (includes arena scaling)\n *\n * @korean 최대속도\n */\n public getMaxSpeed(\n isRunning: boolean,\n stance: TrigramStance,\n legInjuryFactor: number,\n ): number {\n const arenaSpeedScale = this._cachedArenaSpeedScale;\n const baseSpeed = isRunning ? this.BASE_RUN_SPEED : this.BASE_WALK_SPEED;\n const stanceModifier = this.getStanceSpeedModifier(stance);\n const injuryPenalty = 1.0 - legInjuryFactor * 0.5;\n return baseSpeed * arenaSpeedScale * stanceModifier * injuryPenalty;\n }\n\n /**\n * Calculate time required to reach target speed from current velocity.\n *\n * **Korean**: 가속 시간 (Acceleration Time)\n *\n * @param currentSpeed - Current speed magnitude (m/s)\n * @param targetSpeed - Desired speed magnitude (m/s)\n * @returns Time in seconds to reach target speed\n *\n * @korean 가속시간\n */\n public getAccelerationTime(\n currentSpeed: number,\n targetSpeed: number,\n ): number {\n const speedDiff = Math.abs(targetSpeed - currentSpeed);\n return speedDiff / this.BASE_ACCELERATION;\n }\n\n /**\n * Calculate stopping distance from current velocity.\n *\n * **Korean**: 제동 거리 (Braking Distance)\n *\n * @param currentSpeed - Current speed magnitude (m/s)\n * @returns Distance in meters required to stop\n *\n * @korean 제동거리\n */\n public getStoppingDistance(currentSpeed: number): number {\n // Using kinematic equation: d = v² / (2a)\n return (currentSpeed * currentSpeed) / (2 * this.BASE_DECELERATION);\n }\n\n /**\n * Get tactical step size.\n *\n * **Korean**: 보법 거리 (Step Distance)\n *\n * @returns Step size in meters (0.3m = 30cm)\n *\n * @korean 보법거리\n */\n public getStepSize(): number {\n return this.STEP_SIZE;\n }\n\n /**\n * Override maximum speed for external speed modifier systems.\n *\n * **Korean**: 최대 속도 설정 (Set Maximum Speed)\n *\n * Allows external systems (like SpeedModifierSystem) to override\n * the calculated maximum speed. This is applied in the next\n * updateMovement call.\n *\n * @param speed - Maximum speed in m/s\n *\n */\n public setMaxSpeed(speed: number): void {\n this._overrideMaxSpeed = speed;\n }\n\n /**\n * Override acceleration for external speed modifier systems.\n *\n * **Korean**: 가속도 설정 (Set Acceleration)\n *\n * Allows external systems (like SpeedModifierSystem) to override\n * the base acceleration rate. This is applied in the next\n * updateMovement call.\n *\n * @param acceleration - Acceleration in m/s²\n *\n */\n public setAcceleration(acceleration: number): void {\n this._overrideAcceleration = acceleration;\n }\n\n /**\n * Clear speed and acceleration overrides.\n *\n * **Korean**: 속도 재정의 해제 (Clear Speed Overrides)\n *\n * Resets movement to use default calculations without external\n * override values.\n *\n */\n public clearOverrides(): void {\n this._overrideMaxSpeed = null;\n this._overrideAcceleration = null;\n }\n\n /**\n * Set arena width for arena-aware speed scaling.\n *\n * **Korean**: 경기장 너비 설정 (Set Arena Width)\n *\n * Updates the arena width used for speed scaling calculations.\n * Call this when the arena size changes (e.g., screen resize).\n * Recalculates and caches the arena speed scale.\n *\n * @param widthMeters - Arena width in meters (must be positive)\n * @throws {Error} If widthMeters is not a positive number\n *\n */\n public setArenaWidth(widthMeters: number): void {\n if (widthMeters <= 0 || !Number.isFinite(widthMeters)) {\n throw new Error(\n `Arena width must be a positive finite number, got: ${widthMeters}`,\n );\n }\n this._arenaWidthMeters = widthMeters;\n this._cachedArenaSpeedScale = this.calculateArenaSpeedScale();\n }\n\n /**\n * Get current arena width.\n *\n * **Korean**: 경기장 너비 가져오기 (Get Arena Width)\n *\n * @returns Arena width in meters\n *\n */\n public getArenaWidth(): number {\n return this._arenaWidthMeters;\n }\n\n /**\n * Get current arena speed scale factor.\n *\n * **Korean**: 경기장 속도 배수 가져오기 (Get Arena Speed Scale)\n *\n * Returns the cached arena speed scale value.\n *\n * @returns Arena-based speed multiplier (0.7 to 1.3)\n *\n */\n public getArenaSpeedScale(): number {\n return this._cachedArenaSpeedScale;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"MovementPhysics.js","names":[],"sources":["../../../src/systems/physics/MovementPhysics.ts"],"sourcesContent":["/**\n * Physics-based movement system for realistic combat movement.\n *\n * **Korean**: 이동 물리 시스템 (Movement Physics System)\n *\n * This module implements realistic physics-based player movement with proper\n * acceleration/deceleration, foot-wide step precision, and stance-based speed\n * modifiers for authentic Korean martial arts combat feel.\n *\n * ## Features\n *\n * - **Realistic Acceleration**: 0 to 2m/s in 0.5 seconds (4.0 m/s²)\n * - **Realistic Deceleration**: 2m/s to 0 in 0.3 seconds (6.67 m/s²)\n * - **Foot-wide Steps**: Discrete 30cm movement increments for tactical positioning\n * - **Stance Modifiers**: 8 trigram stances with different speed characteristics\n * - **Injury Integration**: Movement speed reduced by leg damage (10-50%)\n *\n * @module systems/physics/MovementPhysics\n * @category Physics System\n * @korean 이동물리\n */\n\nimport { TrigramStance } from \"@/types/common\";\nimport { BASE_MOVEMENT_ACCELERATION } from \"@/types/physicsConstants\";\nimport type { MovementArenaBounds } from \"@/types/PhysicsTypes\";\nimport * as THREE from \"three\";\n\n/**\n * Movement input from keyboard/gamepad controls.\n *\n * **Korean**: 이동 입력 (Movement Input)\n *\n * @category Physics System\n * @korean 이동입력\n */\nexport interface MovementInput {\n /** Forward/backward input (-1 to 1, where 1 is forward) */\n readonly forward: number;\n /** Lateral left/right input (-1 to 1, where 1 is right) */\n readonly lateral: number;\n /** Whether sprint/run key is held */\n readonly isRunning: boolean;\n /** Whether any movement input is active */\n readonly isMoving: boolean;\n /** Whether to use tactical step mode (30cm grid) */\n readonly useTacticalSteps: boolean;\n}\n\n/**\n * Complete movement state for physics calculations.\n *\n * **Korean**: 이동 상태 (Movement State)\n *\n * Contains position, velocity, and current movement parameters.\n * All vectors are mutable for performance (updated in-place during physics loop).\n *\n * @category Physics System\n * @korean 이동상태\n */\nexport interface MovementState {\n /**\n * Current position in 3D space (meters).\n *\n * NOTE: This is a readonly reference to a mutable THREE.Vector3.\n * The physics engine intentionally mutates the vector in-place\n * (e.g. via position.add(...)) for performance. The readonly\n * modifier prevents reassignment of the Vector3 instance, not\n * mutation of its components.\n */\n readonly position: THREE.Vector3;\n /**\n * Current velocity vector (m/s).\n *\n * NOTE: This is a readonly reference to a mutable THREE.Vector3.\n * The physics engine updates this vector in-place each frame.\n * Callers must not reassign the velocity reference, but may pass\n * it to APIs that read or modify its components.\n */\n readonly velocity: THREE.Vector3;\n /** Current acceleration magnitude (m/s²) */\n acceleration: number;\n /** Maximum speed for current state (m/s) */\n maxSpeed: number;\n /** Current Eight Trigram stance */\n readonly currentStance: TrigramStance;\n /** Leg injury percentage (0-1, where 1 is fully injured) */\n legInjuryFactor: number;\n}\n\n/**\n * Stance-based speed modifiers for Eight Trigram system.\n *\n * **Korean**: 팔괘 속도 배수 (Eight Trigram Speed Multipliers)\n *\n * Each trigram stance has unique movement characteristics based on\n * traditional Korean martial arts philosophy:\n *\n * - ☰ 건 (Geon/Heaven): 100% - Balanced, standard speed\n * - ☱ 태 (Tae/Lake): 110% - Fluid movement, flowing techniques\n * - ☲ 리 (Li/Fire): 120% - Aggressive, fast attacks\n * - ☳ 진 (Jin/Thunder): 115% - Explosive power\n * - ☴ 손 (Son/Wind): 125% - Continuous motion, fastest stance\n * - ☵ 감 (Gam/Water): 105% - Adaptive, slightly faster than neutral\n * - ☶ 간 (Gan/Mountain): 80% - Solid defense, slower movement\n * - ☷ 곤 (Gon/Earth): 85% - Grounded, stable but slower\n *\n * @korean 자세속도배수\n */\nexport const STANCE_SPEED_MODIFIERS: Record<TrigramStance, number> = {\n [TrigramStance.GEON]: 1.0, // Heaven: balanced\n [TrigramStance.TAE]: 1.1, // Lake: fluid\n [TrigramStance.LI]: 1.2, // Fire: aggressive\n [TrigramStance.JIN]: 1.15, // Thunder: explosive\n [TrigramStance.SON]: 1.25, // Wind: fastest\n [TrigramStance.GAM]: 1.05, // Water: adaptive\n [TrigramStance.GAN]: 0.8, // Mountain: defensive\n [TrigramStance.GON]: 0.85, // Earth: grounded\n};\n\n/**\n * Physics-based movement engine for combat.\n *\n * **Korean**: 이동 물리 엔진 (Movement Physics Engine)\n *\n * Implements realistic acceleration, deceleration, and stance-based movement\n * for authentic Korean martial arts combat. Supports both continuous movement\n * and tactical foot-wide steps for precise positioning.\n *\n * @example\n * ```typescript\n * const physics = new MovementPhysics();\n *\n * const state: MovementState = {\n * position: new THREE.Vector3(0, 0, 0),\n * velocity: new THREE.Vector3(0, 0, 0),\n * acceleration: 0,\n * maxSpeed: 2.0,\n * currentStance: TrigramStance.GEON,\n * legInjuryFactor: 0,\n * };\n *\n * const input: MovementInput = {\n * forward: 1.0,\n * lateral: 0,\n * isRunning: false,\n * isMoving: true,\n * useTacticalSteps: false,\n * };\n *\n * // In game loop at 60fps\n * physics.updateMovement(state, input, deltaTime);\n * ```\n *\n * @category Physics System\n * @korean 이동물리엔진\n */\nexport class MovementPhysics {\n /**\n * Base acceleration rate (m/s²)\n * Achieves 0 to 6m/s in 0.2 seconds (instant-response combat movement)\n * Increased from 12.0 to 30.0 for arcade-style responsiveness\n *\n * Imported from physicsConstants.ts to maintain consistency across systems.\n *\n * **Korean**: 기본 가속도 (Base Acceleration)\n */\n private readonly BASE_ACCELERATION = BASE_MOVEMENT_ACCELERATION;\n\n /**\n * Base deceleration rate (m/s²)\n * Achieves 6m/s to 0 in 0.3 seconds (responsive combat stopping)\n *\n * **Korean**: 기본 감속도 (Base Deceleration)\n */\n private readonly BASE_DECELERATION = 20.0;\n\n /**\n * Foot-wide step size (meters)\n * Standard Korean martial arts step is approximately 30cm\n *\n * **Korean**: 보법 거리 (Step Distance)\n */\n private readonly STEP_SIZE = 0.3;\n\n /**\n * Base walking speed (m/s)\n * Optimized for responsive combat movement - crosses 14m arena in ~2.3s\n *\n * **Korean**: 기본 걷기 속도 (Base Walking Speed)\n */\n private readonly BASE_WALK_SPEED = 6.0;\n\n /**\n * Base running speed (m/s)\n * Sprint speed for rapid repositioning - crosses 14m arena in ~1.4s\n *\n * **Korean**: 기본 달리기 속도 (Base Running Speed)\n */\n private readonly BASE_RUN_SPEED = 10.0;\n\n /**\n * Reference arena size for speed calibration (meters).\n * All speeds are calibrated for a 10m arena.\n *\n * **Korean**: 기준 경기장 크기 (Reference Arena Size)\n */\n private readonly REFERENCE_ARENA_SIZE = 10.0;\n\n /**\n * Current arena width in meters for arena-aware speed scaling.\n *\n * **Korean**: 현재 경기장 너비 (Current Arena Width)\n */\n private _arenaWidthMeters: number = 10.0;\n\n // LATERAL_SPEED removed - now using state.maxSpeed for all directions\n // This ensures speed override applies to lateral movement too\n\n /**\n * Override for max speed from external speed modifier systems.\n *\n * **Korean**: 최대속도 재정의 (Max Speed Override)\n */\n private _overrideMaxSpeed: number | null = null;\n\n /**\n * Override for acceleration from external speed modifier systems.\n *\n * **Korean**: 가속도 재정의 (Acceleration Override)\n */\n private _overrideAcceleration: number | null = null;\n\n /**\n * Cached arena speed scale to avoid repeated calculations.\n *\n * **Korean**: 캐시된 경기장 속도 배수 (Cached Arena Speed Scale)\n */\n private _cachedArenaSpeedScale: number = 1.0;\n\n // Temporary vectors to avoid allocations in update loop\n private readonly tempTargetVelocity = new THREE.Vector3();\n private readonly tempMovement = new THREE.Vector3();\n private readonly tempDirection = new THREE.Vector3();\n private readonly tempTargetDirection = new THREE.Vector3();\n\n /**\n * Create a new MovementPhysics instance.\n *\n * **Korean**: 이동 물리 생성 (Create Movement Physics)\n *\n * @param arenaWidthMeters - Width of the arena in meters (default: 10m, min: 1m)\n * @throws {Error} If arenaWidthMeters is not a positive number\n *\n * @example\n * ```typescript\n * // Default 10m arena (1.0x speed scale)\n * const physics = new MovementPhysics();\n *\n * // Small 6m arena (0.7x speed scale)\n * const smallPhysics = new MovementPhysics(6.0);\n *\n * // Large 14m arena (1.3x speed scale)\n * const largePhysics = new MovementPhysics(14.0);\n * ```\n *\n */\n constructor(arenaWidthMeters: number = 10.0) {\n if (arenaWidthMeters <= 0 || !Number.isFinite(arenaWidthMeters)) {\n throw new Error(\n `Arena width must be a positive finite number, got: ${arenaWidthMeters}`,\n );\n }\n this._arenaWidthMeters = arenaWidthMeters;\n this._cachedArenaSpeedScale = this.calculateArenaSpeedScale();\n }\n\n /**\n * Calculate arena-aware speed scaling factor.\n *\n * **Korean**: 경기장 크기 기반 속도 배수 (Arena-Based Speed Multiplier)\n *\n * Scales movement speed proportionally to arena size to maintain consistent\n * gameplay feel across different screen resolutions. Smaller arenas get\n * slightly slower speeds, larger arenas get slightly faster speeds.\n *\n * Formula: scaleFactor = arenaWidth / referenceArenaSize\n * Clamped to [0.7, 1.3] range for balanced gameplay\n *\n * Examples:\n * - 6m arena: 0.7x speed (70% of base)\n * - 10m arena: 1.0x speed (baseline)\n * - 14m arena: 1.3x speed (130% of base)\n *\n * @returns Speed multiplier (0.7 to 1.3)\n *\n * @korean 경기장속도배수\n */\n private calculateArenaSpeedScale(): number {\n const rawScale = this._arenaWidthMeters / this.REFERENCE_ARENA_SIZE;\n // Clamp to reasonable range to maintain gameplay balance\n return Math.max(0.7, Math.min(1.3, rawScale));\n }\n\n /**\n * Update player movement based on input and physics.\n *\n * **Korean**: 이동 업데이트 (Update Movement)\n *\n * Called every frame (60fps) to update player position based on\n * current velocity, acceleration, and input. Applies stance modifiers\n * and injury penalties automatically.\n *\n * @param state - Current movement state (modified in-place)\n * @param input - Current movement input from controls\n * @param deltaTime - Time since last update (seconds)\n * @param bounds - Optional arena bounds for clamping position (meters)\n *\n * @korean 이동업데이트\n */\n public updateMovement(\n state: MovementState,\n input: MovementInput,\n deltaTime: number,\n bounds?: MovementArenaBounds,\n ): void {\n // Use cached arena-aware speed scaling\n const arenaSpeedScale = this._cachedArenaSpeedScale;\n\n // Calculate stance speed modifier\n const stanceModifier = this.getStanceSpeedModifier(state.currentStance);\n\n // Calculate injury penalty (0-50% speed reduction)\n const injuryPenalty = 1.0 - state.legInjuryFactor * 0.5;\n\n // Calculate base target speed (walking or running)\n const baseSpeed = input.isRunning\n ? this.BASE_RUN_SPEED\n : this.BASE_WALK_SPEED;\n\n // Apply all modifiers including arena scaling to get final max speed (or use override)\n state.maxSpeed =\n this._overrideMaxSpeed ??\n baseSpeed * arenaSpeedScale * stanceModifier * injuryPenalty;\n\n // Use override acceleration if set, otherwise use base\n const currentAcceleration =\n this._overrideAcceleration ?? this.BASE_ACCELERATION;\n\n // Calculate target velocity based on input direction\n // forward > 0 = moving in positive Z direction (toward bottom of screen)\n // forward < 0 = moving in negative Z direction (toward top of screen)\n // ✅ REMOVED backward multiplier: All directions use full speed for responsive gameplay\n // The backward penalty should be applied contextually by the combat system\n // based on player facing direction vs movement direction\n // ✅ FIX: Both lateral and forward now use state.maxSpeed (which includes all modifiers)\n // This ensures consistent speed in all movement directions and includes arena scaling\n this.tempTargetVelocity.set(\n input.lateral * state.maxSpeed,\n 0,\n input.forward * state.maxSpeed,\n );\n\n // Apply acceleration or deceleration\n if (input.isMoving) {\n // Accelerate toward target velocity with realistic direction changes\n const currentSpeed = state.velocity.length();\n const targetSpeed = this.tempTargetVelocity.length();\n\n if (currentSpeed < targetSpeed) {\n // Check if direction change is needed\n if (currentSpeed > 0.001 && targetSpeed > 0.001) {\n // Current movement direction\n this.tempDirection.copy(state.velocity).normalize();\n // Desired movement direction (reuse temp vector to avoid allocation)\n this.tempTargetDirection.copy(this.tempTargetVelocity).normalize();\n const directionDot = this.tempDirection.dot(this.tempTargetDirection);\n\n if (directionDot < 0) {\n // Moving in opposite direction: decelerate first before reversing\n const velocityDelta = this.BASE_DECELERATION * deltaTime;\n const newSpeed = Math.max(currentSpeed - velocityDelta, 0);\n\n if (newSpeed > 0.001) {\n state.velocity.copy(this.tempDirection.multiplyScalar(newSpeed));\n } else {\n // Fully stopped; can now start accelerating in new direction\n state.velocity.set(0, 0, 0);\n }\n state.acceleration = -this.BASE_DECELERATION;\n } else if (directionDot < 0.7) {\n // Perpendicular direction change (e.g., forward to strafe): moderate deceleration\n const blendedAccel = currentAcceleration * 0.6; // Reduced acceleration for sharp turns\n const velocityDelta = blendedAccel * deltaTime;\n const newSpeed = Math.min(\n currentSpeed + velocityDelta,\n targetSpeed,\n );\n this.tempDirection.copy(this.tempTargetVelocity).normalize();\n state.velocity.copy(this.tempDirection.multiplyScalar(newSpeed));\n state.acceleration = blendedAccel;\n } else {\n // Same or similar direction: full acceleration\n this.tempDirection.copy(this.tempTargetVelocity).normalize();\n const velocityDelta = currentAcceleration * deltaTime;\n const newSpeed = Math.min(\n currentSpeed + velocityDelta,\n targetSpeed,\n );\n state.velocity.copy(this.tempDirection.multiplyScalar(newSpeed));\n state.acceleration = currentAcceleration;\n }\n } else {\n // Very low speed: safe to accelerate directly toward target\n this.tempDirection.copy(this.tempTargetVelocity).normalize();\n const velocityDelta = currentAcceleration * deltaTime;\n const newSpeed = Math.min(currentSpeed + velocityDelta, targetSpeed);\n state.velocity.copy(this.tempDirection.multiplyScalar(newSpeed));\n state.acceleration = currentAcceleration;\n }\n } else {\n // Already at or above target speed: snap to target velocity\n state.velocity.copy(this.tempTargetVelocity);\n state.acceleration = 0;\n }\n } else {\n // Decelerate to stop\n const currentSpeed = state.velocity.length();\n if (currentSpeed > 0.01) {\n this.tempDirection.copy(state.velocity).normalize();\n const velocityDelta = this.BASE_DECELERATION * deltaTime;\n const newSpeed = Math.max(currentSpeed - velocityDelta, 0);\n state.velocity.copy(this.tempDirection.multiplyScalar(newSpeed));\n } else {\n state.velocity.set(0, 0, 0);\n }\n state.acceleration = -this.BASE_DECELERATION;\n }\n\n // Calculate movement delta for this frame\n this.tempMovement.copy(state.velocity).multiplyScalar(deltaTime);\n\n // Apply tactical step quantization if enabled\n if (input.useTacticalSteps) {\n // Quantize to 30cm grid steps\n this.tempMovement.x =\n Math.round(this.tempMovement.x / this.STEP_SIZE) * this.STEP_SIZE;\n this.tempMovement.z =\n Math.round(this.tempMovement.z / this.STEP_SIZE) * this.STEP_SIZE;\n }\n\n // Update position\n state.position.add(this.tempMovement);\n\n // Apply arena bounds clamping if bounds provided\n if (bounds) {\n // Check if position exceeded boundaries\n const exceededMinX = state.position.x < bounds.minX;\n const exceededMaxX = state.position.x > bounds.maxX;\n const exceededMinZ = state.position.z < bounds.minZ;\n const exceededMaxZ = state.position.z > bounds.maxZ;\n\n // Clamp position to arena boundaries\n state.position.x = Math.max(bounds.minX, Math.min(bounds.maxX, state.position.x));\n state.position.z = Math.max(bounds.minZ, Math.min(bounds.maxZ, state.position.z));\n\n // Zero velocity component if exceeded boundary (smooth stopping)\n if (exceededMinX || exceededMaxX) {\n state.velocity.x = 0;\n }\n if (exceededMinZ || exceededMaxZ) {\n state.velocity.z = 0;\n }\n }\n }\n\n /**\n * Get speed modifier for a specific trigram stance.\n *\n * **Korean**: 자세 속도 배수 가져오기 (Get Stance Speed Modifier)\n *\n * @param stance - Eight Trigram stance\n * @returns Speed multiplier (0.8 to 1.25)\n *\n * @korean 자세속도배수\n */\n public getStanceSpeedModifier(stance: TrigramStance): number {\n return STANCE_SPEED_MODIFIERS[stance];\n }\n\n /**\n * Calculate movement penalty from leg injury.\n *\n * **Korean**: 부상 이동 페널티 (Injury Movement Penalty)\n *\n * Leg damage reduces movement speed by 10-50% based on injury severity.\n *\n * @param legHealthPercentage - Remaining leg health (0-1)\n * @returns Injury factor (0 = no injury, 1 = maximum injury)\n *\n * @korean 부상페널티\n */\n public calculateInjuryPenalty(legHealthPercentage: number): number {\n // Injury penalty scales from 0% (healthy) to 50% (critical)\n // Clamp between 0 and 1\n const healthFactor = Math.max(0, Math.min(1, legHealthPercentage));\n return 1.0 - healthFactor;\n }\n\n /**\n * Get maximum speed for current state configuration.\n *\n * **Korean**: 최대 속도 계산 (Calculate Maximum Speed)\n *\n * @param isRunning - Whether running (vs walking)\n * @param stance - Current trigram stance\n * @param legInjuryFactor - Leg injury severity (0-1)\n * @returns Maximum speed in m/s (includes arena scaling)\n *\n * @korean 최대속도\n */\n public getMaxSpeed(\n isRunning: boolean,\n stance: TrigramStance,\n legInjuryFactor: number,\n ): number {\n const arenaSpeedScale = this._cachedArenaSpeedScale;\n const baseSpeed = isRunning ? this.BASE_RUN_SPEED : this.BASE_WALK_SPEED;\n const stanceModifier = this.getStanceSpeedModifier(stance);\n const injuryPenalty = 1.0 - legInjuryFactor * 0.5;\n return baseSpeed * arenaSpeedScale * stanceModifier * injuryPenalty;\n }\n\n /**\n * Calculate time required to reach target speed from current velocity.\n *\n * **Korean**: 가속 시간 (Acceleration Time)\n *\n * @param currentSpeed - Current speed magnitude (m/s)\n * @param targetSpeed - Desired speed magnitude (m/s)\n * @returns Time in seconds to reach target speed\n *\n * @korean 가속시간\n */\n public getAccelerationTime(\n currentSpeed: number,\n targetSpeed: number,\n ): number {\n const speedDiff = Math.abs(targetSpeed - currentSpeed);\n return speedDiff / this.BASE_ACCELERATION;\n }\n\n /**\n * Calculate stopping distance from current velocity.\n *\n * **Korean**: 제동 거리 (Braking Distance)\n *\n * @param currentSpeed - Current speed magnitude (m/s)\n * @returns Distance in meters required to stop\n *\n * @korean 제동거리\n */\n public getStoppingDistance(currentSpeed: number): number {\n // Using kinematic equation: d = v² / (2a)\n return (currentSpeed * currentSpeed) / (2 * this.BASE_DECELERATION);\n }\n\n /**\n * Get tactical step size.\n *\n * **Korean**: 보법 거리 (Step Distance)\n *\n * @returns Step size in meters (0.3m = 30cm)\n *\n * @korean 보법거리\n */\n public getStepSize(): number {\n return this.STEP_SIZE;\n }\n\n /**\n * Override maximum speed for external speed modifier systems.\n *\n * **Korean**: 최대 속도 설정 (Set Maximum Speed)\n *\n * Allows external systems (like SpeedModifierSystem) to override\n * the calculated maximum speed. This is applied in the next\n * updateMovement call.\n *\n * @param speed - Maximum speed in m/s\n *\n */\n public setMaxSpeed(speed: number): void {\n this._overrideMaxSpeed = speed;\n }\n\n /**\n * Override acceleration for external speed modifier systems.\n *\n * **Korean**: 가속도 설정 (Set Acceleration)\n *\n * Allows external systems (like SpeedModifierSystem) to override\n * the base acceleration rate. This is applied in the next\n * updateMovement call.\n *\n * @param acceleration - Acceleration in m/s²\n *\n */\n public setAcceleration(acceleration: number): void {\n this._overrideAcceleration = acceleration;\n }\n\n /**\n * Clear speed and acceleration overrides.\n *\n * **Korean**: 속도 재정의 해제 (Clear Speed Overrides)\n *\n * Resets movement to use default calculations without external\n * override values.\n *\n */\n public clearOverrides(): void {\n this._overrideMaxSpeed = null;\n this._overrideAcceleration = null;\n }\n\n /**\n * Set arena width for arena-aware speed scaling.\n *\n * **Korean**: 경기장 너비 설정 (Set Arena Width)\n *\n * Updates the arena width used for speed scaling calculations.\n * Call this when the arena size changes (e.g., screen resize).\n * Recalculates and caches the arena speed scale.\n *\n * @param widthMeters - Arena width in meters (must be positive)\n * @throws {Error} If widthMeters is not a positive number\n *\n */\n public setArenaWidth(widthMeters: number): void {\n if (widthMeters <= 0 || !Number.isFinite(widthMeters)) {\n throw new Error(\n `Arena width must be a positive finite number, got: ${widthMeters}`,\n );\n }\n this._arenaWidthMeters = widthMeters;\n this._cachedArenaSpeedScale = this.calculateArenaSpeedScale();\n }\n\n /**\n * Get current arena width.\n *\n * **Korean**: 경기장 너비 가져오기 (Get Arena Width)\n *\n * @returns Arena width in meters\n *\n */\n public getArenaWidth(): number {\n return this._arenaWidthMeters;\n }\n\n /**\n * Get current arena speed scale factor.\n *\n * **Korean**: 경기장 속도 배수 가져오기 (Get Arena Speed Scale)\n *\n * Returns the cached arena speed scale value.\n *\n * @returns Arena-based speed multiplier (0.7 to 1.3)\n *\n */\n public getArenaSpeedScale(): number {\n return this._cachedArenaSpeedScale;\n }\n}\n"],"mappings":"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"}
|
|
@@ -293,19 +293,20 @@ function getSafeAreaInsets() {
|
|
|
293
293
|
right: 0
|
|
294
294
|
};
|
|
295
295
|
const isLandscape = platform.screenWidth > platform.screenHeight;
|
|
296
|
-
if (platform.screenHeight >= 812 || platform.screenWidth >= 812)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
296
|
+
if (platform.screenHeight >= 812 || platform.screenWidth >= 812) {
|
|
297
|
+
if (isLandscape) return {
|
|
298
|
+
top: 0,
|
|
299
|
+
bottom: 21,
|
|
300
|
+
left: 44,
|
|
301
|
+
right: 44
|
|
302
|
+
};
|
|
303
|
+
else return {
|
|
304
|
+
top: 44,
|
|
305
|
+
bottom: 34,
|
|
306
|
+
left: 0,
|
|
307
|
+
right: 0
|
|
308
|
+
};
|
|
309
|
+
} else return {
|
|
309
310
|
top: 20,
|
|
310
311
|
bottom: 0,
|
|
311
312
|
left: 0,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deviceDetection.js","names":[],"sources":["../../src/utils/deviceDetection.ts"],"sourcesContent":["/**\n * Device Detection Utility\n *\n * Provides robust mobile device detection combining:\n * - User-agent string analysis\n * - Screen size detection\n * - Touch capability detection\n *\n * This ensures mobile controls are shown on all mobile devices,\n * including high-resolution phones that exceed typical mobile breakpoints.\n *\n * @module utils/deviceDetection\n * @category Mobile\n * @korean 기기감지유틸리티\n */\n\n/**\n * Device type classification\n */\nexport enum DeviceType {\n /** Desktop computer or laptop */\n DESKTOP = \"desktop\",\n /** Mobile phone (iOS, Android, etc.) */\n MOBILE = \"mobile\",\n /** Tablet device (iPad, Android tablets) */\n TABLET = \"tablet\",\n}\n\n/**\n * Platform detection results\n */\nexport interface PlatformInfo {\n /** Operating system type */\n readonly os: \"ios\" | \"android\" | \"windows\" | \"macos\" | \"linux\" | \"unknown\";\n /** Device type classification */\n readonly deviceType: DeviceType;\n /** Whether device has touch capability */\n readonly hasTouch: boolean;\n /** Whether device is mobile phone */\n readonly isMobile: boolean;\n /** Whether device is tablet */\n readonly isTablet: boolean;\n /** Whether device is desktop */\n readonly isDesktop: boolean;\n /** Screen width in pixels */\n readonly screenWidth: number;\n /** Screen height in pixels */\n readonly screenHeight: number;\n}\n\n/**\n * Detect if user-agent indicates a mobile device\n * Checks for common mobile device identifiers in user-agent string\n *\n * @param userAgent - Browser user-agent string\n * @returns True if user-agent indicates mobile device\n */\nfunction isMobileUserAgent(userAgent: string): boolean {\n const mobileKeywords = [\n \"Android\",\n \"webOS\",\n \"iPhone\",\n \"iPod\",\n \"BlackBerry\",\n \"IEMobile\",\n \"Opera Mini\",\n \"Mobile\",\n \"mobile\",\n ];\n\n return mobileKeywords.some((keyword) => userAgent.includes(keyword));\n}\n\n/**\n * Detect if user-agent indicates a tablet device\n *\n * @param userAgent - Browser user-agent string\n * @returns True if user-agent indicates tablet\n */\nfunction isTabletUserAgent(userAgent: string): boolean {\n if (userAgent.includes(\"iPad\")) {\n return true;\n }\n\n if (userAgent.includes(\"Android\")) {\n return userAgent.includes(\"Tablet\") || !userAgent.includes(\"Mobile\");\n }\n\n return false;\n}\n\n/**\n * Detect operating system from user-agent\n *\n * @param userAgent - Browser user-agent string\n * @returns Operating system identifier\n */\nfunction detectOS(userAgent: string): PlatformInfo[\"os\"] {\n if (\n userAgent.includes(\"iPhone\") ||\n userAgent.includes(\"iPad\") ||\n userAgent.includes(\"iPod\")\n ) {\n return \"ios\";\n }\n if (userAgent.includes(\"Android\")) {\n return \"android\";\n }\n if (userAgent.includes(\"Windows\")) {\n return \"windows\";\n }\n if (userAgent.includes(\"Mac\")) {\n const isLikelyIPadOSDesktop =\n typeof navigator !== \"undefined\" &&\n typeof navigator.maxTouchPoints === \"number\" &&\n navigator.maxTouchPoints > 1 &&\n userAgent.includes(\"Macintosh\");\n\n if (isLikelyIPadOSDesktop) {\n return \"ios\";\n }\n return \"macos\";\n }\n if (userAgent.includes(\"Linux\")) {\n return \"linux\";\n }\n return \"unknown\";\n}\n\n/**\n * Detect if device has touch capability\n * Uses multiple methods for reliability\n *\n * @returns True if touch is supported\n */\nfunction hasTouchSupport(): boolean {\n if (\"ontouchstart\" in window) {\n return true;\n }\n\n if (\n typeof navigator !== \"undefined\" &&\n typeof navigator.maxTouchPoints !== \"undefined\" &&\n navigator.maxTouchPoints > 0\n ) {\n return true;\n }\n\n if (\n typeof window !== \"undefined\" &&\n window.matchMedia?.(\"(pointer: coarse)\")?.matches\n ) {\n return true;\n }\n\n return false;\n}\n\n/**\n * Mobile screen size breakpoint\n * Devices with width <= this value are considered mobile by size\n */\nexport const MOBILE_BREAKPOINT = 768;\n\n/**\n * Tablet screen size breakpoint\n * Devices with width > MOBILE_BREAKPOINT and <= TABLET_BREAKPOINT are tablets\n */\nexport const TABLET_BREAKPOINT = 1024;\n\n/**\n * Cached CSS environment variable insets\n */\nlet cachedCSSEnvInsets: { top: number; bottom: number } | null = null;\n\n/**\n * Read CSS environment variables for safe area insets\n * Results are cached as they don't change during a session\n */\nfunction readCSSEnvInsets(): { top: number; bottom: number } | null {\n if (cachedCSSEnvInsets !== null) {\n return cachedCSSEnvInsets;\n }\n\n if (typeof window !== \"undefined\" && typeof getComputedStyle === \"function\") {\n try {\n const root = document.documentElement;\n const style = getComputedStyle(root);\n const topEnv = style.getPropertyValue(\"env(safe-area-inset-top)\");\n const bottomEnv = style.getPropertyValue(\"env(safe-area-inset-bottom)\");\n\n if (topEnv || bottomEnv) {\n const result = {\n top: parseInt(topEnv || \"0\", 10) || 0,\n bottom: parseInt(bottomEnv || \"0\", 10) || 0,\n };\n cachedCSSEnvInsets = result;\n return result;\n }\n } catch {\n // intentional: fall through to null\n }\n }\n\n return null;\n}\n\n/**\n * Cached platform information to avoid re-parsing user-agent on every call\n */\nlet cachedPlatform: PlatformInfo | null = null;\nlet cachedScreenWidth = 0;\nlet cachedScreenHeight = 0;\n\n/**\n * Clear the cached platform information\n * Useful when window is resized or device emulation changes\n * Also clears CSS environment variable cache\n *\n */\nexport function clearPlatformCache(): void {\n cachedPlatform = null;\n cachedScreenWidth = 0;\n cachedScreenHeight = 0;\n cachedCSSEnvInsets = null;\n}\n\n/**\n * Detect device type and platform information\n *\n * Combines multiple detection methods for reliability:\n * 1. User-agent string analysis (most reliable for device type)\n * 2. Screen dimensions\n * 3. Touch capability\n *\n * This ensures mobile controls are shown on:\n * - Standard mobile phones (< 768px width)\n * - High-resolution phones (>= 768px width but mobile user-agent)\n * - Android 15/16 devices with 2K/4K resolutions (1200px+, 1440px+)\n * - Tablets (user preference via touch support)\n *\n * **User-agent detection takes priority over screen size**, ensuring that\n * high-end Android phones with desktop-class resolutions (e.g., Galaxy S23 Ultra,\n * Pixel 9 Pro) are correctly identified as mobile devices.\n *\n * Results are cached to avoid re-parsing user-agent on every call.\n * Cache is invalidated when screen dimensions change.\n *\n * @returns Complete platform information\n *\n * @example\n * ```typescript\n * const platform = detectPlatform();\n *\n * if (platform.isMobile) {\n * // Show mobile controls even on 4K Android phones\n * return <MobileControls />;\n * }\n * ```\n *\n * @korean 플랫폼감지\n */\nexport function detectPlatform(): PlatformInfo {\n const userAgent = typeof navigator !== \"undefined\" ? navigator.userAgent : \"\";\n const screenWidth = typeof window !== \"undefined\" ? window.innerWidth : 1920;\n const screenHeight =\n typeof window !== \"undefined\" ? window.innerHeight : 1080;\n\n if (\n cachedPlatform !== null &&\n cachedScreenWidth === screenWidth &&\n cachedScreenHeight === screenHeight\n ) {\n return cachedPlatform;\n }\n\n const os = detectOS(userAgent);\n const hasTouch = hasTouchSupport();\n const isMobileUA = isMobileUserAgent(userAgent);\n const isTabletUA = isTabletUserAgent(userAgent);\n const isMobileBySize = screenWidth <= MOBILE_BREAKPOINT;\n const isTabletBySize =\n screenWidth > MOBILE_BREAKPOINT && screenWidth <= TABLET_BREAKPOINT;\n\n let deviceType: DeviceType;\n let isMobile: boolean;\n let isTablet: boolean;\n\n if (isMobileUA && !isTabletUA) {\n deviceType = DeviceType.MOBILE;\n isMobile = true;\n isTablet = false;\n } else if (isTabletUA) {\n deviceType = DeviceType.TABLET;\n isMobile = false;\n isTablet = true;\n } else if (isMobileBySize) {\n deviceType = DeviceType.MOBILE;\n isMobile = true;\n isTablet = false;\n } else if (isTabletBySize && hasTouch) {\n deviceType = DeviceType.TABLET;\n isMobile = false;\n isTablet = true;\n } else {\n deviceType = DeviceType.DESKTOP;\n isMobile = false;\n isTablet = false;\n }\n\n const isDesktop = deviceType === DeviceType.DESKTOP;\n\n const result: PlatformInfo = {\n os,\n deviceType,\n hasTouch,\n isMobile,\n isTablet,\n isDesktop,\n screenWidth,\n screenHeight,\n };\n\n cachedPlatform = result;\n cachedScreenWidth = screenWidth;\n cachedScreenHeight = screenHeight;\n\n return result;\n}\n\n/**\n * Simple mobile check for backward compatibility\n * Returns true for both mobile phones and tablets\n *\n * @returns True if device is mobile or tablet\n *\n * @korean 모바일확인\n */\nexport function isMobileDevice(): boolean {\n const platform = detectPlatform();\n return platform.isMobile || platform.isTablet;\n}\n\n/**\n * Check if device should use mobile controls\n * Takes into account device type, screen size, and touch capability\n *\n * Uses user-agent detection to correctly identify mobile devices regardless\n * of screen resolution. This ensures high-end Android 15/16 phones with\n * 2K/4K displays (1200px+, 1440px+) show mobile controls.\n *\n * Also ensures tablets and touch-enabled devices always show mobile controls\n * regardless of resolution, for better UX on touch devices.\n *\n * @returns True if mobile controls should be shown\n *\n * @example\n * ```typescript\n * // High-res Android phone (1440x3168) → returns true via user-agent\n * // Desktop with 1440px screen → returns false (no mobile user-agent)\n * // iPad Pro 12.9\" (1024x1366) → returns true (tablet user-agent)\n * // Surface Pro in tablet mode → returns true (touch + tablet size)\n * if (shouldUseMobileControls()) {\n * return <VirtualDPad />; // Touch-optimized controls\n * }\n * ```\n *\n * @korean 모바일컨트롤사용\n */\nexport function shouldUseMobileControls(): boolean {\n const platform = detectPlatform();\n\n if (platform.isMobile) {\n return true;\n }\n\n if (platform.isTablet) {\n return true;\n }\n\n if (platform.hasTouch && platform.screenWidth <= TABLET_BREAKPOINT) {\n return true;\n }\n\n if (platform.screenWidth <= MOBILE_BREAKPOINT && platform.hasTouch) {\n return true;\n }\n\n return false;\n}\n\n/**\n * Get safe area insets for device\n * Returns appropriate values based on device type and OS\n *\n * For iOS devices, attempts to detect if device has a notch by checking\n * screen dimensions. Falls back to CSS environment variables if available.\n *\n * @returns Safe area insets in pixels\n *\n * @korean 안전영역인셋\n */\nexport function getSafeAreaInsets() {\n const platform = detectPlatform();\n\n if (platform.os === \"ios\" && platform.isMobile) {\n const cssEnvInsets = readCSSEnvInsets();\n if (cssEnvInsets) {\n return {\n top: cssEnvInsets.top,\n bottom: cssEnvInsets.bottom,\n left: 0,\n right: 0,\n };\n }\n\n const isLandscape = platform.screenWidth > platform.screenHeight;\n\n const hasNotch =\n platform.screenHeight >= 812 || platform.screenWidth >= 812;\n\n if (hasNotch) {\n if (isLandscape) {\n return {\n top: 0,\n bottom: 21,\n left: 44,\n right: 44,\n };\n } else {\n return {\n top: 44,\n bottom: 34,\n left: 0,\n right: 0,\n };\n }\n } else {\n return {\n top: 20,\n bottom: 0,\n left: 0,\n right: 0,\n };\n }\n }\n\n if (platform.os === \"android\" && platform.isMobile) {\n return {\n top: 24,\n bottom: 0,\n left: 0,\n right: 0,\n };\n }\n\n return {\n top: 0,\n bottom: 0,\n left: 0,\n right: 0,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"deviceDetection.js","names":[],"sources":["../../src/utils/deviceDetection.ts"],"sourcesContent":["/**\n * Device Detection Utility\n *\n * Provides robust mobile device detection combining:\n * - User-agent string analysis\n * - Screen size detection\n * - Touch capability detection\n *\n * This ensures mobile controls are shown on all mobile devices,\n * including high-resolution phones that exceed typical mobile breakpoints.\n *\n * @module utils/deviceDetection\n * @category Mobile\n * @korean 기기감지유틸리티\n */\n\n/**\n * Device type classification\n */\nexport enum DeviceType {\n /** Desktop computer or laptop */\n DESKTOP = \"desktop\",\n /** Mobile phone (iOS, Android, etc.) */\n MOBILE = \"mobile\",\n /** Tablet device (iPad, Android tablets) */\n TABLET = \"tablet\",\n}\n\n/**\n * Platform detection results\n */\nexport interface PlatformInfo {\n /** Operating system type */\n readonly os: \"ios\" | \"android\" | \"windows\" | \"macos\" | \"linux\" | \"unknown\";\n /** Device type classification */\n readonly deviceType: DeviceType;\n /** Whether device has touch capability */\n readonly hasTouch: boolean;\n /** Whether device is mobile phone */\n readonly isMobile: boolean;\n /** Whether device is tablet */\n readonly isTablet: boolean;\n /** Whether device is desktop */\n readonly isDesktop: boolean;\n /** Screen width in pixels */\n readonly screenWidth: number;\n /** Screen height in pixels */\n readonly screenHeight: number;\n}\n\n/**\n * Detect if user-agent indicates a mobile device\n * Checks for common mobile device identifiers in user-agent string\n *\n * @param userAgent - Browser user-agent string\n * @returns True if user-agent indicates mobile device\n */\nfunction isMobileUserAgent(userAgent: string): boolean {\n const mobileKeywords = [\n \"Android\",\n \"webOS\",\n \"iPhone\",\n \"iPod\",\n \"BlackBerry\",\n \"IEMobile\",\n \"Opera Mini\",\n \"Mobile\",\n \"mobile\",\n ];\n\n return mobileKeywords.some((keyword) => userAgent.includes(keyword));\n}\n\n/**\n * Detect if user-agent indicates a tablet device\n *\n * @param userAgent - Browser user-agent string\n * @returns True if user-agent indicates tablet\n */\nfunction isTabletUserAgent(userAgent: string): boolean {\n if (userAgent.includes(\"iPad\")) {\n return true;\n }\n\n if (userAgent.includes(\"Android\")) {\n return userAgent.includes(\"Tablet\") || !userAgent.includes(\"Mobile\");\n }\n\n return false;\n}\n\n/**\n * Detect operating system from user-agent\n *\n * @param userAgent - Browser user-agent string\n * @returns Operating system identifier\n */\nfunction detectOS(userAgent: string): PlatformInfo[\"os\"] {\n if (\n userAgent.includes(\"iPhone\") ||\n userAgent.includes(\"iPad\") ||\n userAgent.includes(\"iPod\")\n ) {\n return \"ios\";\n }\n if (userAgent.includes(\"Android\")) {\n return \"android\";\n }\n if (userAgent.includes(\"Windows\")) {\n return \"windows\";\n }\n if (userAgent.includes(\"Mac\")) {\n const isLikelyIPadOSDesktop =\n typeof navigator !== \"undefined\" &&\n typeof navigator.maxTouchPoints === \"number\" &&\n navigator.maxTouchPoints > 1 &&\n userAgent.includes(\"Macintosh\");\n\n if (isLikelyIPadOSDesktop) {\n return \"ios\";\n }\n return \"macos\";\n }\n if (userAgent.includes(\"Linux\")) {\n return \"linux\";\n }\n return \"unknown\";\n}\n\n/**\n * Detect if device has touch capability\n * Uses multiple methods for reliability\n *\n * @returns True if touch is supported\n */\nfunction hasTouchSupport(): boolean {\n if (\"ontouchstart\" in window) {\n return true;\n }\n\n if (\n typeof navigator !== \"undefined\" &&\n typeof navigator.maxTouchPoints !== \"undefined\" &&\n navigator.maxTouchPoints > 0\n ) {\n return true;\n }\n\n if (\n typeof window !== \"undefined\" &&\n window.matchMedia?.(\"(pointer: coarse)\")?.matches\n ) {\n return true;\n }\n\n return false;\n}\n\n/**\n * Mobile screen size breakpoint\n * Devices with width <= this value are considered mobile by size\n */\nexport const MOBILE_BREAKPOINT = 768;\n\n/**\n * Tablet screen size breakpoint\n * Devices with width > MOBILE_BREAKPOINT and <= TABLET_BREAKPOINT are tablets\n */\nexport const TABLET_BREAKPOINT = 1024;\n\n/**\n * Cached CSS environment variable insets\n */\nlet cachedCSSEnvInsets: { top: number; bottom: number } | null = null;\n\n/**\n * Read CSS environment variables for safe area insets\n * Results are cached as they don't change during a session\n */\nfunction readCSSEnvInsets(): { top: number; bottom: number } | null {\n if (cachedCSSEnvInsets !== null) {\n return cachedCSSEnvInsets;\n }\n\n if (typeof window !== \"undefined\" && typeof getComputedStyle === \"function\") {\n try {\n const root = document.documentElement;\n const style = getComputedStyle(root);\n const topEnv = style.getPropertyValue(\"env(safe-area-inset-top)\");\n const bottomEnv = style.getPropertyValue(\"env(safe-area-inset-bottom)\");\n\n if (topEnv || bottomEnv) {\n const result = {\n top: parseInt(topEnv || \"0\", 10) || 0,\n bottom: parseInt(bottomEnv || \"0\", 10) || 0,\n };\n cachedCSSEnvInsets = result;\n return result;\n }\n } catch {\n // intentional: fall through to null\n }\n }\n\n return null;\n}\n\n/**\n * Cached platform information to avoid re-parsing user-agent on every call\n */\nlet cachedPlatform: PlatformInfo | null = null;\nlet cachedScreenWidth = 0;\nlet cachedScreenHeight = 0;\n\n/**\n * Clear the cached platform information\n * Useful when window is resized or device emulation changes\n * Also clears CSS environment variable cache\n *\n */\nexport function clearPlatformCache(): void {\n cachedPlatform = null;\n cachedScreenWidth = 0;\n cachedScreenHeight = 0;\n cachedCSSEnvInsets = null;\n}\n\n/**\n * Detect device type and platform information\n *\n * Combines multiple detection methods for reliability:\n * 1. User-agent string analysis (most reliable for device type)\n * 2. Screen dimensions\n * 3. Touch capability\n *\n * This ensures mobile controls are shown on:\n * - Standard mobile phones (< 768px width)\n * - High-resolution phones (>= 768px width but mobile user-agent)\n * - Android 15/16 devices with 2K/4K resolutions (1200px+, 1440px+)\n * - Tablets (user preference via touch support)\n *\n * **User-agent detection takes priority over screen size**, ensuring that\n * high-end Android phones with desktop-class resolutions (e.g., Galaxy S23 Ultra,\n * Pixel 9 Pro) are correctly identified as mobile devices.\n *\n * Results are cached to avoid re-parsing user-agent on every call.\n * Cache is invalidated when screen dimensions change.\n *\n * @returns Complete platform information\n *\n * @example\n * ```typescript\n * const platform = detectPlatform();\n *\n * if (platform.isMobile) {\n * // Show mobile controls even on 4K Android phones\n * return <MobileControls />;\n * }\n * ```\n *\n * @korean 플랫폼감지\n */\nexport function detectPlatform(): PlatformInfo {\n const userAgent = typeof navigator !== \"undefined\" ? navigator.userAgent : \"\";\n const screenWidth = typeof window !== \"undefined\" ? window.innerWidth : 1920;\n const screenHeight =\n typeof window !== \"undefined\" ? window.innerHeight : 1080;\n\n if (\n cachedPlatform !== null &&\n cachedScreenWidth === screenWidth &&\n cachedScreenHeight === screenHeight\n ) {\n return cachedPlatform;\n }\n\n const os = detectOS(userAgent);\n const hasTouch = hasTouchSupport();\n const isMobileUA = isMobileUserAgent(userAgent);\n const isTabletUA = isTabletUserAgent(userAgent);\n const isMobileBySize = screenWidth <= MOBILE_BREAKPOINT;\n const isTabletBySize =\n screenWidth > MOBILE_BREAKPOINT && screenWidth <= TABLET_BREAKPOINT;\n\n let deviceType: DeviceType;\n let isMobile: boolean;\n let isTablet: boolean;\n\n if (isMobileUA && !isTabletUA) {\n deviceType = DeviceType.MOBILE;\n isMobile = true;\n isTablet = false;\n } else if (isTabletUA) {\n deviceType = DeviceType.TABLET;\n isMobile = false;\n isTablet = true;\n } else if (isMobileBySize) {\n deviceType = DeviceType.MOBILE;\n isMobile = true;\n isTablet = false;\n } else if (isTabletBySize && hasTouch) {\n deviceType = DeviceType.TABLET;\n isMobile = false;\n isTablet = true;\n } else {\n deviceType = DeviceType.DESKTOP;\n isMobile = false;\n isTablet = false;\n }\n\n const isDesktop = deviceType === DeviceType.DESKTOP;\n\n const result: PlatformInfo = {\n os,\n deviceType,\n hasTouch,\n isMobile,\n isTablet,\n isDesktop,\n screenWidth,\n screenHeight,\n };\n\n cachedPlatform = result;\n cachedScreenWidth = screenWidth;\n cachedScreenHeight = screenHeight;\n\n return result;\n}\n\n/**\n * Simple mobile check for backward compatibility\n * Returns true for both mobile phones and tablets\n *\n * @returns True if device is mobile or tablet\n *\n * @korean 모바일확인\n */\nexport function isMobileDevice(): boolean {\n const platform = detectPlatform();\n return platform.isMobile || platform.isTablet;\n}\n\n/**\n * Check if device should use mobile controls\n * Takes into account device type, screen size, and touch capability\n *\n * Uses user-agent detection to correctly identify mobile devices regardless\n * of screen resolution. This ensures high-end Android 15/16 phones with\n * 2K/4K displays (1200px+, 1440px+) show mobile controls.\n *\n * Also ensures tablets and touch-enabled devices always show mobile controls\n * regardless of resolution, for better UX on touch devices.\n *\n * @returns True if mobile controls should be shown\n *\n * @example\n * ```typescript\n * // High-res Android phone (1440x3168) → returns true via user-agent\n * // Desktop with 1440px screen → returns false (no mobile user-agent)\n * // iPad Pro 12.9\" (1024x1366) → returns true (tablet user-agent)\n * // Surface Pro in tablet mode → returns true (touch + tablet size)\n * if (shouldUseMobileControls()) {\n * return <VirtualDPad />; // Touch-optimized controls\n * }\n * ```\n *\n * @korean 모바일컨트롤사용\n */\nexport function shouldUseMobileControls(): boolean {\n const platform = detectPlatform();\n\n if (platform.isMobile) {\n return true;\n }\n\n if (platform.isTablet) {\n return true;\n }\n\n if (platform.hasTouch && platform.screenWidth <= TABLET_BREAKPOINT) {\n return true;\n }\n\n if (platform.screenWidth <= MOBILE_BREAKPOINT && platform.hasTouch) {\n return true;\n }\n\n return false;\n}\n\n/**\n * Get safe area insets for device\n * Returns appropriate values based on device type and OS\n *\n * For iOS devices, attempts to detect if device has a notch by checking\n * screen dimensions. Falls back to CSS environment variables if available.\n *\n * @returns Safe area insets in pixels\n *\n * @korean 안전영역인셋\n */\nexport function getSafeAreaInsets() {\n const platform = detectPlatform();\n\n if (platform.os === \"ios\" && platform.isMobile) {\n const cssEnvInsets = readCSSEnvInsets();\n if (cssEnvInsets) {\n return {\n top: cssEnvInsets.top,\n bottom: cssEnvInsets.bottom,\n left: 0,\n right: 0,\n };\n }\n\n const isLandscape = platform.screenWidth > platform.screenHeight;\n\n const hasNotch =\n platform.screenHeight >= 812 || platform.screenWidth >= 812;\n\n if (hasNotch) {\n if (isLandscape) {\n return {\n top: 0,\n bottom: 21,\n left: 44,\n right: 44,\n };\n } else {\n return {\n top: 44,\n bottom: 34,\n left: 0,\n right: 0,\n };\n }\n } else {\n return {\n top: 20,\n bottom: 0,\n left: 0,\n right: 0,\n };\n }\n }\n\n if (platform.os === \"android\" && platform.isMobile) {\n return {\n top: 24,\n bottom: 0,\n left: 0,\n right: 0,\n };\n }\n\n return {\n top: 0,\n bottom: 0,\n left: 0,\n right: 0,\n };\n}\n"],"mappings":"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"}
|
package/lib/utils/inputSystem.js
CHANGED
|
@@ -72,8 +72,10 @@ function usePlayerMovement(config) {
|
|
|
72
72
|
}, [bounds]);
|
|
73
73
|
const arenaBounds = arenaBoundsResult.bounds;
|
|
74
74
|
useEffect(() => {
|
|
75
|
-
if (arenaBoundsResult.error)
|
|
76
|
-
|
|
75
|
+
if (arenaBoundsResult.error) {
|
|
76
|
+
if (bounds?.worldWidthMeters != null && bounds?.worldDepthMeters != null) console.warn("Failed to calculate arena bounds, using defaults:", arenaBoundsResult.error);
|
|
77
|
+
else console.error("Failed to calculate default arena bounds:", arenaBoundsResult.error);
|
|
78
|
+
}
|
|
77
79
|
}, [
|
|
78
80
|
arenaBoundsResult.error,
|
|
79
81
|
bounds?.worldWidthMeters,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inputSystem.js","names":[],"sources":["../../src/utils/inputSystem.ts"],"sourcesContent":["import { COMBAT_CONTROLS } from \"@/systems/types\";\nimport type { Position } from \"@/types/common\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport * as THREE from \"three\";\nimport type { MovementInput } from \"../systems/physics/MovementPhysics\";\nimport { MovementPhysics } from \"../systems/physics/MovementPhysics\";\nimport { TrigramStance } from \"../types/common\";\nimport { calculateArenaBounds, DEFAULT_PHYSICS_ARENA_BOUNDS } from \"../types/PhysicsTypes\";\nimport type { MovementArenaBounds } from \"../types/PhysicsTypes\";\n\n/**\n * Configuration interface for the input system and player movement.\n * Uses physics-first approach: all positions and velocities are in meters.\n *\n * **Korean**: 입력 시스템 설정 (Input System Configuration)\n *\n * ## Physics-First Architecture\n *\n * This interface requires worldWidthMeters and worldDepthMeters to enable\n * the new physics-first coordinate system. Without these properties, the\n * movement system cannot properly convert between physics (meters) and\n * rendering (pixels).\n *\n * ### Migration Guide\n *\n * Existing code must be updated to pass world dimensions:\n *\n * ```typescript\n * // Before (incorrect):\n * const config = { bounds: { x: 0, y: 0, width: 960, height: 480 } };\n *\n * // After (correct):\n * const config = {\n * bounds: {\n * worldWidthMeters: 10, // From layout hook\n * worldDepthMeters: 10 // From layout hook\n * }\n * };\n * ```\n *\n * ### Fallback Behavior\n *\n * If worldWidthMeters/worldDepthMeters are not provided, the system falls back\n * to DEFAULT_PHYSICS_ARENA_BOUNDS (10m × 7.5m) to ensure movement stays bounded.\n * Callers SHOULD provide these values from their layout hooks (useCombatLayout, \n * useTrainingLayout) for proper arena sizing.\n */\nexport interface InputSystemConfig {\n /** Whether the input system is enabled and processing input */\n readonly enabled?: boolean;\n\n /**\n * Arena world dimensions in meters for physics calculations.\n *\n * **REQUIRED for physics-first coordinate system to work.**\n *\n * These values must come from layout hooks:\n * - CombatScreen3D: Use arenaBounds.worldWidthMeters/worldDepthMeters from useCombatLayout()\n * - TrainingScreen3D: Use trainingAreaBounds.worldWidthMeters/worldDepthMeters from useTrainingLayout()\n */\n readonly bounds?: {\n /** Physical arena width in meters (e.g., 6m mobile, 10m desktop, 14m 4K) */\n readonly worldWidthMeters: number;\n /** Physical arena depth in meters (e.g., 6m mobile, 10m desktop, 14m 4K) */\n readonly worldDepthMeters: number;\n };\n\n /** Callback invoked when player position changes (position in meters) */\n readonly onPositionChange?: (position: Position) => void;\n\n /** Initial player position in METERS (x = lateral, y = forward/backward) */\n readonly initialPositionMeters?: Position;\n\n // Physics-based movement parameters (always enabled)\n /** Current trigram stance affecting movement speed */\n readonly currentStance?: TrigramStance;\n\n /** Leg injury factor (0-1, where 1 is fully injured) affecting movement speed */\n readonly legInjuryFactor?: number;\n\n /** Whether player is running (sprint mode) */\n readonly isRunning?: boolean;\n\n /** Whether to use tactical step mode (30cm grid quantization) */\n readonly useTacticalSteps?: boolean;\n\n // Speed modifier overrides from SpeedModifierSystem\n /** Final calculated maximum speed in meters per second */\n readonly maxSpeedOverride?: number;\n\n /** Final calculated acceleration in meters per second squared */\n readonly accelerationOverride?: number;\n}\n\nexport interface MovementState {\n readonly up: boolean;\n readonly down: boolean;\n readonly left: boolean;\n readonly right: boolean;\n readonly position: Position;\n readonly isMoving: boolean; // Add isMoving to movement state\n}\n\nexport interface PlayerMovementResult {\n /** Player position in METERS (x = lateral, y = forward/backward in arena) */\n readonly playerPosition: Position;\n readonly movementState: MovementState;\n readonly isMoving: boolean;\n readonly isKeyPressed: (key: string) => boolean;\n /** Velocity in m/s (x = lateral, y = forward/backward) */\n readonly velocity?: { x: number; y: number };\n /** Current speed magnitude in m/s */\n readonly speed?: number;\n}\n\n/**\n * Hook for handling player movement with physics-first approach.\n * All positions and velocities are in METERS - no pixel conversions.\n *\n * **Korean**: 플레이어 이동 훅 (Player Movement Hook)\n *\n * @param config - Physics-first configuration with positions in meters\n * @returns Movement state and physics data (all in meters)\n */\nexport function usePlayerMovement(\n config: InputSystemConfig,\n): PlayerMovementResult {\n const {\n enabled = true,\n bounds,\n onPositionChange,\n initialPositionMeters = { x: 0, y: 0 },\n currentStance = TrigramStance.GEON,\n legInjuryFactor = 0,\n isRunning: isRunningProp = false,\n useTacticalSteps = false,\n maxSpeedOverride,\n accelerationOverride,\n } = config;\n\n // Position in METERS (x = lateral position, y = forward/backward position)\n const [playerPosition, setPlayerPosition] = useState<Position>(\n initialPositionMeters,\n );\n const [keyState, setKeyState] = useState({\n up: false,\n down: false,\n left: false,\n right: false,\n });\n // Physics state for render (velocity and speed in m/s)\n const [velocity, setVelocity] = useState<\n { x: number; y: number } | undefined\n >(undefined);\n const [speed, setSpeed] = useState<number | undefined>(undefined);\n\n // Auto-run detection: track how long movement keys have been held\n // After sustained movement, automatically transition from walking to running\n const movementStartTimeRef = useRef<number | null>(null);\n const AUTO_RUN_THRESHOLD_MS = 300; // Transition to run after 300ms of sustained movement\n\n // Physics-based movement state (always initialized for realistic combat)\n const physicsEngineRef = useRef<MovementPhysics | null>(null);\n const physicsStateRef = useRef<{\n position: THREE.Vector3;\n velocity: THREE.Vector3;\n acceleration: number;\n maxSpeed: number;\n currentStance: TrigramStance;\n legInjuryFactor: number;\n } | null>(null);\n\n // Initialize physics engine once on mount (always enabled)\n // All positions are in METERS - no pixel conversion needed\n useEffect(() => {\n if (!physicsEngineRef.current) {\n // Use arena width for physics-aware speed scaling\n // Validate and fall back to default if invalid\n const width = bounds?.worldWidthMeters;\n const arenaWidth =\n width != null && Number.isFinite(width) && width > 0\n ? width\n : DEFAULT_PHYSICS_ARENA_BOUNDS.worldWidthMeters;\n physicsEngineRef.current = new MovementPhysics(arenaWidth);\n // Initial position in meters (x = lateral, z = forward/backward)\n physicsStateRef.current = {\n position: new THREE.Vector3(\n initialPositionMeters.x,\n 0,\n initialPositionMeters.y,\n ),\n velocity: new THREE.Vector3(0, 0, 0),\n acceleration: 0,\n maxSpeed: 6.0, // Default to BASE_WALK_SPEED (6.0 m/s for responsive combat)\n currentStance,\n legInjuryFactor: legInjuryFactor ?? 0,\n };\n }\n }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n // Compute arena bounds synchronously when bounds dimensions change\n // Uses useMemo to ensure bounds are available immediately (not after effect runs)\n // Falls back to default arena bounds if invalid or missing\n // Depend on the whole `bounds` object so the compiler's inferred property-access\n // dependencies (bounds.worldWidthMeters / bounds.worldDepthMeters) are covered.\n const arenaBoundsResult = useMemo<{\n bounds: MovementArenaBounds | undefined;\n error?: Error;\n }>(() => {\n if (bounds?.worldWidthMeters != null && bounds?.worldDepthMeters != null) {\n try {\n return {\n bounds: calculateArenaBounds(\n {\n worldWidthMeters: bounds.worldWidthMeters,\n worldDepthMeters: bounds.worldDepthMeters,\n },\n 0.3 // 0.3m character radius\n ),\n };\n } catch (error) {\n // If validation fails, fall back to default bounds\n // Error will be logged in useEffect to keep render pure\n return {\n bounds: undefined,\n error: error instanceof Error ? error : new Error(String(error)),\n };\n }\n }\n\n // Fallback: use default arena bounds to ensure movement stays bounded\n try {\n return {\n bounds: calculateArenaBounds(\n {\n worldWidthMeters: DEFAULT_PHYSICS_ARENA_BOUNDS.worldWidthMeters,\n worldDepthMeters: DEFAULT_PHYSICS_ARENA_BOUNDS.worldDepthMeters,\n },\n 0.3 // 0.3m character radius\n ),\n };\n } catch (error) {\n // Should never happen with default bounds, but handle gracefully\n // Error will be logged in useEffect to keep render pure\n return {\n bounds: undefined,\n error: error instanceof Error ? error : new Error(String(error)),\n };\n }\n }, [bounds]);\n\n const arenaBounds = arenaBoundsResult.bounds;\n\n // Log arena bounds calculation errors in an effect (not during render)\n useEffect(() => {\n if (arenaBoundsResult.error) {\n if (bounds?.worldWidthMeters != null && bounds?.worldDepthMeters != null) {\n // Custom bounds failed validation\n console.warn(\n \"Failed to calculate arena bounds, using defaults:\",\n arenaBoundsResult.error\n );\n } else {\n // Should never happen with default bounds\n console.error(\n \"Failed to calculate default arena bounds:\",\n arenaBoundsResult.error\n );\n }\n }\n }, [arenaBoundsResult.error, bounds?.worldWidthMeters, bounds?.worldDepthMeters]);\n\n // Update physics engine arena width when bounds change (legacy)\n useEffect(() => {\n if (!physicsEngineRef.current) {\n return;\n }\n\n const width = bounds?.worldWidthMeters;\n if (width == null) {\n return;\n }\n\n // Validate width before applying to physics engine to avoid runtime errors\n if (!Number.isFinite(width) || width <= 0) {\n console.warn(\n \"Ignoring invalid worldWidthMeters when updating arena width:\",\n width,\n );\n return;\n }\n\n try {\n physicsEngineRef.current.setArenaWidth(width);\n } catch (error) {\n console.warn(\"Failed to update physics arena width:\", error);\n }\n }, [bounds?.worldWidthMeters]);\n\n // Track pressed keys for combat system\n const pressedKeys = useRef<Set<string>>(new Set());\n // Use useState lazy initializer for performance.now() to avoid impure function during render\n const [initialTime] = useState(() => performance.now());\n const lastUpdateTime = useRef(initialTime);\n const animationFrameId = useRef<number | null>(null);\n\n // Refs to track last reported position/velocity to avoid useCallback dependency issues\n // This prevents the animation frame from being cancelled every frame due to callback recreation\n const lastReportedPositionRef = useRef<Position>(initialPositionMeters);\n const lastReportedVelocityRef = useRef<{ x: number; y: number } | undefined>(\n undefined,\n );\n const lastReportedSpeedRef = useRef<number | undefined>(undefined);\n\n // Ref to track keyState for physics loop - avoids recreating callback on key changes\n const keyStateRef = useRef({\n up: false,\n down: false,\n left: false,\n right: false,\n });\n\n // Calculate if currently moving\n const isMoving =\n keyState.up || keyState.down || keyState.left || keyState.right;\n\n // Create complete movement state\n const movementState: MovementState = {\n ...keyState,\n position: playerPosition,\n isMoving,\n };\n\n // Key press checker for combat system\n const isKeyPressed = useCallback((key: string): boolean => {\n return pressedKeys.current.has(key);\n }, []);\n\n // Enhanced keyboard event handlers\n const handleKeyDown = useCallback(\n (event: KeyboardEvent) => {\n if (!enabled) return;\n\n const key = event.key.toLowerCase();\n pressedKeys.current.add(key);\n\n // ✅ FIXED: Add all movement keys including WASD and arrows\n // Update both ref (for physics loop) and state (for React re-render)\n switch (key) {\n case \"w\":\n case \"arrowup\":\n keyStateRef.current.up = true;\n setKeyState((prev) => ({ ...prev, up: true }));\n event.preventDefault();\n break;\n case \"s\":\n case \"arrowdown\":\n keyStateRef.current.down = true;\n setKeyState((prev) => ({ ...prev, down: true }));\n event.preventDefault();\n break;\n case \"a\":\n case \"arrowleft\":\n keyStateRef.current.left = true;\n setKeyState((prev) => ({ ...prev, left: true }));\n event.preventDefault();\n break;\n case \"d\":\n case \"arrowright\":\n keyStateRef.current.right = true;\n setKeyState((prev) => ({ ...prev, right: true }));\n event.preventDefault();\n break;\n }\n },\n [enabled],\n );\n\n const handleKeyUp = useCallback(\n (event: KeyboardEvent) => {\n if (!enabled) return;\n\n const key = event.key.toLowerCase();\n pressedKeys.current.delete(key);\n\n // ✅ FIXED: Handle key release for all movement keys\n // Update both ref (for physics loop) and state (for React re-render)\n switch (key) {\n case \"w\":\n case \"arrowup\":\n keyStateRef.current.up = false;\n setKeyState((prev) => ({ ...prev, up: false }));\n break;\n case \"s\":\n case \"arrowdown\":\n keyStateRef.current.down = false;\n setKeyState((prev) => ({ ...prev, down: false }));\n break;\n case \"a\":\n case \"arrowleft\":\n keyStateRef.current.left = false;\n setKeyState((prev) => ({ ...prev, left: false }));\n break;\n case \"d\":\n case \"arrowright\":\n keyStateRef.current.right = false;\n setKeyState((prev) => ({ ...prev, right: false }));\n break;\n }\n },\n [enabled],\n );\n\n // ✅ FIXED: Proper movement calculation with correct bounds\n // Use a ref to store the callback to avoid reference before declaration issue\n const updatePositionRef = useRef<(() => void) | null>(null);\n\n const updatePosition = useCallback(() => {\n // Check if any movement keys are pressed using ref (not stale state)\n const keys = keyStateRef.current;\n const isCurrentlyMoving = keys.up || keys.down || keys.left || keys.right;\n\n if (!enabled || !isCurrentlyMoving) {\n animationFrameId.current = null;\n return;\n }\n\n const now = performance.now();\n const deltaTime = Math.min(now - (lastUpdateTime.current ?? now), 50);\n lastUpdateTime.current = now;\n\n if (deltaTime <= 0) {\n animationFrameId.current = requestAnimationFrame(() =>\n updatePositionRef.current?.(),\n );\n return;\n }\n\n // Physics-based movement (always enabled for realistic combat)\n if (physicsEngineRef.current && physicsStateRef.current) {\n // Apply speed modifiers if provided by SpeedModifierSystem\n // BUG FIX: Now properly passing maxSpeedOverride to physics engine\n if (maxSpeedOverride !== undefined) {\n physicsEngineRef.current.setMaxSpeed(maxSpeedOverride);\n }\n\n if (accelerationOverride !== undefined) {\n physicsEngineRef.current.setAcceleration(accelerationOverride);\n }\n\n // Convert key state to physics input (using ref to avoid callback recreation)\n // Screen coordinates: UP/W = toward top of screen, DOWN/S = toward bottom\n // Physics Z-axis: negative Z = toward top, positive Z = toward bottom\n const keys = keyStateRef.current;\n const forward = keys.up ? -1 : keys.down ? 1 : 0;\n const lateral = keys.right ? 1 : keys.left ? -1 : 0;\n const isCurrentlyMoving = forward !== 0 || lateral !== 0;\n\n // Auto-run detection: transition to running after sustained movement\n const now = performance.now();\n if (isCurrentlyMoving) {\n movementStartTimeRef.current ??= now;\n } else {\n movementStartTimeRef.current = null;\n }\n\n // Determine if player should be running (auto-run after threshold)\n const movementDuration = movementStartTimeRef.current\n ? now - movementStartTimeRef.current\n : 0;\n const shouldRun =\n isRunningProp || movementDuration > AUTO_RUN_THRESHOLD_MS;\n\n const physicsInput: MovementInput = {\n forward,\n lateral,\n isRunning: shouldRun,\n isMoving: isCurrentlyMoving,\n useTacticalSteps,\n };\n\n // Update physics state\n const state = physicsStateRef.current;\n state.currentStance = currentStance;\n state.legInjuryFactor = legInjuryFactor;\n\n // Clamp delta time to 1/30s (≈33.33ms) to match usePlayerMovement and prevent instability\n const clampedDeltaTimeMs = Math.min(deltaTime, 1000 / 30);\n\n // Use arena bounds computed via useMemo (available synchronously)\n physicsEngineRef.current.updateMovement(\n state,\n physicsInput,\n clampedDeltaTimeMs / 1000,\n arenaBounds, // Use memoized bounds\n );\n\n // Position in meters (x = lateral, y = forward/backward)\n const newPosition = { x: state.position.x, y: state.position.z };\n\n // Velocity in m/s (x = lateral, y = forward/backward)\n const newVelocity = { x: state.velocity.x, y: state.velocity.z };\n const newSpeed = state.velocity.length();\n\n // Use refs for comparison to avoid recreating callback on every frame\n // This prevents the animation frame from being cancelled due to useCallback recreation\n const lastPos = lastReportedPositionRef.current;\n if (newPosition.x !== lastPos.x || newPosition.y !== lastPos.y) {\n lastReportedPositionRef.current = newPosition;\n setPlayerPosition(newPosition);\n onPositionChange?.(newPosition);\n }\n\n // Update velocity and speed if changed (with epsilon tolerance for floating-point stability)\n const EPSILON = 0.001;\n const lastVel = lastReportedVelocityRef.current;\n const velocityChanged =\n !lastVel ||\n Math.abs(lastVel.x - newVelocity.x) > EPSILON ||\n Math.abs(lastVel.y - newVelocity.y) > EPSILON;\n if (velocityChanged) {\n lastReportedVelocityRef.current = newVelocity;\n setVelocity(newVelocity);\n }\n // Initialize speed when undefined, then update only on significant changes\n const lastSpd = lastReportedSpeedRef.current;\n if (lastSpd === undefined || Math.abs(lastSpd - newSpeed) > EPSILON) {\n lastReportedSpeedRef.current = newSpeed;\n setSpeed(newSpeed);\n }\n }\n\n // Continue animation if still moving (check ref, not stale closure)\n const stillMoving =\n keyStateRef.current.up ||\n keyStateRef.current.down ||\n keyStateRef.current.left ||\n keyStateRef.current.right;\n if (stillMoving) {\n animationFrameId.current = requestAnimationFrame(() =>\n updatePositionRef.current?.(),\n );\n } else {\n animationFrameId.current = null;\n }\n // NOTE: playerPosition, velocity, speed, keyState, isMoving intentionally excluded from deps\n // Using refs (lastReportedPositionRef, lastReportedVelocityRef, lastReportedSpeedRef, keyStateRef)\n // for comparison to prevent animation frame cancellation on every state update.\n // arenaBounds is computed from bounds and automatically updates when bounds changes\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [\n enabled,\n // playerPosition - excluded, using ref\n // keyState - excluded, using keyStateRef\n // isMoving - excluded, using keyStateRef for movement check\n // arenaBounds - excluded, derived from bounds (below)\n bounds,\n onPositionChange,\n currentStance,\n legInjuryFactor,\n isRunningProp,\n useTacticalSteps,\n // velocity - excluded, using ref\n // speed - excluded, using ref\n maxSpeedOverride,\n accelerationOverride,\n ]);\n\n // Keep updatePositionRef in sync via useEffect (not during render)\n useEffect(() => {\n updatePositionRef.current = updatePosition;\n }, [updatePosition]);\n\n // Handle keyboard input\n useEffect(() => {\n if (!enabled) return;\n\n window.addEventListener(\"keydown\", handleKeyDown);\n window.addEventListener(\"keyup\", handleKeyUp);\n\n return () => {\n window.removeEventListener(\"keydown\", handleKeyDown);\n window.removeEventListener(\"keyup\", handleKeyUp);\n if (animationFrameId.current) {\n cancelAnimationFrame(animationFrameId.current);\n }\n };\n }, [enabled, handleKeyDown, handleKeyUp]);\n\n // Start animation loop when movement begins\n useEffect(() => {\n if (isMoving && !animationFrameId.current) {\n lastUpdateTime.current = performance.now();\n // Use ref to avoid dependency on updatePosition callback\n animationFrameId.current = requestAnimationFrame(() => {\n updatePositionRef.current?.();\n });\n } else if (!isMoving && animationFrameId.current) {\n cancelAnimationFrame(animationFrameId.current);\n animationFrameId.current = null;\n }\n\n return () => {\n if (animationFrameId.current) {\n cancelAnimationFrame(animationFrameId.current);\n animationFrameId.current = null;\n }\n };\n // Only depend on isMoving - updatePositionRef is stable\n }, [isMoving]);\n\n return {\n playerPosition,\n movementState,\n isMoving,\n isKeyPressed,\n velocity,\n speed,\n };\n}\n\nexport interface InputEvent {\n readonly type: \"keydown\" | \"keyup\" | \"click\" | \"touchstart\" | \"touchend\";\n readonly key?: string;\n readonly target?: EventTarget | null;\n readonly timestamp: number;\n}\n\nexport interface CombatInput {\n readonly stanceChange?: TrigramStance;\n readonly attack?: boolean;\n readonly block?: boolean;\n readonly movement?: MovementState;\n readonly timestamp: number;\n}\n\n/**\n * Input system for combat controls\n */\nexport class InputSystem {\n private actionCallbacks = new Map<string, (() => void)[]>();\n private isEnabled = true;\n\n constructor() {\n this.setupEventListeners();\n }\n\n private setupEventListeners() {\n window.addEventListener(\"keydown\", this.handleKeyDown.bind(this));\n window.addEventListener(\"keyup\", this.handleKeyUp.bind(this));\n }\n\n private handleKeyDown(event: KeyboardEvent) {\n if (!this.isEnabled) return;\n\n const key = event.key;\n this.triggerAction(`keydown:${key}`);\n this.triggerAction(\"keydown\");\n }\n\n private handleKeyUp(event: KeyboardEvent) {\n if (!this.isEnabled) return;\n\n const key = event.key;\n this.triggerAction(`keyup:${key}`);\n this.triggerAction(\"keyup\");\n }\n\n registerAction(action: string, callback: () => void) {\n if (!this.actionCallbacks.has(action)) {\n this.actionCallbacks.set(action, []);\n }\n const callbacks = this.actionCallbacks.get(action);\n if (callbacks) {\n callbacks.push(callback);\n }\n }\n\n unregisterAction(action: string, callback?: () => void) {\n if (!this.actionCallbacks.has(action)) return;\n\n if (callback) {\n const callbacks = this.actionCallbacks.get(action);\n if (callbacks) {\n const index = callbacks.indexOf(callback);\n if (index > -1) {\n callbacks.splice(index, 1);\n }\n }\n } else {\n this.actionCallbacks.delete(action);\n }\n }\n\n clearActions() {\n this.actionCallbacks.clear();\n }\n\n isActionActive(action: string): boolean {\n return this.actionCallbacks.has(action);\n }\n\n enable() {\n this.isEnabled = true;\n }\n\n disable() {\n this.isEnabled = false;\n }\n\n private triggerAction(action: string) {\n const callbacks = this.actionCallbacks.get(action);\n if (callbacks) {\n callbacks.forEach((callback) => callback());\n }\n }\n\n destroy() {\n window.removeEventListener(\"keydown\", this.handleKeyDown.bind(this));\n window.removeEventListener(\"keyup\", this.handleKeyUp.bind(this));\n this.clearActions();\n }\n}\n\n/**\n * Get stance from keyboard input\n */\nexport function getStanceFromKey(key: string): TrigramStance | null {\n const stanceKey = key as keyof typeof COMBAT_CONTROLS.stanceControls;\n\n if (stanceKey in COMBAT_CONTROLS.stanceControls) {\n return COMBAT_CONTROLS.stanceControls[stanceKey].stance;\n }\n\n return null;\n}\n\n/**\n * Process combat input and return structured combat data\n */\nexport function processCombatInput(event: KeyboardEvent): CombatInput | null {\n const key = event.key;\n const timestamp = performance.now();\n\n // Check for stance change (1-8 keys)\n const stance = getStanceFromKey(key);\n if (stance) {\n return {\n stanceChange: stance,\n timestamp,\n };\n }\n\n // Check for combat actions\n switch (key.toLowerCase()) {\n case \" \": // Space for attack\n return {\n attack: true,\n timestamp,\n };\n case \"shift\":\n return {\n block: true,\n timestamp,\n };\n default:\n return null;\n }\n}\n\n/**\n * Hook for combat input handling\n */\nexport function useCombatInput(onCombatInput: (input: CombatInput) => void) {\n const isEnabled = useRef<boolean>(true);\n\n useEffect(() => {\n const handleKeyDown = (event: KeyboardEvent) => {\n if (!isEnabled.current) return;\n\n const combatInput = processCombatInput(event);\n if (combatInput) {\n onCombatInput(combatInput);\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n return () => window.removeEventListener(\"keydown\", handleKeyDown);\n }, [onCombatInput]);\n\n return {\n enable: () => {\n isEnabled.current = true;\n },\n disable: () => {\n isEnabled.current = false;\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;AA4HA,SAAgB,kBACd,QACsB;CACtB,MAAM,EACJ,UAAU,MACV,QACA,kBACA,wBAAwB;EAAE,GAAG;EAAG,GAAG;CAAE,GACrC,gBAAgB,cAAc,MAC9B,kBAAkB,GAClB,WAAW,gBAAgB,OAC3B,mBAAmB,OACnB,kBACA,yBACE;CAGJ,MAAM,CAAC,gBAAgB,qBAAqB,SAC1C,qBACF;CACA,MAAM,CAAC,UAAU,eAAe,SAAS;EACvC,IAAI;EACJ,MAAM;EACN,MAAM;EACN,OAAO;CACT,CAAC;CAED,MAAM,CAAC,UAAU,eAAe,SAE9B,KAAA,CAAS;CACX,MAAM,CAAC,OAAO,YAAY,SAA6B,KAAA,CAAS;CAIhE,MAAM,uBAAuB,OAAsB,IAAI;CACvD,MAAM,wBAAwB;CAG9B,MAAM,mBAAmB,OAA+B,IAAI;CAC5D,MAAM,kBAAkB,OAOd,IAAI;CAId,gBAAgB;EACd,IAAI,CAAC,iBAAiB,SAAS;GAG7B,MAAM,QAAQ,QAAQ;GACtB,MAAM,aACJ,SAAS,QAAQ,OAAO,SAAS,KAAK,KAAK,QAAQ,IAC/C,QACA,6BAA6B;GACnC,iBAAiB,UAAU,IAAI,gBAAgB,UAAU;GAEzD,gBAAgB,UAAU;IACxB,UAAU,IAAI,MAAM,QAClB,sBAAsB,GACtB,GACA,sBAAsB,CACxB;IACA,UAAU,IAAI,MAAM,QAAQ,GAAG,GAAG,CAAC;IACnC,cAAc;IACd,UAAU;IACV;IACA,iBAAiB,mBAAmB;GACtC;EACF;CACF,GAAG,CAAC,CAAC;CAOL,MAAM,oBAAoB,cAGjB;EACP,IAAI,QAAQ,oBAAoB,QAAQ,QAAQ,oBAAoB,MAClE,IAAI;GACF,OAAO,EACL,QAAQ,qBACN;IACE,kBAAkB,OAAO;IACzB,kBAAkB,OAAO;GAC3B,GACA,EACF,EACF;EACF,SAAS,OAAO;GAGd,OAAO;IACL,QAAQ,KAAA;IACR,OAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;GACjE;EACF;EAIF,IAAI;GACF,OAAO,EACL,QAAQ,qBACN;IACE,kBAAkB,6BAA6B;IAC/C,kBAAkB,6BAA6B;GACjD,GACA,EACF,EACF;EACF,SAAS,OAAO;GAGd,OAAO;IACL,QAAQ,KAAA;IACR,OAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;GACjE;EACF;CACF,GAAG,CAAC,MAAM,CAAC;CAEX,MAAM,cAAc,kBAAkB;CAGtC,gBAAgB;EACd,IAAI,kBAAkB,OACpB,IAAI,QAAQ,oBAAoB,QAAQ,QAAQ,oBAAoB,MAElE,QAAQ,KACN,qDACA,kBAAkB,KACpB;OAGA,QAAQ,MACN,6CACA,kBAAkB,KACpB;CAGN,GAAG;EAAC,kBAAkB;EAAO,QAAQ;EAAkB,QAAQ;CAAgB,CAAC;CAGhF,gBAAgB;EACd,IAAI,CAAC,iBAAiB,SACpB;EAGF,MAAM,QAAQ,QAAQ;EACtB,IAAI,SAAS,MACX;EAIF,IAAI,CAAC,OAAO,SAAS,KAAK,KAAK,SAAS,GAAG;GACzC,QAAQ,KACN,gEACA,KACF;GACA;EACF;EAEA,IAAI;GACF,iBAAiB,QAAQ,cAAc,KAAK;EAC9C,SAAS,OAAO;GACd,QAAQ,KAAK,yCAAyC,KAAK;EAC7D;CACF,GAAG,CAAC,QAAQ,gBAAgB,CAAC;CAG7B,MAAM,cAAc,uBAAoB,IAAI,IAAI,CAAC;CAEjD,MAAM,CAAC,eAAe,eAAe,YAAY,IAAI,CAAC;CACtD,MAAM,iBAAiB,OAAO,WAAW;CACzC,MAAM,mBAAmB,OAAsB,IAAI;CAInD,MAAM,0BAA0B,OAAiB,qBAAqB;CACtE,MAAM,0BAA0B,OAC9B,KAAA,CACF;CACA,MAAM,uBAAuB,OAA2B,KAAA,CAAS;CAGjE,MAAM,cAAc,OAAO;EACzB,IAAI;EACJ,MAAM;EACN,MAAM;EACN,OAAO;CACT,CAAC;CAGD,MAAM,WACJ,SAAS,MAAM,SAAS,QAAQ,SAAS,QAAQ,SAAS;CAG5D,MAAM,gBAA+B;EACnC,GAAG;EACH,UAAU;EACV;CACF;CAGA,MAAM,eAAe,aAAa,QAAyB;EACzD,OAAO,YAAY,QAAQ,IAAI,GAAG;CACpC,GAAG,CAAC,CAAC;CAGL,MAAM,gBAAgB,aACnB,UAAyB;EACxB,IAAI,CAAC,SAAS;EAEd,MAAM,MAAM,MAAM,IAAI,YAAY;EAClC,YAAY,QAAQ,IAAI,GAAG;EAI3B,QAAQ,KAAR;GACE,KAAK;GACL,KAAK;IACH,YAAY,QAAQ,KAAK;IACzB,aAAa,UAAU;KAAE,GAAG;KAAM,IAAI;IAAK,EAAE;IAC7C,MAAM,eAAe;IACrB;GACF,KAAK;GACL,KAAK;IACH,YAAY,QAAQ,OAAO;IAC3B,aAAa,UAAU;KAAE,GAAG;KAAM,MAAM;IAAK,EAAE;IAC/C,MAAM,eAAe;IACrB;GACF,KAAK;GACL,KAAK;IACH,YAAY,QAAQ,OAAO;IAC3B,aAAa,UAAU;KAAE,GAAG;KAAM,MAAM;IAAK,EAAE;IAC/C,MAAM,eAAe;IACrB;GACF,KAAK;GACL,KAAK;IACH,YAAY,QAAQ,QAAQ;IAC5B,aAAa,UAAU;KAAE,GAAG;KAAM,OAAO;IAAK,EAAE;IAChD,MAAM,eAAe;EAEzB;CACF,GACA,CAAC,OAAO,CACV;CAEA,MAAM,cAAc,aACjB,UAAyB;EACxB,IAAI,CAAC,SAAS;EAEd,MAAM,MAAM,MAAM,IAAI,YAAY;EAClC,YAAY,QAAQ,OAAO,GAAG;EAI9B,QAAQ,KAAR;GACE,KAAK;GACL,KAAK;IACH,YAAY,QAAQ,KAAK;IACzB,aAAa,UAAU;KAAE,GAAG;KAAM,IAAI;IAAM,EAAE;IAC9C;GACF,KAAK;GACL,KAAK;IACH,YAAY,QAAQ,OAAO;IAC3B,aAAa,UAAU;KAAE,GAAG;KAAM,MAAM;IAAM,EAAE;IAChD;GACF,KAAK;GACL,KAAK;IACH,YAAY,QAAQ,OAAO;IAC3B,aAAa,UAAU;KAAE,GAAG;KAAM,MAAM;IAAM,EAAE;IAChD;GACF,KAAK;GACL,KAAK;IACH,YAAY,QAAQ,QAAQ;IAC5B,aAAa,UAAU;KAAE,GAAG;KAAM,OAAO;IAAM,EAAE;EAErD;CACF,GACA,CAAC,OAAO,CACV;CAIA,MAAM,oBAAoB,OAA4B,IAAI;CAE1D,MAAM,iBAAiB,kBAAkB;EAEvC,MAAM,OAAO,YAAY;EACzB,MAAM,oBAAoB,KAAK,MAAM,KAAK,QAAQ,KAAK,QAAQ,KAAK;EAEpE,IAAI,CAAC,WAAW,CAAC,mBAAmB;GAClC,iBAAiB,UAAU;GAC3B;EACF;EAEA,MAAM,MAAM,YAAY,IAAI;EAC5B,MAAM,YAAY,KAAK,IAAI,OAAO,eAAe,WAAW,MAAM,EAAE;EACpE,eAAe,UAAU;EAEzB,IAAI,aAAa,GAAG;GAClB,iBAAiB,UAAU,4BACzB,kBAAkB,UAAU,CAC9B;GACA;EACF;EAGA,IAAI,iBAAiB,WAAW,gBAAgB,SAAS;GAGvD,IAAI,qBAAqB,KAAA,GACvB,iBAAiB,QAAQ,YAAY,gBAAgB;GAGvD,IAAI,yBAAyB,KAAA,GAC3B,iBAAiB,QAAQ,gBAAgB,oBAAoB;GAM/D,MAAM,OAAO,YAAY;GACzB,MAAM,UAAU,KAAK,KAAK,KAAK,KAAK,OAAO,IAAI;GAC/C,MAAM,UAAU,KAAK,QAAQ,IAAI,KAAK,OAAO,KAAK;GAClD,MAAM,oBAAoB,YAAY,KAAK,YAAY;GAGvD,MAAM,MAAM,YAAY,IAAI;GAC5B,IAAI,mBACF,qBAAqB,YAAY;QAEjC,qBAAqB,UAAU;GAIjC,MAAM,mBAAmB,qBAAqB,UAC1C,MAAM,qBAAqB,UAC3B;GAIJ,MAAM,eAA8B;IAClC;IACA;IACA,WALA,iBAAiB,mBAAmB;IAMpC,UAAU;IACV;GACF;GAGA,MAAM,QAAQ,gBAAgB;GAC9B,MAAM,gBAAgB;GACtB,MAAM,kBAAkB;GAGxB,MAAM,qBAAqB,KAAK,IAAI,WAAW,MAAO,EAAE;GAGxD,iBAAiB,QAAQ,eACvB,OACA,cACA,qBAAqB,KACrB,WACF;GAGA,MAAM,cAAc;IAAE,GAAG,MAAM,SAAS;IAAG,GAAG,MAAM,SAAS;GAAE;GAG/D,MAAM,cAAc;IAAE,GAAG,MAAM,SAAS;IAAG,GAAG,MAAM,SAAS;GAAE;GAC/D,MAAM,WAAW,MAAM,SAAS,OAAO;GAIvC,MAAM,UAAU,wBAAwB;GACxC,IAAI,YAAY,MAAM,QAAQ,KAAK,YAAY,MAAM,QAAQ,GAAG;IAC9D,wBAAwB,UAAU;IAClC,kBAAkB,WAAW;IAC7B,mBAAmB,WAAW;GAChC;GAGA,MAAM,UAAU;GAChB,MAAM,UAAU,wBAAwB;GAKxC,IAHE,CAAC,WACD,KAAK,IAAI,QAAQ,IAAI,YAAY,CAAC,IAAI,WACtC,KAAK,IAAI,QAAQ,IAAI,YAAY,CAAC,IAAI,SACnB;IACnB,wBAAwB,UAAU;IAClC,YAAY,WAAW;GACzB;GAEA,MAAM,UAAU,qBAAqB;GACrC,IAAI,YAAY,KAAA,KAAa,KAAK,IAAI,UAAU,QAAQ,IAAI,SAAS;IACnE,qBAAqB,UAAU;IAC/B,SAAS,QAAQ;GACnB;EACF;EAQA,IAJE,YAAY,QAAQ,MACpB,YAAY,QAAQ,QACpB,YAAY,QAAQ,QACpB,YAAY,QAAQ,OAEpB,iBAAiB,UAAU,4BACzB,kBAAkB,UAAU,CAC9B;OAEA,iBAAiB,UAAU;CAO/B,GAAG;EACD;EAKA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;CACF,CAAC;CAGD,gBAAgB;EACd,kBAAkB,UAAU;CAC9B,GAAG,CAAC,cAAc,CAAC;CAGnB,gBAAgB;EACd,IAAI,CAAC,SAAS;EAEd,OAAO,iBAAiB,WAAW,aAAa;EAChD,OAAO,iBAAiB,SAAS,WAAW;EAE5C,aAAa;GACX,OAAO,oBAAoB,WAAW,aAAa;GACnD,OAAO,oBAAoB,SAAS,WAAW;GAC/C,IAAI,iBAAiB,SACnB,qBAAqB,iBAAiB,OAAO;EAEjD;CACF,GAAG;EAAC;EAAS;EAAe;CAAW,CAAC;CAGxC,gBAAgB;EACd,IAAI,YAAY,CAAC,iBAAiB,SAAS;GACzC,eAAe,UAAU,YAAY,IAAI;GAEzC,iBAAiB,UAAU,4BAA4B;IACrD,kBAAkB,UAAU;GAC9B,CAAC;EACH,OAAO,IAAI,CAAC,YAAY,iBAAiB,SAAS;GAChD,qBAAqB,iBAAiB,OAAO;GAC7C,iBAAiB,UAAU;EAC7B;EAEA,aAAa;GACX,IAAI,iBAAiB,SAAS;IAC5B,qBAAqB,iBAAiB,OAAO;IAC7C,iBAAiB,UAAU;GAC7B;EACF;CAEF,GAAG,CAAC,QAAQ,CAAC;CAEb,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"inputSystem.js","names":[],"sources":["../../src/utils/inputSystem.ts"],"sourcesContent":["import { COMBAT_CONTROLS } from \"@/systems/types\";\nimport type { Position } from \"@/types/common\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport * as THREE from \"three\";\nimport type { MovementInput } from \"../systems/physics/MovementPhysics\";\nimport { MovementPhysics } from \"../systems/physics/MovementPhysics\";\nimport { TrigramStance } from \"../types/common\";\nimport { calculateArenaBounds, DEFAULT_PHYSICS_ARENA_BOUNDS } from \"../types/PhysicsTypes\";\nimport type { MovementArenaBounds } from \"../types/PhysicsTypes\";\n\n/**\n * Configuration interface for the input system and player movement.\n * Uses physics-first approach: all positions and velocities are in meters.\n *\n * **Korean**: 입력 시스템 설정 (Input System Configuration)\n *\n * ## Physics-First Architecture\n *\n * This interface requires worldWidthMeters and worldDepthMeters to enable\n * the new physics-first coordinate system. Without these properties, the\n * movement system cannot properly convert between physics (meters) and\n * rendering (pixels).\n *\n * ### Migration Guide\n *\n * Existing code must be updated to pass world dimensions:\n *\n * ```typescript\n * // Before (incorrect):\n * const config = { bounds: { x: 0, y: 0, width: 960, height: 480 } };\n *\n * // After (correct):\n * const config = {\n * bounds: {\n * worldWidthMeters: 10, // From layout hook\n * worldDepthMeters: 10 // From layout hook\n * }\n * };\n * ```\n *\n * ### Fallback Behavior\n *\n * If worldWidthMeters/worldDepthMeters are not provided, the system falls back\n * to DEFAULT_PHYSICS_ARENA_BOUNDS (10m × 7.5m) to ensure movement stays bounded.\n * Callers SHOULD provide these values from their layout hooks (useCombatLayout, \n * useTrainingLayout) for proper arena sizing.\n */\nexport interface InputSystemConfig {\n /** Whether the input system is enabled and processing input */\n readonly enabled?: boolean;\n\n /**\n * Arena world dimensions in meters for physics calculations.\n *\n * **REQUIRED for physics-first coordinate system to work.**\n *\n * These values must come from layout hooks:\n * - CombatScreen3D: Use arenaBounds.worldWidthMeters/worldDepthMeters from useCombatLayout()\n * - TrainingScreen3D: Use trainingAreaBounds.worldWidthMeters/worldDepthMeters from useTrainingLayout()\n */\n readonly bounds?: {\n /** Physical arena width in meters (e.g., 6m mobile, 10m desktop, 14m 4K) */\n readonly worldWidthMeters: number;\n /** Physical arena depth in meters (e.g., 6m mobile, 10m desktop, 14m 4K) */\n readonly worldDepthMeters: number;\n };\n\n /** Callback invoked when player position changes (position in meters) */\n readonly onPositionChange?: (position: Position) => void;\n\n /** Initial player position in METERS (x = lateral, y = forward/backward) */\n readonly initialPositionMeters?: Position;\n\n // Physics-based movement parameters (always enabled)\n /** Current trigram stance affecting movement speed */\n readonly currentStance?: TrigramStance;\n\n /** Leg injury factor (0-1, where 1 is fully injured) affecting movement speed */\n readonly legInjuryFactor?: number;\n\n /** Whether player is running (sprint mode) */\n readonly isRunning?: boolean;\n\n /** Whether to use tactical step mode (30cm grid quantization) */\n readonly useTacticalSteps?: boolean;\n\n // Speed modifier overrides from SpeedModifierSystem\n /** Final calculated maximum speed in meters per second */\n readonly maxSpeedOverride?: number;\n\n /** Final calculated acceleration in meters per second squared */\n readonly accelerationOverride?: number;\n}\n\nexport interface MovementState {\n readonly up: boolean;\n readonly down: boolean;\n readonly left: boolean;\n readonly right: boolean;\n readonly position: Position;\n readonly isMoving: boolean; // Add isMoving to movement state\n}\n\nexport interface PlayerMovementResult {\n /** Player position in METERS (x = lateral, y = forward/backward in arena) */\n readonly playerPosition: Position;\n readonly movementState: MovementState;\n readonly isMoving: boolean;\n readonly isKeyPressed: (key: string) => boolean;\n /** Velocity in m/s (x = lateral, y = forward/backward) */\n readonly velocity?: { x: number; y: number };\n /** Current speed magnitude in m/s */\n readonly speed?: number;\n}\n\n/**\n * Hook for handling player movement with physics-first approach.\n * All positions and velocities are in METERS - no pixel conversions.\n *\n * **Korean**: 플레이어 이동 훅 (Player Movement Hook)\n *\n * @param config - Physics-first configuration with positions in meters\n * @returns Movement state and physics data (all in meters)\n */\nexport function usePlayerMovement(\n config: InputSystemConfig,\n): PlayerMovementResult {\n const {\n enabled = true,\n bounds,\n onPositionChange,\n initialPositionMeters = { x: 0, y: 0 },\n currentStance = TrigramStance.GEON,\n legInjuryFactor = 0,\n isRunning: isRunningProp = false,\n useTacticalSteps = false,\n maxSpeedOverride,\n accelerationOverride,\n } = config;\n\n // Position in METERS (x = lateral position, y = forward/backward position)\n const [playerPosition, setPlayerPosition] = useState<Position>(\n initialPositionMeters,\n );\n const [keyState, setKeyState] = useState({\n up: false,\n down: false,\n left: false,\n right: false,\n });\n // Physics state for render (velocity and speed in m/s)\n const [velocity, setVelocity] = useState<\n { x: number; y: number } | undefined\n >(undefined);\n const [speed, setSpeed] = useState<number | undefined>(undefined);\n\n // Auto-run detection: track how long movement keys have been held\n // After sustained movement, automatically transition from walking to running\n const movementStartTimeRef = useRef<number | null>(null);\n const AUTO_RUN_THRESHOLD_MS = 300; // Transition to run after 300ms of sustained movement\n\n // Physics-based movement state (always initialized for realistic combat)\n const physicsEngineRef = useRef<MovementPhysics | null>(null);\n const physicsStateRef = useRef<{\n position: THREE.Vector3;\n velocity: THREE.Vector3;\n acceleration: number;\n maxSpeed: number;\n currentStance: TrigramStance;\n legInjuryFactor: number;\n } | null>(null);\n\n // Initialize physics engine once on mount (always enabled)\n // All positions are in METERS - no pixel conversion needed\n useEffect(() => {\n if (!physicsEngineRef.current) {\n // Use arena width for physics-aware speed scaling\n // Validate and fall back to default if invalid\n const width = bounds?.worldWidthMeters;\n const arenaWidth =\n width != null && Number.isFinite(width) && width > 0\n ? width\n : DEFAULT_PHYSICS_ARENA_BOUNDS.worldWidthMeters;\n physicsEngineRef.current = new MovementPhysics(arenaWidth);\n // Initial position in meters (x = lateral, z = forward/backward)\n physicsStateRef.current = {\n position: new THREE.Vector3(\n initialPositionMeters.x,\n 0,\n initialPositionMeters.y,\n ),\n velocity: new THREE.Vector3(0, 0, 0),\n acceleration: 0,\n maxSpeed: 6.0, // Default to BASE_WALK_SPEED (6.0 m/s for responsive combat)\n currentStance,\n legInjuryFactor: legInjuryFactor ?? 0,\n };\n }\n }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n // Compute arena bounds synchronously when bounds dimensions change\n // Uses useMemo to ensure bounds are available immediately (not after effect runs)\n // Falls back to default arena bounds if invalid or missing\n // Depend on the whole `bounds` object so the compiler's inferred property-access\n // dependencies (bounds.worldWidthMeters / bounds.worldDepthMeters) are covered.\n const arenaBoundsResult = useMemo<{\n bounds: MovementArenaBounds | undefined;\n error?: Error;\n }>(() => {\n if (bounds?.worldWidthMeters != null && bounds?.worldDepthMeters != null) {\n try {\n return {\n bounds: calculateArenaBounds(\n {\n worldWidthMeters: bounds.worldWidthMeters,\n worldDepthMeters: bounds.worldDepthMeters,\n },\n 0.3 // 0.3m character radius\n ),\n };\n } catch (error) {\n // If validation fails, fall back to default bounds\n // Error will be logged in useEffect to keep render pure\n return {\n bounds: undefined,\n error: error instanceof Error ? error : new Error(String(error)),\n };\n }\n }\n\n // Fallback: use default arena bounds to ensure movement stays bounded\n try {\n return {\n bounds: calculateArenaBounds(\n {\n worldWidthMeters: DEFAULT_PHYSICS_ARENA_BOUNDS.worldWidthMeters,\n worldDepthMeters: DEFAULT_PHYSICS_ARENA_BOUNDS.worldDepthMeters,\n },\n 0.3 // 0.3m character radius\n ),\n };\n } catch (error) {\n // Should never happen with default bounds, but handle gracefully\n // Error will be logged in useEffect to keep render pure\n return {\n bounds: undefined,\n error: error instanceof Error ? error : new Error(String(error)),\n };\n }\n }, [bounds]);\n\n const arenaBounds = arenaBoundsResult.bounds;\n\n // Log arena bounds calculation errors in an effect (not during render)\n useEffect(() => {\n if (arenaBoundsResult.error) {\n if (bounds?.worldWidthMeters != null && bounds?.worldDepthMeters != null) {\n // Custom bounds failed validation\n console.warn(\n \"Failed to calculate arena bounds, using defaults:\",\n arenaBoundsResult.error\n );\n } else {\n // Should never happen with default bounds\n console.error(\n \"Failed to calculate default arena bounds:\",\n arenaBoundsResult.error\n );\n }\n }\n }, [arenaBoundsResult.error, bounds?.worldWidthMeters, bounds?.worldDepthMeters]);\n\n // Update physics engine arena width when bounds change (legacy)\n useEffect(() => {\n if (!physicsEngineRef.current) {\n return;\n }\n\n const width = bounds?.worldWidthMeters;\n if (width == null) {\n return;\n }\n\n // Validate width before applying to physics engine to avoid runtime errors\n if (!Number.isFinite(width) || width <= 0) {\n console.warn(\n \"Ignoring invalid worldWidthMeters when updating arena width:\",\n width,\n );\n return;\n }\n\n try {\n physicsEngineRef.current.setArenaWidth(width);\n } catch (error) {\n console.warn(\"Failed to update physics arena width:\", error);\n }\n }, [bounds?.worldWidthMeters]);\n\n // Track pressed keys for combat system\n const pressedKeys = useRef<Set<string>>(new Set());\n // Use useState lazy initializer for performance.now() to avoid impure function during render\n const [initialTime] = useState(() => performance.now());\n const lastUpdateTime = useRef(initialTime);\n const animationFrameId = useRef<number | null>(null);\n\n // Refs to track last reported position/velocity to avoid useCallback dependency issues\n // This prevents the animation frame from being cancelled every frame due to callback recreation\n const lastReportedPositionRef = useRef<Position>(initialPositionMeters);\n const lastReportedVelocityRef = useRef<{ x: number; y: number } | undefined>(\n undefined,\n );\n const lastReportedSpeedRef = useRef<number | undefined>(undefined);\n\n // Ref to track keyState for physics loop - avoids recreating callback on key changes\n const keyStateRef = useRef({\n up: false,\n down: false,\n left: false,\n right: false,\n });\n\n // Calculate if currently moving\n const isMoving =\n keyState.up || keyState.down || keyState.left || keyState.right;\n\n // Create complete movement state\n const movementState: MovementState = {\n ...keyState,\n position: playerPosition,\n isMoving,\n };\n\n // Key press checker for combat system\n const isKeyPressed = useCallback((key: string): boolean => {\n return pressedKeys.current.has(key);\n }, []);\n\n // Enhanced keyboard event handlers\n const handleKeyDown = useCallback(\n (event: KeyboardEvent) => {\n if (!enabled) return;\n\n const key = event.key.toLowerCase();\n pressedKeys.current.add(key);\n\n // ✅ FIXED: Add all movement keys including WASD and arrows\n // Update both ref (for physics loop) and state (for React re-render)\n switch (key) {\n case \"w\":\n case \"arrowup\":\n keyStateRef.current.up = true;\n setKeyState((prev) => ({ ...prev, up: true }));\n event.preventDefault();\n break;\n case \"s\":\n case \"arrowdown\":\n keyStateRef.current.down = true;\n setKeyState((prev) => ({ ...prev, down: true }));\n event.preventDefault();\n break;\n case \"a\":\n case \"arrowleft\":\n keyStateRef.current.left = true;\n setKeyState((prev) => ({ ...prev, left: true }));\n event.preventDefault();\n break;\n case \"d\":\n case \"arrowright\":\n keyStateRef.current.right = true;\n setKeyState((prev) => ({ ...prev, right: true }));\n event.preventDefault();\n break;\n }\n },\n [enabled],\n );\n\n const handleKeyUp = useCallback(\n (event: KeyboardEvent) => {\n if (!enabled) return;\n\n const key = event.key.toLowerCase();\n pressedKeys.current.delete(key);\n\n // ✅ FIXED: Handle key release for all movement keys\n // Update both ref (for physics loop) and state (for React re-render)\n switch (key) {\n case \"w\":\n case \"arrowup\":\n keyStateRef.current.up = false;\n setKeyState((prev) => ({ ...prev, up: false }));\n break;\n case \"s\":\n case \"arrowdown\":\n keyStateRef.current.down = false;\n setKeyState((prev) => ({ ...prev, down: false }));\n break;\n case \"a\":\n case \"arrowleft\":\n keyStateRef.current.left = false;\n setKeyState((prev) => ({ ...prev, left: false }));\n break;\n case \"d\":\n case \"arrowright\":\n keyStateRef.current.right = false;\n setKeyState((prev) => ({ ...prev, right: false }));\n break;\n }\n },\n [enabled],\n );\n\n // ✅ FIXED: Proper movement calculation with correct bounds\n // Use a ref to store the callback to avoid reference before declaration issue\n const updatePositionRef = useRef<(() => void) | null>(null);\n\n const updatePosition = useCallback(() => {\n // Check if any movement keys are pressed using ref (not stale state)\n const keys = keyStateRef.current;\n const isCurrentlyMoving = keys.up || keys.down || keys.left || keys.right;\n\n if (!enabled || !isCurrentlyMoving) {\n animationFrameId.current = null;\n return;\n }\n\n const now = performance.now();\n const deltaTime = Math.min(now - (lastUpdateTime.current ?? now), 50);\n lastUpdateTime.current = now;\n\n if (deltaTime <= 0) {\n animationFrameId.current = requestAnimationFrame(() =>\n updatePositionRef.current?.(),\n );\n return;\n }\n\n // Physics-based movement (always enabled for realistic combat)\n if (physicsEngineRef.current && physicsStateRef.current) {\n // Apply speed modifiers if provided by SpeedModifierSystem\n // BUG FIX: Now properly passing maxSpeedOverride to physics engine\n if (maxSpeedOverride !== undefined) {\n physicsEngineRef.current.setMaxSpeed(maxSpeedOverride);\n }\n\n if (accelerationOverride !== undefined) {\n physicsEngineRef.current.setAcceleration(accelerationOverride);\n }\n\n // Convert key state to physics input (using ref to avoid callback recreation)\n // Screen coordinates: UP/W = toward top of screen, DOWN/S = toward bottom\n // Physics Z-axis: negative Z = toward top, positive Z = toward bottom\n const keys = keyStateRef.current;\n const forward = keys.up ? -1 : keys.down ? 1 : 0;\n const lateral = keys.right ? 1 : keys.left ? -1 : 0;\n const isCurrentlyMoving = forward !== 0 || lateral !== 0;\n\n // Auto-run detection: transition to running after sustained movement\n const now = performance.now();\n if (isCurrentlyMoving) {\n movementStartTimeRef.current ??= now;\n } else {\n movementStartTimeRef.current = null;\n }\n\n // Determine if player should be running (auto-run after threshold)\n const movementDuration = movementStartTimeRef.current\n ? now - movementStartTimeRef.current\n : 0;\n const shouldRun =\n isRunningProp || movementDuration > AUTO_RUN_THRESHOLD_MS;\n\n const physicsInput: MovementInput = {\n forward,\n lateral,\n isRunning: shouldRun,\n isMoving: isCurrentlyMoving,\n useTacticalSteps,\n };\n\n // Update physics state\n const state = physicsStateRef.current;\n state.currentStance = currentStance;\n state.legInjuryFactor = legInjuryFactor;\n\n // Clamp delta time to 1/30s (≈33.33ms) to match usePlayerMovement and prevent instability\n const clampedDeltaTimeMs = Math.min(deltaTime, 1000 / 30);\n\n // Use arena bounds computed via useMemo (available synchronously)\n physicsEngineRef.current.updateMovement(\n state,\n physicsInput,\n clampedDeltaTimeMs / 1000,\n arenaBounds, // Use memoized bounds\n );\n\n // Position in meters (x = lateral, y = forward/backward)\n const newPosition = { x: state.position.x, y: state.position.z };\n\n // Velocity in m/s (x = lateral, y = forward/backward)\n const newVelocity = { x: state.velocity.x, y: state.velocity.z };\n const newSpeed = state.velocity.length();\n\n // Use refs for comparison to avoid recreating callback on every frame\n // This prevents the animation frame from being cancelled due to useCallback recreation\n const lastPos = lastReportedPositionRef.current;\n if (newPosition.x !== lastPos.x || newPosition.y !== lastPos.y) {\n lastReportedPositionRef.current = newPosition;\n setPlayerPosition(newPosition);\n onPositionChange?.(newPosition);\n }\n\n // Update velocity and speed if changed (with epsilon tolerance for floating-point stability)\n const EPSILON = 0.001;\n const lastVel = lastReportedVelocityRef.current;\n const velocityChanged =\n !lastVel ||\n Math.abs(lastVel.x - newVelocity.x) > EPSILON ||\n Math.abs(lastVel.y - newVelocity.y) > EPSILON;\n if (velocityChanged) {\n lastReportedVelocityRef.current = newVelocity;\n setVelocity(newVelocity);\n }\n // Initialize speed when undefined, then update only on significant changes\n const lastSpd = lastReportedSpeedRef.current;\n if (lastSpd === undefined || Math.abs(lastSpd - newSpeed) > EPSILON) {\n lastReportedSpeedRef.current = newSpeed;\n setSpeed(newSpeed);\n }\n }\n\n // Continue animation if still moving (check ref, not stale closure)\n const stillMoving =\n keyStateRef.current.up ||\n keyStateRef.current.down ||\n keyStateRef.current.left ||\n keyStateRef.current.right;\n if (stillMoving) {\n animationFrameId.current = requestAnimationFrame(() =>\n updatePositionRef.current?.(),\n );\n } else {\n animationFrameId.current = null;\n }\n // NOTE: playerPosition, velocity, speed, keyState, isMoving intentionally excluded from deps\n // Using refs (lastReportedPositionRef, lastReportedVelocityRef, lastReportedSpeedRef, keyStateRef)\n // for comparison to prevent animation frame cancellation on every state update.\n // arenaBounds is computed from bounds and automatically updates when bounds changes\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [\n enabled,\n // playerPosition - excluded, using ref\n // keyState - excluded, using keyStateRef\n // isMoving - excluded, using keyStateRef for movement check\n // arenaBounds - excluded, derived from bounds (below)\n bounds,\n onPositionChange,\n currentStance,\n legInjuryFactor,\n isRunningProp,\n useTacticalSteps,\n // velocity - excluded, using ref\n // speed - excluded, using ref\n maxSpeedOverride,\n accelerationOverride,\n ]);\n\n // Keep updatePositionRef in sync via useEffect (not during render)\n useEffect(() => {\n updatePositionRef.current = updatePosition;\n }, [updatePosition]);\n\n // Handle keyboard input\n useEffect(() => {\n if (!enabled) return;\n\n window.addEventListener(\"keydown\", handleKeyDown);\n window.addEventListener(\"keyup\", handleKeyUp);\n\n return () => {\n window.removeEventListener(\"keydown\", handleKeyDown);\n window.removeEventListener(\"keyup\", handleKeyUp);\n if (animationFrameId.current) {\n cancelAnimationFrame(animationFrameId.current);\n }\n };\n }, [enabled, handleKeyDown, handleKeyUp]);\n\n // Start animation loop when movement begins\n useEffect(() => {\n if (isMoving && !animationFrameId.current) {\n lastUpdateTime.current = performance.now();\n // Use ref to avoid dependency on updatePosition callback\n animationFrameId.current = requestAnimationFrame(() => {\n updatePositionRef.current?.();\n });\n } else if (!isMoving && animationFrameId.current) {\n cancelAnimationFrame(animationFrameId.current);\n animationFrameId.current = null;\n }\n\n return () => {\n if (animationFrameId.current) {\n cancelAnimationFrame(animationFrameId.current);\n animationFrameId.current = null;\n }\n };\n // Only depend on isMoving - updatePositionRef is stable\n }, [isMoving]);\n\n return {\n playerPosition,\n movementState,\n isMoving,\n isKeyPressed,\n velocity,\n speed,\n };\n}\n\nexport interface InputEvent {\n readonly type: \"keydown\" | \"keyup\" | \"click\" | \"touchstart\" | \"touchend\";\n readonly key?: string;\n readonly target?: EventTarget | null;\n readonly timestamp: number;\n}\n\nexport interface CombatInput {\n readonly stanceChange?: TrigramStance;\n readonly attack?: boolean;\n readonly block?: boolean;\n readonly movement?: MovementState;\n readonly timestamp: number;\n}\n\n/**\n * Input system for combat controls\n */\nexport class InputSystem {\n private actionCallbacks = new Map<string, (() => void)[]>();\n private isEnabled = true;\n\n constructor() {\n this.setupEventListeners();\n }\n\n private setupEventListeners() {\n window.addEventListener(\"keydown\", this.handleKeyDown.bind(this));\n window.addEventListener(\"keyup\", this.handleKeyUp.bind(this));\n }\n\n private handleKeyDown(event: KeyboardEvent) {\n if (!this.isEnabled) return;\n\n const key = event.key;\n this.triggerAction(`keydown:${key}`);\n this.triggerAction(\"keydown\");\n }\n\n private handleKeyUp(event: KeyboardEvent) {\n if (!this.isEnabled) return;\n\n const key = event.key;\n this.triggerAction(`keyup:${key}`);\n this.triggerAction(\"keyup\");\n }\n\n registerAction(action: string, callback: () => void) {\n if (!this.actionCallbacks.has(action)) {\n this.actionCallbacks.set(action, []);\n }\n const callbacks = this.actionCallbacks.get(action);\n if (callbacks) {\n callbacks.push(callback);\n }\n }\n\n unregisterAction(action: string, callback?: () => void) {\n if (!this.actionCallbacks.has(action)) return;\n\n if (callback) {\n const callbacks = this.actionCallbacks.get(action);\n if (callbacks) {\n const index = callbacks.indexOf(callback);\n if (index > -1) {\n callbacks.splice(index, 1);\n }\n }\n } else {\n this.actionCallbacks.delete(action);\n }\n }\n\n clearActions() {\n this.actionCallbacks.clear();\n }\n\n isActionActive(action: string): boolean {\n return this.actionCallbacks.has(action);\n }\n\n enable() {\n this.isEnabled = true;\n }\n\n disable() {\n this.isEnabled = false;\n }\n\n private triggerAction(action: string) {\n const callbacks = this.actionCallbacks.get(action);\n if (callbacks) {\n callbacks.forEach((callback) => callback());\n }\n }\n\n destroy() {\n window.removeEventListener(\"keydown\", this.handleKeyDown.bind(this));\n window.removeEventListener(\"keyup\", this.handleKeyUp.bind(this));\n this.clearActions();\n }\n}\n\n/**\n * Get stance from keyboard input\n */\nexport function getStanceFromKey(key: string): TrigramStance | null {\n const stanceKey = key as keyof typeof COMBAT_CONTROLS.stanceControls;\n\n if (stanceKey in COMBAT_CONTROLS.stanceControls) {\n return COMBAT_CONTROLS.stanceControls[stanceKey].stance;\n }\n\n return null;\n}\n\n/**\n * Process combat input and return structured combat data\n */\nexport function processCombatInput(event: KeyboardEvent): CombatInput | null {\n const key = event.key;\n const timestamp = performance.now();\n\n // Check for stance change (1-8 keys)\n const stance = getStanceFromKey(key);\n if (stance) {\n return {\n stanceChange: stance,\n timestamp,\n };\n }\n\n // Check for combat actions\n switch (key.toLowerCase()) {\n case \" \": // Space for attack\n return {\n attack: true,\n timestamp,\n };\n case \"shift\":\n return {\n block: true,\n timestamp,\n };\n default:\n return null;\n }\n}\n\n/**\n * Hook for combat input handling\n */\nexport function useCombatInput(onCombatInput: (input: CombatInput) => void) {\n const isEnabled = useRef<boolean>(true);\n\n useEffect(() => {\n const handleKeyDown = (event: KeyboardEvent) => {\n if (!isEnabled.current) return;\n\n const combatInput = processCombatInput(event);\n if (combatInput) {\n onCombatInput(combatInput);\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n return () => window.removeEventListener(\"keydown\", handleKeyDown);\n }, [onCombatInput]);\n\n return {\n enable: () => {\n isEnabled.current = true;\n },\n disable: () => {\n isEnabled.current = false;\n },\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blacktrigram",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.113",
|
|
4
4
|
"description": "Black Trigram (흑괘) - Korean Martial Arts Combat Simulator. Reusable game systems, combat mechanics, animation framework, and Korean martial arts data built with React, Three.js, and TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -189,7 +189,7 @@
|
|
|
189
189
|
"three": "0.185.1"
|
|
190
190
|
},
|
|
191
191
|
"devDependencies": {
|
|
192
|
-
"@aws-sdk/client-bedrock-runtime": "3.
|
|
192
|
+
"@aws-sdk/client-bedrock-runtime": "3.1116.0",
|
|
193
193
|
"@eslint/js": "10.0.1",
|
|
194
194
|
"@react-three/drei": "10.7.8",
|
|
195
195
|
"@react-three/fiber": "9.7.0",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"@types/react": "19.2.18",
|
|
203
203
|
"@types/react-dom": "19.2.4",
|
|
204
204
|
"@types/three": "0.185.4",
|
|
205
|
-
"@vitejs/plugin-react": "6.0
|
|
205
|
+
"@vitejs/plugin-react": "6.1.0",
|
|
206
206
|
"@vitest/coverage-v8": "4.1.11",
|
|
207
207
|
"@vitest/ui": "4.1.11",
|
|
208
208
|
"cypress": "15.21.0",
|
|
@@ -211,7 +211,7 @@
|
|
|
211
211
|
"cypress-wait-until": "3.0.2",
|
|
212
212
|
"dependency-cruiser": "18.2.0",
|
|
213
213
|
"dotenv": "17.4.2",
|
|
214
|
-
"eslint": "10.
|
|
214
|
+
"eslint": "10.9.0",
|
|
215
215
|
"eslint-plugin-react-hooks": "7.1.1",
|
|
216
216
|
"eslint-plugin-react-refresh": "0.5.4",
|
|
217
217
|
"globals": "17.11.0",
|
|
@@ -219,7 +219,7 @@
|
|
|
219
219
|
"jsdom": "29.1.1",
|
|
220
220
|
"knip": "6.32.2",
|
|
221
221
|
"license-compliance": "4.0.0",
|
|
222
|
-
"mermaid": "11.
|
|
222
|
+
"mermaid": "11.17.0",
|
|
223
223
|
"mocha-junit-reporter": "2.2.1",
|
|
224
224
|
"mochawesome": "8.0.1",
|
|
225
225
|
"mochawesome-merge": "5.1.1",
|
|
@@ -242,7 +242,7 @@
|
|
|
242
242
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
243
243
|
"typescript-7": "npm:typescript@7.0.2",
|
|
244
244
|
"typescript-eslint": "8.67.0",
|
|
245
|
-
"vite": "8.2.
|
|
245
|
+
"vite": "8.2.2",
|
|
246
246
|
"vite-bundle-analyzer": "1.3.9",
|
|
247
247
|
"vite-tsconfig-paths": "6.1.1",
|
|
248
248
|
"vitest": "4.1.11"
|