diff --git a/src/engine/ChunkManager.ts b/src/engine/ChunkManager.ts index 935373e..31d99c3 100644 --- a/src/engine/ChunkManager.ts +++ b/src/engine/ChunkManager.ts @@ -120,13 +120,16 @@ export default class ChunkManager { } filterByFrustum(planes: Plane[]): ChunkGPUData[] { + // maxTerrainHeight: heightmap values reach up to 10.0 (Mountains HeightMultiplier) + // multiplied by HEIGHT_SCALE=64 → 640 world units max. + const maxTerrainHeight = HEIGHT_SCALE * 10 return this.activeChunks.filter((chunk) => { const cx = chunk.coord.x const cz = chunk.coord.z return testAABB( planes, - cx * CHUNK_SIZE, -HEIGHT_SCALE, cz * CHUNK_SIZE, - (cx + 1) * CHUNK_SIZE, HEIGHT_SCALE, (cz + 1) * CHUNK_SIZE, + cx * CHUNK_SIZE, 0, cz * CHUNK_SIZE, + (cx + 1) * CHUNK_SIZE, maxTerrainHeight, (cz + 1) * CHUNK_SIZE, ) }) } diff --git a/src/engine/FPSCamera.test.ts b/src/engine/FPSCamera.test.ts index c7ddb0c..2510696 100644 --- a/src/engine/FPSCamera.test.ts +++ b/src/engine/FPSCamera.test.ts @@ -8,6 +8,7 @@ const defaultPlayer: PlayerState = { velocityY: 0, grounded: true, sprinting: false, coyoteFrames: 0, jumpProgress: 0, + flying: false, } describe('FPSCamera', () => { diff --git a/src/engine/FPSCamera.ts b/src/engine/FPSCamera.ts index e840186..2b77070 100644 --- a/src/engine/FPSCamera.ts +++ b/src/engine/FPSCamera.ts @@ -11,6 +11,7 @@ export interface PlayerState { sprinting: boolean coyoteFrames: number jumpProgress: number + flying: boolean } const EYE_HEIGHT = 0.8 diff --git a/src/engine/GameEngine.ts b/src/engine/GameEngine.ts index 09d17be..4a6f25c 100644 --- a/src/engine/GameEngine.ts +++ b/src/engine/GameEngine.ts @@ -137,10 +137,22 @@ export default class GameEngine { if (this.playerState) { const dx = this.playerState.x - this.lastStreamX const dz = this.playerState.z - this.lastStreamZ - if (dx * dx + dz * dz > 128 * 128) { + const streamThreshold = this.playerState.flying ? 64 * 64 : 128 * 128 + if (dx * dx + dz * dz > streamThreshold) { this.lastStreamX = this.playerState.x this.lastStreamZ = this.playerState.z - this.chunkManager?.streamUpdate(this.playerState.x, this.playerState.z) + + let streamX = this.playerState.x + let streamZ = this.playerState.z + + if (this.playerState.flying) { + // Pre-generate chunks in the look direction (2 chunks lookahead = 1024 units) + const lookahead = 1024 + streamX += -Math.sin(this.playerState.yaw) * lookahead + streamZ += -Math.cos(this.playerState.yaw) * lookahead + } + + this.chunkManager?.streamUpdate(streamX, streamZ) .catch(console.error) } } diff --git a/src/engine/InputSystem.ts b/src/engine/InputSystem.ts index 6f6c5fb..84873ff 100644 --- a/src/engine/InputSystem.ts +++ b/src/engine/InputSystem.ts @@ -6,6 +6,7 @@ export interface InputSnapshot { right: boolean jump: boolean sprint: boolean + flyToggle: boolean // edge-triggered: true for one frame when F is pressed mouseDX: number mouseDY: number } @@ -16,9 +17,13 @@ export default class InputSystem { private mouseDY = 0 private sensitivity = 1.0 private canvas: HTMLCanvasElement | null = null + private flyTogglePending = false private onKeyDown = (e: KeyboardEvent): void => { this.keys.add(e.code) + if (e.code === 'KeyF') { + this.flyTogglePending = true + } } private onKeyUp = (e: KeyboardEvent): void => { @@ -49,17 +54,19 @@ export default class InputSystem { // Returns current snapshot and resets mouse deltas flush(): InputSnapshot { const snap: InputSnapshot = { - forward: this.keys.has('KeyW') || this.keys.has('ArrowUp'), - backward: this.keys.has('KeyS') || this.keys.has('ArrowDown'), - left: this.keys.has('KeyA') || this.keys.has('ArrowLeft'), - right: this.keys.has('KeyD') || this.keys.has('ArrowRight'), - jump: this.keys.has('Space'), - sprint: this.keys.has('ShiftLeft'), - mouseDX: this.mouseDX, - mouseDY: this.mouseDY, + forward: this.keys.has('KeyW') || this.keys.has('ArrowUp'), + backward: this.keys.has('KeyS') || this.keys.has('ArrowDown'), + left: this.keys.has('KeyA') || this.keys.has('ArrowLeft'), + right: this.keys.has('KeyD') || this.keys.has('ArrowRight'), + jump: this.keys.has('Space'), + sprint: this.keys.has('ShiftLeft'), + flyToggle: this.flyTogglePending, + mouseDX: this.mouseDX, + mouseDY: this.mouseDY, } this.mouseDX = 0 this.mouseDY = 0 + this.flyTogglePending = false return snap } diff --git a/src/engine/WasmClient.ts b/src/engine/WasmClient.ts index e40a8f1..30fa036 100644 --- a/src/engine/WasmClient.ts +++ b/src/engine/WasmClient.ts @@ -1,5 +1,6 @@ // Main-thread async wrapper around the terrain WASM worker. import type { PlayerState } from './FPSCamera' +import ChunkWorkerPool from './worker/ChunkWorkerPool' export interface WorldUpdate { chunksToAdd: Array<{ coord: { X: number; Z: number } }> | null @@ -10,6 +11,9 @@ export default class WasmClient { private worker: Worker private nextId = 0 private pending = new Map void>() + private pool: ChunkWorkerPool | null = null + private wasmBinaryUrl = '' + private wasmExecUrl = '' onTick: ((playerState: PlayerState) => void) | null = null constructor() { @@ -48,6 +52,8 @@ export default class WasmClient { wasmBinaryUrl: `/terrain.wasm?v=${__WASM_HASH__}`, wasmExecUrl: '/wasm_exec.js', }) + this.wasmBinaryUrl = `/terrain.wasm?v=${__WASM_HASH__}` + this.wasmExecUrl = '/wasm_exec.js' }) } @@ -61,6 +67,10 @@ export default class WasmClient { async initWorld(config: object): Promise { await this.call('initWorld', [JSON.stringify(config)]) + if (!this.pool && this.wasmBinaryUrl) { + this.pool = new ChunkWorkerPool(4) + await this.pool.init(this.wasmBinaryUrl, this.wasmExecUrl, JSON.stringify(config)) + } } async loadWorldConfig(config: object): Promise { @@ -75,6 +85,21 @@ export default class WasmClient { chunkSize: number, heightScale: number, ): Promise<{ heightmap: Float32Array; normals: Float32Array; biomeId: number }> { + if (this.pool) { + const result = await this.pool.generateChunk( + JSON.stringify(config), chunkX, chunkZ, resolution, chunkSize, heightScale, + ) + // Register heightmap with primary worker for physics collision. + // Fire-and-forget: id=-1 is a sentinel never registered in this.pending, + // so the RESULT response from the worker is silently ignored. + this.worker.postMessage({ + type: 'CALL', + id: -1, + method: 'storeHeightmap', + args: [chunkX, chunkZ, result.heightmap], + }) + return result + } return this.call( 'generateChunk', [JSON.stringify(config), chunkX, chunkZ, resolution, chunkSize, heightScale], @@ -86,6 +111,7 @@ export default class WasmClient { } terminate(): void { + this.pool?.terminate() this.worker.terminate() } diff --git a/src/engine/worker/ChunkWorkerPool.ts b/src/engine/worker/ChunkWorkerPool.ts new file mode 100644 index 0000000..11c7d50 --- /dev/null +++ b/src/engine/worker/ChunkWorkerPool.ts @@ -0,0 +1,95 @@ +// Pool of WASM workers dedicated to chunk generation. +// Each worker has its own WASM instance and only handles generateChunk calls. +// Routes calls round-robin to minimize latency compared to a single-worker queue. + +interface PendingCall { + resolve: (value: unknown) => void + reject: (reason: unknown) => void +} + +interface PoolWorker { + worker: Worker + pending: Map + nextId: number + ready: boolean +} + +export default class ChunkWorkerPool { + private workers: PoolWorker[] = [] + private roundRobinIndex = 0 + private readonly size: number + + constructor(size = 4) { + this.size = size + } + + async init(wasmBinaryUrl: string, wasmExecUrl: string, worldConfigJSON: string): Promise { + const readyPromises = Array.from({ length: this.size }, () => { + return new Promise((resolve, reject) => { + const worker = new Worker( + new URL('./terrain.worker.ts', import.meta.url), + ) + const pw: PoolWorker = { worker, pending: new Map(), nextId: 0, ready: false } + this.workers.push(pw) + + worker.onmessage = (e: MessageEvent) => { + const { type, id, result, error } = e.data + if (type === 'READY') { + const cfgId = pw.nextId++ + pw.pending.set(cfgId, { + resolve: () => { + pw.ready = true + resolve() + }, + reject, + }) + worker.postMessage({ type: 'CALL', id: cfgId, method: 'loadWorldConfig', args: [worldConfigJSON] }) + } else if (type === 'RESULT') { + const pending = pw.pending.get(id) + if (pending) { + pw.pending.delete(id) + if (error) pending.reject(new Error(error)) + else pending.resolve(result) + } + } else if (type === 'ERROR') { + reject(new Error(e.data.message)) + } + } + + worker.postMessage({ type: 'INIT', wasmBinaryUrl, wasmExecUrl }) + }) + }) + + await Promise.all(readyPromises) + } + + async updateWorldConfig(worldConfigJSON: string): Promise { + await Promise.all(this.workers.map(pw => this.callWorker(pw, 'loadWorldConfig', [worldConfigJSON]))) + } + + generateChunk( + configJSON: string, + cx: number, + cz: number, + resolution: number, + chunkSize: number, + heightScale: number, + ): Promise<{ heightmap: Float32Array; normals: Float32Array; biomeId: number }> { + const pw = this.workers[this.roundRobinIndex % this.workers.length] + this.roundRobinIndex++ + return this.callWorker(pw, 'generateChunk', [configJSON, cx, cz, resolution, chunkSize, heightScale]) as Promise<{ heightmap: Float32Array; normals: Float32Array; biomeId: number }> + } + + private callWorker(pw: PoolWorker, method: string, args: unknown[]): Promise { + return new Promise((resolve, reject) => { + const id = pw.nextId++ + pw.pending.set(id, { resolve, reject }) + pw.worker.postMessage({ type: 'CALL', id, method, args }) + }) + } + + terminate(): void { + this.workers.forEach(pw => pw.worker.terminate()) + this.workers = [] + } +} diff --git a/src/engine/worker/WasmBridge.ts b/src/engine/worker/WasmBridge.ts index a43d229..8ffe6fa 100644 --- a/src/engine/worker/WasmBridge.ts +++ b/src/engine/worker/WasmBridge.ts @@ -42,6 +42,8 @@ declare global { ): Float32Array /** Load a WorldConfig JSON to configure biome placement before chunk generation. */ function go_loadWorldConfig(configJSON: string): void + /** Store a heightmap (from a pool worker) into the primary worker's globalHeightmaps for physics. */ + function go_storeHeightmap(cx: number, cz: number, heightmap: Float32Array): void } export default class WasmBridge { diff --git a/src/engine/worker/terrain.worker.ts b/src/engine/worker/terrain.worker.ts index 2db3459..14ff7f8 100644 --- a/src/engine/worker/terrain.worker.ts +++ b/src/engine/worker/terrain.worker.ts @@ -63,6 +63,10 @@ function handleCall(event: MessageEvent): void { const [playerX, playerZ] = args as [number, number] const json = go_worldUpdate(playerX, playerZ) as string result = JSON.parse(json) + } else if (method === 'storeHeightmap') { + const [cx, cz, heightmap] = args as [number, number, Float32Array] + go_storeHeightmap(cx, cz, heightmap) + result = null } else { throw new Error(`Unknown method: ${method}`) } diff --git a/wasm/main.go b/wasm/main.go index a10b509..3363144 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -33,6 +33,7 @@ func main() { js.Global().Set("go_generateChunk", js.FuncOf(goGenerateChunk)) js.Global().Set("go_updatePlayer", js.FuncOf(goUpdatePlayer)) js.Global().Set("go_loadWorldConfig", js.FuncOf(goLoadWorldConfig)) + js.Global().Set("go_storeHeightmap", js.FuncOf(goStoreHeightmap)) fmt.Println("[WASM] exports registered, engine ready") select {} @@ -232,5 +233,34 @@ func goUpdatePlayer(_ js.Value, args []js.Value) any { return string(out) } +// goStoreHeightmap receives a heightmap generated by a pool worker and stores it +// in the primary worker's globalHeightmaps so physics collision detection works. +func goStoreHeightmap(_ js.Value, args []js.Value) any { + cx := args[0].Int() + cz := args[1].Int() + jsArr := args[2] // Float32Array from TypeScript + + n := jsArr.Get("length").Int() + hm := make([]float32, n) + + // Use Uint8Array view of the Float32Array's buffer for efficient bulk copy. + byteLen := jsArr.Get("byteLength").Int() + byteOffset := jsArr.Get("byteOffset").Int() + uint8View := js.Global().Get("Uint8Array").New(jsArr.Get("buffer"), byteOffset, byteLen) + goBytes := make([]byte, byteLen) + js.CopyBytesToGo(goBytes, uint8View) + + // Reinterpret little-endian bytes as float32 values. + for i := range hm { + b := goBytes[i*4 : i*4+4] + bits := uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24 + hm[i] = math.Float32frombits(bits) + } + + coord := world.ChunkCoord{X: cx, Z: cz} + globalHeightmaps[coord] = hm + return nil +} + // ensure math import is used var _ = math.Floor diff --git a/wasm/physics/player.go b/wasm/physics/player.go index f1509c2..08fd092 100644 --- a/wasm/physics/player.go +++ b/wasm/physics/player.go @@ -9,8 +9,9 @@ const ( Gravity = -19.62 WalkSpeed = 7.5 SprintSpeed = 20.0 - JumpVelocity = 6.26 // sqrt(2 * 9.81 * 2.0) ≈ 6.26 - SlopeLimit = 65.0 // degrees + FlySpeed = WalkSpeed * 10 // 75.0 — 10x ground speed + JumpVelocity = 6.26 // sqrt(2 * 9.81 * 2.0) ≈ 6.26 + SlopeLimit = 65.0 // degrees Sensitivity = 0.002 PitchMin = -math.Pi / 2.0 * 0.99 PitchMax = math.Pi / 180.0 * 70.0 @@ -19,14 +20,15 @@ const ( ) type InputState struct { - Forward bool `json:"forward"` - Backward bool `json:"backward"` - Left bool `json:"left"` - Right bool `json:"right"` - Jump bool `json:"jump"` - Sprint bool `json:"sprint"` - MouseDX float64 `json:"mouseDX"` - MouseDY float64 `json:"mouseDY"` + Forward bool `json:"forward"` + Backward bool `json:"backward"` + Left bool `json:"left"` + Right bool `json:"right"` + Jump bool `json:"jump"` + Sprint bool `json:"sprint"` + FlyToggle bool `json:"flyToggle"` // true for one frame when F is pressed + MouseDX float64 `json:"mouseDX"` + MouseDY float64 `json:"mouseDY"` } type PlayerState struct { @@ -40,4 +42,5 @@ type PlayerState struct { Sprinting bool `json:"sprinting"` CoyoteFrames int `json:"coyoteFrames"` JumpProgress float64 `json:"jumpProgress"` + Flying bool `json:"flying"` // whether flight mode is active } diff --git a/wasm/physics/update.go b/wasm/physics/update.go index ef9bfef..f34383d 100644 --- a/wasm/physics/update.go +++ b/wasm/physics/update.go @@ -15,88 +15,147 @@ func Update(p *PlayerState, input InputState, dt float64, heightAt func(x, z flo p.Pitch = PitchMax } - // 2. Determine speed - speed := WalkSpeed - p.Sprinting = false - if input.Sprint && (input.Forward || input.Backward || input.Left || input.Right) { - speed = SprintSpeed - p.Sprinting = true + // Toggle flight mode on FlyToggle input + if input.FlyToggle { + p.Flying = !p.Flying + if p.Flying { + // Enter flight: kill vertical velocity, don't snap to ground + p.VelocityY = 0 + } else { + // Exit flight: let gravity take over from current position + p.VelocityY = 0 + p.Grounded = false + } } - // 3. Horizontal movement in yaw-facing direction - sin := math.Sin(p.Yaw) - cos := math.Cos(p.Yaw) - var dx, dz float64 - if input.Forward { - dx -= sin * speed * dt - dz -= cos * speed * dt - } - if input.Backward { - dx += sin * speed * dt - dz += cos * speed * dt - } - if input.Left { - dx -= cos * speed * dt - dz += sin * speed * dt - } - if input.Right { - dx += cos * speed * dt - dz -= sin * speed * dt - } + if p.Flying { + // FLIGHT MODE: drone-style, moves in the full 3D look direction + speed := FlySpeed + // Sprint in flight = 2x fly speed + p.Sprinting = false + if input.Sprint { + speed *= 2 + p.Sprinting = true + } - // 4. Slope check — sample terrain ahead, block if slope > SlopeLimit - newX := p.X + dx - newZ := p.Z + dz - curH := heightAt(p.X, p.Z) - nextH := heightAt(newX, newZ) - moveLen := math.Sqrt(dx*dx + dz*dz) - if moveLen > 0 { - slopeAngle := math.Atan2(math.Abs(nextH-curH), moveLen) * 180.0 / math.Pi - if slopeAngle <= SlopeLimit { + sinYaw := math.Sin(p.Yaw) + cosYaw := math.Cos(p.Yaw) + cosPitch := math.Cos(p.Pitch) + sinPitch := math.Sin(p.Pitch) + + // Forward/backward moves in the full look direction (including vertical component) + if input.Forward { + p.X -= sinYaw * cosPitch * speed * dt + p.Y += sinPitch * speed * dt + p.Z -= cosYaw * cosPitch * speed * dt + } + if input.Backward { + p.X += sinYaw * cosPitch * speed * dt + p.Y -= sinPitch * speed * dt + p.Z += cosYaw * cosPitch * speed * dt + } + // Strafing is purely horizontal + if input.Left { + p.X -= cosYaw * speed * dt + p.Z += sinYaw * speed * dt + } + if input.Right { + p.X += cosYaw * speed * dt + p.Z -= sinYaw * speed * dt + } + // Jump key ascends directly + if input.Jump { + p.Y += speed * dt + } + // No gravity, no ground collision in flight mode + p.VelocityY = 0 + p.Grounded = false + p.CoyoteFrames = 0 + } else { + // GROUND MODE: existing code unchanged + // 2. Determine speed + speed := WalkSpeed + p.Sprinting = false + if input.Sprint && (input.Forward || input.Backward || input.Left || input.Right) { + speed = SprintSpeed + p.Sprinting = true + } + + // 3. Horizontal movement in yaw-facing direction + sin := math.Sin(p.Yaw) + cos := math.Cos(p.Yaw) + var dx, dz float64 + if input.Forward { + dx -= sin * speed * dt + dz -= cos * speed * dt + } + if input.Backward { + dx += sin * speed * dt + dz += cos * speed * dt + } + if input.Left { + dx -= cos * speed * dt + dz += sin * speed * dt + } + if input.Right { + dx += cos * speed * dt + dz -= sin * speed * dt + } + + // 4. Slope check — sample terrain ahead, block if slope > SlopeLimit + newX := p.X + dx + newZ := p.Z + dz + curH := heightAt(p.X, p.Z) + nextH := heightAt(newX, newZ) + moveLen := math.Sqrt(dx*dx + dz*dz) + if moveLen > 0 { + slopeAngle := math.Atan2(math.Abs(nextH-curH), moveLen) * 180.0 / math.Pi + if slopeAngle <= SlopeLimit { + p.X = newX + p.Z = newZ + } + } else { p.X = newX p.Z = newZ } - } else { - p.X = newX - p.Z = newZ - } - // 5. Gravity - p.VelocityY += Gravity * dt + // 5. Gravity + p.VelocityY += Gravity * dt - // 6. Jump initiation — sets JumpProgress to begin smooth ramp - if input.Jump && p.Grounded && p.JumpProgress == 0 { - p.JumpProgress = 1.0 - p.Grounded = false - p.CoyoteFrames = CoyoteGrace + 1 // skip coyote grace so we don't re-land - } + // 6. Jump initiation — sets JumpProgress to begin smooth ramp + if input.Jump && p.Grounded && p.JumpProgress == 0 { + p.JumpProgress = 1.0 + p.Grounded = false + p.CoyoteFrames = CoyoteGrace + 1 // skip coyote grace so we don't re-land + } - // 6a. Smooth jump ramp — lerp VelocityY toward JumpVelocity over ~3 frames - if p.JumpProgress > 0 { - p.VelocityY += (JumpVelocity - p.VelocityY) * 0.5 - p.JumpProgress -= 0.35 - if p.JumpProgress < 0 { - p.JumpProgress = 0 + // 6a. Smooth jump ramp — lerp VelocityY toward JumpVelocity over ~3 frames + if p.JumpProgress > 0 { + p.VelocityY += (JumpVelocity - p.VelocityY) * 0.5 + p.JumpProgress -= 0.35 + if p.JumpProgress < 0 { + p.JumpProgress = 0 + } } - } - // 7. Vertical integration - p.Y += p.VelocityY * dt + // 7. Vertical integration + p.Y += p.VelocityY * dt - // 8. Ground collision with coyote-time to prevent airborne flicker on micro-bumps - groundH := heightAt(p.X, p.Z) + CapsuleHalfHeight + CapsuleRadius - if p.Y <= groundH && p.JumpProgress == 0 { - p.Y = groundH - p.VelocityY = 0 - p.Grounded = true - p.CoyoteFrames = 0 - } else if p.Y > groundH { - if p.Grounded && p.CoyoteFrames < CoyoteGrace && p.JumpProgress == 0 { - // Grace period: keep grounded for a few ticks after leaving ground - p.CoyoteFrames++ - } else { - p.Grounded = false + // 8. Ground collision with coyote-time to prevent airborne flicker on micro-bumps + groundH := heightAt(p.X, p.Z) + CapsuleHalfHeight + CapsuleRadius + if p.Y <= groundH && p.JumpProgress == 0 { + p.Y = groundH + p.VelocityY = 0 + p.Grounded = true p.CoyoteFrames = 0 + } else if p.Y > groundH { + if p.Grounded && p.CoyoteFrames < CoyoteGrace && p.JumpProgress == 0 { + // Grace period: keep grounded for a few ticks after leaving ground + p.CoyoteFrames++ + } else { + p.Grounded = false + p.CoyoteFrames = 0 + } } } } diff --git a/wasm/physics/update_test.go b/wasm/physics/update_test.go index 6760126..6db6a5f 100644 --- a/wasm/physics/update_test.go +++ b/wasm/physics/update_test.go @@ -1,6 +1,9 @@ package physics -import "testing" +import ( + "math" + "testing" +) func flatGround(x, z float64) float64 { return 0 } @@ -63,3 +66,51 @@ func TestSlopeBlocking(t *testing.T) { } } +func TestFlyToggle_EntersAndExitsFlightMode(t *testing.T) { + p := &PlayerState{X: 0, Y: 10, Z: 0} + hf := func(x, z float64) float64 { return 0 } + + // Toggle on + Update(p, InputState{FlyToggle: true}, 0.016, hf) + if !p.Flying { + t.Fatal("expected Flying=true after FlyToggle") + } + + // Toggle off + Update(p, InputState{FlyToggle: true}, 0.016, hf) + if p.Flying { + t.Fatal("expected Flying=false after second FlyToggle") + } +} + +func TestFlight_NoGravity(t *testing.T) { + p := &PlayerState{X: 0, Y: 100, Z: 0, Flying: true} + hf := func(x, z float64) float64 { return 0 } + initialY := p.Y + // No input, should hover in place (no gravity) + Update(p, InputState{}, 0.1, hf) + if math.Abs(p.Y-initialY) > 0.001 { + t.Errorf("flying player should not fall: y changed from %.3f to %.3f", initialY, p.Y) + } +} + +func TestFlight_ForwardMovesInLookDirection(t *testing.T) { + // Pitch up 45°, yaw 0 (facing -Z), press forward + // Should move in -Z and +Y direction + p := &PlayerState{X: 0, Y: 100, Z: 0, Flying: true, Yaw: 0, Pitch: math.Pi / 4} + hf := func(x, z float64) float64 { return 0 } + Update(p, InputState{Forward: true}, 1.0, hf) + if p.Y <= 100 { + t.Errorf("flying forward with pitch up should increase Y, got %.3f", p.Y) + } + if p.Z >= 0 { + t.Errorf("flying forward with yaw=0 should decrease Z, got %.3f", p.Z) + } +} + +func TestFlight_SpeedIs10xWalk(t *testing.T) { + if FlySpeed != WalkSpeed*10 { + t.Errorf("FlySpeed should be 10x WalkSpeed: got %.1f, want %.1f", FlySpeed, WalkSpeed*10) + } +} + diff --git a/wasm/world/config.go b/wasm/world/config.go index 7ff47a4..257cbfb 100644 --- a/wasm/world/config.go +++ b/wasm/world/config.go @@ -2,9 +2,9 @@ package world // Constants mirror terra-major World.cs const ( - InitialRenderRadius = 2048.0 - RenderRadius = 1536.0 - DistanceThreshold = 2560.0 + InitialRenderRadius = 3072.0 + RenderRadius = 2560.0 + DistanceThreshold = 4608.0 MaxConcurrentChunks = 64 )