From 99f67d5d792d28e8229b0af7bd03f24ce4399a58 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 11:34:39 +0000 Subject: [PATCH 01/30] =?UTF-8?q?TUI=20=E5=AF=B9=E8=AF=9D=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20PageUp/PageDown=20=E7=BF=BB=E9=A1=B5=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E5=8E=86=E5=8F=B2=EF=BC=8C=E4=B8=8D=E5=86=8D=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E8=BE=83=E6=97=A9=E8=AE=B0=E5=BD=95=20(TUI=20chat:=20?= =?UTF-8?q?in-app=20PageUp/PageDown=20pager=20through=20history,=20transcr?= =?UTF-8?q?ipt=20bottom-anchored,=20no=20more=20silently=20hidden=20older?= =?UTF-8?q?=20entries)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobius/tui/package.json | 3 +- mobius/tui/src/components/Chat.tsx | 66 ++++++++++--- mobius/tui/tests/scroll.test.tsx | 147 +++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 15 deletions(-) create mode 100644 mobius/tui/tests/scroll.test.tsx diff --git a/mobius/tui/package.json b/mobius/tui/package.json index 8c109d6a..2e96c302 100644 --- a/mobius/tui/package.json +++ b/mobius/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mobius-os/mobius", - "version": "0.2.5", + "version": "0.2.6", "type": "module", "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.", "bin": { @@ -18,6 +18,7 @@ "test:aimux": "tsx tests/aimux.test.tsx", "test:reconnect": "tsx tests/reconnect.test.tsx", "test:screen": "tsx tests/screen.test.tsx", + "test:scroll": "tsx tests/scroll.test.tsx", "test": "npm run typecheck && npm run test:ui && npm run test:integration" }, "dependencies": { diff --git a/mobius/tui/src/components/Chat.tsx b/mobius/tui/src/components/Chat.tsx index 50302b96..2dffad79 100644 --- a/mobius/tui/src/components/Chat.tsx +++ b/mobius/tui/src/components/Chat.tsx @@ -35,7 +35,7 @@ interface TerminalSize { isTty: boolean } -const VERSION = '0.2.5' +const VERSION = '0.2.6' const WELCOME_ROWS = 12 const CHROME_ROWS = 11 @@ -49,6 +49,7 @@ const SLASH_COMMANDS = [ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, aimuxStatus }: ChatProps) { const chat = useChat({ client, ready, resumeSessionId }) const [showHelp, setShowHelp] = useState(false) + const [scrollBack, setScrollBack] = useState(0) const terminal = useTerminalSize() const runSlash = useCallback((raw: string) => { @@ -70,19 +71,42 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, return } setShowHelp(false) + setScrollBack(0) void chat.send(t) }, [chat, runSlash]) const transcriptRows = Math.max(5, terminal.rows - CHROME_ROWS) const fitted = useMemo( - () => fitTranscript(chat.entries, transcriptRows, terminal.columns), - [chat.entries, transcriptRows, terminal.columns], + () => fitTranscript(chat.entries, transcriptRows, terminal.columns, scrollBack), + [chat.entries, transcriptRows, terminal.columns, scrollBack], ) // Welcome card is for fresh / short sessions only. Once the conversation is // long enough that fitTranscript hides older entries, switch to the compact // header + full transcript — otherwise the 12-row welcome card crowds out the // recent messages and the chat area reads as blank after "已隐藏较早的…". - const showWelcome = fitted.hiddenCount === 0 && fitted.estimatedRows + WELCOME_ROWS <= transcriptRows + const showWelcome = fitted.hiddenOlder === 0 && scrollBack === 0 && fitted.estimatedRows + WELCOME_ROWS <= transcriptRows + + // In-app history pager. Ink redraws only the live frame, so the terminal's + // own scrollback holds no past turns — older entries are unreachable unless we + // page through them here. PageUp/PageDown move the viewport back/forward over + // the transcript. While reading history (scrollBack > 0) we keep the view + // pinned as new entries stream in; sending a message (onSubmit above) snaps + // back to the latest so the conversation auto-follows again. + const prevLenRef = useRef(chat.entries.length) + useEffect(() => { + const prev = prevLenRef.current + const cur = chat.entries.length + prevLenRef.current = cur + if (cur > prev && scrollBack > 0) setScrollBack(s => s + (cur - prev)) + }, [chat.entries.length, scrollBack]) + + useInput((_input, key) => { + // The composer ignores pageUp/pageDown, so binding them here can't clash + // with text entry, history navigation, or the slash-command popup. + const step = Math.max(1, fitted.entries.length) + if (key.pageUp) setScrollBack(s => Math.min(chat.entries.length, s + step)) + else if (key.pageDown) setScrollBack(s => Math.max(0, s - step)) + }) return ( : } - - {fitted.hiddenCount > 0 - ? … 已隐藏较早的 {fitted.hiddenCount} 条记录;使用 /resume 可重新载入会话 + + {fitted.hiddenOlder > 0 || scrollBack > 0 + ? ↑ {fitted.hiddenOlder > 0 ? `还有 ${fitted.hiddenOlder} 条较早记录 · PageUp 向上翻页` : '已到最早记录 · PageDown 向下翻页'} : null} {fitted.entries.map((entry, index) => ( ))} {chat.pendingUser !== null ? : null} + {fitted.hiddenRecent > 0 + ? ↓ PageDown 向下翻页 · 较新 {fitted.hiddenRecent} 条 + : null} {chat.entries.length === 0 && chat.pendingUser === null && !showHelp @@ -506,18 +533,29 @@ function entryRows(entry: AnyEntry, columns: number): number { }, 0) } -function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number): { +function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): { entries: AnyEntry[] - hiddenCount: number + hiddenOlder: number + hiddenRecent: number estimatedRows: number } { + // `scrollBack` = how many of the most-recent entries are paged out of view + // below the viewport (the user pressed PageUp). The visible window is then + // fit from the tail of what remains, backward, until the row budget is full. + const tail = Math.max(0, entries.length - scrollBack) + const avail = tail === 0 ? [] : entries.slice(0, tail) let rows = 0 - let first = entries.length - for (let index = entries.length - 1; index >= 0; index--) { - const nextRows = entryRows(entries[index], columns) - if (first < entries.length && rows + nextRows > rowBudget) break + let first = avail.length + for (let index = avail.length - 1; index >= 0; index--) { + const nextRows = entryRows(avail[index], columns) + if (first < avail.length && rows + nextRows > rowBudget) break rows += nextRows first = index } - return { entries: entries.slice(first), hiddenCount: first, estimatedRows: rows } + return { + entries: avail.slice(first), + hiddenOlder: first, // entries older than the viewport + hiddenRecent: entries.length - tail, // == scrollBack: entries newer than the viewport + estimatedRows: rows, + } } diff --git a/mobius/tui/tests/scroll.test.tsx b/mobius/tui/tests/scroll.test.tsx new file mode 100644 index 00000000..24e2c1e7 --- /dev/null +++ b/mobius/tui/tests/scroll.test.tsx @@ -0,0 +1,147 @@ +/** + * Scroll pager regression — "回答问题后把一切都隐藏了,请不要隐藏,支持向上翻页查看". + * + * The chat caps the transcript to the terminal height and (because Ink redraws + * only the live frame) the terminal's own scrollback holds no past turns, so + * older messages used to be unreachable. The fix is an in-app pager: PageUp + * scrolls back through history, PageDown forward, with a "stick to latest" + * rule so the conversation auto-follows again once you page back to the bottom. + * + * Run: npm run test:scroll + */ +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs' + +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'mobius-tui-scroll-')) +process.env.MOBIUS_TUI_HOME = TMP_HOME + +import React from 'react' +import { render } from 'ink-testing-library' +import { App } from '../src/App.js' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const RS: any = (globalThis as any).ReadableStream +const enc = new TextEncoder() +let sseController: any = null + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) +} +function emitEntry(n: number) { + // distinct uuid per entry so useChat's de-dup keeps every one + const payload = { event: 'jsonl_entry', session_id: SID, entry: { type: 'assistant', uuid: `a-${n}`, message: { role: 'assistant', content: [{ type: 'text', text: `回答 ${n}` }] } } } + sseController?.enqueue(enc.encode(`event: jsonl_entry\ndata: ${JSON.stringify(payload)}\n\n`)) +} + +let pass = 0, fail = 0 +function ok(c: boolean, m: string) { c ? (pass++, console.log(` ✓ ${m}`)) : (fail++, console.error(` ✗ ${m}`)) } +const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') + +const PID = 'proj-1', IID = 'issue-1', SID = 'sess-1' + +function mockFetch(url: string, init?: RequestInit): Response { + if (url.includes('/events')) { + return new Response(new RS({ + start(c: any) { + sseController = c + c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed"}\n\n')) + }, + }), { status: 200, headers: { 'content-type': 'text/event-stream' } }) + } + const method = init?.method ?? 'GET' + if (url.endsWith('/api/auth/config')) return json({ password_required: false }) + if (url.endsWith('/api/auth/me')) return json({ id: 'fuqingxu', display_name: '付清旭', role: 'admin', work_dir: '/tmp' }) + if (url.endsWith('/api/auth/login')) return json({ token: 'mock-jwt-token', user: { id: 'fuqingxu', display_name: '付清旭', role: 'admin' } }) + if (url.includes('/aimux_bridge/api/remotes/') && url.includes('/connection')) { + const m = url.match(/remotes\/([^/]+)\/connection/) + return json({ identifier: m ? decodeURIComponent(m[1]) : 'x', event_stream_connected: true }) + } + if (url.includes('/sessions') && url.includes('/issues') && method === 'POST') return json({ session_id: SID }) + if (url.includes('/sessions') && url.includes('/issues') && method === 'GET') return json([]) + if (url.endsWith('/messages') && method === 'POST') return json({ ok: true, session_id: SID, turn_number: 1 }) // keep SSE alive + if (url.endsWith(`/api/sessions/${SID}/status`)) return json({ session_id: SID, alive: true, working: false }) + if (url.includes('/api/projects/') && url.includes('/issues') && method === 'POST') return json({ id: IID, project_id: PID, title: '命令行任务' }) + if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([]) + if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲' }]) + if (url.endsWith('/api/projects') && method === 'POST') return json({ id: PID, name: '测试项目PTY' }) + if (url.includes('/sessions/model-options')) return json([{ key: 'codex', label: 'GPT-5.5', title: 'GPT-5.5', sub: 'Codex', backend: 'tmux-codex' }]) + if (url.includes('/sessions/default-model')) return json({ model: 'codex' }) + if (url.includes('/skills')) return json([]) + if (url.includes('/memories')) return json([]) + return json({ error: `unmocked ${method} ${url}` }, 404) +} + +async function waitFor(lastFrame: () => string | undefined, needle: string, timeoutMs = 6000) { + for (let i = 0; i < timeoutMs / 50; i++) { + if ((strip(lastFrame() ?? '')).includes(needle)) return true + await delay(50) + } + return false +} + +async function main() { + fs.writeFileSync(path.join(TMP_HOME, 'login.json'), JSON.stringify({ + server: 'http://mock.local', username: 'fuqingxu', token: 'mock-jwt-token', + user: { id: 'fuqingxu', display_name: '付清旭', role: 'admin' }, + })) + const realFetch = globalThis.fetch + globalThis.fetch = ((u: any, init?: any) => mockFetch(String(u), init)) as unknown as typeof fetch + + console.log('\n[SCROLL] in-app history pager (mocked backend)\n') + const { stdin, lastFrame, unmount } = render(React.createElement(App)) + + try { + // ── boot through the prep wizard into chat ──────────────────────────────── + ok(await waitFor(lastFrame, '选择当前路径的绑定项目'), 'booted into project picker') + stdin.write('\r'); await delay(120) + ok(await waitFor(lastFrame, '项目名称'), 'project create wizard opened') + stdin.write('测试项目PTY'); await delay(120) + stdin.write('\r'); await delay(300) + ok(await waitFor(lastFrame, '创建新任务'), 'issue picker shown') + stdin.write('\r'); await delay(120) + ok(await waitFor(lastFrame, '第 1 步'), 'issue name wizard opened') + stdin.write('命令行任务'); await delay(120) + stdin.write('\r'); await delay(120) + ok(await waitFor(lastFrame, '第 2 步'), 'issue worktree wizard opened') + stdin.write('\r'); await delay(300) + ok(await waitFor(lastFrame, '选择模型'), 'model picker shown') + stdin.write('\r'); await delay(250) + ok(await waitFor(lastFrame, '选择回复语言'), 'language picker shown') + stdin.write('\r'); await delay(400) + ok(await waitFor(lastFrame, '输入问题'), 'entered chat') + + // ── populate a long transcript ──────────────────────────────────────────── + stdin.write('hi'); await delay(120) + stdin.write('\r'); await delay(400) // creates session → SSE connects + for (let i = 0; i < 25; i++) { emitEntry(i); await delay(15) } + await delay(500) + const tailFrame = strip(lastFrame() ?? '') + + ok(tailFrame.includes('回答 24'), 'latest entry visible at tail (not hidden)') + ok(tailFrame.includes('PageUp'), 'older-records hint offers PageUp (nothing is silently lost)') + + // ── PageUp: viewport scrolls back over history ──────────────────────────── + stdin.write('\x1b[5~') // PageUp + await delay(300) + const upFrame = strip(lastFrame() ?? '') + ok(upFrame.includes('PageDown'), 'after PageUp: a PageDown hint appears (scrolled up)') + ok(!upFrame.includes('回答 24'), 'after PageUp: latest entry paged out of view') + ok(/回答 \d+/.test(upFrame), 'after PageUp: an older entry is visible') + + // ── PageDown: snaps back to the latest ──────────────────────────────────── + stdin.write('\x1b[6~') // PageDown + await delay(300) + const downFrame = strip(lastFrame() ?? '') + ok(downFrame.includes('回答 24'), 'after PageDown: latest entry back in view') + } finally { + unmount() + globalThis.fetch = realFetch + } + + try { fs.rmSync(TMP_HOME, { recursive: true, force: true }) } catch { /* ignore */ } + console.log(`\n==== SCROLL RESULT: ${pass} passed, ${fail} failed ====\n`) + process.exit(fail === 0 ? 0 : 1) +} + +main().catch((e) => { console.error('FATAL', e); process.exit(2) }) From 6f6d42667725f5e7980cd235b638e2f469c2dae0 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 11:41:30 +0000 Subject: [PATCH 02/30] =?UTF-8?q?TUI=20=E5=88=9B=E5=BB=BA=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E9=BB=98=E8=AE=A4=E7=A6=81=E7=94=A8=20git=20worktree?= =?UTF-8?q?=20=E4=B8=94=E4=B8=8D=E5=86=8D=E6=98=BE=E7=A4=BA=E8=AF=A5?= =?UTF-8?q?=E9=80=89=E9=A1=B9=20(TUI:=20disable=20git=20worktree=20by=20de?= =?UTF-8?q?fault,=20drop=20the=20worktree=20yes/no=20step=20from=20issue?= =?UTF-8?q?=20creation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobius/tui/package.json | 2 +- mobius/tui/src/components/Chat.tsx | 2 +- mobius/tui/src/components/PrepScreen.tsx | 30 ++++++++++-------------- mobius/tui/tests/flow.test.tsx | 6 ++--- mobius/tui/tests/reconnect.test.tsx | 4 +--- mobius/tui/tests/scroll.test.tsx | 4 +--- 6 files changed, 18 insertions(+), 30 deletions(-) diff --git a/mobius/tui/package.json b/mobius/tui/package.json index 2e96c302..370c111c 100644 --- a/mobius/tui/package.json +++ b/mobius/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mobius-os/mobius", - "version": "0.2.6", + "version": "0.2.7", "type": "module", "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.", "bin": { diff --git a/mobius/tui/src/components/Chat.tsx b/mobius/tui/src/components/Chat.tsx index 2dffad79..cf07a6a9 100644 --- a/mobius/tui/src/components/Chat.tsx +++ b/mobius/tui/src/components/Chat.tsx @@ -35,7 +35,7 @@ interface TerminalSize { isTty: boolean } -const VERSION = '0.2.6' +const VERSION = '0.2.7' const WELCOME_ROWS = 12 const CHROME_ROWS = 11 diff --git a/mobius/tui/src/components/PrepScreen.tsx b/mobius/tui/src/components/PrepScreen.tsx index 6427fe06..8135f50f 100644 --- a/mobius/tui/src/components/PrepScreen.tsx +++ b/mobius/tui/src/components/PrepScreen.tsx @@ -131,11 +131,13 @@ export function PrepScreen({ client, onReady, onQuit }: { setStep(next) if (!next) finish(iss, p) } - async function createIssue(name: string, useWt: boolean) { + async function createIssue(name: string) { if (!project) return setStatusMsg('创建任务…') try { - const iss = await client.createIssue(project.id, { title: name || '命令行任务', description: '由 TUI 创建', use_worktree: useWt }) + // git worktree is disabled by default and not offered as a choice — TUI + // tasks always run in the bound working tree. + const iss = await client.createIssue(project.id, { title: name || '命令行任务', description: '由 TUI 创建', use_worktree: false }) setIssues(await client.listIssues(project.id, 'active')) await pickIssue(iss) } catch (e: any) { setStatusMsg(`创建任务失败: ${e?.message ?? e}`) } @@ -278,29 +280,21 @@ function ProjectPicker({ cwd, projects, statusMsg, onPick, onCreate, onQuit }: { function IssuePicker({ issues, onPick, onCreate }: { issues: Issue[] onPick: (i: Issue) => void - onCreate: (name: string, useWt: boolean) => void + onCreate: (name: string) => void }) { - const [mode, setMode] = useState<'list' | 'create-name' | 'create-wt'>('list') + const [mode, setMode] = useState<'list' | 'create-name'>('list') const [name, setName] = useState('') if (mode === 'create-name') { + // git worktree is disabled by default and the option is intentionally not + // offered — TUI-created tasks always run in the bound working tree. return ( - 创建新任务 · 第 1 步:名称 + 创建新任务 + 输入任务名称(不使用 git worktree) setMode('create-wt')} /> - 回车继续 · Esc 返回 - - ) - } - if (mode === 'create-wt') { - return ( - - 创建新任务 · 第 2 步:是否使用 git worktree? - { setArchiveFile(e.target.files?.[0] || null); setErr('') }} + /> + + + ) : projectKind === 'extension' ? (
拓展标识名 { setExtensionName(v.toLowerCase().replace(/[^a-z0-9-]/g, '')); setErr('') }} placeholder="例如:my-awesome-ext" dark={dark} /> @@ -951,82 +1028,6 @@ export function CreateProjectForm({ onClose, onDone }: { onClose: () => void; on ) } -// ===================================================================== -// 表单 1b: 上传 ZIP 导入项目 — 一步建项目 + 解压代码. -// 服务"把本地项目直接传进来开发"的心智 (顶栏 [+] 菜单主入口). 复用 POST /api/projects 建项目 + -// POST /api/projects/:id/import-zip 解压 (后者已含 zip-slip/符号链接/配额安全 + 智能扁平化 + 自动 git init). -// 项目名留空则用压缩包文件名; 解压失败时项目已建, 提示用户进项目用「项目文件」的「上传 ZIP」重传. -// ===================================================================== -export function ImportZipProjectForm({ onClose, onDone }: { onClose: () => void; onDone: (entity: any, detailUrl?: string) => void }) { - const { theme, user } = useStore() - const dark = theme !== 'light' - const [name, setName] = useState('') - const [file, setFile] = useState(null) - const [loading, setLoading] = useState(false) - const [err, setErr] = useState('') - const inputRef = useRef(null) - - const submit = async () => { - if (loading) return - if (!file) { setErr('请选择一个压缩包文件'); return } - setLoading(true); setErr('') - try { - const finalName = name.trim() || file.name.replace(/\.(zip|tar\.gz|tgz|tar\.bz2|tar\.xz|tar|gz|bz2|xz)$/i, '').replace(/[._\s]+$/, '').trim() || '导入项目' - const bindPath = randomProjectBindPath(user?.work_dir) - if (!bindPath) { setErr('当前用户尚未配置工作目录'); return } - // 1) 建项目 - const p = await api('/api/projects', { method: 'POST', body: JSON.stringify({ - name: finalName, description: '', visibility: 'private', - bindPath, bindPathManual: false, defaultUseWorktree: false, - }) }) - if (p?.error) { setErr(p.error); return } - // 2) 解压导入 (失败则提示, 不跳转 — 项目已建, 用户可进项目用文件卡入口重传) - try { - const fd = new FormData(); fd.append('file', file, file.name) - await api(`/api/projects/${p.id}/import-zip`, { method: 'POST', body: fd }) - } catch (e: any) { - setErr(`项目「${finalName}」已创建, 但代码导入失败: ${e?.message || '未知错误'}。可关闭后进入该项目, 用「项目文件」的「上传 ZIP」重传。`) - return - } - onDone({ ...p, name: finalName }, p?.created_by ? `/u/${p.created_by}/p/${p.id}` : undefined) - } catch (e: any) { setErr(e?.message || '创建失败') } finally { setLoading(false) } - } - - return ( - }> -
- 项目名称 - { setName(v); setErr('') }} placeholder="例如:my-project(可留空)" autoFocus dark={dark} /> -
-
- 压缩包 - { setFile(e.target.files?.[0] || null); setErr('') }} /> - -
- {err && {err}} -
- ) -} - // ===================================================================== // 表单 2: 创建 Issue (单页: 目标项目 + 标题 + 描述 + 可见性 + worktree + 规划) // 替代旧 TargetPicker(选项目) → NewIssueModal(填字段) 两步流程. @@ -1835,7 +1836,6 @@ export function CreateResearchForm({ onClose, onDone, defaultProjectId }: { onCl // ===================================================================== const MENU_ITEMS: { kind: CreateKind; label: string; icon: any }[] = [ { kind: 'project', label: '新建项目', icon: FolderPlus }, - { kind: 'import-zip', label: '上传 ZIP 导入项目', icon: FileArchive }, { kind: 'issue', label: '新建任务', icon: CircleDot }, { kind: 'session', label: '新建快捷会话', icon: MessagesSquare }, { kind: 'research', label: '新建研究智能体', icon: FlaskConical }, @@ -1923,7 +1923,6 @@ export function GlobalCreateRoot({ kind, ctx, onClose, onNavigate }: { } if (kind === 'project') return - if (kind === 'import-zip') return if (kind === 'issue') return if (kind === 'session') return if (kind === 'research') return diff --git a/mobius/frontend/src/components/shell.tsx b/mobius/frontend/src/components/shell.tsx index b2ae017d..b610bb5a 100644 --- a/mobius/frontend/src/components/shell.tsx +++ b/mobius/frontend/src/components/shell.tsx @@ -10,7 +10,7 @@ import { AdminPanel } from './panels' import { MobiusLogo } from './mobius-logo' import { GuideHelpModal } from './guide-help' import { CustomThemePalette } from './custom-theme-palette' -import { Check, ChevronDown, CircleDot, CircleQuestionMark, FlaskConical, History, LayoutPanelTop, Menu, MessageSquare, Moon, Network, Palette, Plus, Search, Sliders, Sparkles, Sun, WavesHorizontal, createLucideIcon } from 'lucide-react' +import { Check, ChevronDown, CircleDot, CircleQuestionMark, FlaskConical, History, LayoutPanelTop, Menu, MessageSquare, Moon, Network, Palette, Plus, Search, Sliders, Sparkles, Sun, UserRound, WavesHorizontal, createLucideIcon } from 'lucide-react' import { THEME_OPTIONS, getThemeOption } from '../theme' import { applyCustomThemeToRoot, customThemeSwatches, getBaseOption, loadActiveCustomThemeId, loadCustomThemes, saveActiveCustomThemeId, type CustomTheme } from '../services/custom-themes' import { pollRecursive } from '../services/polling' @@ -1110,7 +1110,7 @@ export function TopNav({ rightExtra }: { rightExtra?: React.ReactNode } = {}) {
{/* 中间弹性填充 spacer (整条顶栏已统一挂拖拽, 此处仅占位; 不再单独挂 DesktopDragHandle 以免重复触发)。 */} -
+
{/* 右侧操作 */}
@@ -1166,6 +1166,7 @@ export function TopNav({ rightExtra }: { rightExtra?: React.ReactNode } = {}) { rel="noopener noreferrer" title="GitHub" aria-label="GitHub" + className="mobius-topnav-github" > @@ -1399,7 +1400,13 @@ export function TopNav({ rightExtra }: { rightExtra?: React.ReactNode } = {}) {
{ e.stopPropagation(); setShowUserMenu((s) => !s) }}> + {isMobile && } {!isMobile && {user?.display_name}} {!isMobile && } diff --git a/mobius/frontend/src/index.css b/mobius/frontend/src/index.css index c7289703..718f0d03 100644 --- a/mobius/frontend/src/index.css +++ b/mobius/frontend/src/index.css @@ -5532,29 +5532,36 @@ body.mobius-resizing .mobius-resizable-handle { /* 含可拖拽侧栏的布局行在窄屏下侧栏改走「抽屉」(见文件末尾 .mobius-drawer), 抽屉通过 portal 固定定位脱离文档流, 因此主区自然拿到整屏宽度, 这里无需改方向. */ - /* 顶栏: 允许换行以同时容纳面包屑与操作按钮, 并避让刘海/屏幕圆角. */ + /* 顶栏保持单行:面包屑负责收缩省略,操作按钮始终留在同一行。 + 旧规则允许 topnav/actions flex-wrap,窄屏会把操作区整排推到第二行, + 同时移动端用户名被隐藏后用户按钮只剩空热区。 */ .mobius-topnav { - height: auto; - min-height: 3rem; - flex-wrap: wrap; - row-gap: .4rem; - padding-top: max(.5rem, env(safe-area-inset-top)); - padding-bottom: .5rem; + height: calc(3rem + env(safe-area-inset-top)); + min-height: calc(3rem + env(safe-area-inset-top)); + flex-wrap: nowrap; + gap: .375rem; + row-gap: 0; + padding-top: env(safe-area-inset-top); + padding-bottom: 0; padding-left: max(.75rem, env(safe-area-inset-left)); padding-right: max(.75rem, env(safe-area-inset-right)); } - /* 面包屑区: 窄屏横向滚动而非挤压操作按钮, 隐藏滚动条保持整洁. */ + /* 面包屑吃掉操作区之外的剩余空间;内部文字各自 truncate。 + 保持 overflow:visible,避免再次裁掉品牌/项目/任务的 absolute 下拉面板。 */ .mobius-topnav-crumb { - overflow-x: auto; - scrollbar-width: none; + flex: 1 1 0%; + overflow: visible; flex-wrap: nowrap; } - .mobius-topnav-crumb::-webkit-scrollbar { + /* 单行窄屏不需要中间 spacer;面包屑自身承担弹性空间。 */ + .mobius-topnav-spacer { display: none; } - /* 操作按钮区: 允许换行, 优先贴右排列. */ + /* 操作按钮固定单行贴右,禁止自身再换出第二排。 */ .mobius-topnav-actions { - flex-wrap: wrap; + flex: 0 0 auto; + flex-wrap: nowrap; + gap: .25rem; justify-content: flex-end; } @@ -5589,6 +5596,23 @@ body.mobius-resizing .mobius-resizable-handle { } } +/* 极窄屏优先保留高频入口:新建、搜索、帮助、外观、用户菜单。 + 系统可视化与 GitHub 仍可从更宽视口访问,隐藏后给面包屑留下可读空间。 */ +@media (max-width: 560px) { + [data-tour="top-overview-cluster"], + .mobius-topnav-github { + display: none !important; + } + .mobius-topnav { + gap: .25rem; + padding-left: max(.5rem, env(safe-area-inset-left)); + padding-right: max(.5rem, env(safe-area-inset-right)); + } + .mobius-topnav-actions { + gap: .125rem; + } +} + /* ====================================================================== 移动端侧栏抽屉 (.mobius-drawer) ---------------------------------------------------------------------- diff --git a/mobius/frontend/src/pages/ResearchPage.tsx b/mobius/frontend/src/pages/ResearchPage.tsx index 0ddd782f..7164854d 100644 --- a/mobius/frontend/src/pages/ResearchPage.tsx +++ b/mobius/frontend/src/pages/ResearchPage.tsx @@ -8,13 +8,16 @@ import { ChatArea, SessionRow, isSessionNameMuted } from '../components/chat' import { AgentStatusDot } from '../components/AgentStatusDot' import { ProjectFilesCard } from '../components/project-files' import { Loading } from '../components/shell' -import { ResizablePanel } from '../components/resizable-panel' +import { ResizablePanel, useIsMobile } from '../components/resizable-panel' import { usePagination, PaginationControls } from '../components/pagination' import ResearchGraph from '../components/research-graph' import ResearchBlackboard from '../components/research-blackboard' +import { useEditorAvailability } from '../components/workspace/use-editor-availability' const ResearchAgentTeamModal = lazy(() => import('../components/research-agent-team-modal') .then(mod => ({ default: mod.ResearchAgentTeamModal }))) +const EditorPane = lazy(() => import('../components/workspace/editor-pane').then(mod => ({ default: mod.EditorPane }))) +const CodeConversationPane = lazy(() => import('../components/workspace/code-conversation-pane').then(mod => ({ default: mod.CodeConversationPane }))) // sidebar Research Agent 列表每页 16, 超过即分页. const SESSION_SIDEBAR_PAGE_SIZE = 16 @@ -23,11 +26,40 @@ export default function ResearchPage() { const params = useParams() const [search, setSearch] = useSearchParams() const { projects, setProjects, setCurrentProject, setCurrentIssue, setCurrentResearch, - sessionsMap, setSessionsMap, currentSession, setCurrentSession, setCurrentTask } = useStore() + sessionsMap, setSessionsMap, currentSession, setCurrentSession, setCurrentTask, + workspaceLayoutMode, applySessionWorkspaceLayout } = useStore() const userParam = params.user || '' const projectId = params.project || '' const researchId = params.research || '' const sessionParam = search.get('session') || '' + const currentView = search.get('view') + const showGraph = currentView === 'graph' + const showBlackboard = currentView === 'blackboard' + + // Research Agent 与 Issue Session 共用同一套三种工作区布局。此前顶栏能写入 + // workspaceLayoutMode,但 ResearchPage 从未消费该状态,因此三个菜单项点击后均无视觉变化。 + const isMobile = useIsMobile() + const { bindPath: editorBindPath, vscodeWebUrl: editorVscodeUrl } = useEditorAvailability(projectId, !!currentSession) + const workspaceViewActive = !!currentSession && !showGraph && !showBlackboard + const editorAvailable = workspaceViewActive && !!editorBindPath && !!editorVscodeUrl + const useEditorChat = workspaceLayoutMode === 'editor-chat' && editorAvailable && !isMobile + const codeConversationAvailable = workspaceViewActive && !!editorBindPath && !isMobile + const useCodeConversation = workspaceLayoutMode === 'code-conversation' && codeConversationAvailable + const [editorMounted, setEditorMounted] = useState(false) + const [codeConversationMounted, setCodeConversationMounted] = useState(false) + + useEffect(() => { + setEditorMounted(false) + setCodeConversationMounted(false) + }, [projectId]) + + useEffect(() => { if (useEditorChat) setEditorMounted(true) }, [useEditorChat]) + useEffect(() => { if (useCodeConversation) setCodeConversationMounted(true) }, [useCodeConversation]) + + const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1280 + const editorMinWidth = 480 + const editorMaxWidth = Math.max(editorMinWidth + 240, viewportWidth - 360) + const editorDefaultWidth = Math.max(editorMinWidth, Math.min(editorMaxWidth, Math.floor(viewportWidth * 0.6))) const [researchState, setResearchState] = useState(null) const [showCreateChoice, setShowCreateChoice] = useState(false) @@ -105,6 +137,11 @@ export default function ResearchPage() { return () => { cancelled = true } }, [sessionParam, sessions, sessionsLoaded]) + // 布局按 Research Agent 会话独立保存,切换 Agent 时恢复其上次选择。 + useEffect(() => { + applySessionWorkspaceLayout(currentSession?.session_id || null) + }, [currentSession?.session_id]) + // 刷新 sessions 列表. 合并而非直接覆盖: 当前会话的 agent_status 由 ChatArea 2s 轮询 // 实时维护 (并写回 DB), 这里保留本地值, 避免周期刷新用 DB 滞后值覆盖 -> 当前会话小圆点 // 闪烁 (尤其点"终止"后的 3s 抑制窗内 DB 仍报 running). 其余会话取后端最新值. @@ -152,10 +189,6 @@ export default function ResearchPage() { }, [researchId, refreshSessions]) const openCreateChoice = () => setShowCreateChoice(true) - const currentView = search.get('view') - const showGraph = currentView === 'graph' - const showBlackboard = currentView === 'blackboard' - const goToSession = (sid: string) => { const next = new URLSearchParams(search) next.set('session', sid) @@ -222,6 +255,8 @@ export default function ResearchPage() {
+ {/* 普通会话布局保留原 Research 侧栏;进入任一编辑布局时隐藏。 */} +
+
+ + {/* VSCode 编辑:首次进入后保活 iframe,切回普通模式只隐藏,避免重新连接。 */} + {editorMounted && !isMobile && ( +
+ + }> + + + +
+ )} + + {/* 原生文件编辑器:保活文件树、当前文件和未保存编辑状态。 */} + {codeConversationMounted && !isMobile && ( +
+ + +
+ } + > + + +
+ )} {showGraph ? (
@@ -357,7 +438,10 @@ export default function ResearchPage() {
) : currentSession ? ( - + ) : sessionParam ? ( ) : ( @@ -435,6 +519,15 @@ export default function ResearchPage() { ) } +function ResearchWorkspaceLoading({ label }: { label: string }) { + return ( +
+
+
{label}
+
+ ) +} + function ResearchSessionOverview({ sessions, onOpenSession, onNewSession, onEdit, onDelete, projectId, researchId }: { sessions: any[] onOpenSession: (sid: string) => void From a59e46f06c491c226b517594849c7c9184217162 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 12:28:18 +0000 Subject: [PATCH 06/30] =?UTF-8?q?Add=20lane-focused=20cannons=20and=20perm?= =?UTF-8?q?anent=20bonus=20progression=20(=E5=A2=9E=E5=8A=A0=E5=8D=95?= =?UTF-8?q?=E8=B7=AF=E7=82=AE=E5=8F=B0=E4=B8=8E=E6=B0=B8=E4=B9=85=E5=8A=A0?= =?UTF-8?q?=E6=88=90=E5=BE=AA=E7=8E=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 333d18287f35364d5761674f06a342c2f81679c3 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 12:30:49 +0000 Subject: [PATCH 07/30] =?UTF-8?q?Enable=20research=20workspace=20layout=20?= =?UTF-8?q?switching=20(=E5=90=AF=E7=94=A8=E7=A0=94=E7=A9=B6=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E5=B8=83=E5=B1=80=E5=88=87=E6=8D=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From ac2670d1195f58b3f12f6b62fa6bb1fdad6c8ffa Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 13:47:45 +0000 Subject: [PATCH 08/30] =?UTF-8?q?Add=20character=20dialogue=20and=20verify?= =?UTF-8?q?=20director=20effects=20(=E5=A2=9E=E5=8A=A0=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8F=B0=E8=AF=8D=E5=B9=B6=E7=A1=AE=E4=BF=9D=E5=AF=BC=E6=BC=94?= =?UTF-8?q?=E6=95=88=E6=9E=9C=E7=9C=9F=E5=AE=9E=E7=94=9F=E6=95=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobius/extension/toy-toy-toy/extension.json | 4 +- .../extension/toy-toy-toy/frontend/index.html | 8 +- mobius/extension/toy-toy-toy/frontend/main.js | 253 ++++++++++++++++-- .../extension/toy-toy-toy/frontend/styles.css | 28 ++ 4 files changed, 272 insertions(+), 21 deletions(-) diff --git a/mobius/extension/toy-toy-toy/extension.json b/mobius/extension/toy-toy-toy/extension.json index 50de718b..80a0023b 100644 --- a/mobius/extension/toy-toy-toy/extension.json +++ b/mobius/extension/toy-toy-toy/extension.json @@ -1,8 +1,8 @@ { "name": "toy-toy-toy", "display_name": "广告爽游实验室", - "description": "双题材广告爽游实验室:横移主炮、隐藏 Bonus、结界与永久数值叠加。", - "version": "0.5.0", + "description": "双题材广告爽游实验室:身份台词、横移主炮、隐藏 Bonus 与真实导演效果。", + "version": "0.6.0", "icon": "favicon.svg", "project": { "sync": true diff --git a/mobius/extension/toy-toy-toy/frontend/index.html b/mobius/extension/toy-toy-toy/frontend/index.html index 8303aeac..4c32c7c3 100644 --- a/mobius/extension/toy-toy-toy/frontend/index.html +++ b/mobius/extension/toy-toy-toy/frontend/index.html @@ -6,7 +6,7 @@ 广告爽游实验室 - + + diff --git a/mobius/extension/toy-toy-toy/frontend/main.js b/mobius/extension/toy-toy-toy/frontend/main.js index ee2715b2..ba20494d 100644 --- a/mobius/extension/toy-toy-toy/frontend/main.js +++ b/mobius/extension/toy-toy-toy/frontend/main.js @@ -21,6 +21,13 @@ const THEMES = Object.freeze({ description: '左右移动唯一的主炮台,决定这一秒守哪一路。打爆尸潮结界和隐藏补给,让火力、射速与炮台数量在一局里不断膨胀。', features: ['横移主炮台', '可击破 Bonus', '永久数值叠加', '尸王演出'], roster: ['腐烂行尸', '狂奔者', '屠夫肉盾', '变异精英', '巨型尸王'], + speech: { + normal: ['脑——子——在哪边?', '开门!社区送温暖!', '我只是路过吃个夜宵。', '这路怎么还有炮?'], + runner: ['等等我,鞋跑掉了!', '冲错路了!先别开炮!', '我为什么跑这么快?', '前面的僵尸让一让!'], + tank: ['轻点,我刚吃饱。', '大块头申请优先通行!', '这炮是在给我挠痒吗?', '谁把门修得这么结实?'], + elite: ['谁把我从午睡里叫醒的?', '今天这座基地必须拆!', '我闻到了人类加班的味道。', '普通僵尸都靠边站!'], + boss: ['都别挤,我才是尸王!', '广告里我可是最终主角!', '这城墙看起来很有嚼劲。', '三路都让开,我走中间!'], + }, startButton: '开始守城', startButtonHint: '点击后尸潮立即来袭', startHint: 'A / D 左右移动 · S 回到中路 · P 暂停 · 空格触发超载', @@ -58,8 +65,8 @@ const THEMES = Object.freeze({ barrier: ['◇', '尸潮结界', '击碎可获得强化与炮台碎片'], }, director: { - frenzyIcon: '☣', frenzyLabel: '十倍尸潮', frenzyDescription: '8 秒高密度送爽怪', - overdriveIcon: '⚡', overdriveLabel: '火力超载', overdriveDescription: '10 秒射速与伤害暴涨', + frenzyIcon: '☣', frenzyLabel: '十倍尸潮', frenzyDescription: '8 秒敌潮 ×10,敌人变脆', + overdriveIcon: '⚡', overdriveLabel: '火力超载', overdriveDescription: '10 秒伤害 ×2.45、射速 ×2.4', bossIcon: '♛', bossLabel: '尸王立即登场', bossDescription: '不用等到最后', frenzyToast: '十倍尸潮已启动:密度拉满,但敌人会稍微变脆', frenzyBanner: 'TENFOLD HORDE', @@ -100,6 +107,13 @@ const THEMES = Object.freeze({ description: '把唯一的救火小组左右调度到前端、后端或生产。击破咖啡补给、隐藏需求和流程结界,让修复倍率一路失控。', features: ['横移救火小组', '隐藏需求 Bonus', '永久数值叠加', '甲方 Boss'], roster: ['开发同事', '狂奔实习生', '产品经理', '暴躁 Leader', '甲方老板'], + speech: { + normal: ['开发:这 Bug 不是我引入的!', '开发:我本地明明是好的。', '开发:谁动了我的分支?', '开发:先让我看一下日志。'], + runner: ['实习生:我直接改生产了!', '实习生:测试环境在哪儿?', '实习生:我只删了一个分号。', '实习生:Leader,我先提交啦!'], + tank: ['产品经理:这个需求很简单。', '产品经理:就改亿点点。', '产品经理:用户说今天必须上。', '产品经理:按钮要五彩斑斓的黑。'], + elite: ['Leader:今晚必须上线!', 'Leader:大家再坚持五分钟。', 'Leader:先解决问题,锅以后分。', 'Leader:进度为什么还是 99%?'], + boss: ['甲方:上线前我再改一下。', '甲方:我不懂技术,但这很简单。', '甲方:明早给我看完整版本。', '甲方:原型不是已经能点了吗?'], + }, startButton: '开始上线', startButtonHint: '点击后立即进入救火模式', startHint: 'A / D 左右调度 · S 回到后端 · P 暂停 · 空格咖啡续命', @@ -137,8 +151,8 @@ const THEMES = Object.freeze({ barrier: ['▦', '流程结界', '击穿可获得算力与团队碎片'], }, director: { - frenzyIcon: '⚠', frenzyLabel: '需求井喷', frenzyDescription: '8 秒临时需求疯狂涌入', - overdriveIcon: '☕', overdriveLabel: '咖啡续命', overdriveDescription: '10 秒编译与修复速度暴涨', + frenzyIcon: '⚠', frenzyLabel: '需求井喷', frenzyDescription: '8 秒需求量 ×10,需求变脆', + overdriveIcon: '☕', overdriveLabel: '咖啡续命', overdriveDescription: '10 秒修复 ×2.45、处理速度 ×2.4', bossIcon: '☎', bossLabel: '甲方立即来电', bossDescription: '提前触发最终需求', frenzyToast: '群聊里突然多了 99+ 条新需求:需求井喷已启动', frenzyBanner: 'SCOPE CREEP ×10', @@ -160,7 +174,7 @@ const THEMES = Object.freeze({ victoryToast: '临时改需求已被拒绝:正在生成上线战报', upgrades: { damage: ['代码热修', '每次修复能够消灭更多 Bug,严重异常也会快速掉血'], - rate: ['咖啡因超频', '缩短编译与部署间隔,焦点服务获得额外线程'], + rate: ['咖啡因超频', '真实缩短当前服务的编译与部署间隔'], blast: ['异常连锁', '修掉一个异常时顺便清理附近同类堆栈'], chain: ['调用链追踪', '沿调用关系跳转并修复附近 Bug'], frost: ['冻结需求', '临时冻结需求流入,为生产环境争取时间'], @@ -505,7 +519,7 @@ const enemyPlaneGeometry = new THREE.PlaneGeometry(1.95, 2.55); enemyPlaneGeometry.translate(0, 1.275, 0); function createEnemyMaterial(themeId, type) { - const texture = textureLoader.load(`./assets/characters/${themeId}-atlas.svg?v=0.5.0`); + const texture = textureLoader.load(`./assets/characters/${themeId}-atlas.svg?v=0.6.0`); texture.colorSpace = THREE.SRGBColorSpace; texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; @@ -586,6 +600,7 @@ const BONUS_CONFIG = Object.freeze({ const shockwaves = []; const fxItems = []; const lightningItems = []; +const speechBubbles = []; const state = { mode: 'menu', @@ -605,6 +620,7 @@ const state = { nextBonusAt: 5.5, nextBarrierAt: 14, nextMysteryAt: 23, + nextSpeechAt: 2.8, nextUpgradeAt: 10, upgradeDeadline: 0, currentUpgrades: [], @@ -622,6 +638,15 @@ const state = { lastUiAt: 0, lastShotSoundAt: 0, lastKillSoundAt: 0, + telemetry: { + spawned: 0, + shots: 0, + speech: 0, + frenzyUses: 0, + overdriveUses: 0, + bossUses: 0, + upgrades: 0, + }, bonuses: { damage: 1, rate: 1, @@ -725,7 +750,7 @@ function applyTheme(themeId, { persist = true, refreshLeaderboard = true } = {}) els.startDescription.textContent = theme.description; els.enemyRoster.innerHTML = theme.roster.map((name, index) => `
- + ${name}
`).join(''); @@ -824,6 +849,7 @@ function resize() { function clearWorldState() { enemies.length = 0; + speechBubbles.length = 0; bonusTargets.splice(0).forEach(disposeBonusTarget); Object.values(enemyVisuals).forEach((visual) => { visual.mesh.count = 0; }); enemyShadowMesh.count = 0; @@ -857,6 +883,7 @@ function resetGame() { state.nextBonusAt = 5.5; state.nextBarrierAt = 14; state.nextMysteryAt = 23; + state.nextSpeechAt = 2.8 + randomBetween(0, 1.6); state.nextUpgradeAt = theme.firstUpgradeAt; state.upgradeDeadline = 0; state.currentUpgrades = []; @@ -869,6 +896,7 @@ function resetGame() { state.finishAt = 0; state.shake = 0; state.flash = 0; + state.telemetry = { spawned: 0, shots: 0, speech: 0, frenzyUses: 0, overdriveUses: 0, bossUses: 0, upgrades: 0 }; state.bonuses = { damage: 1, rate: 1, crit: 0, count: 0, shards: 0, barriers: 0 }; state.levels = { damage: 1, rate: 1, blast: 0, chain: 0, frost: 0, multi: 0, crit: 0, cannon: 1 }; wallMaterial.color.setHex(theme.palette.wall); @@ -918,6 +946,7 @@ function togglePause(forceResume = false) { state.mode = 'paused'; setOverlay(els.pauseOverlay, true); els.pauseBtn.textContent = '继续'; + updateHud(true); return; } if (state.mode === 'paused') { @@ -925,6 +954,7 @@ function togglePause(forceResume = false) { state.lastTs = performance.now(); setOverlay(els.pauseOverlay, false); els.pauseBtn.textContent = '暂停'; + updateHud(true); } } @@ -960,6 +990,7 @@ function spawnEnemy(forceType = null) { slowUntil: 0, hitUntil: 0, wobble: randomBetween(0, Math.PI * 2), + speechCount: 0, }; if (type === 'runner') { @@ -999,35 +1030,44 @@ function spawnEnemy(forceType = null) { } enemies.push(enemy); + state.telemetry.spawned += 1; return enemy; } function summonBoss(manual = false) { const theme = currentTheme(); - if (state.mode !== 'playing' || state.bossAlive || state.bossDefeated) return; - state.bossSpawned = true; + if (state.mode !== 'playing' || state.bossSpawned || state.bossAlive || state.bossDefeated) return; const boss = spawnEnemy('boss'); - if (!boss) return; + if (!boss) { + showToast('战场单位已满,清出空间后才能召唤 Boss', 1800); + return; + } + if (manual) state.telemetry.bossUses += 1; state.shake = Math.max(state.shake, 0.85); showToast(manual ? theme.director.manualBossToast : theme.director.bossToast, 2600); showOverdriveBanner(theme.director.bossBanner); sfx.boss(); for (let i = 0; i < 18; i += 1) addFxParticle(boss.x, 1, boss.z, cssHex(theme.palette.enemies.boss), 1.1); + const bossLines = theme.speech?.boss || []; + if (bossLines.length) showEnemySpeech(boss, bossLines[Math.floor(state.random() * bossLines.length)], 3.6); } function triggerFrenzy() { const theme = currentTheme(); if (state.mode !== 'playing') return; + state.telemetry.frenzyUses += 1; state.frenzyUntil = Math.max(state.frenzyUntil, state.elapsed + 8); showToast(theme.director.frenzyToast); showOverdriveBanner(theme.director.frenzyBanner); state.shake = Math.max(state.shake, 0.42); - for (let i = 0; i < 24; i += 1) spawnEnemy(i % 4 === 0 ? 'runner' : 'normal'); + sfx.warning(); + for (let i = 0; i < 30; i += 1) spawnEnemy(i % 4 === 0 ? 'runner' : 'normal'); } function triggerOverdrive(auto = false) { const theme = currentTheme(); if (state.mode !== 'playing') return; + state.telemetry.overdriveUses += 1; state.overdriveUntil = Math.max(state.overdriveUntil, state.elapsed + 10); showToast(auto ? theme.director.bailoutToast : theme.director.overdriveToast); showOverdriveBanner(auto ? theme.director.bailoutBanner : theme.director.overdriveBanner); @@ -1035,6 +1075,43 @@ function triggerOverdrive(auto = false) { sfx.overdrive(); } +function showEnemySpeech(enemy, text, life = 2.8) { + if (!enemy?.active || !text) return; + enemy.speechCount += 1; + speechBubbles.push({ enemy, text, life, maxLife: life }); + state.telemetry.speech += 1; +} + +function scheduleCharacterSpeech() { + if (state.elapsed < state.nextSpeechAt || speechBubbles.length >= 2) return; + state.nextSpeechAt = state.elapsed + randomBetween(2.6, 5.2); + // 大多数时间让战场保持干净,台词只作为偶尔冒出来的身份彩蛋。 + if (state.random() > 0.72) return; + const activeSpeechLanes = new Set(speechBubbles.map((bubble) => bubble.enemy?.lane)); + const candidates = enemies.filter((enemy) => ( + enemy.active + && enemy.type !== 'boss' + && enemy.speechCount < 1 + && enemy.z > WORLD.spawnZ + 1.2 + && enemy.z < WORLD.baseZ - 3.5 + && !activeSpeechLanes.has(enemy.lane) + )); + if (!candidates.length) return; + const enemy = candidates[Math.floor(state.random() * candidates.length)]; + const lines = currentTheme().speech?.[enemy.type] || currentTheme().speech?.normal || []; + if (!lines.length) return; + showEnemySpeech(enemy, lines[Math.floor(state.random() * lines.length)]); +} + +function updateCharacterSpeech(dt) { + scheduleCharacterSpeech(); + for (let index = speechBubbles.length - 1; index >= 0; index -= 1) { + const bubble = speechBubbles[index]; + bubble.life -= dt; + if (bubble.life <= 0 || !bubble.enemy?.active) speechBubbles.splice(index, 1); + } +} + function bonusCanvasSprite(icon, title, color) { const canvas = document.createElement('canvas'); canvas.width = 512; @@ -1303,7 +1380,7 @@ function updateSpawning(dt) { const living = livingEnemies(); const nearestZ = living.reduce((max, enemy) => Math.max(max, enemy.z), WORLD.spawnZ); let spawnRate = (1.7 + progress * 5.5) * theme.spawnMultiplier; - if (state.elapsed < state.frenzyUntil) spawnRate *= 4.8; + if (state.elapsed < state.frenzyUntil) spawnRate *= 10; if (living.length < 18 && nearestZ < 4) spawnRate *= 1.55; if (living.length > 360) spawnRate *= 0.42; if (state.bossAlive) spawnRate *= 0.62; @@ -1378,16 +1455,32 @@ function fireProjectile(turret, target, damage) { projectile.lane = state.focusLane; projectile.mesh.position.set(projectile.x, projectile.y, projectile.z); turret.recoil = 1; + state.telemetry.shots += 1; } -function updateTurrets(dt) { +function currentCombatStats() { const overdrive = state.elapsed < state.overdriveUntil; + return { + overdrive, + fireInterval: 0.24 + / (1 + (state.levels.rate - 1) * 0.22) + / state.bonuses.rate + / (overdrive ? 2.4 : 1), + damage: 22 + * Math.pow(1.5, state.levels.damage - 1) + * state.bonuses.damage + * (overdrive ? 2.45 : 1), + }; +} + +function updateTurrets(dt) { const cannonCount = Math.min(3, state.levels.cannon); const laneX = WORLD.lanes[state.focusLane]; focusLaneGlow.position.x = lerp(focusLaneGlow.position.x, laneX, Math.min(1, dt * 10)); focusRail.position.x = lerp(focusRail.position.x, laneX, Math.min(1, dt * 12)); - const baseInterval = 0.24 / (1 + (state.levels.rate - 1) * 0.22) / state.bonuses.rate; - const baseDamage = 22 * Math.pow(1.5, state.levels.damage - 1) * state.bonuses.damage * (overdrive ? 2.45 : 1); + const combat = currentCombatStats(); + const baseInterval = combat.fireInterval; + const baseDamage = combat.damage; const targetCount = 1 + Math.min(3, state.levels.multi); const targets = findTargets(state.focusLane, targetCount); const offsets = cannonCount === 1 ? [0] : cannonCount === 2 ? [-0.72, 0.72] : [-1.05, 0, 1.05]; @@ -1660,6 +1753,74 @@ function projectToScreen(x, y, z) { }; } +function wrapSpeechText(text, maxWidth) { + const lines = []; + let line = ''; + for (const character of [...text]) { + const candidate = `${line}${character}`; + if (line && fxCtx.measureText(candidate).width > maxWidth) { + lines.push(line); + line = character; + if (lines.length === 2) break; + } else { + line = candidate; + } + } + if (line && lines.length < 2) lines.push(line); + if (lines.length === 2 && lines.join('').length < text.length) { + while (lines[1].length > 1 && fxCtx.measureText(`${lines[1]}…`).width > maxWidth) lines[1] = lines[1].slice(0, -1); + lines[1] = `${lines[1]}…`; + } + return lines; +} + +function renderSpeechBubble(bubble, stageWidth, stageHeight) { + const enemy = bubble.enemy; + if (!enemy?.active) return; + const point = projectToScreen(enemy.x, Math.max(1.75, enemy.scale * 1.75), enemy.z); + if (!point.visible || point.x < -80 || point.x > stageWidth + 80 || point.y < 20 || point.y > stageHeight - 30) return; + const progress = clamp(bubble.life / bubble.maxLife, 0, 1); + const alpha = Math.min(1, (1 - progress) * 7, progress * 4.5); + const fontSize = stageWidth < 620 ? 10 : 12; + const maxTextWidth = stageWidth < 620 ? 126 : 176; + fxCtx.save(); + fxCtx.font = `800 ${fontSize}px Inter, system-ui, sans-serif`; + const lines = wrapSpeechText(bubble.text, maxTextWidth); + const lineHeight = fontSize + 4; + const widest = Math.max(...lines.map((line) => fxCtx.measureText(line).width), 60); + const bubbleWidth = Math.min(maxTextWidth + 22, widest + 22); + const bubbleHeight = lines.length * lineHeight + 16; + const left = clamp(point.x - bubbleWidth / 2, 8, stageWidth - bubbleWidth - 8); + const top = clamp(point.y - bubbleHeight - 18, 68, stageHeight - bubbleHeight - 22); + const tailX = clamp(point.x, left + 16, left + bubbleWidth - 16); + const accent = currentTheme().palette.accent; + fxCtx.globalAlpha = alpha; + fxCtx.fillStyle = 'rgba(4, 12, 21, 0.94)'; + fxCtx.strokeStyle = accent; + fxCtx.lineWidth = 1.4; + fxCtx.shadowColor = accent; + fxCtx.shadowBlur = 10; + fxCtx.beginPath(); + fxCtx.roundRect(left, top, bubbleWidth, bubbleHeight, 9); + fxCtx.fill(); + fxCtx.stroke(); + fxCtx.shadowBlur = 0; + fxCtx.beginPath(); + fxCtx.moveTo(tailX - 6, top + bubbleHeight - 1); + fxCtx.lineTo(tailX, top + bubbleHeight + 8); + fxCtx.lineTo(tailX + 7, top + bubbleHeight - 1); + fxCtx.closePath(); + fxCtx.fill(); + fxCtx.stroke(); + fxCtx.fillStyle = '#f4fbff'; + fxCtx.textAlign = 'center'; + fxCtx.textBaseline = 'middle'; + lines.forEach((line, index) => { + fxCtx.fillText(line, left + bubbleWidth / 2, top + 9 + lineHeight * (index + 0.5)); + }); + fxCtx.restore(); +} + function renderFx() { const width = els.stage.clientWidth; const height = els.stage.clientHeight; @@ -1708,6 +1869,8 @@ function renderFx() { } fxCtx.restore(); } + + for (const bubble of speechBubbles) renderSpeechBubble(bubble, width, height); } function showUpgrade() { @@ -1752,6 +1915,7 @@ function selectUpgrade(index) { if (!upgrade) return; const presentation = upgradePresentation(upgrade); upgrade.apply(); + state.telemetry.upgrades += 1; state.mode = 'playing'; state.lastTs = performance.now(); state.nextUpgradeAt += currentTheme().upgradeInterval; @@ -1778,6 +1942,7 @@ function updateGame(dt) { updateSpawning(dt); updateBonusSpawning(); + updateCharacterSpeech(dt); updateEnemies(dt); updateBonusTargets(dt); if (state.mode !== 'playing') return; @@ -1877,6 +2042,25 @@ function updateHud(force = false) { els.cannonCountValue.textContent = `${state.levels.cannon} / 3`; els.cannonShardValue.textContent = state.levels.cannon >= 3 ? 'MAX' : `${state.bonuses.shards} / 2`; els.bonusCountValue.textContent = `BONUS ×${state.bonuses.count}`; + const frenzyRemaining = Math.max(0, state.frenzyUntil - state.elapsed); + const overdriveRemaining = Math.max(0, state.overdriveUntil - state.elapsed); + els.frenzyBtn.classList.toggle('active', frenzyRemaining > 0); + els.overdriveBtn.classList.toggle('active', overdriveRemaining > 0); + els.bossBtn.classList.toggle('active', state.bossAlive); + els.frenzyBtn.setAttribute('aria-pressed', frenzyRemaining > 0 ? 'true' : 'false'); + els.overdriveBtn.setAttribute('aria-pressed', overdriveRemaining > 0 ? 'true' : 'false'); + els.bossBtn.setAttribute('aria-pressed', state.bossAlive ? 'true' : 'false'); + els.frenzyDescription.textContent = frenzyRemaining > 0 + ? `生效中 ${frenzyRemaining.toFixed(1)} 秒 · 实际敌潮 ×10` + : `${theme.director.frenzyDescription}${state.telemetry.frenzyUses ? ` · 已触发 ${state.telemetry.frenzyUses} 次` : ''}`; + els.overdriveDescription.textContent = overdriveRemaining > 0 + ? `生效中 ${overdriveRemaining.toFixed(1)} 秒 · 伤害 ×2.45 / 射速 ×2.4` + : `${theme.director.overdriveDescription}${state.telemetry.overdriveUses ? ` · 已触发 ${state.telemetry.overdriveUses} 次` : ''}`; + els.bossButtonDescription.textContent = state.bossAlive + ? '已登场 · 固定中路 · 仅当前路可攻击' + : state.bossSpawned + ? '本局 Boss 已处理,不能重复召唤' + : theme.director.bossDescription; els.frenzyBtn.disabled = state.mode !== 'playing'; els.overdriveBtn.disabled = state.mode !== 'playing'; els.bossBtn.disabled = state.mode !== 'playing' || state.bossSpawned; @@ -1974,6 +2158,45 @@ async function submitRun(victory) { } } +window.__TOY_TOY_TOY_DEBUG__ = Object.freeze({ + snapshot() { + const combat = currentCombatStats(); + return { + version: '0.6.0', + mode: state.mode, + theme: state.themeId, + elapsed: state.elapsed, + speed: state.speed, + focusLane: state.focusLane, + livingEnemies: livingEnemies().length, + activeBonusTargets: bonusTargets.filter((target) => target.active).map((target) => target.rewardType), + activeSpeech: speechBubbles.map((bubble) => ({ type: bubble.enemy?.type, text: bubble.text })), + effects: { + frenzyRemaining: Math.max(0, state.frenzyUntil - state.elapsed), + overdriveRemaining: Math.max(0, state.overdriveUntil - state.elapsed), + bossSpawned: state.bossSpawned, + bossAlive: state.bossAlive, + }, + combat: { + damage: combat.damage, + fireInterval: combat.fireInterval, + cannonCount: state.levels.cannon, + targetCount: 1 + Math.min(3, state.levels.multi), + }, + bonuses: { ...state.bonuses }, + levels: { ...state.levels }, + telemetry: { ...state.telemetry }, + controls: { + autoPick: els.autoPickInput.checked, + muted, + frenzyDisabled: els.frenzyBtn.disabled, + overdriveDisabled: els.overdriveBtn.disabled, + bossDisabled: els.bossBtn.disabled, + }, + }; + }, +}); + els.startBtn.addEventListener('click', startGame); els.themeButtons.forEach((button) => button.addEventListener('click', () => { if (state.mode !== 'menu') return; diff --git a/mobius/extension/toy-toy-toy/frontend/styles.css b/mobius/extension/toy-toy-toy/frontend/styles.css index 09520636..04bb8126 100644 --- a/mobius/extension/toy-toy-toy/frontend/styles.css +++ b/mobius/extension/toy-toy-toy/frontend/styles.css @@ -387,6 +387,29 @@ button { color: inherit; } .director-button:hover { transform: translateX(-2px); background: rgba(255, 255, 255, 0.075); } .director-button:disabled { opacity: 0.35; cursor: not-allowed; transform: none; } +.director-button.active { + position: relative; + opacity: 1; + background: rgba(255, 255, 255, 0.105); + box-shadow: inset 0 0 22px rgba(255, 255, 255, 0.06), 0 0 18px rgba(79, 255, 210, 0.12); + animation: directorActivePulse 0.9s ease-in-out infinite alternate; +} +.director-button.active::after { + content: "ON"; + position: absolute; + top: 5px; + right: 6px; + padding: 1px 4px; + border-radius: 999px; + background: var(--mint); + color: #041017; + font-size: 7px; + font-weight: 1000; + letter-spacing: 0.08em; +} +.director-button.danger.active { border-color: rgba(255, 95, 87, 0.72); box-shadow: 0 0 20px rgba(255, 95, 87, 0.18); } +.director-button.energy.active { border-color: rgba(255, 216, 79, 0.72); box-shadow: 0 0 20px rgba(255, 216, 79, 0.18); } +.director-button.boss.active { border-color: rgba(179, 124, 255, 0.78); box-shadow: 0 0 20px rgba(179, 124, 255, 0.22); } .director-button.danger:hover { border-color: rgba(255, 95, 87, 0.58); } .director-button.energy:hover { border-color: rgba(255, 216, 79, 0.58); } .director-button.boss:hover { border-color: rgba(179, 124, 255, 0.64); } @@ -408,6 +431,11 @@ button { color: inherit; } .director-button b { font-size: 11px; } .director-button small { color: var(--dim); font-size: 8px; line-height: 1.25; } +@keyframes directorActivePulse { + from { filter: brightness(0.96); } + to { filter: brightness(1.18); } +} + .director-setting { margin-top: 2px; padding: 9px; From d8fea045885e7365e6f4009969d030365e325e9c Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 14:26:23 +0000 Subject: [PATCH 09/30] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=9D=83=E9=99=90?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=8A=A0=E5=85=A5=E3=80=8C=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=88=90=E5=91=98=E3=80=8D=E7=9B=B4=E8=BE=BE?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=20(Add=20a=20one-click=20'manage=20members'?= =?UTF-8?q?=20entry=20in=20project=20permission=20settings=20that=20switch?= =?UTF-8?q?es=20to=20the=20Team=20tab)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复中/窄视口下「项目组」Tab 被 ProjectOverflowTabs 收进溢出菜单, 用户在权限设置卡片找不到加成员入口、误以为权限设置被删除的问题. 加成员能力统一保留在「项目组」Tab, 此处仅补可见性入口, 不恢复已退役的 inline allowlist 框. --- .../project-page/ProjectSettingsPanel.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx b/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx index 0254ed10..f58b6282 100644 --- a/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx +++ b/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type CSSProperties, type Dispatch, type ReactNode, type SetStateAction } from 'react' -import { Copy, Download, FolderOpen, MoreHorizontal, Plus, Trash2, Upload, X } from 'lucide-react' +import { Copy, Download, FolderOpen, MoreHorizontal, Plus, Trash2, Upload, Users, X } from 'lucide-react' import { ProjectUserContextWhitelist } from '../context-whitelist' import { ToggleSwitch } from '../toggle-switch' import { MemoriesManager } from '../memories' @@ -1349,9 +1349,23 @@ export function ProjectSettingsPanel({

-

- 项目可见性现为「私有 / 公开」;项目成员与角色请在顶部「项目组」标签页统一管理。 -

+ {/* 项目可见性已收敛为「私有 / 公开」, 加成员的能力统一放在「项目组」Tab. + 但顶部 Tab 在中/窄视口会被 ProjectOverflowTabs 收进「⋯」溢出菜单, + 用户在权限设置卡片里看不到入口 → 误以为"加用户功能被删了". + 这里给一个一键直达按钮绕开 Tab 可见性问题, 仍保持单一真相源在项目组. */} +
+ +

+ 项目可见性现为「私有 / 公开」。添加 / 移除项目成员与角色,请在「项目组」统一管理。 +

+ +
)} From a4932baafa5ea4036f758bfefa6dfd7b4808fa47 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 15:46:24 +0000 Subject: [PATCH 10/30] =?UTF-8?q?Add=20staggered=20arithmetic=20gates=20an?= =?UTF-8?q?d=20office=20workbench=20combat=20(=E5=A2=9E=E5=8A=A0=E9=94=99?= =?UTF-8?q?=E5=B3=B0=E7=AE=97=E6=9C=AF=E6=8C=A1=E6=9D=BF=E4=B8=8E=E5=8A=9E?= =?UTF-8?q?=E5=85=AC=E5=AE=A4=E6=95=91=E7=81=AB=E5=B7=A5=E4=BD=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobius/extension/toy-toy-toy/extension.json | 4 +- .../extension/toy-toy-toy/frontend/index.html | 8 +- mobius/extension/toy-toy-toy/frontend/main.js | 696 ++++++++++++++++-- 3 files changed, 661 insertions(+), 47 deletions(-) diff --git a/mobius/extension/toy-toy-toy/extension.json b/mobius/extension/toy-toy-toy/extension.json index 80a0023b..9a64051c 100644 --- a/mobius/extension/toy-toy-toy/extension.json +++ b/mobius/extension/toy-toy-toy/extension.json @@ -1,8 +1,8 @@ { "name": "toy-toy-toy", "display_name": "广告爽游实验室", - "description": "双题材广告爽游实验室:身份台词、横移主炮、隐藏 Bonus 与真实导演效果。", - "version": "0.6.0", + "description": "双题材广告爽游实验室:办公室救火工位、工单弹幕、错峰算术挡板与动态乘除奖励。", + "version": "0.7.0", "icon": "favicon.svg", "project": { "sync": true diff --git a/mobius/extension/toy-toy-toy/frontend/index.html b/mobius/extension/toy-toy-toy/frontend/index.html index 4c32c7c3..6e52666d 100644 --- a/mobius/extension/toy-toy-toy/frontend/index.html +++ b/mobius/extension/toy-toy-toy/frontend/index.html @@ -6,7 +6,7 @@ 广告爽游实验室 - + + diff --git a/mobius/extension/toy-toy-toy/frontend/main.js b/mobius/extension/toy-toy-toy/frontend/main.js index ba20494d..c83b6c28 100644 --- a/mobius/extension/toy-toy-toy/frontend/main.js +++ b/mobius/extension/toy-toy-toy/frontend/main.js @@ -10,6 +10,7 @@ const WORLD = Object.freeze({ maxEnemies: 700, maxProjectiles: 260, }); +const MAX_CANNONS = 8; const THEMES = Object.freeze({ zombie: { @@ -18,8 +19,8 @@ const THEMES = Object.freeze({ title: '尸潮防线', english: 'HORDE OVERDRIVE', eyebrow: '把广告里玩不到的游戏真的做出来', - description: '左右移动唯一的主炮台,决定这一秒守哪一路。打爆尸潮结界和隐藏补给,让火力、射速与炮台数量在一局里不断膨胀。', - features: ['横移主炮台', '可击破 Bonus', '永久数值叠加', '尸王演出'], + description: '左右移动主炮阵列,先割草,再在清场窗口里轰开三选一算术挡板。炮台翻倍、献祭增伤和随机法阵会不断改写这一局。', + features: ['错峰算术挡板', '炮台乘除法', '永久数值叠加', '尸王演出'], roster: ['腐烂行尸', '狂奔者', '屠夫肉盾', '变异精英', '巨型尸王'], speech: { normal: ['脑——子——在哪边?', '开门!社区送温暖!', '我只是路过吃个夜宵。', '这路怎么还有炮?'], @@ -104,8 +105,8 @@ const THEMES = Object.freeze({ title: '程序员保卫 DDL', english: 'SHIP IT OR DIE', eyebrow: '今晚不修完这些 Bug,谁都别想下班', - description: '把唯一的救火小组左右调度到前端、后端或生产。击破咖啡补给、隐藏需求和流程结界,让修复倍率一路失控。', - features: ['横移救火小组', '隐藏需求 Bonus', '永久数值叠加', '甲方 Boss'], + description: '滑动会敲键盘的救火工位,把 BUG 工单和用户反馈扔向当前服务。清场后轰开评审挡板,决定扩编、裁员提效还是赌一次随机上线。', + features: ['会敲键盘的工位', '工单反馈弹幕', '错峰评审挡板', '团队乘除法'], roster: ['开发同事', '狂奔实习生', '产品经理', '暴躁 Leader', '甲方老板'], speech: { normal: ['开发:这 Bug 不是我引入的!', '开发:我本地明明是好的。', '开发:谁动了我的分支?', '开发:先让我看一下日志。'], @@ -179,7 +180,7 @@ const THEMES = Object.freeze({ chain: ['调用链追踪', '沿调用关系跳转并修复附近 Bug'], frost: ['冻结需求', '临时冻结需求流入,为生产环境争取时间'], multi: ['多线程处理', '当前服务同时锁定更多问题并行修复'], - cannon: ['召集支援小组', '增加一个救火小组;所有小组仍只处理当前服务'], + cannon: ['召集支援小组', '增加一个会敲键盘的救火工位;所有小组仍只处理当前服务'], crit: ['一次过编译', '提高无警告通过概率,出现夸张的绿色通过数字'], repair: ['紧急回滚', '恢复服务器稳定度,并获得短暂咖啡因加成'], }, @@ -205,6 +206,8 @@ const els = { bossHpFill: document.getElementById('bossHpFill'), bonusDamageValue: document.getElementById('bonusDamageValue'), bonusRateValue: document.getElementById('bonusRateValue'), + cannonMetricLabel: document.getElementById('cannonMetricLabel'), + shardMetricLabel: document.getElementById('shardMetricLabel'), cannonCountValue: document.getElementById('cannonCountValue'), cannonShardValue: document.getElementById('cannonShardValue'), bonusCountValue: document.getElementById('bonusCountValue'), @@ -330,7 +333,12 @@ function tone(frequency, duration = 0.08, type = 'square', gainValue = 0.035, de } const sfx = { - shoot() { tone(180, 0.025, 'square', 0.008); }, + shoot() { + if (state.themeId === 'deadline') { + tone(520, 0.018, 'triangle', 0.009); + tone(760, 0.016, 'square', 0.006, 0.012); + } else tone(180, 0.025, 'square', 0.008); + }, hit() { tone(95, 0.035, 'sawtooth', 0.01); }, upgrade() { tone(440, 0.09, 'triangle', 0.04); @@ -474,17 +482,49 @@ const core = new THREE.Mesh(new THREE.CylinderGeometry(1.35, 1.8, 2.3, 8), coreM core.position.set(0, 1.2, 13.1); baseGroup.add(core); +// 程序员题材的工位需要一眼能认出“正在救火”,给每个工位挂一块会呼吸的 BUG 牌。 +const workbenchBadgeCanvas = document.createElement('canvas'); +workbenchBadgeCanvas.width = 320; +workbenchBadgeCanvas.height = 112; +const workbenchBadgeCtx = workbenchBadgeCanvas.getContext('2d'); +workbenchBadgeCtx.fillStyle = '#101c35'; +workbenchBadgeCtx.fillRect(4, 4, 312, 104); +workbenchBadgeCtx.fillStyle = '#ff4d68'; +workbenchBadgeCtx.fillRect(4, 4, 88, 104); +workbenchBadgeCtx.fillStyle = '#ffffff'; +workbenchBadgeCtx.font = '1000 36px system-ui, sans-serif'; +workbenchBadgeCtx.textAlign = 'center'; +workbenchBadgeCtx.textBaseline = 'middle'; +workbenchBadgeCtx.fillText('BUG', 48, 54); +workbenchBadgeCtx.textAlign = 'left'; +workbenchBadgeCtx.font = '1000 24px system-ui, sans-serif'; +workbenchBadgeCtx.fillText('在线救火', 108, 42); +workbenchBadgeCtx.fillStyle = '#9bd7ff'; +workbenchBadgeCtx.font = '700 17px system-ui, sans-serif'; +workbenchBadgeCtx.fillText('别让它进生产', 108, 76); +const workbenchBadgeTexture = new THREE.CanvasTexture(workbenchBadgeCanvas); +workbenchBadgeTexture.colorSpace = THREE.SRGBColorSpace; +const workbenchBadgeMaterial = new THREE.SpriteMaterial({ + map: workbenchBadgeTexture, + transparent: true, + depthTest: false, + depthWrite: false, + fog: true, + toneMapped: false, +}); + const turretGroups = []; -for (let lane = 0; lane < WORLD.lanes.length; lane += 1) { +for (let index = 0; index < MAX_CANNONS; index += 1) { const turret = new THREE.Group(); turret.position.set(0, 0, 10.2); - turret.visible = lane === 0; + turret.visible = index === 0; + const cannonModel = new THREE.Group(); const pedestal = new THREE.Mesh( new THREE.CylinderGeometry(0.72, 0.92, 0.75, 10), new THREE.MeshStandardMaterial({ color: 0x28475b, metalness: 0.72, roughness: 0.28 }), ); pedestal.position.y = 0.36; - turret.add(pedestal); + cannonModel.add(pedestal); const pivot = new THREE.Group(); pivot.position.y = 0.9; @@ -507,8 +547,77 @@ for (let lane = 0; lane < WORLD.lanes.length; lane += 1) { const barrel = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 1.85), barrelMaterial); barrel.position.z = -1.18; pivot.add(barrel); - turret.add(pivot); - turretGroups.push({ group: turret, pivot, housingMaterial, barrelMaterial, targetRotation: 0, recoil: 0 }); + cannonModel.add(pivot); + turret.add(cannonModel); + + // 程序员题材不再使用炮台:每个“炮台”变成一个会敲键盘、甩工单的移动救火工位。 + const workbenchModel = new THREE.Group(); + const desk = new THREE.Mesh( + new THREE.BoxGeometry(1.34, 0.16, 0.9), + new THREE.MeshStandardMaterial({ color: 0x254d83, metalness: 0.42, roughness: 0.36, emissive: 0x0b2d62, emissiveIntensity: 0.55 }), + ); + desk.position.y = 0.92; + workbenchModel.add(desk); + const screenPivot = new THREE.Group(); + screenPivot.position.set(0, 1.04, -0.12); + const screen = new THREE.Mesh( + new THREE.BoxGeometry(0.72, 0.5, 0.08), + new THREE.MeshStandardMaterial({ color: 0x79d8ff, emissive: 0x2388d4, emissiveIntensity: 1.2, metalness: 0.18, roughness: 0.2 }), + ); + screenPivot.add(screen); + const screenStand = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.25, 0.1), new THREE.MeshStandardMaterial({ color: 0x9cb5c7, metalness: 0.62, roughness: 0.26 })); + screenStand.position.y = -0.36; + screenPivot.add(screenStand); + workbenchModel.add(screenPivot); + const keyboard = new THREE.Mesh( + new THREE.BoxGeometry(0.58, 0.055, 0.22), + new THREE.MeshStandardMaterial({ color: 0xe9f4ff, emissive: 0x4ca9ff, emissiveIntensity: 0.28, metalness: 0.2, roughness: 0.34 }), + ); + keyboard.position.set(0.08, 1.04, 0.26); + workbenchModel.add(keyboard); + const chair = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.42, 0.15, 12), new THREE.MeshStandardMaterial({ color: 0xd66bff, metalness: 0.28, roughness: 0.42 })); + chair.position.set(0, 0.45, 0.42); + workbenchModel.add(chair); + const head = new THREE.Mesh(new THREE.SphereGeometry(0.19, 12, 10), new THREE.MeshStandardMaterial({ color: 0xffc59e, roughness: 0.66 })); + head.position.set(0, 1.45, 0.34); + workbenchModel.add(head); + const torso = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.42, 0.24), new THREE.MeshStandardMaterial({ color: 0x45f0d0, emissive: 0x116e71, emissiveIntensity: 0.4, roughness: 0.56 })); + torso.position.set(0, 1.12, 0.34); + workbenchModel.add(torso); + const arm = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.42, 0.12), new THREE.MeshStandardMaterial({ color: 0xffc59e, roughness: 0.66 })); + arm.position.set(-0.23, 1.04, 0.2); + arm.rotation.z = -0.65; + workbenchModel.add(arm); + const coffee = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 0.18, 10), new THREE.MeshStandardMaterial({ color: 0xffca5c, emissive: 0x7e4c14, emissiveIntensity: 0.45 })); + coffee.position.set(0.47, 1.12, 0.2); + workbenchModel.add(coffee); + const wheelBar = new THREE.Mesh(new THREE.BoxGeometry(0.95, 0.07, 0.1), new THREE.MeshStandardMaterial({ color: 0x7e9db9, metalness: 0.62, roughness: 0.3 })); + wheelBar.position.y = 0.12; + workbenchModel.add(wheelBar); + const badge = new THREE.Sprite(workbenchBadgeMaterial); + badge.position.set(0, 2.08, 0.02); + badge.scale.set(1.42, 0.5, 1); + badge.renderOrder = 18; + workbenchModel.add(badge); + workbenchModel.scale.setScalar(1.58); + workbenchModel.position.y = 0.62; + workbenchModel.visible = false; + turret.add(workbenchModel); + turretGroups.push({ + group: turret, + cannonModel, + workbenchModel, + pivot, + screenPivot, + keyboard, + coffee, + badge, + housingMaterial, + barrelMaterial, + targetRotation: 0, + recoil: 0, + phase: index * 0.7, + }); baseGroup.add(turret); } @@ -519,7 +628,7 @@ const enemyPlaneGeometry = new THREE.PlaneGeometry(1.95, 2.55); enemyPlaneGeometry.translate(0, 1.275, 0); function createEnemyMaterial(themeId, type) { - const texture = textureLoader.load(`./assets/characters/${themeId}-atlas.svg?v=0.6.0`); + const texture = textureLoader.load(`./assets/characters/${themeId}-atlas.svg?v=0.7.0`); texture.colorSpace = THREE.SRGBColorSpace; texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; @@ -577,12 +686,41 @@ worldGroup.add(enemyShadowMesh); const projectileGeometry = new THREE.SphereGeometry(0.13, 8, 6); const projectileMaterial = new THREE.MeshBasicMaterial({ color: 0xffe36d, toneMapped: false }); +const ticketCanvas = document.createElement('canvas'); +ticketCanvas.width = 320; +ticketCanvas.height = 200; +const ticketCtx = ticketCanvas.getContext('2d'); +ticketCtx.fillStyle = '#f5fbff'; +ticketCtx.fillRect(4, 4, 312, 192); +ticketCtx.fillStyle = '#ff526a'; +ticketCtx.fillRect(4, 4, 312, 48); +ticketCtx.fillStyle = '#ffffff'; +ticketCtx.font = '1000 30px system-ui, sans-serif'; +ticketCtx.fillText('BUG 工单', 18, 38); +ticketCtx.fillStyle = '#19324a'; +ticketCtx.font = '900 28px system-ui, sans-serif'; +ticketCtx.fillText('用户反馈', 18, 92); +ticketCtx.fillStyle = '#7a94a8'; +ticketCtx.fillRect(18, 115, 260, 10); +ticketCtx.fillRect(18, 140, 210, 10); +ticketCtx.fillRect(18, 165, 245, 10); +const ticketTexture = new THREE.CanvasTexture(ticketCanvas); +ticketTexture.colorSpace = THREE.SRGBColorSpace; +const ticketMaterial = new THREE.MeshBasicMaterial({ map: ticketTexture, transparent: true, depthWrite: false, toneMapped: false, side: THREE.DoubleSide }); const projectilePool = []; for (let i = 0; i < WORLD.maxProjectiles; i += 1) { - const mesh = new THREE.Mesh(projectileGeometry, projectileMaterial); + const mesh = new THREE.Group(); + const energy = new THREE.Mesh(projectileGeometry, projectileMaterial); + const ticket = new THREE.Group(); + const paper = new THREE.Mesh(new THREE.PlaneGeometry(0.82, 0.52), ticketMaterial); + ticket.add(paper); + ticket.rotation.x = -0.72; + ticket.scale.setScalar(1.38); + ticket.visible = false; + mesh.add(energy, ticket); mesh.visible = false; worldGroup.add(mesh); - projectilePool.push({ active: false, mesh, x: 0, y: 0, z: 0, target: null, damage: 0, lane: 0 }); + projectilePool.push({ active: false, mesh, energy, ticket, x: 0, y: 0, z: 0, target: null, damage: 0, lane: 0, spin: i * 0.17 }); } const matrixDummy = new THREE.Object3D(); @@ -590,6 +728,7 @@ const shadowDummy = new THREE.Object3D(); const enemyTint = new THREE.Color(); const enemies = []; const bonusTargets = []; +const choiceGates = []; const BONUS_CONFIG = Object.freeze({ damage: { color: 0xffc857, hp: 72, speed: 1.28, score: 320, scale: 1 }, rate: { color: 0x4fffd2, hp: 68, speed: 1.34, score: 320, scale: 1 }, @@ -615,11 +754,18 @@ const state = { comboUntil: 0, baseHp: 100, focusLane: 1, - fireAcc: [0, 0, 0], + fireAcc: Array(MAX_CANNONS).fill(0), spawnAcc: 0, nextBonusAt: 5.5, nextBarrierAt: 14, nextMysteryAt: 23, + nextGateAt: 18, + gatePhase: 'none', + gatePrepUntil: 0, + gateChoiceUntil: 0, + gateResumeAt: 0, + gateRound: 0, + lastGateEffect: '', nextSpeechAt: 2.8, nextUpgradeAt: 10, upgradeDeadline: 0, @@ -646,6 +792,8 @@ const state = { overdriveUses: 0, bossUses: 0, upgrades: 0, + gatesOffered: 0, + gatesChosen: 0, }, bonuses: { damage: 1, @@ -704,9 +852,9 @@ const upgrades = [ apply: () => { state.levels.crit += 1; }, }, { - id: 'cannon', icon: '▥', title: '炮台复制', color: '#ff7cf4', max: 3, + id: 'cannon', icon: '▥', title: '炮台复制', color: '#ff7cf4', max: MAX_CANNONS, describe: () => '增加一座并排炮台,但所有炮台始终只攻击当前战线', - apply: () => { state.levels.cannon = Math.min(3, state.levels.cannon + 1); }, + apply: () => { state.levels.cannon = Math.min(MAX_CANNONS, state.levels.cannon + 1); }, }, { id: 'repair', icon: '✚', title: '防线焊死', color: '#76ff9d', max: 99, @@ -718,6 +866,142 @@ const upgrades = [ }, ]; +const GATE_EFFECTS = Object.freeze([ + { + id: 'team_double', icon: '×2', color: 0xff72e8, hits: 18, + copy: { + zombie: ['炮台复制矩阵', '当前炮台数量 ×2,最多 8 座'], + deadline: ['团队原地扩编', '当前救火工位数量 ×2,最多 8 组'], + }, + apply() { + const before = state.levels.cannon; + state.levels.cannon = Math.min(MAX_CANNONS, Math.max(2, before * 2)); + if (state.levels.cannon === before) state.bonuses.damage *= 1.22; + return `${before} → ${state.levels.cannon}${state.levels.cannon === before ? ',已满编改为火力 ×1.22' : ''}`; + }, + }, + { + id: 'team_half', icon: '÷2', color: 0xff8a55, hits: 12, + copy: { + zombie: ['献祭半数炮台', '炮台 ÷2,但余下炮台火力 ×2.25、射速 ×1.18'], + deadline: ['裁员提效', '救火组 ÷2,但留下的人修复 ×2.25、手速 ×1.18'], + }, + apply() { + const before = state.levels.cannon; + state.levels.cannon = Math.max(1, Math.ceil(before / 2)); + state.bonuses.damage *= 2.25; + state.bonuses.rate *= 1.18; + return `${before} → ${state.levels.cannon},单组输出暴涨`; + }, + }, + { + id: 'rapid_flow', icon: '»2', color: 0x42efd2, hits: 16, + copy: { + zombie: ['供弹流水线', '永久射速 ×1.55,但火力暂时打九折'], + deadline: ['工单自动流转', '永久处理速度 ×1.55,但单张反馈力度 ×0.9'], + }, + apply() { + state.bonuses.rate *= 1.55; + state.bonuses.damage = Math.max(1, state.bonuses.damage * 0.9); + return '速度 ×1.55,火力 ×0.9'; + }, + }, + { + id: 'heavy_packet', icon: '×1.7', color: 0xffcf55, hits: 22, + copy: { + zombie: ['超重弹头', '永久火力 ×1.7,并追加 6% 暴击'], + deadline: ['高优先级反馈', '每张工单力度 ×1.7,并追加 6% 一次通过'], + }, + apply() { + state.bonuses.damage *= 1.7; + state.bonuses.crit += 0.06; + return '火力 ×1.7,暴击 +6%'; + }, + }, + { + id: 'split_queue', icon: '⑶+', color: 0x8fff65, hits: 20, + copy: { + zombie: ['弹头分叉', '多目标等级 +1,射速额外 ×1.12'], + deadline: ['反馈自动抄送', '并行目标 +1,工单流转速度 ×1.12'], + }, + apply() { + state.levels.multi = Math.min(3, state.levels.multi + 1); + state.bonuses.rate *= 1.12; + return `并行目标 ${1 + state.levels.multi},射速 ×1.12`; + }, + }, + { + id: 'swap_stats', icon: '⇄', color: 0x68b8ff, hits: 15, + copy: { + zombie: ['火力射速互换', '交换当前火力与射速倍率,并补 4% 暴击'], + deadline: ['开发测试互换', '交换当前修复力与处理速度,并补 4% 一次通过'], + }, + apply() { + const damage = state.bonuses.damage; + state.bonuses.damage = Math.max(1.05, state.bonuses.rate); + state.bonuses.rate = Math.max(1.05, damage); + state.bonuses.crit += 0.04; + return `火力 ×${state.bonuses.damage.toFixed(2)},射速 ×${state.bonuses.rate.toFixed(2)}`; + }, + }, + { + id: 'odd_even', icon: '奇偶', color: 0xb980ff, hits: 17, + copy: { + zombie: ['奇偶炮阵', '奇数炮台则 ×2;偶数炮台则 ÷2 并火力 ×1.9'], + deadline: ['奇偶编制', '奇数组则扩编 ×2;偶数组则裁半并效率 ×1.9'], + }, + apply() { + const before = state.levels.cannon; + if (before % 2 === 1) state.levels.cannon = Math.min(MAX_CANNONS, before * 2); + else { + state.levels.cannon = Math.max(1, before / 2); + state.bonuses.damage *= 1.9; + } + return before % 2 === 1 ? `${before} 为奇数:扩编至 ${state.levels.cannon}` : `${before} 为偶数:减至 ${state.levels.cannon},火力 ×1.9`; + }, + }, + { + id: 'compound_risk', icon: '+30%', color: 0xff5f7a, hits: 14, + copy: { + zombie: ['透支城墙', '基地 -12%,火力与射速同时 ×1.3'], + deadline: ['带病上线', '服务器 -12%,修复力与处理速度同时 ×1.3'], + }, + apply() { + state.baseHp = Math.max(1, state.baseHp - 12); + state.bonuses.damage *= 1.3; + state.bonuses.rate *= 1.3; + return '基地 -12%,双倍率 ×1.3'; + }, + }, + { + id: 'recovery', icon: '+25', color: 0x72ffad, hits: 13, + copy: { + zombie: ['战地回收', '基地 +25%,碎片 +1,射速 +8%'], + deadline: ['回滚成功', '服务器 +25%,团队碎片 +1,处理速度 +8%'], + }, + apply() { + state.baseHp = Math.min(100, state.baseHp + 25); + addCannonShard(1); + state.bonuses.rate *= 1.08; + return '恢复 25%,碎片 +1,射速 ×1.08'; + }, + }, + { + id: 'roulette', icon: '?', color: 0xffffff, hits: 10, + copy: { + zombie: ['未知变异门', '从其他算术效果里随机执行一个'], + deadline: ['未经评审直接上线', '从其他团队策略里随机执行一个'], + }, + apply() { + const pool = GATE_EFFECTS.filter((effect) => effect.id !== 'roulette'); + const picked = pool[Math.floor(state.random() * pool.length)]; + const detail = picked.apply(); + const title = picked.copy[currentTheme().id]?.[0] || picked.id; + return `随机命中「${title}」:${detail}`; + }, + }, +]); + function currentTheme() { return THEMES[state.themeId] || THEMES.zombie; } @@ -750,7 +1034,7 @@ function applyTheme(themeId, { persist = true, refreshLeaderboard = true } = {}) els.startDescription.textContent = theme.description; els.enemyRoster.innerHTML = theme.roster.map((name, index) => `
- + ${name}
`).join(''); @@ -761,6 +1045,8 @@ function applyTheme(themeId, { persist = true, refreshLeaderboard = true } = {}) els.leaderboardKicker.textContent = theme.leaderboardKicker; els.leaderboardTitle.textContent = theme.leaderboardTitle; els.baseStatusLabel.textContent = theme.baseLabel; + els.cannonMetricLabel.textContent = theme.id === 'deadline' ? '救火组' : '炮台'; + els.shardMetricLabel.textContent = theme.id === 'deadline' ? '团队碎片' : '碎片'; els.laneControlLabel.textContent = theme.laneControlLabel; theme.lanes.forEach((label, index) => { els.laneLabels[index].textContent = label; }); els.laneHint.textContent = theme.laneHint; @@ -787,6 +1073,7 @@ function applyTheme(themeId, { persist = true, refreshLeaderboard = true } = {}) wallMaterial.emissive.setHex(theme.palette.wallEmissive); coreMaterial.emissive.setHex(theme.palette.core); coreMaterial.color.setHex(theme.palette.wall); + core.visible = theme.id === 'zombie'; baseLight.color.setHex(theme.palette.core); projectileMaterial.color.setHex(theme.palette.projectile); focusLaneMaterial.color.setHex(theme.palette.core); @@ -794,10 +1081,16 @@ function applyTheme(themeId, { persist = true, refreshLeaderboard = true } = {}) for (const visual of Object.values(enemyVisuals)) { visual.mesh.material = visual.materials[theme.id]; } - turretGroups.forEach(({ housingMaterial, barrelMaterial }) => { + turretGroups.forEach(({ housingMaterial, barrelMaterial, cannonModel, workbenchModel }) => { housingMaterial.color.setHex(theme.palette.core); housingMaterial.emissive.setHex(theme.palette.core); barrelMaterial.emissive.setHex(theme.palette.core); + cannonModel.visible = theme.id === 'zombie'; + workbenchModel.visible = theme.id === 'deadline'; + }); + projectilePool.forEach(({ energy, ticket }) => { + energy.visible = theme.id === 'zombie'; + ticket.visible = theme.id === 'deadline'; }); els.time.textContent = String(theme.roundDuration); @@ -851,6 +1144,7 @@ function clearWorldState() { enemies.length = 0; speechBubbles.length = 0; bonusTargets.splice(0).forEach(disposeBonusTarget); + choiceGates.splice(0).forEach(disposeChoiceGate); Object.values(enemyVisuals).forEach((visual) => { visual.mesh.count = 0; }); enemyShadowMesh.count = 0; projectilePool.forEach((projectile) => { @@ -878,11 +1172,18 @@ function resetGame() { state.comboUntil = 0; state.baseHp = 100; state.focusLane = 1; - state.fireAcc = [0, 0, 0]; + state.fireAcc = Array(MAX_CANNONS).fill(0); state.spawnAcc = 0; state.nextBonusAt = 5.5; state.nextBarrierAt = 14; state.nextMysteryAt = 23; + state.nextGateAt = 18; + state.gatePhase = 'none'; + state.gatePrepUntil = 0; + state.gateChoiceUntil = 0; + state.gateResumeAt = 0; + state.gateRound = 0; + state.lastGateEffect = ''; state.nextSpeechAt = 2.8 + randomBetween(0, 1.6); state.nextUpgradeAt = theme.firstUpgradeAt; state.upgradeDeadline = 0; @@ -896,7 +1197,7 @@ function resetGame() { state.finishAt = 0; state.shake = 0; state.flash = 0; - state.telemetry = { spawned: 0, shots: 0, speech: 0, frenzyUses: 0, overdriveUses: 0, bossUses: 0, upgrades: 0 }; + state.telemetry = { spawned: 0, shots: 0, speech: 0, frenzyUses: 0, overdriveUses: 0, bossUses: 0, upgrades: 0, gatesOffered: 0, gatesChosen: 0 }; state.bonuses = { damage: 1, rate: 1, crit: 0, count: 0, shards: 0, barriers: 0 }; state.levels = { damage: 1, rate: 1, blast: 0, chain: 0, frost: 0, multi: 0, crit: 0, cannon: 1 }; wallMaterial.color.setHex(theme.palette.wall); @@ -909,8 +1210,10 @@ function resetGame() { turretGroups.forEach((turret, index) => { turret.group.visible = index === 0; turret.group.position.x = 0; + turret.group.position.z = 10.2; turret.group.scale.setScalar(1); turret.pivot.position.z = 0; + turret.keyboard.rotation.x = 0; turret.recoil = 0; }); els.bossHud.classList.add('hidden'); @@ -1036,7 +1339,7 @@ function spawnEnemy(forceType = null) { function summonBoss(manual = false) { const theme = currentTheme(); - if (state.mode !== 'playing' || state.bossSpawned || state.bossAlive || state.bossDefeated) return; + if (state.mode !== 'playing' || state.gatePhase !== 'none' || state.bossSpawned || state.bossAlive || state.bossDefeated) return; const boss = spawnEnemy('boss'); if (!boss) { showToast('战场单位已满,清出空间后才能召唤 Boss', 1800); @@ -1054,7 +1357,7 @@ function summonBoss(manual = false) { function triggerFrenzy() { const theme = currentTheme(); - if (state.mode !== 'playing') return; + if (state.mode !== 'playing' || state.gatePhase !== 'none') return; state.telemetry.frenzyUses += 1; state.frenzyUntil = Math.max(state.frenzyUntil, state.elapsed + 8); showToast(theme.director.frenzyToast); @@ -1257,6 +1560,121 @@ function disposeBonusTarget(target) { target.active = false; } +function gateBoardSprite(effect, hitsRemaining, color) { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 320; + const ctx = canvas.getContext('2d'); + const redraw = (hits) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = 'rgba(4, 10, 20, 0.95)'; + ctx.strokeStyle = `#${color.toString(16).padStart(6, '0')}`; + ctx.lineWidth = 7; + ctx.beginPath(); + ctx.roundRect(10, 10, 620, 300, 30); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = `#${color.toString(16).padStart(6, '0')}`; + ctx.font = '1000 82px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(effect.icon, 90, 92); + ctx.fillStyle = '#effaff'; + ctx.font = '1000 40px system-ui, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(effect.copy[currentTheme().id]?.[0] || effect.id, 160, 72); + ctx.fillStyle = '#9bb1c0'; + const description = effect.copy[currentTheme().id]?.[1] || ''; + let descriptionSize = 23; + do { + ctx.font = `700 ${descriptionSize}px system-ui, sans-serif`; + descriptionSize -= 1; + } while (ctx.measureText(description).width > 450 && descriptionSize > 15); + ctx.fillText(description, 160, 116); + ctx.fillStyle = '#ffffff'; + ctx.font = '1000 40px system-ui, sans-serif'; + ctx.fillText(currentTheme().id === 'deadline' ? `需要 ${hits} 份工单反馈` : `需要 ${hits} 发炮弹`, 160, 178); + ctx.fillStyle = '#ffcf55'; + ctx.font = '1000 29px system-ui, sans-serif'; + ctx.fillText(`击破后锁定这一项`, 160, 230); + }; + redraw(hitsRemaining); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, fog: true })); + sprite.scale.set(4.8, 2.4, 1); + sprite.position.y = 1.85; + sprite.renderOrder = 15; + return { sprite, texture, redraw }; +} + +function createChoiceGate(effect, lane, requiredHits) { + const color = effect.color; + const group = new THREE.Group(); + group.position.set(WORLD.lanes[lane], 0.04, -1.35); + const floor = new THREE.Mesh( + new THREE.RingGeometry(1.35, 2.2, 36), + new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.34, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide }), + ); + floor.rotation.x = -Math.PI / 2; + group.add(floor); + const wall = new THREE.Mesh( + new THREE.PlaneGeometry(4.8, 2.65), + new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.16, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide }), + ); + wall.rotation.x = -0.72; + wall.position.y = 1.25; + group.add(wall); + [-2.15, 2.15].forEach((x) => { + const pillar = new THREE.Mesh(new THREE.BoxGeometry(0.25, 2.9, 0.25), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.82, blending: THREE.AdditiveBlending })); + pillar.position.set(x, 1.4, 0); + group.add(pillar); + }); + const board = gateBoardSprite(effect, requiredHits, color); + group.add(board.sprite); + worldGroup.add(group); + const gate = { + active: true, + kind: 'gate', + effect, + lane, + x: WORLD.lanes[lane], + y: 1.2, + z: -1.35, + hitsRemaining: requiredHits, + requiredHits, + scale: 1, + wobble: randomBetween(0, Math.PI * 2), + hitUntil: 0, + group, + board, + floor, + }; + choiceGates.push(gate); + return gate; +} + +function disposeChoiceGate(gate) { + if (!gate?.group) return; + worldGroup.remove(gate.group); + gate.group.traverse((child) => { + if (!child.isMesh && !child.isSprite) return; + child.geometry?.dispose(); + const material = child.material; + if (material?.map) material.map.dispose(); + material?.dispose(); + }); + gate.active = false; +} + +function expireChoiceGate(gate, text = '挡板锁定') { + if (!gate?.active) return; + addFxText(gate.x, 1.35, gate.z, text, '#94afbf', 1.05, 12); + disposeChoiceGate(gate); + const index = choiceGates.indexOf(gate); + if (index >= 0) choiceGates.splice(index, 1); +} + function expireBonusTarget(target, missed = false) { if (!target?.active) return; if (missed) addFxText(target.x, 1.05, target.z, 'BONUS 错过', '#8ba6b8', 0.8, 11); @@ -1268,7 +1686,7 @@ function expireBonusTarget(target, missed = false) { function addCannonShard(amount) { state.bonuses.shards += amount; let gained = 0; - while (state.bonuses.shards >= 2 && state.levels.cannon < 3) { + while (state.bonuses.shards >= 2 && state.levels.cannon < MAX_CANNONS) { state.bonuses.shards -= 2; state.levels.cannon += 1; gained += 1; @@ -1322,7 +1740,120 @@ function grantBonus(target) { updateHud(true); } +function beginGatePrep() { + if (state.gatePhase !== 'none' || state.bossAlive || state.bossDefeated) return; + state.gatePhase = 'prep'; + state.gatePrepUntil = state.elapsed + 3.4; + state.frenzyUntil = Math.min(state.frenzyUntil, state.elapsed); + for (const enemy of enemies) { + if (enemy.active && enemy.type !== 'boss') enemy.slowUntil = Math.max(enemy.slowUntil, state.gatePrepUntil + 1.8); + } + for (const target of [...bonusTargets]) { + addFxText(target.x, 1.1, target.z, '选择阶段回收', '#8ba6b8', 0.85, 10); + expireBonusTarget(target); + } + showOverdriveBanner(currentTheme().id === 'deadline' ? 'REVIEW WINDOW' : 'CHOICE GATES'); + showToast(currentTheme().id === 'deadline' + ? '需求流暂停:先清掉残余同事,评审挡板即将出现' + : '尸潮暂歇:先清理残余敌人,三扇算术挡板即将出现', 2500); +} + +function spawnChoiceGates() { + let cleared = 0; + for (const enemy of enemies) { + if (!enemy.active || enemy.type === 'boss') continue; + enemy.active = false; + cleared += 1; + state.score += Math.round(enemy.score * 0.35); + if (cleared <= 18) addFxParticle(enemy.x, 0.7, enemy.z, currentTheme().palette.secondary, 0.55); + } + if (cleared) addFxText(0, 1.8, -3.5, `波次清算 ${cleared}`, currentTheme().palette.secondary, 1.25, 15); + const pool = [...GATE_EFFECTS]; + for (let index = pool.length - 1; index > 0; index -= 1) { + const swap = Math.floor(state.random() * (index + 1)); + [pool[index], pool[swap]] = [pool[swap], pool[index]]; + } + const selected = pool.slice(0, 3); + if (selected.every((effect) => effect.id !== 'team_double' && effect.id !== 'team_half' && effect.id !== 'odd_even')) { + const arithmetic = GATE_EFFECTS.filter((effect) => ['team_double', 'team_half', 'odd_even'].includes(effect.id)); + selected[Math.floor(state.random() * 3)] = arithmetic[Math.floor(state.random() * arithmetic.length)]; + } + selected.forEach((effect, lane) => { + const scaleByTeam = Math.max(0, state.levels.cannon - 1) * 1.4; + const requiredHits = Math.round(effect.hits + state.gateRound * 1.8 + scaleByTeam + randomBetween(0, 4)); + createChoiceGate(effect, lane, requiredHits); + }); + state.gatePhase = 'active'; + state.gateChoiceUntil = state.elapsed + 11; + state.gateRound += 1; + state.telemetry.gatesOffered += 3; + showToast(currentTheme().id === 'deadline' + ? '评审开始:A / D 切换工位,只能轰开一份方案' + : '三门选择开始:A / D 切路,只能击破一扇挡板', 2200); +} + +function finishGateWindow(delay = 1.25) { + state.gatePhase = 'resume'; + state.gateResumeAt = state.elapsed + delay; + state.nextGateAt = state.elapsed + 13 + randomBetween(0, 5); + state.nextBonusAt = Math.max(state.nextBonusAt, state.elapsed + 4.5); + state.nextBarrierAt = Math.max(state.nextBarrierAt, state.elapsed + 8); + state.nextMysteryAt = Math.max(state.nextMysteryAt, state.elapsed + 10); +} + +function resolveChoiceGate(gate) { + if (!gate?.active || state.gatePhase !== 'active') return; + const copy = gate.effect.copy[currentTheme().id] || gate.effect.copy.zombie; + const detail = gate.effect.apply(); + state.lastGateEffect = gate.effect.id; + state.telemetry.gatesChosen += 1; + state.bonuses.count += 1; + state.score += 1300 + state.gateRound * 260; + const color = cssHex(gate.effect.color); + addShockwave(gate.x, gate.z, color, 3.8); + for (let index = 0; index < 26; index += 1) addFxParticle(gate.x, 1.3, gate.z, color, 1.2); + for (const other of [...choiceGates]) { + if (other === gate) expireChoiceGate(other, 'CHOICE LOCKED'); + else expireChoiceGate(other, '另外两项已锁死'); + } + showOverdriveBanner(`${gate.effect.icon} ${copy[0]}`); + showToast(`${copy[0]}:${detail}`, 3100); + sfx.upgrade(); + finishGateWindow(); + updateHud(true); +} + +function updateChoiceGates(dt) { + if (state.gatePhase === 'none') { + if (state.elapsed >= state.nextGateAt && !state.bossSpawned) beginGatePrep(); + return; + } + if (state.gatePhase === 'prep') { + const living = livingEnemies().filter((enemy) => enemy.type !== 'boss').length; + if (state.elapsed >= state.gatePrepUntil && (living <= 14 || state.elapsed >= state.gatePrepUntil + 2.2)) spawnChoiceGates(); + return; + } + if (state.gatePhase === 'active') { + for (const gate of choiceGates) { + if (!gate.active) continue; + gate.wobble += dt * 2.5; + gate.group.position.y = 0.04 + Math.sin(gate.wobble) * 0.055; + gate.floor.rotation.z += dt * 0.55; + const pulse = state.elapsed < gate.hitUntil ? 1.08 : 1 + Math.sin(gate.wobble * 1.6) * 0.025; + gate.group.scale.setScalar(pulse); + } + if (state.elapsed >= state.gateChoiceUntil) { + for (const gate of [...choiceGates]) expireChoiceGate(gate, '选择超时'); + showToast('选择超时:没有获得算术效果,敌潮即将恢复', 2200); + finishGateWindow(0.8); + } + return; + } + if (state.gatePhase === 'resume' && state.elapsed >= state.gateResumeAt) state.gatePhase = 'none'; +} + function updateBonusSpawning() { + if (state.gatePhase !== 'none') return; if (state.elapsed >= state.nextBonusAt) { const lanes = [0, 1, 2].sort(() => state.random() - 0.5); const types = ['damage', 'rate', 'crit'].sort(() => state.random() - 0.5); @@ -1375,6 +1906,7 @@ function livingEnemies() { } function updateSpawning(dt) { + if (state.gatePhase !== 'none') return; const theme = currentTheme(); const progress = clamp(state.elapsed / theme.roundDuration, 0, 1); const living = livingEnemies(); @@ -1396,7 +1928,7 @@ function updateSpawning(dt) { } } - if (!state.bossSpawned && state.elapsed >= theme.bossAt) summonBoss(false); + if (!state.bossSpawned && state.elapsed >= theme.bossAt && state.gatePhase === 'none') summonBoss(false); } function updateEnemies(dt) { @@ -1428,6 +1960,10 @@ function updateEnemies(dt) { } function findTargets(lane, count = 1) { + const gates = choiceGates + .filter((gate) => gate.active && gate.lane === lane) + .sort((a, b) => b.z - a.z); + if (gates.length) return [gates[0]]; const rewards = bonusTargets .filter((target) => target.active && target.lane === lane) .sort((a, b) => b.z - a.z); @@ -1449,11 +1985,12 @@ function fireProjectile(turret, target, damage) { projectile.mesh.visible = true; projectile.x = turret.group.position.x + randomBetween(-0.14, 0.14); projectile.y = 1.02; - projectile.z = 9.1; + projectile.z = turret.group.position.z - 1.1; projectile.target = target; projectile.damage = damage; projectile.lane = state.focusLane; projectile.mesh.position.set(projectile.x, projectile.y, projectile.z); + projectile.ticket.rotation.z = randomBetween(-0.22, 0.22); turret.recoil = 1; state.telemetry.shots += 1; } @@ -1473,8 +2010,20 @@ function currentCombatStats() { }; } +function formationSlot(index, count) { + const row = count > 4 ? Math.floor(index / 4) : 0; + const rowStart = row * 4; + const rowCount = Math.min(count > 4 ? 4 : count, count - rowStart); + const column = index - rowStart; + return { + x: (column - (rowCount - 1) / 2) * 0.72, + z: row * 0.72, + scale: count > 4 ? 0.88 : count > 2 ? 0.98 : 1.1, + }; +} + function updateTurrets(dt) { - const cannonCount = Math.min(3, state.levels.cannon); + const cannonCount = Math.min(MAX_CANNONS, state.levels.cannon); const laneX = WORLD.lanes[state.focusLane]; focusLaneGlow.position.x = lerp(focusLaneGlow.position.x, laneX, Math.min(1, dt * 10)); focusRail.position.x = lerp(focusRail.position.x, laneX, Math.min(1, dt * 12)); @@ -1483,7 +2032,6 @@ function updateTurrets(dt) { const baseDamage = combat.damage; const targetCount = 1 + Math.min(3, state.levels.multi); const targets = findTargets(state.focusLane, targetCount); - const offsets = cannonCount === 1 ? [0] : cannonCount === 2 ? [-0.72, 0.72] : [-1.05, 0, 1.05]; turretGroups.forEach((turret, index) => { const active = index < cannonCount; @@ -1491,19 +2039,30 @@ function updateTurrets(dt) { if (!active) { turret.recoil = 0; turret.pivot.position.z = 0; + turret.keyboard.rotation.x = 0; return; } - const targetX = laneX + offsets[index]; + const slot = formationSlot(index, cannonCount); + const targetX = laneX + slot.x; + const targetZ = 10.2 + slot.z; turret.group.position.x = lerp(turret.group.position.x, targetX, Math.min(1, dt * 11)); + turret.group.position.z = lerp(turret.group.position.z, targetZ, Math.min(1, dt * 11)); if (targets[0]) { const dx = targets[0].x - turret.group.position.x; - const dz = targets[0].z - 10.2; + const dz = targets[0].z - turret.group.position.z; turret.targetRotation = -Math.atan2(dx, -dz); } turret.pivot.rotation.y = lerp(turret.pivot.rotation.y, turret.targetRotation, Math.min(1, dt * 9)); + turret.screenPivot.rotation.y = lerp(turret.screenPivot.rotation.y, turret.targetRotation * 0.28, Math.min(1, dt * 7)); turret.recoil = Math.max(0, turret.recoil - dt * 11); turret.pivot.position.z = turret.recoil * 0.16; - const scale = 1.14 + (state.focusLane === 1 ? 0.02 : 0); + turret.keyboard.rotation.x = -turret.recoil * 0.72; + turret.keyboard.position.y = 1.04 - turret.recoil * 0.07; + turret.coffee.rotation.z = Math.sin(state.elapsed * 7 + turret.phase) * 0.06 + turret.recoil * 0.22; + turret.workbenchModel.position.y = 0.62 + Math.abs(Math.sin(state.elapsed * 5.8 + turret.phase)) * 0.08; + const badgePulse = 1 + Math.sin(state.elapsed * 4.2 + turret.phase) * 0.045 + turret.recoil * 0.08; + turret.badge.scale.set(1.42 * badgePulse, 0.5 * badgePulse, 1); + const scale = slot.scale + (state.focusLane === 1 ? 0.02 : 0); turret.group.scale.lerp(new THREE.Vector3(scale, scale, scale), Math.min(1, dt * 8)); state.fireAcc[index] += dt; const aligned = Math.abs(turret.group.position.x - targetX) < 0.28; @@ -1534,7 +2093,7 @@ function updateProjectiles(dt) { retireProjectile(projectile); continue; } - const targetY = target.kind === 'bonus' ? 1.05 * target.scale : 0.68 * target.scale; + const targetY = target.kind === 'gate' ? 1.35 : target.kind === 'bonus' ? 1.05 * target.scale : 0.68 * target.scale; const dx = target.x - projectile.x; const dy = targetY - projectile.y; const dz = target.z - projectile.z; @@ -1552,11 +2111,35 @@ function updateProjectiles(dt) { projectile.y += (dy / distance) * step; projectile.z += (dz / distance) * step; projectile.mesh.position.set(projectile.x, projectile.y, projectile.z); + projectile.spin += dt * 8; + if (state.themeId === 'deadline') { + projectile.ticket.rotation.z = Math.sin(projectile.spin) * 0.34; + projectile.ticket.rotation.y += dt * 7.5; + } } } function applyDamage(enemy, amount, options = {}) { if (!enemy?.active) return; + if (enemy.kind === 'gate') { + if (!options.primary) return; + enemy.hitsRemaining = Math.max(0, enemy.hitsRemaining - 1); + enemy.hitUntil = state.elapsed + 0.12; + enemy.board.redraw(enemy.hitsRemaining); + enemy.board.texture.needsUpdate = true; + addFxText( + enemy.x, + 1.55, + enemy.z, + enemy.hitsRemaining > 0 ? `还差 ${enemy.hitsRemaining} 发` : '方案击穿!', + cssHex(enemy.effect.color), + 0.62, + enemy.hitsRemaining > 0 ? 12 : 17, + ); + state.shake = Math.max(state.shake, enemy.hitsRemaining > 0 ? 0.055 : 0.34); + if (enemy.hitsRemaining <= 0) resolveChoiceGate(enemy); + return; + } if (enemy.kind === 'bonus') { let bonusDamage = amount; const bonusCriticalChance = 0.06 + state.levels.crit * 0.085 + state.bonuses.crit; @@ -1940,6 +2523,7 @@ function updateGame(dt) { state.elapsed += dt; if (state.elapsed > state.comboUntil) state.combo = 1; + updateChoiceGates(dt); updateSpawning(dt); updateBonusSpawning(); updateCharacterSpeech(dt); @@ -1951,9 +2535,9 @@ function updateGame(dt) { updateShockwaves(dt); updateFx(dt); - if (state.elapsed >= state.nextUpgradeAt && !state.bossDefeated) showUpgrade(); + if (state.elapsed >= state.nextUpgradeAt && !state.bossDefeated && state.gatePhase === 'none') showUpgrade(); if (state.bossDefeated && state.finishAt && state.elapsed >= state.finishAt) endGame(true); - if (state.elapsed >= theme.roundDuration && !state.bossSpawned) summonBoss(false); + if (state.elapsed >= theme.roundDuration && !state.bossSpawned && state.gatePhase === 'none') summonBoss(false); } function endGame(victory) { @@ -2039,31 +2623,42 @@ function updateHud(force = false) { els.frostLevel.textContent = `Lv.${state.levels.frost}`; els.bonusDamageValue.textContent = `×${state.bonuses.damage.toFixed(2)}`; els.bonusRateValue.textContent = `×${state.bonuses.rate.toFixed(2)}`; - els.cannonCountValue.textContent = `${state.levels.cannon} / 3`; - els.cannonShardValue.textContent = state.levels.cannon >= 3 ? 'MAX' : `${state.bonuses.shards} / 2`; + els.cannonCountValue.textContent = `${state.levels.cannon} / ${MAX_CANNONS}`; + els.cannonShardValue.textContent = state.levels.cannon >= MAX_CANNONS ? 'MAX' : `${state.bonuses.shards} / 2`; els.bonusCountValue.textContent = `BONUS ×${state.bonuses.count}`; const frenzyRemaining = Math.max(0, state.frenzyUntil - state.elapsed); const overdriveRemaining = Math.max(0, state.overdriveUntil - state.elapsed); + const choosingGate = state.gatePhase === 'active'; + if (state.gatePhase === 'prep') els.bonusCountValue.textContent = '清场准备选择'; + else if (choosingGate) els.bonusCountValue.textContent = `算术选择 ${Math.max(0, state.gateChoiceUntil - state.elapsed).toFixed(1)}s`; + else if (state.gatePhase === 'resume') els.bonusCountValue.textContent = '敌潮即将恢复'; els.frenzyBtn.classList.toggle('active', frenzyRemaining > 0); els.overdriveBtn.classList.toggle('active', overdriveRemaining > 0); els.bossBtn.classList.toggle('active', state.bossAlive); els.frenzyBtn.setAttribute('aria-pressed', frenzyRemaining > 0 ? 'true' : 'false'); els.overdriveBtn.setAttribute('aria-pressed', overdriveRemaining > 0 ? 'true' : 'false'); els.bossBtn.setAttribute('aria-pressed', state.bossAlive ? 'true' : 'false'); - els.frenzyDescription.textContent = frenzyRemaining > 0 + els.frenzyDescription.textContent = state.gatePhase !== 'none' + ? '选择阶段锁定:尸潮已经暂停' + : frenzyRemaining > 0 ? `生效中 ${frenzyRemaining.toFixed(1)} 秒 · 实际敌潮 ×10` : `${theme.director.frenzyDescription}${state.telemetry.frenzyUses ? ` · 已触发 ${state.telemetry.frenzyUses} 次` : ''}`; els.overdriveDescription.textContent = overdriveRemaining > 0 ? `生效中 ${overdriveRemaining.toFixed(1)} 秒 · 伤害 ×2.45 / 射速 ×2.4` : `${theme.director.overdriveDescription}${state.telemetry.overdriveUses ? ` · 已触发 ${state.telemetry.overdriveUses} 次` : ''}`; - els.bossButtonDescription.textContent = state.bossAlive + els.bossButtonDescription.textContent = state.gatePhase !== 'none' + ? '选择阶段锁定:完成挡板后可召唤' + : state.bossAlive ? '已登场 · 固定中路 · 仅当前路可攻击' : state.bossSpawned ? '本局 Boss 已处理,不能重复召唤' : theme.director.bossDescription; - els.frenzyBtn.disabled = state.mode !== 'playing'; + els.laneHint.textContent = choosingGate + ? (theme.id === 'deadline' ? '评审窗口:工单只打当前一路,击穿一项后其余锁死' : '选择窗口:炮弹只打当前一路,击穿一门后其余锁死') + : theme.laneHint; + els.frenzyBtn.disabled = state.mode !== 'playing' || state.gatePhase !== 'none'; els.overdriveBtn.disabled = state.mode !== 'playing'; - els.bossBtn.disabled = state.mode !== 'playing' || state.bossSpawned; + els.bossBtn.disabled = state.mode !== 'playing' || state.gatePhase !== 'none' || state.bossSpawned; baseLight.intensity = state.elapsed < state.overdriveUntil ? 34 : 18; baseLight.color.setHex(state.elapsed < state.overdriveUntil ? 0xffd84f : state.baseHp < 30 ? 0xff5f57 : theme.palette.core); wallMaterial.emissive.setHex(state.baseHp < 30 ? 0x66141a : state.elapsed < state.overdriveUntil ? 0x5c4810 : theme.palette.wallEmissive); @@ -2162,7 +2757,7 @@ window.__TOY_TOY_TOY_DEBUG__ = Object.freeze({ snapshot() { const combat = currentCombatStats(); return { - version: '0.6.0', + version: '0.7.0', mode: state.mode, theme: state.themeId, elapsed: state.elapsed, @@ -2170,6 +2765,18 @@ window.__TOY_TOY_TOY_DEBUG__ = Object.freeze({ focusLane: state.focusLane, livingEnemies: livingEnemies().length, activeBonusTargets: bonusTargets.filter((target) => target.active).map((target) => target.rewardType), + gate: { + phase: state.gatePhase, + round: state.gateRound, + lastEffect: state.lastGateEffect, + catalog: GATE_EFFECTS.map((effect) => effect.id), + choices: choiceGates.filter((gate) => gate.active).map((gate) => ({ + id: gate.effect.id, + lane: gate.lane, + hitsRemaining: gate.hitsRemaining, + requiredHits: gate.requiredHits, + })), + }, activeSpeech: speechBubbles.map((bubble) => ({ type: bubble.enemy?.type, text: bubble.text })), effects: { frenzyRemaining: Math.max(0, state.frenzyUntil - state.elapsed), @@ -2181,8 +2788,15 @@ window.__TOY_TOY_TOY_DEBUG__ = Object.freeze({ damage: combat.damage, fireInterval: combat.fireInterval, cannonCount: state.levels.cannon, + projectileStyle: state.themeId === 'deadline' ? 'ticket-feedback' : 'energy-shell', targetCount: 1 + Math.min(3, state.levels.multi), }, + visuals: { + visibleCannons: turretGroups.filter((turret) => turret.group.visible && turret.cannonModel.visible).length, + visibleWorkbenches: turretGroups.filter((turret) => turret.group.visible && turret.workbenchModel.visible).length, + activeTickets: projectilePool.filter((projectile) => projectile.active && projectile.ticket.visible).length, + activeShells: projectilePool.filter((projectile) => projectile.active && projectile.energy.visible).length, + }, bonuses: { ...state.bonuses }, levels: { ...state.levels }, telemetry: { ...state.telemetry }, From 84d54f5097e15cfc1d94c86507d1a96f00e76161 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 16:20:38 +0000 Subject: [PATCH 11/30] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=9D=83=E9=99=90?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=8D=A1=E5=86=85=E5=B5=8C=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=88=90=E5=91=98=E7=AE=A1=E7=90=86=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC=E9=A1=B9=E7=9B=AE=E7=BB=84tab=20(Inline=20me?= =?UTF-8?q?mber=20management=20in=20the=20permission=20settings=20card,=20?= =?UTF-8?q?replacing=20the=20jump-to-Team-tab=20button)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈: 权限设置卡的「管理项目成员」按钮(d8fea04 加的)要点了跳转到项目组tab 才能管成员, 麻烦. 改为直接内嵌 ProjectTeamPanel(与主页编辑项目弹窗 modals.tsx 一致), 折叠展开即可加/改/删成员, 无需跳转. 移除跳转按钮与不再使用的 Users 图标 import. 项目组tab 保留作直达入口. --- .../project-page/ProjectSettingsPanel.tsx | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx b/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx index f58b6282..3e23767e 100644 --- a/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx +++ b/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type CSSProperties, type Dispatch, type ReactNode, type SetStateAction } from 'react' -import { Copy, Download, FolderOpen, MoreHorizontal, Plus, Trash2, Upload, Users, X } from 'lucide-react' +import { Copy, Download, FolderOpen, MoreHorizontal, Plus, Trash2, Upload, X } from 'lucide-react' import { ProjectUserContextWhitelist } from '../context-whitelist' import { ToggleSwitch } from '../toggle-switch' import { MemoriesManager } from '../memories' @@ -1349,23 +1349,16 @@ export function ProjectSettingsPanel({

- {/* 项目可见性已收敛为「私有 / 公开」, 加成员的能力统一放在「项目组」Tab. - 但顶部 Tab 在中/窄视口会被 ProjectOverflowTabs 收进「⋯」溢出菜单, - 用户在权限设置卡片里看不到入口 → 误以为"加用户功能被删了". - 这里给一个一键直达按钮绕开 Tab 可见性问题, 仍保持单一真相源在项目组. */} -
- -

- 项目可见性现为「私有 / 公开」。添加 / 移除项目成员与角色,请在「项目组」统一管理。 -

- -
+ {/* 项目成员管理直接内嵌在此 (与主页「编辑项目」弹窗 modals.tsx 一致) —— + 用户要求权限设置卡能直接加 / 改 / 删成员, 不再跳转到「项目组」tab. */} +
+ + 项目成员 + +
+ +
+
)} From e2fac1395d517dc116fbf68f4e3c0ad255be6143 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 16:29:43 +0000 Subject: [PATCH 12/30] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=9D=83=E9=99=90?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=8D=A1=E4=B8=8A=E7=A7=BB=E5=88=B0=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=A8=A1=E5=9E=8B=E5=81=8F=E5=A5=BD=E4=B9=8B=E5=90=8E?= =?UTF-8?q?=20(Move=20project=20permission=20settings=20card=20up=20to=20r?= =?UTF-8?q?ight=20after=20default=20model=20preference)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈权限设置卡原来位置太靠后(在巡检设置之后、危险操作之前), 不易找; 上移到「默认模型偏好」卡之后、「项目外观」之前, 让权限/成员管理更靠前。仅移动卡片位置, 内容不变(可见性+开关+内嵌项目成员管理)。 --- .../project-page/ProjectSettingsPanel.tsx | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx b/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx index 3e23767e..29a154d8 100644 --- a/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx +++ b/mobius/frontend/src/components/project-page/ProjectSettingsPanel.tsx @@ -1113,6 +1113,71 @@ export function ProjectSettingsPanel({ )} + {project.kind === 'extension' ? null : ( + +
+ +
+ {PROJECT_VISIBILITY_OPTIONS.map((option) => { + const active = editVisibility === option.value + return ( + + ) + })} +
+

+ {PROJECT_VISIBILITY_OPTIONS.find(option => option.value === editVisibility)?.description} +

+
+
+
+
创建任务单
+ +
+
+
启动执行会话
+ +
+
+

+ 仅影响非项目所有者;项目设为「私有」时不生效。 +

+
+
+ {/* 项目成员管理直接内嵌在此 (与主页「编辑项目」弹窗 modals.tsx 一致) —— + 用户要求权限设置卡能直接加 / 改 / 删成员, 不再跳转到「项目组」tab. */} +
+ + 项目成员 + +
+ +
+
+
+ )} + - {project.kind === 'extension' ? null : ( - -
- -
- {PROJECT_VISIBILITY_OPTIONS.map((option) => { - const active = editVisibility === option.value - return ( - - ) - })} -
-

- {PROJECT_VISIBILITY_OPTIONS.find(option => option.value === editVisibility)?.description} -

-
-
-
-
创建任务单
- -
-
-
启动执行会话
- -
-
-

- 仅影响非项目所有者;项目设为「私有」时不生效。 -

-
-
- {/* 项目成员管理直接内嵌在此 (与主页「编辑项目」弹窗 modals.tsx 一致) —— - 用户要求权限设置卡能直接加 / 改 / 删成员, 不再跳转到「项目组」tab. */} -
- - 项目成员 - -
- -
-
-
- )} - {canDeleteProject && project.kind !== 'extension' && (
From d69dcc53f01a5db3e4776ddf54a5dabc3f3ae8e4 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 16:31:42 +0000 Subject: [PATCH 13/30] =?UTF-8?q?Add=20ten-level=20campaigns=20and=20expon?= =?UTF-8?q?ential=20boss=20progression=20(=E5=A2=9E=E5=8A=A0=E5=8D=81?= =?UTF-8?q?=E5=85=B3=E6=88=98=E5=BD=B9=E4=B8=8E=E6=8C=87=E6=95=B0Boss?= =?UTF-8?q?=E6=88=90=E9=95=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/extension_backend_handler.js | 6 +- mobius/extension/toy-toy-toy/extension.json | 4 +- .../extension/toy-toy-toy/frontend/index.html | 26 +- mobius/extension/toy-toy-toy/frontend/main.js | 497 ++++++++++++------ .../extension/toy-toy-toy/frontend/styles.css | 82 ++- 5 files changed, 441 insertions(+), 174 deletions(-) diff --git a/mobius/extension/toy-toy-toy/backend/extension_backend_handler.js b/mobius/extension/toy-toy-toy/backend/extension_backend_handler.js index b6c13977..91ee8870 100644 --- a/mobius/extension/toy-toy-toy/backend/extension_backend_handler.js +++ b/mobius/extension/toy-toy-toy/backend/extension_backend_handler.js @@ -50,6 +50,7 @@ function publicRow(row, rank) { display_name: row.display_name || row.username, score: row.score, kills: row.kills, + level: finiteInt(row.level, 1, 10) || 1, victory: Boolean(row.victory), runs: row.runs || 1, ts: row.ts, @@ -94,7 +95,8 @@ module.exports = async function toyToyToyHandler({ const score = finiteInt(payload.score, 0, MAX_SCORE); const kills = finiteInt(payload.kills, 0, MAX_KILLS); const duration = finiteInt(payload.duration, 0, MAX_DURATION); - if (score === null || kills === null || duration === null) { + const level = finiteInt(payload.level === undefined ? 1 : payload.level, 1, 10); + if (score === null || kills === null || duration === null || level === null) { return { ok: false, error: 'invalid run result' }; } @@ -108,6 +110,7 @@ module.exports = async function toyToyToyHandler({ score, kills, duration, + level, victory: payload.victory === true, runs: (existing && finiteInt(existing.runs, 1, 1_000_000)) || 0, ts: now, @@ -123,6 +126,7 @@ module.exports = async function toyToyToyHandler({ existing.runs = result.runs; existing.last_score = score; existing.last_kills = kills; + existing.last_level = level; existing.last_victory = result.victory; existing.last_ts = now; existing.display_name = result.display_name; diff --git a/mobius/extension/toy-toy-toy/extension.json b/mobius/extension/toy-toy-toy/extension.json index 9a64051c..649b0208 100644 --- a/mobius/extension/toy-toy-toy/extension.json +++ b/mobius/extension/toy-toy-toy/extension.json @@ -1,8 +1,8 @@ { "name": "toy-toy-toy", "display_name": "广告爽游实验室", - "description": "双题材广告爽游实验室:办公室救火工位、工单弹幕、错峰算术挡板与动态乘除奖励。", - "version": "0.7.0", + "description": "双题材十关广告爽游:随队算术门、扩展角色编成与指数生命终局 Boss。", + "version": "0.8.0", "icon": "favicon.svg", "project": { "sync": true diff --git a/mobius/extension/toy-toy-toy/frontend/index.html b/mobius/extension/toy-toy-toy/frontend/index.html index 6e52666d..1bbfe3c3 100644 --- a/mobius/extension/toy-toy-toy/frontend/index.html +++ b/mobius/extension/toy-toy-toy/frontend/index.html @@ -6,7 +6,7 @@ 广告爽游实验室 - + + diff --git a/mobius/extension/toy-toy-toy/frontend/main.js b/mobius/extension/toy-toy-toy/frontend/main.js index c83b6c28..9075d97f 100644 --- a/mobius/extension/toy-toy-toy/frontend/main.js +++ b/mobius/extension/toy-toy-toy/frontend/main.js @@ -11,6 +11,7 @@ const WORLD = Object.freeze({ maxProjectiles: 260, }); const MAX_CANNONS = 8; +const BOSS_HP_GROWTH = 1.72; const THEMES = Object.freeze({ zombie: { @@ -19,8 +20,8 @@ const THEMES = Object.freeze({ title: '尸潮防线', english: 'HORDE OVERDRIVE', eyebrow: '把广告里玩不到的游戏真的做出来', - description: '左右移动主炮阵列,先割草,再在清场窗口里轰开三选一算术挡板。炮台翻倍、献祭增伤和随机法阵会不断改写这一局。', - features: ['错峰算术挡板', '炮台乘除法', '永久数值叠加', '尸王演出'], + description: '左右移动主炮阵列,在尸群中识别高频三路算术门。每次乘除、交换与武器特效选择都会决定指数生命 Boss 能不能被打穿。', + features: ['10 关尸城战役', '随队算术门', '指数生命 Boss', '精英角色混编'], roster: ['腐烂行尸', '狂奔者', '屠夫肉盾', '变异精英', '巨型尸王'], speech: { normal: ['脑——子——在哪边?', '开门!社区送温暖!', '我只是路过吃个夜宵。', '这路怎么还有炮?'], @@ -68,7 +69,7 @@ const THEMES = Object.freeze({ director: { frenzyIcon: '☣', frenzyLabel: '十倍尸潮', frenzyDescription: '8 秒敌潮 ×10,敌人变脆', overdriveIcon: '⚡', overdriveLabel: '火力超载', overdriveDescription: '10 秒伤害 ×2.45、射速 ×2.4', - bossIcon: '♛', bossLabel: '尸王立即登场', bossDescription: '不用等到最后', + bossIcon: '♛', bossLabel: '尸王立即登场', bossDescription: '提前挑战本关指数生命 Boss', frenzyToast: '十倍尸潮已启动:密度拉满,但敌人会稍微变脆', frenzyBanner: 'TENFOLD HORDE', overdriveToast: '火力超载:伤害与射速暴涨 10 秒', @@ -80,7 +81,7 @@ const THEMES = Object.freeze({ bossBanner: 'OMEGA BOSS INBOUND', }, bossName: '巨型尸王 · OMEGA', - openingToast: '主炮台只打一条路:A / D 横移,优先抢下会发光的 Bonus 与结界', + openingToast: '主炮台只打一条路:A / D 横移;算术门会混在尸群里,选对构筑才能击杀 Boss', victoryTitle: '防线守住了', victoryDescription: '广告里的那一局,这次真的打完了。你可以直接重开,或者继续用导演台折腾下一局。', defeatTitle: '城墙被吃光了', @@ -105,8 +106,8 @@ const THEMES = Object.freeze({ title: '程序员保卫 DDL', english: 'SHIP IT OR DIE', eyebrow: '今晚不修完这些 Bug,谁都别想下班', - description: '滑动会敲键盘的救火工位,把 BUG 工单和用户反馈扔向当前服务。清场后轰开评审挡板,决定扩编、裁员提效还是赌一次随机上线。', - features: ['会敲键盘的工位', '工单反馈弹幕', '错峰评审挡板', '团队乘除法'], + description: '调度会敲键盘的救火工位,在需求队伍中识别高频评审算术门。每次扩编、裁员、冻结或调用链选择都会决定最终上线能否成功。', + features: ['10 关上线战役', '随队评审门', '指数需求 Boss', '办公室角色混编'], roster: ['开发同事', '狂奔实习生', '产品经理', '暴躁 Leader', '甲方老板'], speech: { normal: ['开发:这 Bug 不是我引入的!', '开发:我本地明明是好的。', '开发:谁动了我的分支?', '开发:先让我看一下日志。'], @@ -154,7 +155,7 @@ const THEMES = Object.freeze({ director: { frenzyIcon: '⚠', frenzyLabel: '需求井喷', frenzyDescription: '8 秒需求量 ×10,需求变脆', overdriveIcon: '☕', overdriveLabel: '咖啡续命', overdriveDescription: '10 秒修复 ×2.45、处理速度 ×2.4', - bossIcon: '☎', bossLabel: '甲方立即来电', bossDescription: '提前触发最终需求', + bossIcon: '☎', bossLabel: '甲方立即来电', bossDescription: '提前触发本关指数需求 Boss', frenzyToast: '群聊里突然多了 99+ 条新需求:需求井喷已启动', frenzyBanner: 'SCOPE CREEP ×10', overdriveToast: '咖啡因超频:编译与热修速度暴涨 10 秒', @@ -166,7 +167,7 @@ const THEMES = Object.freeze({ bossBanner: 'CLIENT CALL INBOUND', }, bossName: '上线前临时改需求 · FINAL', - openingToast: '救火小组一次只修一个服务:A / D 调度,优先抢咖啡、补丁与隐藏需求', + openingToast: '救火小组一次只修一个服务:A / D 调度;评审门混在需求里,选对构筑才能拒绝甲方', victoryTitle: '居然准时上线了', victoryDescription: '所有 Bug 被压进了发布包,临时需求也被当场打回。现在可以再模拟一次更离谱的上线夜。', defeatTitle: '生产环境炸了', @@ -187,12 +188,68 @@ const THEMES = Object.freeze({ }, }); +// 两个题材各自拥有 10 关。角色复用五套高质量剪影,但通过体型、颜色、速度、生命和身份台词形成更多可辨认角色。 +const ENEMY_ROLES = Object.freeze({ + zombie: { + shambler: { name: '腐烂行尸', visual: 'normal', hp: 1, speed: 0.94, scale: 1, score: 11, damage: 5, tint: 0xffffff, weight: 34, lines: ['行尸:我就散个步。', '行尸:这条路以前没炮。'] }, + crawler: { name: '贴地爬尸', visual: 'normal', hp: 0.58, speed: 1.48, scale: 0.72, score: 13, damage: 4, tint: 0xb6ff9b, weight: 19, lines: ['爬尸:低姿态也要挨炮?', '爬尸:我从地板下面来的。'] }, + sprinter: { name: '红眼狂奔者', visual: 'runner', hp: 0.66, speed: 1.72, scale: 0.76, score: 16, damage: 5, tint: 0xffd28a, weight: 18, lines: ['狂奔者:刹车坏了!', '狂奔者:前面的让一让!'] }, + spitter: { name: '酸液喷吐者', visual: 'runner', hp: 1.45, speed: 0.88, scale: 1.08, score: 26, damage: 8, tint: 0x9cff75, weight: 10, lines: ['喷吐者:请保持酸性距离。', '喷吐者:今天胃不太舒服。'] }, + bloater: { name: '腐肉胖尸', visual: 'tank', hp: 3.3, speed: 0.55, scale: 1.44, score: 38, damage: 13, tint: 0xd8bdff, weight: 12, lines: ['胖尸:我只是骨架比较大。', '胖尸:炮弹能不能少放辣?'] }, + armored: { name: '装甲防暴尸', visual: 'tank', hp: 5.1, speed: 0.45, scale: 1.6, score: 58, damage: 16, tint: 0xa7c6d8, weight: 8, lines: ['装甲尸:盾牌是单位发的。', '装甲尸:今天谁都别想通关。'] }, + mutant: { name: '双臂变异体', visual: 'elite', hp: 6.1, speed: 0.74, scale: 1.72, score: 105, damage: 20, tint: 0xff8ea1, weight: 7, lines: ['变异体:普通僵尸靠边!', '变异体:我有两倍的拥抱。'] }, + screamer: { name: '尖啸女尸', visual: 'elite', hp: 4.8, speed: 0.96, scale: 1.48, score: 96, damage: 18, tint: 0xff82e7, weight: 7, lines: ['尖啸者:啊——麦克风开了吗?', '尖啸者:这只是我的高音。'] }, + nestGuard: { name: '尸巢守卫', visual: 'elite', hp: 8.8, speed: 0.54, scale: 2.05, score: 155, damage: 24, tint: 0xe776ff, weight: 5, lines: ['守卫:母巢禁止参观!', '守卫:先过我这一吨。'] }, + alpha: { name: '阿尔法尸将', visual: 'elite', hp: 12.5, speed: 0.43, scale: 2.35, score: 240, damage: 30, tint: 0xff536d, weight: 4, lines: ['尸将:这一波由我带队。', '尸将:炮台数量报一下。'] }, + }, + deadline: { + bug: { name: '普通线上 Bug', visual: 'normal', hp: 1, speed: 0.96, scale: 1, score: 11, damage: 5, tint: 0xffffff, weight: 32, lines: ['Bug:我本地无法复现。', 'Bug:我已经存在三年了。'] }, + intern: { name: '直推生产实习生', visual: 'runner', hp: 0.63, speed: 1.76, scale: 0.76, score: 17, damage: 5, tint: 0xffd073, weight: 18, lines: ['实习生:我直接推生产啦!', '实习生:回滚按钮在哪儿?'] }, + qa: { name: '穷举测试同事', visual: 'normal', hp: 1.32, speed: 0.9, scale: 1.08, score: 22, damage: 7, tint: 0x85e9ff, weight: 16, lines: ['测试:我又发现了 37 个。', '测试:这不是偶现,是必现。'] }, + product: { name: '五彩斑斓产品经理', visual: 'tank', hp: 3.4, speed: 0.56, scale: 1.44, score: 40, damage: 13, tint: 0x9b92ff, weight: 12, lines: ['产品:这个需求很简单。', '产品:只改亿点点。'] }, + ops: { name: '报警轰炸运维', visual: 'runner', hp: 1.75, speed: 1.08, scale: 1.05, score: 34, damage: 9, tint: 0xffa66b, weight: 10, lines: ['运维:报警群已经 99+!', '运维:磁盘又满了!'] }, + architect: { name: '重构架构师', visual: 'tank', hp: 5.3, speed: 0.46, scale: 1.6, score: 64, damage: 17, tint: 0x8ac7ff, weight: 8, lines: ['架构师:我们先重写一遍。', '架构师:这个抽象还不够纯。'] }, + security: { name: '安全审计专家', visual: 'elite', hp: 6.4, speed: 0.72, scale: 1.7, score: 112, damage: 21, tint: 0xd389ff, weight: 7, lines: ['安全:这里有高危漏洞。', '安全:先全部下线再说。'] }, + leader: { name: '暴躁技术 Leader', visual: 'elite', hp: 5, speed: 0.94, scale: 1.5, score: 102, damage: 19, tint: 0xff7bb7, weight: 7, lines: ['Leader:今晚必须上线!', 'Leader:为什么还是 99%?'] }, + clientRep: { name: '驻场甲方代表', visual: 'elite', hp: 9.2, speed: 0.53, scale: 2.02, score: 165, damage: 25, tint: 0xff7292, weight: 5, lines: ['甲方代表:我再加一个小需求。', '甲方代表:原型不是能点了吗?'] }, + executive: { name: '拍脑袋业务总监', visual: 'elite', hp: 13, speed: 0.42, scale: 2.34, score: 250, damage: 31, tint: 0xff4f7e, weight: 4, lines: ['总监:明早我要全球上线。', '总监:技术问题你们解决。'] }, + }, +}); + +const CAMPAIGNS = Object.freeze({ + zombie: [ + { title: '封锁线外缘', description: '基础尸群,熟悉三路火力和随队推进的算术门。', duration: 64, bossAt: 45, spawn: 0.82, hp: 0.72, speed: 0.9, bossHp: 1, roles: ['shambler', 'crawler', 'sprinter'], boss: '门卫尸长 · 大门牙', bossTint: 0xff6f65, bossScale: 3.05 }, + { title: '废弃便利店', description: '腐肉胖尸开始顶在队伍前面,错误选择会明显漏怪。', duration: 68, bossAt: 48, spawn: 0.9, hp: 0.82, speed: 0.93, bossHp: 1.05, roles: ['shambler', 'crawler', 'sprinter', 'bloater'], boss: '冰柜屠夫 · FROZEN', bossTint: 0x9ddfff, bossScale: 3.15 }, + { title: '地铁末班车', description: '狂奔者和喷吐者混编,要求更快切换攻击路线。', duration: 72, bossAt: 51, spawn: 0.98, hp: 0.92, speed: 0.97, bossHp: 1.1, roles: ['shambler', 'sprinter', 'spitter', 'bloater'], boss: '站台尖啸者 · LINE 13', bossTint: 0xff78dc, bossScale: 3.25 }, + { title: '医院夜班', description: '装甲尸出现,算术选择开始决定能否穿透前排。', duration: 76, bossAt: 54, spawn: 1.05, hp: 1.02, speed: 1, bossHp: 1.16, roles: ['crawler', 'spitter', 'bloater', 'armored'], boss: '缝合护士长 · NIGHT SHIFT', bossTint: 0xd8c2ff, bossScale: 3.35 }, + { title: '高速收费站', description: '变异精英加入冲线,炮台数量和单发火力需要取舍。', duration: 80, bossAt: 57, spawn: 1.12, hp: 1.12, speed: 1.03, bossHp: 1.22, roles: ['shambler', 'sprinter', 'armored', 'mutant'], boss: '收费站暴君 · NO EXIT', bossTint: 0xff685f, bossScale: 3.45 }, + { title: '地下实验室', description: '尖啸者和变异体成群出现,错误构筑会被精英压垮。', duration: 84, bossAt: 60, spawn: 1.2, hp: 1.24, speed: 1.06, bossHp: 1.3, roles: ['spitter', 'armored', 'mutant', 'screamer'], boss: '失控实验体 · SUBJECT 06', bossTint: 0xd35bff, bossScale: 3.55 }, + { title: '工业尸巢', description: '尸巢守卫进入战场,需要成型的爆炸、连锁或分裂构筑。', duration: 88, bossAt: 63, spawn: 1.28, hp: 1.36, speed: 1.08, bossHp: 1.38, roles: ['bloater', 'mutant', 'screamer', 'nestGuard'], boss: '孵化母体 · HIVE MOTHER', bossTint: 0xff55c8, bossScale: 3.65 }, + { title: '军事封锁区', description: '装甲精英密集推进,Boss 生命正式进入指数区间。', duration: 92, bossAt: 66, spawn: 1.36, hp: 1.48, speed: 1.1, bossHp: 1.46, roles: ['armored', 'mutant', 'nestGuard', 'alpha'], boss: '装甲尸将 · WARLORD', bossTint: 0xff514f, bossScale: 3.78 }, + { title: '核心尸城', description: '高阶角色全量混编,必须围绕前几次选择规划终局。', duration: 97, bossAt: 70, spawn: 1.46, hp: 1.62, speed: 1.12, bossHp: 1.56, roles: ['spitter', 'armored', 'screamer', 'nestGuard', 'alpha'], boss: '双头尸皇 · TWIN CROWN', bossTint: 0xff3d72, bossScale: 3.92 }, + { title: '终焉防线', description: '最终试炼:只有连续做对算术选择,才有机会击穿尸王。', duration: 104, bossAt: 76, spawn: 1.58, hp: 1.78, speed: 1.15, bossHp: 1.68, roles: ['mutant', 'screamer', 'nestGuard', 'alpha'], boss: '巨型尸王 · OMEGA', bossTint: 0xff2e4f, bossScale: 4.15 }, + ], + deadline: [ + { title: '本地开发', description: '普通 Bug 与直推实习生,先熟悉工单算术门。', duration: 62, bossAt: 44, spawn: 0.86, hp: 0.68, speed: 0.93, bossHp: 0.96, roles: ['bug', 'intern', 'qa'], boss: '合并冲突 · FIRST BLOOD', bossTint: 0xff7182, bossScale: 3.05 }, + { title: '测试环境', description: '测试同事不断补单,产品经理开始作为肉盾推进。', duration: 66, bossAt: 47, spawn: 0.94, hp: 0.78, speed: 0.97, bossHp: 1.02, roles: ['bug', 'intern', 'qa', 'product'], boss: '回归测试清单 · 999+', bossTint: 0x8b9cff, bossScale: 3.14 }, + { title: '三方联调', description: '报警运维加入战场,反馈流速明显加快。', duration: 70, bossAt: 50, spawn: 1.02, hp: 0.88, speed: 1, bossHp: 1.08, roles: ['bug', 'qa', 'product', 'ops'], boss: '接口字段改名 · V2 FINAL', bossTint: 0xffa25f, bossScale: 3.24 }, + { title: '需求评审', description: '产品与架构师组成厚血前排,需要重新评估团队编制。', duration: 74, bossAt: 53, spawn: 1.1, hp: 0.98, speed: 1.03, bossHp: 1.14, roles: ['intern', 'product', 'ops', 'architect'], boss: '五彩斑斓 PRD · 88 页', bossTint: 0xac8cff, bossScale: 3.34 }, + { title: '灰度发布', description: '安全审计首次出现,单纯堆射速已经不够。', duration: 78, bossAt: 56, spawn: 1.18, hp: 1.08, speed: 1.06, bossHp: 1.2, roles: ['qa', 'ops', 'architect', 'security'], boss: '灰度异常 · 1% 用户全炸', bossTint: 0xd474ff, bossScale: 3.44 }, + { title: '大促前夜', description: 'Leader 和报警一起到场,选择错误会拖垮生产稳定度。', duration: 82, bossAt: 59, spawn: 1.26, hp: 1.2, speed: 1.08, bossHp: 1.28, roles: ['product', 'ops', 'security', 'leader'], boss: '零点大促 · TRAFFIC ×100', bossTint: 0xff6da8, bossScale: 3.54 }, + { title: '生产事故', description: '驻场甲方加入精英波次,工单构筑必须开始成型。', duration: 86, bossAt: 62, spawn: 1.34, hp: 1.32, speed: 1.1, bossHp: 1.36, roles: ['architect', 'security', 'leader', 'clientRep'], boss: '生产全红 · SEV-0', bossTint: 0xff4f68, bossScale: 3.64 }, + { title: '安全审计', description: '高血量审计与甲方代表混编,Boss 生命进入指数区。', duration: 90, bossAt: 65, spawn: 1.42, hp: 1.44, speed: 1.12, bossHp: 1.44, roles: ['ops', 'security', 'leader', 'clientRep'], boss: '合规整改 · DEADLINE TODAY', bossTint: 0xe154ff, bossScale: 3.76 }, + { title: '董事会 Demo', description: '业务总监加入战线,每一次算术选择都在决定演示生死。', duration: 96, bossAt: 69, spawn: 1.52, hp: 1.58, speed: 1.14, bossHp: 1.54, roles: ['security', 'leader', 'clientRep', 'executive'], boss: '董事会临时演示 · LIVE', bossTint: 0xff3f88, bossScale: 3.9 }, + { title: '全球上线', description: '最终试炼:必须形成指数级工单输出,才能拒绝最终需求。', duration: 102, bossAt: 75, spawn: 1.64, hp: 1.74, speed: 1.17, bossHp: 1.66, roles: ['architect', 'leader', 'clientRep', 'executive'], boss: '全球上线前临时改需求 · FINAL', bossTint: 0xff285f, bossScale: 4.12 }, + ], +}); + const els = { shell: document.getElementById('gameShell'), stage: document.getElementById('stage'), fxCanvas: document.getElementById('fxCanvas'), brandKicker: document.getElementById('brandKicker'), brandTitle: document.getElementById('brandTitle'), + level: document.getElementById('levelValue'), score: document.getElementById('scoreValue'), kills: document.getElementById('killsValue'), combo: document.getElementById('comboValue'), @@ -254,6 +311,11 @@ const els = { enemyRoster: document.getElementById('enemyRoster'), featureRow: document.getElementById('featureRow'), themeButtons: [...document.querySelectorAll('.theme-card')], + levelPicker: document.getElementById('levelPicker'), + levelTitle: document.getElementById('levelTitle'), + levelDescription: document.getElementById('levelDescription'), + levelEnemyHint: document.getElementById('levelEnemyHint'), + levelBossHint: document.getElementById('levelBossHint'), startBtn: document.getElementById('startBtn'), startButtonLabel: document.getElementById('startButtonLabel'), startButtonHint: document.getElementById('startButtonHint'), @@ -281,6 +343,7 @@ const els = { finalRank: document.getElementById('finalRank'), newBestBadge: document.getElementById('newBestBadge'), againBtn: document.getElementById('againBtn'), + againButtonLabel: document.getElementById('againButtonLabel'), menuBtn: document.getElementById('menuBtn'), }; @@ -628,7 +691,7 @@ const enemyPlaneGeometry = new THREE.PlaneGeometry(1.95, 2.55); enemyPlaneGeometry.translate(0, 1.275, 0); function createEnemyMaterial(themeId, type) { - const texture = textureLoader.load(`./assets/characters/${themeId}-atlas.svg?v=0.7.0`); + const texture = textureLoader.load(`./assets/characters/${themeId}-atlas.svg?v=0.8.0`); texture.colorSpace = THREE.SRGBColorSpace; texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; @@ -744,6 +807,8 @@ const speechBubbles = []; const state = { mode: 'menu', themeId: THEMES[localStorage.getItem('toy-toy-toy-theme')] ? localStorage.getItem('toy-toy-toy-theme') : 'zombie', + level: 1, + lastVictory: false, seed: 0, random: Math.random, elapsed: 0, @@ -759,7 +824,7 @@ const state = { nextBonusAt: 5.5, nextBarrierAt: 14, nextMysteryAt: 23, - nextGateAt: 18, + nextGateAt: 8.5, gatePhase: 'none', gatePrepUntil: 0, gateChoiceUntil: 0, @@ -767,7 +832,7 @@ const state = { gateRound: 0, lastGateEffect: '', nextSpeechAt: 2.8, - nextUpgradeAt: 10, + nextUpgradeAt: Number.POSITIVE_INFINITY, upgradeDeadline: 0, currentUpgrades: [], speed: 1, @@ -930,6 +995,54 @@ const GATE_EFFECTS = Object.freeze([ return `并行目标 ${1 + state.levels.multi},射速 ×1.12`; }, }, + { + id: 'blast_formula', icon: '✦+1', color: 0xff9f43, hits: 17, + copy: { + zombie: ['尸爆算式', '尸爆等级 +1,并追加永久火力 ×1.08'], + deadline: ['异常批量关闭', '异常扩散等级 +1,并追加修复力 ×1.08'], + }, + apply() { + state.levels.blast = Math.min(5, state.levels.blast + 1); + state.bonuses.damage *= 1.08; + return `范围特效 Lv.${state.levels.blast},火力 ×1.08`; + }, + }, + { + id: 'frost_formula', icon: '❄+1', color: 0x69d8ff, hits: 15, + copy: { + zombie: ['冷冻方程', '冰冻等级 +1,并追加永久射速 ×1.1'], + deadline: ['需求冻结令', '冻结等级 +1,并追加处理速度 ×1.1'], + }, + apply() { + state.levels.frost = Math.min(4, state.levels.frost + 1); + state.bonuses.rate *= 1.1; + return `冻结特效 Lv.${state.levels.frost},射速 ×1.1`; + }, + }, + { + id: 'chain_formula', icon: 'ϟ+1', color: 0xb37cff, hits: 19, + copy: { + zombie: ['连锁导电阵', '连锁等级 +1,并追加 3% 暴击'], + deadline: ['调用链追踪', '调用链等级 +1,并追加 3% 一次通过'], + }, + apply() { + state.levels.chain = Math.min(5, state.levels.chain + 1); + state.bonuses.crit += 0.03; + return `连锁特效 Lv.${state.levels.chain},暴击 +3%`; + }, + }, + { + id: 'crit_formula', icon: '※+1', color: 0xff6f91, hits: 18, + copy: { + zombie: ['猎杀暴击式', '暴击等级 +1,并追加永久火力 ×1.12'], + deadline: ['一次过编译', '一次通过等级 +1,并追加修复力 ×1.12'], + }, + apply() { + state.levels.crit = Math.min(5, state.levels.crit + 1); + state.bonuses.damage *= 1.12; + return `暴击等级 Lv.${state.levels.crit},火力 ×1.12`; + }, + }, { id: 'swap_stats', icon: '⇄', color: 0x68b8ff, hits: 15, copy: { @@ -1006,6 +1119,66 @@ function currentTheme() { return THEMES[state.themeId] || THEMES.zombie; } +function currentLevel() { + const levels = CAMPAIGNS[state.themeId] || CAMPAIGNS.zombie; + return levels[clamp(state.level - 1, 0, levels.length - 1)]; +} + +function currentRoleMap() { + return ENEMY_ROLES[state.themeId] || ENEMY_ROLES.zombie; +} + +function bossHpFactor(levelNumber = state.level) { + return Math.pow(BOSS_HP_GROWTH, Math.max(0, levelNumber - 1)) * currentLevel().bossHp; +} + +function formatCompactNumber(value) { + const number = Math.max(0, Number(value) || 0); + if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(number >= 10_000_000 ? 0 : 1)}M`; + if (number >= 1_000) return `${(number / 1_000).toFixed(number >= 100_000 ? 0 : 1)}K`; + return String(Math.round(number)); +} + +function renderLevelPicker() { + const levels = CAMPAIGNS[state.themeId] || CAMPAIGNS.zombie; + const level = currentLevel(); + els.levelPicker.innerHTML = ''; + levels.forEach((entry, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `level-button${index === state.level - 1 ? ' active' : ''}${index >= 6 ? ' danger' : ''}${index === 9 ? ' final' : ''}`; + button.textContent = String(index + 1).padStart(2, '0'); + button.title = `第 ${index + 1} 关 · ${entry.title}`; + button.addEventListener('click', () => selectLevel(index + 1)); + els.levelPicker.appendChild(button); + }); + const uniqueRoles = [...new Set(level.roles)].map((id) => currentRoleMap()[id]).filter(Boolean); + els.levelTitle.textContent = `第 ${state.level} 关 · ${level.title}`; + els.levelDescription.textContent = level.description; + els.levelEnemyHint.textContent = `敌方角色 ${uniqueRoles.length} 种 · ${state.level >= 5 ? '高阶精英已加入' : '逐步解锁精英'}`; + els.levelBossHint.textContent = `Boss 指数生命 ×${bossHpFactor().toFixed(state.level >= 7 ? 0 : 1)}`; + els.enemyRoster.innerHTML = [...uniqueRoles.slice(0, 4), { name: level.boss, visual: 'boss' }].map((role) => { + const frame = ENEMY_ATLAS_FRAMES[role.visual] || 0; + return ` +
+ + ${role.name} +
+ `; + }).join(''); + els.startButtonHint.textContent = `${level.duration} 秒构筑 · 最终 Boss ${level.boss}`; + els.time.textContent = String(level.duration); + els.level.textContent = `${String(state.level).padStart(2, '0')}/10`; +} + +function selectLevel(levelNumber, { persist = true } = {}) { + if (state.mode !== 'menu') return; + state.level = clamp(Math.trunc(Number(levelNumber) || 1), 1, 10); + if (persist) localStorage.setItem(`toy-toy-toy-level-${state.themeId}`, String(state.level)); + renderLevelPicker(); + updateHud(true); +} + function upgradePresentation(upgrade) { const copy = currentTheme().upgrades[upgrade.id]; return { @@ -1032,12 +1205,6 @@ function applyTheme(themeId, { persist = true, refreshLeaderboard = true } = {}) els.startTitle.textContent = theme.title; els.startEnglish.textContent = theme.english; els.startDescription.textContent = theme.description; - els.enemyRoster.innerHTML = theme.roster.map((name, index) => ` -
- - ${name} -
- `).join(''); els.featureRow.innerHTML = theme.features.map((feature) => `${feature}`).join(''); els.startButtonLabel.textContent = theme.startButton; els.startButtonHint.textContent = theme.startButtonHint; @@ -1065,6 +1232,8 @@ function applyTheme(themeId, { persist = true, refreshLeaderboard = true } = {}) els.bossButtonLabel.textContent = theme.director.bossLabel; els.bossButtonDescription.textContent = theme.director.bossDescription; els.themeButtons.forEach((button) => button.classList.toggle('active', button.dataset.theme === themeId)); + state.level = clamp(Number(localStorage.getItem(`toy-toy-toy-level-${themeId}`) || state.level || 1), 1, 10); + renderLevelPicker(); scene.background.setHex(theme.palette.bg); scene.fog.color.setHex(theme.palette.fog); @@ -1093,7 +1262,6 @@ function applyTheme(themeId, { persist = true, refreshLeaderboard = true } = {}) ticket.visible = theme.id === 'deadline'; }); - els.time.textContent = String(theme.roundDuration); if (refreshLeaderboard) loadLeaderboard(); } @@ -1170,14 +1338,15 @@ function resetGame() { state.combo = 1; state.maxCombo = 1; state.comboUntil = 0; + state.lastVictory = false; state.baseHp = 100; state.focusLane = 1; state.fireAcc = Array(MAX_CANNONS).fill(0); state.spawnAcc = 0; - state.nextBonusAt = 5.5; - state.nextBarrierAt = 14; - state.nextMysteryAt = 23; - state.nextGateAt = 18; + state.nextBonusAt = Number.POSITIVE_INFINITY; + state.nextBarrierAt = Number.POSITIVE_INFINITY; + state.nextMysteryAt = Number.POSITIVE_INFINITY; + state.nextGateAt = 7.5 + randomBetween(0, 1.5); state.gatePhase = 'none'; state.gatePrepUntil = 0; state.gateChoiceUntil = 0; @@ -1185,7 +1354,7 @@ function resetGame() { state.gateRound = 0; state.lastGateEffect = ''; state.nextSpeechAt = 2.8 + randomBetween(0, 1.6); - state.nextUpgradeAt = theme.firstUpgradeAt; + state.nextUpgradeAt = Number.POSITIVE_INFINITY; state.upgradeDeadline = 0; state.currentUpgrades = []; state.frenzyUntil = 0; @@ -1231,7 +1400,7 @@ function startGame() { setOverlay(els.resultOverlay, false); setOverlay(els.pauseOverlay, false); setOverlay(els.upgradeOverlay, false); - showToast(theme.openingToast); + showToast(`第 ${state.level} 关「${currentLevel().title}」:${theme.openingToast}`, 2600); for (let i = 0; i < 10; i += 1) spawnEnemy(i < 2 ? 'runner' : 'normal'); } @@ -1241,6 +1410,7 @@ function showMenu() { setOverlay(els.pauseOverlay, false); setOverlay(els.upgradeOverlay, false); setOverlay(els.startOverlay, true); + renderLevelPicker(); loadLeaderboard(); } @@ -1261,75 +1431,71 @@ function togglePause(forceResume = false) { } } -function spawnEnemy(forceType = null) { - const theme = currentTheme(); - if (enemies.filter((enemy) => enemy.active).length >= WORLD.maxEnemies) return null; - const progress = clamp(state.elapsed / theme.roundDuration, 0, 1); - let type = forceType; - if (!type) { - const roll = state.random(); - if (progress > 0.48 && roll < 0.08) type = 'tank'; - else if (progress > 0.2 && roll < 0.22) type = 'runner'; - else if (progress > 0.66 && roll < 0.29) type = 'elite'; - else type = 'normal'; +function pickEnemyRole(forceRole = null) { + const roleMap = currentRoleMap(); + if (forceRole && roleMap[forceRole]) return { id: forceRole, ...roleMap[forceRole] }; + let roleIds = [...currentLevel().roles]; + if (forceRole && ['normal', 'runner', 'tank', 'elite'].includes(forceRole)) { + const matching = roleIds.filter((id) => roleMap[id]?.visual === forceRole); + if (matching.length) roleIds = matching; + } + const weighted = roleIds.map((id) => ({ id, ...roleMap[id] })).filter((role) => role.name); + const total = weighted.reduce((sum, role) => sum + (role.weight || 1), 0); + let roll = state.random() * Math.max(1, total); + for (const role of weighted) { + roll -= role.weight || 1; + if (roll <= 0) return role; } + return weighted[0] || { id: 'normal', name: '敌人', visual: 'normal', hp: 1, speed: 1, scale: 1, score: 11, damage: 5, tint: 0xffffff }; +} - const lane = type === 'boss' ? 1 : Math.floor(state.random() * 3); - const baseHp = (20 + state.elapsed * 0.72) * theme.hpMultiplier; +function spawnEnemy(forceRole = null, options = {}) { + const theme = currentTheme(); + const level = currentLevel(); + if (enemies.filter((enemy) => enemy.active).length >= WORLD.maxEnemies) return null; + const progress = clamp(state.elapsed / level.duration, 0, 1); + const isBoss = forceRole === 'boss'; + const role = isBoss ? null : pickEnemyRole(forceRole); + const type = isBoss ? 'boss' : role.visual; + const lane = isBoss ? 1 : clamp(Number.isFinite(options.lane) ? options.lane : Math.floor(state.random() * 3), 0, 2); + const regularBaseHp = (18 + state.level * 3.6 + progress * 34) * theme.hpMultiplier * level.hp; + const hp = isBoss + ? 9200 * bossHpFactor() * theme.bossHpMultiplier + : regularBaseHp * role.hp; const enemy = { active: true, id: `${state.seed}-${state.elapsed}-${enemies.length}`, type, + roleId: isBoss ? 'boss' : role.id, + roleName: isBoss ? level.boss : role.name, lane, - x: WORLD.lanes[lane] + randomBetween(-1.15, 1.15), + x: WORLD.lanes[lane] + (isBoss ? 0 : randomBetween(-1.15, 1.15)), y: 0.62, - z: WORLD.spawnZ - randomBetween(0, 2.2), - hp: baseHp, - maxHp: baseHp, - speed: (1.28 + progress * 1.05) * theme.speedMultiplier, - scale: 1, - score: 11, - baseDamage: 5, + z: Number.isFinite(options.z) ? options.z : WORLD.spawnZ - randomBetween(0, 2.2), + hp, + maxHp: hp, + speed: isBoss + ? Math.max(0.12, 0.235 - (state.level - 1) * 0.011) + : (1.18 + progress * 0.78) * theme.speedMultiplier * level.speed * role.speed, + scale: isBoss ? level.bossScale : role.scale, + score: isBoss ? Math.round(5000 * Math.pow(1.45, state.level - 1)) : Math.round(role.score * (1 + state.level * 0.12)), + baseDamage: isBoss ? 100 : role.damage, + tint: isBoss ? level.bossTint : role.tint, slowUntil: 0, hitUntil: 0, wobble: randomBetween(0, Math.PI * 2), speechCount: 0, }; - if (type === 'runner') { - enemy.hp *= 0.62; - enemy.maxHp = enemy.hp; - enemy.speed *= 1.72; - enemy.scale = 0.72; - enemy.score = 14; - enemy.baseDamage = 4; - } else if (type === 'tank') { - enemy.hp *= 3.4; - enemy.maxHp = enemy.hp; - enemy.speed *= 0.55; - enemy.scale = 1.45; - enemy.score = 35; - enemy.baseDamage = 13; - } else if (type === 'elite') { - enemy.hp *= 5.5; - enemy.maxHp = enemy.hp; - enemy.speed *= 0.78; - enemy.scale = 1.72; - enemy.score = 90; - enemy.baseDamage = 19; - } else if (type === 'boss') { + if (isBoss) { enemy.x = 0; enemy.z = WORLD.spawnZ - 1.5; - enemy.hp = (1750 + state.elapsed * 14) * theme.bossHpMultiplier; - enemy.maxHp = enemy.hp; - enemy.speed = theme.bossSpeed; - enemy.scale = 3.2; - enemy.score = 5000; - enemy.baseDamage = 100; state.bossSpawned = true; state.bossAlive = true; - els.bossName.textContent = theme.bossName; + els.bossName.textContent = `${level.boss} · ${formatCompactNumber(enemy.maxHp)} HP`; els.bossHud.classList.remove('hidden'); + } else if (role.weight <= 5 && state.random() < 0.08) { + addFxText(enemy.x, 1.15 * enemy.scale, enemy.z, role.name, cssHex(role.tint), 1.15, 11); } enemies.push(enemy); @@ -1401,7 +1567,11 @@ function scheduleCharacterSpeech() { )); if (!candidates.length) return; const enemy = candidates[Math.floor(state.random() * candidates.length)]; - const lines = currentTheme().speech?.[enemy.type] || currentTheme().speech?.normal || []; + const lines = currentRoleMap()[enemy.roleId]?.lines + || currentTheme().speech?.[enemy.roleId] + || currentTheme().speech?.[enemy.type] + || currentTheme().speech?.normal + || []; if (!lines.length) return; showEnemySpeech(enemy, lines[Math.floor(state.random() * lines.length)]); } @@ -1610,8 +1780,9 @@ function gateBoardSprite(effect, hitsRemaining, color) { function createChoiceGate(effect, lane, requiredHits) { const color = effect.color; + const startZ = -8.4; const group = new THREE.Group(); - group.position.set(WORLD.lanes[lane], 0.04, -1.35); + group.position.set(WORLD.lanes[lane], 0.04, startZ); const floor = new THREE.Mesh( new THREE.RingGeometry(1.35, 2.2, 36), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.34, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide }), @@ -1640,7 +1811,8 @@ function createChoiceGate(effect, lane, requiredHits) { lane, x: WORLD.lanes[lane], y: 1.2, - z: -1.35, + z: startZ, + speed: 0.58 + state.level * 0.012, hitsRemaining: requiredHits, requiredHits, scale: 1, @@ -1743,48 +1915,41 @@ function grantBonus(target) { function beginGatePrep() { if (state.gatePhase !== 'none' || state.bossAlive || state.bossDefeated) return; state.gatePhase = 'prep'; - state.gatePrepUntil = state.elapsed + 3.4; - state.frenzyUntil = Math.min(state.frenzyUntil, state.elapsed); - for (const enemy of enemies) { - if (enemy.active && enemy.type !== 'boss') enemy.slowUntil = Math.max(enemy.slowUntil, state.gatePrepUntil + 1.8); - } - for (const target of [...bonusTargets]) { - addFxText(target.x, 1.1, target.z, '选择阶段回收', '#8ba6b8', 0.85, 10); - expireBonusTarget(target); - } - showOverdriveBanner(currentTheme().id === 'deadline' ? 'REVIEW WINDOW' : 'CHOICE GATES'); + state.gatePrepUntil = state.elapsed + 0.9; + showOverdriveBanner(currentTheme().id === 'deadline' ? 'REVIEW CONVOY' : 'ARITHMETIC CONVOY'); showToast(currentTheme().id === 'deadline' - ? '需求流暂停:先清掉残余同事,评审挡板即将出现' - : '尸潮暂歇:先清理残余敌人,三扇算术挡板即将出现', 2500); + ? '评审方案混进需求队伍:准备调度工位,三选一击穿' + : '算术挡板混进尸群:准备切路,只能轰开其中一门', 1900); } function spawnChoiceGates() { - let cleared = 0; - for (const enemy of enemies) { - if (!enemy.active || enemy.type === 'boss') continue; - enemy.active = false; - cleared += 1; - state.score += Math.round(enemy.score * 0.35); - if (cleared <= 18) addFxParticle(enemy.x, 0.7, enemy.z, currentTheme().palette.secondary, 0.55); - } - if (cleared) addFxText(0, 1.8, -3.5, `波次清算 ${cleared}`, currentTheme().palette.secondary, 1.25, 15); - const pool = [...GATE_EFFECTS]; - for (let index = pool.length - 1; index > 0; index -= 1) { - const swap = Math.floor(state.random() * (index + 1)); - [pool[index], pool[swap]] = [pool[swap], pool[index]]; - } - const selected = pool.slice(0, 3); - if (selected.every((effect) => effect.id !== 'team_double' && effect.id !== 'team_half' && effect.id !== 'odd_even')) { - const arithmetic = GATE_EFFECTS.filter((effect) => ['team_double', 'team_half', 'odd_even'].includes(effect.id)); - selected[Math.floor(state.random() * 3)] = arithmetic[Math.floor(state.random() * arithmetic.length)]; + const shuffle = (items) => { + const pool = [...items]; + for (let index = pool.length - 1; index > 0; index -= 1) { + const swap = Math.floor(state.random() * (index + 1)); + [pool[index], pool[swap]] = [pool[swap], pool[index]]; + } + return pool; + }; + const arithmeticIds = new Set(['team_double', 'team_half', 'rapid_flow', 'heavy_packet', 'swap_stats', 'odd_even', 'compound_risk']); + const arithmetic = shuffle(GATE_EFFECTS.filter((effect) => arithmeticIds.has(effect.id) && effect.id !== state.lastGateEffect)); + const remainder = shuffle(GATE_EFFECTS.filter((effect) => !arithmetic.slice(0, 2).includes(effect) && effect.id !== state.lastGateEffect)); + const selected = [...arithmetic.slice(0, 2), remainder[0]].filter(Boolean); + while (selected.length < 3) { + const fallback = shuffle(GATE_EFFECTS.filter((effect) => !selected.includes(effect)))[0]; + if (!fallback) break; + selected.push(fallback); } selected.forEach((effect, lane) => { - const scaleByTeam = Math.max(0, state.levels.cannon - 1) * 1.4; - const requiredHits = Math.round(effect.hits + state.gateRound * 1.8 + scaleByTeam + randomBetween(0, 4)); + const scaleByTeam = Math.max(0, state.levels.cannon - 1) * 0.8; + const requiredHits = Math.round(effect.hits + state.gateRound * 0.65 + state.level * 0.9 + scaleByTeam + randomBetween(-1, 3)); createChoiceGate(effect, lane, requiredHits); }); + for (let index = 0; index < 12 + state.level; index += 1) { + spawnEnemy(null, { lane: index % 3, z: -10.8 + randomBetween(0, 5.6) }); + } state.gatePhase = 'active'; - state.gateChoiceUntil = state.elapsed + 11; + state.gateChoiceUntil = state.elapsed + 13.5; state.gateRound += 1; state.telemetry.gatesOffered += 3; showToast(currentTheme().id === 'deadline' @@ -1795,10 +1960,7 @@ function spawnChoiceGates() { function finishGateWindow(delay = 1.25) { state.gatePhase = 'resume'; state.gateResumeAt = state.elapsed + delay; - state.nextGateAt = state.elapsed + 13 + randomBetween(0, 5); - state.nextBonusAt = Math.max(state.nextBonusAt, state.elapsed + 4.5); - state.nextBarrierAt = Math.max(state.nextBarrierAt, state.elapsed + 8); - state.nextMysteryAt = Math.max(state.nextMysteryAt, state.elapsed + 10); + state.nextGateAt = state.elapsed + 6.5 + randomBetween(0, 2.4); } function resolveChoiceGate(gate) { @@ -1829,51 +1991,32 @@ function updateChoiceGates(dt) { return; } if (state.gatePhase === 'prep') { - const living = livingEnemies().filter((enemy) => enemy.type !== 'boss').length; - if (state.elapsed >= state.gatePrepUntil && (living <= 14 || state.elapsed >= state.gatePrepUntil + 2.2)) spawnChoiceGates(); + if (state.elapsed >= state.gatePrepUntil) spawnChoiceGates(); return; } if (state.gatePhase === 'active') { + let convoyEscaped = false; for (const gate of choiceGates) { if (!gate.active) continue; gate.wobble += dt * 2.5; + gate.z += gate.speed * dt; + gate.group.position.z = gate.z; gate.group.position.y = 0.04 + Math.sin(gate.wobble) * 0.055; gate.floor.rotation.z += dt * 0.55; const pulse = state.elapsed < gate.hitUntil ? 1.08 : 1 + Math.sin(gate.wobble * 1.6) * 0.025; gate.group.scale.setScalar(pulse); + if (gate.z >= WORLD.baseZ - 1.4) convoyEscaped = true; } - if (state.elapsed >= state.gateChoiceUntil) { + if (convoyEscaped || state.elapsed >= state.gateChoiceUntil) { for (const gate of [...choiceGates]) expireChoiceGate(gate, '选择超时'); - showToast('选择超时:没有获得算术效果,敌潮即将恢复', 2200); - finishGateWindow(0.8); + showToast('算术车队已穿过防线:本轮没有获得构筑效果', 2200); + finishGateWindow(0.35); } return; } if (state.gatePhase === 'resume' && state.elapsed >= state.gateResumeAt) state.gatePhase = 'none'; } -function updateBonusSpawning() { - if (state.gatePhase !== 'none') return; - if (state.elapsed >= state.nextBonusAt) { - const lanes = [0, 1, 2].sort(() => state.random() - 0.5); - const types = ['damage', 'rate', 'crit'].sort(() => state.random() - 0.5); - createBonusTarget(types[0], lanes[0]); - createBonusTarget(types[1], lanes[1]); - state.nextBonusAt += 8.5 + randomBetween(0, 2.4); - showToast('两路 Bonus 已进入:移动炮台,选择你要的永久强化', 1600); - } - if (state.elapsed >= state.nextBarrierAt) { - createBonusTarget('barrier', Math.floor(state.random() * 3), WORLD.spawnZ + 2.4); - state.nextBarrierAt += 18 + randomBetween(0, 4); - showToast('结界出现:打穿它,炮台会继续进化', 1700); - } - if (state.elapsed >= state.nextMysteryAt) { - createBonusTarget('mystery', Math.floor(state.random() * 3), WORLD.spawnZ + 1.8); - state.nextMysteryAt += 24 + randomBetween(0, 4); - showToast('隐藏 Bonus 出现:里面可能是倍率,也可能直接复制炮台', 1800); - } -} - function updateBonusTargets(dt) { for (let index = bonusTargets.length - 1; index >= 0; index -= 1) { const target = bonusTargets[index]; @@ -1906,12 +2049,15 @@ function livingEnemies() { } function updateSpawning(dt) { - if (state.gatePhase !== 'none') return; const theme = currentTheme(); - const progress = clamp(state.elapsed / theme.roundDuration, 0, 1); + const level = currentLevel(); + const progress = clamp(state.elapsed / level.duration, 0, 1); const living = livingEnemies(); const nearestZ = living.reduce((max, enemy) => Math.max(max, enemy.z), WORLD.spawnZ); - let spawnRate = (1.7 + progress * 5.5) * theme.spawnMultiplier; + let spawnRate = (1.55 + progress * 5.1) * theme.spawnMultiplier * level.spawn; + if (state.gatePhase === 'prep') spawnRate *= 0.72; + if (state.gatePhase === 'active') spawnRate *= 0.56; + if (state.gatePhase === 'resume') spawnRate *= 0.8; if (state.elapsed < state.frenzyUntil) spawnRate *= 10; if (living.length < 18 && nearestZ < 4) spawnRate *= 1.55; if (living.length > 360) spawnRate *= 0.42; @@ -1928,7 +2074,7 @@ function updateSpawning(dt) { } } - if (!state.bossSpawned && state.elapsed >= theme.bossAt && state.gatePhase === 'none') summonBoss(false); + if (!state.bossSpawned && state.elapsed >= level.bossAt && state.gatePhase === 'none') summonBoss(false); } function updateEnemies(dt) { @@ -2131,7 +2277,9 @@ function applyDamage(enemy, amount, options = {}) { enemy.x, 1.55, enemy.z, - enemy.hitsRemaining > 0 ? `还差 ${enemy.hitsRemaining} 发` : '方案击穿!', + enemy.hitsRemaining > 0 + ? `还差 ${enemy.hitsRemaining} ${currentTheme().id === 'deadline' ? '份' : '发'}` + : '方案击穿!', cssHex(enemy.effect.color), 0.62, enemy.hitsRemaining > 0 ? 12 : 17, @@ -2519,13 +2667,12 @@ function updateUpgradeCountdown(now) { } function updateGame(dt) { - const theme = currentTheme(); + const level = currentLevel(); state.elapsed += dt; if (state.elapsed > state.comboUntil) state.combo = 1; updateChoiceGates(dt); updateSpawning(dt); - updateBonusSpawning(); updateCharacterSpeech(dt); updateEnemies(dt); updateBonusTargets(dt); @@ -2535,19 +2682,22 @@ function updateGame(dt) { updateShockwaves(dt); updateFx(dt); - if (state.elapsed >= state.nextUpgradeAt && !state.bossDefeated && state.gatePhase === 'none') showUpgrade(); if (state.bossDefeated && state.finishAt && state.elapsed >= state.finishAt) endGame(true); - if (state.elapsed >= theme.roundDuration && !state.bossSpawned && state.gatePhase === 'none') summonBoss(false); + if (state.elapsed >= level.duration && !state.bossSpawned && state.gatePhase === 'none') summonBoss(false); } function endGame(victory) { const theme = currentTheme(); if (!['playing', 'upgrade'].includes(state.mode)) return; + state.lastVictory = victory; state.mode = 'result'; setOverlay(els.upgradeOverlay, false); els.resultEyebrow.textContent = victory ? `RUN COMPLETE / ${theme.english}` : `SIMULATION FAILED / ${theme.english}`; els.resultTitle.textContent = victory ? theme.victoryTitle : theme.defeatTitle; - els.resultDescription.textContent = victory ? theme.victoryDescription : theme.defeatDescription; + els.resultDescription.textContent = victory + ? `第 ${state.level} 关「${currentLevel().title}」完成。${theme.victoryDescription}` + : `第 ${state.level} 关「${currentLevel().title}」失败。${theme.defeatDescription}`; + els.againButtonLabel.textContent = victory && state.level < 10 ? `进入第 ${state.level + 1} 关` : victory ? '重打最终关' : '重新挑战本关'; els.finalScore.textContent = formatScore(state.score); els.finalKills.textContent = formatScore(state.kills); els.finalCombo.textContent = `×${state.maxCombo}`; @@ -2557,6 +2707,14 @@ function endGame(victory) { submitRun(victory); } +function startNextOrReplay() { + if (state.lastVictory && state.level < 10) { + state.level += 1; + localStorage.setItem(`toy-toy-toy-level-${state.themeId}`, String(state.level)); + } + startGame(); +} + function renderEnemies() { const counts = { normal: 0, runner: 0, tank: 0, elite: 0, boss: 0 }; let shadowCount = 0; @@ -2576,7 +2734,7 @@ function renderEnemies() { matrixDummy.rotation.set(-0.72, 0, stride * (enemy.type === 'runner' ? 0.105 : 0.055)); matrixDummy.updateMatrix(); visual.mesh.setMatrixAt(index, matrixDummy.matrix); - enemyTint.setHex(hit ? 0xff6d78 : slowed ? 0x79d9ff : 0xffffff); + enemyTint.setHex(hit ? 0xff6d78 : slowed ? 0x79d9ff : (enemy.tint || 0xffffff)); visual.mesh.setColorAt(index, enemyTint); shadowDummy.position.set(enemy.x, -0.065, enemy.z + 0.34 * enemy.scale); @@ -2599,7 +2757,7 @@ function renderEnemies() { if (boss?.active) { const ratio = clamp(boss.hp / boss.maxHp, 0, 1); els.bossHpFill.style.width = `${ratio * 100}%`; - els.bossHpText.textContent = `${Math.ceil(ratio * 100)}%`; + els.bossHpText.textContent = `${Math.ceil(ratio * 100)}% · ${formatCompactNumber(boss.hp)}`; } } @@ -2611,7 +2769,8 @@ function updateHud(force = false) { els.score.textContent = formatScore(state.score); els.kills.textContent = formatScore(state.kills); els.combo.textContent = `×${state.combo}`; - const remaining = Math.max(0, Math.ceil(theme.roundDuration - state.elapsed)); + els.level.textContent = `${String(state.level).padStart(2, '0')}/10`; + const remaining = Math.max(0, Math.ceil(currentLevel().duration - state.elapsed)); els.time.textContent = remaining > 0 ? String(remaining) : state.bossAlive ? 'BOSS' : '0'; els.baseHpText.textContent = `${Math.ceil(state.baseHp)}%`; els.baseHpFill.style.width = `${clamp(state.baseHp, 0, 100)}%`; @@ -2625,13 +2784,13 @@ function updateHud(force = false) { els.bonusRateValue.textContent = `×${state.bonuses.rate.toFixed(2)}`; els.cannonCountValue.textContent = `${state.levels.cannon} / ${MAX_CANNONS}`; els.cannonShardValue.textContent = state.levels.cannon >= MAX_CANNONS ? 'MAX' : `${state.bonuses.shards} / 2`; - els.bonusCountValue.textContent = `BONUS ×${state.bonuses.count}`; + els.bonusCountValue.textContent = `选择 ×${state.telemetry.gatesChosen}`; const frenzyRemaining = Math.max(0, state.frenzyUntil - state.elapsed); const overdriveRemaining = Math.max(0, state.overdriveUntil - state.elapsed); const choosingGate = state.gatePhase === 'active'; - if (state.gatePhase === 'prep') els.bonusCountValue.textContent = '清场准备选择'; - else if (choosingGate) els.bonusCountValue.textContent = `算术选择 ${Math.max(0, state.gateChoiceUntil - state.elapsed).toFixed(1)}s`; - else if (state.gatePhase === 'resume') els.bonusCountValue.textContent = '敌潮即将恢复'; + if (state.gatePhase === 'prep') els.bonusCountValue.textContent = '算术车队接近'; + else if (choosingGate) els.bonusCountValue.textContent = `随队选择 ${Math.max(0, state.gateChoiceUntil - state.elapsed).toFixed(1)}s`; + else if (state.gatePhase === 'resume') els.bonusCountValue.textContent = '选择已锁定'; els.frenzyBtn.classList.toggle('active', frenzyRemaining > 0); els.overdriveBtn.classList.toggle('active', overdriveRemaining > 0); els.bossBtn.classList.toggle('active', state.bossAlive); @@ -2639,7 +2798,7 @@ function updateHud(force = false) { els.overdriveBtn.setAttribute('aria-pressed', overdriveRemaining > 0 ? 'true' : 'false'); els.bossBtn.setAttribute('aria-pressed', state.bossAlive ? 'true' : 'false'); els.frenzyDescription.textContent = state.gatePhase !== 'none' - ? '选择阶段锁定:尸潮已经暂停' + ? '算术车队中:普通敌潮仍以低密度推进' : frenzyRemaining > 0 ? `生效中 ${frenzyRemaining.toFixed(1)} 秒 · 实际敌潮 ×10` : `${theme.director.frenzyDescription}${state.telemetry.frenzyUses ? ` · 已触发 ${state.telemetry.frenzyUses} 次` : ''}`; @@ -2652,9 +2811,9 @@ function updateHud(force = false) { ? '已登场 · 固定中路 · 仅当前路可攻击' : state.bossSpawned ? '本局 Boss 已处理,不能重复召唤' - : theme.director.bossDescription; + : `${theme.director.bossDescription} · 本关生命约 ${formatCompactNumber(9200 * bossHpFactor() * theme.bossHpMultiplier)}`; els.laneHint.textContent = choosingGate - ? (theme.id === 'deadline' ? '评审窗口:工单只打当前一路,击穿一项后其余锁死' : '选择窗口:炮弹只打当前一路,击穿一门后其余锁死') + ? (theme.id === 'deadline' ? '评审车队混在需求中:工单只打一条服务,击穿一项后其余锁死' : '算术门混在尸群中:炮弹只打一条路,击穿一门后其余锁死') : theme.laneHint; els.frenzyBtn.disabled = state.mode !== 'playing' || state.gatePhase !== 'none'; els.overdriveBtn.disabled = state.mode !== 'playing'; @@ -2700,7 +2859,7 @@ function renderLeaderboard(rows) { const item = document.createElement('li'); const name = document.createElement('b'); const score = document.createElement('em'); - name.textContent = row.display_name || row.username || '匿名玩家'; + name.textContent = `${row.display_name || row.username || '匿名玩家'} · L${row.level || 1}`; score.textContent = formatScore(row.score || 0); item.append(name, score); els.leaderboardList.appendChild(item); @@ -2740,6 +2899,7 @@ async function submitRun(victory) { score: Math.round(state.score), kills: state.kills, duration: Math.round(state.elapsed), + level: state.level, victory, seed: state.seed, }); @@ -2757,9 +2917,15 @@ window.__TOY_TOY_TOY_DEBUG__ = Object.freeze({ snapshot() { const combat = currentCombatStats(); return { - version: '0.7.0', + version: '0.8.0', mode: state.mode, theme: state.themeId, + level: state.level, + levelTitle: currentLevel().title, + levelDuration: currentLevel().duration, + levelBossAt: currentLevel().bossAt, + bossHpFactor: bossHpFactor(), + roleCatalog: currentLevel().roles.map((id) => currentRoleMap()[id]?.name).filter(Boolean), elapsed: state.elapsed, speed: state.speed, focusLane: state.focusLane, @@ -2783,6 +2949,9 @@ window.__TOY_TOY_TOY_DEBUG__ = Object.freeze({ overdriveRemaining: Math.max(0, state.overdriveUntil - state.elapsed), bossSpawned: state.bossSpawned, bossAlive: state.bossAlive, + bossHp: enemies.find((enemy) => enemy.active && enemy.type === 'boss')?.hp || 0, + bossMaxHp: enemies.find((enemy) => enemy.active && enemy.type === 'boss')?.maxHp || 0, + bossSpeed: enemies.find((enemy) => enemy.active && enemy.type === 'boss')?.speed || 0, }, combat: { damage: combat.damage, @@ -2817,7 +2986,7 @@ els.themeButtons.forEach((button) => button.addEventListener('click', () => { applyTheme(button.dataset.theme); updateHud(true); })); -els.againBtn.addEventListener('click', startGame); +els.againBtn.addEventListener('click', startNextOrReplay); els.menuBtn.addEventListener('click', showMenu); els.pauseBtn.addEventListener('click', () => togglePause()); els.resumeBtn.addEventListener('click', () => togglePause(true)); diff --git a/mobius/extension/toy-toy-toy/frontend/styles.css b/mobius/extension/toy-toy-toy/frontend/styles.css index 04bb8126..39b877dd 100644 --- a/mobius/extension/toy-toy-toy/frontend/styles.css +++ b/mobius/extension/toy-toy-toy/frontend/styles.css @@ -177,7 +177,8 @@ button { color: inherit; } text-shadow: 0 0 18px rgba(79, 255, 210, 0.24); } -.metric:nth-child(3) b { color: var(--yellow); } +.metric:first-child b { color: var(--mint); font-size: 15px; letter-spacing: 0.04em; } +.metric:nth-child(4) b { color: var(--yellow); } .hud-actions { justify-self: end; @@ -672,6 +673,80 @@ button { color: inherit; } .theme-card b { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } .theme-card small { overflow: hidden; color: var(--dim); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.level-picker-wrap { + margin: -5px 0 15px; + padding: 11px; + border: 1px solid rgba(146, 189, 213, 0.16); + border-radius: 13px; + background: rgba(2, 10, 19, 0.38); +} + +.level-picker-head { + display: grid; + grid-template-columns: auto 1fr; + align-items: baseline; + gap: 3px 9px; + margin-bottom: 8px; +} + +.level-picker-head span { + grid-row: 1 / 3; + align-self: center; + padding: 5px 7px; + border-radius: 7px; + color: #06131e; + background: var(--mint); + font-size: 8px; + font-weight: 1000; + letter-spacing: 0.08em; +} + +.level-picker-head b { font-size: 11px; } +.level-picker-head small { color: var(--dim); font-size: 8px; } + +.level-picker { + display: grid; + grid-template-columns: repeat(10, minmax(0, 1fr)); + gap: 4px; +} + +.level-button { + min-width: 0; + padding: 7px 2px; + border: 1px solid rgba(146, 189, 213, 0.16); + border-radius: 7px; + color: #7390a3; + background: rgba(255, 255, 255, 0.025); + cursor: pointer; + font-size: 9px; + font-weight: 900; + transition: 0.16s ease; +} + +.level-button:hover, +.level-button.active { + color: #efffff; + border-color: var(--mint); + background: color-mix(in srgb, var(--mint) 14%, rgba(255, 255, 255, 0.025)); + transform: translateY(-1px); + box-shadow: 0 0 16px color-mix(in srgb, var(--mint) 14%, transparent); +} + +.level-button.danger { color: #ffbc72; } +.level-button.final { color: #ff7188; border-color: rgba(255, 95, 122, 0.36); } + +.level-danger-row { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 7px; + color: #718b9d; + font-size: 8px; + font-weight: 800; +} + +.level-danger-row strong { color: var(--yellow); } + .enemy-roster { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); @@ -855,6 +930,11 @@ button { color: inherit; } .theme-card i { grid-row: auto; font-size: 21px; } .theme-card b { font-size: 9px; } .theme-card small { display: none; } + .level-picker-wrap { margin-bottom: 12px; padding: 9px; } + .level-picker-head { grid-template-columns: 1fr; } + .level-picker-head span { display: none; } + .level-picker { grid-template-columns: repeat(5, minmax(0, 1fr)); } + .level-danger-row { font-size: 7px; } .enemy-roster { display: none; } .leaderboard-card { display: none; } .upgrade-options { grid-template-columns: 1fr; } From b460d054c2bfa2f96b29d7b560ea25643e2f60b7 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 17:28:58 +0000 Subject: [PATCH 14/30] =?UTF-8?q?Add=20per-group=20restricted=20project=20?= =?UTF-8?q?visibility=20(=E6=96=B0=E5=A2=9E=E7=BE=A4=E7=BB=84=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=8F=AF=E8=A7=81=E6=80=A7=C2=B7=E5=8F=97=E9=99=90?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 受限群组(如试用组)成员默认仅可见 ①自己创建的 ②自己被加入成员的 ③白名单授权的项目, 含公开项目一律隔离; 管理员在「用户管理→群组管理」每张群组卡片配置标准/受限 + 项目白名单; 新增 user_groups.project_visibility_mode 列与 group_visible_projects 表, canReadProject 增加受限过滤层 --- mobius/backend/repositories/users.ts | 51 +++++- mobius/backend/routes/admin.ts | 45 ++++- mobius/backend/services/access-control.ts | 32 ++++ mobius/backend/types/rows.ts | 1 + mobius/db.ts | 32 ++++ mobius/frontend/src/components/panels.tsx | 199 ++++++++++++++++++---- mobius/schema.sql | 16 ++ 7 files changed, 337 insertions(+), 39 deletions(-) diff --git a/mobius/backend/repositories/users.ts b/mobius/backend/repositories/users.ts index c0601744..dccadec4 100644 --- a/mobius/backend/repositories/users.ts +++ b/mobius/backend/repositories/users.ts @@ -68,6 +68,9 @@ function makeGroupId(): string { interface GroupRow extends UserGroupRawRow { active_user_count?: number; user_count?: number; + is_default?: boolean; + project_visibility_mode?: 'default' | 'restricted'; + visible_project_ids?: string[]; } interface UserGroupMembershipRow { @@ -159,13 +162,16 @@ const replaceGroupsTx = db.transaction((userId: unknown, rawGroupIds: unknown, a }; }); -function shapeGroup(row: (UserGroupRawRow & { active_user_count?: number; user_count?: number }) | null | undefined): GroupRow | null { +function shapeGroup(row: (UserGroupRawRow & { active_user_count?: number; user_count?: number; visible_project_ids?: string | null }) | null | undefined): GroupRow | null { if (!row) return null; + const rawVisible = row.visible_project_ids; return { ...row, is_default: row.id === DEFAULT_GROUP_ID, active_user_count: Number(row.active_user_count || 0), user_count: Number(row.user_count || row.active_user_count || 0), + project_visibility_mode: row.project_visibility_mode === 'restricted' ? 'restricted' : 'default', + visible_project_ids: typeof rawVisible === 'string' && rawVisible ? rawVisible.split(',').filter(Boolean) : [], } as GroupRow; } @@ -435,14 +441,51 @@ const Users = { listGroups: (): Array => { ensureDefaultGroup(); return (db.prepare(` - SELECT g.id, g.name, g.description, g.created_at, g.updated_at, + SELECT g.id, g.name, g.description, g.project_visibility_mode, g.created_at, g.updated_at, SUM(CASE WHEN u.id IS NOT NULL AND ${ACTIVE_USER_SQL.replaceAll('deleted_at', 'u.deleted_at')} THEN 1 ELSE 0 END) AS active_user_count, - COUNT(u.id) AS user_count + COUNT(u.id) AS user_count, + (SELECT GROUP_CONCAT(gvp.project_id, ',') FROM group_visible_projects gvp WHERE gvp.group_id = g.id) AS visible_project_ids FROM user_groups g LEFT JOIN users u ON u.group_id = g.id GROUP BY g.id ORDER BY CASE WHEN g.id = ? THEN 0 ELSE 1 END, g.name COLLATE NOCASE ASC - `).all(DEFAULT_GROUP_ID) as Array).map(shapeGroup); + `).all(DEFAULT_GROUP_ID) as Array).map(shapeGroup); + }, + getGroupProjectVisibilityMode: (groupId: unknown): 'default' | 'restricted' => { + const gid = String(groupId || '').trim(); + if (!gid) return 'default'; + const row = db.prepare('SELECT project_visibility_mode FROM user_groups WHERE id = ?').get(gid) as { project_visibility_mode?: string } | undefined; + return row?.project_visibility_mode === 'restricted' ? 'restricted' : 'default'; + }, + listVisibleProjectIds: (groupId: unknown): string[] => { + const gid = String(groupId || '').trim(); + if (!gid) return []; + const rows = db.prepare('SELECT project_id FROM group_visible_projects WHERE group_id = ?').all(gid) as Array<{ project_id: string }>; + return rows.map((r) => r.project_id); + }, + setGroupProjectVisibility: (groupId: unknown, params: { mode?: unknown; visible_project_ids?: unknown } = {}) => { + ensureDefaultGroup(); + const gid = String(groupId || '').trim(); + const existing = findGroupById(gid); + if (!existing) throw repoError('群组不存在', 404); + if (gid === DEFAULT_GROUP_ID) throw repoError('默认组不能设为受限'); + const mode: 'default' | 'restricted' = params.mode === 'restricted' ? 'restricted' : 'default'; + const rawIds = Array.isArray(params.visible_project_ids) ? params.visible_project_ids : []; + const projectIds = Array.from(new Set(rawIds.map((v) => String(v || '').trim()).filter(Boolean))); + if (projectIds.length) { + const found = db.prepare(`SELECT COUNT(*) AS c FROM projects WHERE id IN (${projectIds.map(() => '?').join(',')})`).get(...projectIds) as { c: number }; + if (found.c !== projectIds.length) throw repoError('部分指定项目不存在', 400); + } + const tx = db.transaction(() => { + db.prepare(`UPDATE user_groups SET project_visibility_mode = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`).run(mode, gid); + db.prepare('DELETE FROM group_visible_projects WHERE group_id = ?').run(gid); + if (projectIds.length) { + const ins = db.prepare('INSERT OR IGNORE INTO group_visible_projects (group_id, project_id) VALUES (?, ?)'); + projectIds.forEach((pid) => ins.run(gid, pid)); + } + }); + tx(); + return { group_id: gid, project_visibility_mode: mode, visible_project_ids: projectIds }; }, listGroupMemberships, listGroupMembers, diff --git a/mobius/backend/routes/admin.ts b/mobius/backend/routes/admin.ts index 3ebebd02..0118e4f6 100644 --- a/mobius/backend/routes/admin.ts +++ b/mobius/backend/routes/admin.ts @@ -12,7 +12,7 @@ import { bridge } from '../bridge/instance'; import { db } from '../../db'; // @ts-ignore — agents 仍是 .js import agents from '../agents'; -import { homeWorkDirFor } from '../config'; +import { homeWorkDirFor, ENABLE_PASSWORD_LOGIN } from '../config'; // @ts-ignore — service 仍是 .js import adminSettings from '../services/admin-settings'; // @ts-ignore — service 仍是 .js @@ -140,7 +140,10 @@ function normalizeEmployeePayload(input: EmployeeInput | null | undefined): any const src = input || {}; const id = normalizeEmployeeId(src.id ?? src.username); const password = String(src.password || ''); - if (password.length < 6) throw errorWithStatus('密码至少 6 位'); + // 开启密码登录(ENABLE_PASSWORD_LOGIN=true)时密码必填且至少 6 位; + // 关闭密码登录(免密登录)时密码可选——不填则生成无密码账号, 但若填写仍要求≥6位以防弱密码. + if (ENABLE_PASSWORD_LOGIN && password.length < 6) throw errorWithStatus('密码至少 6 位'); + if (!ENABLE_PASSWORD_LOGIN && password.length > 0 && password.length < 6) throw errorWithStatus('密码至少 6 位'); const explicitWorkDir = normalizeEmployeeWorkDir(src.work_dir ?? src.workDir); const group = Users.resolveGroup({ group_id: src.group_id ?? src.groupId, @@ -547,6 +550,44 @@ router.delete('/user-groups/:id', adminAuth, (req: express.Request, res: express } }); +// 群组项目可见性 (受限群组): 取某群组当前模式 + 白名单 + 全量项目候选(供管理员勾选). +router.get('/user-groups/:id/project-visibility', adminAuth, (req: express.Request, res: express.Response) => { + try { + const gid = String(req.params.id || '').trim(); + const mode = Users.getGroupProjectVisibilityMode(gid); + const visible_project_ids = Users.listVisibleProjectIds(gid); + const candidates = (Projects.listAll() as any[]) + .map((p) => ({ + id: p.id, + name: p.name, + kind: p.kind, + visibility: p.visibility, + created_by: p.created_by, + created_by_name: p.created_by_name, + })) + .sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'zh-Hans-CN')); + res.json({ mode, visible_project_ids, candidates }); + } catch (e) { + const err = e as RepoError; + res.status(err.status || 400).json({ error: err.message || String(e) }); + } +}); + +// 群组项目可见性: 更新模式('default'|'restricted') + 可见项目白名单. +router.put('/user-groups/:id/project-visibility', adminAuth, (req: express.Request, res: express.Response) => { + try { + const body = req.body || {}; + const result = Users.setGroupProjectVisibility(req.params.id, { + mode: body.mode, + visible_project_ids: body.visible_project_ids, + }); + res.json({ ok: true, ...result }); + } catch (e) { + const err = e as RepoError; + res.status(err.status || 400).json({ error: err.message || String(e) }); + } +}); + router.get('/users', adminAuth, (req: express.Request, res: express.Response) => { const includeDeleted = req.query.include_deleted === '1' || req.query.include_deleted === 'true'; const users = Users.listForAdmin({ includeDeleted }); diff --git a/mobius/backend/services/access-control.ts b/mobius/backend/services/access-control.ts index 1884aec6..b3d7b35a 100644 --- a/mobius/backend/services/access-control.ts +++ b/mobius/backend/services/access-control.ts @@ -231,11 +231,43 @@ function allowedByVisibility(user: any, { resourceType, resourceId, ownerId, vis return false; } +// 受限群组(如"试用组")的项目可见性上下文: 取用户主群组(group_id)的受限模式 + 白名单. +// 结果挂到 user 对象上 —— readableProjectsForUser 用同一 user 过滤多个项目时只查一次库. +function ensureGroupVisCtx(user: any): { restricted: boolean; whitelist: Set } { + const ctx = { restricted: false, whitelist: new Set() }; + if (user?.id) { + const cached = (user as any).__groupVisCtx; + if (cached) return cached; + const gid = userGroupId(user); + if (gid) { + try { + const g = db.prepare('SELECT project_visibility_mode FROM user_groups WHERE id = ?').get(gid) as { project_visibility_mode?: string } | undefined; + if (g?.project_visibility_mode === 'restricted') { + ctx.restricted = true; + const rows = db.prepare('SELECT project_id FROM group_visible_projects WHERE group_id = ?').all(gid) as Array<{ project_id: string }>; + ctx.whitelist = new Set(rows.map((r) => r.project_id)); + } + } catch { + // 查询失败(如迁移未完成缺表) 视为非受限, 不阻断可见性. + } + } + (user as any).__groupVisCtx = ctx; + } + return ctx; +} + function canReadProject(user: any, projectOrId: any): boolean { const project = projectById(projectOrId); if (!project || !user?.id) return false; // 项目成员 (任意角色) 可读本项目, 先于可见性判定. if (ProjectMemberships.roleFor(project.id, user.id)) return true; + // 受限群组: 非成员用户即使面对公开项目也只允许 ①自己创建的 ②群组白名单授权的, 其余拒绝. + const visCtx = ensureGroupVisCtx(user); + if (visCtx.restricted) { + if (project.created_by === user.id) return true; + if (visCtx.whitelist.has(project.id)) return true; + return false; + } const visibility = normalizeProjectVisibility(project.visibility, 'private'); return allowedByVisibility(user, { resourceType: 'project', diff --git a/mobius/backend/types/rows.ts b/mobius/backend/types/rows.ts index e5c22241..1d0c8e6f 100644 --- a/mobius/backend/types/rows.ts +++ b/mobius/backend/types/rows.ts @@ -19,6 +19,7 @@ export interface UserGroupRawRow { description: string; created_at: string; updated_at: string; + project_visibility_mode?: string; } export interface UserGroupMembershipRawRow { diff --git a/mobius/db.ts b/mobius/db.ts index c346fa8d..39ffd968 100644 --- a/mobius/db.ts +++ b/mobius/db.ts @@ -142,6 +142,38 @@ function migrateEmployeeAndProjectMemberships() { } migrateEmployeeAndProjectMemberships(); +// ===== 群组项目可见性 (受限群组) ===== +// 管理员可把某群组(如"试用组")设为"受限": 该组成员默认只能看到 ①自己创建的项目 +// ②自己被加为项目成员的项目 ③管理员在此显式授权的项目; 其余(含公开项目)一律不可见. +// project_visibility_mode: 'default'(标准, 不受限) | 'restricted'(受限). +// group_visible_projects: 受限组显式授权可见的项目白名单 (group↔project). +function migrateGroupProjectVisibility() { + try { + const cols = db.prepare('PRAGMA table_info(user_groups)').all().map((c: any) => c.name); + if (!cols.includes('project_visibility_mode')) { + db.exec(`ALTER TABLE user_groups ADD COLUMN project_visibility_mode TEXT NOT NULL DEFAULT 'default'`); + console.log('[mobius/db] migrate: user_groups.project_visibility_mode 已加'); + } + db.exec(` + CREATE TABLE IF NOT EXISTS group_visible_projects ( + group_id TEXT NOT NULL, + project_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + PRIMARY KEY (group_id, project_id), + FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_group_visible_projects_group + ON group_visible_projects(group_id); + CREATE INDEX IF NOT EXISTS idx_group_visible_projects_project + ON group_visible_projects(project_id); + `); + } catch (e) { + console.warn('[mobius/db] ⚠️ group project visibility 迁移失败:', (e as Error).message); + } +} +migrateGroupProjectVisibility(); + // ===== allowlist 可见性 → 项目成员 (私有/公开两档简化) ===== // 项目可见性从 4 档(仅自己/同组/公开/指定用户) 简化为 2 档(私有/公开). // 原"指定用户(allowlist)"可见的项目: 把名单内用户迁移为项目成员(访客 viewer, 只读), diff --git a/mobius/frontend/src/components/panels.tsx b/mobius/frontend/src/components/panels.tsx index 7c161ca0..ac79c185 100644 --- a/mobius/frontend/src/components/panels.tsx +++ b/mobius/frontend/src/components/panels.tsx @@ -29,6 +29,7 @@ import { Save, Server, Settings, + Shield, Sparkles, Terminal, Trash2, @@ -282,6 +283,17 @@ type AdminUserGroup = { active_user_count?: number user_count?: number is_default?: boolean + project_visibility_mode?: 'default' | 'restricted' + visible_project_ids?: string[] +} + +type VisCandidate = { + id: string + name: string + kind?: string + visibility?: string + created_by?: string + created_by_name?: string } type EmployeeFormState = { @@ -354,6 +366,14 @@ function AdminUsersPanel() { const [creatingGroup, setCreatingGroup] = useState(false) const [savingGroupId, setSavingGroupId] = useState(null) const [deletingGroupId, setDeletingGroupId] = useState(null) + // 群组「项目可见性」编辑态: 一次只展开一个群组, 展开时按需加载候选项目. + const [visOpenId, setVisOpenId] = useState(null) + const [visLoading, setVisLoading] = useState(false) + const [visSavingId, setVisSavingId] = useState(null) + const [visMode, setVisMode] = useState<'default' | 'restricted'>('default') + const [visSelected, setVisSelected] = useState([]) + const [visCandidates, setVisCandidates] = useState([]) + const [visQuery, setVisQuery] = useState('') const [updatingUserId, setUpdatingUserId] = useState(null) const [deletingId, setDeletingId] = useState(null) @@ -565,6 +585,47 @@ function AdminUsersPanel() { } } + const openGroupVis = async (group: AdminUserGroup) => { + if (visOpenId === group.id) { setVisOpenId(null); return } + setVisOpenId(group.id) + setVisLoading(true) + setVisQuery('') + setVisMode(group.project_visibility_mode === 'restricted' ? 'restricted' : 'default') + setVisSelected(Array.isArray(group.visible_project_ids) ? [...group.visible_project_ids] : []) + setVisCandidates([]) + try { + const data = await api(`/api/admin/user-groups/${encodeURIComponent(group.id)}/project-visibility`) + setVisMode(data?.mode === 'restricted' ? 'restricted' : 'default') + setVisSelected(Array.isArray(data?.visible_project_ids) ? data.visible_project_ids : []) + setVisCandidates(Array.isArray(data?.candidates) ? data.candidates : []) + } catch (e: any) { + setError(e?.message || String(e)) + } finally { + setVisLoading(false) + } + } + + const toggleVisProject = (pid: string) => { + setVisSelected((prev) => (prev.includes(pid) ? prev.filter((x) => x !== pid) : [...prev, pid])) + } + + const saveGroupVis = async (group: AdminUserGroup) => { + setError(''); setNotice('') + setVisSavingId(group.id) + try { + await api(`/api/admin/user-groups/${encodeURIComponent(group.id)}/project-visibility`, { + method: 'PUT', + body: JSON.stringify({ mode: visMode, visible_project_ids: visSelected }), + }) + await refresh(true) + setNotice(`已更新「${group.name}」的项目可见性:${visMode === 'restricted' ? '受限' : '标准'}`) + } catch (e: any) { + setError(e?.message || String(e)) + } finally { + setVisSavingId(null) + } + } + const updateEmployeeGroup = async (row: AdminUserRow, groupId: string) => { if (!groupId || groupId === row.group_id) return const nextGroup = groups.find((g) => g.id === groupId) @@ -757,41 +818,113 @@ function AdminUsersPanel() { const draft = groupDrafts[g.id] ?? g.name const changed = draft.trim() !== g.name return ( -
-
-
- { setGroupDrafts((prev) => ({ ...prev, [g.id]: e.target.value })); setError(''); setNotice('') }} - className="h-8 min-w-0 flex-1 rounded-md border px-2 text-[12px] outline-none focus:border-blue-500/50" - style={fieldStyle} - /> - {g.is_default && ( - 默认 - )} -
-
- 启用员工 {activeCount} · 全部记录 {toCount(g.user_count)} +
+
+
+
+ { setGroupDrafts((prev) => ({ ...prev, [g.id]: e.target.value })); setError(''); setNotice('') }} + className="h-8 min-w-0 flex-1 rounded-md border px-2 text-[12px] outline-none focus:border-blue-500/50" + style={fieldStyle} + /> + {g.is_default && ( + 默认 + )} + {!g.is_default && ( + + {g.project_visibility_mode === 'restricted' ? '项目受限' : '项目标准'} + + )} +
+
+ 启用员工 {activeCount} · 全部记录 {toCount(g.user_count)} +
+ {!g.is_default && ( + + )} + +
- - + {!g.is_default && visOpenId === g.id && ( +
+ {visLoading ? ( +
+ 加载中… +
+ ) : ( + <> +
+ 项目可见性 +
+ + +
+ + {visMode === 'restricted' ? '仅可见:自己创建的 + 自己被加入成员的 + 下方指定项目' : '可见全部公开项目 + 自己的项目'} + + +
+ {visMode === 'restricted' && ( +
+ setVisQuery(e.target.value)} placeholder="搜索要开放给本组的项目…" + className="h-7 w-full rounded-md border px-2 text-[11px] outline-none focus:border-blue-500/50" style={fieldStyle} /> +
+ {visCandidates + .filter((c) => !visQuery.trim() || String(c.name || '').toLowerCase().includes(visQuery.toLowerCase())) + .map((c) => { + const checked = visSelected.includes(c.id) + return ( + + ) + })} + {visCandidates.filter((c) => !visQuery.trim() || String(c.name || '').toLowerCase().includes(visQuery.toLowerCase())).length === 0 && ( +
没有匹配的项目
+ )} +
+ {visSelected.length > 0 && ( +
已选 {visSelected.length} 个项目对「{g.name}」可见
+ )} +
+ )} + + )} +
+ )}
) })} diff --git a/mobius/schema.sql b/mobius/schema.sql index b6e2a53c..dab79aee 100644 --- a/mobius/schema.sql +++ b/mobius/schema.sql @@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS user_groups ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE, description TEXT NOT NULL DEFAULT '', + -- 项目可见性模式: 'default'(标准) | 'restricted'(受限, 仅自己建的/成员/白名单可见) + project_visibility_mode TEXT NOT NULL DEFAULT 'default', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); @@ -49,6 +51,20 @@ CREATE TABLE IF NOT EXISTS user_group_memberships ( CREATE INDEX IF NOT EXISTS idx_user_group_memberships_group ON user_group_memberships(group_id, user_id); +-- 受限群组显式授权可见的项目白名单 (group↔project). +CREATE TABLE IF NOT EXISTS group_visible_projects ( + group_id TEXT NOT NULL, + project_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + PRIMARY KEY (group_id, project_id), + FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_group_visible_projects_group + ON group_visible_projects(group_id); +CREATE INDEX IF NOT EXISTS idx_group_visible_projects_project + ON group_visible_projects(project_id); + CREATE TABLE IF NOT EXISTS user_preferences ( user_id TEXT PRIMARY KEY, response_style TEXT NOT NULL DEFAULT 'detailed' CHECK(response_style IN ('concise','detailed','very_detailed')), From 62d884f42016645a786104be248e3587b325d2c4 Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sat, 1 Aug 2026 17:30:48 +0000 Subject: [PATCH 15/30] =?UTF-8?q?Rebalance=20campaign=20difficulty=20with?= =?UTF-8?q?=20calculated=20progression=20(=E6=8C=89=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E6=9B=B2=E7=BA=BF=E9=87=8D=E5=B9=B3=E8=A1=A1=E6=88=98=E5=BD=B9?= =?UTF-8?q?=E9=9A=BE=E5=BA=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobius/extension/toy-toy-toy/extension.json | 2 +- .../extension/toy-toy-toy/frontend/index.html | 4 +- mobius/extension/toy-toy-toy/frontend/main.js | 139 ++++++++++++------ 3 files changed, 97 insertions(+), 48 deletions(-) diff --git a/mobius/extension/toy-toy-toy/extension.json b/mobius/extension/toy-toy-toy/extension.json index 649b0208..b71b2db5 100644 --- a/mobius/extension/toy-toy-toy/extension.json +++ b/mobius/extension/toy-toy-toy/extension.json @@ -2,7 +2,7 @@ "name": "toy-toy-toy", "display_name": "广告爽游实验室", "description": "双题材十关广告爽游:随队算术门、扩展角色编成与指数生命终局 Boss。", - "version": "0.8.0", + "version": "0.8.2", "icon": "favicon.svg", "project": { "sync": true diff --git a/mobius/extension/toy-toy-toy/frontend/index.html b/mobius/extension/toy-toy-toy/frontend/index.html index 1bbfe3c3..bba0862e 100644 --- a/mobius/extension/toy-toy-toy/frontend/index.html +++ b/mobius/extension/toy-toy-toy/frontend/index.html @@ -6,7 +6,7 @@ 广告爽游实验室 - + + diff --git a/mobius/extension/toy-toy-toy/frontend/main.js b/mobius/extension/toy-toy-toy/frontend/main.js index 9075d97f..2b8948ca 100644 --- a/mobius/extension/toy-toy-toy/frontend/main.js +++ b/mobius/extension/toy-toy-toy/frontend/main.js @@ -11,7 +11,8 @@ const WORLD = Object.freeze({ maxProjectiles: 260, }); const MAX_CANNONS = 8; -const BOSS_HP_GROWTH = 1.72; +// 修复基础科技与多炮台分摊后,正确的 L10 构筑约有 L1 的 60 倍输出;1.48 曲线让终局 Boss 约为 L1 的 57 倍,而非旧版 221 倍数学绝境。 +const BOSS_HP_GROWTH = 1.48; const THEMES = Object.freeze({ zombie: { @@ -134,9 +135,9 @@ const THEMES = Object.freeze({ bossAt: 50, firstUpgradeAt: 9, upgradeInterval: 11, - spawnMultiplier: 1.18, + spawnMultiplier: 1.08, hpMultiplier: 0.82, - speedMultiplier: 1.1, + speedMultiplier: 1.04, bossHpMultiplier: 0.92, bossSpeed: 0.47, palette: { @@ -219,27 +220,27 @@ const ENEMY_ROLES = Object.freeze({ const CAMPAIGNS = Object.freeze({ zombie: [ { title: '封锁线外缘', description: '基础尸群,熟悉三路火力和随队推进的算术门。', duration: 64, bossAt: 45, spawn: 0.82, hp: 0.72, speed: 0.9, bossHp: 1, roles: ['shambler', 'crawler', 'sprinter'], boss: '门卫尸长 · 大门牙', bossTint: 0xff6f65, bossScale: 3.05 }, - { title: '废弃便利店', description: '腐肉胖尸开始顶在队伍前面,错误选择会明显漏怪。', duration: 68, bossAt: 48, spawn: 0.9, hp: 0.82, speed: 0.93, bossHp: 1.05, roles: ['shambler', 'crawler', 'sprinter', 'bloater'], boss: '冰柜屠夫 · FROZEN', bossTint: 0x9ddfff, bossScale: 3.15 }, - { title: '地铁末班车', description: '狂奔者和喷吐者混编,要求更快切换攻击路线。', duration: 72, bossAt: 51, spawn: 0.98, hp: 0.92, speed: 0.97, bossHp: 1.1, roles: ['shambler', 'sprinter', 'spitter', 'bloater'], boss: '站台尖啸者 · LINE 13', bossTint: 0xff78dc, bossScale: 3.25 }, - { title: '医院夜班', description: '装甲尸出现,算术选择开始决定能否穿透前排。', duration: 76, bossAt: 54, spawn: 1.05, hp: 1.02, speed: 1, bossHp: 1.16, roles: ['crawler', 'spitter', 'bloater', 'armored'], boss: '缝合护士长 · NIGHT SHIFT', bossTint: 0xd8c2ff, bossScale: 3.35 }, - { title: '高速收费站', description: '变异精英加入冲线,炮台数量和单发火力需要取舍。', duration: 80, bossAt: 57, spawn: 1.12, hp: 1.12, speed: 1.03, bossHp: 1.22, roles: ['shambler', 'sprinter', 'armored', 'mutant'], boss: '收费站暴君 · NO EXIT', bossTint: 0xff685f, bossScale: 3.45 }, - { title: '地下实验室', description: '尖啸者和变异体成群出现,错误构筑会被精英压垮。', duration: 84, bossAt: 60, spawn: 1.2, hp: 1.24, speed: 1.06, bossHp: 1.3, roles: ['spitter', 'armored', 'mutant', 'screamer'], boss: '失控实验体 · SUBJECT 06', bossTint: 0xd35bff, bossScale: 3.55 }, - { title: '工业尸巢', description: '尸巢守卫进入战场,需要成型的爆炸、连锁或分裂构筑。', duration: 88, bossAt: 63, spawn: 1.28, hp: 1.36, speed: 1.08, bossHp: 1.38, roles: ['bloater', 'mutant', 'screamer', 'nestGuard'], boss: '孵化母体 · HIVE MOTHER', bossTint: 0xff55c8, bossScale: 3.65 }, - { title: '军事封锁区', description: '装甲精英密集推进,Boss 生命正式进入指数区间。', duration: 92, bossAt: 66, spawn: 1.36, hp: 1.48, speed: 1.1, bossHp: 1.46, roles: ['armored', 'mutant', 'nestGuard', 'alpha'], boss: '装甲尸将 · WARLORD', bossTint: 0xff514f, bossScale: 3.78 }, - { title: '核心尸城', description: '高阶角色全量混编,必须围绕前几次选择规划终局。', duration: 97, bossAt: 70, spawn: 1.46, hp: 1.62, speed: 1.12, bossHp: 1.56, roles: ['spitter', 'armored', 'screamer', 'nestGuard', 'alpha'], boss: '双头尸皇 · TWIN CROWN', bossTint: 0xff3d72, bossScale: 3.92 }, - { title: '终焉防线', description: '最终试炼:只有连续做对算术选择,才有机会击穿尸王。', duration: 104, bossAt: 76, spawn: 1.58, hp: 1.78, speed: 1.15, bossHp: 1.68, roles: ['mutant', 'screamer', 'nestGuard', 'alpha'], boss: '巨型尸王 · OMEGA', bossTint: 0xff2e4f, bossScale: 4.15 }, + { title: '废弃便利店', description: '腐肉胖尸开始顶在队伍前面,错误选择会明显漏怪。', duration: 68, bossAt: 48, spawn: 0.86, hp: 0.78, speed: 0.93, bossHp: 1.05, roles: ['shambler', 'crawler', 'sprinter', 'bloater'], boss: '冰柜屠夫 · FROZEN', bossTint: 0x9ddfff, bossScale: 3.15 }, + { title: '地铁末班车', description: '狂奔者和喷吐者混编,要求更快切换攻击路线。', duration: 72, bossAt: 51, spawn: 0.91, hp: 0.84, speed: 0.97, bossHp: 1.1, roles: ['shambler', 'sprinter', 'spitter', 'bloater'], boss: '站台尖啸者 · LINE 13', bossTint: 0xff78dc, bossScale: 3.25 }, + { title: '医院夜班', description: '装甲尸出现,算术选择开始决定能否穿透前排。', duration: 76, bossAt: 54, spawn: 0.97, hp: 0.9, speed: 1, bossHp: 1.16, roles: ['crawler', 'spitter', 'bloater', 'armored'], boss: '缝合护士长 · NIGHT SHIFT', bossTint: 0xd8c2ff, bossScale: 3.35 }, + { title: '高速收费站', description: '变异精英加入冲线,炮台数量和单发火力需要取舍。', duration: 80, bossAt: 57, spawn: 1.03, hp: 0.96, speed: 1.03, bossHp: 1.22, roles: ['shambler', 'sprinter', 'armored', 'mutant'], boss: '收费站暴君 · NO EXIT', bossTint: 0xff685f, bossScale: 3.45 }, + { title: '地下实验室', description: '尖啸者和变异体混进杂兵潮,错误构筑会被精英压垮。', duration: 84, bossAt: 60, spawn: 1.09, hp: 1.02, speed: 1.06, bossHp: 1.3, roles: ['shambler', 'sprinter', 'spitter', 'armored', 'mutant', 'screamer'], boss: '失控实验体 · SUBJECT 06', bossTint: 0xd35bff, bossScale: 3.55 }, + { title: '工业尸巢', description: '尸巢守卫混在大量杂兵中,需要成型的爆炸、连锁或分裂构筑。', duration: 88, bossAt: 63, spawn: 1.15, hp: 1.08, speed: 1.08, bossHp: 1.38, roles: ['shambler', 'crawler', 'bloater', 'mutant', 'screamer', 'nestGuard'], boss: '孵化母体 · HIVE MOTHER', bossTint: 0xff55c8, bossScale: 3.65 }, + { title: '军事封锁区', description: '杂兵掩护装甲精英推进,Boss 生命正式进入指数区间。', duration: 92, bossAt: 66, spawn: 1.21, hp: 1.14, speed: 1.1, bossHp: 1.46, roles: ['shambler', 'sprinter', 'armored', 'mutant', 'nestGuard', 'alpha'], boss: '装甲尸将 · WARLORD', bossTint: 0xff514f, bossScale: 3.78 }, + { title: '核心尸城', description: '小怪与高阶角色全量混编,必须围绕前几次选择规划终局。', duration: 97, bossAt: 70, spawn: 1.27, hp: 1.21, speed: 1.12, bossHp: 1.56, roles: ['crawler', 'spitter', 'armored', 'screamer', 'nestGuard', 'alpha'], boss: '双头尸皇 · TWIN CROWN', bossTint: 0xff3d72, bossScale: 3.92 }, + { title: '终焉防线', description: '最终试炼:清理杂兵、击穿精英并连续做对算术选择,才有机会击杀尸王。', duration: 104, bossAt: 76, spawn: 1.34, hp: 1.28, speed: 1.15, bossHp: 1.68, roles: ['shambler', 'sprinter', 'mutant', 'screamer', 'nestGuard', 'alpha'], boss: '巨型尸王 · OMEGA', bossTint: 0xff2e4f, bossScale: 4.15 }, ], deadline: [ { title: '本地开发', description: '普通 Bug 与直推实习生,先熟悉工单算术门。', duration: 62, bossAt: 44, spawn: 0.86, hp: 0.68, speed: 0.93, bossHp: 0.96, roles: ['bug', 'intern', 'qa'], boss: '合并冲突 · FIRST BLOOD', bossTint: 0xff7182, bossScale: 3.05 }, - { title: '测试环境', description: '测试同事不断补单,产品经理开始作为肉盾推进。', duration: 66, bossAt: 47, spawn: 0.94, hp: 0.78, speed: 0.97, bossHp: 1.02, roles: ['bug', 'intern', 'qa', 'product'], boss: '回归测试清单 · 999+', bossTint: 0x8b9cff, bossScale: 3.14 }, - { title: '三方联调', description: '报警运维加入战场,反馈流速明显加快。', duration: 70, bossAt: 50, spawn: 1.02, hp: 0.88, speed: 1, bossHp: 1.08, roles: ['bug', 'qa', 'product', 'ops'], boss: '接口字段改名 · V2 FINAL', bossTint: 0xffa25f, bossScale: 3.24 }, - { title: '需求评审', description: '产品与架构师组成厚血前排,需要重新评估团队编制。', duration: 74, bossAt: 53, spawn: 1.1, hp: 0.98, speed: 1.03, bossHp: 1.14, roles: ['intern', 'product', 'ops', 'architect'], boss: '五彩斑斓 PRD · 88 页', bossTint: 0xac8cff, bossScale: 3.34 }, - { title: '灰度发布', description: '安全审计首次出现,单纯堆射速已经不够。', duration: 78, bossAt: 56, spawn: 1.18, hp: 1.08, speed: 1.06, bossHp: 1.2, roles: ['qa', 'ops', 'architect', 'security'], boss: '灰度异常 · 1% 用户全炸', bossTint: 0xd474ff, bossScale: 3.44 }, - { title: '大促前夜', description: 'Leader 和报警一起到场,选择错误会拖垮生产稳定度。', duration: 82, bossAt: 59, spawn: 1.26, hp: 1.2, speed: 1.08, bossHp: 1.28, roles: ['product', 'ops', 'security', 'leader'], boss: '零点大促 · TRAFFIC ×100', bossTint: 0xff6da8, bossScale: 3.54 }, - { title: '生产事故', description: '驻场甲方加入精英波次,工单构筑必须开始成型。', duration: 86, bossAt: 62, spawn: 1.34, hp: 1.32, speed: 1.1, bossHp: 1.36, roles: ['architect', 'security', 'leader', 'clientRep'], boss: '生产全红 · SEV-0', bossTint: 0xff4f68, bossScale: 3.64 }, - { title: '安全审计', description: '高血量审计与甲方代表混编,Boss 生命进入指数区。', duration: 90, bossAt: 65, spawn: 1.42, hp: 1.44, speed: 1.12, bossHp: 1.44, roles: ['ops', 'security', 'leader', 'clientRep'], boss: '合规整改 · DEADLINE TODAY', bossTint: 0xe154ff, bossScale: 3.76 }, - { title: '董事会 Demo', description: '业务总监加入战线,每一次算术选择都在决定演示生死。', duration: 96, bossAt: 69, spawn: 1.52, hp: 1.58, speed: 1.14, bossHp: 1.54, roles: ['security', 'leader', 'clientRep', 'executive'], boss: '董事会临时演示 · LIVE', bossTint: 0xff3f88, bossScale: 3.9 }, - { title: '全球上线', description: '最终试炼:必须形成指数级工单输出,才能拒绝最终需求。', duration: 102, bossAt: 75, spawn: 1.64, hp: 1.74, speed: 1.17, bossHp: 1.66, roles: ['architect', 'leader', 'clientRep', 'executive'], boss: '全球上线前临时改需求 · FINAL', bossTint: 0xff285f, bossScale: 4.12 }, + { title: '测试环境', description: '测试同事不断补单,产品经理开始作为肉盾推进。', duration: 66, bossAt: 47, spawn: 0.89, hp: 0.75, speed: 0.97, bossHp: 1.02, roles: ['bug', 'intern', 'qa', 'product'], boss: '回归测试清单 · 999+', bossTint: 0x8b9cff, bossScale: 3.14 }, + { title: '三方联调', description: '报警运维加入战场,反馈流速明显加快。', duration: 70, bossAt: 50, spawn: 0.94, hp: 0.82, speed: 1, bossHp: 1.08, roles: ['bug', 'qa', 'product', 'ops'], boss: '接口字段改名 · V2 FINAL', bossTint: 0xffa25f, bossScale: 3.24 }, + { title: '需求评审', description: '产品与架构师组成厚血前排,需要重新评估团队编制。', duration: 74, bossAt: 53, spawn: 1, hp: 0.89, speed: 1.03, bossHp: 1.14, roles: ['intern', 'product', 'ops', 'architect'], boss: '五彩斑斓 PRD · 88 页', bossTint: 0xac8cff, bossScale: 3.34 }, + { title: '灰度发布', description: '安全审计首次出现,单纯堆射速已经不够。', duration: 78, bossAt: 56, spawn: 1.06, hp: 0.96, speed: 1.06, bossHp: 1.2, roles: ['qa', 'ops', 'architect', 'security'], boss: '灰度异常 · 1% 用户全炸', bossTint: 0xd474ff, bossScale: 3.44 }, + { title: '大促前夜', description: '普通 Bug 掩护 Leader 和报警一起到场,选择错误会拖垮生产稳定度。', duration: 82, bossAt: 59, spawn: 1.12, hp: 1.03, speed: 1.08, bossHp: 1.28, roles: ['bug', 'intern', 'product', 'ops', 'security', 'leader'], boss: '零点大促 · TRAFFIC ×100', bossTint: 0xff6da8, bossScale: 3.54 }, + { title: '生产事故', description: '普通工单与驻场甲方组成精英波次,工单构筑必须开始成型。', duration: 86, bossAt: 62, spawn: 1.18, hp: 1.1, speed: 1.1, bossHp: 1.36, roles: ['bug', 'qa', 'architect', 'security', 'leader', 'clientRep'], boss: '生产全红 · SEV-0', bossTint: 0xff4f68, bossScale: 3.64 }, + { title: '安全审计', description: '普通 Bug 混入高血量审计与甲方代表,Boss 生命进入指数区。', duration: 90, bossAt: 65, spawn: 1.24, hp: 1.17, speed: 1.12, bossHp: 1.44, roles: ['bug', 'intern', 'ops', 'security', 'leader', 'clientRep'], boss: '合规整改 · DEADLINE TODAY', bossTint: 0xe154ff, bossScale: 3.76 }, + { title: '董事会 Demo', description: '杂项反馈掩护业务总监加入战线,每一次算术选择都在决定演示生死。', duration: 96, bossAt: 69, spawn: 1.3, hp: 1.24, speed: 1.14, bossHp: 1.54, roles: ['bug', 'qa', 'security', 'leader', 'clientRep', 'executive'], boss: '董事会临时演示 · LIVE', bossTint: 0xff3f88, bossScale: 3.9 }, + { title: '全球上线', description: '最终试炼:清理普通工单、压住精英需求并形成指数级输出,才能拒绝最终需求。', duration: 102, bossAt: 75, spawn: 1.38, hp: 1.32, speed: 1.17, bossHp: 1.66, roles: ['bug', 'intern', 'architect', 'leader', 'clientRep', 'executive'], boss: '全球上线前临时改需求 · FINAL', bossTint: 0xff285f, bossScale: 4.12 }, ], }); @@ -691,7 +692,7 @@ const enemyPlaneGeometry = new THREE.PlaneGeometry(1.95, 2.55); enemyPlaneGeometry.translate(0, 1.275, 0); function createEnemyMaterial(themeId, type) { - const texture = textureLoader.load(`./assets/characters/${themeId}-atlas.svg?v=0.8.0`); + const texture = textureLoader.load(`./assets/characters/${themeId}-atlas.svg?v=0.8.2`); texture.colorSpace = THREE.SRGBColorSpace; texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; @@ -824,7 +825,7 @@ const state = { nextBonusAt: 5.5, nextBarrierAt: 14, nextMysteryAt: 23, - nextGateAt: 8.5, + nextGateAt: 6.5, gatePhase: 'none', gatePrepUntil: 0, gateChoiceUntil: 0, @@ -1161,7 +1162,7 @@ function renderLevelPicker() { const frame = ENEMY_ATLAS_FRAMES[role.visual] || 0; return `
- + ${role.name}
`; @@ -1346,7 +1347,7 @@ function resetGame() { state.nextBonusAt = Number.POSITIVE_INFINITY; state.nextBarrierAt = Number.POSITIVE_INFINITY; state.nextMysteryAt = Number.POSITIVE_INFINITY; - state.nextGateAt = 7.5 + randomBetween(0, 1.5); + state.nextGateAt = 6.2 + randomBetween(0, 1.1); state.gatePhase = 'none'; state.gatePrepUntil = 0; state.gateChoiceUntil = 0; @@ -1367,7 +1368,10 @@ function resetGame() { state.shake = 0; state.flash = 0; state.telemetry = { spawned: 0, shots: 0, speech: 0, frenzyUses: 0, overdriveUses: 0, bossUses: 0, upgrades: 0, gatesOffered: 0, gatesChosen: 0 }; - state.bonuses = { damage: 1, rate: 1, crit: 0, count: 0, shards: 0, barriers: 0 }; + // 关卡敌人会增长,基础装备也必须有温和科技成长;否则高关开局的来袭 HP/s 已超过裸装 DPS,第一扇门前就数学无解。 + const campaignDamage = Math.pow(1.05, Math.max(0, state.level - 1)); + const campaignRate = Math.pow(1.04, Math.max(0, state.level - 1)); + state.bonuses = { damage: campaignDamage, rate: campaignRate, crit: 0, count: 0, shards: 0, barriers: 0 }; state.levels = { damage: 1, rate: 1, blast: 0, chain: 0, frost: 0, multi: 0, crit: 0, cannon: 1 }; wallMaterial.color.setHex(theme.palette.wall); wallMaterial.emissive.setHex(theme.palette.wallEmissive); @@ -1458,7 +1462,7 @@ function spawnEnemy(forceRole = null, options = {}) { const role = isBoss ? null : pickEnemyRole(forceRole); const type = isBoss ? 'boss' : role.visual; const lane = isBoss ? 1 : clamp(Number.isFinite(options.lane) ? options.lane : Math.floor(state.random() * 3), 0, 2); - const regularBaseHp = (18 + state.level * 3.6 + progress * 34) * theme.hpMultiplier * level.hp; + const regularBaseHp = (18 + state.level * 1.5 + progress * 31) * theme.hpMultiplier * level.hp; const hp = isBoss ? 9200 * bossHpFactor() * theme.bossHpMultiplier : regularBaseHp * role.hp; @@ -1479,7 +1483,7 @@ function spawnEnemy(forceRole = null, options = {}) { : (1.18 + progress * 0.78) * theme.speedMultiplier * level.speed * role.speed, scale: isBoss ? level.bossScale : role.scale, score: isBoss ? Math.round(5000 * Math.pow(1.45, state.level - 1)) : Math.round(role.score * (1 + state.level * 0.12)), - baseDamage: isBoss ? 100 : role.damage, + baseDamage: isBoss ? 100 : Math.max(1, Math.round(role.damage * (0.34 + (state.level - 1) * 0.007))), tint: isBoss ? level.bossTint : role.tint, slowUntil: 0, hitUntil: 0, @@ -1512,6 +1516,14 @@ function summonBoss(manual = false) { return; } if (manual) state.telemetry.bossUses += 1; + let cleared = 0; + for (const enemy of enemies) { + if (!enemy.active || enemy === boss || enemy.type === 'boss') continue; + enemy.active = false; + cleared += 1; + state.score += Math.round(enemy.score * 0.2); + } + if (cleared) addFxText(0, 1.7, -2.8, `Boss 压场清算 ${cleared}`, theme.palette.secondary, 1.35, 15); state.shake = Math.max(state.shake, 0.85); showToast(manual ? theme.director.manualBossToast : theme.director.bossToast, 2600); showOverdriveBanner(theme.director.bossBanner); @@ -1942,11 +1954,18 @@ function spawnChoiceGates() { } selected.forEach((effect, lane) => { const scaleByTeam = Math.max(0, state.levels.cannon - 1) * 0.8; - const requiredHits = Math.round(effect.hits + state.gateRound * 0.65 + state.level * 0.9 + scaleByTeam + randomBetween(-1, 3)); + const requiredHits = Math.round(effect.hits + state.gateRound * 0.55 + state.level * 0.35 + scaleByTeam + randomBetween(-1, 2)); createChoiceGate(effect, lane, requiredHits); }); - for (let index = 0; index < 12 + state.level; index += 1) { - spawnEnemy(null, { lane: index % 3, z: -10.8 + randomBetween(0, 5.6) }); + for (let index = 0; index < 5 + Math.ceil(state.level / 3); index += 1) { + const escort = spawnEnemy(null, { lane: index % 3, z: -10.8 + randomBetween(0, 5.6) }); + if (escort) { + // 挡板已经强制玩家锁定一路,随车怪只承担视觉与清怪压力,不能再叠加一整波精英的致命撞线伤害。 + escort.isGateEscort = true; + escort.hp *= 0.58; + escort.maxHp = escort.hp; + escort.baseDamage = Math.max(1, Math.round(escort.baseDamage * 0.55)); + } } state.gatePhase = 'active'; state.gateChoiceUntil = state.elapsed + 13.5; @@ -1960,7 +1979,7 @@ function spawnChoiceGates() { function finishGateWindow(delay = 1.25) { state.gatePhase = 'resume'; state.gateResumeAt = state.elapsed + delay; - state.nextGateAt = state.elapsed + 6.5 + randomBetween(0, 2.4); + state.nextGateAt = state.elapsed + 4.8 + randomBetween(0, 1.6); } function resolveChoiceGate(gate) { @@ -1970,9 +1989,17 @@ function resolveChoiceGate(gate) { state.lastGateEffect = gate.effect.id; state.telemetry.gatesChosen += 1; state.bonuses.count += 1; + state.baseHp = Math.min(100, state.baseHp + 6); state.score += 1300 + state.gateRound * 260; + let convoyCleared = 0; + for (const enemy of enemies) { + if (!enemy.active || !enemy.isGateEscort) continue; + killEnemy(enemy); + convoyCleared += 1; + } const color = cssHex(gate.effect.color); addShockwave(gate.x, gate.z, color, 3.8); + if (convoyCleared) addFxText(gate.x, 2.2, gate.z + 0.8, `选择冲击波 ×${convoyCleared}`, color, 1.2, 16); for (let index = 0; index < 26; index += 1) addFxParticle(gate.x, 1.3, gate.z, color, 1.2); for (const other of [...choiceGates]) { if (other === gate) expireChoiceGate(other, 'CHOICE LOCKED'); @@ -2051,17 +2078,17 @@ function livingEnemies() { function updateSpawning(dt) { const theme = currentTheme(); const level = currentLevel(); + if (state.bossAlive) return; const progress = clamp(state.elapsed / level.duration, 0, 1); const living = livingEnemies(); const nearestZ = living.reduce((max, enemy) => Math.max(max, enemy.z), WORLD.spawnZ); - let spawnRate = (1.55 + progress * 5.1) * theme.spawnMultiplier * level.spawn; + let spawnRate = (1.35 + progress * 2.7) * theme.spawnMultiplier * level.spawn; if (state.gatePhase === 'prep') spawnRate *= 0.72; - if (state.gatePhase === 'active') spawnRate *= 0.56; + if (state.gatePhase === 'active') spawnRate *= 0.44; if (state.gatePhase === 'resume') spawnRate *= 0.8; if (state.elapsed < state.frenzyUntil) spawnRate *= 10; - if (living.length < 18 && nearestZ < 4) spawnRate *= 1.55; + if (living.length < 18 && nearestZ < 4) spawnRate *= 1.3; if (living.length > 360) spawnRate *= 0.42; - if (state.bossAlive) spawnRate *= 0.62; state.spawnAcc += spawnRate * dt; while (state.spawnAcc >= 1) { @@ -2097,12 +2124,12 @@ function updateEnemies(dt) { } } - if (state.baseHp <= 0) endGame(false); if (state.baseHp < 30 && !state.bailoutUsed && state.mode === 'playing') { state.bailoutUsed = true; state.baseHp = Math.max(state.baseHp, 22); triggerOverdrive(true); } + if (state.baseHp <= 0) endGame(false); } function findTargets(lane, count = 1) { @@ -2176,7 +2203,8 @@ function updateTurrets(dt) { const combat = currentCombatStats(); const baseInterval = combat.fireInterval; const baseDamage = combat.damage; - const targetCount = 1 + Math.min(3, state.levels.multi); + const targetsPerCannon = 1 + Math.min(3, state.levels.multi); + const targetCount = cannonCount * targetsPerCannon; const targets = findTargets(state.focusLane, targetCount); turretGroups.forEach((turret, index) => { @@ -2193,9 +2221,13 @@ function updateTurrets(dt) { const targetZ = 10.2 + slot.z; turret.group.position.x = lerp(turret.group.position.x, targetX, Math.min(1, dt * 11)); turret.group.position.z = lerp(turret.group.position.z, targetZ, Math.min(1, dt * 11)); - if (targets[0]) { - const dx = targets[0].x - turret.group.position.x; - const dz = targets[0].z - turret.group.position.z; + const turretTargets = targets.length <= 1 + ? targets + : Array.from({ length: targetsPerCannon }, (_, targetIndex) => targets[(index + targetIndex * cannonCount) % targets.length]) + .filter((target, targetIndex, list) => list.indexOf(target) === targetIndex); + if (turretTargets[0]) { + const dx = turretTargets[0].x - turret.group.position.x; + const dz = turretTargets[0].z - turret.group.position.z; turret.targetRotation = -Math.atan2(dx, -dz); } turret.pivot.rotation.y = lerp(turret.pivot.rotation.y, turret.targetRotation, Math.min(1, dt * 9)); @@ -2213,9 +2245,9 @@ function updateTurrets(dt) { state.fireAcc[index] += dt; const aligned = Math.abs(turret.group.position.x - targetX) < 0.28; let safety = 0; - while (aligned && state.fireAcc[index] >= baseInterval && targets.length && safety < 7) { + while (aligned && state.fireAcc[index] >= baseInterval && turretTargets.length && safety < 7) { state.fireAcc[index] -= baseInterval; - targets.forEach((target, targetIndex) => fireProjectile(turret, target, baseDamage * (targetIndex ? 0.78 : 1))); + turretTargets.forEach((target, targetIndex) => fireProjectile(turret, target, baseDamage * (targetIndex ? 0.78 : 1))); safety += 1; if (performance.now() - state.lastShotSoundAt > 48) { state.lastShotSoundAt = performance.now(); @@ -2271,6 +2303,12 @@ function applyDamage(enemy, amount, options = {}) { if (!options.primary) return; enemy.hitsRemaining = Math.max(0, enemy.hitsRemaining - 1); enemy.hitUntil = state.elapsed + 0.12; + // 选择门混在敌群里时,专注打门不应等于完全放弃防守;主弹会穿透门,对同路最近护送怪造成部分伤害。 + const piercedTargets = enemies + .filter((target) => target.active && target.type !== 'boss' && target.lane === enemy.lane) + .sort((a, b) => Math.abs(a.z - enemy.z) - Math.abs(b.z - enemy.z)) + .slice(0, 1 + Math.min(2, state.levels.multi)); + piercedTargets.forEach((target) => applyDamage(target, amount * 0.52, { gatePierce: true })); enemy.board.redraw(enemy.hitsRemaining); enemy.board.texture.needsUpdate = true; addFxText( @@ -2917,8 +2955,9 @@ window.__TOY_TOY_TOY_DEBUG__ = Object.freeze({ snapshot() { const combat = currentCombatStats(); return { - version: '0.8.0', + version: '0.8.2', mode: state.mode, + lastVictory: state.lastVictory, theme: state.themeId, level: state.level, levelTitle: currentLevel().title, @@ -2928,8 +2967,18 @@ window.__TOY_TOY_TOY_DEBUG__ = Object.freeze({ roleCatalog: currentLevel().roles.map((id) => currentRoleMap()[id]?.name).filter(Boolean), elapsed: state.elapsed, speed: state.speed, + baseHp: state.baseHp, focusLane: state.focusLane, livingEnemies: livingEnemies().length, + laneThreat: [0, 1, 2].map((lane) => { + const laneEnemies = enemies.filter((enemy) => enemy.active && enemy.lane === lane); + return { + lane, + count: laneEnemies.length, + nearestZ: laneEnemies.reduce((nearest, enemy) => Math.max(nearest, enemy.z), WORLD.spawnZ), + totalHp: laneEnemies.reduce((sum, enemy) => sum + enemy.hp, 0), + }; + }), activeBonusTargets: bonusTargets.filter((target) => target.active).map((target) => target.rewardType), gate: { phase: state.gatePhase, @@ -2958,7 +3007,7 @@ window.__TOY_TOY_TOY_DEBUG__ = Object.freeze({ fireInterval: combat.fireInterval, cannonCount: state.levels.cannon, projectileStyle: state.themeId === 'deadline' ? 'ticket-feedback' : 'energy-shell', - targetCount: 1 + Math.min(3, state.levels.multi), + targetCount: Math.min(MAX_CANNONS, state.levels.cannon) * (1 + Math.min(3, state.levels.multi)), }, visuals: { visibleCannons: turretGroups.filter((turret) => turret.group.visible && turret.cannonModel.visible).length, From e1d60b354ab5cae346fd1a0dd52d8cea0921d24d Mon Sep 17 00:00:00 2001 From: Mobius OS Date: Sun, 2 Aug 2026 00:53:31 +0000 Subject: [PATCH 16/30] =?UTF-8?q?Keep=20article=20generation=20running=20i?= =?UTF-8?q?n=20background=20(=E6=94=AF=E6=8C=81=E5=85=AC=E4=BC=97=E5=8F=B7?= =?UTF-8?q?=E5=88=9D=E7=A8=BF=E5=90=8E=E5=8F=B0=E6=8C=81=E7=BB=AD=E7=94=9F?= =?UTF-8?q?=E6=88=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- .../wechat-article/backend/article-worker.js | 187 ++++++++ .../backend/extension_backend_handler.js | 289 ++++++++++++ .../wechat-article/backend/lib/claims.js | 49 ++ .../backend/lib/config-store.js | 80 ++++ .../wechat-article/backend/lib/crypto.js | 39 ++ .../wechat-article/backend/lib/humanize.js | 42 ++ .../wechat-article/backend/lib/image.js | 83 ++++ .../wechat-article/backend/lib/job-store.js | 131 ++++++ .../wechat-article/backend/lib/llm.js | 129 +++++ .../wechat-article/backend/lib/render.js | 61 +++ .../wechat-article/backend/lib/research.js | 115 +++++ .../wechat-article/backend/lib/safe-fetch.js | 121 +++++ .../wechat-article/backend/lib/store.js | 158 +++++++ .../wechat-article/backend/lib/wechat.js | 143 ++++++ .../wechat-article/backend/lib/write.js | 65 +++ .../extension/wechat-article/extension.json | 9 + .../extension/wechat-article/frontend/app.css | 103 ++++ .../wechat-article/frontend/index.html | 14 + .../extension/wechat-article/frontend/main.js | 441 ++++++++++++++++++ mobius/tests/wechat-article-background-job.js | 90 ++++ scripts/check-js-syntax.sh | 10 + 22 files changed, 2360 insertions(+), 1 deletion(-) create mode 100644 mobius/extension/wechat-article/backend/article-worker.js create mode 100644 mobius/extension/wechat-article/backend/extension_backend_handler.js create mode 100644 mobius/extension/wechat-article/backend/lib/claims.js create mode 100644 mobius/extension/wechat-article/backend/lib/config-store.js create mode 100644 mobius/extension/wechat-article/backend/lib/crypto.js create mode 100644 mobius/extension/wechat-article/backend/lib/humanize.js create mode 100644 mobius/extension/wechat-article/backend/lib/image.js create mode 100644 mobius/extension/wechat-article/backend/lib/job-store.js create mode 100644 mobius/extension/wechat-article/backend/lib/llm.js create mode 100644 mobius/extension/wechat-article/backend/lib/render.js create mode 100644 mobius/extension/wechat-article/backend/lib/research.js create mode 100644 mobius/extension/wechat-article/backend/lib/safe-fetch.js create mode 100644 mobius/extension/wechat-article/backend/lib/store.js create mode 100644 mobius/extension/wechat-article/backend/lib/wechat.js create mode 100644 mobius/extension/wechat-article/backend/lib/write.js create mode 100644 mobius/extension/wechat-article/extension.json create mode 100644 mobius/extension/wechat-article/frontend/app.css create mode 100644 mobius/extension/wechat-article/frontend/index.html create mode 100644 mobius/extension/wechat-article/frontend/main.js create mode 100644 mobius/tests/wechat-article-background-job.js create mode 100755 scripts/check-js-syntax.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc561414..13979966 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -72,7 +72,7 @@ repos: # JS 语法检查 (node --check) — 秒级, 拦截语法错误 - id: node-check-js name: node --check (.js 语法) - entry: node --check + entry: scripts/check-js-syntax.sh language: system files: \.js$ exclude: ^mobius/frontend/dist/|^mobius/frontend/.build/|^mobius/frontend/node_modules/|^mobius/extension/.*/frontend/dist/|^docs/|^\.trash/ diff --git a/mobius/extension/wechat-article/backend/article-worker.js b/mobius/extension/wechat-article/backend/article-worker.js new file mode 100644 index 00000000..9ce9995f --- /dev/null +++ b/mobius/extension/wechat-article/backend/article-worker.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +// article-worker.js — detached 长任务编排(方案 §4)。 +// 由 handler 经 spawn(process.execPath, [__filename, specPath], {detached:true}) 启动,与 30s 的 handler 隔离。 +// 读 argv[2] = spec.json(含 jobId / extDataDir / topic / mode / articleId)。10s 心跳;阶段推进写 checkpoint; +// 协作式取消(阶段间查 status.state===cancelled)+ 强制取消(handler 杀进程组)。 +// 状态机:queued→researching→outlining→writing→reviewing→(waiting_user|rendering→uploading)→done。 +// 无微信凭据时停在 waiting_user(产出可编辑稿 + 微信预览),不强行推送。 + +const fs = require("fs"), path = require("path"); + +const specPath = process.argv[2]; +if (!specPath) { console.error("article-worker: missing spec path"); process.exit(2); } +let SPEC; +try { SPEC = JSON.parse(fs.readFileSync(specPath, "utf8")); } +catch (e) { console.error("article-worker: bad spec: " + e.message); process.exit(2); } + +const { extDataDir, mode } = SPEC; +const jobId = SPEC.jobId || SPEC.id; // createJob 写入字段名为 id +if (!extDataDir || !jobId) { console.error("article-worker: spec missing extDataDir/jobId"); process.exit(2); } + +const job = require("./lib/job-store"); +const store = require("./lib/store"); +const cfg = require("./lib/config-store"); +const llm = require("./lib/llm"); +const research = require("./lib/research"); +const claims = require("./lib/claims"); +const write = require("./lib/write"); +const humanize = require("./lib/humanize"); +const { render } = require("./lib/render"); + +const logFile = path.join(extDataDir, "jobs", jobId, "worker.log"); +const log = (...a) => { try { fs.appendFileSync(logFile, a.map((x) => String(x)).join(" ") + "\n"); } catch (_) {} }; +const logger = { info: (...a) => log("[info]", ...a), warn: (...a) => log("[warn]", ...a), error: (...a) => log("[error]", ...a) }; + +function setState(state, phase, message, extra = {}) { + job.updateStatus(extDataDir, jobId, { state, phase, message, ...extra }); + job.appendEvent(extDataDir, jobId, { type: "state", state, phase, message }); + log("[state]", state, phase, message); +} +function setProgress(progress, message) { job.updateStatus(extDataDir, jobId, { progress, message }); } +function isCancelled() { const st = job.readStatusRaw(extDataDir, jobId); return st && st.state === "cancelled"; } +function throwIfCancelled() { if (isCancelled()) { setState("cancelled", "cancelled", "用户取消"); stopHeartbeat(); process.exit(0); } } + +let heartbeatTimer; +function startHeartbeat() { heartbeatTimer = setInterval(() => { try { job.heartbeat(extDataDir, jobId); } catch (_) {} }, 10_000); heartbeatTimer.unref && heartbeatTimer.unref(); } +function stopHeartbeat() { if (heartbeatTimer) clearInterval(heartbeatTimer); } + +function openDb() { try { return store.open(extDataDir); } catch (e) { logger.warn("db open 失败(降级无库): " + e.message); return null; } } + +async function runArticle() { + const topic = SPEC.topic || { title: SPEC.title || "未命名选题", angle: "", framework: SPEC.framework || "interpretation", referenceUrls: SPEC.referenceUrls || [], questions: SPEC.questions || "" }; + const config = cfg.load(extDataDir); + const provider = llm.findProvider(SPEC.modelKey || config.model_key || null); + const profile = config.account_profile || {}; + const style = config.style || {}; + const budgets = config.budgets || {}; + const db = openDb(); + + // researching + setState("researching", "research", "开始检索资料"); + const { evidence, note } = await research.runResearch({ topic, db, provider, budgets, logger }); + throwIfCancelled(); + job.writeCheckpoint(extDataDir, jobId, { phase: "research", evidence }); + setProgress(0.25, note || "资料就绪"); + + // outlining + setState("outlining", "outline", "抽取事实并拟定大纲"); + let facts = []; + try { facts = await claims.extractFacts({ provider, evidence, topic }); } + catch (e) { logger.warn("事实抽取失败,降级为无结构化事实继续生成: " + (e.message || e)); } + throwIfCancelled(); + let outlineObj; + try { outlineObj = await write.outline({ provider, topic, facts, profile, style }); } + catch (e) { + logger.warn("大纲生成失败,使用基础大纲继续生成: " + (e.message || e)); + outlineObj = { title: topic.title, digest: "", outline: [] }; + } + throwIfCancelled(); + job.writeCheckpoint(extDataDir, jobId, { phase: "outline", facts, outline: outlineObj }); + setProgress(0.4, "大纲就绪"); + + // writing + setState("writing", "write", "撰写正文"); + const { bodyMd } = await write.draft({ provider, topic, profile, style, facts, outlineObj }); + throwIfCancelled(); + job.writeCheckpoint(extDataDir, jobId, { phase: "draft", bodyMd }); + + // reviewing: humanize + 主张账本绑定 + setState("reviewing", "review", "去 AI 味 + 主张账本"); + let hum; + try { hum = await humanize.humanize({ provider, bodyMd, style }); } + catch (e) { + logger.warn("去 AI 味阶段超时或失败,保留原稿继续: " + (e.message || e)); + hum = { bodyMd, detection: humanize.detect(bodyMd), changed: false, skipped: "model_error" }; + } + throwIfCancelled(); + let boundClaims = []; + try { boundClaims = await claims.bindToArticle({ provider, bodyMd: hum.bodyMd, evidence }); } + catch (e) { logger.warn("主张账本绑定失败,降级为空账本继续: " + (e.message || e)); } + const lintResult = claims.lint({ claims: boundClaims, evidence }); + job.writeCheckpoint(extDataDir, jobId, { phase: "review", bodyMd: hum.bodyMd, claims: boundClaims, lint: lintResult, humanize: hum.detection }); + + // rendering + setState("rendering", "render", "渲染微信 HTML"); + const bodyHtml = render(hum.bodyMd); + const title = (outlineObj.title || topic.title || "未命名").slice(0, 32); + const digest = (outlineObj.digest || "").slice(0, 120); + const articleId = SPEC.articleId || ("art_" + jobId.replace(/^article_/, "")); + + if (db) { + try { + store.upsertArticle(db, { id: articleId, job_id: jobId, title, digest, body_md: hum.bodyMd, body_html: bodyHtml, + framework: topic.framework, outline: JSON.stringify(outlineObj), state: "draft", + quality: JSON.stringify({ lint: lintResult, humanize: hum.detection, facts: facts.length, evidence: evidence.length }) }); + const insEv = db.prepare("INSERT OR REPLACE INTO evidence (id,article_id,source_url,source_name,author,published_at,fetched_at,excerpt,content_hash,tier,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)"); + evidence.forEach((e) => insEv.run(e.id, articleId, e.source_url, e.source_name, e.author, e.published_at, e.fetched_at, e.excerpt, e.content_hash, e.tier, new Date().toISOString())); + const insCl = db.prepare("INSERT OR REPLACE INTO claim (id,article_id,paragraph_idx,claim_text,risk,evidence_id,relation,resolved,note,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)"); + boundClaims.forEach((c) => insCl.run(c.id, articleId, c.paragraph_idx, c.claim_text, c.risk, c.evidence_id, c.relation, c.resolved, "", new Date().toISOString())); + } catch (e) { logger.warn("落库失败: " + e.message); } + } + + // 自动推送:仅当 mode=auto_push 且凭据齐全;否则停 waiting_user + const wantPush = SPEC.mode === "auto_push" && config.wx && config.wx.appid && config.wx.secret; + if (wantPush) { + await pushToWechat({ db, articleId, title, digest, bodyHtml, config, declaration: config.ai_declaration }); + } + + setState(wantPush ? "done" : "waiting_user", wantPush ? "done" : "review", wantPush ? "已推送至草稿箱" : "初稿就绪,等待编辑确认", + { progress: 1, articleId, title, digest }); + job.writeCheckpoint(extDataDir, jobId, { phase: "done", articleId, title, digest, bodyMd: hum.bodyMd, bodyHtml }); + if (db) try { db.close(); } catch (_) {} +} + +async function runPushOnly() { + const articleId = SPEC.articleId; + if (!articleId) throw new Error("push_only 缺 articleId"); + const config = cfg.load(extDataDir); + if (!config.wx || !config.wx.appid || !config.wx.secret) throw new Error("未配置微信凭据"); + const db = openDb(); + if (!db) throw new Error("数据库不可用"); + const art = store.getArticle(db, articleId); + if (!art) throw new Error("文章不存在: " + articleId); + setState("uploading", "upload", "推送到微信草稿箱"); + await pushToWechat({ db, articleId, title: art.title, digest: art.digest, bodyHtml: art.body_html, config, declaration: config.ai_declaration }); + setState("done", "done", "已推送至草稿箱", { progress: 1, articleId }); + try { db.close(); } catch (_) {} +} + +async function pushToWechat({ db, articleId, title, digest, bodyHtml, config, declaration }) { + const wechat = require("./lib/wechat"); + const image = require("./lib/image"); + const ctx = { extDataDir, appid: config.wx.appid, secret: config.wx.secret, logger }; + let thumbMediaId = ""; + try { + const cover = await image.generatePlaceholderCover({ extDataDir, title, subtitle: digest }); + if (!cover.need_rasterize) thumbMediaId = await wechat.uploadThumb(ctx, cover.path); + else logger.warn("封面为 SVG(无 sharp 栅格化),跳过上传,请在后台手动设置封面"); + } catch (e) { logger.warn("封面生成/上传失败: " + e.message); } + let r; + try { + r = await wechat.addDraft(ctx, { title, author: "", digest, bodyHtml, coverMediaId: thumbMediaId, declaration }); + } catch (e) { + if (e.unknown) { + setState("unknown_external_result", "upload", "draft/add 超时,结果未知,请用 reconcile_draft 对账或查公众号后台"); + if (db) try { store.upsertArticle(db, { id: articleId, state: "unknown" }); } catch (_) {} + stopHeartbeat(); process.exit(0); + } + throw e; + } + if (db) try { store.upsertArticle(db, { id: articleId, state: "pushed", cover_media_id: thumbMediaId }); } catch (_) {} + return r; +} + +async function main() { + startHeartbeat(); + if (mode === "push_only") await runPushOnly(); + else await runArticle(); + stopHeartbeat(); + process.exit(0); +} + +main().catch((e) => { + stopHeartbeat(); + try { setState("failed", "error", "worker 异常: " + String(e && e.message || e).slice(0, 300)); } catch (_) {} + logger.error(String((e && e.stack) || e)); + process.exit(1); +}); diff --git a/mobius/extension/wechat-article/backend/extension_backend_handler.js b/mobius/extension/wechat-article/backend/extension_backend_handler.js new file mode 100644 index 00000000..472f4e59 --- /dev/null +++ b/mobius/extension/wechat-article/backend/extension_backend_handler.js @@ -0,0 +1,289 @@ +// wechat-article/backend/extension_backend_handler.js +// 公众号图文生成 handler。30s 内返回:配置/校验/轻 LLM(clarify/render)/文章 CRUD/状态查询 全同步; +// 长任务(生成正文、推送草稿)只 createJob + spawn(detached) article-worker 然后立即返回。 +// 硬约束(SKILL + 方案 §14):≤30s / ≤5MB 返回 / ≤1MB 入参 / stateless / 只写 ext_data_dir / 禁 chdir / logger.*。 + +const path = require("path"), fs = require("fs"), crypto = require("crypto"); +const { spawn } = require("child_process"); +const store = require("./lib/store"); +const job = require("./lib/job-store"); +const cfgStore = require("./lib/config-store"); +const cryptoLib = require("./lib/crypto"); +const llm = require("./lib/llm"); +const claimsLib = require("./lib/claims"); +const { render, validateWechatFields } = require("./lib/render"); +const wechat = require("./lib/wechat"); +const image = require("./lib/image"); + +const VERSION = "0.2.1"; +const WORKER = path.join(__dirname, "article-worker.js"); +const NODE_MODULES = path.resolve(__dirname, "../../../node_modules"); // mobius/node_modules +const FRAMEWORKS = new Set(["interpretation", "opinion", "list"]); + +const txt = (s, n = 512) => String(s == null ? "" : s).replace(/\s+/g, " ").trim().slice(0, n); +const ok = (o) => ({ ok: true, ...(o || {}) }); +const fail = (e, extra) => ({ ok: false, error: String(e && (e.message || e)).slice(0, 300), ...(extra || {}) }); +const ARTICLE_ACTIVE = job.ACTIVE_STATES; + +// ---------- spawn detached worker ---------- +function spawnWorker(extDataDir, jobId) { + const dir = job.jobDir(extDataDir, jobId); + const specPath = path.join(dir, "spec.json"); + const logFd = fs.openSync(path.join(dir, "worker.log"), "a"); + let child; + try { + child = spawn(process.execPath, [WORKER, specPath], { + env: Object.assign({}, process.env, { NODE_PATH: NODE_MODULES }), + detached: true, stdio: ["ignore", logFd, logFd], + }); + fs.closeSync(logFd); + child.unref(); + } catch (e) { + try { fs.closeSync(logFd); } catch (_) {} + job.updateStatus(extDataDir, jobId, { state: "failed", error: "启动失败: " + (e.message || e) }); + return fail(e); + } + job.writePid(extDataDir, jobId, child.pid); + return { ok: true }; +} + +function anyActive(jobs) { return jobs.some((j) => ARTICLE_ACTIVE.has(j.state)); } + +// ---------- handlers ---------- +function handlePing(extDataDir) { + let dbOk = false; + try { const db = store.open(extDataDir); db.close(); dbOk = true; } catch (_) {} + return ok({ service: "wechat-article", version: VERSION, time: new Date().toISOString(), + crypto_available: cryptoLib.hasKey(), channels: llm.channelsOut().length, db_ok: dbOk }); +} +function handleGetConfig(extDataDir) { + return ok({ config: cfgStore.publicView(cfgStore.load(extDataDir)), + channels: llm.channelsOut(), default_model: llm.defaultModelKey() }); +} +function handleSaveConfig(p, extDataDir) { + try { const next = cfgStore.mergeSave(extDataDir, p.config || {}); return ok({ config: cfgStore.publicView(next) }); } + catch (e) { return fail(e); } +} +function handleSaveProfile(p, extDataDir) { + const c = cfgStore.load(extDataDir); + c.account_profile = Object.assign({}, c.account_profile, p.profile || {}); + cfgStore.save(extDataDir, c); + try { const db = store.open(extDataDir); store.setProfile(db, c.account_profile); db.close(); } catch (_) {} + return ok({ config: cfgStore.publicView(c) }); +} +function handleSaveStyle(p, extDataDir) { + const c = cfgStore.load(extDataDir); + c.style = Object.assign({}, c.style, p.style || {}); + cfgStore.save(extDataDir, c); + try { const db = store.open(extDataDir); store.setStyle(db, c.style); db.close(); } catch (_) {} + return ok({ config: cfgStore.publicView(c) }); +} +async function handleTestProvider(p) { + try { + const provider = llm.findProvider(p.model_key || null); + const r = await llm.callModel({ provider, system: "只回 pong", user: "ping", maxTokens: 10, timeoutMs: 15_000 }); + return ok({ alive: !!r.text, text: (r.text || "").slice(0, 40), model: r.model }); + } catch (e) { return fail(e); } +} +async function handleClarify(p) { + const title = txt(p.title, 200); + if (!title) return fail("需要 title"); + try { + const provider = llm.findProvider(p.model_key || null); + const r = await llm.callJson({ provider, system: "只输出 JSON。", maxTokens: 900, timeoutMs: 26_000, + user: `用户想写公众号文章,主题「${title}」。补全:1) core_claim 核心主张;2) audience 目标读者;3) framework 推荐(interpretation|opinion|list);4) questions 需核实的 3-5 个问题(数组)。输出 {"core_claim":"","audience":"","framework":"interpretation","questions":[""]}` }); + return ok(r.json || {}); + } catch (e) { return fail(e); } +} +function handleRenderPreview(p) { + const md = String(p.body_md || "").slice(0, 200000); + return ok({ html: render(md), fields: validateWechatFields({ title: p.title, author: p.author, digest: p.digest, bodyHtml: render(md) }) }); +} +async function handleGenerateCover(p, extDataDir) { + try { + const c = await image.generatePlaceholderCover({ extDataDir, title: txt(p.title, 40), subtitle: txt(p.subtitle, 60) }); + let dataUrl = ""; + try { const b = fs.readFileSync(c.path); dataUrl = "data:image/" + (c.type === "jpg" ? "jpeg" : c.type) + ";base64," + b.toString("base64"); } catch (_) {} + return ok({ path: c.path, type: c.type, need_rasterize: !!c.need_rasterize, data_url: dataUrl }); + } catch (e) { return fail(e); } +} +function handleStartArticle(p, extDataDir) { + const title = txt(p.title, 200); + if (!title) return fail("需要 title"); + const framework = FRAMEWORKS.has(p.framework) ? p.framework : "interpretation"; + const referenceUrls = Array.isArray(p.referenceUrls) ? p.referenceUrls + .filter((u) => /^https?:\/\//.test(String(u))).slice(0, 8).map((u) => txt(u, 1024)) : []; + const topic = { title, angle: txt(p.angle, 600), framework, referenceUrls, questions: txt(p.questions, 1000), audience: txt(p.audience, 300) }; + const modelKey = txt(p.model_key, 160); + const mode = p.mode === "auto_push" ? "auto_push" : "manual"; + const jobs = job.listJobs(extDataDir, 20); + if (jobs.some((j) => ARTICLE_ACTIVE.has(j.state) && j.kind === "article")) + return fail("已有文章任务在运行,请等待完成或取消"); + const jobId = job.createJob(extDataDir, { kind: "article", spec: { topic, mode, modelKey } }); + const r = spawnWorker(extDataDir, jobId); + if (!r.ok) return r; + return ok({ job_id: jobId, state: "queued" }); +} +function handlePushDraft(p, extDataDir) { + const articleId = txt(p.article_id || p.articleId, 120); + if (!articleId) return fail("需要 article_id"); + const config = cfgStore.load(extDataDir); + if (!config.wx || !config.wx.appid || !config.wx.secret) return fail("未配置微信凭据(设置页填 AppID/AppSecret)"); + if (anyActive(job.listJobs(extDataDir, 20))) return fail("已有任务在运行"); + let art; + try { const db = store.open(extDataDir); art = store.getArticle(db, articleId); db.close(); } catch (_) {} + if (!art) return fail("文章不存在"); + const jobId = job.createJob(extDataDir, { kind: "push", spec: { mode: "push_only", articleId } }); + const r = spawnWorker(extDataDir, jobId); + if (!r.ok) return r; + return ok({ job_id: jobId, state: "uploading" }); +} +async function handleReconcile(p, extDataDir) { + const config = cfgStore.load(extDataDir); + if (!config.wx || !config.wx.appid) return fail("未配置微信凭据"); + try { + const ctx = { extDataDir, appid: config.wx.appid, secret: config.wx.secret, logger: () => {} }; + const r = await wechat.reconcile(ctx, { title: txt(p.title, 40), digest: txt(p.digest, 120) }); + return ok(r); + } catch (e) { return fail(e); } +} +function handleListArticles(p, extDataDir) { + try { const db = store.open(extDataDir); const r = store.listArticles(db, Math.min(Number(p.limit) || 50, 500)); db.close(); return ok({ articles: r }); } + catch (e) { return fail(e); } +} +function handleGetArticle(p, extDataDir) { + const id = txt(p.article_id || p.id, 120); + try { + const db = store.open(extDataDir); + const art = store.getArticle(db, id); + if (!art) { db.close(); return fail("文章不存在"); } + const evidence = db.prepare("SELECT id,source_url,source_name,author,published_at,excerpt,tier FROM evidence WHERE article_id=?").all(id); + const claimsR = db.prepare("SELECT id,paragraph_idx,claim_text,risk,evidence_id,relation,resolved FROM claim WHERE article_id=?").all(id); + db.close(); + let quality = null, outline = null; + try { quality = JSON.parse(art.quality || "null"); } catch (_) {} + try { outline = JSON.parse(art.outline || "null"); } catch (_) {} + return ok({ article: Object.assign({}, art, { quality, outline }), evidence, claims: claimsR }); + } catch (e) { return fail(e); } +} +function handleSaveArticle(p, extDataDir) { + const id = txt(p.article_id || p.id, 120); + if (!id) return fail("需要 article_id"); + const title = p.title != null ? txt(p.title, 32) : undefined; + const digest = p.digest != null ? txt(p.digest, 120) : undefined; + const bodyMd = p.body_md != null ? String(p.body_md).slice(0, 200000) : undefined; + try { + const db = store.open(extDataDir); + const prev = store.getArticle(db, id); + store.upsertArticle(db, { id, title, digest, body_md: bodyMd, state: prev && prev.state === "pushed" ? "pushed" : "edited" }); + if (bodyMd != null && (!prev || prev.body_md !== bodyMd)) { + const vno = db.prepare("SELECT COALESCE(MAX(version_no),0)+1 n FROM article_version WHERE article_id=?").get(id).n; + db.prepare("INSERT INTO article_version (id,article_id,version_no,title,digest,body_md,note,created_at) VALUES (?,?,?,?,?,?,?,?)") + .run("v_" + crypto.randomBytes(4).toString("hex"), id, vno, (prev && prev.title) || title || "", (prev && prev.digest) || digest || "", bodyMd, txt(p.note || "自动保存", 200), new Date().toISOString()); + } + const art = store.getArticle(db, id); db.close(); + return ok({ article: art }); + } catch (e) { return fail(e); } +} +function handleDeleteArticle(p, extDataDir) { + const id = txt(p.article_id || p.id, 120); + try { + const db = store.open(extDataDir); + db.prepare("DELETE FROM article WHERE id=?").run(id); + db.prepare("DELETE FROM article_version WHERE article_id=?").run(id); + db.prepare("DELETE FROM evidence WHERE article_id=?").run(id); + db.prepare("DELETE FROM claim WHERE article_id=?").run(id); + db.close(); return ok({}); + } catch (e) { return fail(e); } +} +function handleListVersions(p, extDataDir) { + const id = txt(p.article_id || p.id, 120); + try { const db = store.open(extDataDir); + const r = db.prepare("SELECT id,version_no,title,digest,note,created_at FROM article_version WHERE article_id=? ORDER BY version_no DESC").all(id); + db.close(); return ok({ versions: r }); } + catch (e) { return fail(e); } +} +function handleRestoreVersion(p, extDataDir) { + const vid = txt(p.version_id, 120); + try { + const db = store.open(extDataDir); + const v = db.prepare("SELECT * FROM article_version WHERE id=?").get(vid); + if (!v) { db.close(); return fail("版本不存在"); } + store.upsertArticle(db, { id: v.article_id, title: v.title, digest: v.digest, body_md: v.body_md, state: "edited" }); + db.close(); return ok({}); + } catch (e) { return fail(e); } +} +function handleGetEvidence(p, extDataDir) { + const id = txt(p.article_id || p.id, 120); + try { const db = store.open(extDataDir); const r = db.prepare("SELECT * FROM evidence WHERE article_id=?").all(id); db.close(); return ok({ evidence: r }); } + catch (e) { return fail(e); } +} +function handleGetClaims(p, extDataDir) { + const id = txt(p.article_id || p.id, 120); + try { const db = store.open(extDataDir); const r = db.prepare("SELECT * FROM claim WHERE article_id=?").all(id); db.close(); return ok({ claims: r }); } + catch (e) { return fail(e); } +} +function handleResolveClaim(p, extDataDir) { + const id = txt(p.claim_id || p.id, 120); + try { const db = store.open(extDataDir); db.prepare("UPDATE claim SET resolved=1, note=? WHERE id=?").run(txt(p.note || "", 300), id); db.close(); return ok({}); } + catch (e) { return fail(e); } +} +function handleExport(extDataDir) { + try { const db = store.open(extDataDir); const arts = store.listArticles(db, 500); db.close(); + return ok({ db_path: path.join(extDataDir, "data.db"), ext_data_dir: extDataDir, articles: arts }); } + catch (e) { return fail(e); } +} +function handlePurge(p, extDataDir) { + if (p.confirm !== "DELETE_ALL") return fail("需要 confirm='DELETE_ALL' 才能清空"); + try { + const db = store.open(extDataDir); + for (const t of ["article", "article_version", "evidence", "claim", "topic", "hot_item", "hot_cluster", "operation", "published_history", "article_image"]) + try { db.prepare(`DELETE FROM ${t}`).run(); } catch (_) {} + db.close(); + try { const jd = job.jobsDir(extDataDir); for (const d of fs.readdirSync(jd)) fs.rmSync(path.join(jd, d), { recursive: true, force: true }); } catch (_) {} + return ok({}); + } catch (e) { return fail(e); } +} + +module.exports = async function ({ username, display_name, ext_main_payload, ext_data_dir, extension_name, logger }) { + const p = (ext_main_payload && typeof ext_main_payload === "object") ? ext_main_payload : {}; + const action = txt(p.action, 64) || "ping"; + const extDataDir = ext_data_dir; + try { + switch (action) { + case "ping": case "diagnostics": return handlePing(extDataDir); + case "get_config": return handleGetConfig(extDataDir); + case "save_config": return handleSaveConfig(p, extDataDir); + case "save_account_profile": return handleSaveProfile(p, extDataDir); + case "save_style_profile": return handleSaveStyle(p, extDataDir); + case "list_ai_channels": return ok({ channels: llm.channelsOut(), default_model: llm.defaultModelKey() }); + case "test_provider": return await handleTestProvider(p); + case "clarify_topic": return await handleClarify(p); + case "render_preview": return handleRenderPreview(p); + case "generate_cover": return await handleGenerateCover(p, extDataDir); + case "start_article": return handleStartArticle(p, extDataDir); + case "push_draft": return handlePushDraft(p, extDataDir); + case "job_status": { const s = job.readStatus(extDataDir, txt(p.job_id, 120)); return s ? ok({ status: s }) : fail("job 不存在"); } + case "list_jobs": return ok({ jobs: job.listJobs(extDataDir, Math.min(Number(p.limit) || 50, 200)) }); + case "cancel_job": return ok(job.cancelJob(extDataDir, txt(p.job_id, 120))); + case "list_articles": return handleListArticles(p, extDataDir); + case "get_article": return handleGetArticle(p, extDataDir); + case "save_article": return handleSaveArticle(p, extDataDir); + case "delete_article": return handleDeleteArticle(p, extDataDir); + case "list_versions": return handleListVersions(p, extDataDir); + case "restore_version": return handleRestoreVersion(p, extDataDir); + case "get_evidence": return handleGetEvidence(p, extDataDir); + case "get_claims": return handleGetClaims(p, extDataDir); + case "resolve_claim": return handleResolveClaim(p, extDataDir); + case "reconcile_draft": return await handleReconcile(p, extDataDir); + case "export_data": return handleExport(extDataDir); + case "purge_data": return handlePurge(p, extDataDir); + case "start_collect": case "collect_status": case "stop_collect": case "list_hotspots": case "list_topics": + return ok({ stub: true, note: "热点采集为 M3 阶段,本期未启用" }); + default: return fail("未知 action: " + action); + } + } catch (e) { + try { logger && logger.error && logger.error((e && e.stack) || String(e)); } catch (_) {} + return fail(e); + } +}; diff --git a/mobius/extension/wechat-article/backend/lib/claims.js b/mobius/extension/wechat-article/backend/lib/claims.js new file mode 100644 index 00000000..048f268f --- /dev/null +++ b/mobius/extension/wechat-article/backend/lib/claims.js @@ -0,0 +1,49 @@ +// lib/claims.js — 主张账本(方案 §8.2)。 +// 写作前 extractFacts:从证据抽可核验事实点(数字/日期/引语/能力/因果)+ 风险。 +// 写作后 bindToArticle:把正文每段主张绑定到证据 + support/refute/uncertain。 +// 推送前 lint:高风险主张须有 A 级或两个独立 B 级;未解决高风险冲突为 0;引语与来源一致。 + +const crypto = require("crypto"); +const { callJson } = require("./llm"); +const txt = (s, n = 6000) => String(s || "").replace(/\s+/g, " ").trim().slice(0, n); +const hash = (s) => crypto.createHash("sha256").update(String(s)).digest("hex").slice(0, 24); +const newId = () => "cl_" + hash(Date.now() + ":" + Math.random()); +function srcTag(name) { try { return String(name || "").replace(/^RSS[·\/]/, ""); } catch { return name; } } +function splitParas(md) { return String(md || "").split(/\n{2,}/).map((s) => s.trim()).filter(Boolean); } + +async function extractFacts({ provider, evidence, topic }) { + const evBlock = evidence.slice(0, 12).map((e, i) => `[E${i + 1}|${e.tier}|${srcTag(e.source_name)}] ${txt(e.excerpt, 600)}`).join("\n"); + const r = await callJson({ provider, system: "只输出 JSON。", maxTokens: 1500, timeoutMs: 45_000, retries: 1, + user: `从下列证据抽取【可核验事实点】用于公众号写作:数字、日期、引语(原文)、产品能力、因果判断。每条标注 risk(high/mid/low) 与对应证据序号 Ei。无证据不要编造。\n选题:${txt(topic.title, 200)}\n证据:\n${evBlock}\n输出:{"facts":[{"text":"","risk":"low|mid|high","evidence":["E1"],"kind":"number|date|quote|capability|causal"}]}` }); + return (r.json && r.json.facts) || []; +} + +async function bindToArticle({ provider, bodyMd, evidence }) { + const paras = splitParas(bodyMd); + const evBlock = evidence.slice(0, 12).map((e, i) => `E${i + 1}: ${srcTag(e.source_name)} (${e.tier})`).join("\n"); + const r = await callJson({ provider, system: "只输出 JSON。", maxTokens: 2000, timeoutMs: 45_000, retries: 1, + user: `下列公众号正文按段落拆分。为每段抽取其中的【具体主张】(数字/日期/引语/能力/因果),匹配证据 Ei,判定关系:support/refute/uncertain。\n证据清单:\n${evBlock}\n段落:\n${paras.map((p, i) => "P" + (i + 1) + ": " + txt(p, 400)).join("\n")}\n输出:{"claims":[{"paragraph":1,"text":"","risk":"low|mid|high","evidence":"E1","relation":"support|refute|uncertain"}]}` }); + const claims = (r.json && r.json.claims) || []; + return claims.map((c) => ({ id: newId(), paragraph_idx: Math.max(0, Number(c.paragraph) - 1), + claim_text: txt(c.text, 400), risk: c.risk || "low", evidence_id: c.evidence || "", + relation: c.relation || "uncertain", resolved: 0 })); +} + +function lint({ claims = [], evidence = [] }) { + const evTier = {}; + evidence.forEach((e, i) => { evTier["E" + (i + 1)] = e.tier; evTier[e.id] = e.tier; }); + const blockers = []; + const highRisk = claims.filter((c) => c.risk === "high"); + const unresolved = claims.filter((c) => c.relation === "uncertain" || c.relation === "refute"); + for (const c of highRisk) { + const t = evTier[c.evidence_id]; + if (!t) blockers.push(`高风险主张无证据:第${(c.paragraph_idx || 0) + 1}段「${txt(c.claim_text, 40)}」`); + else if (t === "C") blockers.push(`高风险主张仅有 C 级证据:第${(c.paragraph_idx || 0) + 1}段`); + } + const conflictHi = unresolved.filter((c) => c.risk === "high"); + if (conflictHi.length) blockers.push(`存在 ${conflictHi.length} 条未解决的高风险冲突`); + return { ok: blockers.length === 0, blockers, + stats: { claims: claims.length, high_risk: highRisk.length, unresolved: unresolved.length } }; +} + +module.exports = { extractFacts, bindToArticle, lint, splitParas }; diff --git a/mobius/extension/wechat-article/backend/lib/config-store.js b/mobius/extension/wechat-article/backend/lib/config-store.js new file mode 100644 index 00000000..ecb27d6c --- /dev/null +++ b/mobius/extension/wechat-article/backend/lib/config-store.js @@ -0,0 +1,80 @@ +// lib/config-store.js — 加密配置层(config.enc,AES-256-GCM)。 +// 单用户:微信凭据 / 账号画像 / 风格档案 / 预算 / AI 声明 / 模型选择。 +// 缺主密钥时生产模式拒绝落凭据(方案 §13);对外只回脱敏视图(不回传假 Key)。 + +const fs = require("fs"), path = require("path"); +const { hasKey, encryptObj, decryptObj } = require("./crypto"); +const CONFIG_FILE = "config.enc"; + +function configPath(extDataDir) { return path.join(extDataDir, CONFIG_FILE); } + +function defaultConfig() { + return { + wx: { appid: "", secret: "", note: "" }, + account_profile: { positioning: "", audience: "", forbidden: "", goals: "", tone: "" }, + style: { tone: "", structure: "", syntax: "", opinion_strength: "", banned_phrases: "" }, + budgets: { per_article_search: 6, per_article_tokens: 20000, per_article_images: 1, per_article_amount: 2.0, daily_amount: 20.0 }, + ai_declaration: "本文由作者借助 AI 辅助整理资料与初稿,最终观点与文字经人工核实与修改。", + model_key: "", + inline_images: false, + }; +} + +function load(extDataDir) { + const file = configPath(extDataDir); + if (!fs.existsSync(file)) return defaultConfig(); + try { + const raw = fs.readFileSync(file, "utf8").trim(); + if (!raw) return defaultConfig(); + return { ...defaultConfig(), ...decryptObj(raw) }; + } catch (_) { return defaultConfig(); } +} + +function save(extDataDir, cfg) { + if (!hasKey()) { + if (process.env.NODE_ENV === "production") throw new Error("缺少主密钥,生产模式拒绝保存凭据"); + throw new Error("缺少主密钥(MOBIUS_EXTENSION_SECRET/JWT_SECRET),无法保存配置"); + } + const file = configPath(extDataDir); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, encryptObj(cfg), { mode: 0o600 }); + try { fs.chmodSync(file, 0o600); } catch (_) {} + return true; +} + +// 脱敏:凭据只暴露"已配置"+ 前 4 位掩码 +function publicView(cfg) { + return { + wx_configured: !!(cfg.wx && cfg.wx.appid && cfg.wx.secret), + wx_appid_masked: cfg.wx && cfg.wx.appid ? cfg.wx.appid.slice(0, 4) + "***" : "", + account_profile: cfg.account_profile || {}, + style: cfg.style || {}, + budgets: cfg.budgets || {}, + ai_declaration: cfg.ai_declaration || "", + model_key: cfg.model_key || "", + inline_images: !!cfg.inline_images, + crypto_available: hasKey(), + }; +} + +// 部分更新(前端只传改动字段;凭据字段空串=不覆盖,避免误清空) +function mergeSave(extDataDir, patch) { + const cur = load(extDataDir); + const next = JSON.parse(JSON.stringify(cur)); + for (const k of Object.keys(defaultConfig())) { + if (patch[k] && typeof patch[k] === "object" && !Array.isArray(patch[k])) { + next[k] = { ...next[k], ...patch[k] }; + } else if (k in patch) { + next[k] = patch[k]; + } + } + // 凭据:空串保留旧值 + if (next.wx) { + next.wx.appid = next.wx.appid || cur.wx.appid || ""; + next.wx.secret = next.wx.secret || cur.wx.secret || ""; + } + save(extDataDir, next); + return next; +} + +module.exports = { load, save, mergeSave, publicView, defaultConfig, configPath }; diff --git a/mobius/extension/wechat-article/backend/lib/crypto.js b/mobius/extension/wechat-article/backend/lib/crypto.js new file mode 100644 index 00000000..a1199890 --- /dev/null +++ b/mobius/extension/wechat-article/backend/lib/crypto.js @@ -0,0 +1,39 @@ +// lib/crypto.js — AES-256-GCM 配置/凭据加密(config.enc / wx-token.enc)。 +// 主密钥来源:MOBIUS_EXTENSION_SECRET 优先;否则用宿主 JWT_SECRET 派生(handler worker 可读宿主 env, +// 与 ai-hotspot-radar 读 RCC2_API_KEY 同理)。无密钥时生产模式拒绝落凭据(方案 §13)。 + +const crypto = require("crypto"); + +function rawKey() { + const src = process.env.MOBIUS_EXTENSION_SECRET || process.env.JWT_SECRET || ""; + if (!src) return null; + // 域隔离派生:拓展专用 32 字节密钥,与 JWT 签名用途隔离 + return crypto.createHash("sha256").update("wechat-article/v1:" + src).digest(); +} +function hasKey() { return !!rawKey(); } + +function encryptObj(obj) { + const key = rawKey(); + if (!key) throw new Error("缺少主密钥(MOBIUS_EXTENSION_SECRET / JWT_SECRET),无法加密凭据"); + const iv = crypto.randomBytes(12); + const c = crypto.createCipheriv("aes-256-gcm", key, iv); + const buf = Buffer.concat([c.update(JSON.stringify(obj), "utf8"), c.final()]); + const tag = c.getAuthTag(); + // 打包:ver(1) + iv(12) + tag(16) + ciphertext + return Buffer.concat([Buffer.from([1]), iv, tag, buf]).toString("base64"); +} + +function decryptObj(str) { + const key = rawKey(); + if (!key) throw new Error("缺少主密钥"); + const data = Buffer.from(String(str), "base64"); + if (data.length < 1 + 12 + 16) throw new Error("密文损坏"); + const ver = data[0], iv = data.slice(1, 13), tag = data.slice(13, 29), ct = data.slice(29); + if (ver !== 1) throw new Error("密文版本不支持"); + const d = crypto.createDecipheriv("aes-256-gcm", key, iv); + d.setAuthTag(tag); + const json = Buffer.concat([d.update(ct), d.final()]).toString("utf8"); + return JSON.parse(json); +} + +module.exports = { hasKey, encryptObj, decryptObj }; diff --git a/mobius/extension/wechat-article/backend/lib/humanize.js b/mobius/extension/wechat-article/backend/lib/humanize.js new file mode 100644 index 00000000..7dbcf84d --- /dev/null +++ b/mobius/extension/wechat-article/backend/lib/humanize.js @@ -0,0 +1,42 @@ +// lib/humanize.js — 去 AI 味(方案 §9)。三层: +// 1) 检测套话/机械连接词/虚假归因/排比滥用;2) 句长与重复句式统计(仅提醒,不强制); +// 3) 仅定向改写命中句子,不改引用/事实/用户内容。不用"AI 率"指标,最终看保留率/修改量/盲评。 + +const { callModel } = require("./llm"); +const txt = (s, n = 12000) => String(s || "").trim().slice(0, n); + +const CLICHE = ["全面解析", "至关重要", "不断演变的格局", "日新月异", "综上所述", "值得注意的是", + "随着.{0,8}的发展", "赋能", "底层逻辑", "闭环", "深度赋能", "强势赋能", "不可忽视", + "在当今.{0,8}背景下", "总而言之", "由此可见", "毋庸置疑"]; +const FILLER_CONN = ["首先.{0,40}其次.{0,40}最后", "一方面.{0,40}另一方面", "不仅.{0,40}而且"]; + +function detect(md) { + const hits = []; + for (const p of CLICHE) { try { if (new RegExp(p).test(md)) hits.push({ kind: "cliche", pattern: p }); } catch {} } + for (const p of FILLER_CONN) { try { if (new RegExp(p).test(md)) hits.push({ kind: "mechanical", pattern: p }); } catch {} } + const sentences = String(md).split(/[。!?\n]/).map((s) => s.trim()).filter((s) => s.length > 0); + const lens = sentences.map((s) => s.length); + const avg = lens.length ? Math.round(lens.reduce((a, b) => a + b, 0) / lens.length) : 0; + const longRatio = lens.length ? lens.filter((l) => l > 80).length / lens.length : 0; + return { hits, stats: { sentences: lens.length, avg_len: avg, long_ratio: Math.round(longRatio * 100) / 100 } }; +} + +async function humanize({ provider, bodyMd, style }) { + const det = detect(bodyMd); + // 无明显问题且句长适中 → 跳过改写,避免无谓扰动 + if (!det.hits.length && det.stats.avg_len < 70) return { bodyMd, detection: det, changed: false }; + const r = await callModel({ provider, system: "你是中文编辑,只做局部润色,不改变事实与立场。", maxTokens: 4000, timeoutMs: 60_000, retries: 1, + user: [ + "定向改写下面公众号正文中【命中套话/机械连接/排比滥用】的句子,使其更自然、具体。", + "硬规则:1) 不要改任何数字、日期、引语、专有名词、事实陈述;2) 不要改作者明确表达的观点;", + "3) 不要新增未经证据的具体信息;4) 保持 Markdown 结构与 [事实N] 标注不变;5) 原句已足够自然则原样保留。", + "命中:" + (det.hits.map((h) => h.pattern).join("、") || "无"), + "禁用表达:" + ((style && style.banned_phrases) || "无"), + "正文:", txt(bodyMd), + "输出:纯 Markdown 正文(去掉```),保持同等结构与长度。", + ].join("\n") }); + const out = (r.text || bodyMd).trim(); + return { bodyMd: out, detection: det, changed: out !== String(bodyMd).trim() }; +} + +module.exports = { detect, humanize }; diff --git a/mobius/extension/wechat-article/backend/lib/image.js b/mobius/extension/wechat-article/backend/lib/image.js new file mode 100644 index 00000000..1480f154 --- /dev/null +++ b/mobius/extension/wechat-article/backend/lib/image.js @@ -0,0 +1,83 @@ +// lib/image.js — 封面/配图(方案 §11)。MVP 默认只封面。 +// 校验:文件魔数/尺寸/像素/字节;统一重编码为 JPG/PNG;正文单图 <1MB;SVG 须先栅格化; +// 按 SHA-256 缓存微信上传。生图:env IMAGE_GEN_BASE/API 配置后启用;否则生成文字占位封面。 + +const fs = require("fs"), path = require("path"), crypto = require("crypto"); +const { safeFetch } = require("./safe-fetch"); + +const MAGIC = { + jpg: [0xff, 0xd8, 0xff], png: [0x89, 0x50, 0x4e, 0x47], + gif: [0x47, 0x49, 0x46, 0x38], webp: [0x52, 0x49, 0x46, 0x46], +}; +function detectType(buf) { + const eq = (arr) => arr.every((b, i) => buf[i] === b); + if (eq(MAGIC.jpg)) return "jpg"; + if (eq(MAGIC.png)) return "png"; + if (eq(MAGIC.gif)) return "gif"; + if (eq(MAGIC.webp)) return "webp"; + return null; +} +function sha256(buf) { return crypto.createHash("sha256").update(buf).digest("hex"); } +function esc(s) { return String(s).replace(/&/g, "&").replace(//g, ">"); } + +// 校验并落地一张图到 images/ +function ingestImage({ extDataDir, source, kind = "inline" }) { + const imagesDir = path.join(extDataDir, "images"); + fs.mkdirSync(imagesDir, { recursive: true }); + let buf; + if (Buffer.isBuffer(source)) buf = source; + else buf = fs.readFileSync(source); + const type = detectType(buf); + if (!type) throw new Error("无法识别的图片格式(魔数校验失败)"); + if (buf.byteLength > 5_000_000) throw new Error("原图过大(>5MB)"); + const hash = sha256(buf); + const file = path.join(imagesDir, `${kind}_${hash}.${type}`); + fs.writeFileSync(file, buf); + return { path: file, hash, type, bytes: buf.byteLength }; +} + +// 占位文字封面:SVG → 尝试 sharp 栅格化为 PNG;sharp 不可用则返回 SVG 并标记需手动替换。 +async function generatePlaceholderCover({ extDataDir, title, subtitle = "" }) { + const imagesDir = path.join(extDataDir, "images"); + fs.mkdirSync(imagesDir, { recursive: true }); + const t = esc(String(title || "AI 热点").slice(0, 24)); + const sub = esc(String(subtitle || "").slice(0, 40)); + const svg = ` + + + +${t} +${sub} +AI 热点 · 公众号图文 +`; + try { + const sharp = require("sharp"); + const png = await sharp(Buffer.from(svg)).png().toBuffer(); + const pngPath = path.join(imagesDir, `cover_${sha256(png).slice(0, 16)}.png`); + fs.writeFileSync(pngPath, png); + return { path: pngPath, hash: sha256(png), type: "png", bytes: png.length }; + } catch (_) { + const svgPath = path.join(imagesDir, `cover_${sha256(Buffer.from(svg)).slice(0, 16)}.svg`); + fs.writeFileSync(svgPath, svg); + return { path: svgPath, hash: sha256(Buffer.from(svg)), type: "svg", bytes: svg.length, need_rasterize: true }; + } +} + +// 远程生图(可选,env 驱动) +async function generateRemoteCover({ prompt }) { + const base = process.env.IMAGE_GEN_BASE, key = process.env.IMAGE_GEN_API; + if (!base || !key) throw new Error("未配置生图服务(IMAGE_GEN_BASE/API)"); + const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 40_000); + try { + const r = await fetch(base.replace(/\/+$/, "") + "/generate", { method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` }, + body: JSON.stringify({ prompt, size: "1024x576" }) }); + const j = await r.json(); + const url = j.url || (j.data && j.data[0] && j.data[0].url); + if (!url) throw new Error("生图未返回 url"); + const f = await safeFetch(url, { maxBytes: 5_000_000, timeoutMs: 30_000 }); + return f.buffer; + } finally { clearTimeout(timer); } +} + +module.exports = { ingestImage, generatePlaceholderCover, generateRemoteCover, detectType, sha256 }; diff --git a/mobius/extension/wechat-article/backend/lib/job-store.js b/mobius/extension/wechat-article/backend/lib/job-store.js new file mode 100644 index 00000000..e88bb525 --- /dev/null +++ b/mobius/extension/wechat-article/backend/lib/job-store.js @@ -0,0 +1,131 @@ +// lib/job-store.js — detached worker 的文件系统状态层。 +// handler ↔ worker 仅通过 ext_data_dir/jobs// 下文件通信(无 IPC / 无 stdout 解析 / 无 socket)。 +// 文件:spec.json(输入) / status.json(状态+心跳) / pid.json / checkpoint.json / events.jsonl / worker.log +// 状态机见方案 §4.2:queued→researching→outlining→writing→reviewing→waiting_user→rendering→uploading→done +// 以及 paused/cancelled/failed/unknown_external_result。心跳超 90s 且 PID 不在 → 自愈标 failed。 + +const fs = require("fs"), path = require("path"), crypto = require("crypto"); +const now = () => new Date().toISOString(); +const STALE_MS = 90_000; + +const ACTIVE_STATES = new Set(["queued", "running", "researching", "outlining", "writing", "reviewing", "rendering", "uploading"]); + +function jobsDir(extDataDir) { return path.join(extDataDir, "jobs"); } +function jobDir(extDataDir, jobId) { return path.join(jobsDir(extDataDir), jobId); } + +function readJson(file, dflt) { + try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return dflt; } +} +function writeJsonAtomic(file, obj) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = file + ".tmp." + crypto.randomBytes(4).toString("hex"); + fs.writeFileSync(tmp, JSON.stringify(obj, null, 2)); + fs.renameSync(tmp, file); +} +function newId(prefix) { return `${prefix}_${Date.now().toString(36)}${crypto.randomBytes(3).toString("hex")}`; } + +function createJob(extDataDir, { kind = "article", spec }) { + const jobId = newId(kind); + const dir = jobDir(extDataDir, jobId); + fs.mkdirSync(dir, { recursive: true }); + writeJsonAtomic(path.join(dir, "spec.json"), { id: jobId, kind, extDataDir, createdAt: now(), ...spec }); + writeJsonAtomic(path.join(dir, "status.json"), { + jobId, kind, state: "queued", phase: "init", progress: 0, + message: "已入队", startedAt: now(), updatedAt: now(), + }); + return jobId; +} +function getSpec(extDataDir, jobId) { return readJson(path.join(jobDir(extDataDir, jobId), "spec.json"), null); } + +function pidAlive(pid) { + if (!pid || !Number.isInteger(pid)) return false; + try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } +} +function readPid(extDataDir, jobId) { return readJson(path.join(jobDir(extDataDir, jobId), "pid.json"), null); } +function writePid(extDataDir, jobId, pid) { writeJsonAtomic(path.join(jobDir(extDataDir, jobId), "pid.json"), { pid, startedAt: now() }); } + +function readStatusRaw(extDataDir, jobId) { return readJson(path.join(jobDir(extDataDir, jobId), "status.json"), null); } + +// 自愈:活跃态但 PID 死 / 心跳超时 → failed +function readStatus(extDataDir, jobId) { + const dir = jobDir(extDataDir, jobId); + const st = readJson(path.join(dir, "status.json"), null); + if (!st) return null; + if (ACTIVE_STATES.has(st.state)) { + const pid = readJson(path.join(dir, "pid.json"), null); + const upd = st.updatedAt ? Date.parse(st.updatedAt) : 0; + const stale = upd && (Date.now() - upd) > STALE_MS; + if (!pidAlive(pid?.pid) || stale) { + st.state = "failed"; + st.error = stale ? "worker 心跳超时(可能被杀)" : "worker 进程异常退出"; + st.fixedAt = now(); + writeJsonAtomic(path.join(dir, "status.json"), st); + } + } + return st; +} +function updateStatus(extDataDir, jobId, patch) { + const file = path.join(jobDir(extDataDir, jobId), "status.json"); + const st = { ...readJson(file, {}), ...patch, updatedAt: now() }; + writeJsonAtomic(file, st); + return st; +} +function heartbeat(extDataDir, jobId) { + const file = path.join(jobDir(extDataDir, jobId), "status.json"); + const st = readJson(file, {}); + if (st && ACTIVE_STATES.has(st.state)) { + st.updatedAt = now(); + writeJsonAtomic(file, st); + } +} +function appendEvent(extDataDir, jobId, evt) { + const file = path.join(jobDir(extDataDir, jobId), "events.jsonl"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.appendFileSync(file, JSON.stringify({ t: Date.now(), ...evt }) + "\n"); +} +function readEvents(extDataDir, jobId) { + try { + return fs.readFileSync(path.join(jobDir(extDataDir, jobId), "events.jsonl"), "utf8") + .trim().split("\n").filter(Boolean) + .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); + } catch { return []; } +} +function writeCheckpoint(extDataDir, jobId, ck) { writeJsonAtomic(path.join(jobDir(extDataDir, jobId), "checkpoint.json"), { ...ck, savedAt: now() }); } +function readCheckpoint(extDataDir, jobId) { return readJson(path.join(jobDir(extDataDir, jobId), "checkpoint.json"), null); } + +// 取消:杀进程组(detached worker 是 session leader,-pid = PGID,连带 ffmpeg 等子进程) +function cancelJob(extDataDir, jobId) { + const dir = jobDir(extDataDir, jobId); + const pid = readJson(path.join(dir, "pid.json"), null); + if (pid && pidAlive(pid.pid)) { + try { process.kill(-pid.pid); } + catch { try { process.kill(pid.pid); } catch (_) {} } + } + updateStatus(extDataDir, jobId, { state: "cancelled", message: "已取消", endedAt: now() }); + return { ok: true, state: "cancelled" }; +} + +function listJobs(extDataDir, limit = 50) { + const dir = jobsDir(extDataDir); + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()); } + catch { return []; } + const jobs = entries.map((d) => { + const st = readStatus(extDataDir, d.name); + if (!st) return null; + const spec = readJson(path.join(dir, d.name, "spec.json"), {}); + return { jobId: d.name, state: st.state, phase: st.phase, progress: st.progress, + title: spec.title || spec.topic?.title || st.title || "", kind: st.kind || spec.kind, + updatedAt: st.updatedAt, startedAt: st.startedAt, error: st.error }; + }).filter(Boolean); + jobs.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || "")); + return jobs.slice(0, limit); +} + +module.exports = { + jobsDir, jobDir, newId, createJob, getSpec, + readStatus, readStatusRaw, updateStatus, heartbeat, + readPid, writePid, pidAlive, + appendEvent, readEvents, writeCheckpoint, readCheckpoint, + cancelJob, listJobs, STALE_MS, ACTIVE_STATES, +}; diff --git a/mobius/extension/wechat-article/backend/lib/llm.js b/mobius/extension/wechat-article/backend/lib/llm.js new file mode 100644 index 00000000..993d415a --- /dev/null +++ b/mobius/extension/wechat-article/backend/lib/llm.js @@ -0,0 +1,129 @@ +// lib/llm.js — LLM 渠道与调用(自包含精简版,复用 ai-hotspot-radar 的已验证配方)。 +// 渠道来源:宿主 model-access.json + ~/.claude/settings-.json 的 anthropic 渠道,及 codex env(RCC2_API_KEY)。 +// 单轮 messages 调用 + JSON 容错解析(去围栏 / 提取 {} / 修复字符串内部未转义引号)。 + +const fs = require("fs"), os = require("os"), path = require("path"); +const REPO_ROOT = path.resolve(__dirname, "../../../../.."); // .../imac-test +const txt = (s, n = 512) => String(s || "").replace(/\s+/g, " ").trim().slice(0, n); + +function chatProviders() { + const out = []; + const accessPath = txt(process.env.MODEL_ACCESS_PATH || path.join(REPO_ROOT, ".deploy_data/data/model-access.json"), 1024); + try { + if (fs.existsSync(accessPath)) { + const access = JSON.parse(fs.readFileSync(accessPath, "utf8")); + for (const m of access.claudeCodeModels || []) { + if (!m.enabled || !m.imported) continue; + const sf = path.join(os.homedir(), ".claude", `settings-${m.key}.json`); + if (!fs.existsSync(sf)) continue; + let st; try { st = JSON.parse(fs.readFileSync(sf, "utf8")); } catch { continue; } + const env = st.env || {}; + if (!env.ANTHROPIC_BASE_URL || !env.ANTHROPIC_AUTH_TOKEN) continue; + out.push({ key: m.key, label: m.label || m.key, type: "anthropic", + baseUrl: env.ANTHROPIC_BASE_URL, authToken: env.ANTHROPIC_AUTH_TOKEN, + model: st.model || env.ANTHROPIC_DEFAULT_SONNET_MODEL || m.claude_model || "GLM-5.2" }); + } + } + } catch (_) {} + const codexKey = txt(process.env.RCC2_API_KEY || process.env.RIGHTCODE_API_KEY || "", 512); + if (codexKey) out.push({ key: "env:codex", label: "Codex (env)", type: "responses", + baseUrl: "https://right.codes/codex/v1", apiKey: codexKey, + model: txt(process.env.WECHAT_ART_LLM_MODEL || "gpt-5.5", 120) }); + return out; +} +function findProvider(modelKey) { + const ps = chatProviders(); + if (!ps.length) throw new Error("没有可用的 AI 渠道(检查 model-access.json / RCC2_API_KEY)"); + if (!modelKey) return ps[0]; + return ps.find((p) => p.key === modelKey) || ps[0]; +} +function defaultModelKey() { return txt(process.env.WECHAT_ART_REPORT_MODEL, 120) || (chatProviders()[0]?.key || ""); } +function channelsOut() { + return chatProviders().map((p) => ({ key: p.key, label: p.label, model: p.model, type: p.type })); +} + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function normalizeModelError(error, timeoutMs) { + if (error?.name === "AbortError") { + const e = new Error(`模型响应超时(>${Math.ceil(timeoutMs / 1000)} 秒)`); + e.name = "ModelTimeoutError"; + e.code = "MODEL_TIMEOUT"; + return e; + } + return error instanceof Error ? error : new Error(String(error || "模型调用失败")); +} + +function isTransientModelError(error) { + if (!error) return false; + if (error.code === "MODEL_TIMEOUT") return true; + const message = String(error.message || error); + return /fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket|HTTP (408|409|425|429|5\d\d)/i.test(message); +} + +async function callModel({ provider, system, user, maxTokens = 3000, timeoutMs = 25000, retries = 0, retryDelayMs = 1200 }) { + const p = provider || findProvider(); + const url = p.baseUrl.replace(/\/+$/, "") + "/v1/messages"; + const maxAttempts = Math.max(1, Math.min(Number(retries) + 1 || 1, 3)); + let lastError; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const r = await fetch(url, { method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json", "x-api-key": p.authToken, + "anthropic-version": "2023-06-01", Authorization: `Bearer ${p.authToken}` }, + body: JSON.stringify({ model: p.model, max_tokens: maxTokens, system, + messages: [{ role: "user", content: user }] }) }); + const j = await r.json(); + if (!r.ok) throw new Error(`HTTP ${r.status}: ${txt(JSON.stringify(j?.error || j), 200)}`); + const text = Array.isArray(j.content) ? j.content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim() : ""; + return { text, usage: j.usage || {}, model: p.label + "/" + p.model }; + } catch (rawError) { + lastError = normalizeModelError(rawError, timeoutMs); + if (attempt >= maxAttempts || !isTransientModelError(lastError)) throw lastError; + await wait(Math.min(retryDelayMs * attempt, 5000)); + } finally { clearTimeout(timer); } + } + throw lastError || new Error("模型调用失败"); +} + +function repairInnerQuotes(json) { + let out = "", inStr = false, esc = false; + for (let i = 0; i < json.length; i++) { + const ch = json[i]; + if (inStr) { + if (esc) { out += ch; esc = false; continue; } + if (ch === "\\") { out += ch; esc = true; continue; } + if (ch === '"') { + let j = i + 1; while (j < json.length && /\s/.test(json[j])) j++; + const nx = json[j]; + if (nx === "," || nx === "}" || nx === "]" || nx === ":") { out += '"'; inStr = false; } + else out += '\\"'; + continue; + } + out += ch; continue; + } + if (ch === '"') { inStr = true; out += '"'; continue; } + out += ch; + } + return out; +} +function parseJsonLoose(text) { + if (!text) return null; + let s = String(text); + const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fence) s = fence[1]; + const m = s.match(/\{[\s\S]*\}/); + if (!m) return null; + try { return JSON.parse(m[0]); } catch {} + try { return JSON.parse(repairInnerQuotes(m[0])); } catch {} + return null; +} +async function callJson(args) { + const resp = await callModel(args); + return { ...resp, json: parseJsonLoose(resp.text) }; +} + +module.exports = { chatProviders, findProvider, defaultModelKey, channelsOut, + callModel, callJson, parseJsonLoose, normalizeModelError, isTransientModelError }; diff --git a/mobius/extension/wechat-article/backend/lib/render.js b/mobius/extension/wechat-article/backend/lib/render.js new file mode 100644 index 00000000..781374a2 --- /dev/null +++ b/mobius/extension/wechat-article/backend/lib/render.js @@ -0,0 +1,61 @@ +// lib/render.js — Markdown → 微信公众号内联 HTML(方案 §11/§12,参考 Doocs/md 思路)。 +// 公众号编辑器会剥离