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 {
AIM_TRAINER_DIFFICULTIES,
CIVILIAN_PENALTY,
MAX_CONCURRENT_CIVILIANS,
calculateAimTrainerAccuracy,
calculateAimTrainerScore,
COURSE_DURATION_MS,
HEADSHOT_POINTS,
createAimTrainerTarget,
resolveAimTrainerTargetHit,
stepAimTrainerTarget,
stepAimTrainerTargets,
TORSO_POINTS,
type AimTrainerDifficulty,
} from "@/lib/aim-trainer";
@ -44,6 +46,7 @@ import {
type AimTrainerLiveStats,
} from "./AimTrainerCanvas";
import {
playCivilianHit,
playCountdownTick,
playFire,
playHeadshot,
@ -94,6 +97,7 @@ type LastRun = {
readonly accuracy: number;
readonly headshots: number;
readonly torsoHits: number;
readonly civilianHits: number;
readonly shotsFired: number;
readonly despawnedUnhitTargets: number;
readonly xpEarned: number;
@ -205,8 +209,12 @@ export function AimTrainerGame({
round.torsoHits,
round.shotsFired,
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 preview: LastRun = {
hitScore,
@ -215,6 +223,7 @@ export function AimTrainerGame({
accuracy,
headshots: round.headshots,
torsoHits: round.torsoHits,
civilianHits: round.civilianHits,
shotsFired: round.shotsFired,
despawnedUnhitTargets: round.despawnedUnhitTargets,
xpEarned: 0,
@ -352,6 +361,23 @@ export function AimTrainerGame({
const [activeTarget] = game.targets.splice(hit.index, 1);
if (!activeTarget) return;
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);
switch (hit.zone) {
case "head":
@ -410,13 +436,14 @@ export function AimTrainerGame({
setLive(getAimTrainerLiveStats(game));
setPhaseSafe("over");
playRoundComplete();
void handleRoundCompleteRef.current({
difficulty,
headshots: game.headshots,
torsoHits: game.torsoHits,
shotsFired: game.shotsFired,
despawnedUnhitTargets: game.despawnedUnhitTargets,
});
void handleRoundCompleteRef.current({
difficulty,
headshots: game.headshots,
torsoHits: game.torsoHits,
civilianHits: game.civilianHits,
shotsFired: game.shotsFired,
despawnedUnhitTargets: game.despawnedUnhitTargets,
});
};
const step = (now: number) => {
@ -443,32 +470,60 @@ export function AimTrainerGame({
}
const survivingTargets: AimTrainerActiveTarget[] = [];
let expiredTargets = 0;
let expiredStandardTargets = 0;
for (const activeTarget of game.targets) {
if (now - activeTarget.spawnedAt >= config.targetLifetimeMs) {
expiredTargets += 1;
game.pendingSpawnAts.push(now + config.replacementDelayMs);
if (activeTarget.target.kind === "standard") {
expiredStandardTargets += 1;
game.pendingSpawnAts.push(now + config.replacementDelayMs);
}
continue;
}
survivingTargets.push({
target: stepAimTrainerTarget(activeTarget.target, dtSec, CANVAS_W, CANVAS_H),
target: activeTarget.target,
spawnedAt: activeTarget.spawnedAt,
});
}
game.targets = survivingTargets;
if (expiredTargets > 0) {
game.despawnedUnhitTargets += expiredTargets;
const steppedTargets = stepAimTrainerTargets(
survivingTargets.map((activeTarget) => activeTarget.target),
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));
}
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);
if (dueIndex === -1) break;
game.pendingSpawnAts.splice(dueIndex, 1);
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,
});
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 });
};
}, [
config.replacementDelayMs,
config.civilianChance,
config.maxConcurrentTargets,
config.replacementDelayMs,
config.targetLifetimeMs,
difficulty,
handlePrimaryAction,
@ -571,7 +627,8 @@ export function AimTrainerGame({
Ready on the line
</span>
<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>
</div>
<Button
@ -593,7 +650,7 @@ export function AimTrainerGame({
{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="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>
</div>
{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">
<span>{lastRun.shotsFired} shots fired</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>
{lastRun.error && <span className="text-xs text-destructive">{lastRun.error}</span>}
{submitting && (
@ -669,7 +727,8 @@ export function AimTrainerGame({
</div>
</div>
<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>
</CardContent>
</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 {
const ctx = getCtx();
if (!ctx) return;