diff --git a/electron/main.cjs b/electron/main.cjs index 43613b6..97caef7 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -70,6 +70,46 @@ updater.init({ setQuitting: (v) => { isQuitting = v }, }) +// --------------------------------------------------------------------- +// 屏幕模式:window 普通窗口 / windowFull 窗口全屏(最大化铺满工作区+隐藏菜单栏)/ +// full 真全屏(覆盖任务栏)。渲染进程经 preload 的 setScreenMode 切换; +// 菜单「视图 → 全屏」(F11) 与 Esc 退全屏通过 enter/leave-full-screen 事件回同步。 +// 模式持久化到 config.json,下次启动恢复。 +// --------------------------------------------------------------------- +let screenMode = 'window' +let prevWindowed = 'window' // 从真全屏退出时回到的窗口模式 + +function applyWindowedMode(win, mode) { + if (!win || win.isDestroyed()) return + if (mode === 'windowFull') { + if (!win.isMaximized()) win.maximize() + win.setAutoHideMenuBar(true) + win.setMenuBarVisibility(false) + } else { + if (win.isMaximized()) win.unmaximize() + win.setAutoHideMenuBar(false) + win.setMenuBarVisibility(true) + } +} + +function setScreenModeInMain(mode) { + if (!mainWin || mainWin.isDestroyed()) return { mode: screenMode } + if (mode === 'full') { + if (!mainWin.isFullScreen()) mainWin.setFullScreen(true) + screenMode = 'full' + } else { + if (mainWin.isFullScreen()) mainWin.setFullScreen(false) + prevWindowed = mode === 'windowFull' ? 'windowFull' : 'window' + screenMode = prevWindowed + applyWindowedMode(mainWin, prevWindowed) + } + writeCfg({ screenMode }) + mainWin.webContents.send('nlg:screen-mode-changed', screenMode) + return { mode: screenMode } +} + +ipcMain.handle('nlg:set-screen-mode', (_e, mode) => setScreenModeInMain(mode === 'full' ? 'full' : mode === 'windowFull' ? 'windowFull' : 'window')) + // --------------------------------------------------------------------- // 服务器设置窗口(极简内嵌页面,深色风格与主站一致) // --------------------------------------------------------------------- @@ -214,7 +254,7 @@ function buildMenu() { { label: '缩小', role: 'zoomOut' }, { label: '实际大小', role: 'resetZoom' }, { type: 'separator' }, - { label: '全屏', role: 'togglefullscreen' }, + { label: '全屏', role: 'togglefullscreen', accelerator: 'F11' }, ], }, { @@ -264,15 +304,35 @@ function createWindow() { }, }) mainWin.once('ready-to-show', () => { - if (cfg.winMax) mainWin.maximize() + // 恢复上次屏幕模式:full=真全屏;windowFull=窗口全屏(最大化+隐藏菜单栏) + if (cfg.screenMode === 'full') { + screenMode = 'full' + mainWin.setFullScreen(true) + } else { + screenMode = prevWindowed = cfg.screenMode === 'windowFull' ? 'windowFull' : 'window' + applyWindowedMode(mainWin, screenMode) + if (screenMode === 'window' && cfg.winMax) mainWin.maximize() // 普通窗口保留「最大化启动」记忆 + } mainWin.show() // 启动后 3 秒静默检查一次更新(已是最新版不弹窗,发现新版本才提示) setTimeout(() => { updater.checkUpdate(true).catch(() => {}) }, 3000) }) + // 原生全屏状态变化回同步(菜单 F11 / Esc 退出全屏时渲染层才能拿到正确状态) + mainWin.on('enter-full-screen', () => { + screenMode = 'full' + writeCfg({ screenMode }) + if (!mainWin.isDestroyed()) mainWin.webContents.send('nlg:screen-mode-changed', screenMode) + }) + mainWin.on('leave-full-screen', () => { + screenMode = prevWindowed + applyWindowedMode(mainWin, prevWindowed) + writeCfg({ screenMode }) + if (!mainWin.isDestroyed()) mainWin.webContents.send('nlg:screen-mode-changed', screenMode) + }) // 点关闭:记住窗口状态并隐藏到托盘(冒烟模式 / 真正退出时直接关) mainWin.on('close', (e) => { try { - writeCfg({ winBounds: mainWin.getNormalBounds(), winMax: mainWin.isMaximized() }) + writeCfg({ winBounds: mainWin.getNormalBounds(), winMax: mainWin.isMaximized(), screenMode }) } catch {} if (!isQuitting && !SMOKE && tray) { e.preventDefault() diff --git a/electron/mario-feel-check.cjs b/electron/mario-feel-check.cjs new file mode 100644 index 0000000..e347aff --- /dev/null +++ b/electron/mario-feel-check.cjs @@ -0,0 +1,136 @@ +// 手感实测(开发用临时脚本):进入 1-1 后用 __marioProbe 采样, +// 测站立满跳/助跑跳的高度与滞空、连跳节奏、按住不自动回弹、走/跑极速 +const { app, BrowserWindow } = require('electron') +const path = require('path') +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +async function main() { + const win = new BrowserWindow({ width: 1280, height: 860, show: true, backgroundColor: '#0f1220', webPreferences: { backgroundThrottling: false } }) + win.webContents.setBackgroundThrottling(false) + const pageErrors = [] + win.webContents.on('console-message', (e) => { if (e?.level === 'error' && e?.message) pageErrors.push(e.message) }) + const js = (code) => win.webContents.executeJavaScript(code, true).catch((e) => { console.error('[feel] JS 失败:', String(e.message).slice(0, 80)); return null }) + const waitFor = async (cond, t, d) => { + const t0 = Date.now() + while (Date.now() - t0 < t) { try { if (await js(cond)) return } catch {} ; await sleep(300) } + throw new Error('超时:' + d) + } + const key = async (k, down) => js(`window.dispatchEvent(new KeyboardEvent('${down ? 'keydown' : 'keyup'}', { key: '${k}' })); true`) + // 以 ~28ms 间隔采样探针 + const sample = async (ms) => { + const out = [] + const t0 = Date.now() + while (Date.now() - t0 < ms) { + const p = await js(`JSON.stringify(window.__marioProbe || null)`) + if (p) out.push({ ...JSON.parse(p), t: Date.now() - t0 }) + await sleep(25) + } + return out + } + + await win.loadURL('http://localhost:5173') + await js('localStorage.clear(); true') + await win.webContents.reload() + await waitFor(`!!document.querySelector('input[placeholder^="用户名"]')`, 10000, '登录页') + await js(` + (() => { + const us = document.querySelector('input[placeholder^="用户名"]') + us.value = 'mariotest01'; us.dispatchEvent(new Event('input', { bubbles: true })) + const ps = document.querySelector('input[type="password"]') + ps.value = 'test123456'; ps.dispatchEvent(new Event('input', { bubbles: true })) + document.querySelector('button.submit').click() + return true + })() + `) + await waitFor(`!!localStorage.getItem('token')`, 8000, '登录') + await sleep(1200) + await js(`location.href = '/play/mario'; true`).catch(() => {}) + await waitFor(`!!document.querySelector('.overlay button') && [...document.querySelectorAll('.overlay button')].some((b) => /开始闯关|继续第|开始游戏/.test(b.textContent))`, 20000, '游玩页') + await js(` + (() => { + const b = document.querySelector('.overlay button.btn-lg') || + [...document.querySelectorAll('.overlay button')].find((x) => /开始闯关|继续第|开始游戏/.test(x.textContent)) + b.click() + return true + })() + `) + await sleep(900) // 选关画面出现,光标在最新解锁关(12 → 11=3-4) + await key('ArrowRight', true); await sleep(50); await key('ArrowRight', false); await sleep(80) // → 0 = 1-1 + await key(' ', true); await sleep(80); await key(' ', false) + await sleep(2600) // 等横幅结束 + + // —— 测 A:站立满跳(按住)—— + const groundY0 = (await js(`window.__marioProbe.y + window.__marioProbe.h ?? null`)) // 无 h,用落点近似 + const a = [] + await key(' ', true) + const aS = await sample(1300) + await key(' ', false) + await sleep(600) + // —— 测 C:连跳(原地按 ~0.83s 滞空节奏快速重按,验证落地前缓冲让连跳无缝)—— + await key('x', true) + await sleep(300) + const cS = [] + for (let i = 0; i < 5; i++) { + await key(' ', true) + const s1 = await sample(280) + await key(' ', false) + await sleep(430) // 重按落在落地前缓冲窗内 + cS.push(...s1) + } + await key(' ', false) + await key('x', false) + await sleep(500) + // —— 测 B:走/跑极速(地面 0-18 列约 608px,控制跑动时长别掉坑)—— + await key('ArrowRight', true) + const walkS = await sample(900) // 未按住跑步键 = 走 + await key('x', true) + await sleep(300) + const runS = await sample(900) // 按住 x = 跑 + await key('x', false) + await key('ArrowRight', false) + await sleep(400) + // —— 测 D:按住跳不放 —— + await sleep(500) + await key(' ', true) + const dS = await sample(1600) + await key(' ', false) + + const analyze = (s, label) => { + if (!s.length) return null + const gY = Math.max(...s.map((p) => p.y)) // 最大 y 即地面站立位 + const air = s.filter((p) => !p.ground) + const apex = air.length ? gY - Math.min(...air.map((p) => p.y)) : 0 + // 起落对:ground false 的连续段 + let hops = 0 + let prev = true + let airMs = 0 + const dts = [] + for (let i = 1; i < s.length; i++) dts.push(s[i].t - s[i - 1].t) + for (const p of s) { + if (prev && !p.ground) hops++ + if (!p.ground) airMs += 28 + prev = p.ground + } + return { label, apexPx: apex.toFixed(0), hops, airMsPerHop: hops ? Math.round(airMs / hops) : 0, groundY: gY.toFixed(0) } + } + // 极速:相邻采样位移/时间 + const topSpeed = (s) => { + let best = 0 + for (let i = 1; i < s.length; i++) { + const dt = (s[i].t - s[i - 1].t) / 1000 + const dx = Math.abs(s[i].x - s[i - 1].x) + if (dt > 0 && s[i].ground) best = Math.max(best, dx / dt) + } + return Math.round(best) + } + console.log('[feel] A 站立满跳:', JSON.stringify(analyze(aS, 'A'))) + console.log('[feel] B 走极速:', topSpeed(walkS), 'px/s(采样口径)') + console.log('[feel] B 跑极速:', topSpeed(runS), 'px/s(采样口径)') + console.log('[feel] C 连跳:', JSON.stringify(analyze(cS, 'C'))) + console.log('[feel] D 按住不放:', JSON.stringify(analyze(dS, 'D'))) + const report = await js(`({ marioErr: window.__marioErr ?? null, pageErrors: window.__pageErrors || [] })`) + console.log('[feel] 异常:', JSON.stringify(report), 'console:', pageErrors.length) + app.exit(0) +} +app.whenReady().then(() => main().catch((e) => { console.error('[feel] 失败:', e.message); app.exit(1) })) +setTimeout(() => { console.error('[feel] 总超时'); app.exit(1) }, 90000) diff --git a/electron/mario-verify.cjs b/electron/mario-verify.cjs new file mode 100644 index 0000000..50aeace --- /dev/null +++ b/electron/mario-verify.cjs @@ -0,0 +1,188 @@ +// 马里奥专项验证(开发用临时脚本): +// 1. 登录 → 解锁全部关卡(API)→ 1-1 开局:横幅/问号居中/跑动截图 +// 2. 依次冒烟 2-1/2-2/2-3/2-4:开局跑动,检查循环异常与 AudioContext +// 运行:NLG_E2E_MODE=web npx electron electron/mario-verify.cjs(需 5173 开发服务器) +const { app, BrowserWindow } = require('electron') +const path = require('path') +const fs = require('fs') + +const WEB_URL = 'http://localhost:5173' +const OUT_DIR = process.cwd() +const pageErrors = [] +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +async function main() { + const win = new BrowserWindow({ + width: 1280, + height: 860, + show: true, + backgroundColor: '#0f1220', + webPreferences: { backgroundThrottling: false }, + }) + win.webContents.setBackgroundThrottling(false) + win.webContents.on('console-message', (e) => { + if (e?.level === 'error' && e?.message) pageErrors.push(e.message) + }) + const js = (code) => win.webContents.executeJavaScript(code, true).catch((e) => { + console.error('[verify] JS 执行失败片段:', JSON.stringify(code.slice(0, 100).replace(/\s+/g, ' '))) + throw e + }) + const shot = async (name) => { + for (let i = 0; i < 3; i++) { + try { + const img = await win.webContents.capturePage() + const file = path.join(OUT_DIR, `${name}.png`) + fs.writeFileSync(file, img.toPNG()) + console.log('[verify] screenshot:', file) + return + } catch (err) { + if (i === 2) console.warn(`[verify] 截图 ${name} 失败:${err.message}`) + else await sleep(600) + } + } + } + const waitFor = async (cond, timeoutMs, desc) => { + const t0 = Date.now() + while (Date.now() - t0 < timeoutMs) { + try { if (await js(cond)) return } catch {} + await sleep(300) + } + throw new Error(`等待超时:${desc}`) + } + const runRight = async (ms) => { + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); true`) + await sleep(ms) + await js(`window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' })); true`) + } + + await win.loadURL(WEB_URL) + await js('localStorage.clear(); true') + await win.webContents.reload() + await waitFor(`!!document.querySelector('input[placeholder^="用户名"]')`, 10000, '登录页渲染') + await js(` + (() => { + const us = document.querySelector('input[placeholder^="用户名"]') + us.value = 'mariotest01'; us.dispatchEvent(new Event('input', { bubbles: true })) + const ps = document.querySelector('input[type="password"]') + ps.value = 'test123456'; ps.dispatchEvent(new Event('input', { bubbles: true })) + document.querySelector('button.submit').click() + return true + })() + `) + await waitFor(`!!localStorage.getItem('token')`, 8000, '登录') + console.log('[verify] 登录成功') + + // 解锁全部 8 关(后端防作弊每次最多 +1,循环推进) + await js(` + (async () => { + const H = { Authorization: 'Bearer ' + localStorage.getItem('token'), 'Content-Type': 'application/json' } + for (let lv = 2; lv <= 12; lv++) { + for (let t = 0; t < 3; t++) { + try { + const r = await fetch('/api/games/mario/progress', { + method: 'POST', headers: H, body: JSON.stringify({ level: lv }), + }) + const j = await r.json() + if (j && j.code === 0) break + } catch {} + await new Promise((r2) => setTimeout(r2, 400)) + } + } + const g = await fetch('/api/games/mario/progress', { headers: { Authorization: H.Authorization } }).then((r) => r.json()) + return (g.data || {}).level + })() + `) + await sleep(800) + const unlockedLevel = await js(`fetch('/api/games/mario/progress', { headers: { Authorization: 'Bearer ' + localStorage.getItem('token') } }).then((r) => r.json()).then((j) => (j.data || {}).level).catch(() => 0)`) + console.log('[verify] 解锁进度:', unlockedLevel) + + const playLevel = async (label, idx, runMs) => { + await js(`location.href = '/play/mario'; true`).catch(() => {}) + try { + await waitFor(`!![...document.querySelectorAll('button')].find((b) => /开始闯关|继续第|开始游戏/.test(b.textContent))`, 20000, `${label} 游玩页`) + } catch (e) { + await shot(`mario-verify-debug-${idx}`) + console.error('[verify] 页面状态:', JSON.stringify(await js(`({ url: location.href, btns: [...document.querySelectorAll('button')].map((b) => b.textContent.trim()).slice(0, 12) })`))) + throw e + } + // 开局前装监听 + 代理 AudioContext + await js(` + (() => { + window.__pageErrors = [] + window.addEventListener('error', (e) => window.__pageErrors.push(String(e.message || e))) + window.addEventListener('unhandledrejection', (e) => window.__pageErrors.push(String(e.reason))) + const Orig = window.AudioContext || window.webkitAudioContext + window.AudioContext = class extends Orig { + constructor(...a) { super(...a); (window.__actxs = window.__actxs || []).push(this) } + } + // 选择关卡(若按钮可用)再开始 + const btns = [...document.querySelectorAll('button')] + const lv = btns.find((b) => b.textContent.includes('世界 ${label}') && !b.disabled) + if (lv) lv.click() + return true + })() + `) + await sleep(300) + await js(` + (() => { + const b = document.querySelector('.overlay .ls-continue') || + [...document.querySelectorAll('button')].find((x) => /开始闯关|继续第|开始游戏/.test(x.textContent)) + if (!b) return false + b.click() + return true + })() + `) + await sleep(900) + if (idx === 'a') await shot('mario-verify-select') // 世界选关画面 + // 光标默认停在最新解锁关(11=3-4),用右移导航到目标关:(i+1)%12 次到 i + const target = { a: 0, b: 4, c: 8, d: 9, e: 10, f: 11 }[idx] ?? 11 + const presses = (target + 1) % 12 + for (let i = 0; i < presses; i++) { + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); true`) + await sleep(50) + await js(`window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' })); true`) + await sleep(60) + } + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); true`) + await sleep(80) + await js(`window.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); true`) + await sleep(3400) // 横幅 1.6s + 余量 + await runRight(runMs) + await sleep(1600) + const report = await js(`({ + marioErr: window.__marioErr ?? null, + pageErrors: window.__pageErrors, + audio: (window.__actxs || []).map((a) => a.state), + level: window.__marioLevel ?? null, + })`) + const wantLevel = { a: '1-1', b: '2-1', c: '3-1', d: '3-2', e: '3-3', f: '3-4' }[idx] + if (report.level !== wantLevel) { + console.error(`[verify] ${label} 未进入目标关:期望 ${wantLevel},实际 ${report.level}`) + report.marioErr = report.marioErr || 'level mismatch' + } + console.log(`[verify] ${label} report:`, JSON.stringify(report)) + await shot(`mario-verify-${idx}-${label}`) + return report + } + + const reports = [] + reports.push(await playLevel('1-1', 'a', 1400)) + for (const [label, idx, runMs] of [['2-1', 'b', 3200], ['3-1', 'c', 3000], ['3-2', 'd', 2600], ['3-3', 'e', 2400], ['3-4', 'f', 2400]]) { + reports.push(await playLevel(label, idx, runMs)) + } + + const bad = reports.filter((r) => r.marioErr || r.pageErrors.length) + const audioOk = reports.filter((r) => r.audio.includes('running')).length >= 2 + if (pageErrors.length) console.error('[verify] 页面 console 错误:', pageErrors.slice(0, 8)) + if (bad.length === 0 && audioOk && pageErrors.length === 0) { + console.log('[verify] PASS:五关全部正常,无循环异常,BGM 引擎运行') + } else { + console.error('[verify] FAIL', JSON.stringify({ bad, audioOk })) + app.exit(1) + return + } + app.exit(0) +} + +app.whenReady().then(() => main().catch((err) => { console.error('[verify] 失败:', err.message); app.exit(1) })) +setTimeout(() => { console.error('[verify] 总超时'); app.exit(1) }, 180000) diff --git a/electron/preload.cjs b/electron/preload.cjs index 75ba6c3..69d07f0 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -14,6 +14,14 @@ contextBridge.exposeInMainWorld('desktop', { checkUpdate: (silent = false) => ipcRenderer.invoke('nlg:check-update', silent), // 取消正在进行的更新下载 cancelUpdateDownload: () => ipcRenderer.invoke('nlg:cancel-update-download'), + // 切换屏幕模式:window 普通窗口 / windowFull 窗口全屏 / full 真全屏 + setScreenMode: (mode) => ipcRenderer.invoke('nlg:set-screen-mode', mode), + // 订阅屏幕模式变化(Esc 退全屏、菜单 F11 等原生状态变化时同步),返回取消订阅函数 + onScreenModeChanged: (cb) => { + const handler = (_e, mode) => cb(mode) + ipcRenderer.on('nlg:screen-mode-changed', handler) + return () => ipcRenderer.removeListener('nlg:screen-mode-changed', handler) + }, // 运行环境版本信息 versions: { electron: process.versions.electron, diff --git a/mario-jump-1.png b/mario-jump-1.png new file mode 100644 index 0000000..53ee30f Binary files /dev/null and b/mario-jump-1.png differ diff --git a/mario-jump-2.png b/mario-jump-2.png new file mode 100644 index 0000000..5c8cbd4 Binary files /dev/null and b/mario-jump-2.png differ diff --git a/mario-jumpbuf-air.png b/mario-jumpbuf-air.png new file mode 100644 index 0000000..9c9cd9b Binary files /dev/null and b/mario-jumpbuf-air.png differ diff --git a/mario-verify-a-1-1.png b/mario-verify-a-1-1.png new file mode 100644 index 0000000..6f181e1 Binary files /dev/null and b/mario-verify-a-1-1.png differ diff --git a/mario-verify-a-start.png b/mario-verify-a-start.png new file mode 100644 index 0000000..44678fc Binary files /dev/null and b/mario-verify-a-start.png differ diff --git a/mario-verify-b-2-1.png b/mario-verify-b-2-1.png new file mode 100644 index 0000000..5c4eafd Binary files /dev/null and b/mario-verify-b-2-1.png differ diff --git a/mario-verify-b-pipes.png b/mario-verify-b-pipes.png new file mode 100644 index 0000000..83a1532 Binary files /dev/null and b/mario-verify-b-pipes.png differ diff --git a/mario-verify-c-2-2.png b/mario-verify-c-2-2.png new file mode 100644 index 0000000..e9737a6 Binary files /dev/null and b/mario-verify-c-2-2.png differ diff --git a/mario-verify-c-3-1.png b/mario-verify-c-3-1.png new file mode 100644 index 0000000..9d74444 Binary files /dev/null and b/mario-verify-c-3-1.png differ diff --git a/mario-verify-c-later.png b/mario-verify-c-later.png new file mode 100644 index 0000000..8b08f09 Binary files /dev/null and b/mario-verify-c-later.png differ diff --git a/mario-verify-d-2-3.png b/mario-verify-d-2-3.png new file mode 100644 index 0000000..fd82315 Binary files /dev/null and b/mario-verify-d-2-3.png differ diff --git a/mario-verify-d-3-2.png b/mario-verify-d-3-2.png new file mode 100644 index 0000000..c39aaf5 Binary files /dev/null and b/mario-verify-d-3-2.png differ diff --git a/mario-verify-d-goomba-pipe.png b/mario-verify-d-goomba-pipe.png new file mode 100644 index 0000000..63b653f Binary files /dev/null and b/mario-verify-d-goomba-pipe.png differ diff --git a/mario-verify-debug-a.png b/mario-verify-debug-a.png new file mode 100644 index 0000000..162ed67 Binary files /dev/null and b/mario-verify-debug-a.png differ diff --git a/mario-verify-e-2-4.png b/mario-verify-e-2-4.png new file mode 100644 index 0000000..6cc6988 Binary files /dev/null and b/mario-verify-e-2-4.png differ diff --git a/mario-verify-e-3-3.png b/mario-verify-e-3-3.png new file mode 100644 index 0000000..997cab4 Binary files /dev/null and b/mario-verify-e-3-3.png differ diff --git a/mario-verify-f-3-4.png b/mario-verify-f-3-4.png new file mode 100644 index 0000000..ef45f37 Binary files /dev/null and b/mario-verify-f-3-4.png differ diff --git a/mario-verify-select.png b/mario-verify-select.png new file mode 100644 index 0000000..73060cb Binary files /dev/null and b/mario-verify-select.png differ diff --git a/src/components/ScreenModeToggle.vue b/src/components/ScreenModeToggle.vue new file mode 100644 index 0000000..5d98cf3 --- /dev/null +++ b/src/components/ScreenModeToggle.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/src/composables/useScreenMode.js b/src/composables/useScreenMode.js new file mode 100644 index 0000000..1cbe9b4 --- /dev/null +++ b/src/composables/useScreenMode.js @@ -0,0 +1,72 @@ +// 屏幕模式(模块级单例,多视图共享状态): +// - 桌面端(Electron):window / windowFull / full 三档,经 IPC 由主进程切换窗口; +// 菜单 F11 与 Esc 退全屏等原生变化通过 onScreenModeChanged 回同步 +// - 浏览器端:仅 full(Fullscreen API),windowFull 请求会被映射为 full +import { ref } from 'vue' + +const desktop = typeof window !== 'undefined' && !!window.desktop +const mode = ref('window') // window / windowFull / full +let installed = false + +function notify(m) { + if (mode.value !== m) { + mode.value = m + } +} + +function install() { + if (installed || typeof window === 'undefined') return + installed = true + if (desktop) { + window.desktop.onScreenModeChanged?.(notify) + } else { + // 浏览器:Fullscreen API 状态追踪 + F11 切换 + document.addEventListener('fullscreenchange', () => { + notify(document.fullscreenElement ? 'full' : 'window') + }) + window.addEventListener('keydown', (e) => { + if (e.key === 'F11') { + e.preventDefault() + setScreenMode(mode.value === 'full' ? 'window' : 'full') + } + }) + } +} + +// 切换屏幕模式;浏览器端 windowFull 落为 full +async function setScreenMode(m) { + install() + let target = 'window' + if (m === 'full') target = 'full' + else if (m === 'windowFull') target = desktop ? 'windowFull' : 'full' + if (desktop) { + try { + const r = await window.desktop.setScreenMode(target) + notify(r?.mode || target) + } catch { + /* 主进程异常时保持原状态 */ + } + } else if (target === 'full') { + try { + await document.documentElement.requestFullscreen() + } catch { + /* 用户拒绝或环境不支持 */ + } + } else if (document.fullscreenElement) { + try { + await document.exitFullscreen() + } catch { + /* 忽略 */ + } + } + return mode.value +} + +function toggleFull() { + return setScreenMode(mode.value === 'full' ? 'window' : 'full') +} + +export function useScreenMode() { + install() + return { mode, isDesktop: desktop, setScreenMode, toggleFull } +} diff --git a/src/games/components/MarioGame.vue b/src/games/components/MarioGame.vue index 1ed5a2c..25ba8f1 100644 --- a/src/games/components/MarioGame.vue +++ b/src/games/components/MarioGame.vue @@ -1,904 +1,1691 @@ + + - - diff --git a/src/games/components/StarveGame.vue b/src/games/components/StarveGame.vue index 32251e9..c619b79 100644 --- a/src/games/components/StarveGame.vue +++ b/src/games/components/StarveGame.vue @@ -5,7 +5,7 @@ // 主循环、玩家交互、库存装备、联机快照、存档、渲染调度与 UI import { ref, onMounted, onBeforeUnmount } from 'vue' import { createLoop, Keys, randInt } from '../engine' -import { skinByCode, drawPlayerFig } from '../starveSkins' +import { skinByCode, drawPlayerFig, drawGuard, IMPACT_T, SWING_RATE, SKINS } from '../starveSkins' import { CAP, DAY_LEN, cookPot, DECOR } from '../starve/data' import { seasonOf, daySplit, phaseOf, ambientTemp, rollWeather, tickWeather, updateBodyTemp } from '../starve/climate' import { @@ -13,7 +13,12 @@ import { genIsland, planEntities, genDecor, buildTilePatterns, drawTiles, configureWorld, } from '../starve/world' import { buildIcons, drawEnt, drawMob, drawFirePainter, drawDecor } from '../starve/paint' +import { spawnFx, updateFx, drawFx } from '../starve/fx' import { updateMob, moveMob } from '../starve/mobs' +import { createEntGrid } from '../starve/spatial' +import { genCave } from '../starve/cave' +import { sfx, setMood, startMusic, stopMusic } from '../starve/audio' +import { newGuide, guideEvent, guideText, guideDone, GUIDE_REWARD } from '../starve/guide' import { activateMods, callHook, callHandled, callAllows, collectModSaves, applyModSaves } from '../starve/mods/registry' // ---- mod 运行时注册表:以下 let 遮蔽基表,激活 mod 后指向合并表(核心其余代码无感) ---- @@ -52,6 +57,8 @@ const san = ref(CAP.san) const temp = ref(20) const day = ref(1) const cursorMode = ref('normal') +const cursorPos = ref({ x: -100, y: -100 }) +const TAU = Math.PI * 2 const seasonTag = ref({ name: '秋', dayIn: 1, len: 20, tint: '#c98548', code: 'autumn' }) const tips = ref([]) const hint = ref('') @@ -89,10 +96,11 @@ let joyBaseY = 0 const ICONS = buildIcons() // {url, cv} // 切换 mod 运行时:重定向遮蔽表 + 补齐 mod 物品图标 +let uiWorldScale = 1 // 开始面板选择的地图倍率(新开局/联机房主生效;联机经全量快照 wscale 同步客机) function applyModRuntime(rt) { modRt = rt - // 应用 mod 声明的世界缩放:必须在任何瓦片数组分配(genWorldAll / 客机 tiles 分配)之前 - configureWorld(rt.flags?.worldScale || 1) + // 应用世界缩放:mod 声明的 flags 与开始面板倍率取更大者;必须在任何瓦片数组分配(genWorldAll / 客机 tiles 分配)之前 + configureWorld(Math.max(rt.flags?.worldScale || 1, uiWorldScale || 1)) TUNE = rt.tune ITEMS = rt.items RECIPES = rt.recipes @@ -170,6 +178,7 @@ let tilePats = null let decoSpots = [] let explored = null let entities = [] +const entGrid = createEntGrid() // 静止实体空间索引(兔子等 mobile 实体走补扫),高频近邻查询用 let monsters = [] let fires = [] let players = [] @@ -184,12 +193,23 @@ let kills = { spider: 0, hound: 0, shadow: 0, treeguard: 0, other: 0 } let treesChopped = 0 let houndPlan = { nextDay: 5, at: 40, warnT: 0, doneToday: false } let deer = { spawned: false, warnT: 0 } +let goose = { spawned: false, warnT: 0 } // 春季麋鹿鹅 +let dfly = { spawned: false, warnT: 0 } // 夏季龙蝇 +let guide = null // 新手目标引导状态(null = 不需要引导的旧档) +const guideLabel = ref('') +// ---- 洞穴(世界交换)---- +let caveData = null // 洞穴世界:{ tiles, road, ents, mons, lights, spawn, explored } +let caveOn = false // 当前是否身处洞穴 +let owStash = null // 进洞时的地表状态暂存 +const bossBar = ref(null) // Boss 血条:{ name, pct } | null +const caveOnUi = ref(false) // 洞穴指示(UI 镜像) let shakeT = 0 let shakeAmp = 0 let flashRed = 0 let potOpenId = 0 let boxOpenId = 0 const BOX_SLOTS = 12 +const ICEBOX_SLOTS = 9 // 建造/迁移:选位置放置(不落在脚下) let placeMode = null // { code, name, cost, kind:'ent'|'fire', pit?, relocateId?, extra? } let placeCursor = { x: 0, y: 0 } @@ -523,6 +543,7 @@ function damagePlayer(p, dmg, label) { if (best.dur <= 0) breakEquip(p, bestSlot, `${it.name}碎裂了!`) } p.hp = Math.max(0, p.hp - real) + if (p === player && real > 0) sfx('hurt') if (label && real >= 25) ptip(p, label) if (p.hp <= 0) die(p) } @@ -532,6 +553,7 @@ function die(p) { p.dead = true p.fallT = 0 p.target = null + if (p === player) sfx('death') ptip(p, '你死了……') if (!coop) { gameOver() @@ -580,6 +602,13 @@ function makeEnt(type, x, y, extra = {}) { while (e.mx.slots.length < BOX_SLOTS) e.mx.slots.push(null) e.mx.open = false } + if (type === 'icebox') { + if (!e.mx) e.mx = {} + if (!Array.isArray(e.mx.slots)) e.mx.slots = makeSlots(ICEBOX_SLOTS) + while (e.mx.slots.length < ICEBOX_SLOTS) e.mx.slots.push(null) + e.mx.open = false + } + if (type === 'beebox') e.mx = { honey: 0, beeT: 45 } if (type === 'tentacle') { e.state = 'lurk'; e.atkT = 0; e.cd = 0; e.dir = 1 } if (type === 'rabbit') { e.vx = 0; e.vy = 0; e.dir = 1; e.hop = Math.random() * 7; e.flee = false; e.wanderT = 0 } if (type === 'rabbithole') { e.respawnT = 0; e.rabbitId = 0; e.solid = false } @@ -591,11 +620,15 @@ function makeEnt(type, x, y, extra = {}) { function addEntity(type, x, y, extra) { const e = makeEnt(type, x, y, extra) entities.push(e) + entGrid.add(e) markAdded(e) return e } function removeEntity(e) { - entities = entities.filter((x) => x !== e) + e._gone = true // O(1) 存活判断(target 引用校验等),配合网格删除 + const i = entities.indexOf(e) + if (i >= 0) entities.splice(i, 1) + entGrid.remove(e) markRemoved(e) } function dropLoot(x, y, code, n, fresh = 1) { @@ -607,7 +640,7 @@ function spawnMob(kind, x, y, extra = {}) { const m = { id: mid++, kind, x, y, hp: conf.hp, cd: 0, shake: 0, bob: Math.random() * 7, dir: 1, moving: false, - state: kind === 'treeguard' || kind === 'deerclops' ? 'chase' : '', + state: kind === 'treeguard' || kind === 'deerclops' || kind === 'moosegoose' || kind === 'dragonfly' ? 'chase' : '', t: 0, awayT: 0, wanderT: 0, loyalT: 0, angryT: 0, life: 15, ...extra, } @@ -616,7 +649,9 @@ function spawnMob(kind, x, y, extra = {}) { return m } function removeMob(m) { - monsters = monsters.filter((x) => x !== m) + m._gone = true + const i = monsters.indexOf(m) + if (i >= 0) monsters.splice(i, 1) } function killMob(m, credit) { removeMob(m) @@ -635,7 +670,16 @@ function killMob(m, credit) { ;(MOB_DROPS[m.kind] || []).forEach(([prob, code, n]) => { if (Math.random() < prob) dropLoot(m.x, m.y, code, n) }) - if (m.kind === 'deerclops') tip('独眼巨鹿倒下了!它掉落了眼球与大肉') + if (m.kind === 'deerclops') { + tip('独眼巨鹿倒下了!它掉落了眼球与大肉') + sfx('bossfall') + } else if (m.kind === 'moosegoose') { + tip('麋鹿鹅倒下了!掉落鹅毛与大肉') + sfx('bossfall') + } else if (m.kind === 'dragonfly') { + tip('龙蝇熄灭了!掉落炽热的宝石') + sfx('bossfall') + } callHook(modRt, 'onKillMob', gCtx, m, credit) } // 巨鹿拆家:建筑损毁并崩出部分材料 @@ -687,9 +731,96 @@ function genWorldAll() { } }) plan.frogs.forEach((f) => spawnMob('frog', f.x, f.y)) + entGrid.rebuild(entities) + // 重生成地表时同步丢弃旧洞穴(新开局/旧档迁移/尺寸护栏都走这里) + caveData = null + caveOn = false + caveOnUi.value = false callHook(modRt, 'onWorldGen', gCtx) } +// ===================================================================== +// 洞穴:与地表同尺寸的世界交换(复用全部现有模拟/绘制/点击系统) +// ===================================================================== +function genCaveWorld() { + const c = genCave() + caveData = { + tiles: c.tiles, + road: new Uint8Array(GW * GH), // 洞内无道路提速 + ents: c.ents.map(({ type, x, y }) => makeEnt(type, x, y)), + mons: [], + lights: c.lights, + spawn: c.spawn, + explored: new Uint8Array(GW * GH).fill(1), // 洞内无战争迷雾 + } + // 石虾群(借用 spawnMob 的标准字段;临时切 monsters 绑定让石虾进入洞穴数组) + const savedMons = monsters + monsters = caveData.mons + for (let i = 0; i < 5; i++) { + const ang = Math.random() * Math.PI * 2 + const d = 260 + Math.random() * 520 + const sx = Math.max(60, Math.min(WORLD.w - 60, caveData.spawn.x + Math.cos(ang) * d)) + const sy = Math.max(60, Math.min(WORLD.h - 60, caveData.spawn.y + Math.sin(ang) * d)) + spawnMob('slurtle', sx, sy) + } + monsters = savedMons +} + +function enterCave() { + if (caveOn || !player) return + owStash = { tiles, road, decoSpots, explored, entities, monsters, px: player.x, py: player.y } + if (!caveData) genCaveWorld() + tiles = caveData.tiles + road = caveData.road + decoSpots = [] + explored = caveData.explored + entities = caveData.ents + monsters = caveData.mons + player.x = caveData.spawn.x + player.y = caveData.spawn.y + player.target = null + entGrid.rebuild(entities) + caveOn = true + caveOnUi.value = true + tip('你沿着石阶爬进了幽深的洞穴……备好光源!') + sfx('dig') +} + +function exitCave() { + if (!caveOn || !owStash) return + caveData.ents = entities + caveData.mons = monsters + tiles = owStash.tiles + road = owStash.road + decoSpots = owStash.decoSpots + explored = owStash.explored + entities = owStash.entities + monsters = owStash.monsters + player.x = owStash.px + player.y = owStash.py + player.target = null + entGrid.rebuild(entities) + caveOn = false + caveOnUi.value = false + tip('你回到了阳光下的荒野') + sfx('dig') +} + +// 洞内光照判定:脚下有未采集的荧光草 / 自己举火把 / 戴着点亮的矿工帽 +function litAtCave(x, y) { + const cands = entGrid.query(x, y, TUNE.flowerLightR + 40) + for (const e of cands) { + if (e.type === 'lightflower' && !e.stub && Math.hypot(e.x - x, e.y - y) < TUNE.flowerLightR) return true + } + for (const p2 of players) { + if (p2.dead) continue + const h = p2.equip.head + if (h?.code === 'minerhat' && (h.dur ?? 0) > 0 && Math.hypot(p2.x - x, p2.y - y) < 130) return true + if (p2.equip.hand?.code === 'torch' && Math.hypot(p2.x - x, p2.y - y) < 105) return true + } + return false +} + // ===================================================================== // 光照 / 温度辅助 // ===================================================================== @@ -748,13 +879,18 @@ function pageLandscapeOn() { } // 页内横屏是 CSS rotate(-90deg):屏幕坐标要映回画布本地坐标 function eventToWorld(ev) { + const { x: lx, y: ly } = eventToLocal(ev) + return { x: lx + camX, y: ly + camY } +} +// 画布本地坐标(无相机偏移;自绘光标用) +function eventToLocal(ev) { const cv = canvas.value const rect = cv.getBoundingClientRect() const nx = (ev.clientX - rect.left) / (rect.width || 1) const ny = (ev.clientY - rect.top) / (rect.height || 1) const lx = pageLandscapeOn() ? (1 - ny) * cv.width : nx * cv.width const ly = pageLandscapeOn() ? nx * cv.height : ny * cv.height - return { x: lx + camX, y: ly + camY } + return { x: lx, y: ly } } function screenDeltaToLocal(dx, dy) { return pageLandscapeOn() ? { x: -dy, y: dx } : { x: dx, y: dy } @@ -822,6 +958,7 @@ function onCanvasContextMenu(ev) { if (callHandled(modRt, 'onRightClick', gCtx, wxp, wyp, ev)) suppressClick = true } function onCanvasPointerMove(ev) { + cursorPos.value = eventToLocal(ev) if (!playing.value || !player || player.dead || waitingWorld.value) { updateCursor(ev) return @@ -877,14 +1014,14 @@ function onCanvasClick(ev) { if (f) return setMyTarget({ kind: 'fire', id: f.fid }) let ent = null let ed = Infinity - entities.forEach((e) => { - if (e.deco && e.type !== 'stump' && !modRt.entKinds[e.type]?.interact) return - if (e.stub) return + for (const e of entGrid.query(wxp, wyp, 96)) { + if (e.deco && e.type !== 'stump' && !modRt.entKinds[e.type]?.interact) continue + if (e.stub) continue // 栅栏等 solid 不可点选;门始终可点以开关 - if (e.solid && e.type !== 'gate' && e.type !== 'campgate') return + if (e.solid && e.type !== 'gate' && e.type !== 'campgate') continue const d = Math.hypot(e.x - wxp, e.y - wyp) if (d < Math.max(26, e.size * 0.9) && d < ed) { ed = d; ent = e } - }) + } if (ent) return setMyTarget({ kind: 'ent', id: ent.id }) setMyTarget({ kind: 'walk', x: wxp, y: wyp }) } @@ -893,6 +1030,11 @@ function updateCursor(ev) { cursorMode.value = 'normal' return } + // 建造模式优先:锤子光标 + if (placeMode) { + cursorMode.value = 'build' + return + } const { x: wxp, y: wyp } = eventToWorld(ev) let mon = null let md = Infinity @@ -907,12 +1049,12 @@ function updateCursor(ev) { } const f = fires.find((f2) => Math.hypot(f2.x - wxp, f2.y - wyp) < 36) if (f) { - cursorMode.value = 'hand' + cursorMode.value = 'fire' return } let ent = null let ed = Infinity - entities.forEach((e) => { + entGrid.query(wxp, wyp, 96).forEach((e) => { if (e.deco && e.type !== 'stump') return if (e.stub) return if (e.solid && e.type !== 'gate' && e.type !== 'campgate') return @@ -921,10 +1063,15 @@ function updateCursor(ev) { }) if (ent) { const attackable = ent.hp > 0 && (ent.mobile || ent.nest || ent.lurk) - cursorMode.value = attackable ? 'attack' : 'hand' + if (attackable) { + cursorMode.value = 'attack' + return + } + // 可采集资源(树/石/草丛等)→ 斧头光标;其余交互物 → 手 + cursorMode.value = (ent.drops && !ent.mobile) || ent.type === 'stump' ? 'gather' : 'hand' return } - cursorMode.value = 'normal' + cursorMode.value = 'walk' } function onCanvasLeave() { cursorMode.value = 'normal' @@ -999,23 +1146,21 @@ function isAutoInteractSkipEnt(e) { if (!e) return true // 篝火另有添柴;房屋/地基休息改名不进自动交互 if (e.type === 'camphouse' || e.type === 'houseplan' || e.type === 'resttent') return true + // 子地图入口(地窖/畜棚等)交互会打开面板,自动交互不应触发 + if (modRt.entKinds[e.type]?.conf?.submap) return true return false } function pickAutoInteractTarget() { if (!player || player.dead) return null let best = null let bd = 150 - entities.forEach((e) => { + entGrid.query(player.x, player.y, 160).forEach((e) => { if (!canInteractEnt(e) || isAutoInteractSkipEnt(e)) return const d = Math.hypot(e.x - player.x, e.y - player.y) if (d < bd) { bd = d; best = { kind: 'ent', id: e.id } } }) - // 可交互友好生物(劳工绑定等);不含篝火、不含纯攻击 - monsters.forEach((m) => { - if (!modRt.mobKinds[m.kind]?.interact) return - const d = Math.hypot(m.x - player.x, m.y - player.y) - if (d < Math.min(bd, 90)) { bd = d; best = { kind: 'mon', id: m.id } } - }) + // 不自动把任何生物/火堆当目标:喂狗/切斯特/雇佣/添柴走手动(点击或近距动作条), + // 避免自动交互追着小狗跑、被误读成"自动交互攻击小狗",也不会悄悄消耗燃料 return best } function tickAutoInteract() { @@ -1026,6 +1171,8 @@ function tickAutoInteract() { } function doQuickInteract() { if (!playing.value || !player || player.dead || waitingWorld.value) return + // 子地图(房屋内室 / 农场地窖 / 牧场畜棚)打开期间屏蔽世界层 E,防误切自动交互 + if (player && !callAllows(modRt, 'allowsPlayerMove', gCtx, player)) return // E:自动交互开关(开=持续寻找可交互物,关=停止;不含篝火/房屋) autoInteractOn.value = !autoInteractOn.value if (autoInteractOn.value) { @@ -1046,7 +1193,7 @@ function refreshNearActions() { return } const list = [] - entities.forEach((e) => { + entGrid.query(player.x, player.y, 150).forEach((e) => { if (!canInteractEnt(e)) return const d = Math.hypot(e.x - player.x, e.y - player.y) if (d > 130) return @@ -1079,9 +1226,11 @@ function refreshNearActions() { // 空格:就近采集(掉落物 / 陷阱 / 好锅 / 资源) function doGather() { if (!player || player.dead) return + // 子地图打开期间屏蔽世界层操作 + if (!callAllows(modRt, 'allowsPlayerMove', gCtx, player)) return let best = null let bd = 130 - entities.forEach((e) => { + entGrid.query(player.x, player.y, 150).forEach((e) => { if (!canInteractEnt(e)) return const d = Math.hypot(e.x - player.x, e.y - player.y) if (d < bd) { bd = d; best = e } @@ -1091,6 +1240,8 @@ function doGather() { // F:就近攻击敌对目标(不含猪牛,防误伤) function doAttack() { if (!player || player.dead) return + // 子地图打开期间屏蔽世界层操作(防退出后角色突然冲出去攻击) + if (!callAllows(modRt, 'allowsPlayerMove', gCtx, player)) return let best = null let bd = 170 monsters.forEach((m) => { @@ -1098,7 +1249,7 @@ function doAttack() { const d = Math.hypot(m.x - player.x, m.y - player.y) if (d < bd) { bd = d; best = { kind: 'mon', id: m.id } } }) - entities.forEach((e) => { + entGrid.query(player.x, player.y, 190).forEach((e) => { if (!(e.mobile || e.nest || e.lurk)) return if (e.lurk && e.state !== 'up') return const d = Math.hypot(e.x - player.x, e.y - player.y) @@ -1117,7 +1268,8 @@ function pursueTarget(p, dt) { return } const ref = t.ref - const alive = t.kind === 'mon' ? monsters.includes(ref) : t.kind === 'fire' ? fires.includes(ref) : entities.includes(ref) + // 存活判断:实体/生物走 _gone 标记(removeMob/removeEntity/快照批量移除都会置位),火焰数组小直接 includes + const alive = t.kind === 'mon' ? !ref._gone : t.kind === 'fire' ? fires.includes(ref) : !ref._gone if (!alive) { p.target = null; return } const reach = t.kind === 'mon' ? (ref.kind === 'deerclops' ? 96 : ref.kind === 'treeguard' ? 68 : 34) @@ -1159,6 +1311,12 @@ function hitMonster(p, m) { useWeaponDur(p) m.hp -= dmg m.shake = 0.25 + // 命中特效:延迟到挥刺动画的实际命中帧爆开 + const dly = (IMPACT_T.attack ?? 0.35) / SWING_RATE + const dirAway = m.x >= p.x ? 0 : Math.PI + spawnFx('hit', m.x, m.y - 14, { delay: dly }) + spawnFx('ring', m.x, m.y - 14, { delay: dly }) + spawnFx('blood', m.x, m.y - 12, { delay: dly, dir: dirAway, spread: 0.85 }) if (m.kind === 'pig') { m.loyalT = 0 m.owner = null @@ -1186,6 +1344,7 @@ function hitEntity(p, e) { } if (e.loot) { addItem(p, e.code, e.n, e.fresh ?? 1) + if (p === player) sfx('pickup') ptip(p, `捡起 ${ITEMS[e.code]?.name || ''} x${e.n}`) removeEntity(e) p.target = null @@ -1244,6 +1403,29 @@ function hitEntity(p, e) { p.target = null return } + if (e.type === 'pond') { + if (p === player) tryFish(e) + p.target = null + return + } + if (e.type === 'beebox') { + if (p === player) harvestBeebox(p, e) + p.target = null + return + } + if (e.type === 'cavein' || e.type === 'caveout') { + if (p === player) { + if (coop) { + ptip(p, '联机模式下洞穴暂不可用(单人冒险专属)') + } else if (caveOn) { + exitCave() + } else { + enterCave() + } + } + p.target = null + return + } if (e.struct) { if (p === player) { const t = RES[e.type]?.techTier @@ -1260,6 +1442,8 @@ function hitEntity(p, e) { swingAct(p, 'dig') useToolDur(p) addItem(p, 'log', 1) + if (p === player) sfx('dig', (IMPACT_T.dig ?? 0.3) / SWING_RATE) + spawnFx('wood', e.x, e.y - 12, { delay: (IMPACT_T.dig ?? 0.3) / SWING_RATE, dir: p.dir > 0 ? 0.35 : Math.PI - 0.35, spread: 0.85 }) ptip(p, '挖出树桩 · 木头 +1(此处不再长树)') e.respawnAs = null e.respawnT = 0 @@ -1274,6 +1458,10 @@ function hitEntity(p, e) { useWeaponDur(p) e.hp -= dmg e.shake = 0.25 + const dlyA = (IMPACT_T.attack ?? 0.35) / SWING_RATE + if (p === player) sfx('hitmob', dlyA) + spawnFx(e.type === 'spidernest' || e.type === 'beehive' ? 'silk' : 'hit', e.x, e.y - 16, { delay: dlyA }) + spawnFx('blood', e.x, e.y - 12, { delay: dlyA, dir: e.x >= p.x ? 0 : Math.PI, spread: 0.85 }) markDirty(e) if (e.type === 'beehive') { const bees = monsters.filter((m) => m.kind === 'bee' && m.hiveId === e.id).length @@ -1302,6 +1490,26 @@ function hitEntity(p, e) { if (e.tool) useToolDur(p) e.left -= 1 e.shake = 0.2 + // 采集命中特效:按资源类型出木屑/碎石/草叶,延迟对齐挥击命中帧 + const actG = e.tool === 'axe' ? 'chop' : e.tool === 'pick' ? 'mine' : 'chop' + const fxType = e.type === 'tree' ? 'wood' + : e.type === 'rock' ? 'stone' + : e.type === 'goldrock' ? 'gold' + : (e.type === 'grass' || e.type === 'sapling' || e.type === 'berry' || e.type === 'reeds' || e.type === 'flower') ? 'leaf' + : (e.type === 'spidernest' || e.type === 'beehive') ? 'silk' + : 'dust' + spawnFx(fxType, e.x, e.y - Math.min(46, (e.size || 40) * 0.7), { + delay: (IMPACT_T[actG] ?? 0.5) / SWING_RATE, + dir: e.x >= p.x ? 0.35 : Math.PI - 0.35, + spread: 0.85, + }) + // 采集音效:按工具类型区分砍/挖/采集,延迟对齐挥击命中帧 + if (p === player) { + const hitDly = (IMPACT_T[actG] ?? 0.5) / SWING_RATE + if (e.tool === 'axe') sfx('chop', hitDly) + else if (e.tool === 'pick') sfx('mine', hitDly) + else sfx('gather', hitDly) + } if (e.left > 0) { markDirty(e); return } // 采尽结算 Object.entries(e.drops).forEach(([code, n]) => { @@ -1309,6 +1517,7 @@ function hitEntity(p, e) { addItem(p, code, cnt) addScore(cnt * 2, p) }) + if (p === player) guideFeed('gather', { type: e.type }) ptip(p, Object.entries(e.drops).map(([code, n]) => `${ITEMS[code]?.name} +${n}`).join(' ')) callHook(modRt, 'onGather', gCtx, p, e) if (e.type === 'flower') { @@ -1351,9 +1560,22 @@ function killEntity(p, e) { addScore(30, p) ptip(p, '捣毁了蜘蛛巢!') } else if (e.type === 'beehive') { - dropLoot(e.x, e.y, 'honey', 3) + dropLoot(e.x, e.y, 'honeycomb', 1) + dropLoot(e.x, e.y, 'honey', 2) addScore(20, p) - ptip(p, '捣毁了蜂巢,蜂蜜洒了一地') + ptip(p, '捣毁了蜂巢!掉落蜂巢块(可建蜂箱)与蜂蜜') + } else if (e.type === 'gianthive') { + // 巨型蜂巢:蜂王镇的入口 + dropLoot(e.x, e.y, 'honeycomb', 2) + addScore(50, p) + spawnMob('beequeen', e.x + 40, e.y) + for (let i = 0; i < 3; i++) { + const ang = Math.random() * Math.PI * 2 + spawnMob('bee', e.x + Math.cos(ang) * 44, e.y + Math.sin(ang) * 44, { angryAt: p, life: 25 }) + } + tip('巨型蜂巢碎了——蜂王带着护卫蜂倾巢而出!') + shake(8) + sfx('boss') } else if (e.type === 'tentacle') { dropLoot(e.x, e.y, 'monstermeat', 1) if (Math.random() < 0.5) dropLoot(e.x, e.y, 'tentaclespike', 1) @@ -1380,6 +1602,7 @@ function feedFire(p, f) { payCost(p, { [code]: 1 }) const wasOut = f.ttl <= 0 f.ttl = Math.min(f.max, Math.max(0, f.ttl) + add) + if (p === player) sfx('fire') ptip(p, wasOut && f.pit ? '火堆重新燃起' : `添了把${ITEMS[code].name} · 火势更旺`) return } @@ -1389,7 +1612,10 @@ function feedFire(p, f) { function ensureBoxSlots(e) { if (!e.mx) e.mx = {} if (!Array.isArray(e.mx.slots)) e.mx.slots = makeSlots(BOX_SLOTS) - while (e.mx.slots.length < BOX_SLOTS) e.mx.slots.push(null) + // 冰箱等小容量容器不扩容 + if (e.type !== 'icebox') { + while (e.mx.slots.length < BOX_SLOTS) e.mx.slots.push(null) + } if (e.mx.slots.length > BOX_SLOTS) e.mx.slots = e.mx.slots.slice(0, BOX_SLOTS) return e.mx.slots } @@ -1496,6 +1722,7 @@ function togglePack() { // 点击物品格:吃 / 用 / 装备 / 放置;箱子打开时改为存入 function clickSlot(area, i) { if (!player || player.dead || !playing.value) return + if (dragJustDone) return if (boxOpenId) { if (coop && !coop.isHost) { coop.send('starve_input', { seat: coop.mySeat, act: { type: 'boxdep', id: boxOpenId, area, i } }) @@ -1521,12 +1748,139 @@ function clickSlot(area, i) { } function clickBoxSlot(bi) { if (!player || player.dead || !boxOpenId) return + if (dragJustDone) return if (coop && !coop.isHost) { coop.send('starve_input', { seat: coop.mySeat, act: { type: 'boxwit', id: boxOpenId, i: bi } }) return } withdrawFromBox(player, bi) } +// ---- 物品拖拽:物品栏/背包/箱子 之间拖动换位与堆叠合并;拖到画布丢在地上,丢进火堆自动添柴 ---- +const drag = ref(null) // { area:'main'|'pack'|'box', i, code, name, n, x, y, moved } +let dragJustDone = false // 拖拽刚结束:吞掉随后的 click,避免误触发"使用" +function dragAllowed() { + if (coop && !coop.isHost) { + tip('联机客户端暂不支持拖拽,请用点击操作') + return false + } + return !!(player && !player.dead && playing.value) +} +function dragSlotGet(area, i) { + if (area === 'box') { + const e = entities.find((x) => x.id === boxOpenId) + return ensureBoxSlots(e)?.[i] || null + } + return slotArea(player, area)?.[i] || null +} +function dragArr(area) { + if (area === 'box') { + const e = entities.find((x) => x.id === boxOpenId) + return e ? ensureBoxSlots(e) : null + } + return slotArea(player, area) +} +function dragStart(ev, area, i) { + if (ev.button !== undefined && ev.button !== 0) return + if (!dragAllowed()) return + const s = dragSlotGet(area, i) + if (!s) return + drag.value = { area, i, code: s.code, name: s.name || s.code, n: s.n, x: ev.clientX, y: ev.clientY, moved: false } + window.addEventListener('pointermove', dragMove) + window.addEventListener('pointerup', dragEnd) + window.addEventListener('pointercancel', dragCancel) +} +function dragMove(ev) { + const d = drag.value + if (!d) return + if (!d.moved && Math.hypot(ev.clientX - d.x, ev.clientY - d.y) > 6) d.moved = true + d.x = ev.clientX + d.y = ev.clientY +} +function dragCancel() { + window.removeEventListener('pointermove', dragMove) + window.removeEventListener('pointerup', dragEnd) + window.removeEventListener('pointercancel', dragCancel) + drag.value = null +} +function dragEnd(ev) { + window.removeEventListener('pointermove', dragMove) + window.removeEventListener('pointerup', dragEnd) + window.removeEventListener('pointercancel', dragCancel) + const d = drag.value + drag.value = null + if (!d || !d.moved) return // 没怎么动 = 普通点击,交回 click 处理 + dragJustDone = true + setTimeout(() => (dragJustDone = false), 60) + const el = document.elementFromPoint(ev.clientX, ev.clientY) + const target = el?.closest?.('[data-slot]') + if (target) { + const [ta, ti] = target.getAttribute('data-slot').split(':') + dragMoveTo(d, ta, Number(ti)) + return + } + dragDropWorld(d, ev) +} +function dragMarkBox(d) { + if (d.area === 'box' || d.targetArea === 'box') { + const e = entities.find((x) => x.id === boxOpenId) + if (e) markDirty(e) + } +} +function dragMoveTo(d, ta, ti) { + if (d.area === ta && d.i === ti) return + const fromArr = dragArr(d.area) + const toArr = dragArr(ta) + if (!fromArr || !toArr) return + const s = fromArr[d.i] + if (!s) return + const t = toArr[ti] + const max = stackMax(s.code) + // 同类可堆叠且都无耐久 → 合并(新鲜度按数量加权平均) + if (t && t.code === s.code && t.n < max && s.dur == null && t.dur == null) { + const take = Math.min(s.n, max - t.n) + if (ITEMS[s.code]?.perish) { + t.fresh = ((t.fresh ?? 1) * t.n + (s.fresh ?? 1) * take) / (t.n + take) + } + t.n += take + s.n -= take + if (s.n <= 0) fromArr[d.i] = null + invDirty = true + dragMarkBox({ area: d.area, targetArea: ta }) + return + } + // 交换(跨区域也允许:拖过去,原来的换回来) + toArr[ti] = s + fromArr[d.i] = t || null + invDirty = true + dragMarkBox({ area: d.area, targetArea: ta }) +} +function dragDropWorld(d, ev) { + const el = document.elementFromPoint(ev.clientX, ev.clientY) + if (!el?.closest?.('canvas')) return // 没拖到画布上(面板/遮罩):视为放回原位 + const arr = dragArr(d.area) + const s = arr?.[d.i] + if (!s) return + const it = ITEMS[s.code] + if (s.dur != null || it?.equip) { + ptip(player, `${it?.name || d.name}有耐久,不能丢弃`) + return + } + const w = eventToWorld(ev) + // 丢进火堆添柴(木头/草/树枝) + const fuel = { log: 30, grass: 10, twig: 8 }[s.code] + const f = fuel ? fires.find((f2) => Math.hypot(f2.x - w.x, f2.y - w.y) < 60) : null + arr[d.i] = null + if (f) { + f.ttl = Math.min(f.max, Math.max(0, f.ttl) + fuel) + ptip(player, `把${it?.name || d.name}丢进了火里 · 火势更旺`) + sfx('fire') + } else { + dropLoot(w.x, w.y, s.code, s.n, s.fresh ?? 1) + ptip(player, `丢下了${it?.name || d.name}`) + sfx('gather') + } + invDirty = true +} // 使用某格物品(主机侧) function useItemAt(p, area, i) { const arr = slotArea(p, area) @@ -1560,6 +1914,19 @@ function useItemAt(p, area, i) { if (it.thermal) { return ptip(p, `保暖石热量 ${Math.round(s.dur ?? 0)}%(靠近火堆充热)`) } + // 荧光果:矿工帽补燃(洞穴光源补给线) + if (s.code === 'lightbulb') { + const h = p.equip.head + if (h?.code === 'minerhat') { + h.dur = Math.min(ITEMS.minerhat.burn, (h.dur ?? 0) + 80) + consumeSlot(arr, i) + invDirty = true + ptip(p, `荧光果给矿工帽充能 · 剩 ${Math.round(h.dur)} 秒`) + } else { + ptip(p, '荧光果会发出微光(佩戴矿工帽时可用它补燃)') + } + return + } if (it.food) { if (s.code === 'rot') return ptip(p, '这已经腐烂了,不能吃') // 喂猪结盟:附近有未结盟猪人且拿的是肉 @@ -1596,6 +1963,10 @@ function useItemAt(p, area, i) { consumeSlot(arr, i) invDirty = true swingAct(p, 'eat') + if (p === player) { + sfx('eat') + guideFeed('eat') + } const parts = [`饥饿 +${Math.round(f.hunger * st.mult)}`] if (f.hp) parts.push(`生命 ${f.hp > 0 ? '+' : ''}${st.hpOk || f.hp < 0 ? f.hp : 0}`) if (f.san) parts.push(`理智 ${f.san > 0 ? '+' : ''}${f.san}`) @@ -1680,6 +2051,12 @@ function startPlaceMode(code, r, opts = {}) { } function confirmPlaceAt(wx, wy) { if (!placeMode || !player || player.dead) return false + if (caveOn) { + tip('洞穴内无法建造,回地表再放吧') + cancelPlaceMode(true) + placeMode = null + return true + } const spot = snapToLand(tiles, wx, wy, 56) if (!spot) { tip('这里不能放,换块陆地') @@ -1735,7 +2112,10 @@ function confirmPlaceAt(wx, wy) { } invDirty = true addScore(10, player) + const placedCode = placeMode.code placeMode = null + sfx('place') + guideFeed('place', { code: placedCode }) return true } function craftFor(p, code) { @@ -1788,6 +2168,10 @@ function craftFor(p, code) { } } invDirty = true + if (p === player) { + sfx('craft') + guideFeed('craft', { code }) + } addScore(10, p) } @@ -1841,6 +2225,8 @@ const gCtx = { get worldH() { return WORLD.h }, isLand: (x, y) => !tiles || isLand(tiles, x, y), litAt: (x, y) => litAt(x, y), + // 附近实体查询(空间网格加速):返回候选数组,调用方自行精确过滤;mobile 实体已包含 + queryEnts: (x, y, r) => entGrid.query(x, y, r), // 推进游戏内时刻;跨日时与 updateDayNight 一致触发 day++ / onNewDay setDayTime: (t) => { let nt = Number(t) @@ -1889,6 +2275,8 @@ const gCtx = { m.moving = true }, swing: (p, act) => swingAct(p, act), + // 粒子特效(供 mod 使用:守卫突刺火花等) + fx: (type, x, y, o = {}) => spawnFx(type, x, y, o), heatAt: (p) => heatAt(p), cookPot: (codes) => cookPot(codes, ITEMS), // 按物品 code 对玩家施加 food 效果(食堂点餐等,不扣背包) @@ -1936,6 +2324,8 @@ function updateDayNight(dt) { } phaseName = phaseOf(seasonNow.code, dayTime) if (tickWeather(wx, dt)) tip('雨停了') + // BGM 情绪跟随:雨 > 夜 > 冬 > 昼(setMood 内部变更才重建节奏,无每帧开销) + setMood(wx.type === 'rain' ? 'rain' : phaseName === 'night' ? 'night' : seasonNow.code === 'winter' ? 'winter' : 'day') // 火焰燃烧(雨天更快) const burn = dt * (wx.type === 'rain' ? 1.6 : 1) fires.forEach((f) => { @@ -1955,11 +2345,14 @@ function updateDayNight(dt) { function onNewDay() { const prev = seasonNow seasonNow = seasonOf(day.value) + guideFeed('newday') if (seasonNow.code !== prev.code) { seasonBanner.value = `${seasonNow.name}季来临` setTimeout(() => (seasonBanner.value = ''), 4200) tip(seasonNow.code === 'winter' ? '冬季来临:注意保暖,植物停止生长' : `${seasonNow.name}季来临`) if (seasonNow.code === 'winter') deer = { spawned: false, warnT: 0 } + if (seasonNow.code === 'spring') goose = { spawned: false, warnT: 0 } + if (seasonNow.code === 'summer') dfly = { spawned: false, warnT: 0 } } wx = rollWeather(seasonNow.code) if (wx.type === 'rain') tip('下雨了……篝火消耗更快,理智缓缓流失') @@ -1990,8 +2383,9 @@ function onNewDay() { callHook(modRt, 'onNewDay', gCtx) if (!coop) { doSave(false); saveTimer = 0 } } -// 猎犬波 + 独眼巨鹿排程 +// 猎犬波 + 独眼巨鹿排程(洞穴地下无野外袭击) function updateThreats(dt) { + if (caveOn) return // 猎犬 if (day.value === houndPlan.nextDay && !houndPlan.doneToday && dayTime >= houndPlan.at && houndPlan.warnT <= 0) { if (callAllows(modRt, 'onSpawnWave', gCtx, 'hound')) { @@ -2015,6 +2409,7 @@ function updateThreats(dt) { spawnMob(kind, spot.x, spot.y) } tip('猎犬扑过来了!') + sfx('hound') } } // 巨鹿:冬季第 8 天起 @@ -2023,6 +2418,7 @@ function updateThreats(dt) { deer.warnT = 15 tip('大地在颤抖,远处传来沉重的呼吸声……') shake(3) + sfx('boss') } if (deer.warnT > 0) { deer.warnT -= dt @@ -2033,6 +2429,47 @@ function updateThreats(dt) { spawnMob('deerclops', spot.x, spot.y) tip('独眼巨鹿降临了!保护你的营地!') shake(8) + sfx('boss') + } + } + // 麋鹿鹅:春季第 5 天起 + if (seasonNow.code === 'spring' && seasonNow.dayIn >= 5 && !goose.spawned && goose.warnT <= 0 && !monsters.some((m) => m.kind === 'moosegoose') + && callAllows(modRt, 'onSpawnWave', gCtx, 'moosegoose')) { + goose.warnT = 15 + tip('远处传来扑翅般的巨响……') + shake(3) + sfx('boss') + } + if (goose.warnT > 0) { + goose.warnT -= dt + if (goose.warnT <= 0 && !goose.spawned) { + goose.spawned = true + const targetP = players.find((p) => !p.dead) || player + const spot = findLandNear(tiles, targetP.x, targetP.y, 480, 680) + spawnMob('moosegoose', spot.x, spot.y) + tip('麋鹿鹅降临了!小心它的冲撞扑砸!') + shake(8) + sfx('boss') + } + } + // 龙蝇:夏季第 5 天起 + if (seasonNow.code === 'summer' && seasonNow.dayIn >= 5 && !dfly.spawned && dfly.warnT <= 0 && !monsters.some((m) => m.kind === 'dragonfly') + && callAllows(modRt, 'onSpawnWave', gCtx, 'dragonfly')) { + dfly.warnT = 15 + tip('空气灼热起来,远处映出橙红色的光……') + shake(3) + sfx('boss') + } + if (dfly.warnT > 0) { + dfly.warnT -= dt + if (dfly.warnT <= 0 && !dfly.spawned) { + dfly.spawned = true + const targetP = players.find((p) => !p.dead) || player + const spot = findLandNear(tiles, targetP.x, targetP.y, 480, 680) + spawnMob('dragonfly', spot.x, spot.y) + tip('龙蝇降临了!它会烧掉你的营地!') + shake(8) + sfx('boss') } } // 暗影怪:理智不足才浮现(官方机制) @@ -2046,7 +2483,11 @@ function updateThreats(dt) { } }) if (monsters.some((m) => m.kind === 'shadow') && players.every((p) => p.dead || p.san >= 95)) { - monsters = monsters.filter((m) => m.kind !== 'shadow') + monsters = monsters.filter((m) => { + if (m.kind !== 'shadow') return true + m._gone = true + return false + }) } } function joyStart(e) { @@ -2197,6 +2638,16 @@ function updateEntities(dt) { e.shake = 0.5 markDirty(e) } + } else if (e.type === 'beebox') { + // 蜂箱酿蜜:上限 4,周期 45 秒 + if ((e.mx?.honey || 0) < 4 && e.mx) { + e.mx.beeT -= dt + if (e.mx.beeT <= 0) { + e.mx.honey = Math.min(4, (e.mx.honey || 0) + 1) + e.mx.beeT = 45 + markDirty(e) + } + } } else if (e.type === 'beehive') { if (e.beeCd > 0) e.beeCd -= dt } else if (e.type === 'tentacle') updateTentacle(e, dt) @@ -2300,17 +2751,25 @@ function updateSurvival(dt) { // 饥饿 p.hunger = Math.max(0, p.hunger - TUNE.hungerRate * dt) if (p.hunger <= 0) p.hp -= TUNE.starveHp * dt - // 理智:官方途径(黄昏/黑暗/雨/花环) + // 理智:官方途径(黄昏/黑暗/雨/花环)+ 洞穴黑暗 const lit = litAt(p.x, p.y) if (phaseName === 'dusk') p.san -= TUNE.duskSan * dt else if (phaseName === 'night') p.san -= (lit ? TUNE.nightLitSan : TUNE.nightDarkSan) * dt - if (wx.type === 'rain') p.san -= TUNE.rainSan * dt + if (caveOn && !litAtCave(p.x, p.y)) p.san -= TUNE.caveSan * dt + if (wx.type === 'rain' && !ITEMS[p.equip.head?.code]?.waterproof) p.san -= TUNE.rainSan * dt const head = p.equip.head if (head && ITEMS[head.code]?.sanAura) p.san += ITEMS[head.code].sanAura * dt p.san = Math.max(0, Math.min(CAP.san, p.san)) // 体温 const insul = ['body', 'head'].reduce((n, sl) => n + (ITEMS[p.equip[sl]?.code]?.insul || 0), 0) - updateBodyTemp(p, ambientTemp(seasonNow.code, phaseName, wx.type), heatAt(p), insul, dt) + const vent = ['body', 'head'].reduce((n, sl) => n + (ITEMS[p.equip[sl]?.code]?.vent || 0), 0) + // 洞内恒温凉爽(12°),地表篝火照不到地下 + updateBodyTemp(p, caveOn ? 12 : ambientTemp(seasonNow.code, phaseName, wx.type), caveOn ? 0 : heatAt(p), insul, dt, caveOn ? '' : seasonNow.code, phaseName, vent) + // 冰箱冰雾降温:靠近冰箱(130px 内)体温缓降(夏季保命) + if (p.temp > 20) { + const nearIce = entGrid.query(p.x, p.y, 130).some((e) => e.type === 'icebox' && Math.hypot(e.x - p.x, e.y - p.y) < 130) + if (nearIce) p.temp -= TUNE.iceboxCool * dt + } // 保暖石:有热量时托底体温 ;[p.slots, p.pack].filter(Boolean).forEach((area) => { area.forEach((s) => { @@ -2326,6 +2785,11 @@ function updateSurvival(dt) { p.hp -= TUNE.freezeHp * dt if (Math.random() < dt * 0.25) ptip(p, '你快冻僵了!生火取暖!') } + // 夏季中暑:体温过高持续掉血(靠近冰箱/戴夏帽缓解) + if (p.temp >= TUNE.heatAt) { + p.hp -= TUNE.heatHp * dt + if (Math.random() < dt * 0.25) ptip(p, '热得快中暑了!靠近冰箱或戴上夏帽!') + } // 计时耐久:火把(手持燃烧)、花环(佩戴枯萎) const hand = p.equip.hand if (hand?.code === 'torch') { @@ -2336,14 +2800,14 @@ function updateSurvival(dt) { head.dur -= dt if (head.dur <= 0) breakEquip(p, 'head', '花环枯萎了') } - // 查理:完全黑暗计时 → 预警 → 袭击 - if (phaseName === 'night' && !lit) { + // 查理:完全黑暗计时 → 预警 → 袭击(洞穴无查理,改为理智流失) + if (!caveOn && phaseName === 'night' && !lit) { p.darkT += dt if (p.darkT >= TUNE.charlieHit) { damagePlayer(p, TUNE.charlieDmg, '查理在黑暗中袭击了你!') p.san = Math.max(0, p.san - TUNE.charlieSan) p.darkT = TUNE.charlieHit - TUNE.charlieRe - if (p === player) { flashRed = 0.55; shake(9) } + if (p === player) { flashRed = 0.55; shake(9); sfx('charlie') } } } else { p.darkT = 0 @@ -2352,10 +2816,124 @@ function updateSurvival(dt) { if (p.hp <= 0) die(p) }) } +// ---- 钓鱼:手持钓竿点击池塘 → 等待咬钩 → 咬钩窗口内再点提竿 ---- +let fishing = null // { pondId, x, y, t, biteAt, phase:'wait'|'bite', biteT } +function tryFish(pond) { + if (fishing) { + if (fishing.phase === 'bite') { + fishing = null + useToolDur(player) + addItem(player, 'fish', 1) + addScore(6, player) + sfx('splash') + ptip(player, '钓到鱼了!') + } + return + } + if (handItem(player)?.tool !== 'fish') { + ptip(player, '需要手持钓竿(合成页「工具」,需科学机器)') + return + } + fishing = { pondId: pond.id, x: player.x, y: player.y, t: 0, biteAt: 2.5 + Math.random() * 3.5, phase: 'wait', biteT: 0 } + sfx('splash') + ptip(player, '甩竿垂钓……站在原地等鱼咬钩,咬钩后点击池塘提竿') +} +function tickFishing(dt) { + if (!fishing) return + if (!player || player.dead || !playing.value) { fishing = null; return } + const pond = entities.find((e) => e.id === fishing.pondId) + if (!pond) { fishing = null; return } + // 移动 / 换下钓竿 → 脱钓 + if (Math.hypot(player.x - fishing.x, player.y - fishing.y) > 26 || handItem(player)?.tool !== 'fish') { + if (fishing.phase === 'bite') ptip(player, '鱼跑了……') + fishing = null + return + } + if (fishing.phase === 'wait') { + fishing.t += dt + if (fishing.t >= fishing.biteAt) { + fishing.phase = 'bite' + fishing.biteT = 1.4 + sfx('splash') + ptip(player, '鱼咬钩了!快点击池塘提竿!') + } + } else { + fishing.biteT -= dt + if (fishing.biteT <= 0) { + fishing.phase = 'wait' + fishing.t = 0 + fishing.biteAt = 2.5 + Math.random() * 3.5 + ptip(player, '鱼溜了……继续等') + } + } +} +// ---- 蜂箱收蜜:无夏帽会被蜂群追蜇 ---- +function harvestBeebox(p, e) { + const honey = e.mx?.honey || 0 + if (honey <= 0) { + ptip(p, `蜂箱酿蜜中(约 ${Math.max(1, Math.ceil(e.mx?.beeT || 45))} 秒后来收)`) + return + } + addItem(p, 'honey', honey) + e.mx.honey = 0 + e.mx.beeT = 45 + e.mx.collected = (e.mx.collected || 0) + 1 + markDirty(e) + invDirty = true + sfx('pickup') + ptip(p, `收获蜂蜜 ×${honey}`) + if (p.equip.head?.code !== 'strawhat') { + for (let i = 0; i < Math.min(2, honey); i++) { + const spot = findLandNear(tiles, e.x, e.y, 10, 40) + spawnMob('bee', spot.x, spot.y, { angryAt: p, life: 16, hiveId: e.id }) + } + ptip(p, '蜜蜂被激怒了!(戴上夏帽收蜜可保平安)') + } + // 收蜜多次后蜂群壮大成巨型蜂巢(打碎召唤蜂王) + if (e.mx.collected >= TUNE.beeboxQueenAt) { + tip('蜂群越养越多,这座蜂箱膨胀成了巨型蜂巢!') + shake(4) + sfx('boss') + const gx = e.x + const gy = e.y + removeEntity(e) + addEntity('gianthive', gx, gy) + } +} +// 容器内容保鲜:木箱内食物正常腐败(补全旧版"箱内永不腐"缺口), +// 冰箱内 ×0.25 减速;腐坏就地转换为腐烂物。仅主机/单人(权威模拟侧)执行 +function tickContainers(dt) { + entities.forEach((e) => { + if (!e.mx?.slots?.length) return + const mul = e.type === 'icebox' ? TUNE.iceboxMul : 1 + for (let i = 0; i < e.mx.slots.length; i++) { + const s = e.mx.slots[i] + if (!s) continue + const it = ITEMS[s.code] + if (!it?.perish) continue + s.fresh = (s.fresh ?? 1) - (dt * mul) / (it.perish * DAY_LEN) + if (s.fresh <= 0) { + e.mx.slots[i] = { code: 'rot', n: s.n } + markDirty(e) + if (boxOpenId === e.id) invDirty = true + } + } + }) +} // 本机查理演出(预警提示;客机也本地跑,只做视觉不掉血) +// 新手引导:喂事件 → 步进 → 奖励(仅本机玩家视角) +function guideFeed(ev, p) { + const done = guideEvent(guide, ev, p) + guideLabel.value = guideText(guide) + if (done) { + addScore(GUIDE_REWARD, player) + tip(`目标完成!积分 +${GUIDE_REWARD}`) + sfx('goal') + } +} function charlieFx(dt) { const p = player - if (!p || p.dead) return + if (!p || p.dead || caveOn) return if (phaseName === 'night' && !litAt(p.x, p.y)) { if (!isHostRole()) p.darkT += dt if (!p.darkWarned && p.darkT >= TUNE.charlieWarn) { @@ -2476,7 +3054,7 @@ function syncHud(force = false) { closeBox() } else { const slots = ensureBoxSlots(box) - boxView.value = { id: box.id, slots: slots.map(packUiSlot) } + boxView.value = { id: box.id, title: box.type === 'icebox' ? '冰箱 · 保鲜中' : '木箱', slots: slots.map(packUiSlot) } } } else if (boxView.value) boxView.value = null // 卸下背包时关闭面板 @@ -2497,6 +3075,7 @@ function hudTick(dt) { if (hudT >= 0.25) { hudT = 0 syncHud() + updateBossBar() } mapT += dt if (showMap.value && mapT >= 0.5) { @@ -2504,6 +3083,29 @@ function hudTick(dt) { drawMinimap() } } +// Boss 血条:420px 内最近的 boss(通用 MON/mod boss 标记;联机客机用快照 hpPct) +function updateBossBar() { + if (!playing.value || !player || player.dead) { + bossBar.value = null + return + } + let best = null + let bd = 420 + monsters.forEach((m) => { + const conf = modRt.mon[m.kind] || MON[m.kind] + if (!conf?.boss) return + const d = Math.hypot(m.x - player.x, m.y - player.y) + if (d < bd) { + bd = d + const maxHp = conf.hp || 1 + const pct = m.hp != null + ? Math.max(0, Math.min(100, (m.hp / maxHp) * 100)) + : Math.max(0, Math.min(100, m.hpPct ?? 100)) + best = { name: conf.name || m.kind, pct: Math.round(pct) } + } + }) + bossBar.value = best +} // ===================================================================== // 主循环 @@ -2512,6 +3114,7 @@ function update(dt) { if (!playing.value) return time += dt lastDt = dt + updateFx(dt) fpsEma = fpsEma * 0.95 + (1 / Math.max(dt, 0.001)) * 0.05 if (coop && !coop.isHost) { clientTick(dt) @@ -2523,6 +3126,8 @@ function update(dt) { updateMonstersAll(dt) updateThreats(dt) updateSurvival(dt) + tickContainers(dt) + tickFishing(dt) callHook(modRt, 'onTick', gCtx, dt) charlieFx(dt) updateExplore(dt) @@ -2723,13 +3328,17 @@ function sendFullWorld() { coop.send('starve_state', { full: 1, mods: modRt.ids, + wscale: uiWorldScale || 1, tiles: Array.from(tiles).join(''), road: Array.from(road).join(''), ents: entities.map(serializeEntFull), day: day.value, dayTime, wx: [wx.type, Math.round(wx.t)], players: players.map(serializePlayerRow), - mons: monsters.map((m) => [m.id, m.kind, Math.round(m.x), Math.round(m.y), m.dir, m.state || '', 0, 100, 0, 0]), + mons: monsters.map((m) => { + const bc = modRt.mon[m.kind] || MON[m.kind] + return [m.id, m.kind, Math.round(m.x), Math.round(m.y), m.dir, m.state || '', 0, Math.max(0, Math.round((m.hp / (bc?.hp || 1)) * 100)), 0, 0] + }), fires: fires.map((f) => [f.fid, Math.round(f.x), Math.round(f.y), Math.round(f.ttl), f.max, f.pit ? 1 : 0, f.eternal ? 1 : 0]), }) } @@ -2737,12 +3346,16 @@ function applySnapshot(snap) { if (snap.full) { // 客机对齐主机的 mod 集合(物品索引/实体表必须一致才能解包快照) applyModRuntime(activateMods(snap.mods || [])) + // 对齐主机的地图倍率(mod flags 与面板倍率取更大者),再分配瓦片数组 + uiWorldScale = Math.max(1, Math.min(4, Number(snap.wscale) || 1)) + configureWorld(Math.max(modRt.flags?.worldScale || 1, uiWorldScale)) tiles = Uint8Array.from(snap.tiles.split('').map(Number)) road = Uint8Array.from(snap.road.split('').map(Number)) decoSpots = genDecor(tiles) explored = new Uint8Array(GW * GH) if (modRt.flags.noFog) explored.fill(1) entities = snap.ents.map(restoreEntFull) + entGrid.rebuild(entities) waitingWorld.value = false if (modRt.ids.length) tip(`本局启用 Mod:${modRt.metas.map((m) => m.name).join('、')}`) } else if (waitingWorld.value) return @@ -2810,16 +3423,29 @@ function applySnapshot(snap) { m.loyalT = loyal ? 10 : 0 m.moving = !!moving }) - monsters = monsters.filter((m) => seen.has(m.id)) + monsters = monsters.filter((m) => { + if (seen.has(m.id)) return true + m._gone = true + return false + }) // 火焰 fires = snap.fires.map(([fid2, x, y, ttl, max, pit, eternal]) => ({ fid: fid2, x, y, ttl, max, pit: !!pit, eternal: !!eternal })) // 实体增量 + if (snap.ea?.length || snap.er?.length) entGrid.rebuildPending = true snap.ea?.forEach((row) => { if (!entities.some((e) => e.id === row[0])) entities.push(restoreEntFull(row)) }) if (snap.er?.length) { const gone = new Set(snap.er) - entities = entities.filter((e) => !gone.has(e.id)) + entities = entities.filter((e) => { + if (!gone.has(e.id)) return true + e._gone = true + return false + }) + } + if (entGrid.rebuildPending) { + entGrid.rebuildPending = false + entGrid.rebuild(entities) } snap.eu?.forEach(([id2, left, stub, state, hpv, extra]) => { const e = entities.find((q) => q.id === id2) @@ -2912,7 +3538,41 @@ function unpackSaveSlot(v) { if (v[3] != null) s.dur = v[3] return s } +// 洞穴存档块(caveData.ents 与洞内 entities 绑定同引用,任意时刻可直接序列化) +function serializeCave() { + if (!caveData) return null + return { + tiles: Array.from(caveData.tiles).join(''), + ents: caveData.ents.map(serializeEntFull), + lights: caveData.lights, + spawn: caveData.spawn, + } +} function serialize() { + // 存档恒以地表世界为准:在洞内自动存档时,先静默切回地表绑定,写完再切回来 + const wasCave = caveOn + if (wasCave) swapToWorld(false) // false = 不回传玩家坐标(坐标以洞内实位存档) + const v = serializeWorld() + v.cave = serializeCave() + v.inCave = wasCave + if (wasCave) { + // 把洞内实位写进存档(swapToWorld 没动 player 坐标) + v.px = Math.round(player.x) + v.py = Math.round(player.y) + // 恢复洞内绑定 + tiles = caveData.tiles + road = caveData.road + decoSpots = [] + explored = caveData.explored + entities = caveData.ents + monsters = caveData.mons + entGrid.rebuild(entities) + caveOn = true + caveOnUi.value = true + } + return v +} +function serializeWorld() { return { v: 4, mods: modRt.ids, @@ -2930,6 +3590,10 @@ function serialize() { kills, chopped: treesChopped, hound: { nextDay: houndPlan.nextDay }, deer: { spawned: deer.spawned }, + goose: { spawned: goose.spawned }, + dfly: { spawned: dfly.spawned }, + guide: guide ? { ...guide } : null, + cave: serializeCave(), wx: { type: wx.type, t: Math.round(wx.t) }, tiles: Array.from(tiles).join(''), road: Array.from(road).join(''), @@ -2958,6 +3622,7 @@ function restore(v) { explored = v.explored ? Uint8Array.from(v.explored.split('').map(Number)) : new Uint8Array(GW * GH) decoSpots = genDecor(tiles) entities = (v.ents || []).map(restoreEntFull) + entGrid.rebuild(entities) fires = (v.fires || []).map(([fid2, x, y, ttl, max, pit, eternal]) => ({ fid: fid2, x, y, ttl, max: max || 90, pit: !!pit, eternal: !!eternal })) fid = fires.reduce((n, f) => Math.max(n, f.fid + 1), 1) player.x = v.px @@ -2982,6 +3647,10 @@ function restore(v) { treesChopped = v.chopped || 0 houndPlan = { nextDay: v.hound?.nextDay || day.value + randInt(4, 7), at: DAY_LEN * 0.4, warnT: 0, doneToday: true } deer = { spawned: !!v.deer?.spawned, warnT: 0 } + goose = { spawned: !!v.goose?.spawned, warnT: 0 } + dfly = { spawned: !!v.dfly?.spawned, warnT: 0 } + guide = v.guide ? { ...newGuide(), ...v.guide } : null + guideLabel.value = guideText(guide) wx = { type: v.wx?.type || 'none', t: v.wx?.t || 0 } // 生物重生(不入档:从巢/屋/群系恢复) monsters = [] @@ -2993,8 +3662,46 @@ function restore(v) { const spot = findLandNear(tiles, player.x, player.y, 500, 700) spawnMob('deerclops', spot.x, spot.y) } + if (goose.spawned && seasonNow.code === 'spring') { + const spot = findLandNear(tiles, player.x, player.y, 500, 700) + spawnMob('moosegoose', spot.x, spot.y) + } + if (dfly.spawned && seasonNow.code === 'summer') { + const spot = findLandNear(tiles, player.x, player.y, 500, 700) + spawnMob('dragonfly', spot.x, spot.y) + } applyModSaves(modRt, gCtx, v.modData) if (modRt.flags.noFog) explored.fill(1) + // 洞穴世界重建(尺寸不符则丢弃,下次进洞重新生成) + if (v.cave && v.cave.tiles && v.cave.tiles.length === GW * GH) { + caveData = { + tiles: Uint8Array.from(v.cave.tiles.split('').map(Number)), + road: new Uint8Array(GW * GH), + ents: (v.cave.ents || []).map(restoreEntFull), + mons: [], + lights: v.cave.lights || [], + spawn: v.cave.spawn || { x: WORLD.w / 2, y: WORLD.h / 2 }, + explored: new Uint8Array(GW * GH).fill(1), + } + } else { + caveData = null + } + // 上次存档时身处洞穴:直接回到洞穴(px/py 即洞内坐标) + if (v.inCave && caveData) { + tiles = caveData.tiles + road = caveData.road + decoSpots = [] + explored = caveData.explored + entities = caveData.ents + monsters = caveData.mons + entGrid.rebuild(entities) + caveOn = true + caveOnUi.value = true + tip('你在洞穴中醒来……') + } else { + caveOn = false + caveOnUi.value = false + } tip(`回到第 ${day.value} 天(${seasonNow.name}季)的荒野`) return } @@ -3090,6 +3797,7 @@ function draw() { drawables.push({ y: e.loot ? e.y - 900 : e.y, fn: () => drawEntWrap(e, env) }) }) fires.forEach((f) => { + if (caveOn) return // 地表篝火不画进洞穴(坐标系不同) const sx = f.x - camX const sy = f.y - camY if (sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) return @@ -3109,6 +3817,8 @@ function draw() { players.forEach((p) => drawables.push({ y: p.y, fn: () => drawPlayerChar(p) })) drawables.sort((a, b) => a.y - b.y) drawables.forEach((d) => d.fn()) + // 命中/采集粒子(世界坐标,压在实体之上、夜晚层之下) + drawFx(ctx, camX, camY) callHook(modRt, 'onDraw', gCtx, ctx, { camX, camY, W, H, time }) // 建造/迁移幽灵预览 if (placeMode) { @@ -3141,8 +3851,11 @@ function draw() { ctx.fillText(placeMode.relocateId ? `迁移 ${placeMode.name}` : `建造 ${placeMode.name}`, 0, -28) ctx.restore() } - drawWeather(W, H) - drawNight(W, H) + if (caveOn) drawCaveDark(W, H) + else { + drawWeather(W, H) + drawNight(W, H) + } // 查理袭击红闪 if (flashRed > 0) { flashRed -= lastDt * 1.4 @@ -3169,11 +3882,19 @@ function draw() { } } ctx.restore() + // 视觉调试图库:?debugGallery=skins|mobs 时整屏渲染画廊并跳过常规 UI + if (galleryMode) { + drawGallery(ctx, W, H) + drawGameCursor(ctx) + return + } // mod UI 钩子:全部全屏覆盖层(天气/夜晚/查理红闪/理智暗角/寒气)绘制完毕后调用, // 此时 ctx 已恢复为屏幕坐标系(无相机平移/缩放),mod 面板不会被压暗 callHook(modRt, 'onDrawUI', gCtx, ctx, { camX, camY, W, H, time }) drawClockV2() if (showFullMap.value) drawFullMap() + // 自绘情境光标置顶 + drawGameCursor(ctx) } function drawEntWrap(e, env) { const sx = e.x - camX @@ -3266,7 +3987,7 @@ function drawPlayerChar(p) { if (p.freezeT > 0 && !p.dead) { ctx.fillStyle = 'rgba(150,210,240,0.28)' ctx.beginPath() - ctx.ellipse(0, -18, 18, 24, 0, 0, 7) + ctx.ellipse(0, -24, 18, 30, 0, 0, 7) ctx.fill() } } @@ -3275,11 +3996,316 @@ function drawPlayerChar(p) { ctx.font = 'bold 11px "Ma Shan Zheng", sans-serif' ctx.textAlign = 'center' ctx.fillStyle = p === player ? 'rgba(255,235,160,0.95)' : 'rgba(255,255,255,0.85)' - ctx.fillText(p.name, 0, -46) + ctx.fillText(p.name, 0, -68) } ctx.restore() } +// ===================================================================== +// 视觉调试图库(仅 ?debugGallery=skins|mobs 时渲染,正常游戏零影响) +// 用于验收 47 皮肤 × 各动作 + 全部野怪 × 各动画片段 +// ===================================================================== +const galleryMode = new URLSearchParams(window.location?.search || '').get('debugGallery') || '' +function drawGallery(ctx, W, H) { + ctx.save() + ctx.fillStyle = '#241c14' + ctx.fillRect(0, 0, W, H) + ctx.textAlign = 'center' + ctx.textBaseline = 'alphabetic' + if (galleryMode === 'mobs') { + ctx.fillStyle = '#f4ead2' + ctx.font = 'bold 18px sans-serif' + ctx.fillText('生物动画画廊(骨骼引擎)', W / 2, 28) + const cells = [ + ['spider', { moving: true }], ['hound', { moving: true }], ['icehound', { moving: true, ice: true }], + ['pig', { moving: true }], ['pig', { loyalT: 9 }], ['beefalo', { moving: true }], + ['beefalo', {}], ['bee', {}], ['frog', { moving: true }], + ['frog', {}], ['treeguard', {}], ['deerclops', {}], + ['guard', {}], ['shadow', {}], + ] + const cols = Math.max(3, Math.floor(W / 210)) + const cw = W / cols + const chh = 150 + cells.forEach(([kind, extra], i) => { + const cx = (i % cols) * cw + cw / 2 + const cy = Math.floor(i / cols) * chh + 92 + ctx.strokeStyle = 'rgba(244,234,210,0.15)' + ctx.lineWidth = 1 + ctx.strokeRect((i % cols) * cw + 4, cy - 74, cw - 8, chh - 24) + const m = { kind, dir: 1, bob: i * 1.3, cd: 0, t: 0, state: '', hp: 100, loyalT: 0, ...extra } + if (kind === 'treeguard' || kind === 'deerclops') { + // 状态机演出循环:追击 → 前摇 → 砸地 → 硬直 + const ph = (time + i * 0.9) % 4.4 + if (ph < 2.4) { m.state = 'chase'; m.moving = true } + else if (ph < 2.95) { m.state = 'windup'; m.moving = false; m.t = 0.55 - (ph - 2.4) } + else if (ph < 3.3) { m.state = 'smash'; m.moving = false; m.t = 0.35 - (ph - 2.95) } + else { m.state = 'recover'; m.moving = false; m.t = 1.1 - (ph - 3.3) } + } else if (kind === 'bee') { + m.moving = false + } else { + m.moving = ((time * 0.4 + i * 0.25) % 1) < 0.62 + } + ctx.save() + ctx.translate(cx, cy) + const scale = kind === 'deerclops' || kind === 'treeguard' ? 0.72 : 1.15 + ctx.scale(scale, scale) + if (kind === 'guard') drawGuard(ctx, { dir: 1, moving: m.moving, walkT: time + i, cd: 0, bob: i, time }) + else drawMob(ctx, m, time) + ctx.restore() + ctx.fillStyle = '#c9b48c' + ctx.font = '11px sans-serif' + ctx.fillText(kind + (extra.ice ? '(冰)' : extra.loyalT ? '(结盟)' : m.moving ? '(走)' : '(站)'), cx, cy + 28) + }) + ctx.restore() + return + } + if (galleryMode === 'tools') { + // 手持工具画廊:每种工具 × 关键姿势(待机/挥砍命中/凿击/铲掘/突刺),定位模型与握点问题 + ctx.fillStyle = '#f4ead2' + ctx.font = 'bold 18px sans-serif' + ctx.fillText('手持工具画廊(待机 / 挥砍 / 凿击 / 突刺 / 铲掘)', W / 2, 24) + const TOOLS = ['axe', 'pick', 'shovel', 'hoe', 'shears', 'torch', 'spear', 'tentaclespike', 'stick'] + const POSES = [ + { label: '待机', swing: 0, act: '' }, + { label: '挥砍', swing: 0.45, act: 'chop' }, + { label: '凿击', swing: 0.34, act: 'mine' }, + { label: '突刺', swing: 0.55, act: 'attack' }, + { label: '铲掘', swing: 0.62, act: 'dig' }, + ] + const cols = POSES.length + 1 + const cw = W / (cols + 0.4) + const chh = 92 + ctx.font = 'bold 12px sans-serif' + TOOLS.forEach((tool, ti) => { + const rowY = 44 + ti * chh + const skin = skinByCode('wilson') + // 行首工具名 + ctx.fillStyle = '#c9b48c' + ctx.textAlign = 'left' + ctx.fillText(tool, 8, rowY + 50) + POSES.forEach((pose, pi) => { + const cx = cw * (pi + 0.6) + 30 + const cy = rowY + 72 + ctx.save() + ctx.translate(cx, cy) + ctx.scale(0.92, 0.92) + drawPlayerFig(ctx, skin, { + dir: 1, walking: false, walkT: 0, + swing: pose.swing, act: pose.act, + toolCode: tool, time: 2 + ti, + breathe: 0, + }) + ctx.restore() + ctx.fillStyle = '#8a7a5c' + ctx.font = '10px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(pose.label, cx, cy + 14) + }) + }) + ctx.restore() + return + } + // 皮肤页:全部皮肤 × 动作轮播 + ctx.fillStyle = '#f4ead2' + ctx.font = 'bold 18px sans-serif' + ctx.fillText(`皮肤画廊(${SKINS.length} 款 × 动作轮播)`, W / 2, 24) + const ACTS = ['', 'walk', 'chop', 'mine', 'dig', 'attack', 'eat', 'swing'] + const TOOLS = { chop: 'axe', mine: 'pick', dig: 'shovel', attack: 'spear' } + const actIdx = Math.floor(time / 1.1) % ACTS.length + const act = ACTS[actIdx] + const cols = Math.max(4, Math.floor(W / 108)) + const cw = W / cols + const chh = 128 + SKINS.forEach((skin, i) => { + const cx = (i % cols) * cw + cw / 2 + const cy = Math.floor(i / cols) * chh + 96 + const walking = act === 'walk' + ctx.save() + ctx.translate(cx, cy) + const sc = 1.12 + ctx.scale(sc, sc) + drawPlayerFig(ctx, skin, { + dir: 1, walking, walkT: time * 1.2, + swing: walking || act === '' ? 0 : 1 - ((time % 1.1) / 1.1) * 0.999, + act, toolCode: TOOLS[act] || '', time, + breathe: walking ? 0 : Math.sin(time * 2.2 + i) * 1.2, + }) + ctx.restore() + ctx.fillStyle = '#c9b48c' + ctx.font = '10px sans-serif' + ctx.fillText(skin.name, cx, cy + 20) + }) + ctx.fillStyle = '#8a7a5c' + ctx.font = '12px sans-serif' + ctx.fillText(`当前动作:${act || '待机'}`, W / 2, H - 10) + ctx.restore() +} // 天气粒子(对象池,雨 110 / 雪 70 上限) +// ===================================================================== +// 自绘光标:按情境切换手绘小图标(行走/采集/攻击/互动/添柴/建造) +// 游戏进行时画布 cursor:none(.hide-cursor),此处每帧画在所有层之上 +// ===================================================================== +function drawGameCursor(ctx) { + if (!playing.value || !canvas.value) return + const { x, y } = cursorPos.value + if (x < -20 || y < -20) return + const mode = cursorMode.value + const CURSOR_SCALE = 1.4 + ctx.save() + ctx.translate(x, y) + ctx.scale(CURSOR_SCALE, CURSOR_SCALE) + ctx.lineJoin = 'round' + ctx.lineCap = 'round' + ctx.strokeStyle = '#1d1409' + const wob = Math.sin(time * 3.1) * 0.05 + // 落影 + ctx.fillStyle = 'rgba(20,16,8,0.16)' + ctx.beginPath() + ctx.ellipse(3, 6, 11, 4.2, 0, 0, TAU) + ctx.fill() + // 手绘箭头(行走/普通/采集共用的底) + const arrow = () => { + ctx.beginPath() + ctx.moveTo(0, 0) + ctx.lineTo(4.6, 16.5) + ctx.lineTo(8.2, 11.6) + ctx.lineTo(13.4, 13.8) + ctx.closePath() + ctx.fillStyle = '#f4ead2' + ctx.fill() + ctx.lineWidth = 1.7 + ctx.stroke() + ctx.strokeStyle = 'rgba(255,255,255,0.55)' + ctx.lineWidth = 1.1 + ctx.beginPath() + ctx.moveTo(1.5, 4.6) + ctx.lineTo(3.4, 11.6) + ctx.stroke() + ctx.strokeStyle = '#1d1409' + } + if (mode === 'attack') { + // 红准星 + 短剑 + ctx.rotate(wob * 0.6) + ctx.strokeStyle = 'rgba(204,62,52,0.9)' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(0, 0, 11.5, 0, TAU) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(-16.5, 0); ctx.lineTo(-8, 0) + ctx.moveTo(8, 0); ctx.lineTo(16.5, 0) + ctx.moveTo(0, -16.5); ctx.lineTo(0, -8) + ctx.moveTo(0, 8); ctx.lineTo(0, 16.5) + ctx.stroke() + ctx.rotate(-0.72) + ctx.fillStyle = '#c3ccd6' + ctx.lineWidth = 1.4 + ctx.beginPath() + ctx.moveTo(3, -3); ctx.lineTo(17, -15); ctx.lineTo(20, -12); ctx.lineTo(7, 0) + ctx.closePath() + ctx.fill(); ctx.stroke() + ctx.fillStyle = '#e8eef4' + ctx.beginPath() + ctx.moveTo(14, -13.4); ctx.lineTo(17, -15); ctx.lineTo(20, -12); ctx.lineTo(18.4, -10.4) + ctx.closePath() + ctx.fill() + ctx.fillStyle = '#8a5a2b' + ctx.beginPath(); ctx.roundRect(1, -6, 6.4, 6.4, 1.6); ctx.fill(); ctx.stroke() + } else if (mode === 'hand') { + // 米白手套(互动/拾取) + ctx.rotate(wob * 0.5) + ctx.fillStyle = '#f4ead2' + ctx.lineWidth = 1.6 + ctx.beginPath() + ctx.moveTo(-7, 12) + ctx.quadraticCurveTo(-10, 2, -7, -3) + ctx.lineTo(-7, -8) + ctx.quadraticCurveTo(-6, -11, -4, -10) + ctx.lineTo(-3.4, -2) + ctx.lineTo(-3, -12) + ctx.quadraticCurveTo(-2.6, -15, -0.4, -14.6) + ctx.lineTo(0.4, -1) + ctx.lineTo(1.4, -13) + ctx.quadraticCurveTo(1.8, -16, 4, -15.2) + ctx.lineTo(4.6, -1) + ctx.lineTo(6, -11) + ctx.quadraticCurveTo(6.6, -13.6, 8.8, -12.6) + ctx.lineTo(8.4, 4) + ctx.quadraticCurveTo(8, 12, 0, 12) + ctx.closePath() + ctx.fill(); ctx.stroke() + ctx.strokeStyle = 'rgba(160,130,90,0.8)' + ctx.lineWidth = 1.1 + ctx.beginPath() + ctx.moveTo(-3.4, -2); ctx.lineTo(-3, 6) + ctx.moveTo(0.4, -1); ctx.lineTo(0.8, 7) + ctx.moveTo(4.6, -1); ctx.lineTo(5, 6) + ctx.stroke() + } else if (mode === 'fire') { + // 火把(火焰随时间摇曳) + ctx.rotate(0.5 + wob * 0.4) + ctx.strokeStyle = '#8a5a2b' + ctx.lineWidth = 3.6 + ctx.beginPath(); ctx.moveTo(0, 6); ctx.lineTo(0, -9); ctx.stroke() + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.3 + ctx.beginPath(); ctx.moveTo(0, 6); ctx.lineTo(0, -9); ctx.stroke() + const fl = Math.sin(time * 9) * 1.6 + ctx.fillStyle = '#f0923c' + ctx.strokeStyle = '#b5541e' + ctx.lineWidth = 1.2 + ctx.beginPath() + ctx.moveTo(0, -19 + fl * 0.4) + ctx.quadraticCurveTo(5.4, -11 + fl, 0.8, -6.5) + ctx.quadraticCurveTo(-5, -11 + fl, 0, -19 + fl * 0.4) + ctx.closePath() + ctx.fill(); ctx.stroke() + ctx.fillStyle = '#ffd76a' + ctx.beginPath() + ctx.moveTo(0, -14.6 + fl * 0.3) + ctx.quadraticCurveTo(2.8, -10.6 + fl * 0.6, 0.4, -7.6) + ctx.quadraticCurveTo(-2.4, -10.6 + fl * 0.6, 0, -14.6 + fl * 0.3) + ctx.closePath() + ctx.fill() + } else if (mode === 'build') { + // 锤子(建造模式) + ctx.rotate(-0.55 + wob * 0.4) + ctx.strokeStyle = '#8a5a2b' + ctx.lineWidth = 3.6 + ctx.beginPath(); ctx.moveTo(-2, 16); ctx.lineTo(0, -4); ctx.stroke() + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.3 + ctx.beginPath(); ctx.moveTo(-2, 16); ctx.lineTo(0, -4); ctx.stroke() + ctx.fillStyle = '#9aa4ae' + ctx.lineWidth = 1.6 + ctx.beginPath(); ctx.roundRect(-9, -14, 18, 10, 2); ctx.fill(); ctx.stroke() + ctx.fillStyle = 'rgba(255,255,255,0.4)' + ctx.beginPath(); ctx.roundRect(-7, -12.6, 14, 3, 1.4); ctx.fill() + } else if (mode === 'gather') { + // 箭头 + 斜握小斧(可采集资源) + arrow() + ctx.save() + ctx.translate(9, -9) + ctx.rotate(-0.75) + ctx.strokeStyle = '#8a5a2b' + ctx.lineWidth = 2.8 + ctx.beginPath(); ctx.moveTo(-4, 8); ctx.lineTo(2, -4); ctx.stroke() + ctx.strokeStyle = '#1d1409' + ctx.lineWidth = 1.1 + ctx.beginPath(); ctx.moveTo(-4, 8); ctx.lineTo(2, -4); ctx.stroke() + ctx.fillStyle = '#b8bec6' + ctx.lineWidth = 1.2 + ctx.beginPath() + ctx.moveTo(1, -3); ctx.lineTo(8, -8); ctx.lineTo(9.5, -3); ctx.lineTo(3, 0) + ctx.closePath() + ctx.fill(); ctx.stroke() + ctx.restore() + } else { + // 行走 / 普通:手绘箭头 + arrow() + } + ctx.restore() +} + function drawWeather(W, H) { const want = wx.type === 'rain' ? 110 : wx.type === 'snow' || seasonNow.code === 'winter' ? 70 : 0 while (particles.length < want) { @@ -3313,7 +4339,13 @@ function drawWeather(W, H) { } }) } -// 夜幕:黑底挖光圈(火/火把) +// 夜幕:黑底挖光圈(火/火把)。 +// 暗幕每帧直接铺在主画布(带当前 alpha),挖孔层缓存在离屏画布上: +// 以「缓存时镜头」为世界锚点、四周留 64px 余量绘制光圈孔,之后镜头小幅移动只需平移复用; +// 仅在火集合变化(ttl 0.2s 分桶)/火把人数变化/镜头位移超 32px/画布尺寸变化时重建, +// 避免旧实现每帧新建全屏 canvas 的 GC 压力。 +const nightHole = { cv: null, camX: 0, camY: 0, key: '', w: 0, h: 0 } +const NIGHT_MARGIN = 64 function drawNight(W, H) { const [duskAt, nightAt] = daySplit(seasonNow.code) let alpha = 0 @@ -3322,30 +4354,85 @@ function drawNight(W, H) { // 黎明淡出 if (dayTime < 6) alpha = Math.max(alpha, 0.9 * (1 - dayTime / 6)) if (alpha <= 0.01) return - const layer = document.createElement('canvas') - layer.width = W - layer.height = H - const lc = layer.getContext('2d') - lc.fillStyle = seasonNow.code === 'winter' ? `rgba(6,10,20,${alpha})` : `rgba(8,6,18,${alpha})` - lc.fillRect(0, 0, W, H) - lc.globalCompositeOperation = 'destination-out' + // 1) 暗幕(alpha 每帧生效,无需重建挖孔层) + ctx.fillStyle = seasonNow.code === 'winter' ? `rgba(6,10,20,${alpha})` : `rgba(8,6,18,${alpha})` + ctx.fillRect(0, 0, W, H) + // 2) 挖孔层:内容只与火/火把/镜头锚点相关,与 alpha 无关 + let key = '' + fires.forEach((f) => { if (f.ttl > 0) key += `${f.fid}:${Math.round(f.ttl / 0.2)},` }) + key += '|' + players.forEach((p) => { if (!p.dead && p.equip.hand?.code === 'torch') key += `${p.seat},` }) + const anchorMoved = Math.abs(camX - nightHole.camX) > NIGHT_MARGIN / 2 || Math.abs(camY - nightHole.camY) > NIGHT_MARGIN / 2 + if (!nightHole.cv || nightHole.key !== key || anchorMoved || nightHole.w !== W || nightHole.h !== H) { + if (!nightHole.cv || nightHole.w !== W + NIGHT_MARGIN * 2 || nightHole.h !== H + NIGHT_MARGIN * 2) { + nightHole.cv = document.createElement('canvas') + nightHole.cv.width = W + NIGHT_MARGIN * 2 + nightHole.cv.height = H + NIGHT_MARGIN * 2 + } + nightHole.w = W + NIGHT_MARGIN * 2 + nightHole.h = H + NIGHT_MARGIN * 2 + nightHole.camX = camX + nightHole.camY = camY + nightHole.key = key + const lc = nightHole.cv.getContext('2d') + lc.clearRect(0, 0, nightHole.w, nightHole.h) + // 世界锚点 = 缓存镜头左上角往左上偏 M,光圈孔画在该坐标系 + const ox = nightHole.camX - NIGHT_MARGIN + const oy = nightHole.camY - NIGHT_MARGIN + const carve = (x, y, r) => { + const g = lc.createRadialGradient(x, y, r * 0.25, x, y, r) + g.addColorStop(0, 'rgba(0,0,0,1)') + g.addColorStop(1, 'rgba(0,0,0,0)') + lc.fillStyle = g + lc.beginPath() + lc.arc(x, y, r, 0, 7) + lc.fill() + } + fires.forEach((f) => { + if (f.ttl <= 0) return + carve(f.x - ox, f.y - oy, fireRadius(f) * (1 + Math.sin(time * 8) * 0.03)) + }) + players.forEach((p) => { + if (!p.dead && p.equip.hand?.code === 'torch') carve(p.x - ox, p.y - oy - 14, 108) + }) + } + ctx.globalCompositeOperation = 'destination-out' + ctx.drawImage(nightHole.cv, nightHole.camX - NIGHT_MARGIN - camX, nightHole.camY - NIGHT_MARGIN - camY) + ctx.globalCompositeOperation = 'source-over' +} + +// 洞穴黑暗:全屏漆黑 + 挖出荧光草/火把/矿工帽光圈(洞内实体少,直接绘制无需缓存层) +function drawCaveDark(W, H) { + ctx.save() + ctx.fillStyle = 'rgba(5,4,10,0.93)' + ctx.fillRect(0, 0, W, H) + ctx.globalCompositeOperation = 'destination-out' const carve = (x, y, r) => { - const g = lc.createRadialGradient(x, y, r * 0.25, x, y, r) + const g = ctx.createRadialGradient(x, y, r * 0.2, x, y, r) g.addColorStop(0, 'rgba(0,0,0,1)') g.addColorStop(1, 'rgba(0,0,0,0)') - lc.fillStyle = g - lc.beginPath() - lc.arc(x, y, r, 0, 7) - lc.fill() + ctx.fillStyle = g + ctx.beginPath() + ctx.arc(x, y, r, 0, 7) + ctx.fill() + } + const x0 = camX - 130 + const y0 = camY - 130 + const x1 = camX + W + 130 + const y1 = camY + H + 130 + for (const e of entities) { + if (e.type !== 'lightflower' || e.stub) continue + if (e.x < x0 || e.x > x1 || e.y < y0 || e.y > y1) continue + carve(e.x - camX, e.y - camY - 12, TUNE.flowerLightR * (1 + Math.sin(time * 2.2 + e.id) * 0.06)) } - fires.forEach((f) => { - if (f.ttl <= 0) return - carve(f.x - camX, f.y - camY, fireRadius(f) * (1 + Math.sin(time * 8) * 0.03)) - }) players.forEach((p) => { - if (!p.dead && p.equip.hand?.code === 'torch') carve(p.x - camX, p.y - camY - 14, 108) + if (p.dead) return + const h = p.equip.head + if (h?.code === 'minerhat' && (h.dur ?? 0) > 0) carve(p.x - camX, p.y - camY - 14, 150) + else if (p.equip.hand?.code === 'torch') carve(p.x - camX, p.y - camY - 14, 108) }) - ctx.drawImage(layer, 0, 0) + ctx.globalCompositeOperation = 'source-over' + ctx.restore() } // 无限接近原版:外木圈 + 三段昼夜盘 + 中心羊皮纸日数盘 + 黑针从中心指向边缘 function drawClockV2() { @@ -3766,6 +4853,8 @@ function start(opts = {}) { mySkinCode = o.skin || 'wilson' coop = o.coop || null isCoop.value = !!coop + // 地图倍率:新开局/联机房主由开始面板传入;续档沿用存档世界(忽略面板倍率) + uiWorldScale = o.save ? 1 : Math.max(1, Math.min(4, Number(o.worldScale) || 1)) // mod 激活:续档以存档记录为准;联机客机等主机全量快照对齐;其余用开局勾选 const modIds = o.save && Array.isArray(o.save.mods) ? o.save.mods : coop && !coop.isHost ? [] @@ -3806,11 +4895,13 @@ function start(opts = {}) { playing.value = true syncHud(true) emit('score', 0) + startMusic() loop.start() return } players = [makePlayer(0, '我', mySkinCode, WORLD.w / 2, WORLD.h / 2 + 40)] player = players[0] + guide = newGuide() // 新档从第一步引导开始(读档时 restore 会用存档覆盖) if (o.save) { try { restore(o.save) @@ -3826,14 +4917,17 @@ function start(opts = {}) { genWorldAll() tip('欢迎来到饥荒世界:先撸草和树枝,天黑前必须有火!') } + guideLabel.value = guideText(guide) playing.value = true syncHud(true) emit('score', player.score) + startMusic() loop.start() } function stop() { playing.value = false loop?.stop() + stopMusic() coop?.offMessage?.() coop = null } @@ -3849,7 +4943,9 @@ onMounted(() => { keys.attach() keys.onPress((k) => { if (!playing.value) return - if ((k === 'p' || k === 'P') || (k === 'Escape' && !potOpenId && !boxOpenId && !packOpen.value && !showMap.value && !craftOpen.value)) { + // 子地图(内室/地窖/畜棚)打开期间屏蔽 P/Esc 暂停,Esc 留给子地图自行关闭 + const submapOpen = player && !callAllows(modRt, 'allowsPlayerMove', gCtx, player) + if (!submapOpen && ((k === 'p' || k === 'P') || (k === 'Escape' && !potOpenId && !boxOpenId && !packOpen.value && !showMap.value && !craftOpen.value))) { togglePause() return } @@ -3911,13 +5007,41 @@ onMounted(() => { hound: () => { houndPlan.nextDay = day.value; houndPlan.doneToday = false; houndPlan.at = 0; houndPlan.warnT = 0 }, boss: () => spawnMob('treeguard', player.x + 200, player.y), deerclops: () => { deer.warnT = 1; deer.spawned = false }, + // 通用调试:任意实体/生物生成(验收与调参用) + ent: (type, dx = 120, dy = 0) => (player ? addEntity(type, player.x + dx, player.y + dy) : null), + ents: () => entities, + spawn: (kind, dx = 150, dy = 0) => (player ? spawnMob(kind, player.x + dx, player.y + dy) : null), + fishing: () => fishing, + me: () => player, + mobs: () => monsters.map((m) => ({ kind: m.kind, hp: Math.round(m.hp), x: Math.round(m.x), y: Math.round(m.y) })), + bossBar: () => bossBar.value, + target: () => (player?.target ? { kind: player.target.kind, id: player.target.ref?.id, type: player.target.ref?.type, x: Math.round(player.target.ref?.x ?? player.target.x ?? -1) } : null), + // 洞穴/蜂王调试 + cave: () => (caveOn ? exitCave() : enterCave()), + caveState: () => ({ on: caveOn, ents: caveData?.ents.length ?? 0, mons: monsters.length }), + queen: () => player && spawnMob('beequeen', player.x + 160, player.y), + count: (code) => (player ? countItem(player, code) : 0), + // 模拟点击最近实体(触发 pursue→hitEntity 交互链路) + tapEnt: (type) => { + if (!player) return false + let best = null + let bd = 220 + entities.forEach((e) => { + if (e.type !== type) return + const d = Math.hypot(e.x - player.x, e.y - player.y) + if (d < bd) { bd = d; best = e } + }) + if (!best) return false + setMyTarget({ kind: 'ent', id: best.id }) + return true + }, spider: () => spawnMob('spider', player.x + 150, player.y), pig: () => spawnMob('pig', player.x + 100, player.y, { home: { x: player.x, y: player.y } }), temp: (v) => { if (player) player.temp = v }, pos: (x, y) => { if (player) { player.x = x; player.y = y } }, weather: (t) => { wx = { type: t, t: 60 } }, san: (v) => { if (player) player.san = v }, - wipe: () => { entities = []; monsters = [] }, + wipe: () => { entities = []; monsters = []; entGrid.rebuild(entities) }, // ---- mod 调试 ---- mods: () => modRt.ids, hurt: (n) => player && damagePlayer(player, n, '调试伤害'), @@ -4004,7 +5128,7 @@ function freshClass(f) { + +
+
+ 🗺 地图大小(房主设定) +
+
+ +
+ 倍率越大世界越大,全员自动同步 +
@@ -953,7 +1032,7 @@ onBeforeUnmount(() => {
- +