The leagues, phase 4: the duel learns, tin tiers by league, level buffs, the board wears the badge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
76d88030e5
commit
787877bce6
10 files changed
+175
-24
No files matched your search
@@ -688,6 +688,16 @@ export const TUNE = {
|
||||
// returner takes. Serve-scoped on purpose: the rally is unchanged.
|
||||
racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU)
|
||||
racketFlub: 0.2, // chance the racket zombie whiffs a return
|
||||
// THE DUEL LEARNS (the leagues, phase 4). At duelFirst 2 / duelEvery 3 with hp-only stiffening
|
||||
// the duel was a weapon DISPENSER, not a gate — the same inflation class the barrel economy was
|
||||
// cured of. Its skill now ramps with the RUN, on gore's own clock (G.runGames — the league buffs
|
||||
// YOU, it never rekeys the director): legs, read, whiff and return quality all climb to these
|
||||
// caps by duelRampGames. Game 0 is exactly the shipped duel (the calibration rule).
|
||||
duelRampGames: 24, // games until the duel opponent plays at full skill
|
||||
duelSpeedMax: 0.35, // its legs end 35% quicker than the shipped 2.8 m/s lumber
|
||||
duelReadMax: 0.93, // its read climbs from aiRead 0.8 toward here — near-machine, never one
|
||||
duelFlubCut: 0.6, // racketFlub 0.2 -> 0.08: late duels stop beating themselves
|
||||
duelQBonus: 0.2, // racketQMax 0.55 -> 0.75: its returns get real pace late
|
||||
missHud: 0, // show the last miss reason on screen (full log: T.G.missLog) — off; dev-only
|
||||
killPips: 1, // splatter: show the kills-to-next-point pips on the scoreboard
|
||||
};
|
||||
@@ -719,6 +729,14 @@ export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e
|
||||
// palette is near it, so "pink means a weapon is in there" needs no explaining
|
||||
pink: '#ff5cf0' };
|
||||
|
||||
// THE TIN TIERS (the leagues, phase 4; settled: tin-tier, NO starting loadout). The league
|
||||
// unlocks what a won duel's tin can PAY, independent of the run's own barrel gates — "weapons are
|
||||
// only ever won" stays literal, the league just deepens the pot: lasers join the duel ladder at
|
||||
// league 3, rockets at 4, and league 5 duels pay DOUBLE tins. The world board is not devalued by
|
||||
// starting inventory, and every weapon still arrives through resolveInterlude('P').
|
||||
export const TIN_LEAGUE = { laser: 3, rocket: 4 };
|
||||
export const TIN_DOUBLE_LEAGUE = 5;
|
||||
|
||||
// ------------------------------------------------------------------ courts ----
|
||||
// A court type is pure DATA: adding one is a row here plus its four PNGs, never a change to render().
|
||||
// Each carries TWO palettes because every court still rots — `tennis` is how it looks before the
|
||||
|
||||
+29
-11
@@ -1,6 +1,6 @@
|
||||
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||||
|
||||
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, LADDER, LEAGUES } from './config.js';
|
||||
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, LADDER, LEAGUES, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
|
||||
import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
|
||||
import { haptic } from './haptics.js';
|
||||
@@ -8,7 +8,7 @@ import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
|
||||
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
||||
import { updateDrill } from './drill.js';
|
||||
import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
|
||||
import { award, beginRunXp, prof } from './profile.js';
|
||||
import { award, beginRunXp, prof, bonus } from './profile.js';
|
||||
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
|
||||
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
|
||||
import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal
|
||||
@@ -169,6 +169,11 @@ export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ?
|
||||
// piece and the reach tests keep believing the legs they measure. Netplay is a human and exempt.
|
||||
export const leaguePace = () => G.match && !G.net ? 1 + (G.league - 1) * TUNE.leaguePace : 1;
|
||||
|
||||
// THE DUEL LEARNS (the leagues, phase 4): 0 fresh, 1 at TUNE.duelRampGames — gore's own clock,
|
||||
// so game 0 is exactly the shipped duel and the league never rekeys the director. Exported for
|
||||
// the suite: the ramp is what makes a late tin worth something again.
|
||||
export const duelSkill = () => G.match ? 0 : Math.min(1, G.runGames / TUNE.duelRampGames);
|
||||
|
||||
function doHit(ch, quality, aim) {
|
||||
// aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short)
|
||||
const toFar = ch.side === 'P';
|
||||
@@ -904,6 +909,11 @@ function spendSlot(i) {
|
||||
// (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde
|
||||
// instead of spending it now. G.weapon picks which tin the next serve throws.
|
||||
const TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out
|
||||
// THE TIN TIERS (the leagues): the league deepens the pot the duel pays from — a barrel weapon
|
||||
// joins the ladder when the RUN unlocks it (as ever) OR when the LEAGUE says it is yours
|
||||
// (config.TIN_LEAGUE). Pure and exported so the suite can pin the pot.
|
||||
export const tinLadder = () => [...TINS, ...Object.keys(BARREL_WEAPONS).filter(w =>
|
||||
G.runGames >= BARREL_WEAPONS[w].from || prof.lg >= (TIN_LEAGUE[w] || 99))];
|
||||
export function deliverTin() {
|
||||
// WALK the ladder to the next rung you have room for, rather than cycling blindly onto one that
|
||||
// is already at wCap. Blind cycling meant `grantWeapon` clamped the top-up to a no-op and a won
|
||||
@@ -915,7 +925,7 @@ export function deliverTin() {
|
||||
// once all four tins hit wCap — and now that wCap is effectively no cap that branch would never
|
||||
// fire again, so a late duel could only ever hand you a fifth bomb. Unlocked ones only, so this
|
||||
// still cannot hand you a rocket in game three.
|
||||
const ladder = [...TINS, ...Object.keys(BARREL_WEAPONS).filter(w => G.runGames >= BARREL_WEAPONS[w].from)];
|
||||
const ladder = tinLadder();
|
||||
let id = null;
|
||||
for (let k = 0; k < ladder.length; k++) {
|
||||
const cand = ladder[(G.deliveries + k) % ladder.length];
|
||||
@@ -925,14 +935,16 @@ export function deliverTin() {
|
||||
// of miming a handover that cannot happen. No boy, no umpire line — nothing was delivered.
|
||||
if (!id) { G.flash = { text: 'RACK FULL', t: 1.1 }; return; }
|
||||
const wasPlain = G.weapon === 0 && !G.temp; // never yank you off a deliberate PLAIN (banking)
|
||||
grantWeapon(id);
|
||||
// league 5 duels pay DOUBLE tins — the top of the club tips well (config.TIN_DOUBLE_LEAGUE)
|
||||
const tinN = WEAPONS[id].tin * (prof.lg >= TIN_DOUBLE_LEAGUE ? 2 : 1);
|
||||
grantWeapon(id, tinN);
|
||||
if (wasPlain) G.weapon = 0;
|
||||
sendBoy(); voice('new_balls', 0.6);
|
||||
aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently."
|
||||
// THE PRIZE GETS ITS OWN CARD: the tin's picture, its name, and how many. Nothing else — the
|
||||
// first-sight MODAL is gone (a popup that freezes a live court mid-run costs runs) and so is the
|
||||
// prose that replaced it. You have just won a point; you want to know what landed and how much.
|
||||
G.card = { title: WEAPONS[id].label, sub1: '×' + WEAPONS[id].tin,
|
||||
G.card = { title: WEAPONS[id].label, sub1: '×' + tinN,
|
||||
icon: WEAPONS[id].icon, color: UI.warm, t: TUNE.tinCardT };
|
||||
}
|
||||
|
||||
@@ -1157,7 +1169,8 @@ function matchFallWillPlay() {
|
||||
// answer "how smart is this one" identically or the twist pulls in three directions at once.
|
||||
const learnK = rz => G.match && !G.net ? Math.min(1, Math.max(0, (G.match.opp - 1) / (LADDER - 1))) : 0;
|
||||
const rzChaseSpeed = rz => TUNE.racketSpeed * leaguePace()
|
||||
* (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1)
|
||||
* (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1)
|
||||
: 1 + TUNE.duelSpeedMax * duelSkill())
|
||||
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1)
|
||||
// wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE
|
||||
// so the intercept planner and the reach tests believe it too (a surprised champion plans like
|
||||
@@ -1465,7 +1478,9 @@ function racketAI(rz, dt) {
|
||||
const learn = learnK(rz);
|
||||
const read = G.match && G.match.surprised ? TUNE.aiRead * (1 - TUNE.surpRead * learn)
|
||||
: G.match && G.match.expected ? TUNE.aiRead + (TUNE.readAnticipate - TUNE.aiRead) * learn
|
||||
: TUNE.aiRead;
|
||||
: G.match ? TUNE.aiRead
|
||||
// the DUEL's read climbs with the run (never to 1 — wrong-footing stays possible)
|
||||
: TUNE.aiRead + (TUNE.duelReadMax - TUNE.aiRead) * duelSkill();
|
||||
const aimX = pred ? ball.x + (pred.x - ball.x) * read : ball.x;
|
||||
const aimZ = pred ? ball.z + (pred.z - ball.z) * read : ball.z;
|
||||
const tz = Math.max(1.5, Math.min(COURT.svcFar, aimZ)); // chase laterally, hold deep on the far court
|
||||
@@ -1506,13 +1521,14 @@ function racketAI(rz, dt) {
|
||||
// ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away,
|
||||
// which across a game is an opponent that beats itself.
|
||||
const flub = (G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub
|
||||
: TUNE.racketFlub)
|
||||
: TUNE.racketFlub * (1 - TUNE.duelFlubCut * duelSkill()))
|
||||
// THE READ: a wrong-footed racket reaches the ball late even when the legs got there —
|
||||
// the surprise ball ADDS whiff on top of the rung's own rate, scaled by how much this
|
||||
// body had actually learned. The champion's near-zero flub is exactly what this opens.
|
||||
+ (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0);
|
||||
if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
|
||||
else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; }
|
||||
else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
|
||||
trySwing(rz, true, TUNE.racketQMax + TUNE.duelQBonus * duelSkill()); rz.swingT = -1; rz.swingCool = 0.3; }
|
||||
// the lean is SPENT at the racket, either way — matchReady clears it when a point ends unmet
|
||||
if (G.match) G.match.surprised = G.match.expected = false;
|
||||
}
|
||||
@@ -1602,7 +1618,9 @@ export function dragServe(aim) {
|
||||
function meleeCooldown() {
|
||||
let near = 0;
|
||||
for (const zb of zombies) if (zb && Math.hypot(zb.x - P.x, zb.z - P.z) < TUNE.meleeCrowdR) near++;
|
||||
return Math.max(TUNE.meleeCoolMin, TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd));
|
||||
// the FLOOR rides the level (the leagues: the level is you) — the relaxed ceiling does not
|
||||
return Math.max(TUNE.meleeCoolMin * bonus().melee,
|
||||
TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd));
|
||||
}
|
||||
|
||||
export function keyPressed(code) {
|
||||
@@ -1637,7 +1655,7 @@ export function keyPressed(code) {
|
||||
// aimed at a machine's shot tossed the serve ball instead and the return was, in practice,
|
||||
// impossible. Oliver could not land one. A hostile ball in the air is the most visible thing on the
|
||||
// screen, so "the tap is a swing at it" keeps one gesture meaning one thing at any given moment.
|
||||
if (G.mode === 'splatter' && shots.some(s => Math.hypot(s.x - P.x, (s.z - P.z) * 0.8) < TUNE.parryArm)) {
|
||||
if (G.mode === 'splatter' && shots.some(s => Math.hypot(s.x - P.x, (s.z - P.z) * 0.8) < TUNE.parryArm * bonus().parry)) {
|
||||
P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true;
|
||||
swish(0.3);
|
||||
return;
|
||||
|
||||
+1
-1
@@ -713,7 +713,7 @@ document.getElementById('nbSend').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('nbSend');
|
||||
btn.disabled = true; nbMsg.textContent = 'SENDING…'; // the double-tap posts twice otherwise
|
||||
try {
|
||||
const r = await stats.submit(name, nbRun.score, nbRun.sub, nbRun.mode);
|
||||
const r = await stats.submit(name, nbRun.score, nbRun.sub, nbRun.mode, profile.prof.lg);
|
||||
rkMode = nbRun.mode; // ...and the board that opens is the one you just wrote your name on
|
||||
for (const el of document.getElementById('rkModes').children)
|
||||
el.classList.toggle('on', el.dataset.m === rkMode);
|
||||
|
||||
@@ -88,6 +88,17 @@ export function award(kind, n = 1) {
|
||||
return amt;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the level is you ----
|
||||
// Phase 4's defensive buffs, through existing dials only: level 1 is the shipped game (factor 1)
|
||||
// and the cap stays honest — a maxed profile survives volleys better, it does not stop dying.
|
||||
// Read at the dial sites (meleeCooldown's floor, the parry's reach/arm, hurtPlayer's grace).
|
||||
export function bonus() {
|
||||
const f = (levelAt(prof.xp) - 1) / (LEVEL_CAP - 1);
|
||||
return { melee: 1 - 0.25 * f, // the swish floor drops to 75% — panic scales with mastery
|
||||
parry: 1 + 0.25 * f, // the parry reaches a quarter further
|
||||
grace: 1 + 0.35 * f }; // a volley needs a third longer to land twice
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the promotion ----
|
||||
// A cup win that moved the league parks its ceremony here (beats.cupWon writes, main.maybeRestart
|
||||
// consumes once the summary is tapped away). Module-local and consumed-on-read, the same shape as
|
||||
|
||||
+9
-3
@@ -218,12 +218,14 @@ export const saveName = s => {
|
||||
|
||||
// `k` is the BOARD's number and `g` its second column — what those mean is the mode's business
|
||||
// (BOARDS), and the server only ever sorts them. `m` is which board the row goes on.
|
||||
export async function submit(rawName, k, g, mode = MODE_GORE) {
|
||||
export async function submit(rawName, k, g, mode = MODE_GORE, lg = 1) {
|
||||
const name = saveName(rawName);
|
||||
if (!name) throw new Error('empty name');
|
||||
// `lg` is the league badge the row will wear (THE LEAGUES) — context beside the score, never a
|
||||
// sort key. Passed in by the caller because profile.js imports THIS module.
|
||||
const r = await fetch(API + 'scores', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ n: name, k, g, m: mode, id: myId() }) });
|
||||
body: JSON.stringify({ n: name, k, g, m: mode, id: myId(), lg } ) });
|
||||
if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; }
|
||||
if (!r.ok) throw new Error('submit ' + r.status);
|
||||
return r.json(); // {ok, rank, scores}
|
||||
@@ -322,7 +324,11 @@ export async function drawRanks(rows, mode = MODE_GORE) {
|
||||
if (!rows.length) { el.innerHTML = '<p class="none">NOBODY IS ON THIS BOARD YET. BE FIRST.</p>'; return; }
|
||||
const me = savedName();
|
||||
el.innerHTML = rows.map((r, i) =>
|
||||
`<div class="rkrow${r.n === me ? ' me' : ''}"><b>${n(i + 1)}</b><span>${esc(r.n)}</span>`
|
||||
`<div class="rkrow${r.n === me ? ' me' : ''}"><b>${n(i + 1)}</b>`
|
||||
// the league badge, when the row has one: CONTEXT beside the name — a league-5 high score is
|
||||
// expected, a league-2 upset is visible. Old rows carry none and show none.
|
||||
+ (r.lg >= 1 && r.lg <= 5 ? `<img class="rkbadge" src="assets/badge_l${r.lg | 0}.png" alt="">` : '')
|
||||
+ `<span>${esc(r.n)}</span>`
|
||||
+ `<i>${n(r.k || 0)} · ${r.g || 0}</i></div>`).join('');
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -11,7 +11,7 @@ import { drillKill, drillCaught } from './drill.js';
|
||||
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
|
||||
import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one
|
||||
import { tally, multiKill, endRun, MODE_MATCH } from './stats.js';
|
||||
import { award } from './profile.js';
|
||||
import { award, bonus } from './profile.js';
|
||||
|
||||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
||||
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
|
||||
@@ -267,7 +267,7 @@ export function hurtPlayer(n) {
|
||||
// it beats this channel and the eat test below, which is the only reason to spend a dodge.
|
||||
if (G.mode !== 'splatter' || G.hurtT > 0 || G.dodgeI > 0 || G.mode === 'eaten') return;
|
||||
G.hp -= n;
|
||||
G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.3;
|
||||
G.hurtT = TUNE.hurtGrace * bonus().grace; G.hurtFlash = 0.3; // the grace rides the level (the leagues)
|
||||
haptic('heavy'); // taking one is rare (hurtGrace gates it) and is the loudest thing the hand gets
|
||||
G.shake = { t: 0.22, mag: 4 };
|
||||
splat(0.7); aww(0.5);
|
||||
@@ -1383,14 +1383,14 @@ export function updateZombies(dt) {
|
||||
const dP = Math.hypot(s.x - P.x, (s.z - P.z) * 0.8);
|
||||
const dHit = Math.hypot(s.x - P.x, s.z - P.z);
|
||||
if (!done && P.swingT >= 0 && P.swingT <= TUNE.parryWindow
|
||||
&& dP < TUNE.parryReach && s.y < CONTACT.maxY) {
|
||||
&& dP < TUNE.parryReach * bonus().parry && s.y < CONTACT.maxY) {
|
||||
// the parry: it becomes an `extras` clone — that array is already ballistic and already kills
|
||||
// zombies, so "returned shot is a live weapon" costs nothing to build.
|
||||
// It is a GLANCING blow, not a mirror. Reversing the incoming line sent every return straight
|
||||
// back up the line it arrived on — which is where the thrower is standing — so a boss was a
|
||||
// free wound per throw. Where you meet the ball across the racket face now sets the angle:
|
||||
// hitting it back INTO him is a shot you have to place your feet for.
|
||||
const off = Math.max(-1, Math.min(1, (s.x - P.x) / TUNE.parryReach)); // -1..1 across the face
|
||||
const off = Math.max(-1, Math.min(1, (s.x - P.x) / (TUNE.parryReach * bonus().parry))); // -1..1 across the face
|
||||
const ang = (off * TUNE.parryDeflect + (Math.random() * 2 - 1) * TUNE.parryJitter) * Math.PI / 180;
|
||||
const sp = Math.hypot(s.vx, s.vz) * 1.15;
|
||||
extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5,
|
||||
|
||||
Reference in new issue
Block a user