From 0c3fb66ef63b01d089ef3df1e46f4c04b55aba94 Mon Sep 17 00:00:00 2001 From: yz4git <65384484+yz4git@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:33:45 +0900 Subject: [PATCH 1/3] Make evasion, paired hit reactions, touch intent and rival patterns readable --- .github/workflows/tps-kick-sequence-audit.yml | 3 + docs/COMBAT_FEEL.md | 29 +++ public/sw.js | 2 +- scripts/capture-tps-combat-feel-audit.mjs | 209 ++++++++++++++++++ src/game/combat-contact-reaction.ts | 42 ++++ src/game/combat-motion-authoring.ts | 18 +- src/game/combat-motion-clock.ts | 2 +- src/game/combat-motion-handoff.ts | 3 +- src/game/tps-enemy-policy.ts | 21 ++ src/game/tps-fight-fundamentals.ts | 29 ++- src/game/tps-game-base.ts | 99 ++++++++- src/game/tps-game.ts | 29 ++- src/game/tps-impact-presentation.ts | 9 + src/game/tps-impact-resolution.ts | 6 +- src/game/tps-player-policy.ts | 7 +- src/game/visual-quaternius-runtime.ts | 9 +- tests/contact-reaction.test.ts | 51 +++++ tests/tps-combat-feel.test.ts | 128 +++++++++++ tests/tps-fight-fundamentals.test.ts | 11 +- 19 files changed, 675 insertions(+), 32 deletions(-) create mode 100644 docs/COMBAT_FEEL.md create mode 100644 scripts/capture-tps-combat-feel-audit.mjs create mode 100644 src/game/combat-contact-reaction.ts create mode 100644 tests/contact-reaction.test.ts create mode 100644 tests/tps-combat-feel.test.ts 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..29109328 --- /dev/null +++ b/docs/COMBAT_FEEL.md @@ -0,0 +1,29 @@ +# 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. + +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/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/src/game/combat-contact-reaction.ts b/src/game/combat-contact-reaction.ts new file mode 100644 index 00000000..0c6f015e --- /dev/null +++ b/src/game/combat-contact-reaction.ts @@ -0,0 +1,42 @@ +import type { ReactionTarget } from "./types"; + +export const CONTACT_REACTION_VERSION = "CONTACT_REACTION_V1"; +export type ContactReactionRegion = "HEAD" | "BODY" | "LEGS" | "BLOCK"; + +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/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..0868fd85 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; @@ -832,6 +871,11 @@ export class TpsFightGame { private advanceEnemyTelegraph(towardPlayer: THREE.Vector3): boolean { if (!this.enemyDirectorPendingMove) return false; + if (this.enemyDirectorTelegraphTicks <= tpsEnemyReactionWindowTicks(this.difficulty) + && (!this.enemyTelegraphAim || this.enemyTelegraphAimMove !== this.enemyDirectorPendingMove)) { + this.enemyTelegraphAim = towardPlayer.clone(); + this.enemyTelegraphAimMove = this.enemyDirectorPendingMove; + } if (this.enemyDirectorTelegraphTicks > 0) { this.tickEnemyTelegraph(); this.p2.updatePhysics(FIXED_STEP); @@ -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-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/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..3effff85 --- /dev/null +++ b/tests/tps-combat-feel.test.ts @@ -0,0 +1,128 @@ +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 { 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): 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(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 }, + 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.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("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, From 2121ea2be7aed5e48b1c2afd1b9a83bc35849316 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:37:09 +0000 Subject: [PATCH 2/3] Generate shared Blender v2 strike actions --- .../blender-cross-core.generator.sha256 | 2 +- .../models/quaternius/blender-cross-core.glb | Bin 541820 -> 541820 bytes .../blender-cross-core.metrics.json | 62 ++++++ .../blender-fight-core.generator.sha256 | 2 +- .../models/quaternius/blender-fight-core.glb | Bin 543916 -> 543916 bytes .../blender-fight-core.metrics.json | 26 +++ .../blender-strikes-core.generator.sha256 | 2 +- .../quaternius/blender-strikes-core.glb | Bin 659732 -> 659736 bytes .../blender-strikes-core.metrics.json | 187 ++++++++++++++++++ 9 files changed, 278 insertions(+), 3 deletions(-) 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 b0f369d1b34c37ee1c5c94e2f1e4bf275a718296..9c221a9c419f5503063b09ff51435cab4738b314 100644 GIT binary patch delta 9101 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 qUo}oz^6mWZ0`_#4>I#1w$|&K~`kIK?=nxK44( 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 D_D6sU delta 9918 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 delta 26380 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 HzsLUriXE&G 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, From 51d733dd094a02f7894882987bb24b542204e0b8 Mon Sep 17 00:00:00 2001 From: yz4git <65384484+yz4git@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:44:02 +0900 Subject: [PATCH 3/3] Preserve authored reaction footwork and validate physical sidestep counters --- docs/COMBAT_FEEL.md | 3 +++ scripts/capture-tps-combat-v2-audit.mjs | 13 ++++++++++- src/game/combat-contact-reaction.ts | 4 ++++ src/game/fighter.ts | 9 ++++++++ src/game/motion-reaction.ts | 9 ++++++-- src/game/presentation-animation.ts | 3 ++- src/game/tps-game-base.ts | 10 ++++----- src/game/tps-guard-clash.ts | 2 ++ src/game/tps-impact-followthrough.ts | 3 ++- src/game/unimate-motion-replacement.ts | 8 +++++++ tests/tps-combat-feel.test.ts | 30 +++++++++++++++++++++++-- 11 files changed, 82 insertions(+), 12 deletions(-) diff --git a/docs/COMBAT_FEEL.md b/docs/COMBAT_FEEL.md index 29109328..5407fde4 100644 --- a/docs/COMBAT_FEEL.md +++ b/docs/COMBAT_FEEL.md @@ -21,6 +21,9 @@ 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 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 index 0c6f015e..ac5253bf 100644 --- a/src/game/combat-contact-reaction.ts +++ b/src/game/combat-contact-reaction.ts @@ -3,6 +3,10 @@ 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"}`; 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-game-base.ts b/src/game/tps-game-base.ts index 0868fd85..740da952 100644 --- a/src/game/tps-game-base.ts +++ b/src/game/tps-game-base.ts @@ -871,11 +871,6 @@ export class TpsFightGame { private advanceEnemyTelegraph(towardPlayer: THREE.Vector3): boolean { if (!this.enemyDirectorPendingMove) return false; - if (this.enemyDirectorTelegraphTicks <= tpsEnemyReactionWindowTicks(this.difficulty) - && (!this.enemyTelegraphAim || this.enemyTelegraphAimMove !== this.enemyDirectorPendingMove)) { - this.enemyTelegraphAim = towardPlayer.clone(); - this.enemyTelegraphAimMove = this.enemyDirectorPendingMove; - } if (this.enemyDirectorTelegraphTicks > 0) { this.tickEnemyTelegraph(); this.p2.updatePhysics(FIXED_STEP); @@ -896,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 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/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/tests/tps-combat-feel.test.ts b/tests/tps-combat-feel.test.ts index 3effff85..2c5ab39b 100644 --- a/tests/tps-combat-feel.test.ts +++ b/tests/tps-combat-feel.test.ts @@ -2,6 +2,7 @@ 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"; @@ -25,12 +26,12 @@ type Sim = { applyAttackStepIn(a: FighterRuntime, d: FighterRuntime): void; resolveAttack(a: FighterRuntime, d: FighterRuntime, guarding: boolean): void; }; -function simulation(telegraphTicks = 12, distance = 1.44): Sim { +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(TpsFightGame.prototype), { + 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, @@ -48,6 +49,9 @@ function simulation(telegraphTicks = 12, distance = 1.44): Sim { 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; @@ -57,6 +61,7 @@ function tick(game: Sim, input: Partial = {}) { 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); } @@ -71,6 +76,27 @@ test("REACT cue sidestep physically escapes the first jab and earns exactly one 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);