diff --git a/index.html b/index.html index 9521827..f4ed077 100644 --- a/index.html +++ b/index.html @@ -1040,6 +1040,7 @@ + diff --git a/src/actors/controller.ts b/src/actors/controller.ts index 69e9eb0..4a62577 100644 --- a/src/actors/controller.ts +++ b/src/actors/controller.ts @@ -13,6 +13,22 @@ export type ActorKind = "humanoid" | "dog" | "crow"; export type ActorMode = "ground" | "flight"; export type ActorModeRequest = "none" | ActorMode; export type ActorKindRequest = "none" | ActorKind; +export type CrowFlightPoseState = "flap" | "glide" | "bank" | "tuck" | "perch"; + +export interface CrowWind { + /** Positive X blows east/right in the actor's metre-space adapter. */ + xMps: number; + /** Positive Z blows toward the actor's rear at yaw zero. */ + zMps: number; +} + +export interface CrowThermal { + x: number; + z: number; + radiusM: number; + /** Maximum deterministic updraft at the core. */ + liftMps: number; +} /** JSON-safe appearance fields. URLs are references; render resources stay outside simulation. */ export interface ActorProfile { @@ -102,6 +118,16 @@ export interface ActorControllerOptions { maximumFlightSpeedMps?: number; glideSpeedMps?: number; maximumClimbSpeedMps?: number; + /** Constant air-mass velocity. Defaults to still air. */ + crowWind?: Partial; + /** Bounded deterministic radial updrafts in actor metre space. */ + crowThermals?: readonly CrowThermal[]; + /** Normalized reserve used by powered flapping. Defaults to one. */ + crowInitialEnergy?: number; + /** Reserve spent per second at a representative powered cruise. */ + crowEnergyDrainPerSecond?: number; + /** Reserve recovered per second while gliding or perched. */ + crowEnergyRecoveryPerSecond?: number; /** Optional rectangle for a board or office adapter without collision geometry. */ horizontalBounds?: ActorHorizontalBounds; fixedStepSeconds?: number; @@ -122,6 +148,17 @@ export interface ActorControllerState extends ActorPosition { /** Renderer request for pose intensity, in [0, 1]. */ poseAmount: number; gliding: boolean; + /** Signed visual/turn bank in radians. */ + roll: number; + /** Normalized powered-flight reserve in [0, 1]. */ + flightEnergy: number; + /** Aerodynamic and thermal vertical contribution before pilot climb. */ + liftMps: number; + thermalLiftMps: number; + windXMps: number; + windZMps: number; + crowPose: CrowFlightPoseState; + perched: boolean; altitudeBoundContact: "none" | "minimum" | "maximum"; distanceM: number; elapsedSteps: number; @@ -161,6 +198,11 @@ interface ResolvedOptions { maximumFlightSpeedMps: number; glideSpeedMps: number; maximumClimbSpeedMps: number; + crowWind: CrowWind; + crowThermals: readonly CrowThermal[]; + crowInitialEnergy: number; + crowEnergyDrainPerSecond: number; + crowEnergyRecoveryPerSecond: number; horizontalBounds?: ActorHorizontalBounds; fixedStepSeconds: number; maxFrameDeltaSeconds: number; @@ -277,6 +319,33 @@ function checkedBounds(bounds: ActorHorizontalBounds | undefined): ActorHorizont return { ...bounds }; } +function checkedCrowWind(value: Partial | undefined): CrowWind { + const xMps = finiteOr(value?.xMps, 0); + const zMps = finiteOr(value?.zMps, 0); + // A malformed adapter cannot inject hurricane-scale displacement into an + // authoritative actor step. The generous cap still covers severe weather. + return { xMps: clamp(xMps, -60, 60), zMps: clamp(zMps, -60, 60) }; +} + +function checkedCrowThermals(value: readonly CrowThermal[] | undefined): readonly CrowThermal[] { + if (!value) return Object.freeze([]); + if (value.length > 32) throw new RangeError("crowThermals supports at most 32 updrafts"); + const checked = value.map((thermal) => { + if ( + !Number.isFinite(thermal?.x) || !Number.isFinite(thermal?.z) || + !Number.isFinite(thermal?.radiusM) || thermal.radiusM <= 0 || + !Number.isFinite(thermal?.liftMps) || thermal.liftMps < 0 + ) throw new RangeError("crow thermal fields must be finite with positive radius and non-negative lift"); + return Object.freeze({ + x: thermal.x, + z: thermal.z, + radiusM: clamp(thermal.radiusM, 0.5, 10_000), + liftMps: clamp(thermal.liftMps, 0, 20), + }); + }); + return Object.freeze(checked); +} + /** Clamp malformed adapter input before it can poison authoritative state. */ export function normalizeActorActions( actions: Partial | undefined, @@ -350,6 +419,19 @@ function resolveOptions(options: ActorControllerOptions): ResolvedOptions { maximumFlightSpeedMps, glideSpeedMps: clamp(positive(options.glideSpeedMps, 7.5, "glideSpeedMps"), minimumFlightSpeedMps, maximumFlightSpeedMps), maximumClimbSpeedMps: positive(options.maximumClimbSpeedMps, 5, "maximumClimbSpeedMps"), + crowWind: checkedCrowWind(options.crowWind), + crowThermals: checkedCrowThermals(options.crowThermals), + crowInitialEnergy: clamp(finiteOr(options.crowInitialEnergy, 1), 0, 1), + crowEnergyDrainPerSecond: clamp( + positive(options.crowEnergyDrainPerSecond, 0.006, "crowEnergyDrainPerSecond"), + 0.0001, + 1, + ), + crowEnergyRecoveryPerSecond: clamp( + positive(options.crowEnergyRecoveryPerSecond, 0.012, "crowEnergyRecoveryPerSecond"), + 0.0001, + 1, + ), horizontalBounds: bounds, fixedStepSeconds: clamp(positive(options.fixedStepSeconds, 1 / 60, "fixedStepSeconds"), 1 / 240, 0.1), maxFrameDeltaSeconds: clamp(positive(options.maxFrameDeltaSeconds, 0.25, "maxFrameDeltaSeconds"), 0.05, 1), @@ -376,6 +458,14 @@ export class ActorController { posePhase: 0, poseAmount: 0, gliding: false, + roll: 0, + flightEnergy: this.options.crowInitialEnergy, + liftMps: 0, + thermalLiftMps: 0, + windXMps: this.options.crowWind.xMps, + windZMps: this.options.crowWind.zMps, + crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch", + perched: this.options.kind === "crow" && this.options.mode === "ground", altitudeBoundContact: "none", distanceM: 0, elapsedSteps: 0, @@ -429,6 +519,11 @@ export class ActorController { this.current.pitch = 0; this.current.poseAmount = 0; this.current.gliding = false; + this.current.roll = 0; + this.current.liftMps = 0; + this.current.thermalLiftMps = 0; + this.current.crowPose = kind === "crow" && this.current.mode === "ground" ? "perch" : "flap"; + this.current.perched = kind === "crow" && this.current.mode === "ground"; this.current.altitudeBoundContact = "none"; if (kind !== "crow") this.land(); } @@ -453,6 +548,14 @@ export class ActorController { posePhase: 0, poseAmount: 0, gliding: false, + roll: 0, + flightEnergy: this.options.crowInitialEnergy, + liftMps: 0, + thermalLiftMps: 0, + windXMps: this.options.crowWind.xMps, + windZMps: this.options.crowWind.zMps, + crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch", + perched: this.options.kind === "crow" && this.options.mode === "ground", altitudeBoundContact: "none", distanceM: 0, elapsedSteps: 0, @@ -518,6 +621,16 @@ export class ActorController { this.current.y = this.options.groundY; this.current.pitch = 0; this.current.gliding = false; + this.current.roll = moveToward(this.current.roll, 0, 7 * dt); + this.current.liftMps = 0; + this.current.thermalLiftMps = 0; + this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap"; + this.current.perched = this.current.kind === "crow"; + this.current.flightEnergy = clamp( + this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * 2.5 * dt, + 0, + 1, + ); this.current.altitudeBoundContact = "none"; this.current.poseAmount = input; const strideLength = this.current.kind === "dog" ? 0.54 : 0.78; @@ -526,23 +639,91 @@ export class ActorController { private stepFlight(actions: ActorActionSnapshot): void { const dt = this.options.fixedStepSeconds; + const idleSoaring = + !actions.glide && Math.abs(actions.forward) < 0.02 && Math.abs(actions.turn) < 0.02 && + Math.abs(actions.pitch) < 0.02 && Math.abs(actions.climb) < 0.02; + const speedFraction = clamp( + this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001), + 0, + 2, + ); + const targetRoll = actions.turn * 0.62; + this.current.roll = moveToward(this.current.roll, targetRoll, 2.7 * dt); this.current.yaw = wrapAngle( - this.current.yaw + actions.turn * this.options.flightTurnRateRadPerSecond * dt, + this.current.yaw + + ( + actions.turn * this.options.flightTurnRateRadPerSecond * (0.68 + speedFraction * 0.22) + + Math.sin(this.current.roll) * 0.34 + ) * dt, ); const targetPitch = actions.pitch * this.options.maximumFlightPitchRad; this.current.pitch = moveToward(this.current.pitch, targetPitch, 1.9 * dt); const throttle = (actions.forward + 1) / 2; + const tucking = actions.glide && actions.forward < -0.6 && actions.pitch < -0.1; + const recovering = actions.glide; + if (recovering) { + this.current.flightEnergy = clamp( + this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * dt, + 0, + 1, + ); + } else { + const effort = 0.35 + throttle * 0.65 + Math.max(0, actions.climb) * 0.55; + this.current.flightEnergy = clamp( + this.current.flightEnergy - this.options.crowEnergyDrainPerSecond * effort * dt, + 0, + 1, + ); + } + const energyAuthority = 0.52 + this.current.flightEnergy * 0.48; const poweredTarget = this.options.minimumFlightSpeedMps + - (this.options.maximumFlightSpeedMps - this.options.minimumFlightSpeedMps) * throttle; - const targetSpeed = actions.glide ? this.options.glideSpeedMps : poweredTarget; - this.current.speedMps = moveToward(this.current.speedMps, targetSpeed, (actions.glide ? 2.2 : 5.5) * dt); + (this.options.maximumFlightSpeedMps - this.options.minimumFlightSpeedMps) * throttle * energyAuthority; + const targetSpeed = tucking + ? Math.min(this.options.maximumFlightSpeedMps, this.options.glideSpeedMps * 1.45) + : actions.glide ? this.options.glideSpeedMps : poweredTarget; + const response = actions.glide ? (tucking ? 3.1 : 2.2) : 5.5 * energyAuthority; + this.current.speedMps = moveToward(this.current.speedMps, targetSpeed, response * dt); + // Parasitic drag rises with the square of airspeed. It is small enough that + // a healthy powered bird holds its requested target but makes a tucked dive + // finite instead of a perpetual acceleration source. + const dragMps2 = 0.0045 * this.current.speedMps * this.current.speedMps; + this.current.speedMps = clamp( + this.current.speedMps - dragMps2 * dt * (actions.glide ? 0.7 : 0.25), + 0, + this.options.maximumFlightSpeedMps, + ); + + let thermalLiftMps = 0; + for (const thermal of this.options.crowThermals) { + const normalizedDistance = Math.hypot( + this.current.x - thermal.x, + this.current.z - thermal.z, + ) / thermal.radiusM; + if (normalizedDistance >= 1) continue; + const falloff = 1 - normalizedDistance; + thermalLiftMps += thermal.liftMps * falloff * falloff; + } + this.current.thermalLiftMps = clamp(thermalLiftMps, 0, this.options.maximumClimbSpeedMps * 1.5); + const liftRatio = this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001); + const wingLiftMps = (liftRatio * liftRatio - 0.92) * (actions.glide ? 0.72 : 0.38); + const flapLiftMps = actions.glide ? 0 : (0.18 + throttle * 0.46) * energyAuthority; + const tuckPenaltyMps = tucking ? 1.35 : 0; + this.current.liftMps = wingLiftMps + flapLiftMps + this.current.thermalLiftMps - tuckPenaltyMps; const pitchLift = Math.sin(this.current.pitch) * this.current.speedMps; - const glideSink = actions.glide ? 0.7 : 0.15; - const targetVertical = pitchLift + actions.climb * this.options.maximumClimbSpeedMps - glideSink; + const baselineSink = actions.glide ? (tucking ? 0.95 : 0.62) : 0.18; + const targetVertical = + pitchLift + + actions.climb * this.options.maximumClimbSpeedMps * energyAuthority + + this.current.liftMps - + baselineSink; this.current.verticalSpeedMps = moveToward( this.current.verticalSpeedMps, - clamp(targetVertical, -this.options.maximumClimbSpeedMps, this.options.maximumClimbSpeedMps), + clamp( + targetVertical, + -this.options.maximumClimbSpeedMps * 1.35, + this.options.maximumClimbSpeedMps + this.current.thermalLiftMps, + ), 8 * dt, ); @@ -550,8 +731,8 @@ export class ActorController { const beforeX = this.current.x; const beforeY = this.current.y; const beforeZ = this.current.z; - this.current.x -= Math.sin(this.current.yaw) * horizontalSpeed * dt; - this.current.z -= Math.cos(this.current.yaw) * horizontalSpeed * dt; + this.current.x += (-Math.sin(this.current.yaw) * horizontalSpeed + this.options.crowWind.xMps) * dt; + this.current.z += (-Math.cos(this.current.yaw) * horizontalSpeed + this.options.crowWind.zMps) * dt; this.current.y += this.current.verticalSpeedMps * dt; this.applyHorizontalBounds(); this.applyAltitudeBounds(); @@ -561,9 +742,16 @@ export class ActorController { this.current.z - beforeZ, ); this.current.gliding = actions.glide; - this.current.poseAmount = actions.glide ? 0.22 : 0.55 + throttle * 0.45; + this.current.perched = false; + this.current.crowPose = tucking + ? "tuck" + : actions.glide + ? Math.abs(this.current.roll) > 0.16 ? "bank" : "glide" + : idleSoaring ? "glide" : "flap"; + this.current.poseAmount = actions.glide || idleSoaring ? (tucking ? 0.42 : 0.72) : 0.55 + throttle * 0.45; if (!actions.glide) { - const flapRate = 3.5 + throttle * 4; + // A tired bird loses cadence before it loses basic control authority. + const flapRate = 3.5 + throttle * (2.2 + energyAuthority * 1.8); this.current.posePhase = wrapPhase(this.current.posePhase + flapRate * TWO_PI * dt); } } @@ -572,14 +760,21 @@ export class ActorController { this.current.mode = "ground"; this.current.y = this.options.groundY; this.current.pitch = 0; + this.current.roll = 0; this.current.verticalSpeedMps = 0; + this.current.liftMps = 0; + this.current.thermalLiftMps = 0; this.current.gliding = false; + this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap"; + this.current.perched = this.current.kind === "crow"; this.current.altitudeBoundContact = "none"; } private takeOff(): void { this.current.mode = "flight"; this.current.y = Math.max(this.current.y, this.options.groundY + this.options.minFlightAltitude); + this.current.crowPose = "flap"; + this.current.perched = false; this.current.altitudeBoundContact = "none"; } diff --git a/src/actors/index.ts b/src/actors/index.ts index c723d29..9b9666f 100644 --- a/src/actors/index.ts +++ b/src/actors/index.ts @@ -16,6 +16,9 @@ export { type ActorPosition, type ActorProfile, type ActorReplayResult, + type CrowFlightPoseState, + type CrowThermal, + type CrowWind, type TimedActorInputFrame, } from "./controller.ts"; diff --git a/src/actors/sceneActor.ts b/src/actors/sceneActor.ts index de2f731..8acace1 100644 --- a/src/actors/sceneActor.ts +++ b/src/actors/sceneActor.ts @@ -14,7 +14,7 @@ import { disposeCrow, disposeDog, disposeHumanoid, - poseCrowFlight, + poseCrow, poseDogAttention, poseDogWalk, poseHumanoid, @@ -115,7 +115,6 @@ function buildActor(kind: ActorKind, identity: Readonly): RiggedA kind, rig: buildCrow({ ...(color(appearance?.primaryColor) === undefined ? {} : { featherColor: color(appearance?.primaryColor) }), - ...(color(appearance?.accentColor) === undefined ? {} : { sheenColor: color(appearance?.accentColor) }), }), }; } @@ -209,7 +208,11 @@ export function createSceneActor(options: SceneActorOptions): SceneActor { origin.z + state.z * scale, ); root.rotation.order = "YXZ"; - root.rotation.set(state.mode === "flight" ? state.pitch : 0, state.yaw, 0); + root.rotation.set( + state.mode === "flight" ? state.pitch : 0, + state.yaw, + state.mode === "flight" && state.kind === "crow" ? -state.roll * 0.42 : 0, + ); view.position.copy(root.position); if (actor.kind === "humanoid") { poseHumanoid(actor.rig, { walkPhase: state.posePhase, stride: state.poseAmount * 0.68 }); @@ -217,22 +220,17 @@ export function createSceneActor(options: SceneActorOptions): SceneActor { poseDogWalk(actor.rig, state.posePhase, state.poseAmount * 0.68); poseDogAttention(actor.rig, 0, state.posePhase * 0.7); } else if (state.mode === "flight") { - // A possessed crow is airborne even before the first key arrives. Keep a - // readable soaring silhouette at neutral input; the controller still - // owns phase/intensity as soon as movement begins. - const idleSoar = enabled && state.poseAmount < 1e-4; - poseCrowFlight( - actor.rig, - idleSoar ? -Math.PI / 2 : state.posePhase, - Math.max(state.poseAmount, idleSoar ? 0.58 : 0), - ); - if (idleSoar) { - // The authored wing sheet already extends along local X. A neutral - // chase view needs that broad plan silhouette, not the edge-on middle - // of a flap cycle. - actor.rig.joints.wingLeft.rotation.set(-0.08, 0, 0.08); - actor.rig.joints.wingRight.rotation.set(-0.08, 0, -0.08); - } + // The controller names the aerodynamic state; the rig owns how that + // state bends shoulder, elbow, wrist, tail and feet. + poseCrow(actor.rig, { + state: state.crowPose, + phase: state.posePhase, + amount: Math.max(state.poseAmount, enabled ? 0.68 : 0.5), + bank: THREE.MathUtils.clamp(state.roll / 0.62, -1, 1), + flight: 1, + }); + } else { + poseCrow(actor.rig, { state: "perch", amount: 1, flight: 0 }); } } diff --git a/src/assets/actors/crow.ts b/src/assets/actors/crow.ts index e172202..36d0734 100644 --- a/src/assets/actors/crow.ts +++ b/src/assets/actors/crow.ts @@ -1,5 +1,13 @@ -/** A low-cost anonymous Tera crow, ready to perch or flap in flight. */ +/** + * Procedural American-crow-scale actor with a readable rear flight silhouette. + * + * The rig remains code-only and cheap to clone, but the wing is no longer one + * flat polygon. Shoulder, elbow and wrist joints carry overlapping covert, + * secondary and primary feathers, so the renderer can describe a flap, glide, + * bank, tuck or perch without replacing geometry. + */ import * as THREE from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; import { actorMesh, disposeActor, @@ -8,7 +16,25 @@ import { type ActorRigBase, } from "./common.ts"; -export const CROW_METRICS = { bodyLength: 0.42, perchedHeight: 0.34, wingspan: 0.84 } as const; +export const CROW_METRICS = { + bodyLength: 0.48, + perchedHeight: 0.44, + wingspan: 1.02, +} as const; + +export type CrowPoseState = "flap" | "glide" | "bank" | "tuck" | "perch"; + +export interface CrowPose { + state: CrowPoseState; + /** Cyclic flap phase in radians. */ + phase?: number; + /** Pose strength in [0, 1]. */ + amount?: number; + /** Signed bank request in [-1, 1]; positive banks right. */ + bank?: number; + /** Tucks the feet as this reaches one. */ + flight?: number; +} export interface CrowMaterials { feather: THREE.Material; @@ -27,9 +53,18 @@ export interface CrowBuildOptions { export interface CrowJoints { body: THREE.Group; head: THREE.Group; + /** Backwards-compatible aliases for the shoulder joints. */ wingLeft: THREE.Group; wingRight: THREE.Group; + shoulderLeft: THREE.Group; + shoulderRight: THREE.Group; + elbowLeft: THREE.Group; + elbowRight: THREE.Group; + wristLeft: THREE.Group; + wristRight: THREE.Group; tail: THREE.Group; + legLeft: THREE.Group; + legRight: THREE.Group; } export interface CrowRig extends ActorRigBase { @@ -38,124 +73,430 @@ export interface CrowRig extends ActorRigBase { export function createCrowMaterials(options: CrowBuildOptions = {}): CrowMaterials { return { - // Wing sheets are intentionally thin. Both faces must render because a - // chase camera sees their backs while a flyover camera sees their fronts. - feather: new THREE.MeshStandardMaterial({ name: "crow.feather", color: options.featherColor ?? 0x111519, roughness: 0.62, metalness: 0.12, side: THREE.DoubleSide }), - sheen: new THREE.MeshStandardMaterial({ name: "crow.sheen", color: options.sheenColor ?? 0x1f2c36, roughness: 0.4, metalness: 0.28 }), - beak: new THREE.MeshStandardMaterial({ name: "crow.beak", color: 0x202327, roughness: 0.78 }), - eye: new THREE.MeshBasicMaterial({ name: "crow.eye", color: 0xd4b168, toneMapped: false }), - foot: new THREE.MeshStandardMaterial({ name: "crow.foot", color: 0x24272a, roughness: 0.9 }), + // A blue-violet highlight keeps the bird readable against black terrain + // without painting a naturally black animal grey. + feather: new THREE.MeshStandardMaterial({ + name: "crow.feather", + color: options.featherColor ?? 0x111821, + roughness: 0.68, + metalness: 0.08, + emissive: 0x07111d, + emissiveIntensity: 0.24, + side: THREE.DoubleSide, + }), + sheen: new THREE.MeshPhysicalMaterial({ + name: "crow.sheen", + color: options.sheenColor ?? 0x253547, + roughness: 0.34, + metalness: 0.22, + clearcoat: 0.28, + clearcoatRoughness: 0.42, + emissive: 0x0b1729, + emissiveIntensity: 0.32, + }), + beak: new THREE.MeshStandardMaterial({ + name: "crow.beak", + color: 0x252a30, + roughness: 0.72, + metalness: 0.08, + }), + eye: new THREE.MeshBasicMaterial({ name: "crow.eye", color: 0xd9b86c, toneMapped: false }), + foot: new THREE.MeshStandardMaterial({ name: "crow.foot", color: 0x2b2f35, roughness: 0.9 }), }; } -function wingGeometry(side: -1 | 1): THREE.BufferGeometry { - const s = side; +/** A small double-sided feather prism, rooted at z=0 and tapered along +Z. */ +function featherGeometry(): THREE.BufferGeometry { + const outline: readonly [number, number][] = [ + [-0.28, 0], + [-0.5, 0.46], + [-0.42, 0.76], + [-0.18, 0.95], + [0, 1], + [0.18, 0.95], + [0.42, 0.76], + [0.5, 0.46], + [0.28, 0], + ]; + const halfThickness = 0.012; + const positions: number[] = []; + for (const y of [halfThickness, -halfThickness]) { + for (const [x, z] of outline) positions.push(x, y, z); + } + const count = outline.length; + const indices: number[] = []; + // Top and bottom are triangle fans around the root-side midpoint. + for (let index = 1; index < count - 1; index += 1) { + indices.push(0, index, index + 1); + indices.push(count, count + index + 1, count + index); + } + for (let index = 0; index < count; index += 1) { + const next = (index + 1) % count; + indices.push(index, count + index, next, next, count + index, count + next); + } const geometry = new THREE.BufferGeometry(); - geometry.setAttribute( - "position", - new THREE.Float32BufferAttribute([ - 0, 0, 0.04, - s * 0.32, -0.015, 0.1, - s * 0.4, -0.03, 0.2, - s * 0.18, -0.015, -0.14, - 0, 0, -0.16, - ], 3), - ); - geometry.setIndex([0, 1, 3, 1, 2, 3, 0, 3, 4]); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setIndex(indices); geometry.computeVertexNormals(); - geometry.name = `crow.wing.${side < 0 ? "left" : "right"}`; + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + geometry.name = "crow.feather-blade"; return geometry; } +function beakGeometry(): THREE.BufferGeometry { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute([ + -0.054, 0.025, 0, + 0.054, 0.025, 0, + -0.043, -0.035, 0, + 0.043, -0.035, 0, + 0, -0.018, -0.19, + ], 3)); + geometry.setIndex([ + 0, 1, 4, + 1, 3, 4, + 3, 2, 4, + 2, 0, 4, + 0, 2, 3, + 0, 3, 1, + ]); + geometry.computeVertexNormals(); + geometry.name = "crow.beak-wedge"; + return geometry; +} + +interface FeatherPlacement { + position: readonly [number, number, number]; + scale: readonly [number, number, number]; + rotation?: readonly [number, number, number]; +} + +/** Merge one articulated feather layer into one draw call. */ +function featherBatch( + name: string, + material: THREE.Material, + placements: readonly FeatherPlacement[], + blade: THREE.BufferGeometry, +): THREE.Mesh { + const parts = placements.map((placement) => { + const matrix = new THREE.Matrix4().compose( + new THREE.Vector3(...placement.position), + new THREE.Quaternion().setFromEuler(new THREE.Euler(...(placement.rotation ?? [0, 0, 0]), "XYZ")), + new THREE.Vector3(...placement.scale), + ); + return blade.clone().applyMatrix4(matrix); + }); + const geometry = mergeGeometries(parts, false); + for (const part of parts) part.dispose(); + if (!geometry) throw new Error(`crow: could not merge feather layer "${name}"`); + geometry.name = `${name}.geometry`; + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + const mesh = actorMesh(name, geometry, material, { receiveShadow: false }); + mesh.userData.featherCount = placements.length; + return mesh; +} + +function addWing( + body: THREE.Group, + side: -1 | 1, + materials: CrowMaterials, + featherBlade: THREE.BufferGeometry, +): { shoulder: THREE.Group; elbow: THREE.Group; wrist: THREE.Group } { + const word = side < 0 ? "left" : "right"; + const shoulder = namedGroup(`crow.wing.${word}`, [side * 0.09, 0.055, -0.035]); + const elbow = namedGroup(`crow.elbow.${word}`, [side * 0.175, 0, 0.012]); + const wrist = namedGroup(`crow.wrist.${word}`, [side * 0.165, -0.004, 0.045]); + shoulder.add(elbow); + elbow.add(wrist); + body.add(shoulder); + + // Rounded scapular coverts bridge the torso to the articulated arm. + const coverts: FeatherPlacement[] = []; + for (let index = 0; index < 4; index += 1) { + coverts.push({ + position: [side * (0.025 + index * 0.035), 0.018 - index * 0.004, -0.035 + index * 0.018], + scale: [0.09, 0.72, 0.18 + index * 0.012], + rotation: [0.06, side * (-0.05 - index * 0.035), 0], + }); + } + shoulder.add(featherBatch(`crow.coverts.${word}`, materials.sheen, coverts, featherBlade)); + + // The secondaries overlap from elbow to wrist and create the broad inner + // trailing edge visible in the accepted rear-view direction. + const innerSecondaries: FeatherPlacement[] = []; + const outerSecondaries: FeatherPlacement[] = []; + for (let index = 0; index < 7; index += 1) { + const along = index / 6; + (index < 3 ? innerSecondaries : outerSecondaries).push({ + position: [side * (0.015 + along * 0.155), -0.006 - along * 0.005, 0.018 + along * 0.014], + scale: [0.095 - along * 0.014, 0.72, 0.26 + along * 0.055], + rotation: [0.02 + along * 0.05, side * (-0.1 - along * 0.16), 0], + }); + } + elbow.add( + featherBatch(`crow.secondaries-inner.${word}`, materials.sheen, innerSecondaries, featherBlade), + featherBatch(`crow.secondaries-outer.${word}`, materials.feather, outerSecondaries, featherBlade), + ); + + // Long, individually separated primaries fan from the wrist. Their small + // angular and length progression gives a clear fingertip silhouette. + const primaries: FeatherPlacement[] = []; + for (let index = 0; index < 9; index += 1) { + const along = index / 8; + primaries.push({ + position: [side * (0.01 + along * 0.095), -0.014 - along * 0.003, 0.025 + along * 0.01], + scale: [0.074 - along * 0.014, 0.78, 0.34 - along * 0.075], + rotation: [0.015 + along * 0.035, side * (-0.2 - along * 0.42), side * (along - 0.5) * 0.035], + }); + } + wrist.add(featherBatch(`crow.primaries.${word}`, materials.feather, primaries, featherBlade)); + return { shoulder, elbow, wrist }; +} + +function addFoot(body: THREE.Group, side: -1 | 1, material: THREE.Material): THREE.Group { + const word = side < 0 ? "left" : "right"; + const leg = namedGroup(`crow.leg.${word}`, [side * 0.052, -0.115, 0.015]); + leg.add(actorMesh( + `crow.shank.${word}`, + new THREE.CylinderGeometry(0.009, 0.007, 0.12, 7), + material, + { position: [0, -0.052, 0.015], rotation: [0.2, 0, 0] }, + )); + const foot = namedGroup(`crow.foot.${word}`, [0, -0.108, -0.002]); + leg.add(foot); + for (let index = 0; index < 3; index += 1) { + const angle = (index - 1) * 0.42; + foot.add(actorMesh( + `crow.toe.${word}.${index}`, + new THREE.CylinderGeometry(0.0045, 0.003, 0.075, 6), + material, + { + position: [Math.sin(angle) * 0.025, -0.004, -0.028 - Math.cos(angle) * 0.006], + rotation: [Math.PI / 2, 0, -angle], + }, + )); + } + foot.add(actorMesh( + `crow.toe.${word}.rear`, + new THREE.CylinderGeometry(0.004, 0.003, 0.055, 6), + material, + { position: [0, -0.005, 0.022], rotation: [Math.PI / 2, 0, 0] }, + )); + body.add(leg); + return leg; +} + function resolveCrow(root: THREE.Group, ownsMaterials: boolean): CrowRig { + const wingLeft = requireGroup(root, "crow.wing.left"); + const wingRight = requireGroup(root, "crow.wing.right"); return { root, ownsMaterials, joints: { body: requireGroup(root, "crow.body"), head: requireGroup(root, "crow.head"), - wingLeft: requireGroup(root, "crow.wing.left"), - wingRight: requireGroup(root, "crow.wing.right"), + wingLeft, + wingRight, + shoulderLeft: wingLeft, + shoulderRight: wingRight, + elbowLeft: requireGroup(root, "crow.elbow.left"), + elbowRight: requireGroup(root, "crow.elbow.right"), + wristLeft: requireGroup(root, "crow.wrist.left"), + wristRight: requireGroup(root, "crow.wrist.right"), tail: requireGroup(root, "crow.tail"), + legLeft: requireGroup(root, "crow.leg.left"), + legRight: requireGroup(root, "crow.leg.right"), }, }; } export function buildCrow(options: CrowBuildOptions = {}): CrowRig { - const m = options.materials ?? createCrowMaterials(options); + const materials = options.materials ?? createCrowMaterials(options); + const featherBlade = featherGeometry(); const root = namedGroup("crow"); root.userData.kind = "actor"; root.userData.actorType = "anonymous-crow"; root.userData.forwardAxis = "-Z"; - const body = namedGroup("crow.body", [0, 0.2, 0]); + root.userData.rigVersion = 2; + + const body = namedGroup("crow.body", [0, 0.225, 0]); root.add(body); body.add( - actorMesh("crow.torso", new THREE.SphereGeometry(0.13, 12, 9), m.feather, { - rotation: [-0.16, 0, 0], - scale: [0.82, 1.08, 1.34], + actorMesh("crow.torso", new THREE.SphereGeometry(0.14, 18, 12), materials.feather, { + rotation: [-0.12, 0, 0], + scale: [0.82, 0.94, 1.58], }), - actorMesh("crow.breast", new THREE.SphereGeometry(0.105, 10, 8), m.sheen, { - position: [0, 0.012, -0.105], - scale: [0.72, 1, 0.58], + actorMesh("crow.breast", new THREE.SphereGeometry(0.112, 16, 10), materials.sheen, { + position: [0, -0.006, -0.112], + rotation: [-0.18, 0, 0], + scale: [0.76, 1.03, 0.72], + }), + actorMesh("crow.mantle", new THREE.SphereGeometry(0.116, 16, 10), materials.sheen, { + position: [0, 0.057, 0.028], + scale: [0.82, 0.62, 1.12], + }), + actorMesh("crow.nape", new THREE.SphereGeometry(0.096, 16, 10), materials.sheen, { + position: [0, 0.086, -0.088], + rotation: [-0.18, 0, 0], + scale: [0.86, 0.74, 1.2], }), ); - const head = namedGroup("crow.head", [0, 0.145, -0.105]); + + // Small overlapping mantle feathers break up the old featureless pawn back. + const crownMantle: FeatherPlacement[] = []; + const lowerMantle: FeatherPlacement[] = []; + for (let row = 0; row < 3; row += 1) { + for (let column = -1; column <= 1; column += 1) { + (row === 0 ? crownMantle : lowerMantle).push({ + position: [column * (0.036 - row * 0.004), 0.105 - row * 0.026, 0.02 + row * 0.045], + scale: [0.07 - row * 0.006, 0.65, 0.1 + row * 0.014], + rotation: [0.28, column * -0.08, 0], + }); + } + } + body.add( + featherBatch("crow.mantle-feathers.crown", materials.sheen, crownMantle, featherBlade), + featherBatch("crow.mantle-feathers.lower", materials.feather, lowerMantle, featherBlade), + ); + + const head = namedGroup("crow.head", [0, 0.108, -0.15]); body.add(head); head.add( - actorMesh("crow.skull", new THREE.SphereGeometry(0.093, 12, 9), m.feather, { scale: [0.92, 1, 0.95] }), - actorMesh("crow.beak", new THREE.ConeGeometry(0.055, 0.18, 6), m.beak, { - position: [0, -0.018, -0.145], - rotation: [-Math.PI / 2, 0, 0], - scale: [0.72, 1, 0.68], + actorMesh("crow.skull", new THREE.SphereGeometry(0.098, 18, 12), materials.feather, { + scale: [0.88, 0.84, 1.14], }), + actorMesh("crow.crown-sheen", new THREE.SphereGeometry(0.09, 16, 10, 0, Math.PI * 2, 0, Math.PI * 0.52), materials.sheen, { + position: [0, 0.012, -0.003], + scale: [0.92, 0.62, 0.96], + }), + actorMesh("crow.beak", beakGeometry(), materials.beak, { position: [0, -0.018, -0.073] }), ); for (const side of [-1, 1] as const) { const word = side < 0 ? "left" : "right"; head.add( - actorMesh(`crow.eye.${word}`, new THREE.SphereGeometry(0.011, 7, 5), m.eye, { - position: [side * 0.068, 0.02, -0.06], + actorMesh(`crow.eye-rim.${word}`, new THREE.SphereGeometry(0.016, 8, 6), materials.beak, { + position: [side * 0.068, 0.022, -0.058], + scale: [0.55, 1, 0.82], + }), + actorMesh(`crow.eye.${word}`, new THREE.SphereGeometry(0.008, 8, 6), materials.eye, { + position: [side * 0.076, 0.023, -0.063], }), ); - const wing = namedGroup(`crow.wing.${word}`, [side * 0.075, 0.035, 0]); - body.add(wing); - wing.add(actorMesh(`crow.wing-mesh.${word}`, wingGeometry(side), m.feather, { receiveShadow: false })); } - const tail = namedGroup("crow.tail", [0, -0.01, 0.13]); + + addWing(body, -1, materials, featherBlade); + addWing(body, 1, materials, featherBlade); + + const tail = namedGroup("crow.tail", [0, -0.025, 0.132]); body.add(tail); - for (const side of [-1, 0, 1] as const) { - tail.add( - actorMesh(`crow.tail-feather.${side}`, new THREE.ConeGeometry(0.045, 0.25, 4), m.feather, { - position: [side * 0.04, -0.015, 0.12], - rotation: [Math.PI / 2, 0, 0], - scale: [0.72, 1, 0.35], - }), - ); + const centralTail: FeatherPlacement[] = []; + const outerTail: FeatherPlacement[] = []; + for (let index = 0; index < 9; index += 1) { + const fan = (index - 4) / 4; + (Math.abs(fan) < 0.5 ? centralTail : outerTail).push({ + position: [fan * 0.052, -Math.abs(fan) * 0.004, 0], + scale: [0.082, 0.8, 0.285 - Math.abs(fan) * 0.035], + rotation: [0.04, fan * -0.44, fan * -0.045], + }); } - for (const side of [-1, 1] as const) { - body.add( - actorMesh(`crow.foot.${side < 0 ? "left" : "right"}`, new THREE.CylinderGeometry(0.012, 0.009, 0.14, 6), m.foot, { - position: [side * 0.048, -0.14, -0.012], - }), - ); - } - return resolveCrow(root, !options.materials); + tail.add( + featherBatch("crow.tail-feathers.central", materials.sheen, centralTail, featherBlade), + featherBatch("crow.tail-feathers.outer", materials.feather, outerTail, featherBlade), + ); + + addFoot(body, -1, materials.foot); + addFoot(body, 1, materials.foot); + featherBlade.dispose(); + + const rig = resolveCrow(root, options.materials === undefined); + poseCrow(rig, { state: "perch", amount: 1, flight: 0 }); + return rig; } export function cloneCrow(source: CrowRig): CrowRig { return resolveCrow(source.root.clone(true), false); } -/** Pose a flap. `amount=0` folds the wings; `amount=1` is a broad flight stroke. */ +function poseWing( + shoulder: THREE.Group, + elbow: THREE.Group, + wrist: THREE.Group, + side: -1 | 1, + pose: Required, +): void { + const stroke = Math.sin(pose.phase); + const bankLift = pose.bank * side; + if (pose.state === "perch") { + elbow.position.x = side * 0.035; + wrist.position.x = side * 0.018; + shoulder.rotation.set(0.08, 0, side * 0.06); + elbow.rotation.set(0.08, 0, side * -0.1); + wrist.rotation.set(-0.06, 0, side * -0.06); + return; + } + if (pose.state === "tuck") { + elbow.position.x = side * 0.065; + wrist.position.x = side * 0.035; + shoulder.rotation.set(-0.05, 0, side * 0.16); + elbow.rotation.set(0.08, 0, side * -0.3); + wrist.rotation.set(-0.08, 0, side * -0.2); + return; + } + + elbow.position.x = side * 0.175; + wrist.position.x = side * 0.165; + const flap = pose.state === "flap" ? stroke * 0.72 * pose.amount : 0; + const glideDihedral = pose.state === "glide" || pose.state === "bank" ? 0.09 : 0.15; + shoulder.rotation.set(-0.06 - Math.max(0, stroke) * 0.05, side * -0.08, side * (glideDihedral + flap + bankLift * 0.2)); + elbow.rotation.set(0.025, side * (-0.1 - Math.max(0, -stroke) * 0.1), side * (-0.05 + flap * 0.22 + bankLift * 0.12)); + wrist.rotation.set(-0.025, side * (-0.16 - pose.amount * 0.06), side * (-0.035 + flap * 0.12 + bankLift * 0.08)); +} + +/** Apply an explicit production pose while preserving the legacy rig contract. */ +export function poseCrow(rig: CrowRig, value: CrowPose): void { + const pose: Required = { + state: value.state, + phase: Number.isFinite(value.phase) ? value.phase ?? 0 : 0, + amount: THREE.MathUtils.clamp(Number.isFinite(value.amount) ? value.amount ?? 1 : 1, 0, 1), + bank: THREE.MathUtils.clamp(Number.isFinite(value.bank) ? value.bank ?? 0 : 0, -1, 1), + flight: THREE.MathUtils.clamp(Number.isFinite(value.flight) ? value.flight ?? 1 : 1, 0, 1), + }; + poseWing(rig.joints.shoulderLeft, rig.joints.elbowLeft, rig.joints.wristLeft, -1, pose); + poseWing(rig.joints.shoulderRight, rig.joints.elbowRight, rig.joints.wristRight, 1, pose); + + const flapBob = pose.state === "flap" ? Math.cos(pose.phase) * 0.045 * pose.amount : 0; + rig.joints.body.rotation.set(flapBob, 0, -pose.bank * 0.12); + rig.joints.head.rotation.set(-flapBob * 0.6, pose.bank * -0.08, pose.bank * 0.08); + rig.joints.tail.rotation.set( + pose.state === "tuck" ? -0.24 : pose.state === "perch" ? 0.18 : -0.03 - flapBob, + 0, + -pose.bank * 0.16, + ); + const tailSpread = pose.state === "perch" + ? 0.78 + : pose.state === "tuck" + ? 0.62 + : pose.state === "flap" + ? 1.2 + : 1.48 + Math.abs(pose.bank) * 0.18; + rig.joints.tail.scale.x = tailSpread; + + const legTuck = pose.state === "perch" ? 0 : 0.22 + pose.flight * 0.62; + rig.joints.legLeft.rotation.x = legTuck; + rig.joints.legRight.rotation.x = legTuck; + rig.joints.legLeft.rotation.z = pose.state === "perch" ? -0.035 : 0.08; + rig.joints.legRight.rotation.z = pose.state === "perch" ? 0.035 : -0.08; + rig.root.userData.pose = pose.state; +} + +/** + * Backwards-compatible flap helper. New adapters should prefer `poseCrow` so a + * glide, bank, tuck and perch is semantic rather than inferred from amplitude. + */ export function poseCrowFlight(rig: CrowRig, phase: number, amount = 1): void { - const strength = THREE.MathUtils.clamp(amount, 0, 1); - const stroke = Math.sin(phase) * 0.72 * strength; - const spread = 0.2 + strength * 0.74; - rig.joints.wingLeft.rotation.z = spread + stroke; - rig.joints.wingRight.rotation.z = -spread - stroke; - rig.joints.wingLeft.rotation.x = -0.12 * strength; - rig.joints.wingRight.rotation.x = -0.12 * strength; - rig.joints.body.rotation.x = 0.08 * Math.cos(phase) * strength; - rig.joints.tail.rotation.x = -0.12 * Math.cos(phase) * strength; + poseCrow(rig, { state: amount <= 0.25 ? "glide" : "flap", phase, amount, flight: 1 }); } export function disposeCrow(rig: CrowRig, options: { disposeMaterials?: boolean } = {}): void { diff --git a/src/assets/actors/index.ts b/src/assets/actors/index.ts index 9eb79c4..5933736 100644 --- a/src/assets/actors/index.ts +++ b/src/assets/actors/index.ts @@ -32,9 +32,12 @@ export { cloneCrow, createCrowMaterials, disposeCrow, + poseCrow, poseCrowFlight, type CrowBuildOptions, type CrowJoints, type CrowMaterials, + type CrowPose, + type CrowPoseState, type CrowRig, } from "./crow.ts"; diff --git a/src/main.ts b/src/main.ts index d94d04d..73b32bb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -904,7 +904,7 @@ async function mountCity(id: string) { minFlightAltitude: 20, maxFlightAltitude: 1_500, ...(access.subject === null - ? { camera: { distance: 1.55, height: 1.35, targetHeight: 0.18, lookAhead: 0 } } + ? { camera: { distance: 2.55, height: 1.2, targetHeight: 0.1, lookAhead: 1.3 } } : {}), }, ...(id === "california" @@ -1763,7 +1763,7 @@ function renderLegend() { : flying ? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.` : exploring - ? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move.` + ? `${cityLabel}, following your ${city.actorState()?.kind ?? "actor"}. Use W A S D to move, Q and E for altitude, and G to glide.` : `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`, ); } @@ -1774,7 +1774,17 @@ function renderLegend() { officePlan?.setActiveView(office?.current() ?? null); renderOfficeBadge(); if (driveControls) driveControls.hidden = !routeDriveIsActive(); - if (walkControls) walkControls.hidden = !(walking || exploring || flying); + if (walkControls) { + walkControls.hidden = !(walking || exploring || flying); + walkControls.setAttribute( + "aria-label", + flying + ? "Aircraft flight controls" + : exploring && city.actorState()?.kind === "crow" + ? "Crow flight controls" + : "Walking controls", + ); + } for (const control of walkControls?.querySelectorAll("[data-walk-key]") ?? []) { control.textContent = flying ? control.dataset.aircraftLabel ?? control.textContent @@ -1786,6 +1796,9 @@ function renderLegend() { for (const control of walkControls?.querySelectorAll(".aircraft-only") ?? []) { control.hidden = !flying; } + for (const control of walkControls?.querySelectorAll(".crow-only") ?? []) { + control.hidden = inside || flying || city.actorState()?.kind !== "crow"; + } if (driveHint) driveHint.hidden = inside || cityId !== "california" || flying; if (walkHint) { walkHint.hidden = !(inside || city.actorState() || city.aircraftState()); @@ -1793,7 +1806,7 @@ function renderLegend() { ? "WASD fly · P assisted · R reset" : inside ? "V walk · WASD move" - : "V explore · WASD move"; + : "V explore · WASD · Q/E altitude · G glide"; } } @@ -3107,6 +3120,7 @@ function publishCityActorActions(): boolean { sprint: heldDriveKeys.has(" "), climb: (heldDriveKeys.has("e") || heldDriveKeys.has(" ") ? 1 : 0) - (heldDriveKeys.has("q") ? 1 : 0), + glide: heldDriveKeys.has("g"), }); return true; } @@ -3275,7 +3289,7 @@ window.addEventListener("keydown", (event) => { const lower = event.key.toLowerCase(); if ( lower === "w" || lower === "a" || lower === "s" || lower === "d" || - lower === "q" || lower === "e" || event.key === " " + lower === "q" || lower === "e" || lower === "g" || event.key === " " ) { heldDriveKeys.add(event.key === " " ? " " : lower); if (publishVehicleActions()) { diff --git a/src/test/actorAssets.test.ts b/src/test/actorAssets.test.ts index 310ee5f..42eec17 100644 --- a/src/test/actorAssets.test.ts +++ b/src/test/actorAssets.test.ts @@ -14,6 +14,7 @@ import { disposeCrow, disposeDog, disposeHumanoid, + poseCrow, poseCrowFlight, poseDogAttention, poseDogWalk, @@ -100,6 +101,52 @@ describe("procedural actor assets", () => { disposeCrow(rig); }); + it("builds a layered v2 feather rig with shoulder, elbow, wrist, tail and claw anatomy", () => { + const rig = buildCrow(); + assert.equal(rig.root.userData.rigVersion, 2); + assert.equal(rig.joints.elbowLeft.parent, rig.joints.shoulderLeft); + assert.equal(rig.joints.wristLeft.parent, rig.joints.elbowLeft); + assert.equal(rig.joints.elbowRight.parent, rig.joints.shoulderRight); + assert.equal(rig.joints.wristRight.parent, rig.joints.elbowRight); + + const allMeshes = meshes(rig.root); + const featherCount = (prefix: string) => allMeshes + .filter((mesh) => mesh.name.startsWith(prefix)) + .reduce((sum, mesh) => sum + Number(mesh.userData.featherCount ?? 0), 0); + assert.equal(featherCount("crow.primaries."), 18); + assert.equal(featherCount("crow.secondaries-"), 14); + assert.equal(featherCount("crow.tail-feathers."), 9); + assert.equal(allMeshes.filter((mesh) => mesh.name.startsWith("crow.toe.")).length, 8); + assert.ok(allMeshes.length <= 36, `${allMeshes.length} meshes keeps one crow bounded for mobile/peers`); + disposeCrow(rig); + }); + + it("poses a materially different glide, bank, tuck and perch without rebuilding geometry", () => { + const rig = buildCrow(); + const width = () => new THREE.Box3().setFromObject(rig.root).getSize(new THREE.Vector3()).x; + + poseCrow(rig, { state: "glide", amount: 0.8, flight: 1 }); + const glideWidth = width(); + assert.equal(rig.root.userData.pose, "glide"); + assert.ok(Math.abs(glideWidth - CROW_METRICS.wingspan) < 0.12, `glide width ${glideWidth}`); + + poseCrow(rig, { state: "bank", amount: 0.8, bank: 0.75, flight: 1 }); + assert.equal(rig.root.userData.pose, "bank"); + assert.notEqual(rig.joints.shoulderLeft.rotation.z, -rig.joints.shoulderRight.rotation.z, + "bank makes the wing dihedral asymmetric"); + assert.ok(rig.joints.tail.rotation.z < 0, "tail counters a right bank"); + + poseCrow(rig, { state: "tuck", amount: 1, flight: 1 }); + const tuckWidth = width(); + poseCrow(rig, { state: "perch", amount: 1, flight: 0 }); + const perchWidth = width(); + assert.ok(tuckWidth < glideWidth, `${tuckWidth} tuck < ${glideWidth} glide`); + assert.ok(perchWidth < tuckWidth, `${perchWidth} perch < ${tuckWidth} tuck`); + assert.equal(rig.joints.legLeft.rotation.x, 0); + assert.equal(rig.joints.legRight.rotation.x, 0); + disposeCrow(rig); + }); + it("never disposes caller-owned materials unless explicitly requested", () => { const source = buildCrow(); const shared = meshes(source.root)[0]!.material as THREE.Material; diff --git a/src/test/actorController.test.ts b/src/test/actorController.test.ts index 1f54bb7..54ed4a7 100644 --- a/src/test/actorController.test.ts +++ b/src/test/actorController.test.ts @@ -129,6 +129,76 @@ describe("crow flight", () => { controller.stepFixed({ modeRequest: "ground" }); assert.equal(controller.state().mode, "ground"); assert.equal(controller.state().y, 12); + assert.equal(controller.state().crowPose, "perch"); + assert.equal(controller.state().perched, true); + }); + + it("models deterministic wind, radial thermal lift, banking and finite energy", () => { + const options = groundOptions({ + kind: "crow", + mode: "flight", + position: { x: 0, y: 20, z: 0 }, + minFlightAltitude: 2, + maxFlightAltitude: 80, + crowWind: { xMps: 2, zMps: -0.5 }, + crowThermals: [{ x: 0, z: 0, radiusM: 100, liftMps: 2.5 }], + crowInitialEnergy: 0.6, + crowEnergyDrainPerSecond: 0.1, + crowEnergyRecoveryPerSecond: 0.2, + }); + const first = new ActorController(options); + const replay = new ActorController(options); + for (let index = 0; index < 10; index += 1) { + const actions = { forward: 1, turn: 0.7, climb: 0.1 }; + first.stepFixed(actions); + replay.stepFixed(actions); + } + assert.deepEqual(first.snapshot(), replay.snapshot()); + assert.ok(first.state().x > 0, "the configured eastward air mass advects the bird"); + assert.ok(first.state().thermalLiftMps > 2, "the bird remains near the thermal core"); + assert.ok(first.state().liftMps > first.state().thermalLiftMps); + assert.ok(first.state().roll > 0); + assert.equal(first.state().crowPose, "flap"); + assert.ok(first.state().flightEnergy < 0.6 && first.state().flightEnergy >= 0); + + const spent = first.state().flightEnergy; + for (let index = 0; index < 10; index += 1) first.stepFixed({ glide: true, turn: 0.7 }); + assert.ok(first.state().flightEnergy > spent); + assert.equal(first.state().crowPose, "bank"); + assert.equal(first.state().gliding, true); + + first.stepFixed({ forward: -1, glide: true, pitch: -1 }); + assert.equal(first.state().crowPose, "tuck"); + assert.ok(first.state().flightEnergy >= 0 && first.state().flightEnergy <= 1); + }); + + it("replays wind and thermal flight bit-for-bit across powered, glide and tuck states", () => { + const options = groundOptions({ + kind: "crow", + mode: "flight", + position: { x: -10, y: 15, z: 5 }, + minFlightAltitude: 2, + maxFlightAltitude: 100, + crowWind: { xMps: -1.25, zMps: 0.8 }, + crowThermals: [ + { x: -10, z: 5, radiusM: 45, liftMps: 3.2 }, + { x: 80, z: -20, radiusM: 25, liftMps: 1.4 }, + ], + crowInitialEnergy: 0.82, + }); + const frames = [ + { steps: 90, actions: { forward: 0.8, turn: 0.3, climb: 0.2 } }, + { steps: 45, actions: { glide: true, turn: -0.7 } }, + { steps: 20, actions: { forward: -1, glide: true, pitch: -0.7 } }, + { steps: 30, actions: { forward: 0.4, pitch: 0.2 } }, + ]; + const first = replayActorInputs(options, frames); + const second = replayActorInputs(options, frames); + assert.deepEqual(first, second); + assert.equal(first.final.elapsedSteps, 185); + assert.ok(first.trajectory.every((state) => + state.flightEnergy >= 0 && state.flightEnergy <= 1 && + Number.isFinite(state.liftMps) && Number.isFinite(state.roll))); }); }); @@ -179,6 +249,10 @@ describe("actor identity, reset and replay", () => { it("rejects invalid configuration and identity before creating state", () => { assert.throws(() => new ActorController(groundOptions({ minFlightAltitude: 5, maxFlightAltitude: 2 })), RangeError); assert.throws(() => new ActorController(groundOptions({ walkSpeedMps: 0 })), RangeError); + assert.throws(() => new ActorController(groundOptions({ crowEnergyDrainPerSecond: 0 })), RangeError); + assert.throws(() => new ActorController(groundOptions({ + crowThermals: [{ x: 0, z: 0, radiusM: 0, liftMps: 2 }], + })), RangeError); assert.throws(() => new ActorController(groundOptions({ horizontalBounds: { minX: 2, maxX: 1, minZ: 0, maxZ: 1 }, })), RangeError); diff --git a/src/test/sceneActor.test.ts b/src/test/sceneActor.test.ts index d089195..5b6457e 100644 --- a/src/test/sceneActor.test.ts +++ b/src/test/sceneActor.test.ts @@ -66,6 +66,13 @@ describe("playable city scene actor", () => { assert.ok(actor.state().y > 4 && actor.state().y <= 6); assert.equal(actor.root.rotation.order, "YXZ"); assert.equal(actor.root.rotation.x, actor.state().pitch); + const crow = actor.root.getObjectByName("crow"); + assert.equal(crow?.userData.pose, "flap"); + actor.setActions({ glide: true, turn: 1 }); + actor.tick(0.3); + assert.equal(actor.state().crowPose, "bank"); + assert.equal(crow?.userData.pose, "bank"); + assert.notEqual(actor.root.rotation.z, 0, "the scene root presents controller-owned bank"); const pose = actor.followPose(); assert.ok(pose.position.toArray().every(Number.isFinite)); assert.ok(pose.target.toArray().every(Number.isFinite));