Active play and the Watchtower Depths dungeon

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 12:05:23 +01:00
parent db1a00e8aa
commit 9d61de2012
9 changed files with 790 additions and 56 deletions

View file

@ -899,14 +899,21 @@ function combatFrame() {
const encounter = a && C.actions.find((x) => x.id === a.id);
const waiting = a?.waiting;
const others = f ? f.members.filter((m) => m.id !== S.uid) : [];
const unit = (id, name, foe = false, extra = "") => `<div class="fighter ${foe ? "foe-unit" : id === S.uid ? "" : "ally"}" data-u="${esc(id)}">
const unit = (id, name, foe = false, extra = "") => `<div class="fighter ${foe ? "foe-unit" : id === S.uid ? "" : "ally"} ${f ? "aimable" : ""}"
data-u="${esc(id)}" ${f ? `data-aim="${esc(id)}" title="${foe ? "Tap to fight this one" : "Tap to aim your heals here"}"` : ""}>
<div class="fighter-name"><b>${name}${extra}</b><span class="small muted"><span data-u-status></span> ${foe ? "" : HEART} <span data-u-hp>${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}</span></span></div>
<div class="bar hp ${foe ? "foe" : "you"}"><span data-u-bar${id === S.uid ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></span></div>
<div class="bar swing"><span data-u-swing></span></div>
<div class="cast small" data-u-cast></div>
</div>`;
const you = unit(S.uid, esc(me.name)) + others.map((m) => unit(m.id, esc(m.name))).join("");
let foe = `<div class="foe-idle muted">${waiting ? `👥 Waiting for a group · 1 / ${C.group_size}` : "Not fighting."}</div>`;
const run = a?.run;
// A dungeon run: where you are in it, and (for the leader, while resting) the button to go in early.
const stage = run ? `<div class="run-head"><b>${esc(run.dungeon)}</b>
<span class="muted small">${run.pull} / ${run.of} · ${esc(run.next)}${run.wipes ? ` · wipes ${run.wipes} / ${C.dungeon_wipes}` : ""}</span>
${f && run.leader === S.uid ? `<button class="btn sm" data-pull data-pull-btn>Pull now</button>` : ""}</div>` : "";
let foe = `<div class="foe-idle muted">${run?.away?.length ? `Waiting for ${run.away.map(esc).join(", ")} to come back`
: run ? `Ready: ${esc(run.next)}` : waiting ? `👥 Waiting for a group · 1 / ${C.group_size}` : "Not fighting."}</div>`;
if (f) {
const mine = C.styles[me.fighter.style || "sword"]?.damage || me.fighter.damage;
const traits = (v) => `<span class="v-attack">${v.attack.map((k) => dmgIcon(k, "attack")).join("")}</span>
@ -918,21 +925,27 @@ function combatFrame() {
}
return `<div class="panel fight combat-frame" ${f ? `data-fight="${a.start}"` : ""}>
<div class="you-side">${you}${abilityBar(me)}${prayerBar(me)}${paperDoll(me, true)}</div>
<div class="foe-side">${foe}</div>
<div class="foe-side">${stage}${foe}</div>
</div>`;
}
// Your abilities, from your weapon and your trade, on auto: each lights up as it's used and shows its
// cooldown. The weapon's ones still to come are shown dimmed with the level they're learned at.
// Your abilities, from your weapon and your trade: each lights up as it's used and shows its cooldown.
// On AUTO a rule uses it on a longer cooldown; on YOU you press it (tap, or its number key) on the real
// one, for as long as you're watching. The weapon's ones still to come are dimmed with their level.
function abilityBar(me) {
const known = me.fighter.abilities, style = me.fighter.style || "sword";
const shield = !!me.equip.shield;
const tile = (id, on) => {
const ab = C.abilities[id];
const ab = C.abilities[id], manual = on && me.manual.includes(id);
const why = on ? "" : ab.needs === "shield" && !shield && me.levels.combat >= ab.level ? "needs a shield" : `combat ${ab.level}`;
const tip = `${ab.name}: ${ab.tip}${ab.cd ? ` · ${ab.cd * C.auto_cd}s on auto` : ""}${why ? ` · ${why}` : ""}`;
return `<span class="ab ${on ? "" : "locked"}" data-ab="${id}" title="${esc(tip)}">${ab.icon}<span class="ab-cd" data-ab-cd></span>
${on ? "" : `<span class="ab-lv">${why === "needs a shield" ? "🛡️" : ab.level}</span>`}</span>`;
const cd = ab.cd ? ` · ${manual ? ab.cd : ab.cd * C.auto_cd}s cooldown` : "";
const tip = `${ab.name}: ${ab.tip}${cd}${why ? ` · ${why}` : ""}`;
return `<span class="ab ${on ? (manual ? "manual" : "auto") : "locked"}" data-ab="${id}">
<button class="ab-press" data-press="${id}" title="${esc(tip)}" ${on ? "" : "disabled"}>${ab.icon}<span class="ab-cd" data-ab-cd></span>
${on ? "" : `<span class="ab-lv">${why === "needs a shield" ? glyph("checked-shield", "#d4dae2") : ab.level}</span>`}</button>
${on ? `<button class="ab-mode" data-mode="${id}" title="${esc(manual ? `Yours: press it while you watch. Look away ${C.watch}s and it's back on auto. Tap for auto.`
: `Auto: used by itself on a ${C.auto_cd}× cooldown. Tap to take it yourself.`)}">${manual ? "YOU" : "AUTO"}</button>` : ""}
</span>`;
};
const kit = C.weapon_kits[style].map((id) => tile(id, known.includes(id)));
const trade = C.trade_ability[me.cls];
@ -976,7 +989,9 @@ function fightLine(e, f) {
case "cc": return `<li class="good">${what} ${name(tgt)} held</li>`;
case "free": return `<li class="muted">${name(tgt)} breaks free</li>`;
case "die": return `<li class="good">${name(tgt)} falls</li>`;
case "enrage": return `<li class="bad">${C.abilities.unstoppable.icon} ${name(src)} enrages</li>`;
case "enrage": return `<li class="bad">${C.abilities.unstoppable.icon} ${name(src)} ${label === "hard" ? "<b>loses patience</b>" : "enrages"}</li>`;
case "yell": return `<li class="warn">${name(src)}: <b>“${esc(label)}”</b></li>`;
case "join": return `<li class="warn">${name(src)} joins the fight</li>`;
case "wind": return `<li class="good">${C.prayers.find((p) => p.id === "second_wind").icon} Second wind! ${name(src)}</li>`;
case "down": return `<li class="bad">${C.abilities.concuss.icon} <b>${src === S.uid ? "You're knocked out" : `${name(src)} is knocked out`}</b></li>`;
case "left": return `<li class="muted">${name(src)} left</li>`;
@ -984,12 +999,21 @@ function fightLine(e, f) {
return "";
}
// Whether a use of an ability came from a press (within the queue window before it), so it ran on its
// real cooldown.
function pressedNear(f, uid, ab, time) {
const m = f.members.find((x) => x.id === uid);
return (m.inputs || []).some((i) => i[1] === "press" && i[2] === ab && time >= (f.rest + i[0]) / f.pace - 0.01
&& time <= (f.rest + i[0] + 1.5) / f.pace + 0.01);
}
// Where everyone in the fight stands at `t` seconds into it: HP, last swing, cast under way, out or not,
// and when each of your abilities was last used.
function fightState(f, t) {
const rest = f.rest / f.pace, st = {};
for (const m of f.members) st[m.id] = { hp: m.hp_in, top: m.top, from: m.at / f.pace + rest, every: m.interval, swung: null, cast: null, out: null, cds: {} };
f.pack.forEach((foe, i) => { st[`e${i}`] = { hp: foe.hp, top: foe.hp, from: rest, every: foe.interval / f.pace, swung: null, cast: null, out: null, held: false, enraged: false }; });
for (const m of f.members) st[m.id] = { hp: m.hp_in, top: m.top, from: m.at / f.pace + rest, every: m.interval, swung: null,
cast: null, out: null, cds: {}, manualAt: {}, pressed: new Set((m.inputs || []).filter((i) => i[1] === "press").map((i) => i[2])) };
f.pack.forEach((foe, i) => { st[`e${i}`] = { hp: foe.hp, top: foe.hp, from: rest, every: foe.interval / f.pace, swung: null, cast: null, out: null, held: false, enraged: false, hidden: !!foe.dormant }; });
let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) {
const [time, src, tgt, kind, , hp, label] = f.events[++shown];
@ -1001,7 +1025,11 @@ function fightState(f, t) {
who.cast = { label, from: time, until: time + spec.cast / f.pace };
}
const ab = C.abilities[label];
if (who?.cds && ab && (ab.cast ? kind === "cast" : kind === "use" || SWINGS.has(kind))) who.cds[label] = time;
if (who?.cds && ab && (ab.cast ? kind === "cast" : kind === "use" || SWINGS.has(kind)) && who.cds[label] !== time) {
who.cds[label] = time;
who.manualAt[label] = who.pressed.has(`${label}`) && pressedNear(f, src, label, time);
}
if (who?.cds && tgt?.[0] === "e" && (SWINGS.has(kind) || kind === "cast" || kind === "use")) who.aim = tgt;
if (who && kind === "heal" && label === "potion") who.drank = true;
if (them && (kind === "stop" || kind === "cc")) them.cast = null;
if (them && kind === "cc") them.held = true;
@ -1009,6 +1037,7 @@ function fightState(f, t) {
if (them && kind === "die") them.out = "dead";
if (who && (kind === "down" || kind === "left")) who.out = kind;
if (who && kind === "enrage") who.enraged = true;
if (who && kind === "join") { who.hidden = false; who.from = time; }
}
return { st, shown };
}
@ -1032,11 +1061,17 @@ function animateFight(now) {
const panel = $("[data-fight]");
if (!panel) return;
panel.classList.toggle("waiting", resting);
const pull = panel.querySelector("[data-pull-btn]");
if (pull) pull.hidden = !resting;
const pick = [...(mine.inputs || [])].reverse().find((i) => i[1] === "target" && (f.rest + i[0]) / f.pace <= t && i[2][0] === "e");
const aim = pick && st[pick[2]] && !st[pick[2]].out ? pick[2] : me.aim && st[me.aim] && !st[me.aim].out ? me.aim : null;
for (const el of panel.querySelectorAll("[data-u]")) {
const u = st[el.dataset.u];
if (!u) continue;
el.classList.toggle("aimed", el.dataset.u === aim && !over);
const gone = !!u.out || over;
el.classList.toggle("out", !!u.out);
el.hidden = !!u.hidden;
el.querySelector("[data-u-bar]").style.width = `${Math.max(0, u.hp / u.top * 100)}%`;
el.querySelector("[data-u-hp]").textContent = u.out === "left" ? "left" : t < u.from && !resting ? "joining…" : `${fmt(u.hp)} / ${fmt(u.top)}`;
const status = u.out === "dead" ? "" : u.out === "down" ? "concuss" : u.held ? "thornbind" : u.enraged ? "unstoppable" : "";
@ -1061,7 +1096,7 @@ function animateFight(now) {
const id = el.dataset.ab;
if (id === "potion") { el.classList.toggle("spent", !!me.drank); continue; }
const ab = C.abilities[id], used = me.cds?.[id];
const cd = (ab.cd || 0) * C.auto_cd / f.pace;
const cd = (ab.cd || 0) * (me.manualAt?.[id] ? 1 : C.auto_cd) / f.pace;
const leftCd = used == null || over ? 0 : Math.max(0, used + cd - t);
el.querySelector("[data-ab-cd]").style.height = `${cd ? leftCd / cd * 100 : 0}%`;
el.classList.toggle("fired", used != null && t - used < 0.6);
@ -1181,7 +1216,8 @@ function actionCard(a) {
let extra = "";
if (a.enemy) {
const o = me.fight_odds[a.id], e = a.enemy;
const own = (a.together ? `<span class="tag warn" title="Fought by ${C.group_size} or more: alone, you wait for someone to join">👥 Group</span> ` : "")
const own = (a.dungeon ? `<span class="tag warn" title="${esc(`${a.pulls.length} pulls: ${a.pulls.join(" · ")}. Trash falls on auto; the bosses take your own hands. ${C.dungeon_wipes} wipes on a pull and the run's over.`)}">🏰 Dungeon · ${C.group_size}–${C.dungeon_size}</span> `
: a.together ? `<span class="tag warn" title="Fought by ${C.group_size} or more: alone, you wait for someone to join">👥 Group</span> ` : "")
+ (a.personal_loot ? `<span class="tag good" title="Everyone in the fight gets their own loot">🎁 Own loot</span>` : "");
extra = `${e.boss || own ? `<div>${e.boss ? `<span class="tag warn">Boss</span> ` : ""}${own}</div>` : ""}${variantRows(e, o)}${fightingHere(a, why)}`;
if (a.gold) io += `<span class="item-chip" title="Gold: ${a.gold[0]}–${a.gold[1]} a kill">${COIN} ${a.gold[0]}–${a.gold[1]}</span>`;
@ -2118,7 +2154,7 @@ function loop() {
// --- events ---------------------------------------------------------------
document.addEventListener("click", (e) => {
const t = e.target.closest("button, [data-menu], [data-close]");
const t = e.target.closest("button, [data-menu], [data-close], [data-aim]");
if (!t) return;
const d = t.dataset;
if (t.matches("[data-battle-skip]")) return playBattle.finish?.();
@ -2137,6 +2173,14 @@ document.addEventListener("click", (e) => {
render(); window.scrollTo(0, 0); return;
}
if (d.firewood) { firewood = Number(d.firewood); remember("firewood", d.firewood); return render(); }
if (d.press) {
if (!S.me.manual.includes(d.press)) return toast("On auto: tap AUTO to take it yourself");
t.closest(".ab")?.classList.add("queued");
return act("/api/press", { id: d.press });
}
if (d.mode) return act("/api/manual", { id: d.mode, on: !S.me.manual.includes(d.mode) });
if ("pull" in d) return act("/api/pull", {});
if (d.aim) return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim });
if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {});
@ -2253,6 +2297,15 @@ document.addEventListener("change", (e) => {
$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; });
// Number keys press your abilities, in the bar's order (the ones you know).
document.addEventListener("keydown", (e) => {
if (e.target.matches("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return;
const n = Number(e.key);
if (!n) return;
const tile = document.querySelectorAll(".ability-bar .ab:not(.locked) [data-press]")[n - 1];
if (tile) { e.preventDefault(); tile.click(); }
});
document.addEventListener("submit", async (e) => {
const form = e.target.closest("[data-form]");
if (!form) return;