Combat: refresh at every kill, seamless countdown to the next enemy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-20 12:54:42 +01:00
parent 35491c6f4d
commit 57bcd812ee
2 changed files with 26 additions and 21 deletions

3
app.py
View file

@ -113,6 +113,8 @@ def player_view(world: dict, player: dict, now: float) -> dict:
action, fight = game.growing(player), player["action"].get("fight")
duration = fight["duration"] if fight else game.action_time(world, player, action)
view["action"] = {**player["action"], "growing": action["id"], "duration": duration}
if fight:
view["action"]["respawn"] = game.RESPAWN / game.speed(world)
# A fight takes as long as it takes: show the average time to a kill.
view["times"] = {action["id"]: (view["fight_odds"][action["id"]]["kill_time"] + game.RESPAWN) / game.speed(world)
if "enemy" in action else game.action_time(world, player, action) for action in game.ACTIONS}
@ -289,6 +291,7 @@ class Handler(BaseHTTPRequestHandler):
player["last_active"] = now
error = self.player_route(path, player, body, now)
if error is None:
game.process_player(world, player, now, self.store.rng) # a new fight is drawn up at once
game.tick(world, now, self.store.rng)
self.store.save()
if error:

View file

@ -14,7 +14,8 @@ let selected = null; // bank item
let pickedClass = null; // registration
const tabs = {}; // skill -> selected recipe tab
let refreshing = false;
let refreshedFor = null; // action start a completion refresh was sent for
let refreshedFor = null; // action (start and length) a completion refresh was sent for
let retriedAt = 0; // last time a fight past its kill asked again for the next one
let version = null; // server build this page was loaded from
let failures = 0; // polls in a row that didn't reach the server
@ -481,20 +482,19 @@ function animateFight(now) {
const a = S.me?.action, f = a?.fight;
if (!f) return;
const t = now - a.start;
{
let i = -1;
while (i + 1 < f.events.length && f.events[i + 1][0] <= t) i++;
const left = i >= 0 ? f.events[i][5] : f.enemy_hp;
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${left / f.enemy_hp * 100}%`;
}
const panel = $("[data-fight]");
if (!panel) return;
// Between kills: the next enemy is on its way, both swing timers empty.
panel.classList.toggle("waiting", t < 0);
const over = t >= f.end;
// From the kill until the next enemy turns up (the next fight's start, once the server sends it),
// the panel is the same quiet countdown: no enemy, both swing timers empty.
const waiting = t < 0 || (over && f.result === "won");
const respawnIn = t < 0 ? -t : f.end + (a.respawn || 1) - t;
let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++;
const last = f.events[shown];
const hp = last ? last[4] : f.start_hp, foeHp = last ? last[5] : f.enemy_hp;
const hp = last ? last[4] : f.start_hp, foeHp = waiting ? 0 : last ? last[5] : f.enemy_hp;
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${foeHp / f.enemy_hp * 100}%`;
const panel = $("[data-fight]");
if (!panel) return;
panel.classList.toggle("waiting", waiting);
// Your HP everywhere follows the fight as it plays out.
for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(hp);
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / f.top * 100)}%`;
@ -502,21 +502,19 @@ function animateFight(now) {
set("you-bar", (el) => { el.style.width = `${Math.max(0, hp / f.top * 100)}%`; });
set("foe-bar", (el) => { el.style.width = `${Math.max(0, foeHp / f.enemy_hp * 100)}%`; });
set("you-hp", (el) => { el.textContent = `${fmt(hp)} / ${fmt(f.top)}`; });
set("foe-hp", (el) => { el.textContent = `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; });
const over = t >= f.end;
set("foe-hp", (el) => { el.textContent = waiting ? "" : `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; });
for (const [who, every] of [["you", f.interval], ["foe", f.enemy_interval]]) {
let swung = 0;
for (let i = shown; i >= 0; i--) if (f.events[i][1] === who && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") { swung = f.events[i][0]; break; }
set(`${who}-swing`, (el) => { el.style.width = over || t < 0 ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; });
set(`${who}-swing`, (el) => { el.style.width = over || waiting ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; });
}
set("log", (el) => {
const key = `${a.start}:${shown}:${over}:${t < 0 ? Math.ceil(-t * 10) : ""}`;
const key = `${a.start}:${shown}:${over}:${waiting ? Math.ceil(respawnIn * 10) : ""}`;
if (el.dataset.key === key) return;
el.dataset.key = key;
const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy));
if (over) lines.unshift(f.result === "won" ? `<li class="good"><b>${esc(f.enemy)} is beaten.</b> The next one is coming…</li>`
: `<li class="bad"><b>You fall back.</b></li>`);
el.innerHTML = t < 0 ? `<li class="good">Victory! Next enemy in ${(-t).toFixed(1)}s</li>`
if (over && !waiting) lines.unshift(`<li class="bad"><b>You fall back.</b></li>`);
el.innerHTML = waiting ? `<li class="good"><b>Victory!</b> ${respawnIn > 0.05 ? `Next enemy in ${respawnIn.toFixed(1)}s` : "Next enemy coming…"}</li>`
: lines.join("") || `<li class="muted">Squaring up…</li>`;
});
}
@ -1174,9 +1172,13 @@ function animate() {
const pct = Math.min(1, (now - a.start) / a.duration);
// In a fight the bar is the enemy's health (set by animateFight), not a timer.
if (!a.fight) for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%";
if (pct >= 1 && refreshedFor !== a.start) {
refreshedFor = a.start;
const key = `${a.start}:${a.duration}`;
if (pct >= 1 && refreshedFor !== key) {
refreshedFor = key;
setTimeout(refresh, 150);
} else if (a.fight && now - a.start > a.duration + (a.respawn || 1) && now - retriedAt > 2) {
retriedAt = now; // the next enemy hasn't turned up yet: ask again
refresh();
}
}
for (const el of document.querySelectorAll("[data-span]")) {