diff --git a/src/controllers/CameraFollowC.ts b/src/controllers/CameraFollowC.ts new file mode 100644 index 0000000..257ce27 --- /dev/null +++ b/src/controllers/CameraFollowC.ts @@ -0,0 +1,115 @@ +import { Object3D, Raycaster, Vector3 } from "three"; +import { CameraC_internal, UpdateController } from "@hitplay/playable_template"; + +// How fast the camera closes the gap to its target position/look point, per +// second. Higher = snappier, lower = floatier. Framerate-independent (see +// the exponential smoothing in update()), so the feel is the same at 30fps +// and 60fps instead of drifting with frame time. +const POSITION_DAMPING = 5; +const LOOK_DAMPING = 8; + +// Keeps the camera a bit off any obstacle it lands on, and never lets it +// collapse onto the target itself. +const OBSTACLE_SKIN = 0.4; +const MIN_DISTANCE = 1.5; + +// How far (world units) the whole camera rig — position and look-at point +// alike — pans toward wherever the character is currently facing, to open +// up more of the space ahead of them instead of centering them dead-on. +// Matches PlayerC's own forward convention (+z at rest); the two aren't +// coupled in code, but there's only one character to stay in sync with. +const LOOK_AHEAD_DISTANCE = 1.2; + +// The character's body can spin at up to 540°/s (PlayerC.MAX_TURN_SPEED), +// which would yank the look-ahead point around just as fast if it followed +// the raw facing direction. Smoothing the direction itself, separately from +// (and slower than) the position/look damping below, is what actually makes +// the pan gentle regardless of how fast the character turns. +const LOOK_AHEAD_DAMPING = 2; + +const FORWARD_AXIS = new Vector3(0, 0, 1); + +export class CameraFollowC { + private static target: Object3D | null = null; + private static offset = new Vector3(); + private static obstacles: Object3D[] = []; + private static eyeHeight = 0; + private static smoothedLookAt = new Vector3(); + // The actual (x,y,z) offset, not a direction — see update() for why that + // distinction matters. + private static smoothedLookAhead = FORWARD_AXIS.clone().multiplyScalar(LOOK_AHEAD_DISTANCE); + private static raycaster = new Raycaster(); + + static init(target: Object3D, offset: Vector3, obstacles: Object3D[] = [], eyeHeight = 0) { + this.target = target; + this.offset = offset.clone(); + this.obstacles = obstacles; + this.eyeHeight = eyeHeight; + this.smoothedLookAt.copy(target.position); + + UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta)); + } + + private static update(delta: number) { + if (!this.target) return; + + const camera = CameraC_internal.getCamera(); + const targetPosition = this.target.position; + + const facing = FORWARD_AXIS.clone().applyQuaternion(this.target.quaternion); + facing.y = 0; + facing.normalize(); + const desiredLookAhead = facing.multiplyScalar(LOOK_AHEAD_DISTANCE); + + // Damping the raw offset vector (not a unit direction re-normalized + // every frame) is what keeps this a straight-line move through the + // character on a direction change, instead of an arc: re-normalizing + // pins the vector's length at LOOK_AHEAD_DISTANCE the whole time, which + // forces it to sweep around a circle of that radius as the direction + // changes. Left as plain (x,y,z) lerp, it can shrink through zero and + // grow back out the other way — a straight line, not a curve. + const lookAheadT = 1 - Math.exp(-LOOK_AHEAD_DAMPING * delta); + this.smoothedLookAhead.lerp(desiredLookAhead, lookAheadT); + const lookAhead = this.smoothedLookAhead; + + const desiredPosition = targetPosition.clone().add(this.offset).add(lookAhead); + this.avoidObstacles(targetPosition, desiredPosition); + + // Exponential (framerate-independent) damping instead of a fixed lerp + // factor per frame — a fixed factor changes speed with the frame rate + // and reads as jerky; this keeps the same feel regardless of fps. + const positionT = 1 - Math.exp(-POSITION_DAMPING * delta); + camera.position.lerp(desiredPosition, positionT); + + const lookT = 1 - Math.exp(-LOOK_DAMPING * delta); + this.smoothedLookAt.lerp(targetPosition.clone().add(lookAhead), lookT); + camera.lookAt(this.smoothedLookAt); + } + + // Only pulls the camera in when an obstacle would actually hide the + // character. The check ray starts at (roughly) eye height instead of the + // character's center/feet, so something that doesn't reach that high never + // triggers a zoom — the character is still visible over it. When it does + // trigger, the ray's own hit distance (measured from the character, not + // from the camera) is what places the camera, so it lands right next to + // the obstacle on the character's side instead of collapsing onto the + // character whenever they aren't standing flush against it. + private static avoidObstacles(targetPosition: Vector3, desired: Vector3) { + if (this.obstacles.length === 0) return; + + const eyePosition = targetPosition.clone().add(new Vector3(0, this.eyeHeight, 0)); + const toDesired = desired.clone().sub(eyePosition); + const distance = toDesired.length(); + if (distance < 1e-4) return; + + const direction = toDesired.divideScalar(distance); + this.raycaster.set(eyePosition, direction); + this.raycaster.far = distance; + + const hits = this.raycaster.intersectObjects(this.obstacles, true); + if (hits.length === 0) return; + + const safeDistance = Math.max(hits[0].distance - OBSTACLE_SKIN, MIN_DISTANCE); + desired.copy(eyePosition).addScaledVector(direction, safeDistance); + } +} diff --git a/src/controllers/PlayerC.ts b/src/controllers/PlayerC.ts new file mode 100644 index 0000000..4f778ee --- /dev/null +++ b/src/controllers/PlayerC.ts @@ -0,0 +1,286 @@ +import { + AnimationAction, + AnimationClip, + AnimationMixer, + Box3, + LoopRepeat, + Object3D, + Quaternion, + Vector2, + Vector3, +} from "three"; +import { JoystickC, UpdateController } from "@hitplay/playable_template"; +import { ThreeC } from "./ThreeC"; + +const MOVE_SPEED = 4; // world units per second +const START_POSITION = { x: 1, y: 0, z: -8 }; // start of the road, the other end, feet on the ground + +// Collision box used for movement — a rough silhouette of the rigged model +// (bbox is ~0.9 x 1.72 x 1.0), not its exact bounds. +const PLAYER_SIZE = new Vector3(0.8, 1.7, 0.8); + +export const PLAYER_HEIGHT = PLAYER_SIZE.y; + +// Colliders shorter than this are treated as ground, not obstacles: the +// map's floor collider is a ~0.3-unit-thin slab spanning almost the whole +// map, while real props (crates, walls) stand ~2 units tall. +const MIN_OBSTACLE_HEIGHT = 0.5; + +// Max turn rate, in radians/second — a hard cap on angular speed, not a +// proportional ease. Exponential damping (like CameraFollowC uses for +// position) closes a fixed percentage of the remaining angle per second, so +// a big gap (e.g. a 180° reversal) still covers a huge arc in the very +// first frame and reads as a snap. A constant max speed turns a small +// correction and a full reversal at the same rate, which reads as smooth. +const MAX_TURN_SPEED = Math.PI * 3; // ~540°/s + +const ANIMATION_FADE = 0.2; // seconds, crossfade between idle/walk/attack + +// How far off-center (in the XZ plane) an obstacle can be from the +// character's forward direction and still count as "facing it". +// dot(forward, toObstacle) > this — 0.5 is a 120°-wide cone (60° each side). +const FACING_DOT_THRESHOLD = 0.5; + +// tryMove() rejects the whole step that would overlap an obstacle, so the +// player always stops just short of actually touching it (up to one frame's +// movement worth of gap) — Box3.intersectsBox on the exact collision box +// would basically never see contact. This extra margin, checked only for +// the attack/interaction range (not movement), covers that gap. +const INTERACTION_REACH = 0.4; + +const UP_AXIS = new Vector3(0, 1, 0); +const FORWARD_AXIS = new Vector3(0, 0, 1); + +export class PlayerC { + static object: Object3D; + + private static moveInput = new Vector2(); + private static obstacles: Box3[] = []; + private static facingRotation = new Quaternion(); + + private static mixer: AnimationMixer; + private static idleAction: AnimationAction; + private static walkAction: AnimationAction; + private static attackAction: AnimationAction; + private static currentAction: AnimationAction; + private static isAttacking = false; + + private static pistol: Object3D; + private static batInHand: Object3D; + private static batOnBack: Object3D; + + static init(colliders: Object3D[] = []) { + // `object` is a plain container: movement/rotation logic below assumes + // its own +z is "forward" (see update()), which happens to already + // match the loaded model's rest pose — see createCharacter(). + this.object = new Object3D(); + this.object.position.set(START_POSITION.x, START_POSITION.y, START_POSITION.z); + this.object.add(this.createCharacter()); + ThreeC.addToScene(this.object); + + this.obstacles = colliders + .map((collider) => new Box3().setFromObject(collider)) + .filter((box) => box.max.y - box.min.y >= MIN_OBSTACLE_HEIGHT); + + this.bindJoystick(); + + UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta)); + } + + private static createCharacter() { + const character = ThreeC.getObject("character"); + + // "Bullet" is a separate rig bundled in the same file for the (not yet + // implemented) shooting animations — hide it until it's actually used. + const bullet = character.getObjectByName("Bullet"); + if (bullet) bullet.visible = false; + + ThreeC.setShadowsStateForChildren(character, true, false); + + this.setupAnimations(character); + this.setupWeapons(character); + + return character; + } + + private static setupWeapons(character: Object3D) { + const pistol = character.getObjectByName("Character_Pistol"); + const batInHand = character.getObjectByName("Tool_1"); + const batOnBack = character.getObjectByName("Tool_2"); + + if (!pistol || !batInHand || !batOnBack) { + throw new Error("character mesh is missing the pistol/bat weapon props"); + } + + this.pistol = pistol; + this.batInHand = batInHand; + this.batOnBack = batOnBack; + + this.equipPistol(); + } + + // Exactly one of {pistol, bat} is ever in hand, and the other is stowed — + // the pistol has no separate "in hand" prop to show (see PlayerC chat + // notes), so its own single mesh just toggles at its holster spot instead. + private static equipPistol() { + this.pistol.visible = true; + this.batInHand.visible = false; + this.batOnBack.visible = true; + } + + private static equipBat() { + this.pistol.visible = false; + this.batInHand.visible = true; + this.batOnBack.visible = false; + } + + private static setupAnimations(character: Object3D) { + this.mixer = new AnimationMixer(character); + ThreeC.addAnimMixer(this.mixer); + + const clips: AnimationClip[] = ThreeC.getAnimations("character"); + const idleClip = AnimationClip.findByName(clips, "Idle"); + const walkClip = AnimationClip.findByName(clips, "SlowWalk"); + const attackClip = AnimationClip.findByName(clips, "Loot"); + + if (!idleClip || !walkClip || !attackClip) { + throw new Error("character mesh is missing the Idle/SlowWalk/Loot animation clips"); + } + + this.idleAction = this.mixer.clipAction(idleClip); + this.walkAction = this.mixer.clipAction(walkClip); + this.attackAction = this.mixer.clipAction(attackClip); + this.attackAction.setLoop(LoopRepeat, Infinity); + + this.currentAction = this.idleAction; + this.currentAction.play(); + } + + private static playAction(action: AnimationAction) { + if (this.currentAction === action) return; + + action.reset().fadeIn(ANIMATION_FADE).play(); + this.currentAction.fadeOut(ANIMATION_FADE); + this.currentAction = action; + } + + private static bindJoystick() { + JoystickC.onJoysticMove.addDelegate(({ data }) => { + // nipplejs clamps the drag to a circle (not per-axis) whenever + // `follow: false` — see JoystickC.init in beforeResourcesLoadedCb — + // so `vector` is already length <= 1 and diagonals aren't faster. + this.moveInput.set(data.vector.x, data.vector.y); + }); + + JoystickC.onJoysticEnd.addDelegate(() => { + this.moveInput.set(0, 0); + }); + } + + private static update(delta: number) { + const inputMagnitude = this.moveInput.length(); + + if (inputMagnitude > 0) { + // Matches the position mapping below (-x -> x, y -> z): the angle a + // movement vector needs to rotate the character's default +z-facing + // front to point the same way. x is negated because the camera now + // looks toward +Z (see TestSceneC's CAMERA_OFFSET) — that's a 180° + // yaw from the default view, which mirrors world +X to screen-left, + // so moving/facing needs the same mirror to keep "stick right" mean + // "screen right". + const angle = Math.atan2(-this.moveInput.x, this.moveInput.y); + this.facingRotation.setFromAxisAngle(UP_AXIS, angle); + + const step = MOVE_SPEED * delta; + + // Move one axis at a time so a wall blocking one direction still lets + // the player slide along it, instead of getting fully stuck. + this.tryMove(-this.moveInput.x * step, 0); + this.tryMove(0, this.moveInput.y * step); + } + + // Turn toward the last movement direction and hold it while idle, + // rather than resetting to face forward the moment input stops. + // rotateTowards caps the step at MAX_TURN_SPEED * delta radians instead + // of interpolating a percentage of the remaining angle. Moving or + // turning away from the obstacle is exactly what breaks updateCombat()'s + // facing check below — no separate "walked/turned away" case needed. + this.object.quaternion.rotateTowards(this.facingRotation, MAX_TURN_SPEED * delta); + + this.updateCombat(); + + if (!this.isAttacking) { + if (inputMagnitude > 0) { + // Match the walk cycle's playback speed to how hard the stick is + // pushed, not just whether it's pushed — otherwise a small nudge + // still plays the animation at full speed while the character + // barely moves, and the feet visibly skate across the ground. + this.walkAction.timeScale = inputMagnitude; + this.playAction(this.walkAction); + } else { + this.playAction(this.idleAction); + } + } + } + + // Attacking requires standing next to a destructible AND facing it — pure + // proximity (e.g. backing into a crate) shouldn't swing the bat. Re-reads + // both conditions every frame, so walking or turning away — or the + // obstacle later being removed by a destruction system — stops it + // automatically, with no separate "stop" case to maintain. + private static updateCombat() { + const obstacleCenter = this.getFacingObstacleCenter(); + const isAttacking = obstacleCenter !== null; + + if (isAttacking === this.isAttacking) return; + this.isAttacking = isAttacking; + + if (isAttacking) { + this.equipBat(); + this.playAction(this.attackAction); + } else { + this.equipPistol(); + } + } + + // Returns the center of the destructible obstacle the player is touching + // AND facing, or null if there isn't one. + private static getFacingObstacleCenter(): Vector3 | null { + const bounds = this.getCollisionBounds(this.object.position).expandByScalar(INTERACTION_REACH); + const touching = this.obstacles.find((box) => box.intersectsBox(bounds)); + if (!touching) return null; + + const center = touching.getCenter(new Vector3()); + + const toObstacle = center.clone().sub(this.object.position); + toObstacle.y = 0; + if (toObstacle.lengthSq() < 1e-6) return center; // standing right on top of it — count as facing + + toObstacle.normalize(); + const forward = FORWARD_AXIS.clone().applyQuaternion(this.object.quaternion); + forward.y = 0; + forward.normalize(); + + return forward.dot(toObstacle) > FACING_DOT_THRESHOLD ? center : null; + } + + private static tryMove(dx: number, dz: number) { + const nextPosition = this.object.position.clone(); + nextPosition.x += dx; + nextPosition.z += dz; + + const nextBounds = this.getCollisionBounds(nextPosition); + const blocked = this.obstacles.some((box) => box.intersectsBox(nextBounds)); + if (blocked) return; + + this.object.position.copy(nextPosition); + } + + // position is anchored at the feet, but the collision box should be + // centered on the body + private static getCollisionBounds(position: Vector3): Box3 { + const center = position.clone(); + center.y += PLAYER_SIZE.y / 2; + return new Box3().setFromCenterAndSize(center, PLAYER_SIZE); + } +} diff --git a/src/controllers/TestSceneC.ts b/src/controllers/TestSceneC.ts index f41f0b4..995bcd9 100644 --- a/src/controllers/TestSceneC.ts +++ b/src/controllers/TestSceneC.ts @@ -1,33 +1,44 @@ +import { Object3D, Vector3 } from "three"; import { ThreeC } from "./ThreeC"; -import { InputC, JoystickC } from "@hitplay/playable_template"; +import { PlayerC, PLAYER_HEIGHT } from "./PlayerC"; +import { CameraFollowC } from "./CameraFollowC"; + +// Above and behind the player, along the road's forward (+Z) direction — +// player now spawns at the other end of the road, walking toward +Z, so +// "behind" flipped from +Z to -Z to keep looking the same way they walk. +const CAMERA_OFFSET = new Vector3(0, 9, -9); + +// Obstacle checks look from roughly head height rather than the player's +// feet (position.y is anchored at the feet), so short obstacles don't +// falsely count as hiding the player. 0.9 of the full height keeps the ray +// just under the very top edge, avoiding edge-grazing false negatives. +const CAMERA_EYE_HEIGHT = PLAYER_HEIGHT * 0.9; export class TestSceneC { static init() { - this.createMap(); + const colliders = this.createMap(); - // example of using InputC events - InputC.onTouchDown.addDelegate((event) => { - console.log("onMouseDown", event); - }); - - // if you have update in your controller - // UpdateController.Instance.onUpdate.addDelegate(() => { - // this.update(); - // }); + PlayerC.init(colliders); + CameraFollowC.init(PlayerC.object, CAMERA_OFFSET, colliders, CAMERA_EYE_HEIGHT); } private static createMap() { const map = ThreeC.getObject("map"); + const colliders: Object3D[] = []; - // physics-only collision proxies exported alongside the visual meshes; hide them + // physics-only collision proxies exported alongside the visual meshes; + // hide them from render, but keep them as camera-obstacle geometry map.traverse((child) => { if (/collider/i.test(child.name)) { child.visible = false; + colliders.push(child); } }); ThreeC.setShadowsStateForChildren(map, true, true); ThreeC.addToScene(map); + + return colliders; } } diff --git a/src/resources/meshes/ZombiePunk_Character.glb b/src/resources/meshes/ZombiePunk_Character.glb new file mode 100644 index 0000000..be8e122 Binary files /dev/null and b/src/resources/meshes/ZombiePunk_Character.glb differ diff --git a/src/resources/meshes/meshes.ts b/src/resources/meshes/meshes.ts index 402b258..04ba4ba 100644 --- a/src/resources/meshes/meshes.ts +++ b/src/resources/meshes/meshes.ts @@ -9,6 +9,10 @@ export const meshes : ConvertResourceType = { name: "map", value: ConvertToBase64WhenRelease("./ZombiePunk_Map.glb"), }, + { + name: "character", + value: ConvertToBase64WhenRelease("./ZombiePunk_Character.glb"), + }, ], loader: Template3d.meshLoader } \ No newline at end of file diff --git a/src/templateConfig/beforeResourcesLoadedCb.ts b/src/templateConfig/beforeResourcesLoadedCb.ts index 64244a1..e6292c3 100644 --- a/src/templateConfig/beforeResourcesLoadedCb.ts +++ b/src/templateConfig/beforeResourcesLoadedCb.ts @@ -1,7 +1,7 @@ import { CameraC_internal, CameraType, - // JoystickC, + JoystickC, Physics_internal, Template, Template3d, @@ -26,21 +26,15 @@ export const beforeResourcesLoadedCb = () => { Physics_internal.init(new Vec3(0, -9.81, 0)); - // example of using joystick. Uncomment if you need joystick - - // JoystickC.init({ - // zone: document.getElementById("joystick_zone") as HTMLDivElement, - // fadeTime: 0, - // mode: "dynamic", - // restJoystick: true, - // catchDistance: 1, - // restOpacity: 0, - // follow: false, - // }); - - // JoystickC.onJoysticMove.addDelegate(({event, data}) => { - // console.log('onJoysticMove', event, data); - // }) + // zone is omitted on purpose: JoystickC auto-creates a full-screen zone + // when none is given, so the joystick can appear wherever the player taps + JoystickC.init({ + mode: "dynamic", + fadeTime: 100, + restJoystick: true, + restOpacity: 0, + follow: false, + }); Template.updateVariableConfig.addDelegate(([category, variable, value]) => { if (category === "global") {