| |
| const { createApp, ref, computed, watch, onMounted, nextTick } = Vue; |
|
|
| const STATE_COLOR = { |
| SPAWNING: "#b0a8a0", |
| BROWSE: "#6fa8dc", |
| LINGER: "#8fbf7f", |
| CONSIDER: "#e0b050", |
| QUEUE: "#d49050", |
| PAY: "#c06060", |
| TALK: "#9b7ed9", |
| EXIT: "#888", |
| }; |
| const CELL = 48; |
|
|
| createApp({ |
| setup() { |
| const view = ref(null); |
| const sessionId = ref(null); |
| |
| const busy = ref(false); |
| const error = ref(""); |
| const speed = ref(180); |
| const showNight = ref(false); |
| const showHelp = ref(true); |
| const nightDay = ref(null); |
| const canvas = ref(null); |
| const restockId = ref(""); |
| const stockPid = ref(""); |
| const stockSlot = ref(""); |
| const toasts = ref([]); |
| const floaters = ref([]); |
| const flavor = ref( |
| localStorage.getItem("town-shop-flavor") === "latte" ? "latte" : "mocha" |
| ); |
| let autoTimer = null; |
| let autoInFlight = false; |
| let toastSeq = 0; |
| let floatSeq = 0; |
| const imgCache = new Map(); |
|
|
| function setFlavor(name) { |
| flavor.value = name === "latte" ? "latte" : "mocha"; |
| document.documentElement.setAttribute("data-flavor", flavor.value); |
| localStorage.setItem("town-shop-flavor", flavor.value); |
| const meta = document.querySelector('meta[name="theme-color"]'); |
| if (meta) meta.setAttribute("content", flavor.value === "latte" ? "#eff1f5" : "#1e1e2e"); |
| nextTick(draw); |
| } |
|
|
| function ctp(name, fallback) { |
| const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); |
| return v || fallback; |
| } |
|
|
| const reversedLog = computed(() => [...(view.value?.log || [])].reverse()); |
| const catalogOptions = computed(() => view.value?.catalog || []); |
| const stockOptions = computed(() => { |
| const inv = view.value?.inventory || []; |
| return inv.length ? inv : catalogOptions.value; |
| }); |
| const bgStyle = computed(() => { |
| const bg = view.value?.art?.shop_bg; |
| return bg ? { backgroundImage: `url(/${bg})` } : {}; |
| }); |
|
|
| function isHere(agentId) { |
| return (view.value?.agents || []).some((a) => a.agent_id === agentId); |
| } |
|
|
| function img(src) { |
| if (!src) return null; |
| if (imgCache.has(src)) return imgCache.get(src); |
| const im = new Image(); |
| im.onload = () => draw(); |
| im.src = "/" + String(src).replace(/^\//, ""); |
| imgCache.set(src, im); |
| return im; |
| } |
|
|
| function pushToast(text, kind = "info") { |
| const id = ++toastSeq; |
| toasts.value = [...toasts.value, { id, text, kind }].slice(-5); |
| setTimeout(() => { |
| toasts.value = toasts.value.filter((t) => t.id !== id); |
| }, 3200); |
| } |
|
|
| function pushFloater(text, kind = "sale") { |
| const id = ++floatSeq; |
| floaters.value = [...floaters.value, { id, text, kind }].slice(-4); |
| setTimeout(() => { |
| floaters.value = floaters.value.filter((f) => f.id !== id); |
| }, 1600); |
| } |
|
|
| async function api(path, opts = {}) { |
| const res = await fetch(path, { |
| headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, |
| ...opts, |
| }); |
| if (!res.ok) { |
| let detail = res.statusText; |
| try { |
| const j = await res.json(); |
| detail = j.detail || JSON.stringify(j); |
| } catch (_) {} |
| throw new Error(typeof detail === "string" ? detail : "请求失败"); |
| } |
| return res.json(); |
| } |
|
|
| function apply(payload, { fromAuto = false } = {}) { |
| view.value = payload.view; |
| sessionId.value = payload.session_id || sessionId.value; |
| syncSelects(); |
|
|
| |
| const toastsIn = payload.view?.toasts || []; |
| for (const t of toastsIn) { |
| if (fromAuto && t.kind === "info" && !/进店|成交|收入|营收|夜幕/.test(t.text || "")) { |
| continue; |
| } |
| pushToast(t.text, t.kind || "info"); |
| } |
| for (const ev of payload.view?.events || []) { |
| if (ev.type === "sale") pushFloater(ev.text, "sale"); |
| if (ev.type === "enter") pushFloater(ev.text, "enter"); |
| } |
|
|
| const m = payload.view?.meta; |
| if (m?.phase === "NIGHT" && payload.view.night && nightDay.value !== m.day_index) { |
| nightDay.value = m.day_index; |
| showNight.value = true; |
| } |
| |
| if (!fromAuto) { |
| ensureAuto(); |
| } else if (m?.phase === "NIGHT" || m?.phase === "PREP") { |
| stopAuto(); |
| } |
| nextTick(draw); |
| } |
|
|
| function syncSelects() { |
| const cat = catalogOptions.value; |
| const stock = stockOptions.value; |
| if (!restockId.value && cat[0]) restockId.value = cat[0].product_id; |
| if (!stockPid.value && stock[0]) stockPid.value = stock[0].product_id; |
| const slots = view.value?.displays || []; |
| if (!stockSlot.value && slots[0]) stockSlot.value = slots[0].slot; |
| } |
|
|
| function stopAuto() { |
| if (autoTimer) { |
| clearTimeout(autoTimer); |
| autoTimer = null; |
| } |
| } |
|
|
| async function autoTickOnce() { |
| if (!sessionId.value || autoInFlight || busy.value) return; |
| if (!view.value?.ui?.can_tick || view.value.meta.paused) return; |
| autoInFlight = true; |
| try { |
| const data = await api(`/api/session/${sessionId.value}/tick`, { |
| method: "POST", |
| body: JSON.stringify({ |
| steps: 1, |
| delta: 0.25, |
| skip_idle: true, |
| max_skip: 60, |
| }), |
| }); |
| apply(data, { fromAuto: true }); |
| } catch (e) { |
| |
| console.warn(e); |
| } finally { |
| autoInFlight = false; |
| } |
| } |
|
|
| function ensureAuto() { |
| stopAuto(); |
| const ms = Number(speed.value); |
| if (!ms) return; |
| if (!view.value?.ui?.can_tick) return; |
| if (view.value.meta.paused) return; |
|
|
| const loop = async () => { |
| autoTimer = null; |
| if (!view.value?.ui?.can_tick || view.value.meta.paused || !Number(speed.value)) { |
| return; |
| } |
| await autoTickOnce(); |
| |
| if (view.value?.ui?.can_tick && !view.value.meta.paused && Number(speed.value)) { |
| autoTimer = setTimeout(loop, Number(speed.value)); |
| } |
| }; |
| autoTimer = setTimeout(loop, ms); |
| } |
|
|
| |
| function setupAuto() { |
| ensureAuto(); |
| } |
|
|
| async function newGame() { |
| if (busy.value) return; |
| busy.value = true; |
| stopAuto(); |
| error.value = ""; |
| try { |
| const seed = 7 + Math.floor(Math.random() * 90); |
| const data = await api("/api/session", { |
| method: "POST", |
| body: JSON.stringify({ seed, pace: "Brisk" }), |
| }); |
| nightDay.value = null; |
| showNight.value = false; |
| apply(data); |
| } catch (e) { |
| error.value = e.message || "开局失败"; |
| view.value = null; |
| } finally { |
| busy.value = false; |
| } |
| } |
|
|
| |
| async function act(path, body, { lock = true } = {}) { |
| if (!sessionId.value) return; |
| if (lock && busy.value) return; |
| if (lock) busy.value = true; |
| |
| const wasAuto = !!Number(speed.value) && view.value?.ui?.can_tick; |
| if (lock) stopAuto(); |
| try { |
| const data = await api(`/api/session/${sessionId.value}${path}`, { |
| method: "POST", |
| body: body !== undefined ? JSON.stringify(body) : undefined, |
| }); |
| apply(data); |
| } catch (e) { |
| pushToast(e.message, "err"); |
| } finally { |
| if (lock) busy.value = false; |
| if (wasAuto) ensureAuto(); |
| } |
| } |
|
|
| const openShop = async () => { |
| await act("/open"); |
| if (speed.value === 0) speed.value = 180; |
| ensureAuto(); |
| }; |
| const sleep = () => act("/sleep"); |
| const tickOnce = () => |
| act("/tick", { steps: 1, delta: 0.25, skip_idle: true, max_skip: 80 }); |
| const fastNight = () => act("/run_to_night"); |
| const setLight = (mode) => act("/set_light", { mode }); |
| const togglePause = async () => { |
| await act("/pause", { paused: !view.value?.meta?.paused }); |
| ensureAuto(); |
| }; |
| const restock = () => act("/restock", { product_id: restockId.value, qty: 1 }); |
| const stock = () => |
| act("/stock_display", { |
| product_id: stockPid.value, |
| slot_id: stockSlot.value, |
| qty: 1, |
| }); |
|
|
| async function quickPrep() { |
| if (busy.value) return; |
| busy.value = true; |
| stopAuto(); |
| try { |
| const picks = (catalogOptions.value || []).slice(0, 3); |
| for (const p of picks) { |
| try { |
| const data = await api(`/api/session/${sessionId.value}/restock`, { |
| method: "POST", |
| body: JSON.stringify({ product_id: p.product_id, qty: 2 }), |
| }); |
| apply(data); |
| } catch (_) {} |
| } |
| const empty = (view.value?.displays || []).filter((d) => !d.qty); |
| const inv = stockOptions.value; |
| for (let i = 0; i < Math.min(empty.length, inv.length); i++) { |
| try { |
| const data = await api(`/api/session/${sessionId.value}/stock_display`, { |
| method: "POST", |
| body: JSON.stringify({ |
| product_id: inv[i].product_id, |
| slot_id: empty[i].slot, |
| qty: 1, |
| }), |
| }); |
| apply(data); |
| } catch (_) {} |
| } |
| pushToast("备货完成,可以开始营业了", "info"); |
| } finally { |
| busy.value = false; |
| } |
| } |
|
|
| function roundRect(ctx, x, y, w, h, r) { |
| ctx.beginPath(); |
| ctx.moveTo(x + r, y); |
| ctx.arcTo(x + w, y, x + w, y + h, r); |
| ctx.arcTo(x + w, y + h, x, y + h, r); |
| ctx.arcTo(x, y + h, x, y, r); |
| ctx.arcTo(x, y, x + w, y, r); |
| ctx.closePath(); |
| } |
|
|
| function drawBubble(ctx, x, y, text) { |
| const t = String(text).slice(0, 18); |
| const peach = ctp("--ctp-peach", "#fab387"); |
| const base = ctp("--ctp-mantle", "#181825"); |
| const ink = ctp("--ctp-text", "#cdd6f4"); |
| ctx.font = "12px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif"; |
| const tw = ctx.measureText(t).width; |
| const bw = tw + 16; |
| const bh = 24; |
| const bx = x - bw / 2; |
| const by = y - bh - 10; |
| ctx.fillStyle = base; |
| ctx.strokeStyle = peach; |
| roundRect(ctx, bx, by, bw, bh, 10); |
| ctx.fill(); |
| ctx.stroke(); |
| ctx.fillStyle = ink; |
| ctx.textAlign = "center"; |
| ctx.fillText(t, x, by + 16); |
| ctx.textAlign = "left"; |
| } |
|
|
| function draw() { |
| const el = canvas.value; |
| if (!el || !view.value) return; |
| const ctx = el.getContext("2d"); |
| const w = el.width; |
| const h = el.height; |
| const base = ctp("--ctp-base", "#1e1e2e"); |
| const surface0 = ctp("--ctp-surface0", "#313244"); |
| const surface1 = ctp("--ctp-surface1", "#45475a"); |
| const text = ctp("--ctp-text", "#cdd6f4"); |
| const subtext = ctp("--ctp-subtext0", "#a6adc8"); |
| const peach = ctp("--ctp-peach", "#fab387"); |
| const sky = ctp("--ctp-sky", "#89dceb"); |
| const crust = ctp("--ctp-crust", "#11111b"); |
|
|
| ctx.clearRect(0, 0, w, h); |
| ctx.fillStyle = base; |
| ctx.fillRect(0, 0, w, h); |
|
|
| const v = view.value; |
| const gw = v.grid.w; |
| const gh = v.grid.h; |
| const tags = v.grid.cell_tags || {}; |
| const markersZh = v.grid.markers_zh || {}; |
|
|
| for (let y = 0; y < gh; y++) { |
| for (let x = 0; x < gw; x++) { |
| const px = x * CELL; |
| const py = y * CELL; |
| const t = tags[`${x},${y}`] || []; |
| if (t.includes("wall") && t.includes("window")) ctx.fillStyle = sky + "66"; |
| else if (t.includes("wall")) ctx.fillStyle = surface1 + "cc"; |
| else if (t.includes("is_entrance")) ctx.fillStyle = peach + "55"; |
| else ctx.fillStyle = (x + y) % 2 === 0 ? surface0 + "aa" : surface1 + "66"; |
| ctx.fillRect(px, py, CELL, CELL); |
| } |
| } |
|
|
| for (const f of v.furniture || []) { |
| const px = f.x * CELL; |
| const py = f.y * CELL; |
| const im = img(f.sprite); |
| if (im && im.complete && im.naturalWidth) { |
| ctx.save(); |
| roundRect(ctx, px + 2, py + 2, CELL - 4, CELL - 4, 8); |
| ctx.clip(); |
| ctx.drawImage(im, px + 2, py + 2, CELL - 4, CELL - 4); |
| ctx.restore(); |
| } else { |
| ctx.fillStyle = "rgba(120,90,70,0.5)"; |
| roundRect(ctx, px + 6, py + 6, CELL - 12, CELL - 12, 6); |
| ctx.fill(); |
| } |
| ctx.fillStyle = text; |
| ctx.font = "11px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif"; |
| ctx.fillText(String(f.name || "").slice(0, 6), px + 4, py + CELL - 5); |
| } |
|
|
| const slotPos = {}; |
| for (const f of v.furniture || []) { |
| for (const s of f.slots || []) slotPos[s] = [f.x, f.y]; |
| } |
| let di = 0; |
| for (const d of v.displays || []) { |
| if (!d.product_id || !d.qty) continue; |
| const [sx, sy] = slotPos[d.slot] || [1 + (di % 4), 3]; |
| di++; |
| const px = sx * CELL + 28; |
| const py = sy * CELL + 4; |
| const im = img(d.sprite); |
| ctx.fillStyle = "rgba(255,255,255,0.95)"; |
| ctx.beginPath(); |
| ctx.arc(px, py + 14, 13, 0, Math.PI * 2); |
| ctx.fill(); |
| if (im && im.complete && im.naturalWidth) { |
| ctx.save(); |
| ctx.beginPath(); |
| ctx.arc(px, py + 14, 12, 0, Math.PI * 2); |
| ctx.clip(); |
| ctx.drawImage(im, px - 12, py + 2, 24, 24); |
| ctx.restore(); |
| } |
| ctx.fillStyle = text; |
| ctx.font = "bold 11px 'JetBrains Mono',sans-serif"; |
| ctx.fillText("×" + d.qty, px + 12, py + 18); |
| } |
|
|
| for (const [name, xy] of Object.entries(v.grid.markers || {})) { |
| ctx.fillStyle = subtext; |
| ctx.font = "10px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif"; |
| ctx.fillText(markersZh[name] || name, xy[0] * CELL + 3, xy[1] * CELL + 12); |
| } |
|
|
| |
| for (const a of v.agents || []) { |
| const [ax, ay] = a.xy || [5, 9]; |
| const px = ax * CELL + CELL / 2; |
| const py = ay * CELL + CELL / 2; |
| const col = STATE_COLOR[a.state] || "#888"; |
| |
| ctx.beginPath(); |
| ctx.arc(px, py, 22, 0, Math.PI * 2); |
| ctx.fillStyle = col + "55"; |
| ctx.fill(); |
| ctx.beginPath(); |
| ctx.arc(px, py, 18, 0, Math.PI * 2); |
| ctx.fillStyle = col; |
| ctx.fill(); |
| ctx.lineWidth = 3; |
| ctx.strokeStyle = "#fff"; |
| ctx.stroke(); |
| const portrait = img(a.portrait); |
| if (portrait && portrait.complete && portrait.naturalWidth) { |
| ctx.save(); |
| ctx.beginPath(); |
| ctx.arc(px, py, 15, 0, Math.PI * 2); |
| ctx.clip(); |
| ctx.drawImage(portrait, px - 15, py - 15, 30, 30); |
| ctx.restore(); |
| } |
| ctx.fillStyle = text; |
| ctx.font = "bold 12px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif"; |
| ctx.textAlign = "center"; |
| ctx.fillText(a.name || "", px, py + 32); |
| ctx.font = "11px 'JetBrains Mono','PingFang SC','Microsoft YaHei',sans-serif"; |
| ctx.fillStyle = col; |
| ctx.fillText(a.state_zh || "", px, py + 46); |
| ctx.textAlign = "left"; |
| if (a.bark) drawBubble(ctx, px, py - 22, a.bark); |
| } |
| } |
|
|
| watch(view, () => nextTick(draw), { deep: true }); |
|
|
| onMounted(async () => { |
| setFlavor(flavor.value); |
| await newGame(); |
| setInterval(() => { |
| if (view.value) draw(); |
| }, 500); |
| }); |
|
|
| return { |
| view, |
| busy, |
| error, |
| speed, |
| showNight, |
| showHelp, |
| canvas, |
| restockId, |
| stockPid, |
| stockSlot, |
| reversedLog, |
| catalogOptions, |
| stockOptions, |
| bgStyle, |
| toasts, |
| floaters, |
| flavor, |
| setFlavor, |
| isHere, |
| newGame, |
| openShop, |
| sleep, |
| tickOnce, |
| fastNight, |
| setLight, |
| togglePause, |
| restock, |
| stock, |
| quickPrep, |
| setupAuto, |
| }; |
| }, |
| }).mount("#app"); |
|
|