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 {
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue