935 lines
43 KiB
JavaScript
935 lines
43 KiB
JavaScript
"use strict";
|
||
|
||
// World of Guildhall client. The server is the source of truth; between polls the
|
||
// client only animates bars and cooldowns forward from the last state it saw.
|
||
|
||
const POLL_MS = 3000;
|
||
const FIGHT_POLL_MS = 300; // fights are live: keep everyone's view close
|
||
const ROLE = { tank: "Tank", healer: "Healer", dps: "Damage" };
|
||
const RULES = {
|
||
always: (r) => (r.save ? `whenever it's ready (in a group: only above ${pct(r.save)} mana)` : "whenever it's ready"),
|
||
dot: (r) => (r.save ? `keeps it on the target (in a group: only above ${pct(r.save)} mana)` : "keeps it on the target"),
|
||
heal: (r) => `someone under ${pct(r.pct)}`,
|
||
hot: (r) => `someone under ${pct(r.pct)} without it`,
|
||
shield: (r) => `whoever's being hit, or under ${pct(r.pct)}`,
|
||
self_below: (r) => `you're under ${pct(r.pct)}`,
|
||
taunt: () => "a mob hits someone else",
|
||
aoe: (r) => (r.n > 1 ? `${r.n}+ mobs` : "every fight"),
|
||
interrupt: () => "a mob is casting",
|
||
combo: (r) => `${r.n}+ combo points`,
|
||
cc: () => "an extra mob joins",
|
||
peel: () => "mobs are on you",
|
||
tanking: (r) => `an elite hits you, or you're under ${pct(r.pct)}`,
|
||
danger: (r) => `hit while under ${pct(r.pct)}`,
|
||
buff: () => "keeps it up",
|
||
boss: () => "on elites, rares and bosses",
|
||
};
|
||
|
||
let C = null; // static content
|
||
let S = null; // latest state
|
||
let skew = 0; // server clock minus client clock
|
||
let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "adventure";
|
||
let polledAt = 0;
|
||
let inflight = false;
|
||
let failures = 0;
|
||
let selected = null; // bag item
|
||
let regCls = null;
|
||
let pickReward = {}; // quest id -> reward index
|
||
let seenEv = { id: null, t: -1 };
|
||
let barKey = "";
|
||
let seenChat = Number(remember("seenChat") || 0);
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
|
||
function remember(key, value) {
|
||
try {
|
||
if (value === undefined) return localStorage.getItem("wog." + key);
|
||
localStorage.setItem("wog." + key, value);
|
||
} catch (e) { /* private mode */ }
|
||
return null;
|
||
}
|
||
|
||
function esc(text) {
|
||
return String(text ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
}
|
||
|
||
function pct(x) { return `${Math.round(x * 100)}%`; }
|
||
function now() { return Date.now() / 1000 + skew; }
|
||
|
||
function money(copper) {
|
||
copper = Math.floor(copper || 0);
|
||
const g = Math.floor(copper / 10000), s = Math.floor(copper / 100) % 100, c = copper % 100;
|
||
const parts = [];
|
||
if (g) parts.push(`<span class="g">${g}g</span>`);
|
||
if (s) parts.push(`<span class="s">${s}s</span>`);
|
||
if (c || !parts.length) parts.push(`<span class="c">${c}c</span>`);
|
||
return `<span class="money">${parts.join(" ")}</span>`;
|
||
}
|
||
|
||
function clock(seconds) {
|
||
seconds = Math.max(0, Math.round(seconds));
|
||
if (seconds < 60) return `${seconds}s`;
|
||
const m = Math.floor(seconds / 60), s = seconds % 60;
|
||
if (m < 60) return `${m}:${String(s).padStart(2, "0")}`;
|
||
return `${Math.floor(m / 60)}h ${m % 60}m`;
|
||
}
|
||
|
||
function toast(text, ok = false) {
|
||
const el = $("#toast");
|
||
el.textContent = text;
|
||
el.className = "show" + (ok ? " ok" : "");
|
||
clearTimeout(toast.timer);
|
||
toast.timer = setTimeout(() => (el.className = ""), 2600);
|
||
}
|
||
|
||
function clsColor(cls) { return C.classes[cls]?.color || "#ddd"; }
|
||
function place(id) { return C.places[id]; }
|
||
function specOf(cls) { return C.specs[C.classes[cls].spec]; }
|
||
|
||
// --- server -------------------------------------------------------------
|
||
|
||
async function refresh() {
|
||
if (inflight) return;
|
||
inflight = true;
|
||
try {
|
||
const res = await fetch("/api/state", { cache: "no-store" });
|
||
if (!res.ok) throw new Error(res.status);
|
||
accept(await res.json());
|
||
failures = 0;
|
||
} catch (e) {
|
||
failures++;
|
||
} finally {
|
||
inflight = false;
|
||
polledAt = Date.now();
|
||
}
|
||
}
|
||
|
||
async function api(path, body = {}) {
|
||
try {
|
||
const res = await fetch("/api/" + path, {
|
||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
toast(data.error || "Something went wrong");
|
||
return false;
|
||
}
|
||
accept(data);
|
||
return true;
|
||
} catch (e) {
|
||
toast("Can't reach the server");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function accept(state) {
|
||
if (S && S.version !== state.version) {
|
||
location.reload();
|
||
return;
|
||
}
|
||
skew = state.now - Date.now() / 1000;
|
||
S = state;
|
||
render();
|
||
}
|
||
|
||
// --- rendering ----------------------------------------------------------
|
||
|
||
function render() {
|
||
if (!C || !S) return;
|
||
renderTop();
|
||
renderNav();
|
||
renderMain();
|
||
renderFight();
|
||
renderBar();
|
||
}
|
||
|
||
function myUnit() {
|
||
return S.fight ? S.fight.units.find((u) => u.id === S.uid) : null;
|
||
}
|
||
|
||
function renderTop() {
|
||
const me = S.me;
|
||
if (!me) {
|
||
$("#top").innerHTML = `<div class="inner"><b>World of Guildhall</b></div>`;
|
||
return;
|
||
}
|
||
const cls = C.classes[me.cls];
|
||
const unit = myUnit();
|
||
const hp = unit ? unit.hp : me.hp, max = unit ? unit.max_hp : me.stats.max_hp;
|
||
const res = unit ? unit.res : null;
|
||
const resType = me.stats.res;
|
||
const resPct = res ? res.cur / res.max : (resType === "mana" ? me.mana : resType === "energy" ? 1 : 0);
|
||
const xp = me.xp_next ? me.xp / me.xp_next : 1;
|
||
$("#top").innerHTML = `<div class="inner">
|
||
<div class="row">
|
||
<span class="who" style="color:${cls.color}">${esc(me.name)}</span>
|
||
<span class="muted small">${me.level} ${esc(specOf(me.cls).name)} ${esc(cls.name)}</span>
|
||
<span class="right small">${money(me.copper)}</span>
|
||
</div>
|
||
<div class="minibars">
|
||
${bar(hp, max, hp / max < 0.3 ? "low" : "", `${Math.round(hp)} / ${max}`)}
|
||
${bar(resPct, 1, resType, resType === "mana" ? pct(resPct) : `${Math.round(resPct * 100)} ${resType}`)}
|
||
</div>
|
||
<div class="xpbar" title="${me.xp} / ${me.xp_next} xp"><i style="width:${xp * 100}%"></i></div>
|
||
</div>`;
|
||
}
|
||
|
||
function bar(value, max, cls = "", text = "") {
|
||
const w = max ? Math.max(0, Math.min(1, value / max)) * 100 : 0;
|
||
return `<div class="bar ${cls}"><i style="width:${w}%"></i><span>${text}</span></div>`;
|
||
}
|
||
|
||
function renderNav() {
|
||
const me = S.me;
|
||
if (!me) {
|
||
$("#nav").innerHTML = "";
|
||
return;
|
||
}
|
||
const ready = Object.keys(me.quests).some((q) => questReady(q));
|
||
const newChat = S.chat.some((m) => m.t > seenChat && m.who !== S.uid);
|
||
const tabs = [
|
||
["adventure", "⚔️", "Adventure", false],
|
||
["quests", "📜", "Quests", ready],
|
||
["character", "🧍", "Character", false],
|
||
["group", "👥", "Group", !!S.group?.dungeon],
|
||
["chat", "💬", "Guild", newChat && page !== "chat"],
|
||
];
|
||
$("#nav").innerHTML = tabs.map(([id, icon, label, dot]) =>
|
||
`<button data-page="${id}" class="${page === id ? "on" : ""}"><span class="i">${icon}</span>${label}${dot ? '<span class="dot"></span>' : ""}</button>`).join("");
|
||
}
|
||
|
||
function renderMain() {
|
||
const main = $("#main");
|
||
if (!main.querySelector("#fightbox")) {
|
||
main.innerHTML = `<div id="fightbox"></div><div id="page"></div>`;
|
||
}
|
||
const el = $("#page");
|
||
if (!S.me) {
|
||
// Only redraw when the form itself changes: replacing the name field under
|
||
// a phone's keyboard closes the keyboard.
|
||
const html = registerPage();
|
||
if (el.dataset.html !== html) {
|
||
el.innerHTML = html;
|
||
el.dataset.html = html;
|
||
$("#name").value = remember("draftName") || "";
|
||
}
|
||
return;
|
||
}
|
||
if (page === "chat") {
|
||
seenChat = Math.max(seenChat, ...S.chat.map((m) => m.t), 0);
|
||
remember("seenChat", seenChat);
|
||
}
|
||
// Never redraw under a focused field (the chat box aside): on a phone that
|
||
// closes the keyboard or the open dropdown. The next render catches up.
|
||
const focused = document.activeElement;
|
||
const typing = focused && el.contains(focused) && focused.matches("input, select, textarea");
|
||
if (page === "chat" && el.querySelector("#say")) {
|
||
// The chat box stays put (and keeps the keyboard); only the lists refresh.
|
||
const holder = document.createElement("div");
|
||
holder.innerHTML = chatPage();
|
||
for (const id of ["chatlog", "worldlog"]) {
|
||
const fresh = holder.querySelector("#" + id).innerHTML;
|
||
const old = el.querySelector("#" + id);
|
||
if (old.innerHTML !== fresh) old.innerHTML = fresh;
|
||
}
|
||
return;
|
||
}
|
||
if (typing) return;
|
||
const html = { adventure: adventurePage, quests: questsPage, character: characterPage, group: groupPage, chat: chatPage }[page]();
|
||
if (el.dataset.html !== html) {
|
||
el.innerHTML = html;
|
||
el.dataset.html = html;
|
||
animate();
|
||
}
|
||
}
|
||
|
||
// --- register -----------------------------------------------------------
|
||
|
||
function registerPage() {
|
||
const pick = regCls ? C.classes[regCls] : null;
|
||
const spec = pick ? specOf(regCls) : null;
|
||
return `<div class="card">
|
||
<h2>Create your character</h2>
|
||
<p class="muted small">You wake in Northshire Abbey. The Defias Brotherhood stirs in Westfall.</p>
|
||
<div class="row"><input id="name" placeholder="Name" maxlength="16" class="grow" autocomplete="off" autocapitalize="words"></div>
|
||
<h3>Class</h3>
|
||
<div class="classes">${Object.entries(C.classes).map(([id, c]) => `
|
||
<div class="cls ${regCls === id ? "on" : ""}" data-cls="${id}">
|
||
<span class="ic">${c.icon}</span>
|
||
<b style="color:${c.color}">${esc(c.name)}</b>
|
||
<span class="small muted">${esc(specOf(id).name)} · ${ROLE[specOf(id).role]}</span>
|
||
</div>`).join("")}
|
||
</div>
|
||
${spec ? `<h3>${esc(spec.name)} ${esc(pick.name)} · ${ROLE[spec.role]}</h3>
|
||
<div class="list">${spec.abilities.map((a, i) => abilityRow(a, C.unlock[i])).join("")}</div>` : ""}
|
||
<div class="row" style="margin-top:12px"><button class="btn primary" data-act="register" ${regCls ? "" : "disabled"}>Enter the world</button></div>
|
||
</div>`;
|
||
}
|
||
|
||
function abilityRow(id, level, me) {
|
||
const a = C.abilities[id];
|
||
const known = !me || me.known.includes(id);
|
||
const manual = me && me.manual.includes(id);
|
||
const owner = Object.values(C.specs).find((sp) => sp.abilities.includes(id));
|
||
const cost = a.cost ? (a.cost < 1 ? `${Math.round(a.cost * 100)}% mana` : `${a.cost} ${C.classes[owner.cls].res}`) : "";
|
||
const facts = [cost, a.cast ? `${a.cast}s cast` : "instant", a.cd ? `${a.cd}s cooldown (auto ${Math.round(a.cd * C.auto_cd)}s)` : ""].filter(Boolean);
|
||
return `<div class="item-row" style="cursor:default; ${known ? "" : "opacity:.5"}">
|
||
<span style="font-size:22px">${a.icon}</span>
|
||
<div class="grow">
|
||
<b>${esc(a.name)}</b> <span class="small muted">${known ? "" : `level ${level}`}</span>
|
||
<div class="tip">${esc(a.tip)}</div>
|
||
<div class="tip">${facts.join(" · ")}</div>
|
||
<div class="tip">Auto: ${RULES[a.auto.r](a.auto)}</div>
|
||
</div>
|
||
${me ? `<button class="btn small ${manual ? "on" : ""}" data-act="manual" data-id="${id}" data-on="${manual ? 0 : 1}">${manual ? "You" : "Auto"}</button>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
// --- adventure ----------------------------------------------------------
|
||
|
||
function adventurePage() {
|
||
const me = S.me;
|
||
const here = place(me.place);
|
||
const act = me.activity;
|
||
const parts = [statusCard()];
|
||
if (!act || act.kind !== "dungeon") {
|
||
parts.push(placeCard(here));
|
||
}
|
||
parts.push(logCard());
|
||
if (S.dev) parts.push(devCard());
|
||
return parts.join("");
|
||
}
|
||
|
||
function statusCard() {
|
||
const me = S.me;
|
||
const act = me.activity;
|
||
const t = now();
|
||
if (me.dead_until > t) {
|
||
return `<div class="card"><b>💀 You died.</b> <span class="muted">Running back · <span data-until="${me.dead_until}"></span></span></div>`;
|
||
}
|
||
if (!act) return "";
|
||
if (act.kind === "travel") {
|
||
return `<div class="card">
|
||
<div class="row"><b>🐎 On the road to ${esc(place(act.to).name)}</b><span class="right muted small" data-until="${act.end}"></span></div>
|
||
<div class="bar road" style="margin-top:6px" data-from="${act.start}" data-to="${act.end}"><i></i><span></span></div>
|
||
<div class="small muted" style="margin-top:4px">${act.path.map((p) => esc(place(p).name)).join(" → ")}</div>
|
||
</div>`;
|
||
}
|
||
if (act.kind === "hunt") {
|
||
const mob = C.mobs[act.hunt];
|
||
const waiting = !S.fight && me.next_pull > t;
|
||
return `<div class="card"><div class="row">
|
||
<b>🎯 Hunting ${esc(mob.name)}</b>
|
||
<span class="muted small">${waiting ? `resting · <span data-until="${me.next_pull}"></span>` : ""}</span>
|
||
<button class="btn small right" data-act="stop">Stop</button></div>
|
||
</div>`;
|
||
}
|
||
if (act.kind === "dungeon") return dungeonCard();
|
||
return "";
|
||
}
|
||
|
||
function dungeonCard() {
|
||
const g = S.group;
|
||
if (!g || !g.dungeon) return "";
|
||
const d = g.dungeon;
|
||
const spec = C.dungeons[d.id];
|
||
const t = now();
|
||
const leader = g.leader === S.uid;
|
||
const pulls = spec.pulls.map((p, i) => {
|
||
const mark = i < d.pull ? "✅" : i === d.pull && d.state !== "done" ? "➡️" : "▫️";
|
||
return `<div class="small ${i < d.pull ? "muted" : ""}">${mark} ${p.boss ? "<b>" : ""}${esc(p.name)}${p.boss ? "</b>" : ""}
|
||
<span class="muted">${i === d.pull && d.state !== "done" ? "· " + p.mobs.map((m) => esc(C.mobs[m].name)).join(", ") : ""}</span></div>`;
|
||
}).join("");
|
||
let status = "";
|
||
if (d.state === "rest") {
|
||
status = `<div class="row"><span>${d.last && !d.last.won ? "💀 Wiped. " : ""}Next pull in <b data-until="${d.next}"></b></span>
|
||
${leader ? `<button class="btn primary small right" data-act="pull">Pull now</button>` : `<span class="right small muted">the leader can pull early</span>`}</div>`;
|
||
} else if (d.state === "done") {
|
||
status = `<div><b>🏆 ${esc(spec.name)} cleared!</b></div>`;
|
||
} else {
|
||
status = `<div class="muted small">Fighting: ${esc(spec.pulls[d.pull].name)}</div>`;
|
||
}
|
||
return `<div class="card">
|
||
<div class="row"><h2 style="margin:0">${esc(spec.name)}</h2><span class="muted small">wipes ${d.wipes}</span>
|
||
<button class="btn small danger right" data-act="leave-dungeon">Leave</button></div>
|
||
<div style="margin:8px 0">${status}</div>
|
||
${pulls}
|
||
</div>`;
|
||
}
|
||
|
||
function placeCard(here) {
|
||
const me = S.me;
|
||
const act = me.activity;
|
||
const travelling = act && act.kind === "travel";
|
||
const hunting = act && act.kind === "hunt" ? act.hunt : null;
|
||
const hunts = Object.entries(here.hunts || {}).map(([id, pulls]) => {
|
||
const mob = C.mobs[id];
|
||
const lv = mob.lv[0] === mob.lv[1] ? mob.lv[0] : `${mob.lv[0]}-${mob.lv[1]}`;
|
||
const others = [...new Set(pulls.flat())].filter((m) => m !== id).map((m) => C.mobs[m].name);
|
||
const hot = Object.keys(me.quests).some((q) => C.quests[q].obj.some((o) => o.kill === id || (o.from || []).includes(id)));
|
||
return `<div class="item-row" style="cursor:default">
|
||
<div class="grow"><b class="${mob.rare ? "q-rare" : ""}">${esc(mob.name)}</b> <span class="small muted">lv ${lv}${others.length ? " · with " + esc(others.join(", ")) : ""}</span>
|
||
${hot ? '<span class="qmark" title="Quest target">!</span>' : ""}</div>
|
||
${hunting === id ? `<button class="btn small on" data-act="stop">Hunting</button>`
|
||
: `<button class="btn small" data-act="hunt" data-id="${id}" ${travelling ? "disabled" : ""}>Hunt</button>`}
|
||
</div>`;
|
||
}).join("");
|
||
const offers = me.available.map((q) => questOffer(q)).join("");
|
||
const turnIns = Object.keys(me.quests).filter((q) => (C.quests[q].ender || C.quests[q].giver) === me.place && questReady(q))
|
||
.map((q) => questTurnIn(q)).join("");
|
||
const dungeon = here.dungeon ? dungeonEntrance(here.dungeon) : "";
|
||
const junk = me.bags.filter((b) => b.q === "poor");
|
||
const junkValue = junk.reduce((sum, b) => sum + b.value * b.qty, 0);
|
||
const vendor = here.hub ? `<div class="row" style="margin-top:8px"><span class="muted small">🪙 Vendor</span>
|
||
<button class="btn small right" data-act="sell-junk" ${junk.length ? "" : "disabled"}>Sell junk ${junk.length ? money(junkValue) : ""}</button></div>` : "";
|
||
const roads = Object.entries(here.links).map(([to, minutes]) =>
|
||
`<button class="btn small" data-act="travel" data-id="${to}" ${travelling ? "disabled" : ""}>${esc(place(to).name)} <span class="muted">${minutes}m</span></button>`).join("");
|
||
const far = Object.entries(C.places).filter(([id]) => id !== me.place && !here.links[id])
|
||
.map(([id, p]) => `<option value="${id}">${esc(p.name)} (${esc(p.zone)}, ${p.lv[0]}-${p.lv[1]})</option>`).join("");
|
||
return `<div class="card">
|
||
<div class="row"><h2 style="margin:0">${esc(here.name)}</h2><span class="muted small">${esc(here.zone)} · ${here.lv[0]}-${here.lv[1]}</span></div>
|
||
${hunts ? `<h3>Hunt</h3><div class="list">${hunts}</div>` : ""}
|
||
${turnIns ? `<h3>Turn in</h3><div class="list">${turnIns}</div>` : ""}
|
||
${offers ? `<h3>Quests</h3><div class="list">${offers}</div>` : ""}
|
||
${dungeon}
|
||
${vendor}
|
||
<h3>Roads</h3>
|
||
<div class="row wrap">${roads}</div>
|
||
<div class="row" style="margin-top:8px"><select id="far" class="grow" ${travelling ? "disabled" : ""}><option value="">Travel further…</option>${far}</select>
|
||
<button class="btn small" data-act="travel-far" ${travelling ? "disabled" : ""}>Go</button></div>
|
||
</div>`;
|
||
}
|
||
|
||
function dungeonEntrance(id) {
|
||
const spec = C.dungeons[id];
|
||
const g = S.group;
|
||
let action;
|
||
if (!g) action = `<span class="small muted">Form a group (👥) to go in</span>`;
|
||
else if (g.leader !== S.uid) action = `<span class="small muted">Waiting for the leader</span>`;
|
||
else action = `<button class="btn primary small" data-act="enter">Enter</button>`;
|
||
return `<h3>Dungeon</h3><div class="item-row" style="cursor:default">
|
||
<div class="grow"><b>🕳️ ${esc(spec.name)}</b> <span class="small muted">lv ${spec.lv[0]}-${spec.lv[1]} · ${spec.size} players · ${spec.pulls.filter((p) => p.boss).length} bosses</span></div>
|
||
${action}</div>`;
|
||
}
|
||
|
||
function questOffer(qid) {
|
||
const q = C.quests[qid];
|
||
const tooHigh = q.lv > S.me.level + 3;
|
||
return `<div class="quest">
|
||
<div class="row"><span class="qmark">!</span><b class="grow">${esc(q.name)}</b><span class="small ${tooHigh ? "stat-down" : "muted"}">lv ${q.lv}</span></div>
|
||
<div class="small muted" style="margin:4px 0">${esc(q.npc)}: “${esc(q.text)}”</div>
|
||
${q.obj.map((o) => `<div class="obj">· ${objText(o)}</div>`).join("")}
|
||
<div class="row" style="margin-top:6px"><span class="small muted">${q.xp} xp · ${money(q.copper)}${q.choice ? " · a reward of your choice" : ""}</span>
|
||
<button class="btn small primary right" data-act="accept" data-id="${qid}">Accept</button></div>
|
||
</div>`;
|
||
}
|
||
|
||
function questTurnIn(qid) {
|
||
const q = C.quests[qid];
|
||
const choices = (q.choice || []).map((id, i) => {
|
||
const it = C.items[id];
|
||
const on = pickReward[qid] === i;
|
||
const fit = fitsMe(it);
|
||
return `<div class="item-row ${on ? "sel" : ""}" data-act="pick-reward" data-id="${qid}" data-i="${i}" style="${on ? "border-color:var(--gold)" : ""}">
|
||
<span class="q-${it.q}">${esc(it.name)}</span><span class="small muted">${itemLine(it)}</span>
|
||
${fit ? "" : '<span class="small stat-down right">can\'t use</span>'}</div>`;
|
||
}).join("");
|
||
return `<div class="quest ready">
|
||
<div class="row"><span class="qmark">?</span><b class="grow">${esc(q.name)}</b></div>
|
||
<div class="small muted">${q.xp} xp · ${money(q.copper)}</div>
|
||
${choices ? `<div class="list" style="margin-top:6px">${choices}</div>` : ""}
|
||
<div class="row" style="margin-top:6px"><button class="btn small primary right" data-act="turn-in" data-id="${qid}">Complete</button></div>
|
||
</div>`;
|
||
}
|
||
|
||
function objText(o, have) {
|
||
const need = o.n || 1;
|
||
const count = have === undefined ? "" : ` ${have} / ${need}`;
|
||
if (o.kill) return `Slay ${esc(C.mobs[o.kill].name)}${count || (need > 1 ? ` ×${need}` : "")}`;
|
||
if (o.loot) return `${esc(o.loot)}${count || ` ×${need}`} <span class="muted">from ${o.from.map((m) => esc(C.mobs[m].name)).join(", ")}</span>`;
|
||
if (o.visit) return `Go to ${esc(place(o.visit).name)}${count}`;
|
||
return "";
|
||
}
|
||
|
||
function questReady(qid) {
|
||
const progress = S.me.quests[qid];
|
||
return C.quests[qid].obj.every((o, i) => progress[i] >= (o.n || 1));
|
||
}
|
||
|
||
function logCard() {
|
||
const lines = S.me.log.slice(-12).reverse();
|
||
if (!lines.length) return "";
|
||
return `<div class="card"><h3 style="margin-top:0">Lately</h3><div class="log" style="max-height:none; flex-direction:column">
|
||
${lines.map((l) => `<div class="${l.kind}">${esc(l.text)} <span class="small">· ${ago(l.t)}</span></div>`).join("")}</div></div>`;
|
||
}
|
||
|
||
function ago(t) {
|
||
const s = now() - t;
|
||
if (s < 60) return "just now";
|
||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||
return `${Math.floor(s / 86400)}d ago`;
|
||
}
|
||
|
||
function devCard() {
|
||
const users = ["fred", "kayla", "apollon", "tester"];
|
||
const current = (document.cookie.match(/wog_user=([^;]+)/) || [])[1] || "fred";
|
||
return `<div class="card devbar">
|
||
<b>Dev</b>
|
||
<select id="dev-user">${users.map((u) => `<option ${u === current ? "selected" : ""}>${u}</option>`).join("")}</select>
|
||
<input id="dev-level" type="number" min="1" max="20" value="${S.me.level}" style="width:56px"><button class="btn small" data-act="dev-level">Set level + gear</button>
|
||
<button class="btn small" data-act="dev-advance" data-m="10">+10m</button>
|
||
<button class="btn small" data-act="dev-advance" data-m="60">+1h</button>
|
||
<select id="dev-place">${Object.entries(C.places).map(([id, p]) => `<option value="${id}" ${id === S.me.place ? "selected" : ""}>${esc(p.name)}</option>`).join("")}</select>
|
||
<button class="btn small" data-act="dev-teleport">Teleport</button>
|
||
</div>`;
|
||
}
|
||
|
||
// --- quests -------------------------------------------------------------
|
||
|
||
function questsPage() {
|
||
const me = S.me;
|
||
const ids = Object.keys(me.quests);
|
||
if (!ids.length) {
|
||
return `<div class="card"><h2>Quest log</h2><p class="muted">No quests. Look for <span class="qmark">!</span> at the places you visit.</p>
|
||
<p class="small muted">${me.done.length} completed.</p></div>`;
|
||
}
|
||
return `<div class="card"><h2>Quest log <span class="muted small">${ids.length} / 12</span></h2><div class="list">
|
||
${ids.map((qid) => {
|
||
const q = C.quests[qid];
|
||
const ready = questReady(qid);
|
||
const ender = q.ender || q.giver;
|
||
return `<div class="quest ${ready ? "ready" : ""}">
|
||
<div class="row"><b class="grow">${ready ? '<span class="qmark">?</span> ' : ""}${esc(q.name)}</b><span class="small muted">lv ${q.lv}</span></div>
|
||
${q.obj.map((o, i) => `<div class="obj ${me.quests[qid][i] >= (o.n || 1) ? "done" : ""}">· ${objText(o, me.quests[qid][i])}</div>`).join("")}
|
||
<div class="row" style="margin-top:6px"><span class="small muted">${ready ? "Turn in" : "Ends"} at ${esc(place(ender).name)}${q.ender_npc ? ` (${esc(q.ender_npc)})` : ` (${esc(q.npc)})`}</span>
|
||
${ready && me.place !== ender ? `<button class="btn small" data-act="travel" data-id="${ender}">Go</button>` : ""}
|
||
<button class="btn small danger right" data-act="abandon" data-id="${qid}">Abandon</button></div>
|
||
</div>`;
|
||
}).join("")}
|
||
</div><p class="small muted">${me.done.length} completed.</p></div>`;
|
||
}
|
||
|
||
// --- character ----------------------------------------------------------
|
||
|
||
function itemLine(it) {
|
||
if (!it.slot) return it.value ? `sells ${money(it.value)}` : "";
|
||
const bits = [];
|
||
if (it.dps) bits.push(`${it.dps} dps`);
|
||
if (it.armor) bits.push(`${it.armor} armor`);
|
||
if (it.sta) bits.push(`+${it.sta} sta`);
|
||
if (it.power) bits.push(`+${it.power} power`);
|
||
return `${esc(C.slot_names[it.slot])} · ${esc(it.kind)} · ${bits.join(", ")}`;
|
||
}
|
||
|
||
function fitsMe(it) {
|
||
if (!it.slot) return false;
|
||
const cls = C.classes[S.me.cls];
|
||
if (it.slot === "weapon") return cls.weapons.includes(it.kind);
|
||
const rank = { cloth: 0, leather: 1, mail: 2 };
|
||
return rank[it.kind] <= rank[cls.armour];
|
||
}
|
||
|
||
function compare(it) {
|
||
const worn = S.me.equip[it.slot];
|
||
const diff = (key) => (it[key] || 0) - ((worn && worn[key]) || 0);
|
||
const parts = ["sta", "power", "armor", "dps"].map((k) => {
|
||
const d = Math.round(diff(k) * 10) / 10;
|
||
if (!d) return "";
|
||
return `<span class="${d > 0 ? "stat-up" : "stat-down"}">${d > 0 ? "+" : ""}${d} ${k}</span>`;
|
||
}).filter(Boolean);
|
||
return parts.length ? `vs worn: ${parts.join(" ")}` : "same as worn";
|
||
}
|
||
|
||
function characterPage() {
|
||
const me = S.me;
|
||
const st = me.stats;
|
||
const spec = specOf(me.cls);
|
||
const slots = C.slots.map((slot) => {
|
||
const it = me.equip[slot];
|
||
return `<div class="slot" ${it ? `data-act="unequip" data-id="${slot}"` : ""}><span class="k">${esc(C.slot_names[slot])}</span>
|
||
<span>${it ? `<span class="q-${it.q}">${esc(it.name)}</span> <span class="small muted">${itemLine(it).split(" · ").slice(2).join(" · ")}</span>` : '<span class="muted">—</span>'}</span></div>`;
|
||
}).join("");
|
||
const bags = me.bags.map((it) => {
|
||
const on = selected === it.id;
|
||
const atHub = place(me.place).hub;
|
||
return `<div class="item-row ${on ? "sel" : ""}" data-act="select" data-id="${esc(it.id)}">
|
||
<div class="grow"><span class="q-${it.q}">${esc(it.name)}</span>${it.qty > 1 ? ` <span class="muted">×${it.qty}</span>` : ""}
|
||
<div class="tip">${itemLine(it)}${it.req > me.level ? ` · <span class="stat-down">requires ${it.req}</span>` : ""}</div>
|
||
${on && it.slot ? `<div class="tip">${compare(it)}</div>` : ""}
|
||
${on ? `<div class="row" style="margin-top:6px">
|
||
${it.slot ? `<button class="btn small primary" data-act="equip" data-id="${esc(it.id)}" ${it.fit ? "disabled" : ""}>${it.fit ? esc(it.fit) : "Equip"}</button>` : ""}
|
||
<button class="btn small" data-act="sell" data-id="${esc(it.id)}" ${atHub ? "" : "disabled"}>${atHub ? `Sell ${money(it.value)}` : "Sell in town"}</button></div>` : ""}
|
||
</div></div>`;
|
||
}).join("");
|
||
return `<div class="card">
|
||
<h2 style="color:${clsColor(me.cls)}">${esc(me.name)} <span class="muted small">level ${me.level} ${esc(spec.name)} ${esc(C.classes[me.cls].name)} · ${ROLE[spec.role]}</span></h2>
|
||
<div class="statgrid">
|
||
<div><b>${st.max_hp}</b><span>health</span></div>
|
||
<div><b>${st.power}</b><span>power</span></div>
|
||
<div><b>${st.armor}</b><span>armor</span></div>
|
||
<div><b>${st.res_max}</b><span>${st.res}</span></div>
|
||
<div><b>${st.dps}</b><span>weapon dps</span></div>
|
||
<div><b>${me.xp_next ? `${me.xp} / ${me.xp_next}` : "max"}</b><span>xp</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="card"><h2>Abilities</h2>
|
||
<p class="tip">Abilities on <b>Auto</b> cast themselves when their rule says so, on a ${C.auto_cd}× cooldown. Set to <b>You</b> to press them yourself on the shorter cooldown. When you look away for ${C.watch}s, yours go back to auto.</p>
|
||
<div class="list">${spec.abilities.map((a, i) => abilityRow(a, C.unlock[i], me)).join("")}</div></div>
|
||
<div class="card"><h2>Equipment</h2><div class="list">${slots}</div></div>
|
||
<div class="card"><h2>Bags</h2>${bags ? `<div class="list">${bags}</div>` : '<p class="muted">Empty.</p>'}</div>`;
|
||
}
|
||
|
||
// --- group --------------------------------------------------------------
|
||
|
||
function groupPage() {
|
||
const g = S.group;
|
||
let mine;
|
||
if (g) {
|
||
const here = place(S.me.place);
|
||
mine = `<div class="card"><h2>Your group</h2><div class="list">
|
||
${g.members.map((m) => `<div class="item-row" style="cursor:default">
|
||
<b style="color:${clsColor(m.cls)}">${esc(m.name)}</b>${m.id === g.leader ? " 👑" : ""}
|
||
<span class="small muted grow">${m.level} ${esc(C.classes[m.cls].name)} · ${m.inside ? "inside" : esc(place(m.place).name)}${m.busy ? ` · ${esc(m.busy.replace("You're ", ""))}` : ""}</span>
|
||
<div style="width:80px">${bar(m.hp, m.max_hp, "thin")}</div></div>`).join("")}
|
||
</div>
|
||
<div class="row" style="margin-top:8px">
|
||
${!g.dungeon && here.dungeon && g.leader === S.uid ? `<button class="btn primary small" data-act="enter">Enter ${esc(C.dungeons[here.dungeon].name)}</button>` : ""}
|
||
${!g.dungeon && !here.dungeon ? `<span class="small muted">Meet at Moonbrook for the Deadmines</span>` : ""}
|
||
<button class="btn small danger right" data-act="leave-group">Leave group</button></div></div>
|
||
${g.dungeon && !(S.me.activity && S.me.activity.kind === "dungeon") ? "" : ""}`;
|
||
} else {
|
||
const open = S.groups.filter((x) => !x.inside && x.size < 5);
|
||
mine = `<div class="card"><h2>Group</h2>
|
||
<p class="tip">Dungeons take a group. Meet at the entrance; the leader leads you in.</p>
|
||
<div class="list">${open.map((x) => `<div class="item-row" style="cursor:default"><span class="grow">${esc(x.leader)}'s group <span class="muted small">${x.size} / 5</span></span>
|
||
<button class="btn small" data-act="join" data-id="${x.id}">Join</button></div>`).join("")}</div>
|
||
<div class="row" style="margin-top:8px"><button class="btn primary small" data-act="create-group">Start a group</button></div></div>`;
|
||
}
|
||
const people = S.players.map((p) => `<div class="item-row" style="cursor:default">
|
||
<span>${p.online ? "🟢" : "⚪"}</span><b style="color:${clsColor(p.cls)}">${esc(p.name)}</b>
|
||
<span class="small muted grow">${p.level} ${esc(C.classes[p.cls].name)} · ${esc(place(p.place).name)}${p.doing === "hunt" ? ` · hunting ${esc(C.mobs[p.hunt].name)}` : p.doing === "dungeon" ? " · in a dungeon" : p.doing === "travel" ? " · travelling" : ""}</span></div>`).join("");
|
||
return `${mine}<div class="card"><h2>Adventurers</h2><div class="list">${people}</div></div>`;
|
||
}
|
||
|
||
// --- chat ---------------------------------------------------------------
|
||
|
||
function chatPage() {
|
||
const names = Object.fromEntries(S.players.map((p) => [p.id, p]));
|
||
const msgs = S.chat.slice().reverse().map((m) => {
|
||
const p = names[m.who];
|
||
return `<div class="msg"><b style="color:${p ? clsColor(p.cls) : "#ddd"}">${esc(p ? p.name : m.who)}</b>${esc(m.text)} <span class="small muted">${ago(m.t)}</span></div>`;
|
||
}).join("");
|
||
const events = S.log.slice().reverse().map((l) => `<div class="${l.kind}">${esc(l.text)} <span class="small">· ${ago(l.t)}</span></div>`).join("");
|
||
return `<div class="card"><h2>Guild chat</h2>
|
||
<div class="row"><input id="say" class="grow" placeholder="Say something" maxlength="300"><button class="btn primary small" data-act="say">Send</button></div>
|
||
<div class="chat" id="chatlog" style="margin-top:8px">${msgs || '<span class="muted">Quiet in here.</span>'}</div></div>
|
||
<div class="card"><h2>World</h2><div class="log" id="worldlog" style="max-height:none; flex-direction:column">${events}</div></div>`;
|
||
}
|
||
|
||
// --- the fight ----------------------------------------------------------
|
||
|
||
function fightTime() {
|
||
return S.fight ? now() - S.fight.at : 0;
|
||
}
|
||
|
||
function renderFight() {
|
||
const box = $("#fightbox");
|
||
if (!box) return;
|
||
const f = S.fight;
|
||
box.style.display = page === "adventure" ? "" : "none";
|
||
if (!f) {
|
||
if (box.dataset.id) {
|
||
box.innerHTML = "";
|
||
box.dataset.id = "";
|
||
}
|
||
return;
|
||
}
|
||
if (box.dataset.id !== f.id) {
|
||
box.dataset.id = f.id;
|
||
box.innerHTML = `<div class="card"><div class="fight-head"><h2 style="margin:0" id="f-name"></h2><span class="muted small" id="f-state"></span></div>
|
||
<div class="side" id="f-enemy" style="margin-top:8px"></div><div class="vs">— vs —</div><div class="side" id="f-party"></div>
|
||
<div class="log" id="f-log"></div></div>`;
|
||
seenEv = { id: f.id, t: Math.max(-1, ...f.ev.map((e) => e.t)) - 0.5 };
|
||
}
|
||
$("#f-name").textContent = f.name;
|
||
$("#f-state").textContent = f.state === "on" ? clock(f.t) : f.state === "won" ? "Victory" : "Defeat";
|
||
const me = myUnit();
|
||
const byId = Object.fromEntries(f.units.map((u) => [u.id, u]));
|
||
for (const side of ["enemy", "party"]) {
|
||
const holder = $(`#f-${side}`);
|
||
const units = f.units.filter((u) => u.side === side);
|
||
const keep = new Set(units.map((u) => u.id));
|
||
for (const el of [...holder.children]) if (!keep.has(el.dataset.u)) el.remove();
|
||
for (const u of units) {
|
||
let el = holder.querySelector(`[data-u="${CSS.escape(u.id)}"]`);
|
||
if (!el) {
|
||
el = document.createElement("div");
|
||
el.className = "frame";
|
||
el.dataset.u = u.id;
|
||
el.dataset.act = "target";
|
||
el.innerHTML = `<div class="body"></div><div class="floats"></div>`;
|
||
holder.appendChild(el);
|
||
}
|
||
el.classList.toggle("dead", !!u.dead);
|
||
el.classList.toggle("target", !!me && me.target === u.id && side === "enemy");
|
||
el.classList.toggle("ftarget", !!me && me.ftarget === u.id && side === "party");
|
||
const html = frameHtml(u, byId, me);
|
||
const body = el.querySelector(".body");
|
||
if (body.dataset.html !== html) {
|
||
body.innerHTML = html;
|
||
body.dataset.html = html;
|
||
}
|
||
}
|
||
}
|
||
$("#f-log").innerHTML = f.log.slice().reverse().map((l) => `<div class="${l.kind}">${esc(l.text)}</div>`).join("");
|
||
floats(f);
|
||
}
|
||
|
||
function frameHtml(u, byId, me) {
|
||
const isPlayer = u.kind === "player";
|
||
const color = isPlayer ? clsColor(u.cls) : u.side === "enemy" ? "#f0b8a8" : "#cfe3c0";
|
||
const tags = [u.boss ? '<span class="tag boss">Boss</span>' : u.elite ? '<span class="tag">Elite</span>' : "", u.rare ? '<span class="tag">Rare</span>' : ""].join("");
|
||
const low = u.hp / u.max_hp < 0.3;
|
||
let html = `<div class="row"><span class="name" style="color:${color}">${u.icon ? u.icon + " " : ""}${esc(u.name)}</span><span class="lv">${u.level}</span>${tags}</div>`;
|
||
html += bar(u.hp, u.max_hp, low ? "low" : "", u.dead ? "dead" : `${u.hp} / ${u.max_hp}`);
|
||
if (u.res && u.res.type) html += `<div class="bar thin ${u.res.type}" style="margin-top:2px"><i style="width:${(u.res.cur / u.res.max) * 100}%"></i></div>`;
|
||
if (u.cast) {
|
||
const target = byId[u.cast.target];
|
||
html += `<div class="bar cast ${u.side === "enemy" && !u.cast.interrupt ? "safe" : ""}" data-start="${u.cast.start}" data-end="${u.cast.end}">
|
||
<i style="width:0"></i><span>${u.cast.icon} ${esc(u.cast.name)}${target && target.id !== u.id ? " → " + esc(target.name) : ""}</span></div>`;
|
||
}
|
||
const auras = u.auras.map((a) => `<span class="${a.mine === S.uid ? "mine" : ""}" title="${esc(a.name)}">${a.icon}</span>`).join("");
|
||
html += `<div class="auras">${auras}${u.combo ? `<span title="Combo points">${"🔸".repeat(u.combo)}</span>` : ""}${u.stun ? "<span title='Stunned'>💫</span>" : ""}${u.root ? "<span title='Frozen'>🧊</span>" : ""}</div>`;
|
||
if (u.side === "enemy" && !u.dead) {
|
||
const on = byId[u.target];
|
||
const safe = on && (on.role === "tank" || on.kind === "pet");
|
||
const mine = me && (u.threat || []).find(([id]) => id === S.uid);
|
||
html += `<div class="on-whom">${on ? `→ <b class="${safe ? "ok" : ""}">${esc(on.name)}</b>` : ""}${mine ? ` · your threat ${pct(mine[1])}` : ""}</div>`;
|
||
}
|
||
if (u.cc) html += `<span class="cc">${u.cc}</span>`;
|
||
return html;
|
||
}
|
||
|
||
function floats(f) {
|
||
for (const e of f.ev) {
|
||
if (e.t <= seenEv.t) continue;
|
||
seenEv.t = Math.max(seenEv.t, e.t);
|
||
const frame = document.querySelector(`[data-u="${CSS.escape(e.u)}"] .floats`);
|
||
if (!frame) continue;
|
||
const unit = f.units.find((u) => u.id === e.u);
|
||
let kind = e.k, text = String(e.n);
|
||
if (kind === "heal") text = "+" + e.n;
|
||
else if (kind === "interrupt") text = "Interrupted!";
|
||
else if (kind === "oom") text = "Not enough mana";
|
||
else if (kind === "absorb") text = "Absorbed";
|
||
else if (unit && unit.side === "party") { kind = "taken"; text = "-" + e.n; }
|
||
if (kind === "crit") text += "!";
|
||
const el = document.createElement("div");
|
||
el.className = `float ${kind}`;
|
||
el.textContent = text;
|
||
el.style.left = `${35 + Math.random() * 30}%`;
|
||
frame.appendChild(el);
|
||
setTimeout(() => el.remove(), 1200);
|
||
}
|
||
}
|
||
|
||
function renderBar() {
|
||
const el = $("#bar");
|
||
const me = myUnit();
|
||
const show = !!(S.me && me && !me.dead && S.fight.state === "on");
|
||
el.classList.toggle("show", show);
|
||
document.documentElement.style.setProperty("--bar-h", show ? "112px" : "0px");
|
||
if (!show) {
|
||
barKey = "";
|
||
return;
|
||
}
|
||
const spec = specOf(S.me.cls);
|
||
const key = spec.abilities.map((a) => `${a}:${me.abilities.includes(a)}:${S.me.manual.includes(a)}`).join("|");
|
||
if (key !== barKey) {
|
||
barKey = key;
|
||
el.innerHTML = `<div class="inner"><div id="mycast"></div><div class="abilities">${spec.abilities.map((id, i) => {
|
||
const a = C.abilities[id];
|
||
const known = me.abilities.includes(id);
|
||
const manual = S.me.manual.includes(id);
|
||
return `<div class="ab ${known ? (manual ? "manual" : "auto") : "locked"}" data-ab="${id}">
|
||
<span class="icon">${a.icon}</span><span class="label">${known ? esc(a.name) : `level ${C.unlock[i]}`}</span>
|
||
${known ? `<span class="mode" data-mode="${id}">${manual ? "YOU" : "AUTO"}</span>` : ""}
|
||
<span class="gcd"></span></div>`;
|
||
}).join("")}</div>
|
||
<div class="bar-hint">Tap a frame to target it · tap AUTO / YOU to switch</div></div>`;
|
||
}
|
||
animate();
|
||
}
|
||
|
||
// Moves cast bars, cooldowns and travel along between polls.
|
||
function animate() {
|
||
if (!S) return;
|
||
const t = fightTime();
|
||
for (const el of document.querySelectorAll(".bar.cast[data-start]")) {
|
||
const start = Number(el.dataset.start), end = Number(el.dataset.end);
|
||
el.firstElementChild.style.width = `${Math.min(1, Math.max(0, (t - start) / (end - start))) * 100}%`;
|
||
}
|
||
for (const el of document.querySelectorAll(".bar.road[data-from]")) {
|
||
const from = Number(el.dataset.from), to = Number(el.dataset.to);
|
||
el.firstElementChild.style.width = `${Math.min(1, (now() - from) / (to - from)) * 100}%`;
|
||
}
|
||
for (const el of document.querySelectorAll("[data-until]")) {
|
||
el.textContent = clock(Number(el.dataset.until) - now());
|
||
}
|
||
const me = myUnit();
|
||
if (!me || !$("#bar").classList.contains("show")) return;
|
||
const cast = $("#mycast");
|
||
if (cast) {
|
||
const html = me.cast ? `<div class="bar cast casting" data-start="${me.cast.start}" data-end="${me.cast.end}"><i style="width:${Math.min(1, (t - me.cast.start) / (me.cast.end - me.cast.start)) * 100}%"></i><span>${me.cast.icon} ${esc(me.cast.name)}</span></div>` : "";
|
||
if (cast.dataset.html !== html) { cast.innerHTML = html; cast.dataset.html = html; }
|
||
}
|
||
const gcdLeft = Math.max(0, (me.busy || 0) - t);
|
||
for (const el of document.querySelectorAll(".ab[data-ab]")) {
|
||
const id = el.dataset.ab;
|
||
const a = C.abilities[id];
|
||
const ready = (me.cds || {})[id] || 0;
|
||
const left = ready - t;
|
||
let cd = el.querySelector(".cdm");
|
||
if (left > 0.05) {
|
||
if (!cd) {
|
||
cd = document.createElement("span");
|
||
cd.className = "cdm";
|
||
el.appendChild(cd);
|
||
}
|
||
cd.textContent = left >= 10 ? Math.ceil(left) : left.toFixed(1);
|
||
} else if (cd) cd.remove();
|
||
const cost = a.cost ? (a.cost < 1 ? a.cost * me.res.max : a.cost) : 0;
|
||
el.classList.toggle("nores", !!(cost && me.res.cur < cost));
|
||
el.classList.toggle("queued", me.queued === id);
|
||
const gcd = el.querySelector(".gcd");
|
||
if (gcd) gcd.style.width = a.offgcd ? "0" : `${Math.min(1, gcdLeft / 1.5) * 100}%`;
|
||
}
|
||
}
|
||
|
||
// --- input --------------------------------------------------------------
|
||
|
||
document.addEventListener("pointerdown", (ev) => {
|
||
const mode = ev.target.closest("[data-mode]");
|
||
if (mode) {
|
||
ev.preventDefault();
|
||
const id = mode.dataset.mode;
|
||
api("manual", { id, on: !S.me.manual.includes(id) });
|
||
return;
|
||
}
|
||
const ab = ev.target.closest(".ab[data-ab]");
|
||
if (ab) {
|
||
ev.preventDefault();
|
||
const id = ab.dataset.ab;
|
||
if (ab.classList.contains("locked")) return;
|
||
if (!S.me.manual.includes(id)) {
|
||
toast("On auto. Tap AUTO to take it yourself.");
|
||
return;
|
||
}
|
||
ab.classList.add("queued");
|
||
api("press", { id });
|
||
return;
|
||
}
|
||
const frame = ev.target.closest("[data-act=target]");
|
||
if (frame) {
|
||
ev.preventDefault();
|
||
api("target", { id: frame.dataset.u });
|
||
}
|
||
});
|
||
|
||
document.addEventListener("click", async (ev) => {
|
||
const nav = ev.target.closest("[data-page]");
|
||
if (nav) {
|
||
page = nav.dataset.page;
|
||
remember("page", page);
|
||
render();
|
||
window.scrollTo(0, 0);
|
||
return;
|
||
}
|
||
const cls = ev.target.closest("[data-cls]");
|
||
if (cls) {
|
||
remember("draftName", $("#name")?.value || "");
|
||
regCls = cls.dataset.cls;
|
||
document.activeElement?.blur();
|
||
render();
|
||
return;
|
||
}
|
||
const el = ev.target.closest("[data-act]");
|
||
if (!el || el.dataset.act === "target") return;
|
||
const id = el.dataset.id;
|
||
switch (el.dataset.act) {
|
||
case "register":
|
||
if (await api("register", { name: $("#name").value, cls: regCls })) toast("Welcome to Azeroth", true);
|
||
break;
|
||
case "hunt": api("hunt", { id }); break;
|
||
case "stop": api("stop"); break;
|
||
case "travel": api("travel", { id }); break;
|
||
case "travel-far": if ($("#far").value) api("travel", { id: $("#far").value }); break;
|
||
case "accept": api("accept", { id }); break;
|
||
case "abandon": if (confirm("Abandon this quest?")) api("abandon", { id }); break;
|
||
case "pick-reward": pickReward[id] = Number(el.dataset.i); render(); break;
|
||
case "turn-in": api("turn_in", { id, choice: pickReward[id] }); break;
|
||
case "sell-junk": api("sell_junk"); break;
|
||
case "select": if (!ev.target.closest("button")) { selected = selected === id ? null : id; render(); } break;
|
||
case "equip": api("equip", { id }); break;
|
||
case "unequip": api("unequip", { id }); break;
|
||
case "sell": api("sell", { id, qty: "all" }); break;
|
||
case "manual": api("manual", { id, on: el.dataset.on === "1" }); break;
|
||
case "create-group": api("group/create"); break;
|
||
case "join": api("group/join", { id }); break;
|
||
case "leave-group": if (confirm("Leave the group?")) api("group/leave"); break;
|
||
case "enter": api("dungeon/enter"); break;
|
||
case "pull": api("dungeon/pull"); break;
|
||
case "leave-dungeon": if (confirm("Leave the dungeon?")) api("dungeon/leave"); break;
|
||
case "say": {
|
||
const input = $("#say");
|
||
if (input.value.trim() && await api("say", { text: input.value })) input.value = "";
|
||
break;
|
||
}
|
||
case "dev-level": api("dev/level", { level: Number($("#dev-level").value) }); break;
|
||
case "dev-advance": api("dev/advance", { minutes: Number(el.dataset.m) }); break;
|
||
case "dev-teleport": api("dev/teleport", { id: $("#dev-place").value }); break;
|
||
}
|
||
});
|
||
|
||
document.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Enter" && ev.target.id === "say") $("[data-act=say]").click();
|
||
if (ev.target.matches("input, textarea, select")) return;
|
||
const n = Number(ev.key);
|
||
if (n >= 1 && n <= 4) {
|
||
const ab = document.querySelectorAll(".ab[data-ab]")[n - 1];
|
||
if (ab) ab.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||
}
|
||
});
|
||
|
||
document.addEventListener("input", (ev) => {
|
||
if (ev.target.id === "name") remember("draftName", ev.target.value);
|
||
});
|
||
|
||
document.addEventListener("change", (ev) => {
|
||
if (ev.target.id === "dev-user") {
|
||
document.cookie = `wog_user=${encodeURIComponent(ev.target.value)}; path=/; max-age=31536000`;
|
||
location.reload();
|
||
}
|
||
});
|
||
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (!document.hidden) refresh();
|
||
});
|
||
|
||
// --- boot ---------------------------------------------------------------
|
||
|
||
async function boot() {
|
||
const res = await fetch("/api/content");
|
||
C = await res.json();
|
||
await refresh();
|
||
setInterval(() => {
|
||
if (document.hidden) return;
|
||
const fast = S && S.fight && S.fight.state === "on";
|
||
if (Date.now() - polledAt >= (fast ? FIGHT_POLL_MS : POLL_MS)) refresh();
|
||
}, 100);
|
||
setInterval(animate, 100);
|
||
setInterval(() => { if (S && page === "adventure" && !S.fight) renderMain(); }, 1000);
|
||
}
|
||
|
||
boot();
|