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:
parent
6c7a9fb5e2
commit
b905fe2b5e
2 changed files with 95 additions and 23 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue