Sentinel attack runs: duck or die

From level 3, flybys can become attack runs — klaxon + red 'SENTINEL —
DUCK' warning, red eye-light, then a fast sweep across the play plane
at the head height it locked at spawn. Hit costs a life; surviving
pays +25. Attack chance scales with level (25% + 5%/level, cap 70%).
Verified both outcomes via scripted playtest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-29 12:21:27 +10:00
parent d759365b20
commit c1ef9e4dc4
2 changed files with 34 additions and 8 deletions

View File

@ -14,6 +14,9 @@ No build step — single file, Three.js from CDN.
- **W/S · A/D · Q/E** — pitch / yaw / roll 90°, works while held and mid-flight
- Catch +25 · fit +100 × combo · dodge an agent +10 · 3 lives. Speed and agent
frequency ramp per fit; consecutive fits build the combo multiplier.
- From level 3 the **sentinel attacks**: klaxon + red warning, then it sweeps the
play plane at the height your head was at when it locked on. Duck out of the
band or lose a life; surviving pays +25.
## Assets (all generated free & local on MODELBEAST)

View File

@ -93,6 +93,7 @@ const sfx = {
throw: () => tone(190, 0.18, 'sawtooth', 0.13, -140),
fit: () => { tone(523, 0.12, 'sine', 0.16); setTimeout(() => tone(784, 0.14, 'sine', 0.16), 90); setTimeout(() => tone(1047, 0.2, 'sine', 0.16), 180); },
dodge: () => tone(880, 0.07, 'sine', 0.1, 220),
klaxon: () => { tone(110, 0.35, 'sawtooth', 0.18, -40); setTimeout(() => tone(110, 0.35, 'sawtooth', 0.18, -40), 450); },
shatter: () => noise(0.35, 0.3, 1400),
hit: () => { noise(0.3, 0.35, 500); tone(90, 0.3, 'sawtooth', 0.2, -50); },
};
@ -154,31 +155,53 @@ texLoader.load('assets/circuit.png', tex => {
});
// ---- sentinel flyby (generated image → GLB via the farm; optional) ----
let sentinel = null;
let sentinel = null, sentinelEye = null;
new GLTFLoader().load('assets/sentinel.glb', g => {
sentinel = g.scene;
const box = new THREE.Box3().setFromObject(sentinel);
const size = box.getSize(new THREE.Vector3()).length();
sentinel.scale.setScalar(20 / size);
sentinel.visible = false;
sentinelEye = new THREE.PointLight(0xff2233, 0, 40); // lit only during attack runs
sentinel.add(sentinelEye);
scene.add(sentinel);
}, undefined, () => {});
let flybyT = 14;
let flybyT = 14, forceAttack = false;
function flyby(dt) {
if (!sentinel) return;
const s = sentinel.userData;
if (!sentinel.visible) {
flybyT -= dt;
if (flybyT <= 0) {
const playing = state === 'incoming' || state === 'hold' || state === 'thrown';
s.attack = forceAttack || (playing && level >= 2 && Math.random() < Math.min(0.25 + level*0.05, 0.7));
forceAttack = false;
s.hit = false;
s.dir = Math.random() < 0.5 ? 1 : -1;
sentinel.visible = true;
sentinel.userData.dir = Math.random() < 0.5 ? 1 : -1;
sentinel.position.set(-sentinel.userData.dir * 60, (Math.random()-0.5)*10, -45 - Math.random()*25);
sentinel.rotation.set(0, sentinel.userData.dir > 0 ? Math.PI/2 : -Math.PI/2, 0);
sentinel.rotation.set(0, s.dir > 0 ? Math.PI/2 : -Math.PI/2, 0);
sentinelEye.intensity = s.attack ? 80 : 0;
if (s.attack) {
sentinel.position.set(-s.dir * 70, head.y, -10); // locks onto your head height — duck!
sfx.klaxon(); showMsg('SENTINEL — DUCK', 1200, true);
} else {
sentinel.position.set(-s.dir * 60, (Math.random()-0.5)*10, -45 - Math.random()*25);
}
}
} else {
sentinel.position.x += sentinel.userData.dir * 14 * dt;
sentinel.position.x += s.dir * (s.attack ? 26 : 14) * dt;
sentinel.position.y += Math.sin(performance.now()/400) * 0.02;
sentinel.rotation.z = Math.sin(performance.now()/700) * 0.15;
if (Math.abs(sentinel.position.x) > 65) { sentinel.visible = false; flybyT = 12 + Math.random()*16; }
if (s.attack && !s.hit && state !== 'over' &&
Math.abs(sentinel.position.x - head.x) < 3.5 && Math.abs(sentinel.position.y - head.y) < 1.5) {
s.hit = true;
damage('SENTINEL STRIKE');
}
if (Math.abs(sentinel.position.x) > 70) {
if (s.attack && !s.hit && state !== 'over' && state !== 'title') { addScore(25); sfx.dodge(); showMsg('EVADED +25', 700); }
sentinel.visible = false; sentinelEye.intensity = 0;
flybyT = s.attack ? 16 + Math.random()*14 : 12 + Math.random()*16;
}
}
}
@ -454,7 +477,7 @@ window._auto = () => { // rotate held/flying brick to fit the hole
};
Object.defineProperty(window, '_g', { get: () => ({ state, score, health, level, combo, agent: !!(brick && brick.userData.agent), brickZ: brick && +brick.position.z.toFixed(1), brickXY: brick && [+brick.position.x.toFixed(1), +brick.position.y.toFixed(1)], head: [+head.x.toFixed(1), +head.y.toFixed(1)], wallZ: wall && +wall.position.z.toFixed(1) }) });
window._start = () => { if (state === 'title') { $('title').style.display = 'none'; reset(); } };
window._fly = () => { flybyT = 0.01; return sentinel ? 'loaded' : 'no glb'; };
window._fly = (attack = false) => { flybyT = 0.01; forceAttack = attack; return sentinel ? 'loaded' : 'no glb'; };
tick();
</script>