1
0
Fork 0

feat(qualification): handle civilian gameplay feedback

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-04 12:04:44 -04:00
parent 6c7a9fb5e2
commit b905fe2b5e
2 changed files with 95 additions and 23 deletions

View file

@ -8,13 +8,15 @@ import { Crosshair, RotateCcw, Target, Timer, Trophy } from "lucide-react";
import { import {
AIM_TRAINER_DIFFICULTIES, AIM_TRAINER_DIFFICULTIES,
CIVILIAN_PENALTY,
MAX_CONCURRENT_CIVILIANS,
calculateAimTrainerAccuracy, calculateAimTrainerAccuracy,
calculateAimTrainerScore, calculateAimTrainerScore,
COURSE_DURATION_MS, COURSE_DURATION_MS,
HEADSHOT_POINTS, HEADSHOT_POINTS,
createAimTrainerTarget, createAimTrainerTarget,
resolveAimTrainerTargetHit, resolveAimTrainerTargetHit,
stepAimTrainerTarget, stepAimTrainerTargets,
TORSO_POINTS, TORSO_POINTS,
type AimTrainerDifficulty, type AimTrainerDifficulty,
} from "@/lib/aim-trainer"; } from "@/lib/aim-trainer";
@ -44,6 +46,7 @@ import {
type AimTrainerLiveStats, type AimTrainerLiveStats,
} from "./AimTrainerCanvas"; } from "./AimTrainerCanvas";
import { import {
playCivilianHit,
playCountdownTick, playCountdownTick,
playFire, playFire,
playHeadshot, playHeadshot,
@ -94,6 +97,7 @@ type LastRun = {
readonly accuracy: number; readonly accuracy: number;
readonly headshots: number; readonly headshots: number;
readonly torsoHits: number; readonly torsoHits: number;
readonly civilianHits: number;
readonly shotsFired: number; readonly shotsFired: number;
readonly despawnedUnhitTargets: number; readonly despawnedUnhitTargets: number;
readonly xpEarned: number; readonly xpEarned: number;
@ -205,8 +209,12 @@ export function AimTrainerGame({
round.torsoHits, round.torsoHits,
round.shotsFired, round.shotsFired,
round.despawnedUnhitTargets, round.despawnedUnhitTargets,
round.civilianHits,
); );
const hitScore = round.headshots * HEADSHOT_POINTS + round.torsoHits * TORSO_POINTS; const hitScore =
round.headshots * HEADSHOT_POINTS +
round.torsoHits * TORSO_POINTS -
round.civilianHits * CIVILIAN_PENALTY;
const previousTierBest = tierBestScore(statsRef.current, difficulty); const previousTierBest = tierBestScore(statsRef.current, difficulty);
const preview: LastRun = { const preview: LastRun = {
hitScore, hitScore,
@ -215,6 +223,7 @@ export function AimTrainerGame({
accuracy, accuracy,
headshots: round.headshots, headshots: round.headshots,
torsoHits: round.torsoHits, torsoHits: round.torsoHits,
civilianHits: round.civilianHits,
shotsFired: round.shotsFired, shotsFired: round.shotsFired,
despawnedUnhitTargets: round.despawnedUnhitTargets, despawnedUnhitTargets: round.despawnedUnhitTargets,
xpEarned: 0, xpEarned: 0,
@ -352,6 +361,23 @@ export function AimTrainerGame({
const [activeTarget] = game.targets.splice(hit.index, 1); const [activeTarget] = game.targets.splice(hit.index, 1);
if (!activeTarget) return; if (!activeTarget) return;
const target = activeTarget.target; const target = activeTarget.target;
if (target.kind === "civilian") {
game.civilianHits += 1;
game.feedback.push({
x: target.x,
y: target.y + target.torsoH * 0.45,
text: `-${CIVILIAN_PENALTY}`,
color: "#efb08a",
createdAt: now,
});
game.flashColor = "#7fa8d9";
game.flashUntil = now + FLASH_DURATION_MS;
playCivilianHit();
setLive(getAimTrainerLiveStats(game));
return;
}
game.pendingSpawnAts.push(now + config.replacementDelayMs); game.pendingSpawnAts.push(now + config.replacementDelayMs);
switch (hit.zone) { switch (hit.zone) {
case "head": case "head":
@ -410,13 +436,14 @@ export function AimTrainerGame({
setLive(getAimTrainerLiveStats(game)); setLive(getAimTrainerLiveStats(game));
setPhaseSafe("over"); setPhaseSafe("over");
playRoundComplete(); playRoundComplete();
void handleRoundCompleteRef.current({ void handleRoundCompleteRef.current({
difficulty, difficulty,
headshots: game.headshots, headshots: game.headshots,
torsoHits: game.torsoHits, torsoHits: game.torsoHits,
shotsFired: game.shotsFired, civilianHits: game.civilianHits,
despawnedUnhitTargets: game.despawnedUnhitTargets, shotsFired: game.shotsFired,
}); despawnedUnhitTargets: game.despawnedUnhitTargets,
});
}; };
const step = (now: number) => { const step = (now: number) => {
@ -443,32 +470,60 @@ export function AimTrainerGame({
} }
const survivingTargets: AimTrainerActiveTarget[] = []; const survivingTargets: AimTrainerActiveTarget[] = [];
let expiredTargets = 0; let expiredStandardTargets = 0;
for (const activeTarget of game.targets) { for (const activeTarget of game.targets) {
if (now - activeTarget.spawnedAt >= config.targetLifetimeMs) { if (now - activeTarget.spawnedAt >= config.targetLifetimeMs) {
expiredTargets += 1; if (activeTarget.target.kind === "standard") {
game.pendingSpawnAts.push(now + config.replacementDelayMs); expiredStandardTargets += 1;
game.pendingSpawnAts.push(now + config.replacementDelayMs);
}
continue; continue;
} }
survivingTargets.push({ survivingTargets.push({
target: stepAimTrainerTarget(activeTarget.target, dtSec, CANVAS_W, CANVAS_H), target: activeTarget.target,
spawnedAt: activeTarget.spawnedAt, spawnedAt: activeTarget.spawnedAt,
}); });
} }
game.targets = survivingTargets; const steppedTargets = stepAimTrainerTargets(
if (expiredTargets > 0) { survivingTargets.map((activeTarget) => activeTarget.target),
game.despawnedUnhitTargets += expiredTargets; dtSec,
CANVAS_W,
CANVAS_H,
);
game.targets = survivingTargets.map((activeTarget, index) => ({
target: steppedTargets[index] ?? activeTarget.target,
spawnedAt: activeTarget.spawnedAt,
}));
if (expiredStandardTargets > 0) {
game.despawnedUnhitTargets += expiredStandardTargets;
setLive(getAimTrainerLiveStats(game)); setLive(getAimTrainerLiveStats(game));
} }
while (game.targets.length < config.maxConcurrentTargets) { let standardCount = game.targets.filter(
(activeTarget) => activeTarget.target.kind === "standard",
).length;
let civilianCount = game.targets.length - standardCount;
while (standardCount < config.maxConcurrentTargets) {
const dueIndex = game.pendingSpawnAts.findIndex((spawnAt) => spawnAt <= now); const dueIndex = game.pendingSpawnAts.findIndex((spawnAt) => spawnAt <= now);
if (dueIndex === -1) break; if (dueIndex === -1) break;
game.pendingSpawnAts.splice(dueIndex, 1); game.pendingSpawnAts.splice(dueIndex, 1);
game.targets.push({ game.targets.push({
target: createAimTrainerTarget(Math.random, CANVAS_W, CANVAS_H, difficulty), target: createAimTrainerTarget(Math.random, CANVAS_W, CANVAS_H, difficulty, {
existingTargets: game.targets.map((activeTarget) => activeTarget.target),
}),
spawnedAt: now, spawnedAt: now,
}); });
standardCount += 1;
if (civilianCount < MAX_CONCURRENT_CIVILIANS && Math.random() < config.civilianChance) {
game.targets.push({
target: createAimTrainerTarget(Math.random, CANVAS_W, CANVAS_H, difficulty, {
civilian: true,
existingTargets: game.targets.map((activeTarget) => activeTarget.target),
}),
spawnedAt: now,
});
civilianCount += 1;
}
} }
} }
} }
@ -509,8 +564,9 @@ export function AimTrainerGame({
window.removeEventListener("keyup", onKeyUp, { capture: true }); window.removeEventListener("keyup", onKeyUp, { capture: true });
}; };
}, [ }, [
config.replacementDelayMs, config.civilianChance,
config.maxConcurrentTargets, config.maxConcurrentTargets,
config.replacementDelayMs,
config.targetLifetimeMs, config.targetLifetimeMs,
difficulty, difficulty,
handlePrimaryAction, handlePrimaryAction,
@ -571,7 +627,8 @@ export function AimTrainerGame({
Ready on the line Ready on the line
</span> </span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Acquire every silhouette before it clears the range. Acquire every silhouette before it clears the range — and never shoot the blue
civilians.
</span> </span>
</div> </div>
<Button <Button
@ -593,7 +650,7 @@ export function AimTrainerGame({
{phase === "over" && lastRun && ( {phase === "over" && lastRun && (
<div className="absolute inset-0 flex flex-col justify-center gap-3 overflow-y-auto bg-background/90 p-4 text-center"> <div className="absolute inset-0 flex flex-col justify-center gap-3 overflow-y-auto bg-background/90 p-4 text-center">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Trophy aria-hidden="true" className="size-5 text-primary" /> <Trophy aria-hidden="true" className="size-5 text-amber-400" />
<span className="text-lg font-semibold tracking-wide">Course complete</span> <span className="text-lg font-semibold tracking-wide">Course complete</span>
</div> </div>
{lastRun.isNewRecord && <Badge className="mx-auto">New record!</Badge>} {lastRun.isNewRecord && <Badge className="mx-auto">New record!</Badge>}
@ -608,7 +665,8 @@ export function AimTrainerGame({
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{lastRun.shotsFired} shots fired</span> <span>{lastRun.shotsFired} shots fired</span>
<span>{lastRun.despawnedUnhitTargets} targets expired</span> <span>{lastRun.despawnedUnhitTargets} targets expired</span>
<span className="font-semibold text-primary">+{lastRun.xpEarned} XP</span> <span>{lastRun.civilianHits} civilian hits</span>
<span className="font-semibold text-emerald-400">+{lastRun.xpEarned} XP</span>
</div> </div>
{lastRun.error && <span className="text-xs text-destructive">{lastRun.error}</span>} {lastRun.error && <span className="text-xs text-destructive">{lastRun.error}</span>}
{submitting && ( {submitting && (
@ -669,7 +727,8 @@ export function AimTrainerGame({
</div> </div>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Headshots score {HEADSHOT_POINTS}. Torso hits score {TORSO_POINTS}. Headshots score {HEADSHOT_POINTS} · torso hits score {TORSO_POINTS} · civilians cost{" "}
{CIVILIAN_PENALTY}. Press R to restart the lane.
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -109,6 +109,19 @@ export function playTorsoHit(): void {
}); });
} }
export function playCivilianHit(): void {
const ctx = getCtx();
if (!ctx) return;
playTone(ctx, {
frequency: 300,
endFrequency: 140,
duration: 0.22,
type: "sawtooth",
volume: 0.16,
});
playNoise(ctx, { duration: 0.12, volume: 0.12, lowpass: 520 });
}
export function playMiss(): void { export function playMiss(): void {
const ctx = getCtx(); const ctx = getCtx();
if (!ctx) return; if (!ctx) return;