更新饥荒

This commit is contained in:
2026-10-11 09:09:28 +08:00
parent d87ad60492
commit 3af4bc6b52
53 changed files with 12434 additions and 2733 deletions

View File

@@ -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()

View 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
View 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)

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
mario-jump-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
mario-jumpbuf-air.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
mario-verify-a-1-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
mario-verify-a-start.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
mario-verify-b-2-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
mario-verify-b-pipes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
mario-verify-c-2-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
mario-verify-c-3-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
mario-verify-c-later.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
mario-verify-d-2-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
mario-verify-d-3-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
mario-verify-debug-a.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
mario-verify-e-2-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
mario-verify-e-3-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
mario-verify-f-3-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
mario-verify-select.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View 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>

View 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 }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
View 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: [
'旗杆抓得越高分越多,起跳前先看清助跑距离',
'隐藏砖块与地下水管里常有惊喜,多顶多试',
'变小状态下挨一下就会重开,城堡关优先抢蘑菇',
],
},
}

View File

@@ -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
View 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
View 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 }
}

View File

@@ -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))
}

View File

@@ -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
View 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
View 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 ''
}
}

View File

@@ -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('蜂王召唤了护卫蜂群!')
}
}
}

View File

@@ -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)
},
},

File diff suppressed because it is too large Load Diff

View File

@@ -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)
},
},

View File

@@ -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) {

View 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: ['降低生存压力,适合轻松体验'],
},
]

View 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
}

File diff suppressed because it is too large Load Diff

285
src/games/starve/skel.js Normal file
View 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
}

View 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 },
}
}

View File

@@ -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 座猪屋围成一圈

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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>

View File

@@ -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;

View File

@@ -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 } })

View File

@@ -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]})`)