更新饥荒
@@ -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()
|
||||
|
||||
136
electron/mario-feel-check.cjs
Normal file
@@ -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)
|
||||
188
electron/mario-verify.cjs
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
BIN
mario-jump-1.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
mario-jump-2.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
mario-jumpbuf-air.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
mario-verify-a-1-1.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
mario-verify-a-start.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
mario-verify-b-2-1.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
mario-verify-b-pipes.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
mario-verify-c-2-2.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
mario-verify-c-3-1.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
mario-verify-c-later.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
mario-verify-d-2-3.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
mario-verify-d-3-2.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
mario-verify-d-goomba-pipe.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
mario-verify-debug-a.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
mario-verify-e-2-4.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
mario-verify-e-3-3.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
mario-verify-f-3-4.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
mario-verify-select.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
77
src/components/ScreenModeToggle.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
// 屏幕模式分段切换:窗口 / 窗口全屏 / 全屏(浏览器端隐藏「窗口全屏」)
|
||||
import { computed } from 'vue'
|
||||
import { useScreenMode } from '../composables/useScreenMode'
|
||||
|
||||
const { mode, isDesktop, setScreenMode } = useScreenMode()
|
||||
|
||||
const options = [
|
||||
{ id: 'window', label: '窗口', title: '普通窗口' },
|
||||
{ id: 'windowFull', label: '窗口全屏', title: '最大化铺满屏幕(保留任务栏)' },
|
||||
{ id: 'full', label: '全屏', title: '全屏(F11 切换 / Esc 退出)' },
|
||||
]
|
||||
const visible = computed(() => options.filter((o) => isDesktop || o.id !== 'windowFull'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="screen-mode" role="group" aria-label="屏幕模式">
|
||||
<button
|
||||
v-for="o in visible"
|
||||
:key="o.id"
|
||||
class="sm-btn"
|
||||
:class="{ on: mode === o.id }"
|
||||
:title="o.title"
|
||||
@click="setScreenMode(o.id)"
|
||||
>
|
||||
<!-- 窗口:小矩形 / 窗口全屏:宽矩形 / 全屏:四角扩展 -->
|
||||
<svg v-if="o.id === 'window'" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<rect x="3.5" y="4.5" width="9" height="7" rx="1" />
|
||||
</svg>
|
||||
<svg v-else-if="o.id === 'windowFull'" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<rect x="1.5" y="4" width="13" height="8" rx="1" />
|
||||
<line x1="1.5" y1="6" x2="14.5" y2="6" opacity="0.55" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
|
||||
<path d="M6 2.5H2.5V6M10 2.5h3.5V6M6 13.5H2.5V10M10 13.5h3.5V10" />
|
||||
</svg>
|
||||
<span>{{ o.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screen-mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) / 1.6);
|
||||
}
|
||||
.sm-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
padding: 4px 9px;
|
||||
font-size: 12px;
|
||||
border-radius: calc(var(--radius) / 2);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sm-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
.sm-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.sm-btn.on {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 14%, transparent);
|
||||
}
|
||||
</style>
|
||||
72
src/composables/useScreenMode.js
Normal file
@@ -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 }
|
||||
}
|
||||
499
src/games/components/marioSprites.js
Normal file
@@ -0,0 +1,499 @@
|
||||
// marioSprites.js — 马里奥像素图案精灵(手绘近似像素画 + 离屏缓存)
|
||||
// 字符图例随各图案 palette 定义;'.' 为透明。绘制走 drawImage 缓存,性能友好。
|
||||
|
||||
// 小马里奥 12×16(stand / walk1 / walk2 / jump / skid / dead)
|
||||
const SM_STAND = [
|
||||
'...RRRRRR...',
|
||||
'..RRRRRRRRRR',
|
||||
'..BBBSSKS...',
|
||||
'.BSBSSSKSSS.',
|
||||
'.BSBBSSSKSS.',
|
||||
'.BBSSSSKKKK.',
|
||||
'...SSSSSS...',
|
||||
'..RROORRR...',
|
||||
'.RRROOOORRR.',
|
||||
'RRRROOOORRRR',
|
||||
'SSROYOOYORSS',
|
||||
'SSSOOOOOOSSS',
|
||||
'SSOOOOOOOOSS',
|
||||
'..OOO..OOO..',
|
||||
'.BBB....BBB.',
|
||||
'BBBB....BBBB',
|
||||
]
|
||||
const SM_WALK1 = [
|
||||
'...RRRRRR...',
|
||||
'..RRRRRRRRRR',
|
||||
'..BBBSSKS...',
|
||||
'.BSBSSSKSSS.',
|
||||
'.BSBBSSSKSS.',
|
||||
'.BBSSSSKKKK.',
|
||||
'...SSSSSS...',
|
||||
'..RROORRS...',
|
||||
'..RROOORSS..',
|
||||
'.RRROOORRSS.',
|
||||
'.SSOOOOOSS..',
|
||||
'..OOOOOOO...',
|
||||
'..OOOOOOO...',
|
||||
'...OOOOO....',
|
||||
'..BBBB.BBB..',
|
||||
'.BBBB..BBBB.',
|
||||
]
|
||||
const SM_WALK2 = [
|
||||
'...RRRRRR...',
|
||||
'..RRRRRRRRRR',
|
||||
'..BBBSSKS...',
|
||||
'.BSBSSSKSSS.',
|
||||
'.BSBBSSSKSS.',
|
||||
'.BBSSSSKKKK.',
|
||||
'...SSSSSS...',
|
||||
'.SRROORRR...',
|
||||
'SSRROOOORR..',
|
||||
'SSRROOOORRR.',
|
||||
'..OOOOOORR..',
|
||||
'..OOOOOOO...',
|
||||
'..OOOOOO....',
|
||||
'..OOOOO.....',
|
||||
'..BBBBB.....',
|
||||
'.BBBBBBB....',
|
||||
]
|
||||
const SM_JUMP = [
|
||||
'...RRRRRR.SS',
|
||||
'..RRRRRRRRSS',
|
||||
'..BBBSSKS.SS',
|
||||
'.BSBSSSKSS..',
|
||||
'.BSBBSSSKSS.',
|
||||
'.BBSSSSKKKK.',
|
||||
'...SSSSSS...',
|
||||
'SSRROORRR...',
|
||||
'SSRROOOORR..',
|
||||
'.RROOOOOOR..',
|
||||
'..OOOOOORR..',
|
||||
'..OOOOOOOO..',
|
||||
'..OOOOOOO...',
|
||||
'..OOOOOO....',
|
||||
'.BBB..BBB...',
|
||||
'BBBB...BBBB.',
|
||||
]
|
||||
const SM_SKID = [
|
||||
'...RRRRRR...',
|
||||
'..RRRRRRRRRR',
|
||||
'..BBBSSKK...',
|
||||
'.BSBSSSKKSS.',
|
||||
'.BSBBSSSKSS.',
|
||||
'.BBSSSSKKKK.',
|
||||
'...SSSSSS...',
|
||||
'S.RROORRR...',
|
||||
'SSRROOOOR...',
|
||||
'.RROOOOORRR.',
|
||||
'..OOOOOOORSS',
|
||||
'..OOOOOOOSSS',
|
||||
'..OOOOOOOO..',
|
||||
'..OOO.OOO...',
|
||||
'.BBB...BBB..',
|
||||
'BBBB...BBBB.',
|
||||
]
|
||||
const SM_DEAD = [
|
||||
'...RRRRRR...',
|
||||
'..RRRRRRRRRR',
|
||||
'..BBBSSSS...',
|
||||
'.BSBSSSKSS..',
|
||||
'.BSBBSSSKSS.',
|
||||
'.BBSSSSSSS..',
|
||||
'...SSSSSS...',
|
||||
'SSRROORRRSS.',
|
||||
'.RROOOORRR..',
|
||||
'..OOOOOOO...',
|
||||
'.OOOOOOOOO..',
|
||||
'.OO.OOO.OO..',
|
||||
'...OOOOO....',
|
||||
'..O.O.O.O...',
|
||||
'.BB.....BB..',
|
||||
'BBBB...BBBB.',
|
||||
]
|
||||
// 大马里奥 16×32(stand / walk1 / walk2 / jump / crouch)
|
||||
const BG_STAND = [
|
||||
'....RRRRRRR.....',
|
||||
'...RRRRRRRRRR...',
|
||||
'...RRRRRRRRRRR..',
|
||||
'...BBBSSSKS.....',
|
||||
'..BSBSSSSKSS....',
|
||||
'..BSBSSSSSKSSS..',
|
||||
'..BSBBSSSSKSSS..',
|
||||
'..BBSSSSSSKKKK..',
|
||||
'....SSSSSSSS....',
|
||||
'...RRRROORRRR...',
|
||||
'..RRRRROOOORRRR.',
|
||||
'.RRRRRROOOORRRRR',
|
||||
'.RRRRROOOOOORRRR',
|
||||
'.SSRROOYYOOORRSS',
|
||||
'.SSROOYYYYOORSSS',
|
||||
'.SSOOOYYYYOOOSSS',
|
||||
'.SSOOOOYYOOOOSSS',
|
||||
'.SSOOOOOOOOOOSSS',
|
||||
'..SOOOOOOOOOOS..',
|
||||
'...OOOOOOOOOO...',
|
||||
'...OOOOOOOOOO...',
|
||||
'..OOOOO..OOOOO..',
|
||||
'..OOOO....OOOO..',
|
||||
'..OOOO....OOOO..',
|
||||
'..OOOO....OOOO..',
|
||||
'..OOOO....OOOO..',
|
||||
'..BBBB....BBBB..',
|
||||
'..BBBB....BBBB..',
|
||||
'.BBBBB....BBBBB.',
|
||||
'.BBBBB....BBBBB.',
|
||||
'BBBBBB....BBBBBB',
|
||||
'BBBBBB....BBBBBB',
|
||||
]
|
||||
const BG_WALK1 = [
|
||||
'....RRRRRRR.....',
|
||||
'...RRRRRRRRRR...',
|
||||
'...RRRRRRRRRRR..',
|
||||
'...BBBSSSKS.....',
|
||||
'..BSBSSSSKSS....',
|
||||
'..BSBSSSSSKSSS..',
|
||||
'..BSBBSSSSKSSS..',
|
||||
'..BBSSSSSSKKKK..',
|
||||
'....SSSSSSSS....',
|
||||
'...RRRROORRRR...',
|
||||
'..RRRRROOOORRR..',
|
||||
'..RRRRROOOORRRR.',
|
||||
'..RRRROOOOOORRR.',
|
||||
'..SRROOYYOOORRS.',
|
||||
'..SROOYYYYOORSS.',
|
||||
'..SOOOYYYYOOOSS.',
|
||||
'..SOOOOYYOOOOSS.',
|
||||
'..SOOOOOOOOOOS..',
|
||||
'...OOOOOOOOOOS..',
|
||||
'...OOOOOOOOOO...',
|
||||
'...OOOOOOOOOO...',
|
||||
'...OOOOO.OOOO...',
|
||||
'..OOOOO..OOOO...',
|
||||
'..OOOO....OOO...',
|
||||
'..OOOO....OOOO..',
|
||||
'..BBBB....BBBB..',
|
||||
'..BBBBB...BBBB..',
|
||||
'.BBBBB....BBBB..',
|
||||
'.BBBB......BBBB.',
|
||||
'BBBBB......BBBB.',
|
||||
'BBBB.......BBBBB',
|
||||
'BBBB........BBB.',
|
||||
]
|
||||
const BG_WALK2 = [
|
||||
'....RRRRRRR.....',
|
||||
'...RRRRRRRRRR...',
|
||||
'...RRRRRRRRRRR..',
|
||||
'...BBBSSSKS.....',
|
||||
'..BSBSSSSKSS....',
|
||||
'..BSBSSSSSKSSS..',
|
||||
'..BSBBSSSSKSSS..',
|
||||
'..BBSSSSSSKKKK..',
|
||||
'....SSSSSSSS....',
|
||||
'...RRRROORRRR...',
|
||||
'..RRRRROOOORRR..',
|
||||
'..RRRRROOOORRRR.',
|
||||
'.RRRRROOOOOORRR.',
|
||||
'.SSRROOYYOOORRS.',
|
||||
'.SSROOYYYYOORS..',
|
||||
'.SSOOOYYYYOOOS..',
|
||||
'.SSOOOOYYOOOO...',
|
||||
'..SOOOOOOOOOO...',
|
||||
'...OOOOOOOOOO...',
|
||||
'...OOOOOOOOOO...',
|
||||
'...OOOOOOOOO....',
|
||||
'...OOOO.OOOO....',
|
||||
'..OOOO...OOOO...',
|
||||
'..OOOO...OOOO...',
|
||||
'..BBBB...BBBB...',
|
||||
'..BBBB...BBBB...',
|
||||
'.BBBBB..BBBBB...',
|
||||
'.BBBBB..BBBBB...',
|
||||
'BBBBB....BBBB...',
|
||||
'BBBB.....BBBBB..',
|
||||
'BBB.......BBBBB.',
|
||||
'BBB.........BBB.',
|
||||
]
|
||||
const BG_JUMP = [
|
||||
'....RRRRRRR..SSS',
|
||||
'...RRRRRRRRRRSSS',
|
||||
'...RRRRRRRRRRSSS',
|
||||
'...BBBSSSKS..SS.',
|
||||
'..BSBSSSSKSS....',
|
||||
'..BSBSSSSSKSSS..',
|
||||
'..BSBBSSSSKSSS..',
|
||||
'..BBSSSSSSKKKK..',
|
||||
'....SSSSSSSS....',
|
||||
'SSSRRRROORRRR...',
|
||||
'SSSRRRROOOORRR..',
|
||||
'.SRRRROOOOORRRR.',
|
||||
'..RRROOOOOOORRR.',
|
||||
'..RROOYYOOOORR..',
|
||||
'..ROOYYYYOORRS..',
|
||||
'..SOOOYYYYOOOS..',
|
||||
'..SOOOOYYOOOO...',
|
||||
'...OOOOOOOOOO...',
|
||||
'...OOOOOOOOOO...',
|
||||
'...OOOOOOOOOO...',
|
||||
'..OOOOOO.OOOO...',
|
||||
'..OOOOO..OOOO...',
|
||||
'..OOOO...OOOO...',
|
||||
'..OOOO...OOOO...',
|
||||
'..OOOO...OOOO...',
|
||||
'..BBBB...BBBB...',
|
||||
'..BBBB...BBBB...',
|
||||
'.BBBBB..BBBBB...',
|
||||
'.BBBBB..BBBBB...',
|
||||
'BBBBB....BBBBB..',
|
||||
'BBBBB.....BBBB..',
|
||||
'BBBB.......BBB..',
|
||||
]
|
||||
const BG_CROUCH = [
|
||||
'................',
|
||||
'................',
|
||||
'................',
|
||||
'................',
|
||||
'................',
|
||||
'................',
|
||||
'................',
|
||||
'................',
|
||||
'................',
|
||||
'................',
|
||||
'....RRRRRRR.....',
|
||||
'...RRRRRRRRRR...',
|
||||
'...BBBSSSKS.....',
|
||||
'..BSBSSSSKSS....',
|
||||
'..BSBBSSSSKSSS..',
|
||||
'..BBSSSSSSKKKK..',
|
||||
'....SSSSSSSS....',
|
||||
'..RRRRROOOORRR..',
|
||||
'.RRRRROOOOOORRRR',
|
||||
'.SSROOYYYYOORRSS',
|
||||
'.SSOOOYYYYOOOSSS',
|
||||
'..SOOOOOOOOOOS..',
|
||||
'...OOOOOOOOOO...',
|
||||
'..OOOOO..OOOOO..',
|
||||
'..OOOO....OOOO..',
|
||||
'..OOOO....OOOO..',
|
||||
'..OOOO....OOOO..',
|
||||
'..BBBB....BBBB..',
|
||||
'.BBBBB....BBBBB.',
|
||||
'BBBBBB....BBBBBB',
|
||||
'BBBBBB....BBBBBB',
|
||||
'................',
|
||||
]
|
||||
// 板栗仔 14×14(walk1 / walk2 / flat)
|
||||
const GOOMBA1 = [
|
||||
'....KKKKKK....',
|
||||
'..KKBBBBBBKK..',
|
||||
'.KBBBBBBBBBBK.',
|
||||
'KBBWWKBBKWWBBK',
|
||||
'KBWWKKBBKKWWBK',
|
||||
'KBBKKBBBBKKBBK',
|
||||
'KBBBBBBBBBBBBK',
|
||||
'KBBBBBBBBBBBBK',
|
||||
'.KBBBBBBBBBBK.',
|
||||
'..KTTTTTTTTK..',
|
||||
'.KTTKKKKKKTTK.',
|
||||
'KTTTK....KTTTK',
|
||||
'KTTTK....KTTTK',
|
||||
'.KKK......KKK.',
|
||||
]
|
||||
const GOOMBA2 = [
|
||||
'....KKKKKK....',
|
||||
'..KKBBBBBBKK..',
|
||||
'.KBBBBBBBBBBK.',
|
||||
'KBBWWKBBKWWBBK',
|
||||
'KBWWKKBBKKWWBK',
|
||||
'KBBKKBBBBKKBBK',
|
||||
'KBBBBBBBBBBBBK',
|
||||
'KBBBBBBBBBBBBK',
|
||||
'.KBBBBBBBBBBK.',
|
||||
'..KTTTTTTTTK..',
|
||||
'.KTTTKKKKTTTK.',
|
||||
'.KTTTK..KTTTK.',
|
||||
'KTTTK....KTTKK',
|
||||
'.KKK......KKK.',
|
||||
]
|
||||
const GOOMBA_FLAT = [
|
||||
'..............',
|
||||
'..............',
|
||||
'..............',
|
||||
'..............',
|
||||
'..............',
|
||||
'..............',
|
||||
'..............',
|
||||
'..............',
|
||||
'..............',
|
||||
'..KKKKKKKKKK..',
|
||||
'.KBBWWBBWWBBK.',
|
||||
'KBBBBBBBBBBBBK',
|
||||
'KTTTTTTTTTTTTK',
|
||||
'.KKKKKKKKKKKK.',
|
||||
]
|
||||
// 乌龟 14×20(walk1 / walk2)与龟壳 14×14
|
||||
const KOOPA1 = [
|
||||
'.....KKKK.....',
|
||||
'....KGGGGK....',
|
||||
'....KGWKGK....',
|
||||
'....KGGGGK....',
|
||||
'.....KGGK.....',
|
||||
'.....KSSK.....',
|
||||
'..KKKKSSKK....',
|
||||
'.KGGGGKSSK....',
|
||||
'KGGYYGGGGK....',
|
||||
'KGYGYYGYYGK...',
|
||||
'KGYGGYYGGYGK..',
|
||||
'KGGYYGGYYGGK..',
|
||||
'.KGGGGGGGGK...',
|
||||
'..KKGGGGKK....',
|
||||
'...KSSSSSK....',
|
||||
'...KSS.KSSK...',
|
||||
'..KSSK.KSSK...',
|
||||
'..KKK...KKK...',
|
||||
'..............',
|
||||
'..............',
|
||||
]
|
||||
const KOOPA2 = [
|
||||
'.....KKKK.....',
|
||||
'....KGGGGK....',
|
||||
'....KGWKGK....',
|
||||
'....KGGGGK....',
|
||||
'.....KGGK.....',
|
||||
'.....KSSK.....',
|
||||
'..KKKKSSKK....',
|
||||
'.KGGGGKSSK....',
|
||||
'KGGYYGGGGK....',
|
||||
'KGYGYYGYYGK...',
|
||||
'KGYGGYYGGYGK..',
|
||||
'KGGYYGGYYGGK..',
|
||||
'.KGGGGGGGGK...',
|
||||
'..KKGGGGKK....',
|
||||
'..KSSSSSSK....',
|
||||
'..KSSK.KSSK...',
|
||||
'.KSSK...KSSK..',
|
||||
'.KKK.....KKK..',
|
||||
'..............',
|
||||
'..............',
|
||||
]
|
||||
const SHELL = [
|
||||
'....KKKKKK....',
|
||||
'..KKGGGGGGKK..',
|
||||
'.KGYYGGGGYYGK.',
|
||||
'KGGYGGGGGGYGGK',
|
||||
'KGYGGYYYYGGYGK',
|
||||
'KGYGYYYYYYGYGK',
|
||||
'KGGYGGGGGGYGGK',
|
||||
'KGGGGGGGGGGGGK',
|
||||
'.KGGGGGGGGGGK.',
|
||||
'..KKGGGGGGKK..',
|
||||
'...KKKKKKKK...',
|
||||
'..............',
|
||||
'..............',
|
||||
'..............',
|
||||
]
|
||||
// 蘑菇 / 1UP / 火之花 / 星星 14×14
|
||||
const MUSHROOM = [
|
||||
'....RRRRRR....',
|
||||
'..RRRWWRRRRR..',
|
||||
'.RRWWWWRRRWWR.',
|
||||
'.RWWWWWRRRWWB.',
|
||||
'RRRWWWRRRRWWRR',
|
||||
'RRRRRRRRRRRRRR',
|
||||
'RRWWRRRRRRWWRR',
|
||||
'.RRRRRRRRRRRR.',
|
||||
'..SSSSSSSSSS..',
|
||||
'..SSKSSSSKSS..',
|
||||
'..SSKSSSSKSS..',
|
||||
'..SSSSSSSSSS..',
|
||||
'...SSSSSSSS...',
|
||||
'....SSSSSS....',
|
||||
]
|
||||
const ONEUP = [
|
||||
'....GGGGGG....',
|
||||
'..GGGWWGGGGG..',
|
||||
'.GGWWWWGGGWWG.',
|
||||
'.GWWWWWGGGWWD.',
|
||||
'GGGWWWGGGGWWGG',
|
||||
'GGGGGGGGGGGGGG',
|
||||
'GGWWGGGGGGWWGG',
|
||||
'.GGGGGGGGGGGG.',
|
||||
'..SSSSSSSSSS..',
|
||||
'..SSKSSSSKSS..',
|
||||
'..SSKSSSSKSS..',
|
||||
'..SSSSSSSSSS..',
|
||||
'...SSSSSSSS...',
|
||||
'....SSSSSS....',
|
||||
]
|
||||
const FLOWER = [
|
||||
'...WWRRRRWW...',
|
||||
'..WRRYYRRRRW..',
|
||||
'..RRYYYYYYRR..',
|
||||
'..RRYYYYYYRR..',
|
||||
'..WRRYYRRRRW..',
|
||||
'...WWRRRRWW...',
|
||||
'.....GGGG.....',
|
||||
'..GG.GGGG.GG..',
|
||||
'.GGGGGGGGGGGG.',
|
||||
'..GG.GGGG.GG..',
|
||||
'.....GGGG.....',
|
||||
'.....GGGG.....',
|
||||
'....GGGGGG....',
|
||||
'...GGGGGGGG...',
|
||||
]
|
||||
const STAR = [
|
||||
'......KK......',
|
||||
'.....KYYK.....',
|
||||
'.....KYYK.....',
|
||||
'.KKKKKYYKKKKK.',
|
||||
'.KYYYYYYYYYYK.',
|
||||
'..KYYYYYYYYK..',
|
||||
'...KYYKKYYK...',
|
||||
'...KYYKKYYK...',
|
||||
'..KYYYYYYYYK..',
|
||||
'..KYYK..KYYK..',
|
||||
'.KYYK....KYYK.',
|
||||
'.KYK......KYK.',
|
||||
'.KK........KK.',
|
||||
'..............',
|
||||
]
|
||||
|
||||
// 调色板
|
||||
const PAL_MARIO = { R: '#e04838', S: '#f3c898', B: '#7a3a10', O: '#2a5ac8', K: '#1d1409' }
|
||||
const PAL_FIRE = { R: '#f4ead2', S: '#f3c898', B: '#7a3a10', O: '#e04838', K: '#1d1409' }
|
||||
const PAL_STAR = { R: '#ffd76a', S: '#f3c898', B: '#7a3a10', O: '#7fd8f0', K: '#1d1409' }
|
||||
const PAL_STAR2 = { R: '#7fd8f0', S: '#f3c898', B: '#7a3a10', O: '#ffd76a', K: '#1d1409' }
|
||||
const PAL_GOOMBA = { K: '#1d1409', B: '#a05018', W: '#f4ead2', T: '#e8c8a0' }
|
||||
const PAL_KOOPA = { K: '#1d1409', G: '#3ab554', Y: '#e8d860', W: '#f4ead2', S: '#f3c898' }
|
||||
const PAL_MUSH = { R: '#e04838', W: '#f4ead2', S: '#f3c898', B: '#1d1409', K: '#1d1409' }
|
||||
const PAL_1UP = { G: '#3ab554', W: '#f4ead2', S: '#f3c898', B: '#1d1409', K: '#1d1409', D: '#7a3a10' }
|
||||
const PAL_FLOWER = { R: '#e04838', Y: '#ffd76a', W: '#f4ead2', G: '#3ab554' }
|
||||
const PAL_STAR_SPR = { Y: '#ffd76a', K: '#1d1409' }
|
||||
|
||||
const cache = new Map()
|
||||
function spr(key, rows, pal) {
|
||||
let c = cache.get(key)
|
||||
if (!c) {
|
||||
c = document.createElement('canvas')
|
||||
c.width = rows[0].length
|
||||
c.height = rows.length
|
||||
const g = c.getContext('2d')
|
||||
rows.forEach((r, ri) => {
|
||||
for (let i = 0; i < r.length; i++) {
|
||||
const ch = r[i]
|
||||
if (ch === '.' || ch === ' ') continue
|
||||
g.fillStyle = pal[ch] || '#f0f'
|
||||
g.fillRect(i, ri, 1, 1)
|
||||
}
|
||||
})
|
||||
cache.set(key, c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
export {
|
||||
SM_STAND, SM_WALK1, SM_WALK2, SM_JUMP, SM_SKID, SM_DEAD,
|
||||
BG_STAND, BG_WALK1, BG_WALK2, BG_JUMP, BG_CROUCH,
|
||||
GOOMBA1, GOOMBA2, GOOMBA_FLAT, KOOPA1, KOOPA2, SHELL,
|
||||
MUSHROOM, ONEUP, FLOWER, STAR,
|
||||
PAL_MARIO, PAL_FIRE, PAL_STAR, PAL_STAR2, PAL_GOOMBA, PAL_KOOPA, PAL_MUSH, PAL_1UP, PAL_FLOWER, PAL_STAR_SPR,
|
||||
spr,
|
||||
}
|
||||
77
src/games/gameDetails.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// 游戏详情页内容数据:按游戏码提供详情页展示的富内容(注册表 detail:true 的游戏才需要)。
|
||||
// 与 gameRegistry 的动态信息(操作说明/关卡/能力标记)互补,这里放静态的介绍性文案。
|
||||
// 结构:tagline 宣传语 / tags 标签 / features 特性卡片 / controls 结构化操作说明 / sections 玩法板块 / tips 技巧
|
||||
export const gameDetails = {
|
||||
starve: {
|
||||
tagline: '生火、采集、狩猎、越冬——每活过一天,都是荒野里的一次胜利',
|
||||
tags: ['生存冒险', '四季轮转', '2~4 人联机', '45 款皮肤', '19 款 Mod', '永久死亡'],
|
||||
features: [
|
||||
{ icon: '🌍', title: '随机荒野世界', desc: '噪声岛屿 + 六大生态群系 + 天然道路,每次开局都是全新地图;世界大小支持 1×~4× 自由选择' },
|
||||
{ icon: '🕯️', title: '生存三要素', desc: '饥饿 / 生命 / 理智实时管理:黄昏与黑暗蚕食理智,理智过低时暗影怪会找上门来' },
|
||||
{ icon: '🔬', title: '科技与烹饪', desc: '科学机器 / 炼金引擎两级科技树,烹饪锅炖出肉丸、蜜汁火腿等料理,火边还能烤制食材' },
|
||||
{ icon: '❄️', title: '四季轮转', desc: '秋冬春夏各有机宜:冬季保暖防冻伤、提防独眼巨鹿夜袭;雨季会浇灭篝火、加速燃料消耗' },
|
||||
{ icon: '👾', title: '怪物图鉴', desc: '蜘蛛、猎犬、杀人蜂、猪人、皮弗娄牛等 10 种生物独立 AI;给猪人喂肉可结盟助战' },
|
||||
{ icon: '🧩', title: 'Mod 自由组装', desc: '悠闲 / 噩梦等难度 Mod 与农场、工业、切斯特、魔法等内容 Mod,难度 Mod 影响得分系数' },
|
||||
{ icon: '👗', title: '45 款人物皮肤', desc: '更衣室自由换装,从经典威尔逊到节日限定;联机时向队友展示你的独特形象' },
|
||||
{ icon: '🤝', title: '组队联机', desc: '2~4 人共享同一世界,邀请码一键建房;倒下后化身幽灵继续观战,队友可施救' },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: '特色系统',
|
||||
blocks: [
|
||||
{ icon: '💾', name: '云存档 · 永久死亡', desc: '最多 12 个命名存档,每活一天自动云存;死亡即删档,开局请谨慎' },
|
||||
{ icon: '🗺️', name: '战争迷雾地图', desc: '192px 小地图随探索揭开,M 键查看全图,火堆 / 建筑 / Boss 实时标记' },
|
||||
{ icon: '🎨', name: '手绘像素美术', desc: '全部实体骨骼动画重绘,情境自绘光标:攻击 / 采集 / 建造一眼可辨' },
|
||||
{ icon: '📱', name: '触屏适配', desc: '虚拟摇杆 + 点按双模式,手机竖屏自动横排界面' },
|
||||
],
|
||||
},
|
||||
],
|
||||
controls: [
|
||||
{ keys: 'WASD / 方向键', desc: '移动' },
|
||||
{ keys: '空格', desc: '采集资源' },
|
||||
{ keys: 'F', desc: '攻击' },
|
||||
{ keys: 'E', desc: '一键交互(缺工具自动装备 / 合成)' },
|
||||
{ keys: '1 ~ 9', desc: '使用物品栏' },
|
||||
{ keys: 'C', desc: '打开合成' },
|
||||
{ keys: 'B', desc: '打开背包' },
|
||||
{ keys: 'M', desc: '开关地图' },
|
||||
{ keys: 'Esc', desc: '关闭面板 / 暂停' },
|
||||
{ keys: '鼠标', desc: '点击移动与交互 · 右键取消 / 迁移建筑' },
|
||||
],
|
||||
tips: [
|
||||
'夜晚完全黑暗会遭查理致命袭击——出门带火把,营地常备营火',
|
||||
'食物会随时间腐败,尽早点亮科学机器、用烹饪锅加工延长储备',
|
||||
'猎犬定期成群来袭,提前筑墙设防,或喂肉结盟猪人并肩作战',
|
||||
'每活一天 +100 分并自动云存档;得分系数受所选难度 Mod 影响',
|
||||
],
|
||||
},
|
||||
|
||||
mario: {
|
||||
tagline: '顶?块、吃蘑菇、踩酷栗宝——复刻红白机 1-1 ~ 3-4 的十二关经典冒险',
|
||||
tags: ['平台跳跃', '12 大关', 'NES 风 BGM', '经典复刻', '游戏内选关'],
|
||||
features: [
|
||||
{ icon: '🍄', title: '蘑菇变身', desc: '顶?块掉出金币与蘑菇,吃蘑菇变大马里奥——能顶碎砖块、硬抗一次伤害' },
|
||||
{ icon: '🌸', title: '大嘴花水管', desc: '水管里藏着会咬人的大嘴花,时机不对别乱踩;↓ 可钻进特定水管抄近路' },
|
||||
{ icon: '🚩', title: '旗杆计分', desc: '关底旗杆抓得越高分越多,助跑起跳的位置就是分数的差距' },
|
||||
{ icon: '🌉', title: '城堡 Boss 战', desc: '每大关末尾的城堡里躲着火棍,跳过库巴摸到斧头即可断桥通关' },
|
||||
{ icon: '🎵', title: 'NES 风 BGM', desc: '内置 8-bit 芯片音乐引擎,程序合成的原创旋律原汁原味' },
|
||||
{ icon: '🕹️', title: '世界地图选关', desc: '游戏内马里奥风格选关画面,解锁进度自动保存,随时从任意已解锁世界开始' },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: '关卡一览',
|
||||
blocks: [], // 由注册表 levels.names 动态渲染(世界 1-1 ~ 3-4 共 12 关)
|
||||
},
|
||||
],
|
||||
controls: [
|
||||
{ keys: '← →', desc: '左右移动' },
|
||||
{ keys: '空格 / ↑', desc: '跳跃(长按跳得更高)' },
|
||||
{ keys: '↓', desc: '钻入特定水管' },
|
||||
],
|
||||
tips: [
|
||||
'旗杆抓得越高分越多,起跳前先看清助跑距离',
|
||||
'隐藏砖块与地下水管里常有惊喜,多顶多试',
|
||||
'变小状态下挨一下就会重开,城堡关优先抢蘑菇',
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -159,8 +159,10 @@ export const gameRegistry = {
|
||||
mario: {
|
||||
loader: () => import('./components/MarioGame.vue'),
|
||||
supports: ['double_points', 'revive', 'shield'],
|
||||
controls: '←→ 移动,空格/↑ 跳跃,↓ 可钻特定水管;复刻原版 1-1~1-4 四关:顶?块出金币蘑菇,吃蘑菇变大能顶碎砖,旗杆抓得越高分越多,1-4 城堡关躲火棍跳过库巴摸斧头断桥',
|
||||
levels: { total: 4, names: ['世界 1-1', '世界 1-2', '世界 1-3', '世界 1-4'] },
|
||||
detail: true, // 独立详情页:大厅点击卡片先进 /detail/mario
|
||||
inGameSelect: true, // 选关在游戏内完成(马里奥风格世界选关),平台只留开始按钮
|
||||
controls: '←→ 移动,空格/↑ 跳跃,↓ 可钻特定水管;复刻原版 1-1~3-4 十二关:顶?块出金币蘑菇,吃蘑菇变大能顶碎砖,水管里有会咬人的大嘴花,旗杆抓得越高分越多,城堡关躲火棍跳过库巴摸斧头断桥',
|
||||
levels: { total: 12, names: ['世界 1-1', '世界 1-2', '世界 1-3', '世界 1-4', '世界 2-1', '世界 2-2', '世界 2-3', '世界 2-4', '世界 3-1', '世界 3-2', '世界 3-3', '世界 3-4'] },
|
||||
},
|
||||
stack3d: {
|
||||
loader: () => import('./components/Stack3dGame.vue'),
|
||||
@@ -200,6 +202,7 @@ export const gameRegistry = {
|
||||
starve: {
|
||||
loader: () => import('./components/StarveGame.vue'),
|
||||
supports: ['double_points'],
|
||||
detail: true, // 独立详情页:大厅点击卡片先进 /detail/starve
|
||||
saveable: true, // 云存档:每天清晨自动存,死亡删档(永久死亡);仅单人模式
|
||||
wide: true, // 宽屏游戏:游玩页解除 1200px 限宽,画布自适应铺满可视区域
|
||||
coop: true, // 组队联机:进入时可选单人冒险 / 组队联机(2~4 人共享世界)
|
||||
|
||||
290
src/games/starve/audio.js
Normal file
@@ -0,0 +1,290 @@
|
||||
// 轻量程序化音频引擎:WebAudio 全合成,零外部资源(本项目没有音频文件)。
|
||||
// - sfx(name):短音效(砍/挖/采集/受击/吃/合成/添柴/拾取/猎犬嚎/查理/Boss 等)
|
||||
// - startMusic/setMood/stopMusic:极简氛围 BGM(五声音阶琶音 + 长低音),按昼夜/季节切换情绪
|
||||
// - 开关持久化在 localStorage(starve_sfx / starve_music),GamePlay 设置面板读写
|
||||
let ac = null
|
||||
let master = null
|
||||
let sfxBus = null
|
||||
let musBus = null
|
||||
let sfxOn = typeof localStorage !== 'undefined' && localStorage.getItem('starve_sfx') !== '0'
|
||||
let musicOn = typeof localStorage !== 'undefined' && localStorage.getItem('starve_music') !== '0'
|
||||
let noiseBuf = null
|
||||
|
||||
function ensure() {
|
||||
if (ac) return ac.state !== 'closed'
|
||||
const AC = typeof window !== 'undefined' && (window.AudioContext || window.webkitAudioContext)
|
||||
if (!AC) return false
|
||||
try {
|
||||
ac = new AC()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
master = ac.createGain()
|
||||
master.gain.value = 1
|
||||
master.connect(ac.destination)
|
||||
sfxBus = ac.createGain()
|
||||
sfxBus.gain.value = sfxOn ? 0.55 : 0
|
||||
sfxBus.connect(master)
|
||||
musBus = ac.createGain()
|
||||
musBus.gain.value = musicOn ? 0.3 : 0
|
||||
musBus.connect(master)
|
||||
return true
|
||||
}
|
||||
|
||||
// 需要用户手势后才能出声(开始游戏按钮点击时机调用)
|
||||
function resume() {
|
||||
if (ac && ac.state === 'suspended') ac.resume().catch(() => {})
|
||||
}
|
||||
|
||||
function noise() {
|
||||
if (noiseBuf) return noiseBuf
|
||||
noiseBuf = ac.createBuffer(1, ac.sampleRate, ac.sampleRate)
|
||||
const d = noiseBuf.getChannelData(0)
|
||||
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1
|
||||
return noiseBuf
|
||||
}
|
||||
|
||||
function env(g, t0, attack, peak, dur) {
|
||||
g.gain.setValueAtTime(0.0001, t0)
|
||||
g.gain.exponentialRampToValueAtTime(Math.max(0.001, peak), t0 + attack)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
|
||||
}
|
||||
|
||||
let baseDelay = 0 // sfx(name, delay) 设置:把整组音推迟 delay 秒(对齐挥击命中帧)
|
||||
|
||||
function tone({ type = 'sine', f0 = 440, f1 = 0, dur = 0.15, peak = 0.4, delay = 0, out = null }) {
|
||||
const t0 = ac.currentTime + baseDelay + delay
|
||||
const o = ac.createOscillator()
|
||||
o.type = type
|
||||
o.frequency.setValueAtTime(Math.max(1, f0), t0)
|
||||
if (f1) o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), t0 + dur)
|
||||
const g = ac.createGain()
|
||||
env(g, t0, 0.01, peak, dur)
|
||||
o.connect(g)
|
||||
g.connect(out || sfxBus)
|
||||
o.start(t0)
|
||||
o.stop(t0 + dur + 0.06)
|
||||
}
|
||||
|
||||
function noiseHit({ dur = 0.12, peak = 0.3, fc = 1200, q = 1, type = 'bandpass', delay = 0, out = null }) {
|
||||
const t0 = ac.currentTime + baseDelay + delay
|
||||
const src = ac.createBufferSource()
|
||||
src.buffer = noise()
|
||||
src.loop = true
|
||||
const f = ac.createBiquadFilter()
|
||||
f.type = type
|
||||
f.frequency.value = fc
|
||||
f.Q.value = q
|
||||
const g = ac.createGain()
|
||||
env(g, t0, 0.006, peak, dur)
|
||||
src.connect(f)
|
||||
f.connect(g)
|
||||
g.connect(out || sfxBus)
|
||||
src.start(t0)
|
||||
src.stop(t0 + dur + 0.06)
|
||||
}
|
||||
|
||||
// ---- 音效配方表:全部即时合成 ----
|
||||
const RECIPES = {
|
||||
whoosh: () => noiseHit({ dur: 0.09, peak: 0.1, fc: 700, q: 0.8 }),
|
||||
chop: () => {
|
||||
noiseHit({ dur: 0.09, peak: 0.32, fc: 900, q: 1.2 })
|
||||
tone({ type: 'triangle', f0: 190, f1: 90, dur: 0.1, peak: 0.3 })
|
||||
},
|
||||
mine: () => {
|
||||
noiseHit({ dur: 0.07, peak: 0.3, fc: 2400, q: 2.5 })
|
||||
tone({ type: 'square', f0: 620, f1: 180, dur: 0.06, peak: 0.12 })
|
||||
},
|
||||
dig: () => {
|
||||
noiseHit({ dur: 0.14, peak: 0.26, fc: 420, q: 0.9 })
|
||||
tone({ type: 'triangle', f0: 130, f1: 70, dur: 0.12, peak: 0.22 })
|
||||
},
|
||||
gather: () => noiseHit({ dur: 0.12, peak: 0.16, fc: 2600, q: 0.6, type: 'highpass' }),
|
||||
hitmob: () => {
|
||||
noiseHit({ dur: 0.08, peak: 0.3, fc: 500, q: 1 })
|
||||
tone({ type: 'triangle', f0: 150, f1: 60, dur: 0.12, peak: 0.35 })
|
||||
},
|
||||
hurt: () => {
|
||||
tone({ type: 'sawtooth', f0: 300, f1: 120, dur: 0.18, peak: 0.3 })
|
||||
noiseHit({ dur: 0.1, peak: 0.18, fc: 800, q: 1 })
|
||||
},
|
||||
eat: () => {
|
||||
tone({ type: 'triangle', f0: 340, f1: 220, dur: 0.08, peak: 0.25 })
|
||||
tone({ type: 'triangle', f0: 280, f1: 180, dur: 0.08, peak: 0.25, delay: 0.11 })
|
||||
},
|
||||
pickup: () => tone({ type: 'sine', f0: 660, f1: 990, dur: 0.09, peak: 0.22 }),
|
||||
craft: () => {
|
||||
tone({ type: 'triangle', f0: 523, dur: 0.1, peak: 0.25 })
|
||||
tone({ type: 'triangle', f0: 784, dur: 0.14, peak: 0.25, delay: 0.09 })
|
||||
},
|
||||
place: () => {
|
||||
tone({ type: 'triangle', f0: 200, f1: 120, dur: 0.1, peak: 0.3 })
|
||||
noiseHit({ dur: 0.08, peak: 0.16, fc: 600, q: 1 })
|
||||
},
|
||||
fire: () => noiseHit({ dur: 0.45, peak: 0.24, fc: 1400, q: 0.4, type: 'lowpass' }),
|
||||
done: () => {
|
||||
tone({ type: 'sine', f0: 660, dur: 0.12, peak: 0.24 })
|
||||
tone({ type: 'sine', f0: 880, dur: 0.16, peak: 0.24, delay: 0.11 })
|
||||
tone({ type: 'sine', f0: 1100, dur: 0.2, peak: 0.2, delay: 0.22 })
|
||||
},
|
||||
hound: () => {
|
||||
tone({ type: 'sawtooth', f0: 700, f1: 240, dur: 0.55, peak: 0.2 })
|
||||
tone({ type: 'sawtooth', f0: 760, f1: 260, dur: 0.5, peak: 0.14, delay: 0.28 })
|
||||
},
|
||||
boss: () => {
|
||||
tone({ type: 'sawtooth', f0: 98, dur: 0.9, peak: 0.3 })
|
||||
tone({ type: 'sawtooth', f0: 74, dur: 1.1, peak: 0.3, delay: 0.35 })
|
||||
noiseHit({ dur: 0.8, peak: 0.12, fc: 220, q: 0.7, delay: 0.1 })
|
||||
},
|
||||
bossfall: () => {
|
||||
tone({ type: 'sawtooth', f0: 160, f1: 40, dur: 0.9, peak: 0.3 })
|
||||
noiseHit({ dur: 0.6, peak: 0.2, fc: 300, q: 0.6 })
|
||||
},
|
||||
charlie: () => {
|
||||
noiseHit({ dur: 0.5, peak: 0.35, fc: 2000, q: 0.3, type: 'highpass' })
|
||||
tone({ type: 'sawtooth', f0: 900, f1: 90, dur: 0.45, peak: 0.25 })
|
||||
},
|
||||
death: () => {
|
||||
tone({ type: 'triangle', f0: 420, f1: 60, dur: 1.1, peak: 0.3 })
|
||||
noiseHit({ dur: 0.7, peak: 0.14, fc: 400, q: 0.5 })
|
||||
},
|
||||
goal: () => {
|
||||
tone({ type: 'sine', f0: 660, dur: 0.1, peak: 0.2 })
|
||||
tone({ type: 'sine', f0: 990, dur: 0.18, peak: 0.2, delay: 0.1 })
|
||||
},
|
||||
splash: () => noiseHit({ dur: 0.25, peak: 0.25, fc: 1000, q: 0.5 }),
|
||||
}
|
||||
|
||||
export function sfx(name, delay = 0) {
|
||||
if (!sfxOn || !ensure()) return
|
||||
resume()
|
||||
const fn = RECIPES[name]
|
||||
if (fn) {
|
||||
try {
|
||||
baseDelay = delay
|
||||
fn()
|
||||
} catch {
|
||||
/* 音频失败不影响游戏 */
|
||||
} finally {
|
||||
baseDelay = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 氛围 BGM:步进音序器(无音频文件,程序化五声音阶琶音) ----
|
||||
let musTimer = null
|
||||
let musStep = 0
|
||||
let mood = 'day'
|
||||
const MOODS = {
|
||||
day: { scale: [0, 2, 4, 7, 9], root: 220, step: 340, wave: 'triangle', peak: 0.1 },
|
||||
night: { scale: [0, 3, 5, 7, 10], root: 165, step: 520, wave: 'sine', peak: 0.09 },
|
||||
winter: { scale: [0, 2, 3, 7, 8], root: 196, step: 440, wave: 'sine', peak: 0.08 },
|
||||
rain: { scale: [0, 2, 3, 5, 10], root: 185, step: 380, wave: 'sine', peak: 0.07 },
|
||||
}
|
||||
|
||||
function musNote(freq, dur, peak, delay = 0) {
|
||||
const t0 = ac.currentTime + delay
|
||||
const o = ac.createOscillator()
|
||||
o.type = 'triangle'
|
||||
o.frequency.value = freq
|
||||
const g = ac.createGain()
|
||||
g.gain.setValueAtTime(0.0001, t0)
|
||||
g.gain.linearRampToValueAtTime(peak, t0 + 0.04)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
|
||||
o.connect(g)
|
||||
g.connect(musBus)
|
||||
o.start(t0)
|
||||
o.stop(t0 + dur + 0.1)
|
||||
}
|
||||
|
||||
function musBass(freq, dur, peak) {
|
||||
const t0 = ac.currentTime
|
||||
const o = ac.createOscillator()
|
||||
o.type = 'sine'
|
||||
o.frequency.value = freq
|
||||
const g = ac.createGain()
|
||||
g.gain.setValueAtTime(0.0001, t0)
|
||||
g.gain.linearRampToValueAtTime(peak, t0 + 0.3)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
|
||||
o.connect(g)
|
||||
g.connect(musBus)
|
||||
o.start(t0)
|
||||
o.stop(t0 + dur + 0.1)
|
||||
}
|
||||
|
||||
function musicTick() {
|
||||
if (!ac || !musicOn) return
|
||||
const m = MOODS[mood] || MOODS.day
|
||||
const semi = (n) => m.root * Math.pow(2, n / 12)
|
||||
musStep++
|
||||
// 低音每 8 步一次
|
||||
if (musStep % 8 === 1) musBass(semi(m.scale[0]) / 2, 3.2, 0.1)
|
||||
// 琶音:随机挑音阶音,休止概率 35% 避免密集
|
||||
if (Math.random() < 0.65) {
|
||||
const deg = m.scale[Math.floor(Math.random() * m.scale.length)]
|
||||
const oct = Math.random() < 0.3 ? 2 : 1
|
||||
musNote(semi(deg) * oct, 1.6, m.peak)
|
||||
}
|
||||
// 偶发高音点缀
|
||||
if (Math.random() < 0.12) {
|
||||
const deg = m.scale[Math.floor(Math.random() * m.scale.length)]
|
||||
musNote(semi(deg) * 4, 1.2, m.peak * 0.6, 0.2)
|
||||
}
|
||||
}
|
||||
|
||||
function startMusicTimer() {
|
||||
if (musTimer) return
|
||||
const step = (MOODS[mood] || MOODS.day).step
|
||||
musTimer = setInterval(musicTick, step)
|
||||
}
|
||||
|
||||
function stopMusicTimer() {
|
||||
if (musTimer) {
|
||||
clearInterval(musTimer)
|
||||
musTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 场景情绪:day / night / winter / rain(雨优先级高于冬季)
|
||||
export function setMood(m) {
|
||||
if (!MOODS[m] || mood === m) return
|
||||
mood = m
|
||||
if (musTimer) {
|
||||
stopMusicTimer()
|
||||
startMusicTimer()
|
||||
}
|
||||
}
|
||||
|
||||
export function startMusic() {
|
||||
if (!musicOn || !ensure()) return
|
||||
resume()
|
||||
startMusicTimer()
|
||||
}
|
||||
|
||||
export function stopMusic() {
|
||||
stopMusicTimer()
|
||||
}
|
||||
|
||||
// ---- 开关(GamePlay 设置面板读写;persist 到 localStorage) ----
|
||||
export function sfxEnabled() {
|
||||
return sfxOn
|
||||
}
|
||||
export function setSfxEnabled(v) {
|
||||
sfxOn = !!v
|
||||
try {
|
||||
localStorage.setItem('starve_sfx', sfxOn ? '1' : '0')
|
||||
} catch {}
|
||||
if (sfxBus) sfxBus.gain.value = sfxOn ? 0.55 : 0
|
||||
}
|
||||
export function musicEnabled() {
|
||||
return musicOn
|
||||
}
|
||||
export function setMusicEnabled(v) {
|
||||
musicOn = !!v
|
||||
try {
|
||||
localStorage.setItem('starve_music', musicOn ? '1' : '0')
|
||||
} catch {}
|
||||
if (musBus) musBus.gain.value = musicOn ? 0.3 : 0
|
||||
if (musicOn) startMusicTimer()
|
||||
else stopMusicTimer()
|
||||
}
|
||||
95
src/games/starve/cave.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// 洞穴地图生成:与地表同尺寸网格(GW×GH,configureWorld 不重入),
|
||||
// 全岩洞地面(复用 tile 5 岩石/6 沼泽灰调,无需新瓦片美术)。
|
||||
// 布局节奏:荧光草「光岛」簇 + 簇边宝石矿,洞内探索 = 在光岛间跳跃;
|
||||
// 出口洞与玩家进入点同位。lights 表同时供黑暗渲染挖孔与洞穴光照判定使用。
|
||||
import { GW, GH, TILE } from './world.js'
|
||||
|
||||
export function genCave() {
|
||||
const tiles = new Uint8Array(GW * GH).fill(5)
|
||||
// 沼泽灰斑块:随机游走涂抹,制造地面层次
|
||||
const blobN = 10 + Math.floor(GW * GH / 2048)
|
||||
for (let b = 0; b < blobN; b++) {
|
||||
let x = 4 + Math.floor(Math.random() * (GW - 8))
|
||||
let y = 4 + Math.floor(Math.random() * (GH - 8))
|
||||
const len = 12 + Math.floor(Math.random() * 26)
|
||||
for (let i = 0; i < len; i++) {
|
||||
for (let oy = -1; oy <= 1; oy++) {
|
||||
for (let ox = -1; ox <= 1; ox++) {
|
||||
const tx = x + ox
|
||||
const ty = y + oy
|
||||
if (tx > 0 && tx < GW - 1 && ty > 0 && ty < GH - 1) tiles[ty * GW + tx] = 6
|
||||
}
|
||||
}
|
||||
x = Math.max(2, Math.min(GW - 3, x + (Math.random() < 0.5 ? 1 : -1)))
|
||||
y = Math.max(2, Math.min(GH - 3, y + (Math.random() < 0.5 ? 1 : -1)))
|
||||
}
|
||||
}
|
||||
|
||||
const px = (tx) => tx * TILE + 20 + Math.random() * (TILE - 40)
|
||||
const py = (ty) => ty * TILE + 20 + Math.random() * (TILE - 40)
|
||||
const randTile = () => ({
|
||||
tx: 3 + Math.floor(Math.random() * (GW - 6)),
|
||||
ty: 3 + Math.floor(Math.random() * (GH - 6)),
|
||||
})
|
||||
|
||||
const ents = []
|
||||
const lights = [] // 荧光草位置(世界坐标):黑暗渲染挖孔 + 洞内光照判定
|
||||
|
||||
// 出生点:世界中部附近,清出一小块「营地感」
|
||||
const cTx = Math.floor(GW / 2) + Math.floor(Math.random() * 8) - 4
|
||||
const cTy = Math.floor(GH / 2) + Math.floor(Math.random() * 6) - 3
|
||||
const spawn = { x: cTx * TILE + TILE / 2, y: cTy * TILE + TILE / 2 }
|
||||
ents.push({ type: 'caveout', x: spawn.x - 60, y: spawn.y - 40 })
|
||||
ents.push({ type: 'lightflower', x: spawn.x + 70, y: spawn.y - 30 })
|
||||
lights.push({ x: spawn.x + 70, y: spawn.y - 30 })
|
||||
|
||||
// 荧光草「光岛」簇:簇中心彼此远离,形成黑暗中的跳跃节奏
|
||||
const centers = [{ x: spawn.x, y: spawn.y }]
|
||||
let tries = 0
|
||||
while (centers.length < 7 && tries < 300) {
|
||||
tries++
|
||||
const t = randTile()
|
||||
const cx2 = t.tx * TILE + TILE / 2
|
||||
const cy2 = t.ty * TILE + TILE / 2
|
||||
if (centers.some((c) => Math.hypot(c.x - cx2, c.y - cy2) < 620)) continue
|
||||
centers.push({ x: cx2, y: cy2 })
|
||||
// 簇内:2~3 株荧光草 + 1~2 宝石矿 + 0~2 洞穴菇
|
||||
const nF = 2 + Math.floor(Math.random() * 2)
|
||||
for (let i = 0; i < nF; i++) {
|
||||
const fx = cx2 + (Math.random() - 0.5) * 150
|
||||
const fy = cy2 + (Math.random() - 0.5) * 110
|
||||
ents.push({ type: 'lightflower', x: fx, y: fy })
|
||||
lights.push({ x: fx, y: fy })
|
||||
}
|
||||
const nG = 1 + Math.floor(Math.random() * 2)
|
||||
for (let i = 0; i < nG; i++) {
|
||||
ents.push({ type: 'gemrock', x: cx2 + (Math.random() - 0.5) * 210, y: cy2 + (Math.random() - 0.5) * 160 })
|
||||
}
|
||||
const nM = Math.floor(Math.random() * 3)
|
||||
for (let i = 0; i < nM; i++) {
|
||||
ents.push({ type: 'cavemushroom', x: cx2 + (Math.random() - 0.5) * 180, y: cy2 + (Math.random() - 0.5) * 140 })
|
||||
}
|
||||
}
|
||||
|
||||
// 散装补充:黑暗中的零星矿/菇(引诱玩家带光源深入)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const t = randTile()
|
||||
ents.push({ type: 'gemrock', x: px(t.tx), y: py(t.ty) })
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const t = randTile()
|
||||
ents.push({ type: 'cavemushroom', x: px(t.tx), y: py(t.ty) })
|
||||
}
|
||||
// 石虾群:中立生物,散布在远离出生点的两处
|
||||
const herds = []
|
||||
tries = 0
|
||||
while (herds.length < 2 && tries < 200) {
|
||||
tries++
|
||||
const t = randTile()
|
||||
const hx = t.tx * TILE + TILE / 2
|
||||
const hy = t.ty * TILE + TILE / 2
|
||||
if (Math.hypot(hx - spawn.x, hy - spawn.y) < 900) continue
|
||||
herds.push({ x: hx, y: hy })
|
||||
}
|
||||
return { tiles, ents, lights, spawn, herds }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// 饥荒气候系统:季节推算、昼夜分段、环境温度、天气(雨/雪)、体温
|
||||
import { SEASONS, YEAR_DAYS, SEG_SPLIT, SEG, DAY_LEN } from './data.js'
|
||||
import { SEASONS, YEAR_DAYS, SEG_SPLIT, SEG, DAY_LEN, TUNE } from './data.js'
|
||||
|
||||
// 第 day 天所处季节:{code,name,idx,dayIn(季节内第几天),len,temp,tint}
|
||||
export function seasonOf(day) {
|
||||
@@ -57,7 +57,8 @@ export function tickWeather(wx, dt) {
|
||||
// ---- 体温 ----
|
||||
// 体温向环境漂移;篝火/火把加热;保暖装备减缓失温;保暖石托底
|
||||
// p: {temp, ...} · heat: 附近火源强度 0~1 · insul: 保暖值(冬帽 120)
|
||||
export function updateBodyTemp(p, ambient, heat, insul, dt) {
|
||||
// season/phase: 夏季白天烈日直晒额外升温(中暑来源) · vent: 散热值(夏帽 60,烈日升温减半)
|
||||
export function updateBodyTemp(p, ambient, heat, insul, dt, season = '', phase = 'day', vent = 0) {
|
||||
if (heat > 0) {
|
||||
// 火边取暖:快速升向 50
|
||||
p.temp += (50 - p.temp) * dt * 0.5 * heat
|
||||
@@ -67,5 +68,9 @@ export function updateBodyTemp(p, ambient, heat, insul, dt) {
|
||||
const k = 0.05 * (cooling ? 30 / (30 + insul) : 1)
|
||||
p.temp += (ambient - p.temp) * dt * k
|
||||
}
|
||||
// 夏季中暑:白天无火遮蔽时烈日直晒持续升温,戴夏帽可降低升温
|
||||
if (season === 'summer' && phase === 'day' && heat <= 0.35) {
|
||||
p.temp += dt * TUNE.heatGain * (1 - Math.min(0.6, vent / 100))
|
||||
}
|
||||
p.temp = Math.max(-20, Math.min(80, p.temp))
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ export const TUNE = {
|
||||
charlieSan: 20, // 查理袭击附带理智损失
|
||||
freezeHp: 1.25, // 体温 <=0 冰冻掉血/秒(官方 1.25)
|
||||
freezeAt: 0, // 冰冻阈值
|
||||
heatAt: 68, // 体温 >=68 过热掉血阈值(夏季中暑)
|
||||
heatHp: 0.8, // 过热掉血/秒(比冻伤温和)
|
||||
heatGain: 1.5, // 夏季白天烈日升温/秒
|
||||
iceboxCool: 7, // 靠近冰箱的降温/秒
|
||||
iceboxMul: 0.25, // 冰箱内食物腐败速度倍率
|
||||
caveSan: 1.2, // 洞穴黑暗中理智流失/秒(比地表夜晚温和)
|
||||
flowerLightR: 95, // 荧光草照明半径
|
||||
beeboxQueenAt: 8, // 蜂箱收蜜多少次后进化为巨型蜂巢
|
||||
packSlots: 8, // 背包附加格数
|
||||
invSlots: 15, // 主物品栏格数
|
||||
stackMax: 40, // 堆叠上限(官方 40)
|
||||
@@ -74,6 +82,10 @@ export const ITEMS = {
|
||||
petals: { name: '花瓣', food: { hunger: 1, hp: 0, san: 0 }, perish: 6 },
|
||||
reeds: { name: '芦苇' },
|
||||
ash: { name: '灰烬' },
|
||||
feather: { name: '鹅毛' },
|
||||
gem: { name: '宝石' },
|
||||
lightbulb: { name: '荧光果' },
|
||||
stinger: { name: '蜂刺' },
|
||||
beefwool: { name: '牛毛' },
|
||||
pigskin: { name: '猪皮' },
|
||||
rot: { name: '腐烂物' },
|
||||
@@ -89,6 +101,11 @@ export const ITEMS = {
|
||||
froglegs: { name: '蛙腿', food: { hunger: 12.5, hp: 0, san: -10 }, cook: 'cookedfroglegs', perish: 6, meatv: 0.5 },
|
||||
cookedfroglegs: { name: '烤蛙腿', food: { hunger: 12.5, hp: 1, san: 0 }, perish: 10, meatv: 0.5 },
|
||||
honey: { name: '蜂蜜', food: { hunger: 9.4, hp: 3, san: 0 }, perish: 40, sweet: 1 },
|
||||
honeycomb: { name: '蜂巢块' },
|
||||
fish: { name: '鱼', food: { hunger: 12.5, hp: 0, san: -5 }, cook: 'cookedfish', perish: 6, meatv: 0.5 },
|
||||
cookedfish: { name: '烤鱼', food: { hunger: 18.75, hp: 1, san: 0 }, perish: 10, meatv: 0.5 },
|
||||
cavemushroom: { name: '洞穴菇', food: { hunger: 12.5, hp: 0, san: -5 }, cook: 'cookedcavemushroom', perish: 8, veg: 0.5 },
|
||||
cookedcavemushroom: { name: '烤洞穴菇', food: { hunger: 18.75, hp: 1, san: 0 }, perish: 12, veg: 0.5 },
|
||||
eyeball: { name: '巨鹿眼球', food: { hunger: 75, hp: 40, san: -15 }, perish: 20, meatv: 1 },
|
||||
// ---- 烹饪锅料理 ----
|
||||
meatballs: { name: '肉丸', food: { hunger: 62.5, hp: 3, san: 5 }, perish: 10 },
|
||||
@@ -103,6 +120,7 @@ export const ITEMS = {
|
||||
honeypoultice: { name: '蜂蜜药膏', heal: 30, stack: 40 },
|
||||
// ---- 手部装备(官方耐久:斧 100 / 镐 33 / 铲 25 / 矛 150 / 火把计时) ----
|
||||
axe: { name: '斧头', equip: 'hand', tool: 'axe', dmg: 27, uses: 100, stack: 1 },
|
||||
fishingrod: { name: '钓竿', equip: 'hand', tool: 'fish', dmg: 5, uses: 12, stack: 1 },
|
||||
pickaxe: { name: '镐子', equip: 'hand', tool: 'pick', dmg: 27, uses: 33, stack: 1 },
|
||||
shovel: { name: '铲子', equip: 'hand', tool: 'dig', dmg: 17, uses: 25, stack: 1 },
|
||||
spear: { name: '长矛', equip: 'hand', dmg: 34, uses: 150, stack: 1 },
|
||||
@@ -115,6 +133,10 @@ export const ITEMS = {
|
||||
garland: { name: '花环', equip: 'head', sanAura: 0.08, burn: 640, stack: 1 },
|
||||
footballhelmet: { name: '猪皮帽', equip: 'head', absorb: 0.8, armor: 315, stack: 1 },
|
||||
winterhat: { name: '冬帽', equip: 'head', insul: 120, stack: 1 },
|
||||
strawhat: { name: '夏帽', equip: 'head', vent: 60, stack: 1 },
|
||||
eyebrella: { name: '眼球伞', equip: 'head', vent: 50, waterproof: true, stack: 1 },
|
||||
minerhat: { name: '矿工帽', equip: 'head', burn: 240, stack: 1 },
|
||||
beecrown: { name: '蜂王冠', equip: 'head', absorb: 0.6, armor: 500, sanAura: 0.12, stack: 1 },
|
||||
// ---- 特殊 ----
|
||||
trap: { name: '陷阱', place: true, uses: 8, stack: 10 },
|
||||
thermalstone: { name: '保暖石', thermal: true, stack: 1 },
|
||||
@@ -139,6 +161,7 @@ export const CRAFT_TABS = [
|
||||
]
|
||||
export const RECIPES = [
|
||||
{ code: 'axe', name: '斧头', tab: 'tools', tech: 0, cost: { twig: 1, flint: 1 }, desc: '砍树 · 耐久 100 次' },
|
||||
{ code: 'fishingrod', name: '钓竿', tab: 'tools', tech: 1, cost: { twig: 2, silk: 2 }, desc: '在池塘垂钓 · 耐久 12 次' },
|
||||
{ code: 'pickaxe', name: '镐子', tab: 'tools', tech: 0, cost: { twig: 2, flint: 2 }, desc: '采矿 · 耐久 33 次' },
|
||||
{ code: 'shovel', name: '铲子', tab: 'tools', tech: 1, cost: { twig: 2, flint: 2 }, desc: '挖树桩得木头 · 耐久 25 次' },
|
||||
{ code: 'torch', name: '火把', tab: 'light', tech: 0, cost: { grass: 2, twig: 2 }, desc: '手持光源 · 燃烧 60 秒' },
|
||||
@@ -155,10 +178,15 @@ export const RECIPES = [
|
||||
{ code: 'logsuit', name: '木甲', tab: 'fight', tech: 1, cost: { log: 8, rope: 2 }, desc: '身体护甲 · 减伤 80%' },
|
||||
{ code: 'footballhelmet', name: '猪皮帽', tab: 'fight', tech: 1, cost: { pigskin: 1, rope: 1 }, desc: '头部护甲 · 减伤 80%' },
|
||||
{ code: 'crockpot', name: '烹饪锅', tab: 'food', tech: 1, cost: { rock: 6, twig: 6 }, desc: '放置后投入 4 份食材炖煮料理' },
|
||||
{ code: 'beebox', name: '蜂箱', tab: 'food', tech: 2, cost: { honeycomb: 1, log: 4, rock: 2 }, build: true, desc: '定期酿蜜(上限 4)· 收蜜会被蜇,戴夏帽可防' },
|
||||
{ code: 'sciencemachine', name: '科学机器', tab: 'science', tech: 0, cost: { gold: 1, log: 4, rock: 4 }, desc: '解锁一级科技(靠近合成)' },
|
||||
{ code: 'alchemyengine', name: '炼金引擎', tab: 'science', tech: 1, cost: { gold: 6, log: 4, rock: 4 }, desc: '解锁二级科技(靠近合成)' },
|
||||
{ code: 'garland', name: '花环', tab: 'dress', tech: 0, cost: { petals: 12 }, desc: '头戴缓慢回复理智,会枯萎' },
|
||||
{ code: 'winterhat', name: '冬帽', tab: 'dress', tech: 2, cost: { silk: 4, beefwool: 2 }, desc: '保暖 · 冬季生存必备' },
|
||||
{ code: 'strawhat', name: '夏帽', tab: 'dress', tech: 1, cost: { grass: 8, petals: 3 }, desc: '头戴散热 · 夏季防中暑' },
|
||||
{ code: 'eyebrella', name: '眼球伞', tab: 'dress', tech: 2, cost: { eyeball: 1, rope: 2, silk: 4 }, desc: '巨鹿眼球所制 · 防雨淋 + 散热,四季皆宜' },
|
||||
{ code: 'minerhat', name: '矿工帽', tab: 'dress', tech: 2, cost: { strawhat: 1, gold: 1, lightbulb: 2 }, desc: '洞穴照明 · 用荧光果补燃' },
|
||||
{ code: 'icebox', name: '冰箱', tab: 'science', tech: 2, cost: { gold: 2, rock: 8, rope: 1 }, build: true, desc: '9 格储物 · 食物保鲜减速 75%' },
|
||||
]
|
||||
|
||||
// 烹饪锅配方:按优先级匹配(meatv/sweet/mm/veg/grain;itemTable 可传入 mod 合并后的物品表)
|
||||
@@ -202,6 +230,10 @@ export const MON = {
|
||||
pig: { hp: 250, speed: 125, dmg: 33, score: 0, name: '猪人', friendly: true },
|
||||
beefalo: { hp: 500, speed: 100, dmg: 34, score: 10, name: '皮弗娄牛', neutral: true },
|
||||
deerclops: { hp: 1600, speed: 70, dmg: 75, score: 400, name: '独眼巨鹿', hostile: true, boss: true },
|
||||
moosegoose: { hp: 1300, speed: 95, dmg: 60, score: 350, name: '麋鹿鹅', hostile: true, boss: true },
|
||||
dragonfly: { hp: 2000, speed: 62, dmg: 90, score: 500, name: '龙蝇', hostile: true, boss: true },
|
||||
slurtle: { hp: 300, speed: 42, dmg: 30, score: 25, name: '石虾', neutral: true },
|
||||
beequeen: { hp: 2250, speed: 78, dmg: 60, score: 800, name: '蜂王', hostile: true, boss: true },
|
||||
}
|
||||
// 击杀掉落(落地为拾取物):数组每项 [概率, code, 数量]
|
||||
export const MOB_DROPS = {
|
||||
@@ -215,6 +247,10 @@ export const MOB_DROPS = {
|
||||
pig: [[0.75, 'meat', 1], [0.25, 'pigskin', 1]],
|
||||
beefalo: [[1, 'meat', 2], [1, 'beefwool', 1]],
|
||||
deerclops: [[1, 'meat', 3], [1, 'eyeball', 1]],
|
||||
moosegoose: [[1, 'meat', 2], [1, 'feather', 2]],
|
||||
dragonfly: [[1, 'meat', 2], [1, 'gem', 2]],
|
||||
slurtle: [[0.75, 'rock', 2], [0.5, 'meat', 1]],
|
||||
beequeen: [[1, 'honey', 6], [1, 'stinger', 2], [1, 'beecrown', 1]],
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -236,12 +272,21 @@ export const RES = {
|
||||
rabbithole: { size: 18 }, // 兔子洞不挡路(否则劳工会卡在洞边)
|
||||
spidernest: { hp: 200, size: 36, nest: true },
|
||||
beehive: { hp: 100, size: 30, nest: true },
|
||||
gianthive: { hp: 300, size: 46, nest: true },
|
||||
beebox: { size: 30, struct: true },
|
||||
pond: { size: 46 },
|
||||
cavein: { size: 44, struct: true },
|
||||
caveout: { size: 40, struct: true },
|
||||
lightflower: { hits: 1, drops: { lightbulb: 1 }, size: 20, regrow: 300 },
|
||||
gemrock: { hits: 3, tool: 'pick', drops: { gem: 1, flint: 1, rock: 1 }, size: 30 },
|
||||
cavemushroom: { hits: 1, drops: { cavemushroom: 1 }, size: 18, regrow: 240 },
|
||||
pighouse: { size: 40, struct: true },
|
||||
tentacle: { hp: 250, size: 26, lurk: true },
|
||||
sciencemachine: { size: 34, struct: true, techTier: 1 },
|
||||
alchemyengine: { size: 38, struct: true, techTier: 2 },
|
||||
crockpot: { size: 26, struct: true, pot: true },
|
||||
woodchest: { size: 28, struct: true, chest: true },
|
||||
icebox: { size: 34, struct: true, chest: true },
|
||||
trap: { size: 22, placeable: true },
|
||||
loot: { size: 12, loot: true },
|
||||
}
|
||||
@@ -251,7 +296,8 @@ export const DECOR = { stump: { size: 20 }, rubble: { size: 16 } }
|
||||
export const NAMES = {
|
||||
tree: '树', rock: '岩石', goldrock: '金矿石', grass: '草丛', sapling: '树枝苗', berry: '浆果丛',
|
||||
reeds: '芦苇', flower: '野花', flint: '燧石', rabbit: '兔子', rabbithole: '兔子洞',
|
||||
spidernest: '蜘蛛巢', beehive: '蜂巢', pighouse: '猪屋', tentacle: '触手', trap: '陷阱',
|
||||
sciencemachine: '科学机器', alchemyengine: '炼金引擎', crockpot: '烹饪锅', woodchest: '木箱', loot: '掉落物',
|
||||
spidernest: '蜘蛛巢', beehive: '蜂巢', beebox: '蜂箱', pond: '池塘', pighouse: '猪屋', tentacle: '触手', trap: '陷阱',
|
||||
gianthive: '巨型蜂巢', cavein: '洞穴入口', caveout: '洞穴出口', lightflower: '荧光草', gemrock: '宝石矿', cavemushroom: '洞穴菇', slurtle: '石虾',
|
||||
sciencemachine: '科学机器', alchemyengine: '炼金引擎', crockpot: '烹饪锅', woodchest: '木箱', icebox: '冰箱', loot: '掉落物',
|
||||
stump: '树桩', rubble: '碎石',
|
||||
}
|
||||
|
||||
153
src/games/starve/fx.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// starve/fx.js — 轻量命中/采集粒子(纯视觉,不入存档、不同步联机)
|
||||
// 世界坐标存储;由 StarveGame 每帧调用 updateFx(dt) + drawFx(ctx, camX, camY)。
|
||||
// mod 不直接 import 本模块,通过 gCtx.fx(type, x, y, opts)(在 StarveGame 里接线)调用。
|
||||
// delay 选项用于把特效对齐到挥击动画的实际命中帧(挥击起手即结算伤害,视觉命中晚约 0.12~0.18s)。
|
||||
|
||||
const MAX = 220
|
||||
const live = []
|
||||
const pending = [] // 延迟生成的粒子(等待挥击命中帧)
|
||||
|
||||
const TAU = Math.PI * 2
|
||||
const rnd = (a, b) => a + Math.random() * (b - a)
|
||||
|
||||
// 类型配置:n 默认数量 / 颜色组 / 初速与重力 / 形状
|
||||
const KINDS = {
|
||||
wood: { n: 7, cols: ['#a8763e', '#8a5a2b', '#c9a36b'], sp: [70, 150], up: [-140, -40], g: 320, shape: 'chip', ttl: [0.5, 0.8], w: [2.5, 4.5] },
|
||||
stone: { n: 7, cols: ['#9aa0a8', '#7a8088', '#b8bec6'], sp: [60, 140], up: [-150, -50], g: 340, shape: 'chip', ttl: [0.45, 0.75], w: [2, 3.6] },
|
||||
gold: { n: 8, cols: ['#e0b83a', '#f4dc8a', '#c9971e'], sp: [70, 150], up: [-150, -50], g: 340, shape: 'chip', ttl: [0.5, 0.8], w: [2, 3.6] },
|
||||
dust: { n: 5, cols: ['rgba(180,170,150,0.5)', 'rgba(160,150,130,0.45)'], sp: [16, 46], up: [-34, -10], g: -26, shape: 'puff', ttl: [0.55, 0.95], w: [3, 5.5] },
|
||||
spark: { n: 9, cols: ['#ffd76a', '#ffb43c', '#fff2b8'], sp: [120, 230], up: [-200, -20], g: 240, shape: 'spark', ttl: [0.2, 0.4], w: [1.2, 2] },
|
||||
hit: { n: 1, cols: ['#fff6e0'], sp: [0, 0], up: [0, 0], g: 0, shape: 'star', ttl: [0.18, 0.24], w: [10, 14] },
|
||||
ring: { n: 1, cols: ['rgba(255,246,224,0.9)'], sp: [0, 0], up: [0, 0], g: 0, shape: 'ring', ttl: [0.26, 0.3], w: [8, 10] },
|
||||
leaf: { n: 6, cols: ['#6a8a3a', '#8aa84a', '#4a7a34'], sp: [40, 90], up: [-110, -40], g: 190, shape: 'leaf', ttl: [0.6, 1], w: [2.6, 4] },
|
||||
silk: { n: 5, cols: ['rgba(232,226,212,0.85)'], sp: [30, 70], up: [-70, -20], g: 90, shape: 'silk', ttl: [0.5, 0.9], w: [1, 1.6] },
|
||||
blood: { n: 6, cols: ['#a8323a', '#c9484a'], sp: [50, 110], up: [-120, -30], g: 300, shape: 'dot', ttl: [0.3, 0.55], w: [1.6, 2.8] },
|
||||
ice: { n: 7, cols: ['#bfe4f2', '#8ec8e0', '#e8f6fc'], sp: [60, 130], up: [-140, -40], g: 260, shape: 'chip', ttl: [0.4, 0.7], w: [2, 3.4] },
|
||||
}
|
||||
|
||||
export function spawnFx(type, x, y, o = {}) {
|
||||
const cfg = KINDS[type]
|
||||
if (!cfg) return
|
||||
if (o.delay) {
|
||||
pending.push({ type, x, y, o: { ...o }, t: o.delay })
|
||||
return
|
||||
}
|
||||
const n = Math.min(o.n ?? cfg.n, 18)
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (live.length >= MAX) live.shift()
|
||||
const a = o.dir != null
|
||||
? o.dir + rnd(-(o.spread ?? 0.9), o.spread ?? 0.9)
|
||||
: rnd(0, TAU)
|
||||
const sp = rnd(cfg.sp[0], cfg.sp[1]) * (o.power ?? 1)
|
||||
live.push({
|
||||
type,
|
||||
x: x + rnd(-3, 3),
|
||||
y: y + rnd(-4, 2),
|
||||
vx: Math.cos(a) * sp,
|
||||
vy: Math.sin(a) * sp + rnd(cfg.up[0], cfg.up[1]) * (o.power ?? 1),
|
||||
g: cfg.g,
|
||||
rot: rnd(0, TAU),
|
||||
vr: rnd(-9, 9),
|
||||
w: rnd(cfg.w[0], cfg.w[1]),
|
||||
col: cfg.cols[(Math.random() * cfg.cols.length) | 0],
|
||||
ttl: rnd(cfg.ttl[0], cfg.ttl[1]),
|
||||
age: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function updateFx(dt) {
|
||||
for (let i = pending.length - 1; i >= 0; i--) {
|
||||
pending[i].t -= dt
|
||||
if (pending[i].t <= 0) {
|
||||
const p = pending[i]
|
||||
pending.splice(i, 1)
|
||||
spawnFx(p.type, p.x, p.y, { ...p.o, delay: 0 })
|
||||
}
|
||||
}
|
||||
for (let i = live.length - 1; i >= 0; i--) {
|
||||
const p = live[i]
|
||||
p.age += dt
|
||||
if (p.age >= p.ttl) {
|
||||
live.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
p.vy += p.g * dt
|
||||
p.x += p.vx * dt
|
||||
p.y += p.vy * dt
|
||||
p.rot += p.vr * dt
|
||||
}
|
||||
}
|
||||
|
||||
export function drawFx(ctx, camX, camY) {
|
||||
if (!live.length) return
|
||||
ctx.save()
|
||||
live.forEach((p) => {
|
||||
const k = 1 - p.age / p.ttl
|
||||
const sx = p.x - camX
|
||||
const sy = p.y - camY
|
||||
ctx.save()
|
||||
ctx.translate(sx, sy)
|
||||
ctx.rotate(p.rot)
|
||||
ctx.globalAlpha = Math.min(1, k * 1.6)
|
||||
ctx.fillStyle = p.col
|
||||
if (p.shape === 'chip') {
|
||||
ctx.fillRect(-p.w * 0.5, -p.w * 0.28, p.w, p.w * 0.56)
|
||||
} else if (p.shape === 'puff') {
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, p.w * (1 + p.age * 2.2), 0, TAU)
|
||||
ctx.fill()
|
||||
} else if (p.shape === 'spark') {
|
||||
ctx.strokeStyle = p.col
|
||||
ctx.lineWidth = p.w
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(p.vx * 0.03, p.vy * 0.03)
|
||||
ctx.stroke()
|
||||
} else if (p.shape === 'star') {
|
||||
// 四芒星闪光
|
||||
const r = p.w * (0.6 + k * 0.6)
|
||||
ctx.strokeStyle = p.col
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath()
|
||||
for (let a = 0; a < 4; a++) {
|
||||
const ang = (a / 4) * TAU + Math.PI / 4
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(Math.cos(ang) * r, Math.sin(ang) * r)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, r * 0.28, 0, TAU)
|
||||
ctx.fill()
|
||||
} else if (p.shape === 'ring') {
|
||||
const r = p.w * (1 + (1 - k) * 2.6)
|
||||
ctx.strokeStyle = p.col
|
||||
ctx.lineWidth = 2 * k
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, r, 0, TAU)
|
||||
ctx.stroke()
|
||||
} else if (p.shape === 'leaf') {
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(0, 0, p.w, p.w * 0.45, 0, 0, TAU)
|
||||
ctx.fill()
|
||||
} else if (p.shape === 'silk') {
|
||||
ctx.strokeStyle = p.col
|
||||
ctx.lineWidth = p.w
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.quadraticCurveTo(p.w * 2, -2, p.w * 3.6, 1)
|
||||
ctx.stroke()
|
||||
} else {
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, p.w, 0, TAU)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
})
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
export function clearFx() {
|
||||
live.length = 0
|
||||
pending.length = 0
|
||||
}
|
||||
75
src/games/starve/guide.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// 新手目标引导:开局 7 步任务链,由核心动作事件驱动(gather/craft/place/eat/newday)。
|
||||
// 每完成一步给小奖励(+20 分);数据随存档持久化(旧档无 guide 字段视为不需要引导)。
|
||||
// 用法:
|
||||
// const gd = newGuide() // 新开局
|
||||
// guideEvent(gd, 'gather', { type: 'grass' }) // 喂事件;返回 true 表示本步刚完成
|
||||
// guideText(gd) // 当前目标文案('' = 全部完成/无引导)
|
||||
|
||||
export const GUIDE_REWARD = 20
|
||||
|
||||
export function newGuide() {
|
||||
return { step: 0, grass: 0, twig: 0 }
|
||||
}
|
||||
|
||||
export const GUIDE_STEPS = 7
|
||||
|
||||
export function guideDone(gd) {
|
||||
return !gd || gd.step >= GUIDE_STEPS
|
||||
}
|
||||
|
||||
// 喂事件:ev ∈ gather/craft/place/eat/newday;p 为事件参数
|
||||
// 返回刚完成的步数(0 = 无变化),调用方据此发奖励与提示
|
||||
export function guideEvent(gd, ev, p) {
|
||||
if (!gd || gd.step >= GUIDE_STEPS) return 0
|
||||
const before = gd.step
|
||||
switch (gd.step) {
|
||||
case 0: // 采集基础材料:3 草 + 2 树枝
|
||||
if (ev === 'gather') {
|
||||
if (p?.type === 'grass') gd.grass++
|
||||
if (p?.type === 'sapling') gd.twig++
|
||||
if (gd.grass >= 3 && gd.twig >= 2) gd.step = 1
|
||||
}
|
||||
break
|
||||
case 1: // 制作斧头
|
||||
if (ev === 'craft' && p?.code === 'axe') gd.step = 2
|
||||
break
|
||||
case 2: // 砍倒一棵树
|
||||
if (ev === 'gather' && p?.type === 'tree') gd.step = 3
|
||||
break
|
||||
case 3: // 生起火
|
||||
if (ev === 'place' && (p?.code === 'campfire' || p?.code === 'firepit')) gd.step = 4
|
||||
break
|
||||
case 4: // 吃东西
|
||||
if (ev === 'eat') gd.step = 5
|
||||
break
|
||||
case 5: // 建造科学机器
|
||||
if (ev === 'place' && p?.code === 'sciencemachine') gd.step = 6
|
||||
break
|
||||
case 6: // 熬过第一夜(进入新的一天)
|
||||
if (ev === 'newday') gd.step = 7
|
||||
break
|
||||
}
|
||||
return gd.step > before ? gd.step : 0
|
||||
}
|
||||
|
||||
export function guideText(gd) {
|
||||
if (!gd || gd.step >= GUIDE_STEPS) return ''
|
||||
switch (gd.step) {
|
||||
case 0:
|
||||
return `新手目标 · 采集材料:草 ${Math.min(3, gd.grass)}/3 · 树枝 ${Math.min(2, gd.twig)}/2(走近按空格)`
|
||||
case 1:
|
||||
return '新手目标 · 按 C 打开合成,做一把斧头'
|
||||
case 2:
|
||||
return '新手目标 · 砍倒一棵树获得木头(装备斧头走近采集)'
|
||||
case 3:
|
||||
return '新手目标 · 合成篝火并放置(天黑前必须有火!)'
|
||||
case 4:
|
||||
return '新手目标 · 吃点东西(点击物品栏里的浆果/萝卜)'
|
||||
case 5:
|
||||
return '新手目标 · 建造科学机器,解锁高级配方'
|
||||
case 6:
|
||||
return '新手目标 · 守着火堆熬过这一夜'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
// addEntity(type,x,y,extra) · removeEntity(ent) · addLoot(x,y,code,n)
|
||||
// removeMob(m) · killMob(m,credit) · smashStructure(ent) · tip(t) · ptip(p,t) · shake(amp)
|
||||
import { MON } from './data.js'
|
||||
import { spawnFx } from './fx.js'
|
||||
|
||||
function isLaborMob(m) {
|
||||
const k = m?.kind
|
||||
@@ -14,10 +15,12 @@ function isFenceLike(e) {
|
||||
return e.type === 'fence' || e.type === 'gate' || e.type === 'campfence' || e.type === 'campgate'
|
||||
}
|
||||
// 固体栅栏/关着的门挡路(劳工穿栅栏;开着的门、兔子洞不挡)
|
||||
// 用 g.queryEnts(空间网格)取附近候选,替代全实体线性扫描;无网格时回退全量
|
||||
function blockedBySolid(g, x, y, m = null) {
|
||||
if (!g.entities) return false
|
||||
const labor = isLaborMob(m)
|
||||
for (const e of g.entities) {
|
||||
const cands = typeof g.queryEnts === 'function' ? g.queryEnts(x, y, 64) : g.entities
|
||||
for (const e of cands) {
|
||||
if (!e.solid) continue
|
||||
// 旧档兔子洞可能仍带 solid,一律不挡路
|
||||
if (e.type === 'rabbithole') continue
|
||||
@@ -38,7 +41,8 @@ function canStand(g, x, y, m = null) {
|
||||
return g.isLand(x, y) && !blockedBySolid(g, x, y, m)
|
||||
}
|
||||
function nearCampWall(g, m) {
|
||||
return (g.entities || []).some((e) => {
|
||||
const cands = typeof g.queryEnts === 'function' ? g.queryEnts(m.x, m.y, 64) : g.entities || []
|
||||
return cands.some((e) => {
|
||||
if (e.type === 'campfence') return Math.hypot(e.x - m.x, e.y - m.y) < 44
|
||||
if (e.type === 'campgate' && !e.mx?.open) return Math.hypot(e.x - m.x, e.y - m.y) < 52
|
||||
return false
|
||||
@@ -154,6 +158,10 @@ export function updateMob(m, dt, g) {
|
||||
else if (m.kind === 'bee') updBee(m, conf, dt, g)
|
||||
else if (m.kind === 'frog') updFrog(m, conf, dt, g)
|
||||
else if (m.kind === 'deerclops') updDeerclops(m, conf, dt, g)
|
||||
else if (m.kind === 'moosegoose') updMoosegoose(m, conf, dt, g)
|
||||
else if (m.kind === 'dragonfly') updDragonfly(m, conf, dt, g)
|
||||
else if (m.kind === 'slurtle') updSlurtle(m, conf, dt, g)
|
||||
else if (m.kind === 'beequeen') updBeequeen(m, conf, dt, g)
|
||||
m.x = Math.max(24, Math.min(g.worldW - 24, m.x))
|
||||
m.y = Math.max(24, Math.min(g.worldH - 24, m.y))
|
||||
}
|
||||
@@ -178,7 +186,7 @@ function updShadow(m, conf, dt, g) {
|
||||
|
||||
// 蜘蛛:巢边领地怪,白天守巢、夜晚游荡范围扩大;入侵者靠近即追击
|
||||
function updSpider(m, conf, dt, g) {
|
||||
const home = m.nest && g.entities.includes(m.nest) ? m.nest : null
|
||||
const home = m.nest && !m.nest._gone ? m.nest : null
|
||||
const ax = home ? home.x : m.x
|
||||
const ay = home ? home.y : m.y
|
||||
const aggroR = g.phase === 'night' ? 280 : 190
|
||||
@@ -249,6 +257,8 @@ function updTreeguard(m, conf, dt, g) {
|
||||
m.state = 'smash'
|
||||
m.t = 0.35
|
||||
g.shake(6)
|
||||
spawnFx('dust', m.x + 30, m.y - 4, { n: 6, dir: 0.4, spread: 0.8 })
|
||||
spawnFx('leaf', m.x - 20, m.y - 40, { n: 4 })
|
||||
g.players.forEach((p) => {
|
||||
if (!p.dead && Math.hypot(p.x - m.x, p.y - m.y) < 70) {
|
||||
g.damagePlayer(p, conf.dmg, '被树精拍中了!')
|
||||
@@ -275,7 +285,7 @@ function updPig(m, conf, dt, g) {
|
||||
return
|
||||
}
|
||||
// 2) 与敌对生物作战(结盟时主动搜索主人周边的敌人)
|
||||
if (m.targetMob && (!g.monsters.includes(m.targetMob) || m.targetMob.hp <= 0)) m.targetMob = null
|
||||
if (m.targetMob && (m.targetMob._gone || m.targetMob.hp <= 0)) m.targetMob = null
|
||||
if (!m.targetMob && m.loyalT > 0) {
|
||||
const owner = m.owner && !m.owner.dead ? m.owner : null
|
||||
let best = null
|
||||
@@ -409,18 +419,18 @@ function updFrog(m, conf, dt, g) {
|
||||
}
|
||||
|
||||
// 独眼巨鹿:优先拆毁玩家附近建筑,其次追人;范围砸击;冬季结束自行离去
|
||||
function updDeerclops(m, conf, dt, g) {
|
||||
m.t -= dt
|
||||
function updDeerclops(m, conf, dt, g) { m.t -= dt
|
||||
if (g.season !== 'winter') {
|
||||
g.removeMob(m)
|
||||
g.tip('冬天过去了,独眼巨鹿转身离开……')
|
||||
return
|
||||
}
|
||||
// 目标:先找 500 内建筑,否则最近的玩家
|
||||
if (!m.targetEnt || !g.entities.includes(m.targetEnt)) {
|
||||
// 目标:先找 500 内建筑,否则最近的玩家(空间网格查询)
|
||||
if (!m.targetEnt || m.targetEnt._gone) {
|
||||
m.targetEnt = null
|
||||
let bd = 500
|
||||
g.entities.forEach((e) => {
|
||||
const cands = typeof g.queryEnts === 'function' ? g.queryEnts(m.x, m.y, 520) : g.entities
|
||||
cands.forEach((e) => {
|
||||
if (!(e.struct || e.pot)) return
|
||||
const d = Math.hypot(e.x - m.x, e.y - m.y)
|
||||
if (d < bd) { bd = d; m.targetEnt = e }
|
||||
@@ -450,6 +460,8 @@ function updDeerclops(m, conf, dt, g) {
|
||||
m.state = 'smash'
|
||||
m.t = 0.4
|
||||
g.shake(10)
|
||||
spawnFx('dust', m.x + 40, m.y - 6, { n: 8, dir: 0.3, spread: 0.9, power: 1.4 })
|
||||
spawnFx('dust', m.x - 40, m.y - 6, { n: 8, dir: Math.PI - 0.3, spread: 0.9, power: 1.4 })
|
||||
// 范围砸击:玩家/猪人受伤,建筑直接摧毁
|
||||
g.players.forEach((p) => {
|
||||
if (!p.dead && Math.hypot(p.x - m.x, p.y - m.y) < 95) {
|
||||
@@ -474,3 +486,177 @@ function updDeerclops(m, conf, dt, g) {
|
||||
if (m.t <= 0) m.state = 'chase'
|
||||
}
|
||||
}
|
||||
|
||||
// 麋鹿鹅:春季袭击;逼近后扑翅摔砸(范围震击);春季结束振翅离去
|
||||
function updMoosegoose(m, conf, dt, g) {
|
||||
m.t -= dt
|
||||
if (g.season !== 'spring') {
|
||||
g.removeMob(m)
|
||||
g.tip('春天过去了,麋鹿鹅振翅离开……')
|
||||
return
|
||||
}
|
||||
const near = nearestVictim(m, g, true)
|
||||
if (!near) return
|
||||
const tp = near.v.ref
|
||||
const dist = near.d
|
||||
if (m.state === 'chase' || !m.state) {
|
||||
if (dist > 60) toward(m, tp.x, tp.y, conf.speed, dt, g)
|
||||
else {
|
||||
m.state = 'windup'
|
||||
m.t = 0.6
|
||||
}
|
||||
} else if (m.state === 'windup') {
|
||||
if (m.t <= 0) {
|
||||
m.state = 'smash'
|
||||
m.t = 0.35
|
||||
g.shake(9)
|
||||
spawnFx('dust', m.x + 30, m.y - 4, { n: 7, dir: 0.4, spread: 0.8 })
|
||||
spawnFx('leaf', m.x - 20, m.y - 30, { n: 5 })
|
||||
// 扑砸范围伤害
|
||||
g.players.forEach((p) => {
|
||||
if (!p.dead && Math.hypot(p.x - m.x, p.y - m.y) < 85) {
|
||||
g.damagePlayer(p, conf.dmg, '被麋鹿鹅扑砸中了!')
|
||||
}
|
||||
})
|
||||
g.monsters.forEach((q) => {
|
||||
if (q.kind === 'pig' && Math.hypot(q.x - m.x, q.y - m.y) < 85) {
|
||||
q.hp -= conf.dmg
|
||||
if (q.hp <= 0) g.killMob(q, null)
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (m.state === 'smash') {
|
||||
if (m.t <= 0) { m.state = 'recover'; m.t = 1.0 }
|
||||
} else if (m.state === 'recover') {
|
||||
if (m.t <= 0) m.state = 'chase'
|
||||
}
|
||||
}
|
||||
|
||||
// 龙蝇:夏季袭击;优先焚烧可燃目标(树/建筑/植物);夏季结束钻回地底
|
||||
function updDragonfly(m, conf, dt, g) {
|
||||
m.t -= dt
|
||||
if (g.season !== 'summer') {
|
||||
g.removeMob(m)
|
||||
g.tip('夏天过去了,龙蝇钻回了地底……')
|
||||
return
|
||||
}
|
||||
// 目标:先找附近可燃物,否则最近玩家
|
||||
if (!m.targetEnt || m.targetEnt._gone) {
|
||||
m.targetEnt = null
|
||||
let bd = 460
|
||||
const cands = typeof g.queryEnts === 'function' ? g.queryEnts(m.x, m.y, 480) : g.entities
|
||||
cands.forEach((e) => {
|
||||
const flammable = e.type === 'tree' || e.struct || e.pot || e.type === 'grass' || e.type === 'sapling' || e.type === 'berry'
|
||||
if (!flammable) return
|
||||
const d = Math.hypot(e.x - m.x, e.y - m.y)
|
||||
if (d < bd) { bd = d; m.targetEnt = e }
|
||||
})
|
||||
}
|
||||
const near = nearestVictim(m, g)
|
||||
let tx
|
||||
let ty
|
||||
let dist
|
||||
if (m.targetEnt) {
|
||||
tx = m.targetEnt.x
|
||||
ty = m.targetEnt.y
|
||||
dist = Math.hypot(tx - m.x, ty - m.y)
|
||||
} else if (near) {
|
||||
tx = near.v.ref.x
|
||||
ty = near.v.ref.y
|
||||
dist = near.d
|
||||
} else return
|
||||
if (m.state === 'chase' || !m.state) {
|
||||
if (dist > 70) toward(m, tx, ty, conf.speed, dt, g)
|
||||
else {
|
||||
m.state = 'windup'
|
||||
m.t = 0.8
|
||||
}
|
||||
} else if (m.state === 'windup') {
|
||||
if (m.t <= 0) {
|
||||
m.state = 'smash'
|
||||
m.t = 0.4
|
||||
g.shake(10)
|
||||
spawnFx('dust', m.x + 34, m.y - 8, { n: 8, dir: 0.3, spread: 0.9, power: 1.4 })
|
||||
// 喷烧:玩家/猪人受伤,可燃物焚毁
|
||||
g.players.forEach((p) => {
|
||||
if (!p.dead && Math.hypot(p.x - m.x, p.y - m.y) < 95) {
|
||||
g.damagePlayer(p, conf.dmg, '被龙蝇喷烧了!')
|
||||
}
|
||||
})
|
||||
g.monsters.forEach((q) => {
|
||||
if (q.kind === 'pig' && Math.hypot(q.x - m.x, q.y - m.y) < 95) {
|
||||
q.hp -= conf.dmg
|
||||
if (q.hp <= 0) g.killMob(q, null)
|
||||
}
|
||||
})
|
||||
g.entities.slice().forEach((e) => {
|
||||
if (Math.hypot(e.x - m.x, e.y - m.y) >= 95) return
|
||||
if (e.struct || e.pot) {
|
||||
g.smashStructure(e)
|
||||
} else if (e.type === 'tree') {
|
||||
g.removeEntity(e)
|
||||
g.addLoot(e.x, e.y, 'ash', 1)
|
||||
} else if (e.type === 'grass' || e.type === 'sapling' || e.type === 'berry') {
|
||||
g.removeEntity(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (m.state === 'smash') {
|
||||
if (m.t <= 0) { m.state = 'recover'; m.t = 1.2 }
|
||||
} else if (m.state === 'recover') {
|
||||
if (m.t <= 0) m.state = 'chase'
|
||||
}
|
||||
}
|
||||
|
||||
// 石虾:洞穴中立生物,壳厚皮慢;被打会缓慢反击
|
||||
function updSlurtle(m, conf, dt, g) {
|
||||
if (m.angryT > 0) {
|
||||
m.angryT -= dt
|
||||
if (m.angryAt && !m.angryAt.dead) {
|
||||
const d = Math.hypot(m.angryAt.x - m.x, m.angryAt.y - m.y)
|
||||
chaseBite(m, conf, { kind: 'p', ref: m.angryAt }, d, dt, g, '被石虾撞了', (ref) => {
|
||||
g.ptip(ref, '石虾缩头再撞! · 生命 -' + conf.dmg)
|
||||
})
|
||||
return
|
||||
}
|
||||
m.angryT = 0
|
||||
}
|
||||
m.wanderT -= dt
|
||||
if (m.wanderT <= 0) {
|
||||
m.wanderT = 3 + Math.random() * 5
|
||||
const ang = Math.random() * Math.PI * 2
|
||||
m.tx = m.x + Math.cos(ang) * Math.random() * 90
|
||||
m.ty = m.y + Math.sin(ang) * Math.random() * 90
|
||||
}
|
||||
if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 10) {
|
||||
toward(m, m.tx, m.ty, conf.speed * 0.5, dt, g)
|
||||
}
|
||||
}
|
||||
|
||||
// 蜂王:养蜂线的终极 Boss;追蜇 + 定期召唤杀人蜂护卫
|
||||
function updBeequeen(m, conf, dt, g) {
|
||||
m.t -= dt
|
||||
m.summonCd = (m.summonCd || 4) - dt
|
||||
const near = nearestVictim(m, g, true)
|
||||
if (!near) return
|
||||
chaseBite(m, conf, near.v, near.d, dt, g, '被蜂王蜇了', (ref) => {
|
||||
if (near.v.kind === 'p') {
|
||||
ref.san = Math.max(0, ref.san - 8)
|
||||
g.ptip(ref, '蜂王的毒刺! · 生命 -' + conf.dmg + ' 理智 -8')
|
||||
}
|
||||
})
|
||||
// 召唤护卫:场上蜂群不超过 6 只时补 2 只
|
||||
if (m.summonCd <= 0) {
|
||||
m.summonCd = 8
|
||||
const bees = g.monsters.filter((q) => q.kind === 'bee').length
|
||||
if (bees < 6) {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const ang = Math.random() * Math.PI * 2
|
||||
const sx = m.x + Math.cos(ang) * 50
|
||||
const sy = m.y + Math.sin(ang) * 50
|
||||
g.spawnMob('bee', sx, sy, { angryAt: near.v.kind === 'p' ? near.v.ref : null, life: 25 })
|
||||
}
|
||||
g.tip('蜂王召唤了护卫蜂群!')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,291 @@ export function agriYieldAt(x, y, zoneId) {
|
||||
return zoneYieldMul(z?.level || 1)
|
||||
}
|
||||
|
||||
// ---- 地下生态圈(子地图):堆肥造肥 / 滴灌加速 / 地热越冬 / 蚯蚓松土 ----
|
||||
import { skinByCode, drawPlayerFig } from '../../starveSkins.js'
|
||||
import { makeSubmap } from './submap.js'
|
||||
|
||||
const ROOT_MAX = 260 // 与地窖联动的农田半径
|
||||
|
||||
const cellarSub = makeSubmap({
|
||||
W: 480,
|
||||
H: 300,
|
||||
wallRatio: 0.34,
|
||||
speed: 150,
|
||||
avatarR: 13,
|
||||
reach: 46,
|
||||
skins: { skinByCode, drawPlayerFig },
|
||||
title: () => '地下生态圈',
|
||||
enter() {
|
||||
return { x: 240, y: 264 }
|
||||
},
|
||||
bounds() {
|
||||
return { x0: 26, y0: 118, x1: 454, y1: 282 }
|
||||
},
|
||||
objects(g, host, st) {
|
||||
const mx = host.mx || {}
|
||||
const objs = [
|
||||
{ id: 'compost', x: 58, y: 196, w: 66, h: 56, solid: true, prompt: (mx.compost || 0) >= 5 ? '取出肥料' : `投料堆肥 ${mx.compost || 0}/5` },
|
||||
{ id: 'valve', x: 214, y: 112, w: 52, h: 42, solid: true, prompt: mx.irrigOn ? '关闭滴灌' : '开启滴灌' },
|
||||
{ id: 'heater', x: 354, y: 196, w: 64, h: 56, solid: true, prompt: mx.heatOn ? '关闭地热' : '开启地热' },
|
||||
{ id: 'ladder', x: 214, y: 248, w: 52, h: 46, solid: false, prompt: '爬回地面' },
|
||||
]
|
||||
;(st.rt.worms || []).forEach((w, i) => {
|
||||
objs.push({ id: 'worm:' + i, x: w.x - 13, y: w.y - 9, w: 26, h: 18, prompt: '捉住蚯蚓', reach: 34 })
|
||||
})
|
||||
return objs
|
||||
},
|
||||
tick(g, dt, host, st) {
|
||||
const mx = host.mx
|
||||
// 蚯蚓每日刷新(数据记 mx.wormDay,入档)
|
||||
if (st.rt.worms == null) {
|
||||
st.rt.worms = []
|
||||
if ((mx.wormDay || 0) !== g.day) {
|
||||
const n = 2 + (g.rand ? g.rand(0, 1) : 0)
|
||||
for (let i = 0; i < n; i++) {
|
||||
st.rt.worms.push({
|
||||
x: 120 + Math.random() * 240,
|
||||
y: 170 + Math.random() * 70,
|
||||
ph: Math.random() * 6,
|
||||
vx: (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 10),
|
||||
})
|
||||
}
|
||||
mx.wormDay = g.day
|
||||
g.markDirty(host)
|
||||
}
|
||||
}
|
||||
;(st.rt.worms || []).forEach((w) => {
|
||||
w.x += w.vx * dt
|
||||
w.ph += dt * 6
|
||||
if (w.x < 70 || w.x > 410) w.vx *= -1
|
||||
w.y = 190 + Math.sin(w.ph * 0.35) * 46
|
||||
})
|
||||
},
|
||||
interact(g, p, host, id, st) {
|
||||
const mx = host.mx
|
||||
if (id === 'leave' || id === 'ladder') {
|
||||
cellarSub.close(g)
|
||||
g.ptip(p, '顺着梯子回到了地面')
|
||||
return
|
||||
}
|
||||
if (id === 'compost') {
|
||||
if ((mx.compost || 0) >= 5) {
|
||||
mx.compost -= 5
|
||||
g.addItem(p, 'fertilizer', 1)
|
||||
g.markDirty(host)
|
||||
g.swing(p, 'dig')
|
||||
g.ptip(p, '堆肥熟成了!肥料 ×1')
|
||||
} else if (g.countItem(p, 'grass') >= 2) {
|
||||
g.payCost(p, { grass: 2 })
|
||||
mx.compost = Math.min(5, (mx.compost || 0) + 1)
|
||||
g.markDirty(host)
|
||||
g.swing(p, 'dig')
|
||||
g.ptip(p, `埋进两把草,堆肥发酵中…… ${mx.compost}/5`)
|
||||
} else {
|
||||
g.ptip(p, '堆肥需要 2 份草(割草就有),攒满 5 层可取出肥料')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (id === 'valve') {
|
||||
mx.irrigOn = !mx.irrigOn
|
||||
g.markDirty(host)
|
||||
g.ptip(p, mx.irrigOn ? '滴灌开启:周边农田生长 +25%' : '滴灌已关闭')
|
||||
return
|
||||
}
|
||||
if (id === 'heater') {
|
||||
mx.heatOn = !mx.heatOn
|
||||
g.markDirty(host)
|
||||
g.ptip(p, mx.heatOn ? '地热开启:冬季作物也会缓慢生长' : '地热已关闭')
|
||||
return
|
||||
}
|
||||
if (id.startsWith && id.startsWith('worm:')) {
|
||||
const i = +id.slice(5)
|
||||
if (st.rt.worms?.[i]) st.rt.worms.splice(i, 1)
|
||||
mx.compost = Math.min(5, (mx.compost || 0) + 1)
|
||||
g.addScore(2, p)
|
||||
g.markDirty(host)
|
||||
g.swing(p, 'dig')
|
||||
g.ptip(p, '捉到一条肥蚯蚓,丢进了堆肥箱 +1')
|
||||
return
|
||||
}
|
||||
},
|
||||
draw(g, ctx, v, host, st, env) {
|
||||
const t = env.time || 0
|
||||
const mx = host.mx || {}
|
||||
ctx.save()
|
||||
ctx.translate(v.px, v.py)
|
||||
ctx.scale(v.scale, v.scale)
|
||||
// —— 土壤分层 ——
|
||||
const layers = [[0, '#5d4228'], [56, '#543a22'], [118, '#4a321e'], [180, '#3f2a1a'], [242, '#362316']]
|
||||
layers.forEach(([y0, col], i) => {
|
||||
const y1 = layers[i + 1]?.[0] ?? 300
|
||||
ctx.fillStyle = col
|
||||
ctx.fillRect(0, y0, 480, y1 - y0 + 1)
|
||||
ctx.strokeStyle = 'rgba(20,12,6,0.55)'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(0, y1 - 1); ctx.lineTo(480, y1 - 1); ctx.stroke()
|
||||
})
|
||||
ctx.fillStyle = 'rgba(28,17,7,0.5)'
|
||||
for (let i = 0; i < 46; i++) {
|
||||
ctx.beginPath()
|
||||
ctx.arc((i * 97 + 31) % 470, (i * 53 + 17) % 292, 1.7, 0, TAU)
|
||||
ctx.fill()
|
||||
}
|
||||
// —— 地表草皮带 + 垂须 ——
|
||||
ctx.fillStyle = '#5d7a34'
|
||||
ctx.fillRect(0, 0, 480, 12)
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(0, 12); ctx.lineTo(480, 12); ctx.stroke()
|
||||
ctx.strokeStyle = 'rgba(140,170,90,0.4)'
|
||||
ctx.lineWidth = 1.2
|
||||
for (let i = 0; i < 13; i++) {
|
||||
const x = 18 + i * 37
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, 12)
|
||||
ctx.quadraticCurveTo(x + 4, 26, x - 2, 38)
|
||||
ctx.stroke()
|
||||
}
|
||||
// —— 上方农田根系(生长进度可视化)——
|
||||
ctx.lineCap = 'round'
|
||||
const farms = g.entities.filter((e) => e.type === 'agrifarm' && Math.hypot(e.x - host.x, e.y - host.y) < ROOT_MAX)
|
||||
farms.slice(0, 6).forEach((e, i) => {
|
||||
const gx = 66 + i * 68
|
||||
const prog = e.state === 'ready' ? 1 : Math.min(1, (e.mx?.t || 0) / GROW_TOTAL)
|
||||
const depth = 26 + prog * 74
|
||||
ctx.strokeStyle = e.state === 'ready' ? 'rgba(255,220,120,0.9)' : 'rgba(196,158,104,0.8)'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(gx, 40)
|
||||
ctx.quadraticCurveTo(gx + 6, 40 + depth * 0.5, gx, 40 + depth)
|
||||
ctx.stroke()
|
||||
ctx.lineWidth = 1.6
|
||||
;[-1, 1].forEach((s) => {
|
||||
for (let k = 1; k <= 3; k++) {
|
||||
const y2 = 40 + depth * (0.25 + k * 0.22)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(gx, y2)
|
||||
ctx.quadraticCurveTo(gx + s * 8, y2 + 5, gx + s * 13, y2 + 3)
|
||||
ctx.stroke()
|
||||
}
|
||||
})
|
||||
if (e.state === 'ready') {
|
||||
ctx.fillStyle = '#ffd76a'
|
||||
ctx.beginPath(); ctx.arc(gx, 40 + depth, 3.4, 0, TAU); ctx.fill()
|
||||
}
|
||||
})
|
||||
ctx.fillStyle = 'rgba(240,230,200,0.55)'
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
if (!farms.length) ctx.fillText('附近还没有农田,先去地面开垦吧', 14, 50)
|
||||
else ctx.fillText(`上方农田 ${farms.length} 块 · 成熟 ${farms.filter((e) => e.state === 'ready').length}`, 14, 22)
|
||||
// —— 滴灌管道 ——
|
||||
ctx.strokeStyle = '#7e8187'
|
||||
ctx.lineWidth = 5
|
||||
ctx.beginPath(); ctx.moveTo(70, 128); ctx.lineTo(410, 128); ctx.stroke()
|
||||
ctx.strokeStyle = '#54565c'
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath(); ctx.moveTo(70, 125); ctx.lineTo(410, 125); ctx.stroke()
|
||||
if (mx.irrigOn) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ph = (t * 0.9 + i * 0.33) % 1
|
||||
ctx.fillStyle = `rgba(120,190,230,${0.85 * (1 - ph)})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(100 + i * 120, 132 + ph * 56, 2.2, 0, TAU)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(90,150,180,0.45)'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath(); ctx.moveTo(240, 128); ctx.lineTo(240, 246); ctx.stroke()
|
||||
}
|
||||
// 阀门轮盘
|
||||
ctx.strokeStyle = mx.irrigOn ? '#7fb069' : '#8d9097'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath(); ctx.arc(240, 128, 9, 0, TAU); ctx.stroke()
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(231, 128); ctx.lineTo(249, 128)
|
||||
ctx.moveTo(240, 119); ctx.lineTo(240, 137)
|
||||
ctx.stroke()
|
||||
// —— 堆肥箱 ——
|
||||
const comp = mx.compost || 0
|
||||
ctx.fillStyle = '#7a5230'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(58, 206, 66, 42, 3); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#5d3f24'
|
||||
ctx.beginPath(); ctx.roundRect(62, 210, 58, 9, 2); ctx.fill(); ctx.stroke()
|
||||
if (comp > 0) {
|
||||
ctx.fillStyle = '#6a4a26'
|
||||
ctx.beginPath(); ctx.roundRect(63, 245 - comp * 6, 56, comp * 6, 2); ctx.fill()
|
||||
ctx.strokeStyle = 'rgba(220,200,160,0.35)'
|
||||
ctx.lineWidth = 1.2
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const ph = (t * 0.6 + i * 0.5) % 1
|
||||
ctx.beginPath()
|
||||
ctx.arc(80 + i * 22, 236 - comp * 5 - ph * 12, 2.4 + ph * 3, 0, TAU)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = '#c9b48c'
|
||||
ctx.font = 'bold 10px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('堆肥箱', 91, 256)
|
||||
// —— 地热暖石 ——
|
||||
const pulse = mx.heatOn ? 0.6 + Math.sin(t * 3) * 0.25 : 0.15
|
||||
if (mx.heatOn) {
|
||||
const gl = ctx.createRadialGradient(386, 222, 4, 386, 222, 62)
|
||||
gl.addColorStop(0, `rgba(255,120,50,${0.3 * pulse})`)
|
||||
gl.addColorStop(1, 'rgba(255,120,50,0)')
|
||||
ctx.fillStyle = gl
|
||||
ctx.beginPath(); ctx.arc(386, 222, 62, 0, TAU); ctx.fill()
|
||||
}
|
||||
ctx.fillStyle = mx.heatOn ? '#8a4438' : '#6f7278'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(356, 242); ctx.lineTo(362, 212); ctx.lineTo(382, 202); ctx.lineTo(406, 212); ctx.lineTo(412, 242)
|
||||
ctx.closePath()
|
||||
ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = mx.heatOn ? 'rgba(255,150,80,0.8)' : 'rgba(30,30,36,0.5)'
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(368, 218); ctx.lineTo(396, 218)
|
||||
ctx.moveTo(364, 228); ctx.lineTo(402, 228)
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = '#c9b48c'
|
||||
ctx.fillText('地热暖石', 386, 256)
|
||||
// —— 蚯蚓 ——
|
||||
;(st.rt.worms || []).forEach((w) => {
|
||||
ctx.strokeStyle = '#e8a0a8'
|
||||
ctx.lineWidth = 3.4
|
||||
ctx.beginPath()
|
||||
for (let k = 0; k <= 6; k++) {
|
||||
const px2 = w.x + (k - 3) * 3.4
|
||||
const py2 = w.y + Math.sin(w.ph + k * 0.9) * 2.6
|
||||
if (k === 0) ctx.moveTo(px2, py2)
|
||||
else ctx.lineTo(px2, py2)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = '#c97880'
|
||||
ctx.beginPath(); ctx.arc(w.x + 11, w.y + Math.sin(w.ph + 5.4) * 2.6, 1.4, 0, TAU); ctx.fill()
|
||||
})
|
||||
// —— 出口梯子 ——
|
||||
ctx.strokeStyle = '#8a5f34'
|
||||
ctx.lineWidth = 3.4
|
||||
;[216, 258].forEach((x) => {
|
||||
ctx.beginPath(); ctx.moveTo(x, 214); ctx.lineTo(x, 292); ctx.stroke()
|
||||
})
|
||||
ctx.lineWidth = 2.4
|
||||
;[224, 240, 256, 272, 288].forEach((y) => {
|
||||
ctx.beginPath(); ctx.moveTo(216, y); ctx.lineTo(258, y); ctx.stroke()
|
||||
})
|
||||
ctx.fillStyle = 'rgba(255,240,200,0.14)'
|
||||
ctx.beginPath(); ctx.ellipse(238, 290, 34, 8, 0, 0, TAU); ctx.fill()
|
||||
ctx.restore()
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
id: 'agri',
|
||||
name: '农业文明',
|
||||
@@ -271,9 +556,31 @@ export default {
|
||||
{ code: 'well', name: '水井', tab: 'agri', tech: 1, cost: { rock: 12, rope: 2 }, build: true, desc: '灌溉周边良田,生长加速' },
|
||||
{ code: 'granary', name: '粮仓', tab: 'agri', tech: 1, cost: { log: 8, rope: 2 }, build: true, desc: '存储作物,防潮保鲜' },
|
||||
{ code: 'mill', name: '磨坊', tab: 'agri', tech: 2, cost: { log: 4, rock: 6, gold: 2 }, build: true, desc: '小麦磨面粉' },
|
||||
{ code: 'agricellar', name: '地下生态圈', tab: 'agri', tech: 2, cost: { log: 6, rock: 8, rope: 2 }, build: true, desc: '钻进地下:堆肥造肥 · 滴灌加速 · 地热越冬' },
|
||||
{ code: 'fertilizer', name: '肥料', tab: 'agri', tech: 1, cost: { rot: 2, ash: 1 }, desc: '施于良田,生长 +50%' },
|
||||
],
|
||||
icons: {
|
||||
agricellar: (c) => {
|
||||
c.fillStyle = '#6a4a2e'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath()
|
||||
c.moveTo(-13, 10)
|
||||
c.quadraticCurveTo(-12, -6, 0, -9)
|
||||
c.quadraticCurveTo(12, -6, 13, 10)
|
||||
c.closePath()
|
||||
c.fill(); c.stroke()
|
||||
c.fillStyle = '#5d3f24'
|
||||
c.beginPath(); c.roundRect(-7, -4, 14, 14, 2); c.fill(); c.stroke()
|
||||
c.strokeStyle = '#8a5f34'
|
||||
c.lineWidth = 1.2
|
||||
c.beginPath(); c.moveTo(0, -4); c.lineTo(0, 10); c.stroke()
|
||||
c.strokeStyle = '#5d8a45'
|
||||
c.lineWidth = 2
|
||||
c.beginPath(); c.moveTo(0, -9); c.quadraticCurveTo(3, -15, 7, -16); c.stroke()
|
||||
c.fillStyle = '#7fb069'
|
||||
c.beginPath(); c.ellipse(8, -16, 3.4, 2, 0.4, 0, TAU); c.fill(); c.stroke()
|
||||
},
|
||||
agrihoe: (c) => {
|
||||
c.strokeStyle = '#7a5b33'
|
||||
c.lineWidth = 3
|
||||
@@ -445,11 +752,13 @@ export default {
|
||||
},
|
||||
update(e, dt, g) {
|
||||
if (!e.mx || e.state === 'empty' || e.state === 'ready') return
|
||||
if (g.season === 'winter') return // 冬季暂停生长(与官方一致)
|
||||
const cell = g.entities.find((o) => o.type === 'agricellar' && Math.hypot(o.x - e.x, o.y - e.y) < 220)
|
||||
const winter = g.season === 'winter'
|
||||
if (winter && !(cell?.mx?.heatOn)) return // 冬季暂停(地窖开地热可越冬慢长)
|
||||
const watered = g.entities.some((o) => o.type === 'well' && Math.hypot(o.x - e.x, o.y - e.y) < 140)
|
||||
const z = zoneOfPlot(e)
|
||||
const lvMul = 1 + 0.06 * Math.max(0, (z?.level || 1) - 1)
|
||||
e.mx.t += dt * (watered ? 1.6 : 1) * lvMul
|
||||
e.mx.t += dt * (watered ? 1.6 : 1) * (cell?.mx?.irrigOn ? 1.25 : 1) * lvMul * (winter ? 0.5 : 1)
|
||||
const st = stageOf(e.mx.t)
|
||||
if (st !== e.state) {
|
||||
e.state = st
|
||||
@@ -484,7 +793,7 @@ export default {
|
||||
return
|
||||
}
|
||||
const pct = Math.min(99, Math.floor(((e.mx?.t || 0) / GROW_TOTAL) * 100))
|
||||
g.ptip(p, `作物生长中…… ${pct}%(靠近水井更快)`)
|
||||
g.ptip(p, `作物生长中…… ${pct}%(水井/滴灌更快)`)
|
||||
},
|
||||
draw(ctx, e) {
|
||||
const z = zoneOfPlot(e)
|
||||
@@ -794,6 +1103,52 @@ export default {
|
||||
ctx.beginPath(); ctx.arc(0, -32, 3, 0, TAU); ctx.fill()
|
||||
},
|
||||
},
|
||||
agricellar: {
|
||||
name: '地下生态圈',
|
||||
conf: { size: 42, struct: true, submap: true },
|
||||
init(e) {
|
||||
if (!e.mx) e.mx = {}
|
||||
if (e.mx.irrigOn == null) e.mx.irrigOn = false
|
||||
if (e.mx.heatOn == null) e.mx.heatOn = false
|
||||
if (e.mx.compost == null) e.mx.compost = 0
|
||||
if (e.mx.wormDay == null) e.mx.wormDay = 0
|
||||
},
|
||||
interact(p, e, g) {
|
||||
cellarSub.open(g, e)
|
||||
g.ptip(p, '钻进了地下生态圈 · WASD 走动 · E 互动 · Esc 爬回地面')
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
drawNameplate(ctx, '地下生态圈', -46)
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 6, 24, 8, 0, 0, TAU); ctx.fill()
|
||||
// 土丘
|
||||
ctx.fillStyle = '#6a4a2e'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-26, 6)
|
||||
ctx.quadraticCurveTo(-24, -20, 0, -24)
|
||||
ctx.quadraticCurveTo(24, -20, 26, 6)
|
||||
ctx.closePath()
|
||||
ctx.fill(); ctx.stroke()
|
||||
// 石门框 + 木门
|
||||
ctx.fillStyle = '#8d9097'
|
||||
ctx.beginPath(); ctx.roundRect(-16, -18, 32, 24, 3); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#5d3f24'
|
||||
ctx.beginPath(); ctx.roundRect(-11, -13, 22, 19, 2); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#8a5f34'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath(); ctx.moveTo(0, -13); ctx.lineTo(0, 6); ctx.stroke()
|
||||
ctx.fillStyle = '#e0c060'
|
||||
ctx.beginPath(); ctx.arc(7, -3, 1.6, 0, TAU); ctx.fill()
|
||||
// 门顶新芽
|
||||
ctx.strokeStyle = '#5d8a45'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(0, -24); ctx.quadraticCurveTo(3, -30, 7, -31); ctx.stroke()
|
||||
ctx.fillStyle = '#7fb069'
|
||||
ctx.beginPath(); ctx.ellipse(8, -31, 3.4, 2, 0.4, 0, TAU); ctx.fill()
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
save() {
|
||||
@@ -828,6 +1183,13 @@ export default {
|
||||
agriZones.forEach((z) => fillFarmsInZone(g, z, { clear: false }))
|
||||
agriZones.forEach((z) => ensureAgriWarehouse(g, z))
|
||||
},
|
||||
onTick(g, dt) {
|
||||
// 地下生态圈子地图:移动/动画/交互
|
||||
if (cellarSub.active()) cellarSub.update(g, dt)
|
||||
},
|
||||
allowsPlayerMove(g, p) {
|
||||
return !cellarSub.active()
|
||||
},
|
||||
onUseItem(g, p, s, area, i) {
|
||||
if (s.code === 'agrihoe') {
|
||||
const spot = g.findLand(p.x + p.dir * 46, p.y + 8, 0, 24)
|
||||
@@ -927,6 +1289,8 @@ export default {
|
||||
return agriZoneRightAction(g, z)
|
||||
},
|
||||
onClick(g, wx, wy) {
|
||||
// 地下生态圈打开期间吞掉点击
|
||||
if (cellarSub.handleClick(g, g.me(), wx - uiCamX, wy - uiCamY)) return true
|
||||
if (handleZoneMenuClick(wx, wy)) return true
|
||||
const sx = wx - uiCamX
|
||||
const sy = wy - uiCamY
|
||||
@@ -1041,6 +1405,8 @@ export default {
|
||||
uiCancel = null
|
||||
}
|
||||
ctx.restore()
|
||||
// 地下生态圈子地图面板
|
||||
cellarSub.drawFrame(g, ctx, env)
|
||||
drawZoneMenu(ctx, env)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -296,6 +296,285 @@ function wander(m, dt, g, speed) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 畜棚(子地图):食槽投喂 / 剪毛台 / 繁殖栏,靠近鸡舍牛群提升产出 ----
|
||||
import { skinByCode, drawPlayerFig } from '../../starveSkins.js'
|
||||
import { makeSubmap } from './submap.js'
|
||||
|
||||
const BARN_FEEDS = ['wheat', 'pumpkin', 'carrot', 'berry']
|
||||
const PEN_KINDS = ['pig', 'beefalo', 'sheep', 'rabbit', 'chicken']
|
||||
function penCap(kind, area) {
|
||||
if (kind === 'pig') return Math.min(4, 1 + Math.floor(area / 80000))
|
||||
if (kind === 'beefalo') return Math.min(3, 1 + Math.floor(area / 100000))
|
||||
if (kind === 'sheep') return Math.min(3, 1 + Math.floor(area / 90000))
|
||||
if (kind === 'rabbit') return Math.min(4, 1 + Math.floor(area / 70000))
|
||||
return Math.min(6, 2 + Math.floor(area / 60000))
|
||||
}
|
||||
function nearestZone(host) {
|
||||
return pastureZones.find((z) => pointInAabb(host.x, host.y, z))
|
||||
|| pastureZones.find((z) => Math.hypot((z.x0 + z.x1) / 2 - host.x, (z.y0 + z.y1) / 2 - host.y) < 500)
|
||||
|| null
|
||||
}
|
||||
|
||||
const barnSub = makeSubmap({
|
||||
W: 460,
|
||||
H: 300,
|
||||
wallRatio: 0.4,
|
||||
speed: 150,
|
||||
avatarR: 13,
|
||||
reach: 46,
|
||||
skins: { skinByCode, drawPlayerFig },
|
||||
title: (host) => `畜棚 · 饲料 ${host.mx?.feed || 0}`,
|
||||
enter() {
|
||||
return { x: 230, y: 262 }
|
||||
},
|
||||
bounds() {
|
||||
return { x0: 26, y0: 126, x1: 434, y1: 282 }
|
||||
},
|
||||
objects(g, host) {
|
||||
const feed = host.mx?.feed || 0
|
||||
return [
|
||||
{ id: 'feed', x: 46, y: 200, w: 124, h: 52, solid: true, prompt: feed > 0 ? `添饲料(现有 ${feed})` : '投喂食槽' },
|
||||
{ id: 'shear', x: 196, y: 196, w: 84, h: 54, solid: true, prompt: '剪毛台' },
|
||||
{ id: 'breed', x: 324, y: 182, w: 100, h: 82, solid: true, prompt: '繁殖栏' },
|
||||
{ id: 'door', x: 196, y: 252, w: 68, h: 44, solid: false, prompt: '走出畜棚' },
|
||||
]
|
||||
},
|
||||
tick(g, dt, host, st) {
|
||||
// 栖架上的鸡:啄食小动画
|
||||
st.rt.peck = st.rt.peck || { ph: Math.random() * 6 }
|
||||
st.rt.peck.ph += dt * 2.2
|
||||
},
|
||||
interact(g, p, host, id, st) {
|
||||
const mx = host.mx
|
||||
if (id === 'leave' || id === 'door') {
|
||||
barnSub.close(g)
|
||||
g.ptip(p, '走出了畜棚')
|
||||
return
|
||||
}
|
||||
if (id === 'feed') {
|
||||
const food = BARN_FEEDS.find((code) => g.countItem(p, code) > 0)
|
||||
if (!food) {
|
||||
g.ptip(p, '需要作物当饲料(小麦/南瓜/胡萝卜/浆果)')
|
||||
return
|
||||
}
|
||||
g.payCost(p, { [food]: 1 })
|
||||
mx.feed = Math.min(9, (mx.feed || 0) + 1)
|
||||
g.markDirty(host)
|
||||
g.swing(p, 'eat')
|
||||
g.ptip(p, `添了饲料(${mx.feed}/9)· 畜棚周边产出更旺`)
|
||||
return
|
||||
}
|
||||
if (id === 'shear') {
|
||||
if (g.countItem(p, 'husbshears') <= 0) {
|
||||
g.ptip(p, '剪毛台需要背包里有剪刀')
|
||||
return
|
||||
}
|
||||
const z = nearestZone(host)
|
||||
if (!z) {
|
||||
g.ptip(p, '先把畜棚建在牧场附近')
|
||||
return
|
||||
}
|
||||
const tag = 'sheared:' + g.day
|
||||
const sheepList = g.monsters.filter((m) => m.kind === 'sheep' && m.pen && m.zoneId === z.id && m.state !== tag)
|
||||
if (!sheepList.length) {
|
||||
g.ptip(p, '圈里的绵羊今天都剪过了')
|
||||
return
|
||||
}
|
||||
let n = 0
|
||||
sheepList.forEach((m) => {
|
||||
m.state = tag
|
||||
g.dropLoot(m.x, m.y + 18, 'wool', 2)
|
||||
n++
|
||||
})
|
||||
g.addScore(6 * n, p)
|
||||
g.swing(p, 'chop')
|
||||
g.ptip(p, `一次剪了 ${n} 只羊 · 羊毛 ×${n * 2}`)
|
||||
return
|
||||
}
|
||||
if (id === 'breed') {
|
||||
if ((mx.feed || 0) < 3) {
|
||||
g.ptip(p, '繁殖需要 3 份饲料(先添食槽)')
|
||||
return
|
||||
}
|
||||
const z = nearestZone(host)
|
||||
if (!z) {
|
||||
g.ptip(p, '先把畜棚建在牧场附近')
|
||||
return
|
||||
}
|
||||
const area = aabbArea(z)
|
||||
const pick = PEN_KINDS.find((kind) => {
|
||||
if (kind === 'rabbit') {
|
||||
const have = g.entities.filter((e) => e.type === 'rabbit' && e.mx?.zoneId === z.id).length
|
||||
return have < penCap('rabbit', area)
|
||||
}
|
||||
const have = g.monsters.filter((m) => m.kind === kind && m.pen && m.zoneId === z.id).length
|
||||
return have < penCap(kind, area)
|
||||
})
|
||||
if (!pick) {
|
||||
g.ptip(p, '牧场各栏都住满了')
|
||||
return
|
||||
}
|
||||
mx.feed -= 3
|
||||
g.markDirty(host)
|
||||
const cx = (z.x0 + z.x1) / 2
|
||||
const cy = (z.y0 + z.y1) / 2
|
||||
const spot = g.findLand ? g.findLand(cx, cy, 0, 60) : { x: cx, y: cy }
|
||||
if (pick === 'rabbit') {
|
||||
g.addEntity('rabbit', spot.x + g.rand(-30, 30), spot.y + g.rand(-20, 20), { mx: { pen: true, zoneId: z.id } })
|
||||
} else {
|
||||
g.spawnMob(pick, spot.x + g.rand(-30, 30), spot.y + g.rand(-20, 20), { pen: true, zoneId: z.id, home: { x: cx, y: cy }, zone: z })
|
||||
}
|
||||
g.swing(p, 'eat')
|
||||
g.ptip(p, `消耗饲料 ×3 · 小${g.mon[pick]?.name || pick}出生了!`)
|
||||
return
|
||||
}
|
||||
},
|
||||
draw(g, ctx, v, host, st, env) {
|
||||
const t = env.time || 0
|
||||
ctx.save()
|
||||
ctx.translate(v.px, v.py)
|
||||
ctx.scale(v.scale, v.scale)
|
||||
// —— 木墙 + 干草地板 ——
|
||||
ctx.fillStyle = '#8a6a44'
|
||||
ctx.fillRect(0, 0, 460, 120)
|
||||
ctx.strokeStyle = 'rgba(60,40,20,0.45)'
|
||||
ctx.lineWidth = 1.4
|
||||
for (let x = 0; x < 460; x += 46) {
|
||||
ctx.beginPath(); ctx.moveTo(x, 4); ctx.lineTo(x, 118); ctx.stroke()
|
||||
}
|
||||
for (let y = 34; y < 120; y += 42) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(460, y); ctx.stroke()
|
||||
}
|
||||
ctx.fillStyle = '#5d4432'
|
||||
ctx.fillRect(0, 108, 460, 12)
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(0, 120); ctx.lineTo(460, 120); ctx.stroke()
|
||||
// 干草地板块
|
||||
ctx.fillStyle = '#c9a86a'
|
||||
ctx.fillRect(0, 120, 460, 180)
|
||||
ctx.strokeStyle = 'rgba(150,110,60,0.6)'
|
||||
ctx.lineWidth = 1.2
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const x = (i * 61) % 440
|
||||
const y = 132 + ((i * 37) % 150)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y); ctx.lineTo(x + 16, y + 3); ctx.moveTo(x + 4, y + 6); ctx.lineTo(x + 14, y + 9)
|
||||
ctx.stroke()
|
||||
}
|
||||
// —— 窗户(天光 + 流云)——
|
||||
ctx.fillStyle = '#a8d4f0'
|
||||
ctx.strokeStyle = '#5d3f24'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath(); ctx.roundRect(182, 24, 96, 58, 3); ctx.fill(); ctx.stroke()
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(230, 24); ctx.lineTo(230, 82)
|
||||
ctx.moveTo(182, 53); ctx.lineTo(278, 53)
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.7)'
|
||||
ctx.beginPath(); ctx.ellipse(206 + Math.sin(t * 0.4) * 16, 40, 12, 5, 0, 0, TAU); ctx.fill()
|
||||
// —— 挂着的工具 ——
|
||||
ctx.strokeStyle = '#5d3f24'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(320, 26); ctx.lineTo(380, 26); ctx.stroke()
|
||||
;[[332, '#9aa4ae'], [362, '#b06a3a']].forEach(([hx, col]) => {
|
||||
ctx.strokeStyle = col
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath(); ctx.moveTo(hx, 28); ctx.lineTo(hx, 58); ctx.stroke()
|
||||
ctx.fillStyle = col
|
||||
ctx.beginPath(); ctx.arc(hx, 30, 3.4, 0, TAU); ctx.fill()
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath(); ctx.arc(hx, 30, 3.4, 0, TAU); ctx.stroke()
|
||||
})
|
||||
// —— 草料垛 ——
|
||||
;[[26, 96], [416, 96]].forEach(([bx, by]) => {
|
||||
ctx.fillStyle = '#d8b478'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath(); ctx.roundRect(bx - 16, by, 32, 24, 4); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#b08a4a'
|
||||
ctx.lineWidth = 1
|
||||
for (let i = 0; i < 3; i++) {
|
||||
ctx.beginPath(); ctx.moveTo(bx - 12, by + 6 + i * 6); ctx.lineTo(bx + 12, by + 4 + i * 6); ctx.stroke()
|
||||
}
|
||||
})
|
||||
// —— 食槽 ——
|
||||
ctx.fillStyle = '#8a5f34'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(46, 214, 124, 26, 4); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#5d3f24'
|
||||
ctx.beginPath(); ctx.roundRect(52, 210, 112, 10, 3); ctx.fill(); ctx.stroke()
|
||||
const feedN = host.mx?.feed || 0
|
||||
if (feedN > 0) {
|
||||
ctx.fillStyle = '#e0b060'
|
||||
for (let i = 0; i < Math.min(6, feedN); i++) {
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(62 + i * 16, 216, 7, 4, 0.2, 0, TAU)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = '#5d4432'
|
||||
ctx.beginPath(); ctx.roundRect(58, 240, 8, 14, 2); ctx.fill()
|
||||
ctx.beginPath(); ctx.roundRect(150, 240, 8, 14, 2); ctx.fill()
|
||||
// —— 剪毛台 ——
|
||||
ctx.fillStyle = '#a07040'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(196, 210, 84, 20, 3); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#f0e8dc'
|
||||
ctx.beginPath(); ctx.ellipse(238, 206, 26, 8, 0, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#8a6a50'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath(); ctx.arc(238, 206, 16, 0.4, 2.6); ctx.stroke()
|
||||
ctx.fillStyle = '#5d3f24'
|
||||
ctx.beginPath(); ctx.roundRect(204, 230, 7, 16, 2); ctx.fill()
|
||||
ctx.beginPath(); ctx.roundRect(264, 230, 7, 16, 2); ctx.fill()
|
||||
// —— 繁殖栏 ——
|
||||
ctx.strokeStyle = '#8a5f34'
|
||||
ctx.lineWidth = 3
|
||||
ctx.strokeRect(324, 190, 100, 74)
|
||||
ctx.lineWidth = 1.4
|
||||
;[210, 230, 250].forEach((y) => {
|
||||
ctx.beginPath(); ctx.moveTo(326, y); ctx.lineTo(422, y); ctx.stroke()
|
||||
})
|
||||
ctx.fillStyle = '#d8b478'
|
||||
ctx.beginPath(); ctx.ellipse(374, 246, 30, 12, 0, 0, TAU); ctx.fill()
|
||||
ctx.strokeStyle = '#b08a4a'
|
||||
ctx.lineWidth = 1.2
|
||||
ctx.beginPath(); ctx.arc(374, 246, 22, 0.3, 2.7); ctx.stroke()
|
||||
if ((host.mx?.feed || 0) < 3 && nearestZone(host)) {
|
||||
ctx.fillStyle = 'rgba(240,220,180,0.55)'
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('需要 3 饲料', 374, 218)
|
||||
}
|
||||
// —— 栖架上的鸡(装饰动画)——
|
||||
ctx.strokeStyle = '#8a5f34'
|
||||
ctx.lineWidth = 4
|
||||
ctx.beginPath(); ctx.moveTo(60, 84); ctx.lineTo(160, 84); ctx.stroke()
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const px = 78 + i * 38
|
||||
const peck = Math.sin(st.rt.peck?.ph + i * 2) > 0.6 ? 4 : 0
|
||||
ctx.fillStyle = i === 1 ? '#e8e2d4' : '#d8c8a8'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath(); ctx.ellipse(px, 70, 9, 7, 0, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
ctx.beginPath(); ctx.arc(px + 8, 62 + peck, 4.6, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#c45a28'
|
||||
ctx.beginPath(); ctx.arc(px + 8, 56 + peck, 2, 0, TAU); ctx.fill()
|
||||
ctx.fillStyle = '#e0a030'
|
||||
ctx.beginPath(); ctx.moveTo(px + 12.4, 62 + peck); ctx.lineTo(px + 16, 63 + peck); ctx.lineTo(px + 12.4, 64.4 + peck); ctx.closePath(); ctx.fill()
|
||||
ctx.strokeStyle = '#b8a888'
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath(); ctx.moveTo(px - 4, 78); ctx.lineTo(px - 4, 84); ctx.moveTo(px + 4, 78); ctx.lineTo(px + 4, 84); ctx.stroke()
|
||||
}
|
||||
ctx.restore()
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
id: 'husbandry',
|
||||
name: '牧业文明',
|
||||
@@ -318,10 +597,24 @@ export default {
|
||||
{ code: 'gate', name: '牧场门', tab: 'husbandry', tech: 1, cost: { twig: 6, log: 2, grass: 2 }, build: true, desc: '可开关的牧场大门' },
|
||||
{ code: 'trough', name: '饲料槽', tab: 'husbandry', tech: 1, cost: { log: 4, rock: 2 }, build: true, desc: '投喂作物,提升牧场产出' },
|
||||
{ code: 'coop', name: '鸡舍', tab: 'husbandry', tech: 2, cost: { log: 6, silk: 4, rope: 2 }, build: true, desc: '每日自动囤蛋' },
|
||||
{ code: 'pasturebarn', name: '畜棚', tab: 'husbandry', tech: 2, cost: { log: 10, rock: 4, grass: 6 }, build: true, desc: '走进畜棚:投喂/剪毛/繁殖,周边产出加成' },
|
||||
{ code: 'woolhat', name: '羊毛帽', tab: 'husbandry', tech: 2, cost: { wool: 6, rope: 1 }, desc: '保暖 60(弱于冬帽但便宜)' },
|
||||
{ code: 'cheese', name: '奶酪', tab: 'husbandry', tech: 1, cost: { milk: 2 }, desc: '牛奶发酵而成' },
|
||||
],
|
||||
icons: {
|
||||
pasturebarn: (c) => {
|
||||
c.fillStyle = '#a9773c'
|
||||
c.strokeStyle = '#1d1409'
|
||||
c.lineWidth = 1.8
|
||||
c.beginPath(); c.roundRect(-11, -4, 22, 14, 2); c.fill(); c.stroke()
|
||||
c.fillStyle = '#7a5230'
|
||||
c.beginPath(); c.moveTo(-15, -4); c.lineTo(0, -14); c.lineTo(15, -4); c.closePath(); c.fill(); c.stroke()
|
||||
c.fillStyle = '#2a1c10'
|
||||
c.beginPath(); c.roundRect(-4, 0, 8, 10, [4, 4, 0, 0]); c.fill()
|
||||
c.strokeStyle = '#b08a4a'
|
||||
c.lineWidth = 1.2
|
||||
c.beginPath(); c.moveTo(-8, -12); c.lineTo(-2, -7); c.moveTo(8, -12); c.lineTo(2, -7); c.stroke()
|
||||
},
|
||||
husbshears: (c) => {
|
||||
// 指环
|
||||
c.strokeStyle = '#6f767d'
|
||||
@@ -681,6 +974,59 @@ export default {
|
||||
}
|
||||
},
|
||||
},
|
||||
pasturebarn: {
|
||||
name: '畜棚',
|
||||
conf: { size: 44, struct: true, submap: true },
|
||||
init(e) {
|
||||
if (!e.mx) e.mx = {}
|
||||
if (e.mx.feed == null) e.mx.feed = 0
|
||||
},
|
||||
interact(p, e, g) {
|
||||
barnSub.open(g, e)
|
||||
g.ptip(p, '走进了畜棚 · WASD 走动 · E 互动 · Esc 离开')
|
||||
},
|
||||
draw(ctx, e, env) {
|
||||
drawNameplate(ctx, '畜棚', -52)
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath(); ctx.ellipse(0, 6, 26, 8, 0, 0, TAU); ctx.fill()
|
||||
// 主体(谷仓式红墙 + 白木条)
|
||||
ctx.fillStyle = '#a8563c'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(-24, -26, 48, 32, 2); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = 'rgba(244,234,210,0.55)'
|
||||
ctx.lineWidth = 1.4
|
||||
;[-12, 0, 12].forEach((x) => {
|
||||
ctx.beginPath(); ctx.moveTo(x, -25); ctx.lineTo(x, 5); ctx.stroke()
|
||||
})
|
||||
// 谷仓大门
|
||||
ctx.fillStyle = '#5d3f24'
|
||||
ctx.beginPath(); ctx.roundRect(-10, -14, 20, 20, 2); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#c9a86a'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-10, -14); ctx.lineTo(10, 6)
|
||||
ctx.moveTo(10, -14); ctx.lineTo(-10, 6)
|
||||
ctx.stroke()
|
||||
// 复折屋顶
|
||||
ctx.fillStyle = '#7a4a30'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-30, -26); ctx.lineTo(-16, -40); ctx.lineTo(16, -40); ctx.lineTo(30, -26)
|
||||
ctx.closePath()
|
||||
ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#5d3a24'
|
||||
ctx.lineWidth = 1.2
|
||||
ctx.beginPath(); ctx.moveTo(-28, -30); ctx.lineTo(28, -30); ctx.stroke()
|
||||
// 干草垛点缀
|
||||
ctx.fillStyle = '#d8b478'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.beginPath(); ctx.ellipse(26, 2, 8, 5, 0, 0, TAU); ctx.fill(); ctx.stroke()
|
||||
ctx.strokeStyle = '#b08a4a'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath(); ctx.arc(26, 2, 5, 0.3, 2.6); ctx.stroke()
|
||||
},
|
||||
},
|
||||
},
|
||||
mobs: {
|
||||
sheep: {
|
||||
@@ -854,16 +1200,22 @@ export default {
|
||||
onTick(g, dt) {
|
||||
keepPenMobs(g, dt)
|
||||
tickGates(g, dt)
|
||||
// 畜棚子地图:移动/动画/交互
|
||||
if (barnSub.active()) barnSub.update(g, dt)
|
||||
},
|
||||
allowsPlayerMove(g, p) {
|
||||
return !barnSub.active()
|
||||
},
|
||||
onNewDay(g) {
|
||||
// 每日按容量补齐圈养(死亡/跑丢后恢复)
|
||||
pastureZones.forEach((z) => stockPasture(g, z))
|
||||
// 结算每座鸡舍的囤蛋:附近有鸡或喂了饲料槽则翻倍;牧场等级提高上限与产出
|
||||
// 结算每座鸡舍的囤蛋:附近有鸡或喂了饲料槽/畜棚则翻倍;牧场等级提高上限与产出
|
||||
const fedSource = (x, y, r) => g.entities.find((t) => (t.type === 'trough' || t.type === 'pasturebarn') && (t.mx?.feed || 0) > 0 && Math.hypot(t.x - x, t.y - y) < r)
|
||||
g.entities.filter((e) => e.type === 'coop').forEach((coop) => {
|
||||
if (!coop.mx) coop.mx = {}
|
||||
if (coop.mx.stored == null) coop.mx.stored = 0
|
||||
const chickenNear = g.monsters.some((m) => m.kind === 'chicken' && Math.hypot(m.x - coop.x, m.y - coop.y) < 180)
|
||||
const trough = g.entities.find((t) => t.type === 'trough' && (t.mx?.feed || 0) > 0 && Math.hypot(t.x - coop.x, t.y - coop.y) < 160)
|
||||
const trough = fedSource(coop.x, coop.y, 160)
|
||||
let inc = 1
|
||||
if (chickenNear || trough) inc = 2
|
||||
const mul = pastureYieldAt(coop.x, coop.y)
|
||||
@@ -876,9 +1228,9 @@ export default {
|
||||
coop.mx.stored = Math.min(cap, (coop.mx.stored || 0) + inc)
|
||||
g.markDirty(coop)
|
||||
})
|
||||
// 牧场牛产奶:靠近有饲料的饲料槽(高等级多掉)
|
||||
// 牧场牛产奶:靠近有饲料的饲料槽或畜棚(高等级多掉)
|
||||
g.monsters.filter((m) => m.kind === 'beefalo' && m.pen).forEach((cow) => {
|
||||
const trough = g.entities.find((t) => t.type === 'trough' && (t.mx?.feed || 0) > 0 && Math.hypot(t.x - cow.x, t.y - cow.y) < 140)
|
||||
const trough = fedSource(cow.x, cow.y, 140)
|
||||
if (!trough) return
|
||||
trough.mx.feed -= 1
|
||||
g.markDirty(trough)
|
||||
@@ -929,6 +1281,8 @@ export default {
|
||||
return pastureZoneRightAction(g, z)
|
||||
},
|
||||
onClick(g, wx, wy) {
|
||||
// 畜棚打开期间吞掉点击
|
||||
if (barnSub.handleClick(g, g.me(), wx - uiCamX, wy - uiCamY)) return true
|
||||
if (handleZoneMenuClick(wx, wy)) return true
|
||||
const sx = wx - uiCamX
|
||||
const sy = wy - uiCamY
|
||||
@@ -1056,6 +1410,8 @@ export default {
|
||||
uiCancel = null
|
||||
}
|
||||
ctx.restore()
|
||||
// 畜棚子地图面板
|
||||
barnSub.drawFrame(g, ctx, env)
|
||||
drawZoneMenu(ctx, env)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -43,9 +43,14 @@ export default {
|
||||
}
|
||||
return
|
||||
}
|
||||
// 目标:主人当前攻击目标,或靠近主人的敌对生物
|
||||
// 目标:主人当前攻击目标,或靠近主人的敌对生物(不打自己/友军,防误伤)
|
||||
let target = null
|
||||
if (owner.target?.kind === 'mon' && g.monsters.includes(owner.target.ref)) {
|
||||
if (
|
||||
owner.target?.kind === 'mon' &&
|
||||
g.monsters.includes(owner.target.ref) &&
|
||||
owner.target.ref !== m &&
|
||||
g.mon[owner.target.ref.kind]?.hostile
|
||||
) {
|
||||
target = owner.target.ref
|
||||
}
|
||||
if (!target) {
|
||||
|
||||
183
src/games/starve/mods/manual.js
Normal file
@@ -0,0 +1,183 @@
|
||||
// mods/manual.js — Mod 手册文案:每个 mod 的玩法要点与注意事项
|
||||
// 与 mods/index.js 的顺序保持一致;GamePlay 的 Mod 装载面板「手册」页读取本表
|
||||
// 只增不改语义:新 mod 上线时在数组末尾追加一节即可
|
||||
export const MOD_MANUAL = [
|
||||
{
|
||||
id: 'camp',
|
||||
name: '营地',
|
||||
tag: '内容',
|
||||
features: [
|
||||
'出生自带主屋、长明篝火与两名无敌守卫,野怪来犯守卫会迎击',
|
||||
'主屋可进入室内:床睡觉跳过夜晚/回状态,随营地等级升级外观与楼层',
|
||||
'营地仓储(8 箱共享仓库)与食堂(点菜吃饭)',
|
||||
'营地有经验与等级:收获/圈养/建造都会攒 XP,升级扩大安全圈',
|
||||
'划定宅基:框选区域自动铺地基,建筑工会来盖房',
|
||||
'夜晚窗户透光、屋内可撸猫读书烤火',
|
||||
],
|
||||
tips: [
|
||||
'右键宅基或房屋可升级/改名/删除',
|
||||
'守卫无敌但不会追出安全圈太远,别引太多怪',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'agri',
|
||||
name: '农业文明',
|
||||
tag: '内容',
|
||||
features: [
|
||||
'犁锄开垦良田,播种小麦/南瓜/浆果,200 秒三阶段成熟',
|
||||
'水井:140px 内生长 ×1.6,打水回理智(60 秒冷却)',
|
||||
'粮仓储粮防潮,磨坊把小麦磨成面粉做面包',
|
||||
'肥料直接推进生长 50%',
|
||||
'地下生态圈(地窖):捉蚯蚓堆肥造肥料、滴灌 +25%、地热越冬',
|
||||
],
|
||||
tips: [
|
||||
'框选农业区自动铺田,右键区域可升级提高产出',
|
||||
'冬季农田会停产,记得提前囤粮或开地窖地热',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'farm',
|
||||
name: '农场',
|
||||
tag: '内容',
|
||||
features: [
|
||||
'锄头开垦农田,三阶段 320 秒生长(与农业文明二选一即可)',
|
||||
'采草掉胡萝卜种子、采浆果丛掉浆果种子',
|
||||
],
|
||||
tips: ['与农业文明同时开启会各铺各的田,建议二选一'],
|
||||
},
|
||||
{
|
||||
id: 'husbandry',
|
||||
name: '牧业文明',
|
||||
tag: '内容',
|
||||
features: [
|
||||
'框选牧场自动围栏圈养:猪/牛/羊/兔/鸡按面积补栏',
|
||||
'剪刀给绵羊剪毛(每只每天一次,羊毛 ×2)',
|
||||
'饲料槽投喂作物:鸡舍每日囤蛋翻倍、牛每日产奶',
|
||||
'畜棚子地图:投喂/剪毛台/繁殖栏(3 饲料繁殖一只)',
|
||||
'羊毛帽保暖 60,鸡蛋牛奶奶酪补食物',
|
||||
],
|
||||
tips: [
|
||||
'门会自动开关,劳工进出免费',
|
||||
'右键牧场可升级提高产出上限',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'industry',
|
||||
name: '工业文明',
|
||||
tag: '内容',
|
||||
features: [
|
||||
'进阶链:熔炉炼锭 → 蒸汽机供能 → 工厂产齿轮 → 矿机自动开采',
|
||||
'蒸汽机烧木头,为 300px 内的工厂/矿机供能',
|
||||
'矿机每日自动产矿石,蒸汽供能下翻倍',
|
||||
],
|
||||
tips: ['蒸汽机要烧木头才供能,记得补燃料'],
|
||||
},
|
||||
{
|
||||
id: 'labor',
|
||||
name: '劳务市场',
|
||||
tag: '内容',
|
||||
features: [
|
||||
'招聘所可雇:农民/牧工/建筑工/厨师/伐木工/矿工(工资从大仓库扣)',
|
||||
'自然村民可点选分配职业,工资更低',
|
||||
'劳工自动干活:收获入仓、播种、喂养、盖房、砍树、采矿',
|
||||
'大仓库统一存取,劳工夜间回员工宿舍',
|
||||
],
|
||||
tips: [
|
||||
'发不出工资劳工会离职,仓库里常备粮食',
|
||||
'与营地 mod 同开时劳工可入驻房屋',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chest',
|
||||
name: '宝藏',
|
||||
tag: '内容',
|
||||
features: ['世界各处散落 6~10 个宝箱,开箱按战利品表随机获得物资(每箱一次)'],
|
||||
tips: ['宝箱在地图各处,多探索'],
|
||||
},
|
||||
{
|
||||
id: 'chester',
|
||||
name: '切斯特',
|
||||
tag: '内容',
|
||||
features: [
|
||||
'拾到眼骨后切斯特现身跟随,自动吞掉身边掉落物(9 格随身仓)',
|
||||
'点击切斯特把肚子里的东西全部吐出',
|
||||
],
|
||||
tips: ['切斯特死亡后眼骨冷却会再召出来'],
|
||||
},
|
||||
{
|
||||
id: 'loyaldog',
|
||||
name: '最忠诚的伙伴',
|
||||
tag: '内容',
|
||||
features: [
|
||||
'地图散落骨肉/肉,找到小狗喂食结为永久伙伴',
|
||||
'小狗会攻击你攻击的目标和保护你',
|
||||
],
|
||||
tips: ['喂食要拿在手上走近按 E;小狗不会打友军'],
|
||||
},
|
||||
{
|
||||
id: 'lucky',
|
||||
name: '幸运矿工',
|
||||
tag: '增益',
|
||||
features: ['采矿收益提高,石头更值钱'],
|
||||
},
|
||||
{
|
||||
id: 'magic',
|
||||
name: '魔法武器',
|
||||
tag: '内容',
|
||||
features: ['火魔杖:对最近敌人范围灼烧;回旋镖:直线飞出命中后折返'],
|
||||
},
|
||||
{
|
||||
id: 'nightmare',
|
||||
name: '噩梦模式',
|
||||
tag: '难度',
|
||||
features: ['全体怪物血量伤害 ×1.5,猎犬波周期缩短且每波 +1'],
|
||||
tips: ['得分系数更高,与和平模式互斥'],
|
||||
},
|
||||
{
|
||||
id: 'peaceful',
|
||||
name: '和平模式',
|
||||
tag: '难度',
|
||||
features: ['取消猎犬波与独眼巨鹿袭击排程,其余生物照常'],
|
||||
tips: ['适合纯种田建造玩法'],
|
||||
},
|
||||
{
|
||||
id: 'qol',
|
||||
name: '便利包',
|
||||
tag: '便利',
|
||||
features: ['全图显示(无战争迷雾)+ 快速采集 + 堆叠上限 99'],
|
||||
},
|
||||
{
|
||||
id: 'revive',
|
||||
name: '复活雕像',
|
||||
tag: '内容',
|
||||
features: [
|
||||
'二级科技建造石像;任何玩家死亡时自动消耗一座原地复活(半血半饥)',
|
||||
'单人模式等于多一条命,保住死亡删档前的存档',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'startchest',
|
||||
name: '文明火种',
|
||||
tag: '便利',
|
||||
features: ['出生点旁放一只系红绸的木箱,首次开启一次性发齐开局物资'],
|
||||
},
|
||||
{
|
||||
id: 'traveler',
|
||||
name: '旅者行囊',
|
||||
tag: '便利',
|
||||
features: ['更大的随身背包空间'],
|
||||
},
|
||||
{
|
||||
id: 'wideworld',
|
||||
name: '辽阔世界',
|
||||
tag: '便利',
|
||||
features: ['世界放大 1.5 倍,多出近一倍土地'],
|
||||
tips: ['与开始面板的地图倍率取更大值生效'],
|
||||
},
|
||||
{
|
||||
id: 'chill',
|
||||
name: '悠闲时光',
|
||||
tag: '难度',
|
||||
features: ['降低生存压力,适合轻松体验'],
|
||||
},
|
||||
]
|
||||
244
src/games/starve/mods/submap.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// mods/submap.js — mod 通用子地图引擎(室内 / 地下生态圈共用)
|
||||
// 复刻 camp.js 内室系统的成熟套路:
|
||||
// 模块级状态不入档 · allowsPlayerMove 屏蔽世界移动 · onTick 走移动与交互 ·
|
||||
// onDrawUI 画外框并记录相机 · onClick 命中按钮(世界坐标 → 屏幕坐标)
|
||||
// 用法:
|
||||
// const sub = makeSubmap(cfg)
|
||||
// 钩子接线:onTick -> sub.update(g, dt)
|
||||
// onDrawUI -> 先记录相机不必须(引擎自己记),调 sub.drawFrame(g, ctx, env)
|
||||
// onClick -> if (sub.handleClick(g, p, wx - uiCamX, wy - uiCamY)) return true
|
||||
// allowsPlayerMove -> return !sub.active()
|
||||
// cfg = {
|
||||
// W, H 逻辑尺寸(面板内容区)
|
||||
// wallRatio = 0.45 墙占上部比例(地下图可更高)
|
||||
// speed = 150 走动速度 px/s
|
||||
// avatarR = 14 碰撞半径
|
||||
// reach = 48 默认交互半径
|
||||
// title(host) 面板标题
|
||||
// enter(g, host) -> { x, y } 入场点
|
||||
// bounds(g, host) -> { x0, y0, x1, y1 } 活动范围(默认留边)
|
||||
// objects(g, host, st) -> [{ id, x, y, w, h, solid?, prompt?, reach? }]
|
||||
// draw(g, ctx, v, host, st, env) 内容绘制(墙地/装饰/交互物;v=几何,st.rt 存动画态)
|
||||
// interact(g, p, host, id, st) 交互回调
|
||||
// tick?(g, dt, host, st) 每帧内容逻辑(动画/资源刷新)
|
||||
// onLeave?(g, host)
|
||||
// }
|
||||
|
||||
const TAU = Math.PI * 2
|
||||
|
||||
// 全局互斥:同一时间只允许一张子地图打开(开新关旧)
|
||||
let current = null
|
||||
|
||||
export function makeSubmap(cfg) {
|
||||
const W = cfg.W
|
||||
const H = cfg.H
|
||||
const st = {
|
||||
hostId: null,
|
||||
av: null, // { x, y, dir, walking, walkT, prompt }
|
||||
uiCamX: 0,
|
||||
uiCamY: 0,
|
||||
btns: [], // 屏幕坐标交互按钮(含离开)
|
||||
latch: false,
|
||||
escLatch: false,
|
||||
rt: {}, // 内容动画态(不入档)
|
||||
}
|
||||
|
||||
function active() {
|
||||
return st.hostId != null
|
||||
}
|
||||
function hostOf(g) {
|
||||
return g.entities.find((e) => e.id === st.hostId) || null
|
||||
}
|
||||
function view(env) {
|
||||
const pad = 24
|
||||
const maxW = Math.min(env.W - pad * 2, 660)
|
||||
const maxH = Math.min(env.H - pad * 2, 460)
|
||||
const scale = Math.min(maxW / W, maxH / H)
|
||||
return { px: (env.W - W * scale) / 2, py: (env.H - H * scale) / 2, pw: W * scale, ph: H * scale, scale }
|
||||
}
|
||||
function roomToScreen(v, rx, ry) {
|
||||
return { x: v.px + rx * v.scale, y: v.py + ry * v.scale }
|
||||
}
|
||||
function boundsOf(g, host) {
|
||||
if (cfg.bounds) return cfg.bounds(g, host)
|
||||
return { x0: 22, y0: H * cfg.wallRatio + 14, x1: W - 22, y1: H - 14 }
|
||||
}
|
||||
function circleHitsRect(cx, cy, r, rect) {
|
||||
const nx = Math.max(rect.x, Math.min(cx, rect.x + rect.w))
|
||||
const ny = Math.max(rect.y, Math.min(cy, rect.y + rect.h))
|
||||
return Math.hypot(cx - nx, cy - ny) < r
|
||||
}
|
||||
|
||||
function open(g, host) {
|
||||
if (current && current !== api) current.close(g)
|
||||
current = api
|
||||
st.hostId = host.id
|
||||
const spot = cfg.enter ? cfg.enter(g, host) : { x: W / 2, y: H - 48 }
|
||||
st.av = { x: spot.x, y: spot.y, dir: 1, walking: false, walkT: 0, prompt: null }
|
||||
st.rt = {}
|
||||
st.latch = true // 入场瞬间按住的 E 不立即触发
|
||||
g.invalidate?.()
|
||||
}
|
||||
function close(g) {
|
||||
const host = st.hostId != null ? g?.entities?.find((e) => e.id === st.hostId) : null
|
||||
st.hostId = null
|
||||
st.av = null
|
||||
st.btns = []
|
||||
if (current === api) current = null
|
||||
if (host && cfg.onLeave) cfg.onLeave(g, host)
|
||||
g?.invalidate?.()
|
||||
}
|
||||
|
||||
function update(g, dt) {
|
||||
if (!active()) return
|
||||
const host = hostOf(g)
|
||||
const p = g.me()
|
||||
if (!host || !p || p.dead) { close(g); return }
|
||||
cfg.tick?.(g, dt, host, st)
|
||||
const av = st.av
|
||||
const objs = cfg.objects ? cfg.objects(g, host, st) : []
|
||||
// WASD / 方向键 / 联机输入 移动(X/Y 分离碰撞固体)
|
||||
let dx = 0
|
||||
let dy = 0
|
||||
if (g.keyDown) {
|
||||
dx = (g.keyDown('ArrowRight') || g.keyDown('d') || g.keyDown('D') ? 1 : 0)
|
||||
- (g.keyDown('ArrowLeft') || g.keyDown('a') || g.keyDown('A') ? 1 : 0)
|
||||
dy = (g.keyDown('ArrowDown') || g.keyDown('s') || g.keyDown('S') ? 1 : 0)
|
||||
- (g.keyDown('ArrowUp') || g.keyDown('w') || g.keyDown('W') ? 1 : 0)
|
||||
}
|
||||
if (!dx && !dy && p.input) {
|
||||
dx = p.input.dx || 0
|
||||
dy = p.input.dy || 0
|
||||
}
|
||||
const len = Math.hypot(dx, dy)
|
||||
if (len > 1e-3) {
|
||||
dx /= len
|
||||
dy /= len
|
||||
const b = boundsOf(g, host)
|
||||
const step = (cfg.speed || 150) * dt
|
||||
const solids = objs.filter((o) => o.solid)
|
||||
const nx = Math.max(b.x0, Math.min(b.x1, av.x + dx * step))
|
||||
const ny = Math.max(b.y0, Math.min(b.y1, av.y + dy * step))
|
||||
if (!solids.some((o) => circleHitsRect(nx, av.y, cfg.avatarR || 14, o))) av.x = nx
|
||||
if (!solids.some((o) => circleHitsRect(av.x, ny, cfg.avatarR || 14, o))) av.y = ny
|
||||
if (dx) av.dir = dx > 0 ? 1 : -1
|
||||
av.walking = true
|
||||
av.walkT += dt
|
||||
} else {
|
||||
av.walking = false
|
||||
}
|
||||
// 最近可交互物
|
||||
let best = null
|
||||
let bd = Infinity
|
||||
for (const o of objs) {
|
||||
if (!o.prompt) continue
|
||||
const cx = o.x + o.w / 2
|
||||
const cy = o.y + o.h / 2
|
||||
const d = Math.hypot(cx - av.x, cy - av.y)
|
||||
const r = o.reach || cfg.reach || 48
|
||||
if (d < r && d < bd) { bd = d; best = o }
|
||||
}
|
||||
av.prompt = best
|
||||
// E 交互(边沿触发)
|
||||
const wantE = !!(g.keyDown?.('e') || g.keyDown?.('E') || g.keyDown?.(' ') || g.keyDown?.('Enter'))
|
||||
if (wantE && !st.latch && av.prompt) cfg.interact(g, p, host, av.prompt.id, st)
|
||||
st.latch = wantE
|
||||
// Escape 离开
|
||||
const wantEsc = !!g.keyDown?.('Escape')
|
||||
if (wantEsc && !st.escLatch) close(g)
|
||||
st.escLatch = wantEsc
|
||||
}
|
||||
|
||||
function drawFrame(g, ctx, env) {
|
||||
if (!active()) return
|
||||
const host = hostOf(g)
|
||||
if (!host) return
|
||||
const v = view(env)
|
||||
st.uiCamX = env.camX
|
||||
st.uiCamY = env.camY
|
||||
ctx.save()
|
||||
// 全屏暗罩 + 木框
|
||||
ctx.fillStyle = 'rgba(8, 6, 4, 0.74)'
|
||||
ctx.fillRect(0, 0, env.W, env.H)
|
||||
ctx.fillStyle = '#4a3224'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 4
|
||||
ctx.beginPath(); ctx.roundRect(v.px - 6, v.py - 6, v.pw + 12, v.ph + 12, 10); ctx.fill(); ctx.stroke()
|
||||
// 内容
|
||||
cfg.draw(g, ctx, v, host, st, env)
|
||||
// 头像
|
||||
const p = g.me()
|
||||
if (p && st.av) {
|
||||
const { skinByCode, drawPlayerFig } = cfg.skins
|
||||
const a = roomToScreen(v, st.av.x, st.av.y)
|
||||
ctx.save()
|
||||
ctx.translate(a.x, a.y)
|
||||
const s = v.scale
|
||||
ctx.scale(s, s)
|
||||
drawPlayerFig(ctx, skinByCode(p?.skin || 'wilson'), {
|
||||
dir: st.av.dir, walking: st.av.walking, walkT: st.av.walkT,
|
||||
swing: p.swing || 0, act: p.act || '',
|
||||
toolCode: p.equip?.hand?.code || '',
|
||||
breathe: st.av.walking ? 0 : Math.sin((env.time || 0) * 2.2) * 1.2,
|
||||
time: env.time || 0,
|
||||
})
|
||||
ctx.restore()
|
||||
// 交互气泡(头顶上方指向交互物)
|
||||
if (st.av.prompt) {
|
||||
const o = st.av.prompt
|
||||
const ox = v.px + (o.x + o.w / 2) * v.scale
|
||||
const oy = v.py + o.y * v.scale - 12
|
||||
const label = `E · ${o.prompt}`
|
||||
ctx.font = 'bold 11px sans-serif'
|
||||
const tw = ctx.measureText(label).width + 14
|
||||
ctx.fillStyle = 'rgba(30,24,14,0.88)'
|
||||
ctx.beginPath(); ctx.roundRect(ox - tw / 2, oy - 16, tw, 18, 5); ctx.fill()
|
||||
ctx.strokeStyle = '#c9a86a'; ctx.lineWidth = 1.2; ctx.stroke()
|
||||
ctx.fillStyle = '#f4ead2'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(label, ox, oy - 7)
|
||||
}
|
||||
}
|
||||
// 标题
|
||||
ctx.fillStyle = 'rgba(30,22,12,0.85)'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 1.6
|
||||
const title = cfg.title ? cfg.title(host) : ''
|
||||
const tw2 = Math.max(90, title.length * 13 + 20)
|
||||
ctx.beginPath(); ctx.roundRect(v.px + v.pw / 2 - tw2 / 2, v.py - 26, tw2, 20, 6); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#f4ead2'
|
||||
ctx.font = 'bold 12px "PingFang SC","Microsoft YaHei",sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(title, v.px + v.pw / 2, v.py - 16)
|
||||
// 交互按钮(供 onClick 命中)
|
||||
st.btns = []
|
||||
const leave = { id: 'leave', x: v.px + v.pw / 2 - 50, y: v.py + v.ph + 12, w: 100, h: 28 }
|
||||
ctx.fillStyle = '#5d4432'
|
||||
ctx.strokeStyle = '#1d1409'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.roundRect(leave.x, leave.y, leave.w, leave.h, 8); ctx.fill(); ctx.stroke()
|
||||
ctx.fillStyle = '#f4ead2'
|
||||
ctx.font = 'bold 12px sans-serif'
|
||||
ctx.fillText('离开(Esc)', leave.x + leave.w / 2, leave.y + leave.h / 2 + 1)
|
||||
st.btns.push(leave)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function handleClick(g, p, sx, sy) {
|
||||
if (!active()) return false
|
||||
for (const b of st.btns) {
|
||||
if (sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h) {
|
||||
const host = hostOf(g)
|
||||
if (!host) { close(g); return true }
|
||||
cfg.interact(g, p, host, b.id, st)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true // 面板打开期间吞掉所有点击,防点穿世界
|
||||
}
|
||||
|
||||
const api = { active, open, close, update, drawFrame, handleClick, st, view, roomToScreen }
|
||||
return api
|
||||
}
|
||||
285
src/games/starve/skel.js
Normal file
@@ -0,0 +1,285 @@
|
||||
// starve/skel.js — 骨骼动画引擎(零依赖)
|
||||
// 供 starveSkins(主角)、paint.js(野怪)、mod(camp.js 守卫)共用的小型骨骼-关键帧系统。
|
||||
// 坐标约定:脚底为原点,+x 为面朝方向,+y 向下;角度为 canvas 标准弧度(0 = +x,π/2 = 向下)。
|
||||
// 骨骼定义 = { name, parent(可 null), len, ang(相对父骨的静息角), x, y(相对父骨末端的静息偏移,屏幕轴) }
|
||||
// —— 偏移不随父骨姿态旋转(肩/髋偏移只有几像素,误差可忽略,换来简单可预测)。
|
||||
// 姿势 pose = 扁平对象:{ [boneName]: Δ角(弧度), 'root.x': Δx, 'root.y': Δy, 'root.rot': Δ整体角 }
|
||||
// 只有出现键的骨骼会偏离静息角,其余保持静息 —— 片段只需声明关心的轨道。
|
||||
// 关键帧片段 clip = { dur: 秒, loop: bool, tracks: { 键: [[t, 值, 缓动名?], ...] } },t ∈ [0,1]。
|
||||
|
||||
export const TAU = Math.PI * 2
|
||||
|
||||
// =====================================================================
|
||||
// 缓动
|
||||
// =====================================================================
|
||||
export const EASE = {
|
||||
linear: (t) => t,
|
||||
in: (t) => t * t,
|
||||
out: (t) => 1 - (1 - t) * (1 - t),
|
||||
inOut: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
|
||||
outBack: (t) => 1 + 2.7 * Math.pow(t - 1, 3) + 1.7 * Math.pow(t - 1, 2),
|
||||
inBack: (t) => 2.7 * t * t * t - 1.7 * t * t,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 骨架
|
||||
// =====================================================================
|
||||
/** def 必须按「父在子前」的拓扑序给出 */
|
||||
export function defineSkeleton(def) {
|
||||
const bones = {}
|
||||
const order = []
|
||||
def.forEach((b) => {
|
||||
bones[b.name] = {
|
||||
name: b.name,
|
||||
parent: b.parent || null,
|
||||
len: b.len || 0,
|
||||
ang: b.ang || 0,
|
||||
x: b.x || 0,
|
||||
y: b.y || 0,
|
||||
}
|
||||
order.push(b.name)
|
||||
})
|
||||
return { bones, order }
|
||||
}
|
||||
|
||||
/**
|
||||
* 由姿势计算每个骨骼的世界状态。
|
||||
* 返回 { [boneName]: { x, y, ang, cos, sin, ex, ey } }(起点/绝对角/末端)。
|
||||
* root.x/root.y 平移整副骨架,root.rot 绕根骨骼原点旋转。
|
||||
*/
|
||||
export function computePose(skel, pose = {}) {
|
||||
const out = {}
|
||||
const rx = pose['root.x'] || 0
|
||||
const ry = pose['root.y'] || 0
|
||||
const rot = pose['root.rot'] || 0
|
||||
for (const name of skel.order) {
|
||||
const b = skel.bones[name]
|
||||
const p = b.parent ? out[b.parent] : null
|
||||
const ang = (p ? p.ang : rot) + b.ang + (pose[name] || 0)
|
||||
const cos = Math.cos(ang)
|
||||
const sin = Math.sin(ang)
|
||||
const x = (p ? p.ex + b.x : b.x) + (p ? 0 : rx)
|
||||
const y = (p ? p.ey + b.y : b.y) + (p ? 0 : ry)
|
||||
out[name] = { x, y, ang, cos, sin, ex: x + b.len * cos, ey: y + b.len * sin }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 关键帧采样 / 混合
|
||||
// =====================================================================
|
||||
/** 按 [0,1] 归一化进度采样片段,返回姿势对象(只含该片段声明的轨道) */
|
||||
export function sampleClip(clip, tNorm) {
|
||||
const pose = {}
|
||||
if (!clip) return pose
|
||||
const t = clip.loop
|
||||
? tNorm - Math.floor(tNorm)
|
||||
: Math.max(0, Math.min(1, tNorm))
|
||||
for (const key in clip.tracks) {
|
||||
const ks = clip.tracks[key]
|
||||
if (!ks || !ks.length) continue
|
||||
let v
|
||||
if (t <= ks[0][0]) {
|
||||
v = ks[0][1]
|
||||
} else if (t >= ks[ks.length - 1][0]) {
|
||||
v = ks[ks.length - 1][1]
|
||||
} else {
|
||||
let i = 0
|
||||
while (i < ks.length - 2 && ks[i + 1][0] <= t) i++
|
||||
const t0 = ks[i][0]
|
||||
const v0 = ks[i][1]
|
||||
const t1 = ks[i + 1][0]
|
||||
const v1 = ks[i + 1][1]
|
||||
const ease = EASE[ks[i][2]] || EASE.inOut
|
||||
v = v0 + (v1 - v0) * ease((t - t0) / ((t1 - t0) || 1e-6))
|
||||
}
|
||||
pose[key] = v
|
||||
}
|
||||
return pose
|
||||
}
|
||||
|
||||
/** 按实际秒数采样(内部换算 dur) */
|
||||
export function clipPose(clip, timeSec) {
|
||||
if (!clip) return {}
|
||||
return sampleClip(clip, timeSec / (clip.dur || 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 交叉淡化两个姿势:w=0 完全 a,w=1 完全 b。
|
||||
* 只在单侧出现的轨道会淡入/淡出到 0(对静息角)。
|
||||
*/
|
||||
export function blendPose(a, b, w) {
|
||||
const out = {}
|
||||
for (const k in a) out[k] = a[k] * (1 - w)
|
||||
for (const k in b) out[k] = (out[k] || 0) + b[k] * w
|
||||
return out
|
||||
}
|
||||
|
||||
/** 加性叠加(呼吸、眨眼等微动层) */
|
||||
export function addPose(a, b) {
|
||||
const out = {}
|
||||
for (const k in a) out[k] = a[k]
|
||||
for (const k in b) out[k] = (out[k] || 0) + b[k]
|
||||
return out
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 手绘抖动(沸腾线)
|
||||
// =====================================================================
|
||||
function fract(x) {
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
function noise2(i, seed, tq) {
|
||||
return fract(Math.sin(i * 127.1 + seed * 311.7 + tq * 74.7) * 43758.5453) - 0.5
|
||||
}
|
||||
/**
|
||||
* 确定性抖动折线点:seed 相同 + tq 相同 → 结果稳定;
|
||||
* tq 用 qTime(time) 量化后,跨帧按低帧率跳变,产生手绘动画的"沸腾线"质感。
|
||||
*/
|
||||
export function wobblePts(pts, seed = 1, amp = 0.7, tq = 0) {
|
||||
return pts.map(([x, y], i) => [
|
||||
x + noise2(i, seed, tq) * amp * 2,
|
||||
y + noise2(i + 57, seed, tq) * amp * 2,
|
||||
])
|
||||
}
|
||||
/** 量化时间:fps 越低手绘卡顿感越强(常用 8~10) */
|
||||
export function qTime(time, fps = 9) {
|
||||
return Math.floor(time * fps)
|
||||
}
|
||||
|
||||
/** 椭圆 → 多边形顶点(供 blob 抖动填充),n 为边数 */
|
||||
export function ellipsePts(cx, cy, rx, ry, n = 14, rot = 0) {
|
||||
const pts = []
|
||||
const cr = Math.cos(rot)
|
||||
const sr = Math.sin(rot)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = (i / n) * TAU
|
||||
const px = Math.cos(a) * rx
|
||||
const py = Math.sin(a) * ry
|
||||
pts.push([cx + px * cr - py * sr, cy + px * sr + py * cr])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
/** 圆角矩形 → 多边形顶点 */
|
||||
export function boxPts(x, y, w, h, r = 0, n = 3) {
|
||||
const pts = []
|
||||
const cs = [
|
||||
[x + w - r, y + r, -Math.PI / 2, 0],
|
||||
[x + w - r, y + h - r, 0, Math.PI / 2],
|
||||
[x + r, y + h - r, Math.PI / 2, Math.PI],
|
||||
[x + r, y + r, Math.PI, Math.PI * 1.5],
|
||||
]
|
||||
cs.forEach(([cx, cy, a0, a1]) => {
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const a = a0 + ((a1 - a0) * i) / n
|
||||
pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r])
|
||||
}
|
||||
})
|
||||
return pts
|
||||
}
|
||||
|
||||
/**
|
||||
* 抖动多边形填充+描边(描边色默认 #1d1409 与全局风格一致)。
|
||||
* 返回实际绘制的点(可继续叠加细节)。
|
||||
*/
|
||||
export function blob(c, pts, o = {}) {
|
||||
const wp = o.amp ? wobblePts(pts, o.seed || 1, o.amp, o.tq || 0) : pts
|
||||
c.beginPath()
|
||||
c.moveTo(wp[0][0], wp[0][1])
|
||||
for (let i = 1; i < wp.length; i++) c.lineTo(wp[i][0], wp[i][1])
|
||||
c.closePath()
|
||||
if (o.fill) {
|
||||
c.fillStyle = o.fill
|
||||
c.fill()
|
||||
}
|
||||
if (o.stroke) {
|
||||
c.strokeStyle = o.stroke
|
||||
c.lineWidth = o.lw || 1.6
|
||||
c.stroke()
|
||||
}
|
||||
return wp
|
||||
}
|
||||
|
||||
/** 骨骼肢体:圆帽线段 */
|
||||
export function boneStroke(c, x, y, ex, ey, w, color) {
|
||||
c.strokeStyle = color
|
||||
c.lineWidth = w
|
||||
c.lineCap = 'round'
|
||||
c.lineJoin = 'round'
|
||||
c.beginPath()
|
||||
c.moveTo(x, y)
|
||||
c.lineTo(ex, ey)
|
||||
c.stroke()
|
||||
}
|
||||
|
||||
/** 锥形胶囊肢体:起点宽 w0 → 末端宽 w1,可填充+描边 */
|
||||
export function taper(c, x, y, ex, ey, w0, w1, fill, stroke, lw = 1.3) {
|
||||
const dx = ex - x
|
||||
const dy = ey - y
|
||||
const d = Math.hypot(dx, dy) || 1
|
||||
const nx = -dy / d
|
||||
const ny = dx / d
|
||||
const a0 = Math.atan2(ny, nx)
|
||||
c.beginPath()
|
||||
c.moveTo(x + nx * w0 * 0.5, y + ny * w0 * 0.5)
|
||||
c.lineTo(ex + nx * w1 * 0.5, ey + ny * w1 * 0.5)
|
||||
c.arc(ex, ey, w1 * 0.5, a0, a0 - Math.PI, true)
|
||||
c.lineTo(x - nx * w0 * 0.5, y - ny * w0 * 0.5)
|
||||
c.arc(x, y, w0 * 0.5, a0 + Math.PI, a0, true)
|
||||
c.closePath()
|
||||
if (fill) {
|
||||
c.fillStyle = fill
|
||||
c.fill()
|
||||
}
|
||||
if (stroke) {
|
||||
c.strokeStyle = stroke
|
||||
c.lineWidth = lw || 1.3
|
||||
c.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 颜色工具(不改原始皮肤数据,绘制时做明暗/混色)
|
||||
// =====================================================================
|
||||
export function hex2rgb(hex) {
|
||||
let h = hex.replace('#', '')
|
||||
if (h.length === 3) h = h.split('').map((ch) => ch + ch).join('')
|
||||
const n = parseInt(h, 16)
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||
}
|
||||
/** k > 0 往白混(提亮),k < 0 往黑混(压暗) */
|
||||
export function shade(hex, k) {
|
||||
const [r, g, b] = hex2rgb(hex)
|
||||
const f = (v) => Math.round(k >= 0 ? v + (255 - v) * k : v * (1 + k))
|
||||
return `rgb(${f(r)},${f(g)},${f(b)})`
|
||||
}
|
||||
export function mixc(h1, h2, t) {
|
||||
const a = hex2rgb(h1)
|
||||
const b = hex2rgb(h2)
|
||||
const f = (i) => Math.round(a[i] + (b[i] - a[i]) * t)
|
||||
return `rgb(${f(0)},${f(1)},${f(2)})`
|
||||
}
|
||||
export function rgba(hex, a) {
|
||||
const [r, g, b] = hex2rgb(hex)
|
||||
return `rgba(${r},${g},${b},${a})`
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 渐变缓存(CanvasGradient 按调用方 ctx 分开缓存)
|
||||
// =====================================================================
|
||||
const gradCache = new WeakMap()
|
||||
export function linGrad(c, key, x0, y0, x1, y1, stops) {
|
||||
let m = gradCache.get(c)
|
||||
if (!m) {
|
||||
m = new Map()
|
||||
gradCache.set(c, m)
|
||||
}
|
||||
let g = m.get(key)
|
||||
if (!g) {
|
||||
g = c.createLinearGradient(x0, y0, x1, y1)
|
||||
stops.forEach(([o, col]) => g.addColorStop(o, col))
|
||||
m.set(key, g)
|
||||
}
|
||||
return g
|
||||
}
|
||||
94
src/games/starve/spatial.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// 空间散列网格:把高频的「附近实体」查询从 O(实体总数) 线性扫描降为 O(附近实体数)。
|
||||
// 只索引静止实体(树/石/建筑/掉落物等);mobile 实体(兔子等会动的)不入网格,
|
||||
// 由 query() 附加线性补扫(数量少)。实体在网格中的位置不随移动更新——这正是
|
||||
// 只收静止实体的原因;带 e.mobile 标记的实体一律走补扫。
|
||||
// 典型调用方:blockedBySolid / nearCampWall(mobs.js)、点击与光标检测、
|
||||
// refreshNearActions / doGather / doAttack / pickAutoInteractTarget(StarveGame)。
|
||||
export function createEntGrid(cell = 160) {
|
||||
let cells = new Map() // cellKey -> ent[]
|
||||
let mobile = [] // 会动的实体(e.mobile),线性补扫
|
||||
let count = 0
|
||||
|
||||
const keyOf = (cx, cy) => cx * 100003 + cy
|
||||
|
||||
function add(e) {
|
||||
if (e._inGrid) return
|
||||
if (e.mobile) {
|
||||
if (!mobile.includes(e)) mobile.push(e)
|
||||
e._inGrid = true
|
||||
return
|
||||
}
|
||||
const r = Math.max(8, e.size || 16)
|
||||
const x0 = Math.floor((e.x - r) / cell)
|
||||
const x1 = Math.floor((e.x + r) / cell)
|
||||
const y0 = Math.floor((e.y - r) / cell)
|
||||
const y1 = Math.floor((e.y + r) / cell)
|
||||
for (let cx = x0; cx <= x1; cx++) {
|
||||
for (let cy = y0; cy <= y1; cy++) {
|
||||
const k = keyOf(cx, cy)
|
||||
let arr = cells.get(k)
|
||||
if (!arr) cells.set(k, (arr = []))
|
||||
arr.push(e)
|
||||
}
|
||||
}
|
||||
e._inGrid = true
|
||||
count++
|
||||
}
|
||||
|
||||
function remove(e) {
|
||||
if (!e._inGrid) return
|
||||
e._inGrid = false
|
||||
if (e.mobile) {
|
||||
const i = mobile.indexOf(e)
|
||||
if (i >= 0) mobile.splice(i, 1)
|
||||
return
|
||||
}
|
||||
const r = Math.max(8, e.size || 16)
|
||||
const x0 = Math.floor((e.x - r) / cell)
|
||||
const x1 = Math.floor((e.x + r) / cell)
|
||||
const y0 = Math.floor((e.y - r) / cell)
|
||||
const y1 = Math.floor((e.y + r) / cell)
|
||||
for (let cx = x0; cx <= x1; cx++) {
|
||||
for (let cy = y0; cy <= y1; cy++) {
|
||||
const arr = cells.get(keyOf(cx, cy))
|
||||
if (!arr) continue
|
||||
const i = arr.indexOf(e)
|
||||
if (i >= 0) arr.splice(i, 1)
|
||||
if (!arr.length) cells.delete(keyOf(cx, cy))
|
||||
}
|
||||
}
|
||||
count--
|
||||
}
|
||||
|
||||
// 圆范围查询:返回候选实体数组(网格命中 + mobile 补扫)。调用方自行做精确距离过滤。
|
||||
function query(x, y, r) {
|
||||
const out = []
|
||||
const x0 = Math.floor((x - r) / cell)
|
||||
const x1 = Math.floor((x + r) / cell)
|
||||
const y0 = Math.floor((y - r) / cell)
|
||||
const y1 = Math.floor((y + r) / cell)
|
||||
for (let cx = x0; cx <= x1; cx++) {
|
||||
for (let cy = y0; cy <= y1; cy++) {
|
||||
const arr = cells.get(keyOf(cx, cy))
|
||||
if (arr) for (let i = 0; i < arr.length; i++) out.push(arr[i])
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < mobile.length; i++) out.push(mobile[i])
|
||||
return out
|
||||
}
|
||||
|
||||
function rebuild(list) {
|
||||
cells = new Map()
|
||||
mobile = []
|
||||
count = 0
|
||||
for (const e of list) add(e)
|
||||
}
|
||||
|
||||
return {
|
||||
add,
|
||||
remove,
|
||||
query,
|
||||
rebuild,
|
||||
get size() { return count },
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export const WORLD = { w: GW * TILE, h: GH * TILE } // 3200 x 2400
|
||||
// 世界尺寸可配置:GW/GH 为模块级 let,genIsland/planEntities/genDecor/drawTiles/tileIdx
|
||||
// 等函数闭包引用它们(ES module 实时绑定),改尺寸后自动跟随;WORLD 保持同一对象只改属性。
|
||||
export function configureWorld(scale = 1) {
|
||||
const s = Math.max(0.5, Math.min(2, Number(scale) || 1))
|
||||
const s = Math.max(0.5, Math.min(4, Number(scale) || 1))
|
||||
GW = Math.round(64 * s)
|
||||
GH = Math.round(48 * s)
|
||||
WORLD.w = GW * TILE
|
||||
@@ -234,6 +234,25 @@ export function planEntities(tiles, road) {
|
||||
ensure('beehive', 2, [3])
|
||||
ensure('goldrock', 4, [5])
|
||||
ensure('rabbithole', 5, [1, 2])
|
||||
ensure('cavein', 2, [5])
|
||||
// 4) 池塘(钓鱼点):贴水的陆地瓦片随机挑几个
|
||||
const pondSpots = []
|
||||
for (let ty = 1; ty < GH - 1; ty++) {
|
||||
for (let tx = 1; tx < GW - 1; tx++) {
|
||||
const i = ty * GW + tx
|
||||
if (!tiles[i] || road[i]) continue
|
||||
if (!tiles[i - 1] || !tiles[i + 1] || !tiles[i - GW] || !tiles[i + GW]) pondSpots.push(i)
|
||||
}
|
||||
}
|
||||
for (let i = pondSpots.length - 1; i > 0; i--) {
|
||||
const j = (Math.random() * (i + 1)) | 0
|
||||
;[pondSpots[i], pondSpots[j]] = [pondSpots[j], pondSpots[i]]
|
||||
}
|
||||
const pondN = Math.min(pondSpots.length, 5)
|
||||
for (let k = 0; k < pondN; k++) {
|
||||
const i = pondSpots[k]
|
||||
put('pond', (i % GW) * TILE + 20 + Math.random() * 40, Math.floor(i / GW) * TILE + 20 + Math.random() * 40)
|
||||
}
|
||||
ensure('tentacle', 4, [6])
|
||||
ensure('reeds', 6, [6])
|
||||
// 4) 猪人村:草地/森林挑 2 处,每处 3 座猪屋围成一圈
|
||||
|
||||
@@ -10,6 +10,7 @@ const routes = [
|
||||
{ path: '/rank', name: 'rank', component: () => import('../views/Rank.vue') },
|
||||
{ path: '/profile', name: 'profile', component: () => import('../views/Profile.vue') },
|
||||
{ path: '/play/:code', name: 'play', component: () => import('../views/GamePlay.vue') },
|
||||
{ path: '/detail/:code', name: 'detail', component: () => import('../views/GameDetail.vue') },
|
||||
{ path: '/battle', name: 'battle', component: () => import('../views/Battle.vue') },
|
||||
{ path: '/battle/room', name: 'battleRoom', component: () => import('../views/BattleRoom.vue') },
|
||||
{ path: '/chat', name: 'chat', component: () => import('../views/Chat.vue') },
|
||||
|
||||
386
src/views/GameDetail.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<script setup>
|
||||
// 游戏详情页:仅对注册表标记 detail:true 的游戏开放(当前:饥荒 / 马里奥)。
|
||||
// 展示宣传语、特性、玩法板块、结构化操作说明与技巧;CTA 按拥有状态分流(开始游戏 / 购买)。
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import http, { toast } from '../api/http'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { gameRegistry } from '../games'
|
||||
import { gameDetails } from '../games/gameDetails'
|
||||
import { modMeta } from '../games/starve/mods/registry'
|
||||
import GameIcon from '../components/GameIcon.vue'
|
||||
import ScreenModeToggle from '../components/ScreenModeToggle.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const code = route.params.code
|
||||
const meta = gameRegistry[code]
|
||||
// 未注册详情页的游戏回到游玩页,避免直链 404 空白
|
||||
if (!meta?.detail || !gameDetails[code]) {
|
||||
router.replace(`/play/${code}`)
|
||||
}
|
||||
|
||||
const info = gameDetails[code] || null
|
||||
const detail = ref(null) // 后端 {game, owned, best_score, weekly_free, playable}
|
||||
const buying = ref(false)
|
||||
|
||||
const game = computed(() => detail.value?.game || {})
|
||||
const displayName = computed(() => (code === 'starve' ? '年糕求生' : game.value.name || '游戏'))
|
||||
const owned = computed(() => !!detail.value?.owned || !(game.value.price > 0))
|
||||
const mods = computed(() => (code === 'starve' ? modMeta() : []))
|
||||
const levels = computed(() => meta?.levels?.names || [])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
detail.value = await http.get(`/games/${code}`)
|
||||
} catch {
|
||||
/* 未登录由全局守卫处理 */
|
||||
}
|
||||
}
|
||||
|
||||
// 未拥有时直接购买(与大厅购买弹窗同一接口)
|
||||
async function doBuy() {
|
||||
if (buying.value) return
|
||||
buying.value = true
|
||||
try {
|
||||
await http.post('/shop/buy', { item_type: 1, item_id: game.value.id, quantity: 1 })
|
||||
toast(`已购买「${displayName.value}」,开始玩吧!`, 'success')
|
||||
await userStore.refresh()
|
||||
detail.value = await http.get(`/games/${code}`)
|
||||
} finally {
|
||||
buying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function modTag(tag) {
|
||||
return { difficulty: '难度', content: '内容' }[tag] || tag
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="info" class="gd-page">
|
||||
<button class="btn btn-ghost btn-sm gd-back" @click="router.back()">← 返回</button>
|
||||
|
||||
<!-- ============ Hero 区 ============ -->
|
||||
<div class="gd-hero panel">
|
||||
<div class="gd-hero-icon">
|
||||
<GameIcon :code="code" :icon="game.icon" :size="112" />
|
||||
<span v-if="detail?.weekly_free" class="tag tag-accent gd-free">本周免费</span>
|
||||
</div>
|
||||
<div class="gd-hero-main">
|
||||
<h1>{{ displayName }}</h1>
|
||||
<p class="gd-tagline">{{ info.tagline }}</p>
|
||||
<div class="gd-tags">
|
||||
<span v-for="t in info.tags" :key="t" class="tag tag-primary">{{ t }}</span>
|
||||
</div>
|
||||
<p class="gd-desc text-dim">{{ game.description }}</p>
|
||||
<div class="gd-meta-row text-dim">
|
||||
<span v-if="game.category">分类:{{ game.category }}</span>
|
||||
<span v-if="game.score_per_point">积分规则:{{ game.score_per_point }} 分/点</span>
|
||||
<span v-if="detail?.best_score > 0">我的最高分:{{ detail.best_score }}</span>
|
||||
<span v-if="game.play_count">已游玩 {{ game.play_count }} 次</span>
|
||||
</div>
|
||||
<div class="gd-cta">
|
||||
<button v-if="owned" class="btn btn-lg" @click="router.push(`/play/${code}`)">▶ 开始游戏</button>
|
||||
<template v-else>
|
||||
<button class="btn btn-lg" :disabled="buying" @click="doBuy">{{ buying ? '购买中…' : '立即购买' }}</button>
|
||||
<span class="gd-price">{{ game.price }} 积分</span>
|
||||
</template>
|
||||
<ScreenModeToggle class="gd-screen" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 特性网格 ============ -->
|
||||
<h2 class="page-title">游戏特性</h2>
|
||||
<div class="gd-features">
|
||||
<div v-for="f in info.features" :key="f.title" class="card gd-feature">
|
||||
<div class="gd-f-icon">{{ f.icon }}</div>
|
||||
<b>{{ f.title }}</b>
|
||||
<p class="text-dim">{{ f.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ 玩法板块(饥荒特色系统 / 马里奥关卡) ============ -->
|
||||
<template v-for="sec in info.sections" :key="sec.title">
|
||||
<h2 class="page-title">{{ sec.title }}</h2>
|
||||
<div v-if="sec.blocks.length" class="gd-blocks">
|
||||
<div v-for="b in sec.blocks" :key="b.name" class="panel gd-block">
|
||||
<div class="gd-b-head"><span>{{ b.icon }}</span><b>{{ b.name }}</b></div>
|
||||
<p class="text-dim">{{ b.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 马里奥关卡列表(读注册表 levels.names) -->
|
||||
<div v-else-if="levels.length" class="gd-levels">
|
||||
<span v-for="(n, i) in levels" :key="n" class="gd-level">
|
||||
<i>{{ String(i + 1).padStart(2, '0') }}</i>{{ n }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============ Mod 展示(饥荒) ============ -->
|
||||
<template v-if="mods.length">
|
||||
<h2 class="page-title">内置 Mod({{ mods.length }} 款)</h2>
|
||||
<div class="gd-mods">
|
||||
<div v-for="m in mods" :key="m.id" class="card gd-mod">
|
||||
<div class="gd-m-head">
|
||||
<b>{{ m.name }}</b>
|
||||
<span class="tag" :class="{ 'tag-accent': m.tag === 'difficulty' }">{{ modTag(m.tag) }}</span>
|
||||
</div>
|
||||
<p class="text-dim">{{ m.desc }}</p>
|
||||
<span v-if="m.factor !== 1" class="gd-m-factor">得分 ×{{ m.factor }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============ 操作说明 + 技巧 ============ -->
|
||||
<div class="gd-two-col">
|
||||
<div>
|
||||
<h2 class="page-title">操作说明</h2>
|
||||
<div class="panel gd-controls">
|
||||
<div v-for="c in info.controls" :key="c.keys" class="gd-ctl">
|
||||
<kbd>{{ c.keys }}</kbd>
|
||||
<span class="text-dim">{{ c.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="page-title">{{ code === 'mario' ? '通关技巧' : '生存技巧' }}</h2>
|
||||
<div class="panel gd-tips">
|
||||
<p v-for="t in info.tips" :key="t">
|
||||
<span class="gd-tip-dot"></span>{{ t }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gd-page {
|
||||
padding: 18px 0 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.gd-back {
|
||||
align-self: flex-start;
|
||||
}
|
||||
/* ---------- Hero ---------- */
|
||||
.gd-hero {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.gd-hero-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gd-free {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gd-hero-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.gd-hero-main h1 {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.gd-tagline {
|
||||
font-size: 15px;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.gd-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.gd-desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.gd-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 22px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.gd-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.gd-price {
|
||||
color: var(--primary-2);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
/* ---------- 特性网格 ---------- */
|
||||
.gd-features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.gd-feature {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.gd-f-icon {
|
||||
font-size: 26px;
|
||||
}
|
||||
.gd-feature p {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
/* ---------- 玩法板块 ---------- */
|
||||
.gd-blocks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.gd-block p {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.gd-b-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.gd-b-head span {
|
||||
font-size: 20px;
|
||||
}
|
||||
/* ---------- 关卡列表(马里奥) ---------- */
|
||||
.gd-levels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.gd-level {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) / 1.6);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gd-level i {
|
||||
font-style: normal;
|
||||
color: var(--accent);
|
||||
font-family: 'press-start-2p', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* ---------- Mod 网格 ---------- */
|
||||
.gd-mods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.gd-mod {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
position: relative;
|
||||
}
|
||||
.gd-m-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.gd-mod p {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.gd-m-factor {
|
||||
color: var(--primary-2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* ---------- 操作 + 技巧双栏 ---------- */
|
||||
.gd-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
.gd-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.gd-ctl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.gd-ctl kbd {
|
||||
min-width: 110px;
|
||||
text-align: center;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 6px;
|
||||
font-family: 'press-start-2p', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.gd-tips p {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.gd-tip-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 8px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-2));
|
||||
box-shadow: var(--glow);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.gd-hero {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.gd-tags,
|
||||
.gd-meta-row,
|
||||
.gd-cta {
|
||||
justify-content: center;
|
||||
}
|
||||
.gd-two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,9 +11,12 @@ import { useStarveCoopStore } from '../stores/starveCoop'
|
||||
import { gameRegistry } from '../games'
|
||||
import { renderSkinPreview, filterSkins } from '../games/starveSkins'
|
||||
import { modMeta } from '../games/starve/mods/registry'
|
||||
import { MOD_MANUAL } from '../games/starve/mods/manual.js'
|
||||
import { sfxEnabled, setSfxEnabled, musicEnabled, setMusicEnabled } from '../games/starve/audio'
|
||||
import LevelSelect from '../components/LevelSelect.vue'
|
||||
import GameIcon from '../components/GameIcon.vue'
|
||||
import PixelAvatar from '../components/PixelAvatar.vue'
|
||||
import ScreenModeToggle from '../components/ScreenModeToggle.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -41,6 +44,7 @@ const buyingProp = ref('') // 正在购买的道具码(防连点)
|
||||
|
||||
// ---- 关卡进度(带 levels 配置的游戏才启用) ----
|
||||
const levelsMeta = meta?.levels || null // {total, names?}
|
||||
const inGameSelect = !!meta?.inGameSelect // 选关在游戏内完成(马里奥风格),平台只留开始按钮
|
||||
const progress = ref({ level: 1, friends: [] }) // 我的进度 + 好友进度
|
||||
|
||||
// ---- 云存档(注册表标记 saveable 的游戏才启用,如饥荒;支持多存档) ----
|
||||
@@ -92,6 +96,17 @@ function onHeadMouseMove(e) {
|
||||
headShow.value = e.clientY <= 130
|
||||
}
|
||||
const touchMode = ref(localStorage.getItem('starve_touch_mode') || 'both')
|
||||
// 年糕求生音频开关(starve/audio 模块单例,游戏内与设置面板共享)
|
||||
const sfxOn = ref(sfxEnabled())
|
||||
const musicOn = ref(musicEnabled())
|
||||
function toggleSfx() {
|
||||
setSfxEnabled(!sfxOn.value)
|
||||
sfxOn.value = sfxEnabled()
|
||||
}
|
||||
function toggleMusic() {
|
||||
setMusicEnabled(!musicOn.value)
|
||||
musicOn.value = musicEnabled()
|
||||
}
|
||||
function saveTouchMode(mode) {
|
||||
touchMode.value = mode
|
||||
localStorage.setItem('starve_touch_mode', mode)
|
||||
@@ -158,6 +173,23 @@ const playHeadClasses = computed(() => ({
|
||||
const modsAvail = meta?.mods ? modMeta() : []
|
||||
const selMods = ref([])
|
||||
const showModModal = ref(false)
|
||||
// ---- Mod 手册:全部 mod 的玩法/功能/注意事项(manual.js 静态文案)----
|
||||
const manualMode = ref(false)
|
||||
const manualOpenId = ref('')
|
||||
const manualTag = ref('全部')
|
||||
const MANUAL_TAGS = ['全部', '内容', '难度', '增益', '便利']
|
||||
// ---- 地图大小倍率:新开局/联机房主生效,续档沿用存档世界 ----
|
||||
const mapScale = ref(Number(localStorage.getItem('starve_mapscale') || 1) || 1)
|
||||
function setMapScale(s) {
|
||||
mapScale.value = s
|
||||
try { localStorage.setItem('starve_mapscale', String(s)) } catch {}
|
||||
}
|
||||
const manualList = computed(() => MOD_MANUAL
|
||||
.filter((en) => manualTag.value === '全部' || en.tag === manualTag.value)
|
||||
.map((en) => {
|
||||
const meta = modsAvail.find((m) => m.id === en.id)
|
||||
return { ...en, factor: meta?.factor ?? 1, desc: meta?.desc || '' }
|
||||
}))
|
||||
try {
|
||||
selMods.value = JSON.parse(localStorage.getItem('mods_' + code) || '[]').filter((id) => modsAvail.some((m) => m.id === id))
|
||||
} catch { selMods.value = [] }
|
||||
@@ -238,9 +270,10 @@ async function load() {
|
||||
phase.value = 'ready'
|
||||
}
|
||||
|
||||
// 开始本局:数字=起始关卡;{save}=从云存档继续;{saveName}=新开局命名
|
||||
function startGame(arg = 1) {
|
||||
const level = typeof arg === 'number' ? arg : 1
|
||||
// 开始本局:数字=起始关卡;{save}=从云存档继续;{saveName}=新开局命名;无参=游戏内选关(传进度下限)或第 1 关
|
||||
function startGame(arg) {
|
||||
// 游戏内选关的游戏:把服务器进度作为解锁下限传给组件
|
||||
const level = typeof arg === 'number' ? arg : (inGameSelect ? Math.max(1, Number(progress.value.level) || 1) : 1)
|
||||
const save = typeof arg === 'object' && arg?.save ? arg.save : null
|
||||
const saveName = typeof arg === 'object' && arg?.saveName ? String(arg.saveName).trim() : ''
|
||||
liveScore.value = save?.score || 0
|
||||
@@ -257,7 +290,7 @@ function startGame(arg = 1) {
|
||||
const deadline = performance.now() + 3000
|
||||
const tryStart = () => {
|
||||
// 新开局带上勾选的 mod;续档时以存档内记录为准(组件侧处理)
|
||||
if (gameRef.value?.start) gameRef.value.start({ level, save, skin: mySkin.value, mods: save ? undefined : [...selMods.value] })
|
||||
if (gameRef.value?.start) gameRef.value.start({ level, save, skin: mySkin.value, mods: save ? undefined : [...selMods.value], worldScale: save ? undefined : mapScale.value })
|
||||
else if (performance.now() < deadline) requestAnimationFrame(tryStart)
|
||||
}
|
||||
requestAnimationFrame(tryStart)
|
||||
@@ -431,6 +464,17 @@ async function joinRoom() {
|
||||
}
|
||||
if (await ensureCoopConn()) coopStore.joinRoom(joinCode.value)
|
||||
}
|
||||
// 房主点击开始:房间被服务端回收(WS 静默重连等)时给出提示并退回入口,而不是静默无效;
|
||||
// 正常路径仍走 coopStore.startGame() → 后端广播 status=playing → 双端 startCoopGame()
|
||||
function hostClickStart() {
|
||||
if (!coopStore.inRoom || !coopStore.connected) {
|
||||
toast('房间已失效,请重新创建或加入', 'info')
|
||||
coopStore.leaveRoom()
|
||||
coopStage.value = 'mode'
|
||||
return
|
||||
}
|
||||
coopStore.startGame()
|
||||
}
|
||||
function leaveRoom() {
|
||||
coopStore.leaveRoom()
|
||||
}
|
||||
@@ -509,7 +553,7 @@ function startCoopGame() {
|
||||
const deadline = performance.now() + 3000
|
||||
const tryStart = () => {
|
||||
// 房主决定本局 mod 集合,客机等待全量快照对齐
|
||||
if (gameRef.value?.start) gameRef.value.start({ coop: coopCtx, skin: mySkin.value, mods: coopStore.isHost ? [...selMods.value] : [] })
|
||||
if (gameRef.value?.start) gameRef.value.start({ coop: coopCtx, skin: mySkin.value, mods: coopStore.isHost ? [...selMods.value] : [], worldScale: coopStore.isHost ? mapScale.value : undefined })
|
||||
else if (performance.now() < deadline) requestAnimationFrame(tryStart)
|
||||
}
|
||||
requestAnimationFrame(tryStart)
|
||||
@@ -744,6 +788,8 @@ onBeforeUnmount(() => {
|
||||
<span class="stat-chip">本局 <b class="text-accent">{{ liveScore }}</b></span>
|
||||
<span class="stat-chip">最高 <b class="text-primary">{{ detail.best_score }}</b></span>
|
||||
<span class="stat-chip">💰 {{ userStore.user?.points ?? 0 }}</span>
|
||||
<button class="btn btn-ghost btn-sm" v-if="meta?.detail" @click="router.push(`/detail/${code}`)">ℹ️ 详情</button>
|
||||
<ScreenModeToggle />
|
||||
<button class="btn btn-ghost btn-sm" @click="openManual">📖 手册</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="openSettings">⚙️ 设置</button>
|
||||
<button v-if="phase === 'playing'" class="btn btn-ghost btn-sm" @click="togglePause">⏸ 暂停</button>
|
||||
@@ -782,6 +828,7 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
<div class="ov-btns">
|
||||
<button class="btn btn-ghost btn-sm" v-if="meta?.detail" @click="router.push(`/detail/${code}`)">ℹ️ 游戏详情</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="openManual">📖 操作手册</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="openSettings">⚙️ 设置</button>
|
||||
</div>
|
||||
@@ -797,6 +844,22 @@ onBeforeUnmount(() => {
|
||||
<input v-model="useDouble" type="checkbox" />
|
||||
使用 ✨双倍积分卡(剩 {{ propCount('double_points') }} 张),本局积分翻倍
|
||||
</label>
|
||||
<!-- 地图大小:新开局生效;继续冒险沿用存档世界 -->
|
||||
<div class="map-pick">
|
||||
<div class="mp-head">
|
||||
<b>🗺 地图大小{{ saveable && hasSaves ? '(新建冒险时生效)' : '' }}</b>
|
||||
</div>
|
||||
<div class="scale-row">
|
||||
<button
|
||||
v-for="s in [1, 1.5, 2, 3, 4]"
|
||||
:key="s"
|
||||
class="scale-btn"
|
||||
:class="{ on: mapScale === s }"
|
||||
@click="setMapScale(s)"
|
||||
>{{ s }}×</button>
|
||||
</div>
|
||||
<span class="text-dim" style="font-size: 11px">倍率越大世界越大(多人建议 2×~4×),生成时间相应变长</span>
|
||||
</div>
|
||||
<!-- Mod 装载:新开局生效;继续冒险以存档内记录为准 -->
|
||||
<div v-if="modsAvail.length" class="mod-pick">
|
||||
<div class="mp-head">
|
||||
@@ -907,6 +970,22 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 地图大小:房主设定,随世界快照同步全员 -->
|
||||
<div class="map-pick compact">
|
||||
<div class="mp-head">
|
||||
<b>🗺 地图大小(房主设定)</b>
|
||||
</div>
|
||||
<div class="scale-row">
|
||||
<button
|
||||
v-for="s in [1, 1.5, 2, 3, 4]"
|
||||
:key="s"
|
||||
class="scale-btn"
|
||||
:class="{ on: mapScale === s }"
|
||||
@click="setMapScale(s)"
|
||||
>{{ s }}×</button>
|
||||
</div>
|
||||
<span class="text-dim" style="font-size: 11px">倍率越大世界越大,全员自动同步</span>
|
||||
</div>
|
||||
<!-- 房主可为本局勾选 mod(随世界快照同步给全员) -->
|
||||
<div v-if="coopStore.isHost && modsAvail.length" class="mod-pick compact">
|
||||
<div class="mp-head">
|
||||
@@ -953,7 +1032,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="ov-btns">
|
||||
<button v-if="coopStore.isHost" class="btn btn-lg" :disabled="!allReady" :title="allReady ? '' : '等待全员准备'" @click="coopStore.startGame()">开始冒险</button>
|
||||
<button v-if="coopStore.isHost" class="btn btn-lg" :disabled="!allReady" :title="allReady ? '' : '等待全员准备'" @click="hostClickStart()">开始冒险</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn"
|
||||
@@ -981,13 +1060,20 @@ onBeforeUnmount(() => {
|
||||
</label>
|
||||
<!-- 关卡选择(读取上次进度,展示好友都在哪一关) -->
|
||||
<LevelSelect
|
||||
v-if="levelsMeta"
|
||||
v-if="levelsMeta && !inGameSelect"
|
||||
:total="levelsMeta.total"
|
||||
:names="levelsMeta.names"
|
||||
:my-level="progress.level"
|
||||
:friends="progress.friends"
|
||||
@pick="startGame"
|
||||
/>
|
||||
<!-- 游戏内选关(马里奥风格):只留开始按钮 + 进度提示 -->
|
||||
<template v-else-if="levelsMeta">
|
||||
<button class="btn btn-lg" @click="startGame()">开始游戏</button>
|
||||
<p class="text-dim" style="margin-top: 10px; font-size: 13px">
|
||||
进度:已解锁至世界 {{ levelsMeta.names?.[Math.min(progress.level, levelsMeta.names.length) - 1] || progress.level }} · 进游戏后用马里奥选关画面选择
|
||||
</p>
|
||||
</template>
|
||||
<!-- 存档游戏:多存档列表 + 新建 -->
|
||||
<template v-else-if="saveable">
|
||||
<div v-if="hasSaves" class="save-list">
|
||||
@@ -1061,15 +1147,27 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mod 选择模态框:卡片多选 -->
|
||||
<!-- Mod 选择模态框:卡片多选 + 手册 -->
|
||||
<div v-if="showModModal" class="modal-mask" @click.self="showModModal = false">
|
||||
<div class="mod-modal panel">
|
||||
<div class="mod-modal panel" :class="{ wide: manualMode }">
|
||||
<button class="bm-close" @click="showModModal = false">✕</button>
|
||||
<h3>选择 Mod</h3>
|
||||
<p class="text-dim" style="font-size: 12px; margin-top: -4px">
|
||||
<h3>{{ manualMode ? '📖 Mod 手册' : '选择 Mod' }}</h3>
|
||||
<p class="text-dim" style="font-size: 12px; margin-top: -4px" v-if="!manualMode">
|
||||
新开局时生效 · 已选 {{ selMods.length }} 个{{ modFactor !== 1 ? ` · 得分 ×${modFactor}` : '' }}
|
||||
</p>
|
||||
<div class="mod-card-grid">
|
||||
<p class="text-dim" style="font-size: 12px; margin-top: -4px" v-else>
|
||||
全部 Mod 的玩法、功能与注意事项 · 点开条目查看详情
|
||||
</p>
|
||||
<div class="manual-tags" v-if="manualMode">
|
||||
<button
|
||||
v-for="tg in MANUAL_TAGS"
|
||||
:key="tg"
|
||||
class="manual-tag"
|
||||
:class="{ on: manualTag === tg }"
|
||||
@click="manualTag = tg"
|
||||
>{{ tg }}</button>
|
||||
</div>
|
||||
<div class="mod-card-grid" v-if="!manualMode">
|
||||
<div
|
||||
v-for="m in modsAvail"
|
||||
:key="m.id"
|
||||
@@ -1086,9 +1184,41 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="manual-list" v-else>
|
||||
<div
|
||||
v-for="en in manualList"
|
||||
:key="en.id"
|
||||
class="manual-item"
|
||||
:class="{ open: manualOpenId === en.id }"
|
||||
>
|
||||
<div class="manual-head" @click="manualOpenId = manualOpenId === en.id ? '' : en.id">
|
||||
<b>{{ en.name }}</b>
|
||||
<span class="mod-card-tag">{{ en.tag }}</span>
|
||||
<span v-if="en.factor !== 1" class="mod-card-factor" :class="{ up: en.factor > 1 }">得分 ×{{ en.factor }}</span>
|
||||
<span class="manual-arrow">{{ manualOpenId === en.id ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
<div class="manual-body" v-if="manualOpenId === en.id">
|
||||
<div class="manual-desc">{{ en.desc }}</div>
|
||||
<div class="manual-sec">玩法与功能</div>
|
||||
<ul>
|
||||
<li v-for="(f, fi) in en.features" :key="fi">{{ f }}</li>
|
||||
</ul>
|
||||
<template v-if="en.tips && en.tips.length">
|
||||
<div class="manual-sec warn">注意事项</div>
|
||||
<ul class="manual-tips">
|
||||
<li v-for="(t2, ti) in en.tips" :key="ti">{{ t2 }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ov-btns">
|
||||
<button class="btn btn-ghost" @click="clearMods">清空</button>
|
||||
<button class="btn" @click="showModModal = false">确认</button>
|
||||
<template v-if="!manualMode">
|
||||
<button class="btn btn-ghost" @click="clearMods">清空</button>
|
||||
<button class="btn btn-ghost" @click="manualMode = true">📖 Mod 手册</button>
|
||||
</template>
|
||||
<button v-if="manualMode" class="btn btn-ghost" @click="manualMode = false">返回选择</button>
|
||||
<button class="btn" @click="showModModal = false">{{ manualMode ? '关闭' : '确认' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1143,6 +1273,14 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<p class="text-dim" style="font-size: 12px">默认都支持;摇杆未选中时自动隐藏。</p>
|
||||
</div>
|
||||
<!-- 年糕求生(饥荒)音频开关:与游戏内 audio 模块共享 localStorage 状态 -->
|
||||
<div v-if="code === 'starve'" class="setting-block">
|
||||
<b>声音</b>
|
||||
<div class="setting-options">
|
||||
<button class="chip" :class="{ on: sfxOn }" @click="toggleSfx()">音效:{{ sfxOn ? '开' : '关' }}</button>
|
||||
<button class="chip" :class="{ on: musicOn }" @click="toggleMusic()">音乐:{{ musicOn ? '开' : '关' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-block">
|
||||
<b>PC 快捷键</b>
|
||||
<ul class="shortcut-list">
|
||||
@@ -1341,6 +1479,42 @@ onBeforeUnmount(() => {
|
||||
gap: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
/* 地图大小选择(独立 class:全局 .mod-pick 被 display:none 隐藏,不能用) */
|
||||
.map-pick {
|
||||
width: min(460px, 92%);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
.map-pick.compact {
|
||||
width: min(420px, 96%);
|
||||
}
|
||||
/* 地图大小选择 */
|
||||
.scale-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin: 6px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.scale-btn {
|
||||
background: rgba(20, 16, 10, 0.6);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 6px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.scale-btn.on {
|
||||
border-color: #e8bc5a;
|
||||
color: #f4ead2;
|
||||
background: rgba(90, 60, 20, 0.6);
|
||||
}
|
||||
.mod-pick.compact {
|
||||
width: min(420px, 96%);
|
||||
max-height: 200px;
|
||||
@@ -1910,6 +2084,90 @@ onBeforeUnmount(() => {
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
}
|
||||
/* ---- Mod 手册 ---- */
|
||||
.mod-modal.wide {
|
||||
width: min(680px, 92vw);
|
||||
}
|
||||
.manual-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
.manual-tag {
|
||||
background: rgba(20, 16, 10, 0.6);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 999px;
|
||||
padding: 4px 14px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.manual-tag.on {
|
||||
border-color: #e8bc5a;
|
||||
color: #f4ead2;
|
||||
}
|
||||
.manual-list {
|
||||
max-height: 52vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.manual-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: rgba(20, 16, 10, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
.manual-item.open {
|
||||
border-color: #e8bc5a;
|
||||
}
|
||||
.manual-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.manual-head b {
|
||||
font-size: 13px;
|
||||
}
|
||||
.manual-head .manual-arrow {
|
||||
margin-left: auto;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.manual-body {
|
||||
padding: 2px 14px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.manual-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.manual-sec {
|
||||
font-size: 12px;
|
||||
color: #e8bc5a;
|
||||
margin: 8px 0 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.manual-sec.warn {
|
||||
color: #e08a5a;
|
||||
}
|
||||
.manual-body ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.manual-body li {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.manual-tips li {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.mod-card-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
|
||||
@@ -9,6 +9,7 @@ import GameCard from '../components/GameCard.vue'
|
||||
import PixelAvatar from '../components/PixelAvatar.vue'
|
||||
import GameCarousel from '../components/GameCarousel.vue'
|
||||
import GameIcon from '../components/GameIcon.vue'
|
||||
import { gameRegistry } from '../games'
|
||||
|
||||
const router = useRouter()
|
||||
const themeStore = useThemeStore()
|
||||
@@ -38,9 +39,11 @@ const filtered = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
// 点击游戏卡片:玩不了的付费游戏弹购买详情窗(不跳转),联机游戏去对战大厅,其余进入游玩页
|
||||
// 点击游戏卡片:有独立详情页的游戏(饥荒/马里奥)先进详情页,玩不了的付费游戏弹购买详情窗(不跳转),联机游戏去对战大厅,其余进入游玩页
|
||||
function onPlay(game) {
|
||||
if (!game.playable && game.price > 0) {
|
||||
if (gameRegistry[game.code]?.detail) {
|
||||
router.push(`/detail/${game.code}`)
|
||||
} else if (!game.playable && game.price > 0) {
|
||||
buyGame.value = game
|
||||
} else if (game.engine === 'online') {
|
||||
router.push({ path: '/battle', query: { game: game.code } })
|
||||
|
||||
@@ -585,6 +585,7 @@ function mkCG(o = {}) {
|
||||
calls,
|
||||
me: () => o.me || players[0] || null,
|
||||
rand: (a) => a,
|
||||
isLand: () => true,
|
||||
findLand: (x, y) => (o.findLand ? o.findLand(x, y) : { x, y }),
|
||||
moveToward: (m, x, y) => { m.x = x; m.y = y; m.moving = true },
|
||||
damageMob: (m, dmg) => { calls.damageMob.push([m, dmg]); m.hp -= dmg },
|
||||
@@ -604,10 +605,15 @@ function mkCG(o = {}) {
|
||||
return g
|
||||
}
|
||||
// 假 2D 上下文:任意方法可调,measureText 返回带 width 的对象(供 labor clipText 用)
|
||||
// 渐变/图案方法返回可链式调用的 stub(守卫等新绘制用 createLinearGradient)
|
||||
const makeCtx2 = () => new Proxy({}, {
|
||||
get: (t, k) => {
|
||||
if (k in t) return t[k]
|
||||
if (k === 'measureText') return () => ({ width: 12 })
|
||||
if (k === 'createLinearGradient' || k === 'createRadialGradient' || k === 'createConicGradient') {
|
||||
return () => ({ addColorStop: () => {} })
|
||||
}
|
||||
if (k === 'createPattern') return () => ({})
|
||||
return () => {}
|
||||
},
|
||||
set: (t, k, v) => { t[k] = v; return true },
|
||||
@@ -636,7 +642,9 @@ console.log('== A. 世界缩放(configureWorld)==')
|
||||
configureWorld(0.1)
|
||||
ok(GW === 32 && GH === 24, `0.1 夹到下限 0.5 → 32/24 (${GW}/${GH})`)
|
||||
configureWorld(3)
|
||||
ok(GW === 128 && GH === 96, `3 夹到上限 2 → 128/96 (${GW}/${GH})`)
|
||||
ok(GW === 192 && GH === 144, `3 倍不再夹断 → 192/144 (${GW}/${GH})`)
|
||||
configureWorld(6)
|
||||
ok(GW === 256 && GH === 192, `6 夹到上限 4 → 256/192 (${GW}/${GH})`)
|
||||
let e0 = null
|
||||
try { configureWorld(0) } catch (e) { e0 = e.message }
|
||||
ok(e0 === null && GW === 64 && GH === 48, `configureWorld(0) 不崩且回落默认 (${GW}/${GH})`)
|
||||
@@ -724,7 +732,8 @@ const campHooks = (name) => hookFn(rt, name, 'camp')
|
||||
const ents = g.calls.addEntity.map((c) => c[0])
|
||||
ok(ents.includes('camphouse'), 'onWorldGen 放出 camphouse')
|
||||
ok(g.calls.spawnMob.some((s) => s[0] === 'guard'), 'onWorldGen 生成守卫')
|
||||
ok(g.calls.addFire.length === 1 && g.calls.addFire[0].eternal === true, 'onWorldGen 放出 eternal 火焰')
|
||||
ok(g.calls.addFire.length >= 1, `onWorldGen 放出长明篝火(火环 ${g.calls.addFire.length} 个)`)
|
||||
ok(g.calls.addFire.every((f2) => f2.eternal === true), '全部篝火均为 eternal')
|
||||
const f = g.calls.addFire[0]
|
||||
ok(f.pit === true && f.ttl === 150 && f.max === 150, `长明篝火 pit/ttl/max 正确 (${f && f.pit}/${f && f.ttl}/${f && f.max})`)
|
||||
}
|
||||
@@ -1272,13 +1281,14 @@ console.log('== 核心钩子契约(防回归)==')
|
||||
ok(bad.length === 0, `核心钩子全部传 gCtx (${bad.length ? '缺: ' + bad.join(',') : hookCalls + ' 处'})`)
|
||||
|
||||
// 3) mod 侧每个钩子的首参必须是 g(位置契约一致)
|
||||
// 注意:onPick 是 zoneUtil 菜单动作回调(不是 gCtx 钩子),排除
|
||||
const modFiles = fs.readdirSync(url('./src/games/starve/mods'))
|
||||
.filter((f) => f.endsWith('.js') && f !== 'index.js' && f !== 'registry.js')
|
||||
const badSig = []
|
||||
let hookDefs = 0
|
||||
for (const f of modFiles) {
|
||||
const src = fs.readFileSync(url('./src/games/starve/mods/' + f), 'utf8')
|
||||
for (const m of src.matchAll(/^\s{4}(on[A-Z][A-Za-z]*)\(([^)]*)\)/gm)) {
|
||||
for (const m of src.matchAll(/^\s{4}(on(?!Pick\b)[A-Z][A-Za-z]*)\(([^)]*)\)/gm)) {
|
||||
hookDefs++
|
||||
const first = (m[2].split(',')[0] || '').trim()
|
||||
if (first !== 'g') badSig.push(`${f}:${m[1]}(${m[2]})`)
|
||||
|
||||