From 7765c8c2abf0cb3875bb4d44e0aec763bcde1bc9 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Wed, 15 Jul 2026 18:18:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20SH-027=20v2=20=E2=80=94=20the=20gun=20p?= =?UTF-8?q?oints=20where=20the=20camera=20points=20(EN-039)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weapon drew through drawModelRotated, which takes only a Y rotation: the barrel stayed level while you aimed up or down. No yaw-only draw can fix that, which is why the old comment here ended "a full-transform immediate draw is EN-039". That landed, so this consumes it. The barrel is local +Z (build-weapons.ts), and the engine's rotation convention puts COLUMN 2 of the model matrix wherever local +Z points — confirmed against the code being replaced: it passed θ = π - yaw, and the engine's rotY sends +Z to (sin θ, 0, cos θ) = (sin yaw, 0, -cos yaw), i.e. camera-forward at zero pitch. So column 2 becomes the aim vector and an orthonormal basis is built around it. That aim vector is (fwx, fwy, fwz) — the SAME one the muzzle, the tracer and the shot ray are already computed from. The gun and the bullet now agree by construction rather than by coincidence. The cross with world-up cannot collapse: camera pitch is clamped well short of vertical (TP_PITCH_MIN/MAX), so the aim is never parallel to up. Verified in-game with the engine's own F12 capture (tools/f12-shot.ps1 — the takeScreenshot FFI writes no file on Windows, and shot-window.ps1 hands back stale frames): with only the camera pitch changed, 0.30 -> 1.15 rad, the weapon visibly re-orients. It also still DRAWS, which is the real risk — Perry silently no-ops a native missing from the manifest, so a botched registration shows up as the gun quietly vanishing rather than as an error. Claude-Session: https://claude.ai/code/session_01J1UWgMcrTNvwWeXcJ1T3Fp --- src/combat.ts | 57 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/src/combat.ts b/src/combat.ts index 9f57db3..2bb1b14 100644 --- a/src/combat.ts +++ b/src/combat.ts @@ -13,7 +13,7 @@ import { vec3, getTime, - drawCube, drawSphere, drawMeshWithMaterial, drawModelRotated, + drawCube, drawSphere, drawMeshWithMaterial, drawModelRotated, drawModelTransform, playSound, playSound3D, playMusic, stopMusic, } from 'bloom'; import { gamepadRumble } from 'bloom/core'; @@ -471,23 +471,48 @@ export function drawWeapon(): void { const fwy = Math.sin(weaponPitch); const fwz = -Math.cos(weaponYaw) * cpW; const wpnNow = WPN.currentWeapon(); - // drawModelRotated takes DEGREES (see docs/round-4 notes) and gives us the - // cached-model path, which the immediate path would not. + // SH-027 v2 / EN-039 — the gun now points where the camera points, pitch and + // all. This used to be drawModelRotated, which takes a single Y rotation: the + // barrel stayed level while you aimed up or down, and no yaw-only draw can fix + // that. drawModelTransform takes the whole 4x4. // - // The barrel is local +Z (build-weapons.ts), but camera-forward at yaw 0 is - // -Z, so a naive `yaw` points the gun backwards. Rotating +Z onto - // (sin yaw, 0, -cos yaw) needs θ = π - yaw. + // The barrel is local +Z (build-weapons.ts), and the engine's rotation + // convention puts COLUMN 2 of the model matrix wherever local +Z points — + // which the old code confirms: it passed θ = π - yaw, and the engine's rotY + // sends +Z to (sin θ, 0, cos θ) = (sin yaw, 0, -cos yaw), i.e. camera-forward + // at zero pitch. So: make column 2 the aim vector and build an orthonormal + // basis around it. // - // Only yaw is expressible here: drawModelRotated has no pitch, so the gun - // cannot tilt with the aim. Fine in third person at normal engagement - // angles; a full-transform immediate draw is EN-039. - const yawDeg = ((Math.PI - weaponYaw) * 180 / Math.PI); - drawModelRotated( - DEPS.o.mdlWeapons[wpnNow], - vec3(weaponX - fwx * recoilBack, - weaponY - fwy * recoilBack, - weaponZ - fwz * recoilBack), - DEPS.o.WEAPON_DRAW_SCALE[wpnNow], yawDeg, WHITE); + // (fwx, fwy, fwz) IS that aim vector already — it is what the muzzle, the + // tracer and the shot ray are computed from, so the gun and the bullet now + // agree by construction rather than by coincidence. + // + // The cross with world-up is safe because the camera pitch is clamped well + // short of vertical (TP_PITCH_MIN/MAX): the aim can never be parallel to up, + // so the basis can never collapse. + const sc = DEPS.o.WEAPON_DRAW_SCALE[wpnNow]; + // X = normalise(cross(worldUp, forward)) + let rx = 1 * fwz - 0 * fwy; // (up × f).x = up.y*f.z - up.z*f.y + let ry = 0 * fwx - 0 * fwz; // (up × f).y = up.z*f.x - up.x*f.z + let rz = 0 * fwy - 1 * fwx; // (up × f).z = up.x*f.y - up.y*f.x + const rl = Math.sqrt(rx * rx + ry * ry + rz * rz); + rx = rx / rl; ry = ry / rl; rz = rz / rl; + // Y = cross(forward, X) — completes a right-handed basis (X × Y = Z). + const ux = fwy * rz - fwz * ry; + const uy = fwz * rx - fwx * rz; + const uz = fwx * ry - fwy * rx; + + const px = weaponX - fwx * recoilBack; + const py = weaponY - fwy * recoilBack; + const pz = weaponZ - fwz * recoilBack; + // Column-major: [col0 | col1 | col2 | translation]. + const m16 = [ + rx * sc, ry * sc, rz * sc, 0, + ux * sc, uy * sc, uz * sc, 0, + fwx * sc, fwy * sc, fwz * sc, 0, + px, py, pz, 1, + ]; + drawModelTransform(DEPS.o.mdlWeapons[wpnNow], m16, WHITE); // Muzzle flash — additive material at the true muzzle. The particle system // now layers smoke and a shell on top of this (SH-033); the flash card