diff --git a/.github/workflows/tps-kick-sequence-audit.yml b/.github/workflows/tps-kick-sequence-audit.yml index f6f7c2df..1187cd05 100644 --- a/.github/workflows/tps-kick-sequence-audit.yml +++ b/.github/workflows/tps-kick-sequence-audit.yml @@ -9,6 +9,7 @@ on: - app/** - src/game/** - scripts/capture-tps-kick-sequence-audit.mjs + - scripts/capture-tps-combat-feel-audit.mjs - scripts/capture-tps-kick-spam-audit.mjs - scripts/capture-tps-kick-link-audit.mjs - .github/workflows/tps-kick-sequence-audit.yml @@ -19,6 +20,7 @@ on: - app/** - src/game/** - scripts/capture-tps-kick-sequence-audit.mjs + - scripts/capture-tps-combat-feel-audit.mjs - scripts/capture-tps-kick-spam-audit.mjs - scripts/capture-tps-kick-link-audit.mjs - .github/workflows/tps-kick-sequence-audit.yml @@ -75,6 +77,7 @@ jobs: WEBDRIVER_BIN="$DRIVER" AUDIT_URL="http://127.0.0.1:3000/" TPS_AUDIT_DIR="artifacts/tps-kick-sequence" node scripts/capture-tps-kick-sequence-audit.mjs WEBDRIVER_BIN="$DRIVER" AUDIT_URL="http://127.0.0.1:3000/" TPS_AUDIT_DIR="artifacts/tps-kick-sequence" node scripts/capture-tps-kick-spam-audit.mjs WEBDRIVER_BIN="$DRIVER" AUDIT_URL="http://127.0.0.1:3000/" TPS_AUDIT_DIR="artifacts/tps-kick-sequence" node scripts/capture-tps-kick-link-audit.mjs + WEBDRIVER_BIN="$DRIVER" AUDIT_URL="http://127.0.0.1:3000/" TPS_AUDIT_DIR="artifacts/tps-kick-sequence" node scripts/capture-tps-combat-feel-audit.mjs for move in kick lowkick risingkick dashkick; do for stage in startup contact recovery; do image="artifacts/tps-kick-sequence/tps-kick-${move}-${stage}.png" diff --git a/docs/COMBAT_FEEL.md b/docs/COMBAT_FEEL.md new file mode 100644 index 00000000..5407fde4 --- /dev/null +++ b/docs/COMBAT_FEEL.md @@ -0,0 +1,32 @@ +# Readable combat and paired reactions + +- Enemy aim is committed when its telegraph enters REACT. The visible attack + and spatial contact use that same direction. Its short weight shift continues + down the committed lane; an active strike cannot turn after a sidestep. +- A STEP records the threatening move until its active window. It grants no + invulnerability. A perfect evade requires an actual lateral miss, and the + player's position before STEP must have been inside that attack's lane. + Early movement outside REACT and out-of-range whiffs do not award a reversal. +- Straight punches/front kicks cover a narrower lateral lane than backfists + and low kicks. Their startup tracking is also lower. Existing startup, + damage, recovery, hit-confirm and combo rules retain their separate roles. +- Close/far opener selection has hysteresis. Direction is captured with an + ATTACK tap during STEP, so releasing the stick before the buffered attack + begins does not erase the intended branch. +- Ordinary BRAWLER pressure repeats jab/straight/power; SKIRMISHER spacing + favors kicks and a short retreat afterwards. Tactical adaptation retains + priority. Heavy BRAWLER attacks leave a brief open reset beat. +- Resolved contact selects a head/body/leg or braced guard response and + restarts it on repeated contacts. Each real target skeleton receives baked + clips with early upper-body recoil, delayed hip transfer and a recovery step. + These use the existing stun clock and single animation mixer. Attacker and + defender presentation share the same contact tick and effect height. + Legacy host recoil/guard tilts yield to these complete clips, and inertial + transitions preserve their complete pelvis/leg solutions rather than mixing + part of an outgoing stance into the planted feet. + +Regression coverage includes actual fixed-step cue/STEP/contact behavior, +standing/early/far controls, touch selection, CPU phrases and both male/female +rigs' finite transforms, constant leg lengths and grounded support. +The existing kick WebGL audit additionally captures real evade/retaliation and +head/body/leg/block frames through `capture-tps-combat-feel-audit.mjs`. diff --git a/public/models/quaternius/blender-cross-core.generator.sha256 b/public/models/quaternius/blender-cross-core.generator.sha256 index 2f4e54f9..d46806c4 100644 --- a/public/models/quaternius/blender-cross-core.generator.sha256 +++ b/public/models/quaternius/blender-cross-core.generator.sha256 @@ -1 +1 @@ -115fce482fdcae5cec74ff38f503687a057f21a6cab5ed87cd305acf9c80e38b +b1a8a49ba9eb618c8c902e4e9b22a5ffaa6e8bdbbd355c177d6d654f1bfe085b diff --git a/public/models/quaternius/blender-cross-core.glb b/public/models/quaternius/blender-cross-core.glb index b0f369d1..9c221a9c 100644 Binary files a/public/models/quaternius/blender-cross-core.glb and b/public/models/quaternius/blender-cross-core.glb differ diff --git a/public/models/quaternius/blender-cross-core.metrics.json b/public/models/quaternius/blender-cross-core.metrics.json index 92a05afe..cb87e49c 100644 --- a/public/models/quaternius/blender-cross-core.metrics.json +++ b/public/models/quaternius/blender-cross-core.metrics.json @@ -22,6 +22,67 @@ "boneCount": 65, "meshCount": 2, "sharedRig": "MOTION_FOUNDRY_V2_SHARED_STRIKE_RIG", + "motionPriorProvider": "UAL_AUTHORED_STRIKE_V2", + "naturalnessPass": "AUTHORED_SOURCE_V2", + "unimateInbetweenVersion": "UNIMATE_INSPIRED_REPLACEMENT_INBETWEEN_V1", + "unimateInbetweenAnchors": [ + 1, + 6, + 16, + 21, + 24, + 33, + 42 + ], + "unimateInbetweenSmoothedBones": 16, + "unimateInbetweenPreservedBones": [ + "pelvis", + "thigh_l", + "calf_l", + "foot_l" + ], + "unimateRotationAccelerationRmsBefore": 0.18402951713198326, + "unimateRotationAccelerationRmsAfter": 0.16844692555505267, + "unimateMaxRotationStepBefore": 2.2506339095000554, + "unimateMaxRotationStepAfter": 2.1210758237739955, + "unimateMaximumAnchorRotationError": 0.0, + "unimateMaximumAnchorLocationError": 0.0, + "unimateInbetweenAccepted": true, + "referenceImpactNormalizedTime": null, + "referenceTimeWarpKnots": [ + [ + 0.0, + 0.0 + ], + [ + 0.13, + 0.06 + ], + [ + 0.27, + 0.17 + ], + [ + 0.4, + 0.36 + ], + [ + 0.5, + 0.66 + ], + [ + 0.57, + 0.78 + ], + [ + 0.76, + 0.91 + ], + [ + 1.0, + 1.0 + ] + ], "pipeline": [ "Punch_Cross source body motion", "move-specific nonlinear whole-body retiming", @@ -31,6 +92,7 @@ "world-space L support-foot position IK lock", "world-space L support-foot orientation lock", "Blender native NLA visual-keying bake", + "UniMate-inspired replacement in-between cleanup with immutable combat anchors", "glTF Action export" ], "rightHandTravel": 0.4906242518662258, diff --git a/public/models/quaternius/blender-fight-core.generator.sha256 b/public/models/quaternius/blender-fight-core.generator.sha256 index 1683c302..2ffc3f99 100644 --- a/public/models/quaternius/blender-fight-core.generator.sha256 +++ b/public/models/quaternius/blender-fight-core.generator.sha256 @@ -1 +1 @@ -f0911cd68642c69f6cd6537677db77c7f84cc15f18bb01f153602c074adea2d6 +f676043ded98d101a66faa06987662a419d18eae5c3f491146c437f6f9675222 diff --git a/public/models/quaternius/blender-fight-core.glb b/public/models/quaternius/blender-fight-core.glb index 3a736e7d..5a8d943b 100644 Binary files a/public/models/quaternius/blender-fight-core.glb and b/public/models/quaternius/blender-fight-core.glb differ diff --git a/public/models/quaternius/blender-fight-core.metrics.json b/public/models/quaternius/blender-fight-core.metrics.json index 2ca0b943..bdfbfb60 100644 --- a/public/models/quaternius/blender-fight-core.metrics.json +++ b/public/models/quaternius/blender-fight-core.metrics.json @@ -13,12 +13,38 @@ "constrainedFootLockMaxDrift": 4.702727150338739e-07, "boneCount": 65, "meshCount": 2, + "motionPriorProvider": "UAL_AUTHORED_POWER_V1", + "unimateInbetweenVersion": "UNIMATE_INSPIRED_REPLACEMENT_INBETWEEN_V1", + "unimateInbetweenAnchors": [ + 1, + 9, + 24, + 30, + 34, + 43, + 52 + ], + "unimateInbetweenSmoothedBones": 16, + "unimateInbetweenPreservedBones": [ + "pelvis", + "thigh_l", + "calf_l", + "foot_l" + ], + "unimateRotationAccelerationRmsBefore": 0.1234864492702557, + "unimateRotationAccelerationRmsAfter": 0.11096091799728582, + "unimateMaxRotationStepBefore": 1.7447990095183712, + "unimateMaxRotationStepAfter": 1.6646196038180803, + "unimateMaximumAnchorRotationError": 0.0, + "unimateMaximumAnchorLocationError": 0.0, + "unimateInbetweenAccepted": true, "pipeline": [ "Punch_Cross source body motion", "nonlinear whole-body retiming", "right-hand two-bone IK contact control", "world-space left support-foot IK lock", "Blender native NLA visual-keying bake", + "UniMate-inspired replacement in-between cleanup with immutable combat anchors", "glTF Action export" ] } \ No newline at end of file diff --git a/public/models/quaternius/blender-strikes-core.generator.sha256 b/public/models/quaternius/blender-strikes-core.generator.sha256 index d52a2cc3..941255fd 100644 --- a/public/models/quaternius/blender-strikes-core.generator.sha256 +++ b/public/models/quaternius/blender-strikes-core.generator.sha256 @@ -1 +1 @@ -d6cf687884e7a5f5f5dde2514bd84d8d2954eabc66229d703f957fa674edba7e +058f6816f046c2a6d6742d20abb18bc131399eaeb0bb9156493c6a9f74b23c5a diff --git a/public/models/quaternius/blender-strikes-core.glb b/public/models/quaternius/blender-strikes-core.glb index bccc2797..fc5755a4 100644 Binary files a/public/models/quaternius/blender-strikes-core.glb and b/public/models/quaternius/blender-strikes-core.glb differ diff --git a/public/models/quaternius/blender-strikes-core.metrics.json b/public/models/quaternius/blender-strikes-core.metrics.json index 2845e419..ad29a26f 100644 --- a/public/models/quaternius/blender-strikes-core.metrics.json +++ b/public/models/quaternius/blender-strikes-core.metrics.json @@ -7,6 +7,7 @@ "BF_BodyBlow_L", "BF_Backfist_R" ], + "motionPriorProvider": "UAL_AUTHORED_STRIKE_V2", "moves": [ { "version": "BLENDER_MOTION_FOUNDRY_V2_JAB", @@ -32,6 +33,67 @@ "boneCount": 65, "meshCount": 2, "sharedRig": "MOTION_FOUNDRY_V2_SHARED_STRIKE_RIG", + "motionPriorProvider": "UAL_AUTHORED_STRIKE_V2", + "naturalnessPass": "AUTHORED_SOURCE_V2", + "unimateInbetweenVersion": "UNIMATE_INSPIRED_REPLACEMENT_INBETWEEN_V1", + "unimateInbetweenAnchors": [ + 1, + 4, + 12, + 17, + 19, + 26, + 33 + ], + "unimateInbetweenSmoothedBones": 16, + "unimateInbetweenPreservedBones": [ + "pelvis", + "thigh_r", + "calf_r", + "foot_r" + ], + "unimateRotationAccelerationRmsBefore": 0.12844006686434026, + "unimateRotationAccelerationRmsAfter": 0.12374894686532664, + "unimateMaxRotationStepBefore": 2.2130022332403487, + "unimateMaxRotationStepAfter": 2.169927870911275, + "unimateMaximumAnchorRotationError": 0.0, + "unimateMaximumAnchorLocationError": 0.0, + "unimateInbetweenAccepted": true, + "referenceImpactNormalizedTime": null, + "referenceTimeWarpKnots": [ + [ + 0.0, + 0.0 + ], + [ + 0.12, + 0.06 + ], + [ + 0.28, + 0.17 + ], + [ + 0.4, + 0.35 + ], + [ + 0.5, + 0.64 + ], + [ + 0.6, + 0.78 + ], + [ + 0.79, + 0.92 + ], + [ + 1.0, + 1.0 + ] + ], "pipeline": [ "Punch_Jab source body motion", "move-specific nonlinear whole-body retiming", @@ -41,6 +103,7 @@ "world-space R support-foot position IK lock", "world-space R support-foot orientation lock", "Blender native NLA visual-keying bake", + "UniMate-inspired replacement in-between cleanup with immutable combat anchors", "glTF Action export" ], "leftHandTravel": 0.35778286428034883, @@ -72,6 +135,67 @@ "boneCount": 65, "meshCount": 2, "sharedRig": "MOTION_FOUNDRY_V2_SHARED_STRIKE_RIG", + "motionPriorProvider": "UAL_AUTHORED_STRIKE_V2", + "naturalnessPass": "AUTHORED_SOURCE_V2", + "unimateInbetweenVersion": "UNIMATE_INSPIRED_REPLACEMENT_INBETWEEN_V1", + "unimateInbetweenAnchors": [ + 1, + 7, + 17, + 23, + 27, + 36, + 44 + ], + "unimateInbetweenSmoothedBones": 16, + "unimateInbetweenPreservedBones": [ + "pelvis", + "thigh_r", + "calf_r", + "foot_r" + ], + "unimateRotationAccelerationRmsBefore": 0.14561244029592757, + "unimateRotationAccelerationRmsAfter": 0.13305374036717968, + "unimateMaxRotationStepBefore": 2.7085666123380436, + "unimateMaxRotationStepAfter": 2.514208912425967, + "unimateMaximumAnchorRotationError": 0.0, + "unimateMaximumAnchorLocationError": 0.0, + "unimateInbetweenAccepted": true, + "referenceImpactNormalizedTime": null, + "referenceTimeWarpKnots": [ + [ + 0.0, + 0.0 + ], + [ + 0.16, + 0.08 + ], + [ + 0.31, + 0.19 + ], + [ + 0.43, + 0.4 + ], + [ + 0.53, + 0.67 + ], + [ + 0.62, + 0.8 + ], + [ + 0.81, + 0.92 + ], + [ + 1.0, + 1.0 + ] + ], "pipeline": [ "Punch_Jab source body motion", "move-specific nonlinear whole-body retiming", @@ -81,6 +205,7 @@ "world-space R support-foot position IK lock", "world-space R support-foot orientation lock", "Blender native NLA visual-keying bake", + "UniMate-inspired replacement in-between cleanup with immutable combat anchors", "glTF Action export" ], "leftHandTravel": 0.4451729990980557, @@ -112,6 +237,67 @@ "boneCount": 65, "meshCount": 2, "sharedRig": "MOTION_FOUNDRY_V2_SHARED_STRIKE_RIG", + "motionPriorProvider": "UAL_AUTHORED_STRIKE_V2", + "naturalnessPass": "AUTHORED_SOURCE_V2", + "unimateInbetweenVersion": "UNIMATE_INSPIRED_REPLACEMENT_INBETWEEN_V1", + "unimateInbetweenAnchors": [ + 1, + 6, + 15, + 21, + 25, + 33, + 41 + ], + "unimateInbetweenSmoothedBones": 16, + "unimateInbetweenPreservedBones": [ + "pelvis", + "thigh_l", + "calf_l", + "foot_l" + ], + "unimateRotationAccelerationRmsBefore": 0.13205421621675714, + "unimateRotationAccelerationRmsAfter": 0.12724443267332597, + "unimateMaxRotationStepBefore": 1.9793565536761109, + "unimateMaxRotationStepAfter": 1.9530003625087484, + "unimateMaximumAnchorRotationError": 0.0, + "unimateMaximumAnchorLocationError": 0.0, + "unimateInbetweenAccepted": true, + "referenceImpactNormalizedTime": null, + "referenceTimeWarpKnots": [ + [ + 0.0, + 0.0 + ], + [ + 0.15, + 0.07 + ], + [ + 0.31, + 0.18 + ], + [ + 0.43, + 0.38 + ], + [ + 0.52, + 0.64 + ], + [ + 0.62, + 0.79 + ], + [ + 0.82, + 0.92 + ], + [ + 1.0, + 1.0 + ] + ], "pipeline": [ "Punch_Cross source body motion", "move-specific nonlinear whole-body retiming", @@ -121,6 +307,7 @@ "world-space L support-foot position IK lock", "world-space L support-foot orientation lock", "Blender native NLA visual-keying bake", + "UniMate-inspired replacement in-between cleanup with immutable combat anchors", "glTF Action export" ], "rightHandTravel": 0.5122012668972561, diff --git a/public/sw.js b/public/sw.js index f89d6f36..0b53b35b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "poly-fighter-v23-reference-kicks"; +const CACHE_NAME = "poly-fighter-v24-combat-feel"; const APP_SHELL = [ "/", "/manifest.webmanifest", diff --git a/scripts/capture-tps-combat-feel-audit.mjs b/scripts/capture-tps-combat-feel-audit.mjs new file mode 100644 index 00000000..19b79b41 --- /dev/null +++ b/scripts/capture-tps-combat-feel-audit.mjs @@ -0,0 +1,209 @@ +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import process from "node:process"; + +const driver = process.env.WEBDRIVER_BIN; +const url = process.env.AUDIT_URL ?? "http://127.0.0.1:3000/"; +const outputDir = process.env.TPS_AUDIT_DIR ?? "artifacts/tps-kick-sequence"; +if (!driver) throw new Error("WEBDRIVER_BIN is required"); + +const port = 9531; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const driverProcess = spawn(driver, [`--port=${port}`, "--allowed-ips="], { stdio: ["ignore", "pipe", "pipe"] }); +let driverLog = ""; +driverProcess.stdout.on("data", (chunk) => { driverLog += chunk.toString(); }); +driverProcess.stderr.on("data", (chunk) => { driverLog += chunk.toString(); }); + +async function waitForDriver() { + for (let attempt = 0; attempt < 150; attempt += 1) { + try { + const response = await fetch(`http://127.0.0.1:${port}/status`); + if (response.ok) return; + } catch {} + await delay(100); + } + throw new Error(`ChromeDriver did not start.\n${driverLog}`); +} + +async function command(path, method = "GET", body) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.value?.error) throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(payload)}`); + return payload.value; +} + +async function execute(sessionId, script, args = []) { + return command(`/session/${sessionId}/execute/sync`, "POST", { script, args }); +} + +async function clickButton(sessionId, text) { + return execute(sessionId, ` + const button = [...document.querySelectorAll('button')].find((entry) => entry.textContent?.includes(arguments[0])); + if (!button) return false; + button.click(); + return true; + `, [text]); +} + +async function screenshot(sessionId, path) { + const encoded = await command(`/session/${sessionId}/screenshot`); + const bytes = Buffer.from(encoded, "base64"); + if (bytes.length < 128 || bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") throw new Error(`Screenshot is not PNG: ${path}`); + await writeFile(path, bytes); +} + +const gameLookup = ` + function findGame() { + const host = document.querySelector('main.poly-app'); + if (!host) return null; + const key = Object.keys(host).find((entry) => entry.startsWith('__reactFiber$')); + let fiber = key ? host[key] : null; + const visited = new Set(); + while (fiber && !visited.has(fiber)) { + visited.add(fiber); + let hook = fiber.memoizedState; + while (hook) { + const value = hook.memoizedState; + const current = value && typeof value === 'object' && 'current' in value ? value.current : null; + if (current && current.p1 && current.p2 && current.renderer && current.camera && current.scene) return current; + hook = hook.next; + } + fiber = fiber.return; + } + return null; + } +`; + +const reset = ` + const game = findGame(); + cancelAnimationFrame(game.raf); game.running = false; + game.updateEnemy = game.__feelOriginalEnemy ?? game.updateEnemy.bind(game); + game.__feelOriginalEnemy = game.updateEnemy; + game.resetRound(); game.paused = false; game.finished = false; + game.input.clear(); game.p1.resetForRound(0, .72, 1); game.p2.resetForRound(0, -.72, -1); + game.enemyOpeningGraceTicks = 0; game.enemyTacticTicks = 999; + game.effects.update(5); + for (let i = 0; i < 10; i++) { + game.renderTime += 1 / 60; + game.updateVisual(game.p1, game.p2, game.renderTime); + game.updateVisual(game.p2, game.p1, game.renderTime + .007); + } + const frame = () => { game.renderTime += 1 / 60; game.step(); }; + const render = () => { + for (let i = 0; i < 18; i++) game.updateCamera(1 / 60); + game.updateLockOn(); game.renderer.render(game.scene, game.camera); + }; +`; + +let sessionId = null; +try { + await waitForDriver(); + const session = await command('/session', 'POST', { capabilities: { alwaysMatch: { + browserName: 'chrome', timeouts: { script: 180000, pageLoad: 120000, implicit: 0 }, + 'goog:chromeOptions': { args: ['--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--ignore-gpu-blocklist', '--enable-webgl', '--use-angle=swiftshader', '--window-size=1536,706', '--hide-scrollbars'] }, + } } }); + sessionId = session.sessionId; + await command(`/session/${sessionId}/url`, 'POST', { url }); + await delay(600); await mkdir(outputDir, { recursive: true }); + if (!await clickButton(sessionId, 'START FIGHT')) throw new Error('START FIGHT unavailable'); + await delay(140); + if (!await clickButton(sessionId, 'ENGAGE')) throw new Error('ENGAGE unavailable'); + let ready = false; + for (let i = 0; i < 150; i++) { + ready = await execute(sessionId, `${gameLookup} const g=findGame();return Boolean(g && g.p1.visual.root.userData.quaterniusModelState==='ready' && g.p2.visual.root.userData.quaterniusModelState==='ready');`); + if (ready) break; + await delay(100); + } + if (!ready) throw new Error('Combat feel audit did not load both real models'); + + const results = {}; + results.evade = await execute(sessionId, `${gameLookup}${reset} + game.enemyDirectorPendingMove='jab'; game.enemyDirectorTelegraphTicks=12; game.enemyDirectorTelegraphTotalTicks=18; + game.enemyDirectorDecision={intent:'JAB',holdTicks:1,telegraphTicks:18,reason:'feel-react',comebackMercy:0,pressure:0}; + game.press('right','feel-side'); game.press('guard','feel-step'); frame(); + game.release('right','feel-side'); game.release('guard','feel-step'); + let ticks=1; + while(ticks<35 && game.trainingProgress.perfectEvades<1){frame();ticks++;} + render(); return {ticks,health:game.p1.health,perfect:game.trainingProgress.perfectEvades,reversal:game.playerReversalTicks}; + `); + await screenshot(sessionId, `${outputDir}/tps-feel-evade.png`); + if (results.evade.health !== 100 || results.evade.perfect !== 1 || results.evade.reversal <= 0) throw new Error(`Physical evade failed: ${JSON.stringify(results.evade)}`); + results.punish = await execute(sessionId, `${gameLookup} + const game=findGame(); game.press('punch','feel-punish'); game.renderTime+=1/60;game.step();game.release('punch','feel-punish'); + let ticks=0;while(ticks<40 && game.trainingProgress.punishes<1){game.renderTime+=1/60;game.step();ticks++;} + game.updateCamera(1/60);game.updateLockOn();game.renderer.render(game.scene,game.camera); + return {ticks,punishes:game.trainingProgress.punishes,enemyHealth:game.p2.health,playerHealth:game.p1.health}; + `); + await screenshot(sessionId, `${outputDir}/tps-feel-punish.png`); + if (results.punish.punishes !== 1 || results.punish.enemyHealth >= 100) throw new Error(`Dodge counter failed: ${JSON.stringify(results.punish)}`); + + results.reactions = {}; + for (const [moveId, blocked, expected] of [['jab',false,'CM_Hit_HEAD_'],['kick',false,'CM_Hit_BODY_'],['lowKick',false,'CM_Hit_LEGS_'],['straight',true,'CM_Block_']]) { + const key = blocked ? 'block' : moveId; + results.reactions[key] = await execute(sessionId, `${gameLookup}${reset} + const [moveId,blocked]=arguments; + game.updateEnemy=()=>game.p2.updatePhysics(1/60); + game.p1.position.z=.67;game.p2.position.z=-.67; + const host=game.p2.visual.root.children.find(c=>c.name.startsWith('quaternius-ubc-')); + const point=name=>host.getObjectByName(name).getWorldPosition(game.p2.position.clone()); + const floor=Math.min(point('foot_l').y,point('foot_r').y); + if(blocked)game.p2.state='GUARD'; + game.p1.beginMove(moveId); + const move=game.p1.currentMove; + for(let i=0;i<=move.startup;i++){game.p1.moveTick=i;game.renderTime+=1/60;game.updateVisual(game.p1,game.p2,game.renderTime);} + game.trackCommittedAttack(game.p1,game.p2);game.resolveAttack(game.p1,game.p2,blocked); + for(let i=0;i.045 || (blocked ? r.health!==100 : r.health>=100)) throw new Error(`Reaction/grounding failed: ${key} ${JSON.stringify(r)}`); + } + + results.range = await execute(sessionId, `${gameLookup}${reset} + game.updateEnemy=()=>{}; const moves=[]; + for(const d of [1.45,1.68,1.63,1.73,1.85,1.66,1.45]){ + game.input.clear();game.p1.setInput({left:false,right:false,up:false,down:false,punch:false,kick:false,guard:false}); + game.p1.state='IDLE';game.p1.currentMove=null;game.p1.moveTick=0;game.playerComboStage=0; + game.p1.position.set(0,0,d/2);game.p2.position.set(0,0,-d/2); + game.press('punch','feel-range');frame();game.release('punch','feel-range');moves.push(game.p1.currentMove?.id); + } + return moves; + `); + if (JSON.stringify(results.range)!==JSON.stringify(['jab','jab','jab','jab','kick','kick','jab'])) throw new Error(`Touch range hysteresis failed: ${JSON.stringify(results.range)}`); + + results.queuedIntent = await execute(sessionId, `${gameLookup}${reset} + game.updateEnemy=()=>game.p2.updatePhysics(1/60); + game.press('right','feel-queue-side');game.press('guard','feel-queue-step');frame(); + game.release('guard','feel-queue-step');game.release('right','feel-queue-side');frame(); + game.press('down','feel-queue-back');game.press('punch','feel-queue-attack');frame(); + game.release('down','feel-queue-back');game.release('punch','feel-queue-attack'); + for(let i=0;i<10 && game.p1.state!=='ATTACK';i++)frame(); + return {move:game.p1.currentMove?.id,intent:game.p1.visual.root.userData.tpsOpeningIntent}; + `); + if (!results.queuedIntent.intent?.back || !['backfist','lowKick'].includes(results.queuedIntent.move)) throw new Error(`Queued STEP attack lost intent: ${JSON.stringify(results.queuedIntent)}`); + + results.cpu = await execute(sessionId, `${gameLookup}${reset} + game.enemyPersona='SKIRMISHER'; const before=game.p1.position.distanceTo(game.p2.position); + const toward=game.p1.position.clone().sub(game.p2.position).normalize(); + game.beginEnemyDirectorMove('kick','KICK',toward); + const m=game.p2.currentMove;game.p2.moveTick=m.startup+m.active+m.recovery-1; + for(let i=0;i<7;i++)game.updateEnemy(); + return {before,after:game.p1.position.distanceTo(game.p2.position),intent:game.p2.visual.root.userData.tpsCpuDirectorIntent,reason:game.p2.visual.root.userData.tpsCpuDirectorReason}; + `); + if (results.cpu.intent!=='RETREAT' || results.cpu.after{}); + driverProcess.kill('SIGTERM'); + await writeFile(`${outputDir}/webdriver-combat-feel.log`,driverLog).catch(()=>{}); +} + diff --git a/scripts/capture-tps-combat-v2-audit.mjs b/scripts/capture-tps-combat-v2-audit.mjs index 61ac1851..35bb4573 100644 --- a/scripts/capture-tps-combat-v2-audit.mjs +++ b/scripts/capture-tps-combat-v2-audit.mjs @@ -100,6 +100,9 @@ const resetSnippet = ` game.playerEvadeTicks = 0; game.playerEvadeCooldown = 0; game.playerStepThreatTicks = 0; + game.playerStepThreatPosition = null; + game.__openingStrikeRange = undefined; + game.__queuedStepOpener = undefined; game.playerFlankWindowTicks = 0; game.playerFlankAttackTicks = 0; game.playerPerfectEvadeTicks = 0; @@ -112,6 +115,11 @@ const resetSnippet = ` game.combatBeatTicks = 0; game.enemyOpeningGraceTicks = 0; game.enemyDirectorPendingMove = null; + game.enemyTelegraphAim = null; + game.enemyTelegraphAimMove = null; + game.enemyPostAttackTicks = 0; + game.enemyAttackCount = 0; + game.enemyLastMoveId = null; game.enemyDirectorTelegraphTicks = 0; game.enemyDirectorTelegraphTotalTicks = 0; game.enemyDirectorDecision = null; @@ -328,7 +336,10 @@ try { const game = findGame(); ${resetSnippet} if (!game.p2.beginMove('power')) return { error: 'enemy-power-begin-failed' }; - game.p2.moveTick = Math.max(0, game.p2.currentMove.startup - 1); + // Model a windup that already has a committed direction. A STEP one frame + // before a wide strike's contact is physically too late, not invincible. + game.p2.moveTick = Math.max(0, game.p2.currentMove.startup - 8); + game.trackCommittedAttack(game.p2, game.p1); game.press('right', 'tps-v2-reversal-side'); game.press('guard', 'tps-v2-reversal-step'); game.step(); diff --git a/src/game/combat-contact-reaction.ts b/src/game/combat-contact-reaction.ts new file mode 100644 index 00000000..ac5253bf --- /dev/null +++ b/src/game/combat-contact-reaction.ts @@ -0,0 +1,46 @@ +import type { ReactionTarget } from "./types"; + +export const CONTACT_REACTION_VERSION = "CONTACT_REACTION_V1"; +export type ContactReactionRegion = "HEAD" | "BODY" | "LEGS" | "BLOCK"; + +export function ownsContactReaction(data: Record): boolean { + return data.quaterniusModelState === "ready" && data.tpsContactReactionVersion === CONTACT_REACTION_VERSION; +} + +export function contactReactionClip(region: ReactionTarget, side: "LEFT" | "RIGHT", blocked: boolean): string { + return blocked ? `CM_Block_${side === "LEFT" ? "L" : "R"}` + : `CM_Hit_${region}_${side === "LEFT" ? "L" : "R"}`; +} + +function pulse(u: number, peak: number, end: number): number { + const x = u < peak ? u / peak : 1 - (u - peak) / (end - peak); + const t = Math.max(0, Math.min(1, x)); + return t * t * (3 - 2 * t); +} + +/** Baked target-rig response: contact first, weight transfer second, then brace. */ +export function sampleContactReaction(region: ContactReactionRegion, side: "L" | "R", phase: number) { + const u = Math.max(0, Math.min(1, phase)); + const sign = side === "L" ? -1 : 1; + const impact = pulse(u, .12, .68); + const weight = pulse(u, .29, .91); + const step = pulse(u, .61, 1); + const block = region === "BLOCK"; + const body = region === "BODY"; + const legs = region === "LEGS"; + return { + side: sign * (legs ? .027 : .009) * weight, + forward: -(block ? .008 : .022) * weight, + drop: -(legs ? .060 : body ? .030 : block ? .012 : .018) * weight, + pitch: .025 + (body ? .13 : legs ? .065 : block ? -.025 : -.10) * weight, + chest: .035 + (body ? .20 : legs ? .06 : block ? -.045 : -.15) * impact, + yaw: sign * (block ? .035 : .13) * impact, + roll: sign * (legs ? .075 : .035) * weight, + highGuard: block ? 1 : .15 * weight, + headPitch: (body ? .055 : legs ? 0 : block ? -.02 : -.13) * impact, + headYaw: sign * (block ? .025 : .10) * impact, + handDrop: body ? .065 * impact : 0, + rearStep: block ? 0 : .036 * step, + footLift: block ? 0 : .022 * Math.sin(Math.PI * u) ** 2 * step, + }; +} diff --git a/src/game/combat-motion-authoring.ts b/src/game/combat-motion-authoring.ts index c1e4751e..add8efc3 100644 --- a/src/game/combat-motion-authoring.ts +++ b/src/game/combat-motion-authoring.ts @@ -5,6 +5,7 @@ import { isKineticPunch, punchStrikeSide, samplePunchKineticChain } from "./comb import type { FighterDefinition } from "./types"; import { isReferenceKick, REFERENCE_KICK_VERSION, sampleReferenceKickChoreography } from "./reference-kick-choreography"; import { authoredMotionEvents } from "./combat-motion-timeline"; +import { CONTACT_REACTION_VERSION, sampleContactReaction } from "./combat-contact-reaction"; type Transform = { position: THREE.Vector3; rotation: THREE.Quaternion }; type Pose = Map; @@ -72,7 +73,7 @@ export function createCombatMotionLibrary(target: THREE.Group, sourceClips: Map< // imported punches/kicks after a later asset pack becomes available. const sourceSignature = [...sourceClips].map(([name, clip]) => `${name}@${clip.duration.toFixed(4)}:${clip.tracks.length}`).sort().join("|"); - const key = `${REFERENCE_KICK_VERSION}:${definition.archetype}:${sourceSignature}`; + const key = `${REFERENCE_KICK_VERSION}:${CONTACT_REACTION_VERSION}:${definition.archetype}:${sourceSignature}`; const cached = clipCache.get(key); if (cached) return new Map(cached); const rig = cloneSkeleton(target) as THREE.Group; @@ -236,6 +237,21 @@ export function createCombatMotionLibrary(target: THREE.Group, sourceClips: Map< }); author("CM_Land", .22, u => body({ drop: -.057 * Math.sin(Math.PI * u), chest: .09 * Math.sin(Math.PI * u), highGuard: .2 })); + for (const region of ["HEAD", "BODY", "LEGS", "BLOCK"] as const) for (const side of ["L", "R"] as const) { + const name = region === "BLOCK" ? `CM_Block_${side}` : `CM_Hit_${region}_${side}`; + author(name, region === "BLOCK" ? .28 : .42, u => { + const p = sampleContactReaction(region, side, u); + body({ ...p, + leftHand: [-.088, .735 - p.handDrop, .172], + rightHand: [.083, .756 - p.handDrop, .125], + leftFoot: side === "L" ? [-width, p.footLift, .065 - p.rearStep] : stanceFeet.l, + rightFoot: side === "R" ? [width, p.footLift, -.070 - p.rearStep] : stanceFeet.r, + }); + rotate("Head", X, p.headPitch); + rotate("Head", Y, p.headYaw * handedness); + }, 37); + } + const downPose: BodyPose = { drop: -.39, forward: -.10, pitch: -1.46, chest: .08, yaw: -.10, leftFoot: [-.11, .005, .30], rightFoot: [.14, .005, .23], leftHand: [-.24, .025, -.25], rightHand: [.25, .025, -.23] }; function interpolate(a: BodyPose, b: BodyPose, u: number): BodyPose { diff --git a/src/game/combat-motion-clock.ts b/src/game/combat-motion-clock.ts index 04e9e975..52716e23 100644 --- a/src/game/combat-motion-clock.ts +++ b/src/game/combat-motion-clock.ts @@ -5,7 +5,7 @@ import { sampleCombatMotionAtEvent, } from "./combat-motion-timeline"; -export const COMBAT_MOTION_VERSION = "COMBAT_MOTION_V10_REFERENCE_KICKS"; +export const COMBAT_MOTION_VERSION = "COMBAT_MOTION_V11_CONTACT_REACTIONS"; /** @deprecated Use AUTHORED_MOTION_EVENTS from combat-motion-timeline. */ export const AUTHORED_CONTACT_PHASE: Readonly> = Object.freeze( diff --git a/src/game/combat-motion-handoff.ts b/src/game/combat-motion-handoff.ts index 63b57cde..4cef0c74 100644 --- a/src/game/combat-motion-handoff.ts +++ b/src/game/combat-motion-handoff.ts @@ -38,6 +38,7 @@ export function isCombatAttackClip(name: string): boolean { function isReactionClip(name: string): boolean { return name.startsWith("BF_Hit") || name.startsWith("BF_CounterHit") + || name.startsWith("CM_Hit_") || name === "BF_EdgeStagger" || name === "CM_Launch"; } @@ -72,7 +73,7 @@ export function planCombatMotionHandoff( if (isReactionClip(previous) && (nextState === "GUARD" || nextState === "IDLE" || nextState === "WALK")) { return { mode: "REACTION_RECOVERY", duration: .115, velocityCarry: .30 }; } - if (previous === "CM_Block" && (nextState === "GUARD" || nextState === "IDLE" || nextState === "WALK")) { + if (previous.startsWith("CM_Block") && (nextState === "GUARD" || nextState === "IDLE" || nextState === "WALK")) { return { mode: "BLOCK_RECOVERY", duration: .086, velocityCarry: .32 }; } if (nextState === "GUARD") { diff --git a/src/game/fighter.ts b/src/game/fighter.ts index 65f75c0c..aab69fba 100644 --- a/src/game/fighter.ts +++ b/src/game/fighter.ts @@ -228,6 +228,7 @@ export class FighterRuntime { knockdown: boolean, hitStop: number, ): void { + this.clearContactReaction(); this.health = Math.max(0, this.health - damage); this.hitStop = Math.max(this.hitStop, hitStop); this.velocity.x = attackerFacing * knockback * 20; @@ -247,6 +248,7 @@ export class FighterRuntime { } receiveBlock(guardDamage: number, stun: number, hitStop: number): void { + this.clearContactReaction(); this.guardDamage = Math.min(100, this.guardDamage + guardDamage); this.blockStun = stun; this.hitStop = Math.max(this.hitStop, hitStop); @@ -254,6 +256,7 @@ export class FighterRuntime { } receiveThrow(damage: number, knockback: number, attackerFacing: number, hitStop: number): void { + this.clearContactReaction(); this.health = Math.max(0, this.health - damage); this.hitStop = Math.max(this.hitStop, hitStop); this.velocity.x = attackerFacing * knockback * 22; @@ -266,6 +269,7 @@ export class FighterRuntime { } resetForRound(x: number, z: number, facing: number): void { + this.clearContactReaction(); this.position.set(x, 0, z); this.velocity.set(0, 0, 0); this.grounded = true; @@ -287,6 +291,11 @@ export class FighterRuntime { releaseFootPlants(this.visual); } + private clearContactReaction(): void { + delete this.visual.root.userData.tpsContactReactionVersion; + delete this.visual.root.userData.tpsContactReactionClip; + } + validateInvariants(): boolean { const values = [ this.position.x, diff --git a/src/game/motion-reaction.ts b/src/game/motion-reaction.ts index 6ef95c5e..4bb0b446 100644 --- a/src/game/motion-reaction.ts +++ b/src/game/motion-reaction.ts @@ -1,4 +1,5 @@ import type { FighterRuntime } from "./fighter"; +import { ownsContactReaction } from "./combat-contact-reaction"; import { reactionKindForMove } from "./motion-profile"; import { PresentationAnimationController } from "./presentation-animation"; import { TpsHypeDirector } from "./tps-hype"; @@ -16,6 +17,7 @@ export type MotionReactionState = { type DamageAfterfeelState = { lastState: FighterRuntime["state"]; + lastAuthored: boolean; until: number; side: -1 | 1; tier: 1 | 2 | 3; @@ -117,6 +119,7 @@ function ensureDamageAfterfeel(fighter: FighterRuntime): DamageAfterfeelState { if (state) return state; state = { lastState: fighter.state, + lastAuthored: false, until: 0, side: 1, tier: 1, @@ -511,15 +514,17 @@ PresentationAnimationController.prototype.update = function updateWithDamageAfte const combatTps = Boolean(fighter.visual.root.userData.combatTps); const settling = DAMAGE_AFTERFEEL_SETTLE_STATES.has(fighter.state); - if (combatTps && afterfeel.lastState === "HIT" && settling) { + const authored = ownsContactReaction(fighter.visual.root.userData); + if (combatTps && afterfeel.lastState === "HIT" && settling && !afterfeel.lastAuthored && !authored) { const reaction = ensure(fighter); afterfeel.until = timeSeconds + DAMAGE_AFTERFEEL_SECONDS; afterfeel.side = reaction.side; afterfeel.tier = reaction.tier; - } else if (!settling) { + } else if (!settling || authored || afterfeel.lastAuthored) { afterfeel.until = 0; } afterfeel.lastState = fighter.state; + afterfeel.lastAuthored = authored; if (combatTps) { beginComboMomentumCarry(fighter, comboCarry, timeSeconds); diff --git a/src/game/presentation-animation.ts b/src/game/presentation-animation.ts index c87ca82e..1a87a649 100644 --- a/src/game/presentation-animation.ts +++ b/src/game/presentation-animation.ts @@ -1,6 +1,7 @@ import * as THREE from "three"; import { FighterAnimationController, type FighterRuntime } from "./fighter"; import { motionCorrectionsEnabled } from "./motion-correction-state"; +import { ownsContactReaction } from "./combat-contact-reaction"; import { fighterBasis, fighterRootQuaternion, orientBoneForward, solveTwoBoneIK } from "./rig"; import { finalizeQuaterniusModelPose, updateQuaterniusModelSkin } from "./visual-quaternius-runtime"; @@ -43,7 +44,7 @@ const TPS_HIT_RECOIL = { function applyTpsImpactReadability(fighter: FighterRuntime, opponent: FighterRuntime): void { const root = fighter.visual.root; - if (!root.userData.combatTps) return; + if (!root.userData.combatTps || ownsContactReaction(root.userData)) return; const blocked = fighter.state === "BLOCK_STUN"; const hit = fighter.state === "HIT"; diff --git a/src/game/tps-enemy-policy.ts b/src/game/tps-enemy-policy.ts index 8183a90c..9398705b 100644 --- a/src/game/tps-enemy-policy.ts +++ b/src/game/tps-enemy-policy.ts @@ -191,6 +191,7 @@ export function adaptTpsCpuDecision( adaptation: EnemyAdaptation; liveDistance: number; simulationTicks: number; + attackCount?: number; }, ): CpuDecision { let decision = initialDecision; @@ -246,5 +247,25 @@ export function adaptTpsCpuDecision( decision = { ...decision, intent: "SIDESTEP", reason: "adapt-directional-cut" }; } + // A small repeated phrase makes the existing persona readable. Tactical + // counters/adaptation keep priority; only ordinary neutral attacks use it. + const ordinary = ["JAB", "STRAIGHT", "BACKFIST", "BODY_BLOW", "KICK", "LOW_KICK", "POWER"].includes(decision.intent); + if (context.attackCount !== undefined && context.adaptation === "NEUTRAL" && ordinary + && !/punish|counter|interrupt/.test(decision.reason)) { + const index = context.attackCount % 3; + const phrase: readonly CpuIntent[] = context.persona === "BRAWLER" + ? context.liveDistance <= 1.65 ? ["JAB", "STRAIGHT", "POWER"] : ["KICK", "KICK", "DASH_KICK"] + : context.liveDistance > 1.55 ? ["KICK", "LOW_KICK", "KICK"] : ["STRAIGHT", "BACKFIST", "BODY_BLOW"]; + decision = { ...decision, intent: phrase[index], reason: `${decision.reason}-readable-${context.persona.toLowerCase()}-${index + 1}` }; + } return decision; } + +export function tpsEnemyRecoveryHabit(persona: EnemyPersona, moveId: string | null): { intent: CpuIntent; ticks: number } { + if (persona === "SKIRMISHER") { + return { intent: "RETREAT", ticks: moveId?.toLowerCase().includes("kick") ? 12 : 7 }; + } + return moveId === "power" || moveId === "dashKick" + ? { intent: "WAIT", ticks: 10 } + : { intent: "GUARD", ticks: 5 }; +} diff --git a/src/game/tps-fight-fundamentals.ts b/src/game/tps-fight-fundamentals.ts index 912955e6..28e4d041 100644 --- a/src/game/tps-fight-fundamentals.ts +++ b/src/game/tps-fight-fundamentals.ts @@ -8,6 +8,21 @@ import type { MoveDefinition } from "./types"; export const TPS_CONTACT_REACH_TOLERANCE = 0.20; export const TPS_ATTACK_LANE_PADDING = 0.23; export const TPS_ATTACK_AIM_TURN_PER_TICK = 0.028; // ~96 degrees/sec at 60Hz +export type TpsOpeningRange = "CLOSE" | "FAR"; + +/** A small dead band keeps touch movement near 1.65 m from changing the opener. */ +export function tpsOpeningRange(distance: number, previous?: TpsOpeningRange): TpsOpeningRange { + if (previous === "CLOSE") return distance > 1.78 ? "FAR" : "CLOSE"; + if (previous === "FAR") return distance < 1.52 ? "CLOSE" : "FAR"; + return distance > 1.65 ? "FAR" : "CLOSE"; +} + +/** Straight strikes lose to lateral movement; arcs cover more of that lane. */ +export function tpsStrikeTracking(moveId?: string): number { + return moveId === "backfist" || moveId === "lowKick" ? .038 + : moveId === "jab" || moveId === "straight" || moveId === "kick" || moveId === "dashKick" ? .014 + : TPS_ATTACK_AIM_TURN_PER_TICK; +} export type TpsStrikeGeometry = Readonly<{ connected: boolean; @@ -18,7 +33,7 @@ export type TpsStrikeGeometry = Readonly<{ }>; export function evaluateTpsStrikeGeometry(input: { - move: Pick; + move: Pick & Partial>; attackerX: number; attackerZ: number; defenderX: number; @@ -34,7 +49,11 @@ export function evaluateTpsStrikeGeometry(input: { const longitudinal = dx * fx + dz * fz; const lateral = Math.abs(dx * fz - dz * fx); const reach = input.move.reach + (input.move.hitLevel === "THROW" ? .16 : TPS_CONTACT_REACH_TOLERANCE); - const halfWidth = Math.max(.58, input.move.width + TPS_ATTACK_LANE_PADDING); + const straight = ["jab", "straight", "kick", "dashKick"].includes(input.move.id ?? ""); + const sweeping = input.move.id === "backfist" || input.move.id === "lowKick"; + const halfWidth = straight + ? Math.max(.38, Math.min(.64, input.move.width * .65 + .12)) + : Math.max(.58, input.move.width + TPS_ATTACK_LANE_PADDING + (sweeping ? .08 : 0)); return { connected: longitudinal >= .36 && longitudinal <= reach && lateral <= halfWidth, longitudinal, lateral, reach, halfWidth, @@ -62,11 +81,12 @@ export function tpsAttackWindupAdvance( */ export function turnTpsCommittedAttackAim( previousX: number, previousZ: number, targetX: number, targetZ: number, + turnLimit = TPS_ATTACK_AIM_TURN_PER_TICK, ): { x: number; z: number } { const origin = Math.atan2(previousX, previousZ); const target = Math.atan2(targetX, targetZ); const delta = Math.atan2(Math.sin(target - origin), Math.cos(target - origin)); - const heading = origin + Math.max(-TPS_ATTACK_AIM_TURN_PER_TICK, Math.min(TPS_ATTACK_AIM_TURN_PER_TICK, delta)); + const heading = origin + Math.max(-turnLimit, Math.min(turnLimit, delta)); return { x: Math.sin(heading), z: Math.cos(heading) }; } @@ -88,8 +108,9 @@ export function chooseTpsOpeningStrike(input: { forward: boolean; back: boolean; lateral: boolean; + previousRange?: TpsOpeningRange; }): { moveId: "jab" | "straight" | "kick" | "lowKick" | "backfist" | "bodyBlow"; route: "CLOSE_A" | "CLOSE_B" | "FAR" } { - const far = input.distance > 1.65; + const far = tpsOpeningRange(input.distance, input.previousRange) === "FAR"; if (far) { return { moveId: input.back || input.lateral ? "lowKick" : "kick", route: "FAR" }; } diff --git a/src/game/tps-game-base.ts b/src/game/tps-game-base.ts index dd9bcc6e..740da952 100644 --- a/src/game/tps-game-base.ts +++ b/src/game/tps-game-base.ts @@ -41,7 +41,7 @@ import { } from "./tps-gameplay-profile"; import { computeTpsContactSpacing, type TpsContactSpacingMode } from "./tps-contact-spacing"; import { computeTpsHitResolution, tpsImpactHeightForMove } from "./tps-impact-resolution"; -import { evaluateTpsStrikeGeometry, turnTpsCommittedAttackAim, tpsAttackWindupAdvance, tpsCanConfirmCombo } from "./tps-fight-fundamentals"; +import { evaluateTpsStrikeGeometry, turnTpsCommittedAttackAim, tpsAttackWindupAdvance, tpsCanConfirmCombo, tpsStrikeTracking, type TpsStrikeGeometry } from "./tps-fight-fundamentals"; import { applyTpsImpactPresentation } from "./tps-impact-presentation"; import { adaptTpsCpuDecision, @@ -51,6 +51,7 @@ import { tpsCpuActorSnapshot, tpsCpuAttackMove, tpsEnemyReactionWindowTicks, + tpsEnemyRecoveryHabit, type EnemyAdaptation, type EnemyPersona, type EnemyTactic, @@ -161,12 +162,13 @@ export class TpsFightGame { private playerComboStage = 0; private playerComboGraceTicks = 0; private playerAttackQueued = false; - private readonly attackCommitments = new WeakMap(); + private readonly attackCommitments = new WeakMap(); private playerFlankWindowTicks = 0; private playerFlankAttackTicks = 0; private playerPerfectEvadeTicks = 0; private playerStepThreatTicks = 0; private playerStepThreatMoveId: string | null = null; + private playerStepThreatPosition: THREE.Vector3 | null = null; private playerInterceptTicks = 0; private playerReversalTicks = 0; private combatBeatLabel: string | null = null; @@ -195,6 +197,12 @@ export class TpsFightGame { private enemyDirectorTelegraphTicks = 0; private enemyDirectorTelegraphTotalTicks = 0; private enemyDirectorPendingMove: string | null = null; + private enemyTelegraphAim: THREE.Vector3 | null = null; + private enemyTelegraphAimMove: string | null = null; + private enemyAttackCount = 0; + private enemyLastMoveId: string | null = null; + private enemyPostAttackTicks = 0; + private enemyPostAttackIntent: CpuIntent = "WAIT"; constructor(mount: HTMLElement, options: TpsFightGameOptions) { this.mount = mount; @@ -405,8 +413,11 @@ export class TpsFightGame { attacker.visual.root.userData.tpsCommittedAttackForward = forward.toArray(); return; } - const next = turnTpsCommittedAttackAim(current.forward.x, current.forward.z, toward.x, toward.z); - current.forward.set(next.x, 0, next.z); + if (!current.locked && attacker.moveTick < attacker.currentMove.startup) { + const next = turnTpsCommittedAttackAim(current.forward.x, current.forward.z, toward.x, toward.z, + tpsStrikeTracking(attacker.currentMove.id)); + current.forward.set(next.x, 0, next.z); + } current.lastTick = attacker.moveTick; attacker.visual.root.userData.tpsCommittedAttackForward = current.forward.toArray(); } @@ -630,6 +641,11 @@ export class TpsFightGame { }); this.playerStepThreatTicks = stepThreat.ticks; this.playerStepThreatMoveId = stepThreat.moveId; + this.playerStepThreatPosition = stepThreat.ticks > 0 ? this.p1.position.clone() : null; + if (pendingReaction && (!this.enemyTelegraphAim || this.enemyTelegraphAimMove !== this.enemyDirectorPendingMove)) { + this.enemyTelegraphAim = horizontalDirection(this.p2.position, this.p1.position); + this.enemyTelegraphAimMove = this.enemyDirectorPendingMove; + } } private advancePlayerStep( @@ -746,7 +762,17 @@ export class TpsFightGame { this.p2.setInput(EMPTY_INPUT); const liveDistance = horizontalDistance(this.p2.position, this.p1.position); this.enemyFunDirector.observe(this.enemySituation()); - if (this.advanceLockedState(this.p2)) return; + const wasAttacking = this.p2.state === "ATTACK"; + if (this.advanceLockedState(this.p2)) { + if (wasAttacking && this.p2.state !== "ATTACK") { + const habit = tpsEnemyRecoveryHabit(this.enemyPersona, this.enemyLastMoveId); + this.enemyPostAttackTicks = habit.ticks; + this.enemyPostAttackIntent = habit.intent; + this.enemyDirectorHoldTicks = 0; + this.enemyDirectorDecision = null; + } + return; + } this.enemyCooldown = Math.max(0, this.enemyCooldown - 1); if (this.enemyOpeningGraceTicks > 0) this.enemyOpeningGraceTicks -= 1; @@ -756,6 +782,18 @@ export class TpsFightGame { const towardPlayer = horizontalDirection(this.p2.position, this.p1.position); const tangent = new THREE.Vector3(-towardPlayer.z, 0, towardPlayer.x); + if (this.enemyPostAttackTicks > 0) { + this.enemyPostAttackTicks -= 1; + const decision: CpuDecision = { + intent: this.enemyPostAttackIntent, holdTicks: 1, telegraphTicks: 0, + reason: this.enemyPersona === "SKIRMISHER" ? "readable-kick-then-reset" : "readable-heavy-then-open", + comebackMercy: 0, pressure: 0, + }; + this.publishEnemyDecision(decision); + this.moveEnemy(decision.intent, towardPlayer, tangent); + this.p2.updatePhysics(FIXED_STEP); + return; + } if (this.advanceEnemyTelegraph(towardPlayer)) return; if (this.runEnemyOpeningGrace(liveDistance, towardPlayer, tangent)) return; if (this.runHeldEnemyDecision(towardPlayer, tangent)) return; @@ -767,6 +805,7 @@ export class TpsFightGame { adaptation: this.enemyAdaptation, liveDistance, simulationTicks: this.simulationTicks, + attackCount: this.enemyAttackCount, }, ); this.enemyDirectorDecision = decision; @@ -852,6 +891,11 @@ export class TpsFightGame { 1, ); const reactionWindow = tpsEnemyReactionWindowTicks(this.difficulty); + if (this.enemyDirectorTelegraphTicks <= reactionWindow + && (!this.enemyTelegraphAim || this.enemyTelegraphAimMove !== this.enemyDirectorPendingMove)) { + this.enemyTelegraphAim = horizontalDirection(this.p2.position, this.p1.position); + this.enemyTelegraphAimMove = this.enemyDirectorPendingMove; + } rootData.tpsEnemyTelegraphProgress = progress; rootData.tpsEnemyTelegraphMove = this.enemyDirectorPendingMove; rootData.tpsEnemyTelegraphPhase = this.enemyDirectorTelegraphTicks <= reactionWindow @@ -950,6 +994,8 @@ export class TpsFightGame { minimumTpsEnemyTelegraphTicks(this.difficulty, moveId), ); this.enemyDirectorPendingMove = moveId; + this.enemyTelegraphAim = null; + this.enemyTelegraphAimMove = null; this.enemyDirectorTelegraphTicks = telegraphTicks; this.enemyDirectorTelegraphTotalTicks = telegraphTicks; rootData.tpsCpuDirectorTelegraphTicks = telegraphTicks; @@ -1026,6 +1072,15 @@ export class TpsFightGame { ): boolean { const began = this.p2.beginMove(moveId); if (!began) return false; + if (this.enemyTelegraphAim && this.enemyTelegraphAimMove === moveId && this.p2.currentMove) { + this.attackCommitments.set(this.p2, { + move: this.p2.currentMove, lastTick: 0, forward: this.enemyTelegraphAim.clone(), locked: true, + }); + } + this.enemyTelegraphAim = null; + this.enemyTelegraphAimMove = null; + this.enemyLastMoveId = moveId; + this.enemyAttackCount += 1; const rootData = this.p2.visual.root.userData; rootData.tpsCpuDirectorMove = moveId; @@ -1059,6 +1114,12 @@ export class TpsFightGame { return true; } if (["HIT", "BLOCK_STUN", "KNOCKDOWN", "WAKEUP", "THROW", "KO", "RING_OUT"].includes(fighter.state)) { + if (fighter === this.p2) { + this.enemyDirectorPendingMove = null; + this.enemyTelegraphAim = null; + this.enemyTelegraphAimMove = null; + this.enemyPostAttackTicks = 0; + } fighter.updatePhysics(FIXED_STEP); return true; } @@ -1093,7 +1154,7 @@ export class TpsFightGame { if (!geometry.connected) { // Dodge rewards now require the player to physically escape the strike // lane; simply pressing STEP during a telegraph is not invulnerability. - this.tryResolveTrackedSideEvade(attacker, defender, move); + this.tryResolveTrackedSideEvade(attacker, defender, move, geometry); return; } @@ -1182,18 +1243,27 @@ export class TpsFightGame { attacker: FighterRuntime, defender: FighterRuntime, move: MoveDefinition, + geometry: TpsStrikeGeometry, ): boolean { const trackedSideEvade = defender === this.p1 && attacker === this.p2 && this.playerStepThreatTicks > 0 && this.playerStepThreatMoveId === move.id && this.playerStepSideWeight > 0.45 + && geometry.lateral > geometry.halfWidth && move.hitLevel !== "THROW"; if (!trackedSideEvade) return false; + const origin = this.playerStepThreatPosition; + const aim = this.attackCommitments.get(attacker)?.forward; + if (!origin || !aim || !evaluateTpsStrikeGeometry({ + move, attackerX: attacker.position.x, attackerZ: attacker.position.z, + defenderX: origin.x, defenderZ: origin.z, forwardX: aim.x, forwardZ: aim.z, + }).connected) return false; attacker.hitTargets.add(defender.id); this.playerStepThreatTicks = 0; this.playerStepThreatMoveId = null; + this.playerStepThreatPosition = null; this.playerFlankWindowTicks = Math.max( this.playerFlankWindowTicks, TPS_FLANK_WINDOW_TICKS, @@ -1325,11 +1395,15 @@ export class TpsFightGame { private applyAttackStepIn(attacker: FighterRuntime, defender: FighterRuntime): void { const move = attacker.currentMove; if (attacker.state !== "ATTACK" || !move || attacker.moveTick > move.startup) return; - const distance = horizontalDistance(attacker.position, defender.position); + const commitment = this.attackCommitments.get(attacker); + const committed = commitment?.forward ?? horizontalDirection(attacker.position, defender.position); + // Continue a committed weight shift down its original lane. A lateral + // escape must not cancel that step or pull the attack diagonally after it. + const distance = commitment?.locked + ? (defender.position.x - attacker.position.x) * committed.x + (defender.position.z - attacker.position.z) * committed.z + : horizontalDistance(attacker.position, defender.position); const stepDistance = tpsAttackWindupAdvance(move, distance); if (stepDistance <= 0) return; - const committed = this.attackCommitments.get(attacker)?.forward - ?? horizontalDirection(attacker.position, defender.position); attacker.position.addScaledVector(committed, stepDistance); } @@ -1621,6 +1695,7 @@ export class TpsFightGame { this.playerPerfectEvadeTicks = 0; this.playerStepThreatTicks = 0; this.playerStepThreatMoveId = null; + this.playerStepThreatPosition = null; this.playerInterceptTicks = 0; this.playerReversalTicks = 0; @@ -1645,6 +1720,12 @@ export class TpsFightGame { this.enemyDirectorTelegraphTicks = 0; this.enemyDirectorTelegraphTotalTicks = 0; this.enemyDirectorPendingMove = null; + this.enemyTelegraphAim = null; + this.enemyTelegraphAimMove = null; + this.enemyAttackCount = 0; + this.enemyLastMoveId = null; + this.enemyPostAttackTicks = 0; + this.enemyPostAttackIntent = "WAIT"; const rootData = this.p2.visual.root.userData; rootData.tpsEnemyTelegraphProgress = 0; diff --git a/src/game/tps-game.ts b/src/game/tps-game.ts index d10f5018..92ff9b60 100644 --- a/src/game/tps-game.ts +++ b/src/game/tps-game.ts @@ -2,8 +2,9 @@ import * as THREE from "three"; import { TpsFightGame as CoreTpsFightGame } from "./tps-game-base"; import type { FighterRuntime } from "./fighter"; import { resolveContextAttack, type FighterDna } from "./fighter-dna"; -import { chooseTpsOpeningStrike } from "./tps-fight-fundamentals"; +import { chooseTpsOpeningStrike, tpsOpeningRange, type TpsOpeningRange } from "./tps-fight-fundamentals"; import { finalizeQuaterniusModelPose } from "./visual-quaternius-runtime"; +import { tpsImpactHeightForMove } from "./tps-impact-resolution"; import { chooseTpsComboContinuationRoute, chooseTpsComboRoute, @@ -62,6 +63,8 @@ type ExtendedTpsRuntime = CoreTpsFightGame & { __comboRouteSeed?: number; __comboLinkSerial?: number; __comboQueuedBranch?: "FORWARD" | "BACK" | "SIDE" | "NEUTRAL"; + __openingStrikeRange?: TpsOpeningRange; + __queuedStepOpener?: { forward: boolean; back: boolean; lateral: boolean }; __finalImpactSeconds?: number; __finalImpactContact?: THREE.Vector3; }; @@ -128,6 +131,11 @@ const prototype = TpsFightGame.prototype as unknown as { prototype.beginContextAttack = function beginContextAttack(): boolean { const game = extended(this as unknown as TpsFightGame); if (!game.p1.canAct()) return false; + const intent = game.__queuedStepOpener ?? { + forward: game.p1.input.up, back: game.p1.input.down, + lateral: game.p1.input.left || game.p1.input.right, + }; + game.__queuedStepOpener = undefined; const distance = Math.hypot( game.p2.position.x - game.p1.position.x, game.p2.position.z - game.p1.position.z, @@ -195,10 +203,11 @@ prototype.beginContextAttack = function beginContextAttack(): boolean { const opener = chooseTpsOpeningStrike({ distance, - forward: game.p1.input.up, - back: game.p1.input.down, - lateral: game.p1.input.left || game.p1.input.right, + ...intent, + previousRange: game.__openingStrikeRange, }); + game.p1.visual.root.userData.tpsOpeningRange = game.__openingStrikeRange; + game.p1.visual.root.userData.tpsOpeningIntent = intent; if (stage === 0 && !flank && !perfect) game.__comboRoute = opener.route; const moveId = stage === 0 && !flank && !perfect ? opener.moveId @@ -244,6 +253,14 @@ prototype.updatePlayer = function updatePlayer(input: InputFrame): void { const beforeMoveId = game.p1.currentMove?.id ?? null; const rawAttackPressed = input.punch && !game.p1.input.punch; const activeMove = game.p1.currentMove; + game.__openingStrikeRange = tpsOpeningRange( + Math.hypot(game.p2.position.x - game.p1.position.x, game.p2.position.z - game.p1.position.z), + game.__openingStrikeRange, + ); + if (rawAttackPressed && game.playerEvadeTicks > 0 && game.playerStepForwardWeight <= .45) { + game.__queuedStepOpener = { forward: input.up, back: input.down, lateral: input.left || input.right }; + } + if (["HIT", "BLOCK_STUN", "KNOCKDOWN", "KO", "THROW"].includes(game.p1.state)) game.__queuedStepOpener = undefined; // Capture direction together with the ATTACK tap. Early taps are allowed to // buffer, but the continuation only fires inside the move's authored @@ -519,7 +536,7 @@ prototype.resolveAttack = function resolveAttack( } const impactPosition = attacker.position.clone().lerp(defender.position, 0.55); - impactPosition.y = move.hitLevel === "LOW" ? 0.55 : move.reactionTarget === "HEAD" ? 1.85 : 1.35; + impactPosition.y = tpsImpactHeightForMove(move); const event: HitEvent = { attacker: attacker.id, defender: defender.id, @@ -594,6 +611,8 @@ prototype.resetRound = function resetRound(): void { game.__comboRouteSeed = 0; game.__comboLinkSerial = 0; game.__comboQueuedBranch = undefined; + game.__openingStrikeRange = undefined; + game.__queuedStepOpener = undefined; game.p1.visual.root.userData.tpsComboRoute = null; game.p1.visual.root.userData.tpsComboLinkSerial = 0; game.p1.visual.root.userData.tpsComboLinkState = null; diff --git a/src/game/tps-guard-clash.ts b/src/game/tps-guard-clash.ts index af985a82..f8c2b6c0 100644 --- a/src/game/tps-guard-clash.ts +++ b/src/game/tps-guard-clash.ts @@ -1,5 +1,6 @@ import * as THREE from "three"; import type { FighterRuntime } from "./fighter"; +import { ownsContactReaction } from "./combat-contact-reaction"; import { PresentationAnimationController } from "./presentation-animation"; type AppliedBoneDelta = { @@ -104,6 +105,7 @@ function applyGuardClash(fighter: FighterRuntime, opponent: FighterRuntime, stat if (!root.userData.combatTps || root.userData.quaterniusModelState !== "ready") return; const defending = fighter.state === "BLOCK_STUN"; + if (defending && ownsContactReaction(root.userData)) return; const attackingIntoGuard = fighter.state === "ATTACK" && Boolean(fighter.currentMove) && opponent.state === "BLOCK_STUN"; diff --git a/src/game/tps-impact-followthrough.ts b/src/game/tps-impact-followthrough.ts index ccaab216..a3342cc4 100644 --- a/src/game/tps-impact-followthrough.ts +++ b/src/game/tps-impact-followthrough.ts @@ -1,5 +1,6 @@ import * as THREE from "three"; import type { FighterRuntime } from "./fighter"; +import { ownsContactReaction } from "./combat-contact-reaction"; import { PresentationAnimationController } from "./presentation-animation"; type ImpactFollowthroughState = { @@ -289,7 +290,7 @@ function applyImpactFollowthrough( : 0; state.lastTimeSeconds = timeSeconds; - if (!root.userData.combatTps || root.userData.quaterniusModelState !== "ready") { + if (!root.userData.combatTps || root.userData.quaterniusModelState !== "ready" || ownsContactReaction(root.userData)) { state.recoverySeconds = 0; state.recoveryDuration = 0; state.recoveryFactor = 0; diff --git a/src/game/tps-impact-presentation.ts b/src/game/tps-impact-presentation.ts index 905eca65..56388a5c 100644 --- a/src/game/tps-impact-presentation.ts +++ b/src/game/tps-impact-presentation.ts @@ -1,6 +1,7 @@ import type { FighterRuntime } from "./fighter"; import type { ReactionTarget } from "./types"; import type { TpsReactionType } from "./tps-impact-resolution"; +import { contactReactionClip, CONTACT_REACTION_VERSION } from "./combat-contact-reaction"; export function applyTpsImpactPresentation(input: { attacker: FighterRuntime; @@ -47,4 +48,12 @@ export function applyTpsImpactPresentation(input: { defender.visual.root.userData.tpsReactionDirectionX = direction.x; defender.visual.root.userData.tpsReactionDirectionZ = direction.z; defender.visual.root.userData.tpsReactionTick = simulationTicks; + const limb = attacker.currentMove?.visualContact; + const side = limb?.startsWith("LEFT") ? "RIGHT" : "LEFT"; + const kind = reactionType === "COUNTER" || reactionType === "INTERCEPT" || reactionType === "REVERSAL" + ? "COUNTER" : reactionRegion === "HEAD" && reactionStrength < 1.2 ? "LIGHT" + : reactionType === "HEAVY" || reactionType === "FINISHER" ? "HEAVY" : "MID"; + defender.setHitReactionVisual(kind, side, Math.hypot(defender.position.x, defender.position.z) > 5.8); + defender.visual.root.userData.tpsContactReactionVersion = CONTACT_REACTION_VERSION; + defender.visual.root.userData.tpsContactReactionClip = contactReactionClip(reactionRegion, side, reactionType === "BLOCK"); } diff --git a/src/game/tps-impact-resolution.ts b/src/game/tps-impact-resolution.ts index 6bb7cdef..da1196cc 100644 --- a/src/game/tps-impact-resolution.ts +++ b/src/game/tps-impact-resolution.ts @@ -28,10 +28,10 @@ const TPS_IMPACT_HEIGHTS: Readonly> = Object.freeze({ backfist: 2.48, bodyBlow: 2.12, power: 2.35, - kick: 1.80, + kick: 2.28, lowKick: 0.92, - risingKick: 2.06, - dashKick: 1.98, + risingKick: 2.70, + dashKick: 2.70, throw: 1.75, counter: 2.66, }); diff --git a/src/game/tps-player-policy.ts b/src/game/tps-player-policy.ts index 65ec1cec..6a3e37f5 100644 --- a/src/game/tps-player-policy.ts +++ b/src/game/tps-player-policy.ts @@ -165,9 +165,10 @@ export function tpsReactiveStepThreat(input: { ); return { ticks: reactiveSideStep - // The physical sidestep must occur near contact. A STEP triggered long - // before startup cannot bank an automatic perfect evade for the future. - ? Math.min(input.stepTicks + 6, Math.max(input.stepTicks, incomingFrames + 1)) + // This is observation, never invulnerability. A REACT cue can precede + // startup: retain its identity until the active window actually arrives. + // resolveAttack still requires the body to be outside the committed lane. + ? Math.max(input.stepTicks, incomingFrames + 2) : 0, moveId: reactiveSideStep ? incomingMove?.id ?? null : null, }; diff --git a/src/game/unimate-motion-replacement.ts b/src/game/unimate-motion-replacement.ts index 5ae41e9f..7cf43fc7 100644 --- a/src/game/unimate-motion-replacement.ts +++ b/src/game/unimate-motion-replacement.ts @@ -185,6 +185,11 @@ export function buildUniMateReplacementProfile(input: UniMateReplacementInput): } if (input.state === "HIT") { + if (input.currentClip.startsWith("CM_Hit_")) { + // The baked pelvis and both complete leg chains own their planted feet. + // Carrying only some of the old joint rotations breaks that IK solution. + for (const n of ["pelvis", "thigh_l", "calf_l", "foot_l", "thigh_r", "calf_r", "foot_r"]) seed(n, 1, LIGHT_GRAPH_FALLOFF); + } // Contact-reactive joints must follow the incoming blow immediately. // An outgoing kick/punch should never carry its former head and torso // rotation through the start of a stun animation. @@ -198,6 +203,9 @@ export function buildUniMateReplacementProfile(input: UniMateReplacementInput): } if (input.state === "BLOCK_STUN") { + if (input.currentClip.startsWith("CM_Block_")) { + for (const n of ["pelvis", "thigh_l", "calf_l", "foot_l", "thigh_r", "calf_r", "foot_r"]) seed(n, 1, LIGHT_GRAPH_FALLOFF); + } // Defender keeps the two-fist shield while a blocked strike transfers // force into the shoulders. Stop an old attacking arm from passing through // the incoming guard pose. diff --git a/src/game/visual-quaternius-runtime.ts b/src/game/visual-quaternius-runtime.ts index 99148e8a..950aa417 100644 --- a/src/game/visual-quaternius-runtime.ts +++ b/src/game/visual-quaternius-runtime.ts @@ -537,7 +537,10 @@ function desiredClip(fighter: FighterRuntime, runtime: QuaterniusRuntime): { nam }; case "CROUCH": return { name: "CM_Crouch", loop: true, speed: 1 }; case "GUARD": return { name: "CM_Guard", loop: true, speed: 1 }; - case "BLOCK_STUN": return { name: "CM_Block", loop: false, speed: 1 }; + case "BLOCK_STUN": { + const contactClip = fighter.visual.root.userData.tpsContactReactionClip; + return { name: typeof contactClip === "string" && runtime.clips.has(contactClip) ? contactClip : "CM_Block", loop: false, speed: 1 }; + } case "SIDESTEP": { const direction = String(fighter.visual.root.userData.combatStepDirection ?? "R"); return { name: `CM_Step_${direction}`, loop: false, speed: 1 }; @@ -547,6 +550,8 @@ function desiredClip(fighter: FighterRuntime, runtime: QuaterniusRuntime): { nam const side = fighter.reactionSide === "LEFT" ? "L" : "R"; if (!fighter.grounded && fighter.velocity.y > 2.5) return { name: "CM_Launch", loop: false, speed: 1 }; if (fighter.reactionAtEdge) return { name: "BF_EdgeStagger", loop: false, speed: 1 }; + const contactClip = fighter.visual.root.userData.tpsContactReactionClip; + if (typeof contactClip === "string" && runtime.clips.has(contactClip)) return { name: contactClip, loop: false, speed: 1 }; if (fighter.reactionKind === "COUNTER") return { name: `BF_CounterHit_${side}`, loop: false, speed: 1 }; if (fighter.reactionKind === "LIGHT") return { name: `BF_HitLight_${side}`, loop: false, speed: 1 }; if (fighter.reactionKind === "MID") return { name: `BF_HitMid_${side}`, loop: false, speed: 1 }; @@ -936,7 +941,7 @@ export function updateQuaterniusModelSkin(fighter: FighterRuntime, timeSeconds: const stateChanged = runtime.lastState !== fighter.state; const restartedState = fighter.stateMachine.stateTicks < runtime.lastStateTicks; const restartingAttack = fighter.state === "ATTACK" && (stateChanged || fighter.moveTick < runtime.lastMoveTick); - const restartingReaction = fighter.state === "HIT" && fighter.reactionSerial !== runtime.lastReactionSerial; + const restartingReaction = (fighter.state === "HIT" || fighter.state === "BLOCK_STUN") && fighter.reactionSerial !== runtime.lastReactionSerial; if (stateChanged || restartedState || restartingReaction) { runtime.stateDuration = fighter.stateMachine.stateTicks + (fighter.state === "HIT" ? fighter.hitStun : fighter.blockStun); runtime.plantedFeet.l = null; runtime.plantedFeet.r = null; diff --git a/tests/contact-reaction.test.ts b/tests/contact-reaction.test.ts new file mode 100644 index 00000000..a00b4362 --- /dev/null +++ b/tests/contact-reaction.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import * as THREE from "three"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { createCombatMotionLibrary } from "../src/game/combat-motion-authoring"; +import { retargetMotionClips } from "../src/game/visual-quaternius-runtime"; +import { FIGHTER_DEFINITIONS } from "../src/game/definitions"; + +async function glb(name: string) { + const b = await readFile(new URL(`../public/models/quaternius/${name}`, import.meta.url)); + return new GLTFLoader().parseAsync(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength), ""); +} +for (const [body, definition] of [["male", FIGHTER_DEFINITIONS.red], ["female", FIGHTER_DEFINITIONS.blue]] as const) { + test(`${body}: contact reactions retain limb lengths, grounded support and distinct hit regions`, async () => { + const [target, source] = await Promise.all([glb(`ubc-superhero-${body}-flat.glb`), glb("ual-fight-core.glb")]); + const clips = createCombatMotionLibrary(target.scene, retargetMotionClips(source.scene, target.scene, source.animations), definition); + const mixer = new THREE.AnimationMixer(target.scene); + const names = ["Head", "pelvis", "hand_l", "hand_r", "thigh_l", "thigh_r", "calf_l", "calf_r", "foot_l", "foot_r"]; + const pose = (name: string, phase: number) => { + mixer.stopAllAction(); + const a = mixer.clipAction(clips.get(name)!).reset().setLoop(THREE.LoopOnce, 1); + a.clampWhenFinished = true; a.play(); a.time = a.getClip().duration * phase; + mixer.update(0); target.scene.updateMatrixWorld(true); + return Object.fromEntries(names.map(n => [n, target.scene.getObjectByName(n)!.getWorldPosition(new THREE.Vector3())])); + }; + const ready = pose("CM_Ready", 0); + for (const region of ["HEAD", "BODY", "LEGS", "BLOCK"]) for (const side of ["L", "R"]) { + const name = region === "BLOCK" ? `CM_Block_${side}` : `CM_Hit_${region}_${side}`; + assert.ok(clips.has(name)); + for (let i = 0; i <= 36; i++) { + const p = pose(name, i / 36); + const grounded = ["l", "r"].some(s => Math.abs(p[`foot_${s}`].y - ready[`foot_${s}`].y) < .003); + assert.ok(grounded, `${name}/${i}: reaction lifted both feet`); + for (const s of ["l", "r"]) for (const [a, b] of [[`thigh_${s}`, `calf_${s}`], [`calf_${s}`, `foot_${s}`]]) { + assert.ok(Math.abs(p[a].distanceTo(p[b]) - ready[a].distanceTo(ready[b])) < 1e-5, `${name}: changed a leg length`); + } + for (const v of Object.values(p)) assert.ok(v.toArray().every(Number.isFinite)); + } + if (region !== "BLOCK") for (const phase of [0, 1]) { + const p = pose(name, phase); + for (const n of names) assert.ok(p[n].distanceTo(ready[n]) < .005, `${name}/${phase}: failed to return to guard at ${n}`); + } + } + const head = pose("CM_Hit_HEAD_L", .17), bodyHit = pose("CM_Hit_BODY_L", .17); + assert.ok(head.Head.distanceTo(bodyHit.Head) > .04, "head and body hits must have different silhouettes"); + const low = pose("CM_Hit_LEGS_L", .29); + assert.ok(low.pelvis.y < ready.pelvis.y - .07, "low kick must visibly buckle the stance"); + mixer.stopAllAction(); mixer.uncacheRoot(target.scene); + }); +} diff --git a/tests/tps-combat-feel.test.ts b/tests/tps-combat-feel.test.ts new file mode 100644 index 00000000..2c5ab39b --- /dev/null +++ b/tests/tps-combat-feel.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as THREE from "three"; +import { TpsFightGame } from "../src/game/tps-game-base"; +import { TpsFightGame as LiveTpsFightGame } from "../src/game/tps-game"; +import { FighterRuntime } from "../src/game/fighter"; +import { FIGHTER_DEFINITIONS } from "../src/game/definitions"; +import { fighterDnaForName } from "../src/game/fighter-dna"; +import { EMPTY_INPUT, type InputFrame } from "../src/game/types"; +import { adaptTpsCpuDecision, tpsEnemyRecoveryHabit } from "../src/game/tps-enemy-policy"; +import { chooseTpsOpeningStrike, tpsOpeningRange } from "../src/game/tps-fight-fundamentals"; +import { applyTpsImpactPresentation } from "../src/game/tps-impact-presentation"; + +// Real simulation methods and FighterRuntime, with only rendering/audio omitted. +// This reproduces the REACT cue -> STEP -> active contact regression without WebGL. +type Sim = { + p1: FighterRuntime; p2: FighterRuntime; + trainingProgress: { perfectEvades: number; punishes: number }; + playerReversalTicks: number; + enemyDirectorTelegraphTicks: number; + enemyDirectorPendingMove: string | null; + enemyTelegraphAim: THREE.Vector3 | null; + updatePlayer(input: InputFrame): void; + updateEnemy(): void; + trackCommittedAttack(a: FighterRuntime, d: FighterRuntime): void; + applyAttackStepIn(a: FighterRuntime, d: FighterRuntime): void; + resolveAttack(a: FighterRuntime, d: FighterRuntime, guarding: boolean): void; +}; +function simulation(telegraphTicks = 12, distance = 1.44, live = false): Sim { + const p1 = new FighterRuntime("p1", FIGHTER_DEFINITIONS.red); + const p2 = new FighterRuntime("p2", FIGHTER_DEFINITIONS.blue, true); + p1.resetForRound(0, distance / 2, 1); p2.resetForRound(0, -distance / 2, -1); + const wait = { intent: "WAIT", holdTicks: 30, telegraphTicks: 0, reason: "test", comebackMercy: 0, pressure: 0 }; + const game = Object.assign(Object.create(live ? LiveTpsFightGame.prototype : TpsFightGame.prototype), { + p1, p2, difficulty: "NORMAL", p1Dna: fighterDnaForName(p1.definition.name), p2Dna: fighterDnaForName(p2.definition.name), + attackCommitments: new WeakMap(), simulationTicks: 0, + playerEvadeTicks: 0, playerEvadeCooldown: 0, playerEvadeSign: 0, + playerStepDirection: new THREE.Vector3(), playerStepForwardWeight: 0, playerStepSideWeight: 0, + playerComboStage: 0, playerComboGraceTicks: 0, playerAttackQueued: false, playerStepAttackQueued: false, + playerFlankWindowTicks: 0, playerFlankAttackTicks: 0, playerPerfectEvadeTicks: 0, + playerStepThreatTicks: 0, playerStepThreatMoveId: null, playerStepThreatPosition: null, + playerInterceptTicks: 0, playerReversalTicks: 0, playerAttackSamples: 0, playerStepSamples: 0, + playerLeftStepSamples: 0, playerRightStepSamples: 0, playerRetreatSamples: 0, + enemyFunDirector: { observe() {}, decide() { return wait; } }, enemyCooldown: 0, + enemyOpeningGraceTicks: 0, enemyAdaptReviewTicks: 999, enemyTacticTicks: 999, + enemyPersona: "SKIRMISHER", enemyAdaptation: "NEUTRAL", enemyOrbitSign: 1, + enemyDirectorDecision: { ...wait, intent: "JAB", holdTicks: 1 }, enemyDirectorHoldTicks: 0, + enemyDirectorPendingMove: "jab", enemyDirectorTelegraphTicks: telegraphTicks, enemyDirectorTelegraphTotalTicks: telegraphTicks, + enemyTelegraphAim: null, enemyTelegraphAimMove: null, enemyAttackCount: 0, + enemyPostAttackTicks: 0, enemyPostAttackIntent: "WAIT", enemyLastMoveId: null, + trainingProgress: { hits: 0, sideSteps: 0, perfectEvades: 0, punishes: 0, intercepts: 0 }, + scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(), + __hypeDirector: { step() {}, comboShift() {}, hit() {}, dash() {} }, + audio: { rush() {}, comboShift() {}, hypeImpact() {} }, + emitImpactFeedback() {}, + }); + return game as Sim; +} +function tick(game: Sim, input: Partial = {}) { + game.updatePlayer({ ...EMPTY_INPUT, ...input }); + game.updateEnemy(); + game.trackCommittedAttack(game.p1, game.p2); game.trackCommittedAttack(game.p2, game.p1); + game.applyAttackStepIn(game.p1, game.p2); game.applyAttackStepIn(game.p2, game.p1); + game.resolveAttack(game.p1, game.p2, false); + game.resolveAttack(game.p2, game.p1, false); +} + +test("REACT cue sidestep physically escapes the first jab and earns exactly one reversal", () => { + const game = simulation(); + tick(game, { right: true, guard: true }); + for (let i = 0; i < 24 && game.trainingProgress.perfectEvades === 0; i++) tick(game); + assert.equal(game.p1.health, 100); + assert.equal(game.trainingProgress.perfectEvades, 1); + assert.ok(game.playerReversalTicks > 0); + for (let i = 0; i < 5; i++) tick(game); + assert.equal(game.trainingProgress.perfectEvades, 1, "one attack cannot pay the dodge reward twice"); +}); + +test("live TPS controller converts a physically earned sidestep into a reachable counter", () => { + const game = simulation(12, 1.44, true); + tick(game, { right: true, guard: true }); + for (let i = 0; i < 30 && game.trainingProgress.perfectEvades === 0; i++) tick(game); + assert.equal(game.trainingProgress.perfectEvades, 1); + tick(game, { punch: true }); + for (let i = 0; i < 35 && game.trainingProgress.punishes === 0; i++) tick(game); + assert.equal(game.trainingProgress.punishes, 1); + assert.ok(game.p2.health < 100); +}); + +test("a STEP begun just before contact is still hit while inside the attack lane", () => { + const game = simulation(0, 1.34); + game.enemyDirectorPendingMove = null; + game.p2.beginMove("jab"); game.p2.moveTick = game.p2.currentMove!.startup - 1; + game.trackCommittedAttack(game.p2, game.p1); + tick(game, { right: true, guard: true }); + assert.ok(game.p1.health < 100); + assert.equal(game.trainingProgress.perfectEvades, 0); +}); + +test("standing still takes the jab; an early/out-of-range step cannot bank a perfect evade", () => { + const still = simulation(); + for (let i = 0; i < 23; i++) tick(still); + assert.ok(still.p1.health < 100, "the REACT cue itself is not an invulnerability window"); + const early = simulation(28); + tick(early, { right: true, guard: true }); + for (let i = 0; i < 45; i++) tick(early); + assert.equal(early.trainingProgress.perfectEvades, 0); + const far = simulation(12, 3.4); + tick(far, { right: true, guard: true }); + for (let i = 0; i < 24; i++) tick(far); + assert.equal(far.trainingProgress.perfectEvades, 0, "dodging an attack that could not reach is not a perfect evade"); +}); + +test("touch opener keeps its range across boundary jitter and changes after a deliberate move", () => { + let band = tpsOpeningRange(1.45); + for (const d of [1.60, 1.68, 1.63, 1.73]) { + band = tpsOpeningRange(d, band); + assert.equal(chooseTpsOpeningStrike({ distance: d, previousRange: band, forward: false, back: false, lateral: false }).moveId, "jab"); + } + band = tpsOpeningRange(1.85, band); + assert.equal(band, "FAR"); + for (const d of [1.72, 1.60, 1.65]) assert.equal(tpsOpeningRange(d, band), "FAR"); + assert.equal(tpsOpeningRange(1.45, band), "CLOSE"); +}); + +test("CPU phrases and post-attack habits are readable while tactical adaptation keeps priority", () => { + const decision = { intent: "JAB" as const, holdTicks: 1, telegraphTicks: 12, reason: "neutral", comebackMercy: 0, pressure: 0 }; + const phrase = [0, 1, 2, 3].map(attackCount => adaptTpsCpuDecision(decision, { + persona: "BRAWLER", adaptation: "NEUTRAL", liveDistance: 1.4, simulationTicks: 0, attackCount, + }).intent); + assert.deepEqual(phrase, ["JAB", "STRAIGHT", "POWER", "JAB"]); + assert.equal(adaptTpsCpuDecision(decision, { persona: "BRAWLER", adaptation: "ANTI_STEP", liveDistance: 1.4, simulationTicks: 0, attackCount: 2 }).intent, "COUNTER"); + assert.deepEqual(tpsEnemyRecoveryHabit("SKIRMISHER", "lowKick"), { intent: "RETREAT", ticks: 12 }); + assert.deepEqual(tpsEnemyRecoveryHabit("BRAWLER", "power"), { intent: "WAIT", ticks: 10 }); +}); + +test("resolved contacts select body-region reactions and restart repeated guard contacts", () => { + const game = simulation(); + for (const [moveId, region] of [["jab", "HEAD"], ["kick", "BODY"], ["lowKick", "LEGS"]] as const) { + game.p1.state = "IDLE"; game.p1.beginMove(moveId); + applyTpsImpactPresentation({ attacker: game.p1, defender: game.p2, simulationTicks: 1, + moveId, contact: { x: 0, y: 1.8, z: 0 }, direction: { x: 0, z: -1 }, + reactionType: "NORMAL", reactionRegion: region, reactionVariant: 0, reactionStrength: 1, impactPairStrength: 1, + }); + assert.match(game.p2.visual.root.userData.tpsContactReactionClip, new RegExp(`^CM_Hit_${region}_`)); + } + const serial = game.p2.reactionSerial; + for (let i = 0; i < 2; i++) applyTpsImpactPresentation({ attacker: game.p1, defender: game.p2, simulationTicks: i + 2, + moveId: "lowKick", contact: { x: 0, y: .9, z: 0 }, direction: { x: 0, z: -1 }, + reactionType: "BLOCK", reactionRegion: "LEGS", reactionVariant: 0, reactionStrength: .72, impactPairStrength: .72, + }); + assert.match(game.p2.visual.root.userData.tpsContactReactionClip, /^CM_Block_/); + assert.equal(game.p2.reactionSerial, serial + 2); +}); diff --git a/tests/tps-fight-fundamentals.test.ts b/tests/tps-fight-fundamentals.test.ts index 746f1ace..bf995c3f 100644 --- a/tests/tps-fight-fundamentals.test.ts +++ b/tests/tps-fight-fundamentals.test.ts @@ -31,7 +31,8 @@ test("fundamental striking game: hit, whiff, and sidestep have distinct spatial assert.ok(strike("kick", 1.6).reach > strike("jab", 1.6).reach, "kick trades speed for spacing"); assert.equal(strike("throw", moves.throw.reach + .19).connected, false, "throw must be point blank"); assert.equal(strike("throw", moves.throw.reach + .10).connected, true); - assert.ok(strike("straight", 1.4, .82).connected, "a wider straight tolerates small lateral errors"); + assert.equal(strike("straight", 1.4, .82).connected, false, "a straight loses to a real lateral escape"); + assert.ok(strike("backfist", 1.4, .82).connected, "a sweeping backfist covers that same lateral lane"); }); test("attack windup is a small commitment, not an automatic pursuit system", () => { @@ -91,7 +92,7 @@ test("CPU normal/light attacks begin promptly, heavy attacks remain readable", ( minimumTpsEnemyTelegraphTicks("HARD", "jab")); }); -test("perfect evade timing is limited to the sidestep itself, not a stored automatic dodge", () => { +test("evade tracking retains the incoming move identity without granting invulnerability", () => { const threatening = tpsReactiveStepThreat({ sideWeight: 1, activeIncomingMove: moves.straight, pendingMove: null, pendingMoveId: null, pendingReaction: false, pendingTelegraphTicks: 0, @@ -99,6 +100,12 @@ test("perfect evade timing is limited to the sidestep itself, not a stored autom }); assert.ok(threatening.ticks >= 9 && threatening.ticks <= 15); assert.equal(threatening.moveId, "straight"); + const telegraphed = tpsReactiveStepThreat({ + sideWeight: 1, activeIncomingMove: null, pendingMove: moves.straight, + pendingMoveId: "straight", pendingReaction: true, pendingTelegraphTicks: 12, + enemyMoveTick: 0, incomingDistance: 1.5, stepTicks: 9, + }); + assert.ok(telegraphed.ticks > 12 + moves.straight.startup, "the cue's observation cannot expire before contact"); assert.equal(tpsReactiveStepThreat({ sideWeight: 0, activeIncomingMove: moves.straight, pendingMove: null, pendingMoveId: null, pendingReaction: false, pendingTelegraphTicks: 0,