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/backend/repositories/users.ts b/mobius/backend/repositories/users.ts index c0601744..7ed41014 100644 --- a/mobius/backend/repositories/users.ts +++ b/mobius/backend/repositories/users.ts @@ -11,6 +11,34 @@ import { import type { UserRow, UserGroupRawRow } from '../types/rows'; import type * as BetterSqlite3 from 'better-sqlite3'; +// migration: users.role 增加 developer 取值 (旧库 CHECK 仅允许 admin/user, 需重建表放宽约束; 幂等, 已迁移/新库自动跳过). +(() => { + const row = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='users'").get() as { sql?: string } | undefined; + const oldSql = row?.sql || ''; + if (!oldSql.includes("'admin','user'") || oldSql.includes('developer')) return; + db.exec('PRAGMA foreign_keys = OFF'); + try { + db.transaction(() => { + db.exec(`CREATE TABLE users__role_migration ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','developer','user')), + work_dir TEXT NOT NULL, + group_id TEXT DEFAULT 'default', + deleted_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE SET NULL + )`); + db.exec('INSERT INTO users__role_migration (id, display_name, password_hash, role, work_dir, group_id, deleted_at, created_at) SELECT id, display_name, password_hash, role, work_dir, group_id, deleted_at, created_at FROM users'); + db.exec('DROP TABLE users'); + db.exec('ALTER TABLE users__role_migration RENAME TO users'); + })(); + } finally { + db.exec('PRAGMA foreign_keys = ON'); + } +})(); + const ACTIVE_USER_SQL = "(deleted_at IS NULL OR deleted_at = '')"; const DEFAULT_GROUP_ID = 'default'; const DEFAULT_GROUP_NAME = '默认组'; @@ -68,6 +96,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 +190,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; } @@ -280,7 +314,7 @@ interface CreateUserParams { id: string; display_name: string; password_hash: string; - role: 'admin' | 'user'; + role: 'admin' | 'developer' | 'user'; work_dir: string; group_id?: string; [key: string]: any; @@ -435,14 +469,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, @@ -499,6 +570,8 @@ const Users = { WHERE id = ? AND ${ACTIVE_USER_SQL} `).run(id), updatePassword: (id: string, hash: string) => db.prepare(`UPDATE users SET password_hash = ? WHERE id = ? AND ${ACTIVE_USER_SQL}`).run(hash, id), + updateRole: (id: string, role: string) => db.prepare(`UPDATE users SET role = ? WHERE id = ? AND ${ACTIVE_USER_SQL}`).run(role, id), + updateDisplayName: (id: string, displayName: string) => db.prepare(`UPDATE users SET display_name = ? WHERE id = ? AND ${ACTIVE_USER_SQL}`).run(displayName, id), activeAdminCount: (): number => (db.prepare(`SELECT COUNT(*) as c FROM users WHERE role = 'admin' AND ${ACTIVE_USER_SQL}`).get() as { c: number }).c, countAll: (): number => (db.prepare(`SELECT COUNT(*) as c FROM users WHERE ${ACTIVE_USER_SQL}`).get() as { c: number }).c, }; diff --git a/mobius/backend/routes/admin.ts b/mobius/backend/routes/admin.ts index 3ebebd02..fb484586 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 @@ -98,8 +98,9 @@ function normalizeEmployeeId(value: unknown): string { return id; } -function normalizeEmployeeRole(value: unknown): 'admin' | 'user' { - return value === 'admin' ? 'admin' : 'user'; +function normalizeEmployeeRole(value: unknown): 'admin' | 'developer' | 'user' { + if (value === 'admin' || value === 'developer') return value; + return 'user'; } function normalizeDisplayName(value: unknown, id: string): string { @@ -140,7 +141,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 +551,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 }); @@ -642,6 +684,47 @@ router.patch('/users/:id/group', adminAuth, (req: express.Request, res: express. } }); +router.patch('/users/:id', adminAuth, (req: express.Request, res: express.Response) => { + try { + const me = adminReqUser(req); + const id = String(req.params.id || '').trim(); + const target = Users.findById(id); + if (!target) { res.status(404).json({ error: '员工账号不存在或已删除' }); return; } + const body = req.body || {}; + const hasField = (k1: string, k2?: string) => + Object.prototype.hasOwnProperty.call(body, k1) || (k2 !== undefined && Object.prototype.hasOwnProperty.call(body, k2)); + if (hasField('role')) { + const newRole = normalizeEmployeeRole(body.role); + if (newRole !== target.role) { + if (target.role === 'admin' && newRole !== 'admin' && Users.activeAdminCount() <= 1) { + res.status(400).json({ error: '不能降级最后一个管理员账号' }); return; + } + if (id === me.id && target.role === 'admin' && newRole !== 'admin') { + res.status(400).json({ error: '不能降级当前登录的管理员账号, 请改由其他管理员操作' }); return; + } + Users.updateRole(id, newRole); + } + } + if (hasField('display_name', 'displayName')) { + Users.updateDisplayName(id, normalizeDisplayName(body.display_name ?? body.displayName, id)); + } + if (hasField('work_dir', 'workDir')) { + const wd = normalizeEmployeeWorkDir(body.work_dir ?? body.workDir); + if (wd) Users.updateWorkDir(id, wd); + } + const pwd = body.password ?? body.newPassword; + if (typeof pwd === 'string' && pwd.length > 0) { + if (pwd.length < 6) { res.status(400).json({ error: '密码至少 6 位' }); return; } + Users.updatePassword(id, bcrypt.hashSync(pwd, 10)); + } + const fresh = Users.findById(id); + res.json({ ok: true, user: { id: fresh?.id, display_name: fresh?.display_name, role: fresh?.role, work_dir: fresh?.work_dir } }); + } catch (e) { + const err = e as RepoError; + res.status(err.status || 400).json({ error: err.message || String(e) }); + } +}); + router.delete('/users/:id', adminAuth, (req: express.Request, res: express.Response) => { const user = adminReqUser(req); const id = String(req.params.id || '').trim(); diff --git a/mobius/backend/routes/ext.ts b/mobius/backend/routes/ext.ts index e5b01cd6..b5f4320a 100644 --- a/mobius/backend/routes/ext.ts +++ b/mobius/backend/routes/ext.ts @@ -164,7 +164,7 @@ metaRouter.get('/:name/user-asset/*', authOrQuery, (req: express.Request, res: e return; } const ext = path.extname(abs).toLowerCase(); - const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.webm', '.mov', '.m4v', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md', '.csv', '.rtf']); + const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.webm', '.mov', '.m4v', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md', '.csv', '.rtf', '.zip']); if (!allowed.has(ext)) { res.status(403).send('mime not allowed'); return; @@ -175,6 +175,10 @@ metaRouter.get('/:name/user-asset/*', authOrQuery, (req: express.Request, res: e return; } res.set('content-type', mime); + if (ext === '.zip') { + const filename = path.basename(abs); + res.set('content-disposition', `attachment; filename="download.zip"; filename*=UTF-8''${encodeURIComponent(filename)}`); + } streamAssetWithRange(req, res, abs, `user-asset/${rel}`, 'private, max-age=604800, immutable'); }); @@ -532,6 +536,7 @@ const MIME: Record = { '.md': 'text/markdown; charset=utf-8', '.csv': 'text/csv; charset=utf-8', '.rtf': 'application/rtf', + '.zip': 'application/zip', }; function scriptJson(value: unknown): string { diff --git a/mobius/backend/routes/projects.ts b/mobius/backend/routes/projects.ts index 8715832a..f68044a6 100644 --- a/mobius/backend/routes/projects.ts +++ b/mobius/backend/routes/projects.ts @@ -1870,8 +1870,8 @@ router.post('/', auth, (req: express.Request, res: express.Response) => { // ── 莫比乌斯拓展项目 ────────────────────────────────────────────────────── if (kind === 'extension') { - if (user.role !== 'admin') { - return res.status(403).json({ error: '只有管理员可以创建莫比乌斯拓展项目' }); + if (user.role !== 'admin' && user.role !== 'developer') { + return res.status(403).json({ error: '只有管理员或开发者可以创建莫比乌斯拓展项目' }); } if (hasBoolField(req.body || {}, 'can_post_issue', 'canPostIssue') || hasBoolField(req.body || {}, 'can_run_session', 'canRunSession')) { 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/services/session-context.ts b/mobius/backend/services/session-context.ts index 54c05bb9..b47548d8 100644 --- a/mobius/backend/services/session-context.ts +++ b/mobius/backend/services/session-context.ts @@ -123,7 +123,7 @@ function zh_add_user_level_info(lines: string[], user: any): void { if (!user) return; lines.push('## 用户'); lines.push(`- 姓名: ${user.display_name || user.id}`); - lines.push(`- 角色: ${user.role === 'admin' ? '管理员' : '成员'}`); + lines.push(`- 角色: ${user.role === 'admin' ? '管理员' : user.role === 'developer' ? '开发者' : '成员'}`); lines.push(`- 倾向语言: 中文`); lines.push(''); } @@ -342,7 +342,7 @@ function en_add_user_level_info(lines: string[], user: any): void { if (!user) return; lines.push('## User'); lines.push(`- Name: ${user.display_name || user.id}`); - lines.push(`- Role: ${user.role === 'admin' ? 'Admin' : 'Member'}`); + lines.push(`- Role: ${user.role === 'admin' ? 'Admin' : user.role === 'developer' ? 'Developer' : 'Member'}`); lines.push(`- Language Preference: English`); lines.push(''); } diff --git a/mobius/backend/types/rows.ts b/mobius/backend/types/rows.ts index e5c22241..e00924dc 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 { @@ -44,7 +45,7 @@ export interface UserRawRow { id: string; display_name: string; password_hash: string; - role: 'admin' | 'user'; + role: 'admin' | 'developer' | 'user'; work_dir: string; group_id: string | null; deleted_at: string | null; 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/extension/best-api-console/backend/extension_backend_handler.js b/mobius/extension/best-api-console/backend/extension_backend_handler.js new file mode 100644 index 00000000..143c155d --- /dev/null +++ b/mobius/extension/best-api-console/backend/extension_backend_handler.js @@ -0,0 +1,124 @@ +// Best API 控制台拓展 — 后端 handler (阶段 A+B: 只读 + 写转发) +// 薄转发层: 前端 extCall({action, params?, body?}) → best-api /admin/api/* (GET/POST/PUT/DELETE)。 +// best-api 是数据/鉴权/计费权威, 这里只做 admin key 注入 + URL 拼装 + 透传响应。 +// +// 约束 (mobius 拓展): stateless worker, 30s/5MB/256MB, 只写 ext_data_dir (本 handler 不写文件)。 +const ADMIN_KEY = process.env.BEST_API_ADMIN_KEY || "sk-c686c15deb1a4de79c1bdcf2e904f06d"; +const BASE = (process.env.BEST_API_BASE_URL || "http://127.0.0.1:39929").replace(/\/+$/, ""); +const enc = (s) => encodeURIComponent(String(s == null ? "" : s)); + +// action → { m: HTTP方法, p: 路径字符串 | (body)=>路径 } +const ROUTES = { + // ---- 只读 (阶段A: 监控) ---- + overview: { m: "GET", p: "/admin/api/overview" }, + key_usage: { m: "GET", p: "/admin/api/key_usage" }, + usage: { m: "GET", p: "/admin/api/usage" }, + calls: { m: "GET", p: "/admin/api/calls" }, + glm_status: { m: "GET", p: "/admin/api/glm_status" }, + users: { m: "GET", p: "/admin/api/users" }, + ledger: { m: "GET", p: "/admin/api/ledger" }, + stats: { m: "GET", p: "/admin/api/stats" }, + users_usage: { m: "GET", p: "/admin/api/users_usage" }, + models: { m: "GET", p: "/v1/models" }, + // ---- 渠道管理 (阶段B) ---- + list_channels: { m: "GET", p: "/admin/api/channels" }, + create_channel: { m: "POST", p: "/admin/api/channels" }, + update_channel: { m: "PUT", p: (b) => `/admin/api/channels/${enc(b.name)}` }, + delete_channel: { m: "DELETE", p: (b) => `/admin/api/channels/${enc(b.name)}` }, + // ---- 模型路由 (阶段B) ---- + list_routes: { m: "GET", p: "/admin/api/routes" }, + set_model_rank: { m: "PUT", p: (b) => `/admin/api/models/${enc(b.model)}/rank` }, + set_model_latency:{ m: "PUT", p: (b) => `/admin/api/models/${enc(b.model)}/latency` }, + delete_model: { m: "DELETE", p: (b) => `/admin/api/models/${enc(b.model)}` }, + set_rank_default: { m: "PUT", p: "/admin/api/rank_default" }, + // ---- 上游 Key 池 (阶段B) ---- + list_keys: { m: "GET", p: "/admin/api/keys" }, + create_key: { m: "POST", p: "/admin/api/keys" }, + delete_key: { m: "DELETE", p: (b) => `/admin/api/keys/${enc(b.key_id)}` }, + enable_key: { m: "POST", p: (b) => `/admin/api/keys/${enc(b.key_id)}/enable` }, + disable_key: { m: "POST", p: (b) => `/admin/api/keys/${enc(b.key_id)}/disable` }, + update_key: { m: "PUT", p: (b) => `/admin/api/keys/${enc(b.key_id)}` }, + // ---- 用户与令牌 (阶段C) ---- + list_tokens: { m: "GET", p: "/admin/api/tokens" }, + add_user: { m: "POST", p: "/admin/api/users" }, + delete_user: { m: "DELETE", p: (b) => `/admin/api/users/${enc(b.user)}` }, + set_balance: { m: "PUT", p: (b) => `/admin/api/users/${enc(b.user)}/balance` }, + set_status: { m: "PUT", p: (b) => `/admin/api/users/${enc(b.user)}/status` }, + issue_key: { m: "POST", p: (b) => `/admin/api/users/${enc(b.user)}/keys` }, + revoke_key: { m: "DELETE", p: (b) => `/admin/api/tokens/${enc(b.key)}` }, +}; + +// 只读 action 允许透传的 query 白名单 (防注入) +const READ_QUERY = { + overview: ["window"], key_usage: ["window"], usage: ["dim", "window"], + calls: ["limit", "offset", "q", "status", "window"], ledger: ["limit", "offset", "user"], +}; + +module.exports = async function bestApiConsoleHandler({ ext_main_payload }) { + const p = ext_main_payload || {}; + const action = p.action; + + // call_detail 动态路径 + if (action === "call_detail") { + const id = String(p.id == null ? "" : p.id); + if (!/^\d+$/.test(id)) return { ok: false, error: "invalid call id" }; + return await fwd("GET", `/admin/api/call/${id}`); + } + + const route = ROUTES[action]; + if (!route) return { ok: false, error: `unknown action: ${action}` }; + + const body = p.body || {}; + const path = typeof route.p === "function" ? route.p(body) : route.p; + + let qs = ""; + if (route.m === "GET" && READ_QUERY[action] && p.params) { + const sp = new URLSearchParams(); + for (const k of READ_QUERY[action]) { + if (p.params[k] != null && p.params[k] !== "") sp.set(k, String(p.params[k])); + } + const s = sp.toString(); + if (s) qs = "?" + s; + } + + const reqBody = route.m !== "GET" ? body : undefined; + return await fwd(route.m, path + qs, reqBody); +}; + +async function fwd(method, path, body) { + let r; + try { + r = await fetch(BASE + path, { + method, + headers: Object.assign( + { Authorization: "Bearer " + ADMIN_KEY, Accept: "application/json" }, + body ? { "content-type": "application/json" } : {} + ), + body: body ? JSON.stringify(body) : undefined, + }); + } catch (e) { + return { ok: false, error: `best-api 不可达: ${e.message} (BASE=${BASE})` }; + } + const text = await r.text(); + let data; + try { data = JSON.parse(text); } catch { data = text; } + if (!r.ok) return { ok: false, error: `best-api HTTP ${r.status}`, status: r.status, detail: data }; + + // 管理写接口统一返回 { ok, data } / { ok:false, error, status },而旧只读接口 + // 直接返回业务对象。这里把两种契约归一为拓展前端期望的单层响应,避免 + // extCall 再包一层后页面把 channels / routes / keys 误读成空数据。 + if (data && typeof data === "object" && !Array.isArray(data) && typeof data.ok === "boolean") { + if (!data.ok) { + return { + ok: false, + error: data.error || "best-api 操作失败", + status: Number(data.status) || 400, + detail: data, + }; + } + if (Object.prototype.hasOwnProperty.call(data, "data")) { + return { ok: true, data: data.data }; + } + } + return { ok: true, data }; +} diff --git a/mobius/extension/best-api-console/extension.json b/mobius/extension/best-api-console/extension.json new file mode 100644 index 00000000..77d67b53 --- /dev/null +++ b/mobius/extension/best-api-console/extension.json @@ -0,0 +1,8 @@ +{ + "name": "best-api-console", + "display_name": "Best API 控制台", + "description": "Best API 中转服务管理控制台:渠道与上游 Key 状态、用量日志、计费、模型总览(类 new-api)。数据实时来自 best-api /admin/api。", + "version": "0.1.0", + "icon": "favicon.svg", + "project": { "sync": true } +} diff --git a/mobius/extension/best-api-console/frontend/favicon.svg b/mobius/extension/best-api-console/frontend/favicon.svg new file mode 100644 index 00000000..6f6f8dec --- /dev/null +++ b/mobius/extension/best-api-console/frontend/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/mobius/extension/best-api-console/frontend/index.html b/mobius/extension/best-api-console/frontend/index.html new file mode 100644 index 00000000..6ad05220 --- /dev/null +++ b/mobius/extension/best-api-console/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + Best API 控制台 + + + + +
+ + + diff --git a/mobius/extension/best-api-console/frontend/main.js b/mobius/extension/best-api-console/frontend/main.js new file mode 100644 index 00000000..120aa9ba --- /dev/null +++ b/mobius/extension/best-api-console/frontend/main.js @@ -0,0 +1,338 @@ +// Best API 控制台 — 前端 (阶段A 只读 + 阶段B 写管理) +// Tab: 概览 / Key池(写) / 渠道(写) / 用量日志 / 计费 / 模型。 +// 读: extCall({action,params}); 写: extCall({action,body})。经 handler → best-api /admin/api。 +import { extCall } from '/extension/_sdk/ext.js'; + +const TABS = [ + { id: 'overview', name: '概览', ico: '📊' }, + { id: 'keypool', name: 'Key 池', ico: '🔑' }, + { id: 'channels', name: '渠道', ico: '🔌' }, + { id: 'calls', name: '用量日志', ico: '📋' }, + { id: 'billing', name: '计费', ico: '💰' }, + { id: 'models', name: '模型', ico: '🧩' }, +]; + +let currentTab = 'overview'; +let autoTimer = null; + +// ---------- 工具 ---------- +const nf = new Intl.NumberFormat('zh-CN'); +const fmtInt = v => (v == null || v === '' || isNaN(v)) ? '—' : nf.format(Number(v)); +const fmtMoney = v => (v == null || isNaN(v)) ? '—' : '¥' + Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 4 }); +const fmtPct = v => (v == null || isNaN(v)) ? '—' : Number(v).toFixed(2) + '%'; +const fmtTime = s => { if (!s) return '—'; const d = new Date(String(s).endsWith('Z') ? s : s + 'Z'); return isNaN(d) ? s : d.toLocaleString('zh-CN', { hour12: false }); }; +const fmtAgo = s => { if (!s) return '—'; const d = new Date(String(s).endsWith('Z') ? s : s + 'Z'); if (isNaN(d)) return '—'; const sec = Math.max(0, ((Date.now() - d) / 1000) | 0); if (sec < 60) return sec + 's'; if (sec < 3600) return (sec / 60 | 0) + 'm'; if (sec < 86400) return (sec / 3600 | 0) + 'h'; return (sec / 86400 | 0) + 'd'; }; +const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +const $ = id => document.getElementById(id); + +async function api(action, params) { + const r = await extCall({ action, params }); + if (!r || !r.ok) throw new Error((r && r.error) || '调用失败'); + return r.data; +} +async function apiWrite(action, body) { + const r = await extCall({ action, body }); + if (!r || !r.ok) throw new Error((r && r.error) || '写操作失败'); + return r.data; +} +async function apiDel(action, body) { return apiWrite(action, body); } + +// ---------- 框架 ---------- +function renderShell() { + $('app').innerHTML = ` + +
+

${esc(TABS.find(t => t.id === currentTab).name)}

+
+ +
+
+
`; + document.querySelectorAll('.nav-item').forEach(el => el.addEventListener('click', () => switchTab(el.dataset.tab))); + $('refresh').addEventListener('click', () => loadTab(true)); + $('auto').addEventListener('click', toggleAuto); +} +function switchTab(id) { if (id !== currentTab) { currentTab = id; renderShell(); loadTab(true); } } +function toggleAuto() { + if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } else { autoTimer = setInterval(() => loadTab(false), 30000); } + renderShell(); loadTab(true); +} +function setSub(t) { $('tab-sub').textContent = t || ''; } +function setErr(e) { $('err').textContent = e ? ('⚠ ' + e) : ''; } +function showLoader() { $('content').innerHTML = '
加载中…
'; } +async function loadTab(showLoad) { + if (showLoad) { showLoader(); setErr(''); } + try { + const fn = { overview: renderOverview, keypool: renderKeyPool, channels: renderChannels, + calls: renderCalls, billing: renderBilling, models: renderModels }[currentTab]; + await fn(); setErr(''); + } catch (e) { $('content').innerHTML = `
⚠ ${esc(e.message)}
`; setErr(e.message); } +} + +// ---------- Tab: 概览 ---------- +async function renderOverview() { + setSub('近 24 小时'); + const d = await api('overview', { window: '24h' }); + const k = d.kpi || {}; + const cards = [ + { k: '请求数', v: fmtInt(k.requests), c: 'acc', s: `成功 ${fmtInt(k.success)} · ${fmtPct(k.success_rate)}` }, + { k: '总 Tokens', v: fmtInt(k.total_tokens), c: 'grn', s: `入 ${fmtInt(k.prompt_tokens)} / 出 ${fmtInt(k.completion_tokens)}` }, + { k: '消耗', v: fmtMoney(k.cost), c: 'yel', s: d.bucket_label || '' }, + { k: '平均延迟', v: (k.avg_latency == null ? '—' : Number(k.avg_latency).toFixed(2) + 's'), c: '', s: `首 token ${k.avg_first_token == null ? '—' : Number(k.avg_first_token).toFixed(2) + 's'}` }, + ]; + const topM = (d.top_models || []).slice(0, 6), topP = (d.top_providers || []).slice(0, 6); + $('content').innerHTML = ` +
${cards.map(c => `
${c.k}
${c.v}
${esc(c.s)}
`).join('')}
+
+

Top 模型

+ + ${topM.length ? topM.map(m => ``).join('') : ''}
模型请求Tokens消耗
${esc(m.model)}${fmtInt(m.requests)}${fmtInt(m.tokens)}${fmtMoney(m.cost)}
+

Top 渠道

+ + ${topP.length ? topP.map(p => ``).join('') : ''}
渠道请求成功率
${esc(p.provider)}${fmtInt(p.requests)}${fmtPct(p.requests ? p.success / p.requests * 100 : null)}
+
`; +} + +// ---------- Tab: Key 池 (写: 加/启停/删/改rank) ---------- +async function renderKeyPool() { + setSub('上游 Key 池管理 (会话负载均衡)'); + const [stat, pool] = await Promise.all([api('glm_status'), api('list_keys')]); + const c = stat.status_counts || {}; + const keys = (pool.keys || []); + const providers = pool.providers || ['zhipu-codingplan']; + $('content').innerHTML = ` +
+ ${[['可用','active','grn',c.active],['冷却','cooldown','yel',c.cooldown],['禁用','disabled','red',c.disabled],['累计请求','acc','',stat.totals?.requests]].map(x=>`
${x[0]}
${fmtInt(x[3])}
`).join('')} +
+
+

添加上游 Key

写入 keys_status.json (走 LoadBalancer 事务)
+
+
+
+
+
+
+
+
+

Key 列表

${keys.length} 个
+
+ ${keys.length ? keys.map(r => ` + + + + + + + + + + `).join('') : ''}
状态Key渠道rank会话请求成功率最后使用操作
${kpStatusPill(r)}${esc(r.key_id||'—')}
${esc(r.label||r.key_masked)}
${esc(r.provider)}${r.rank ?? 0}${fmtInt(r.session_count)}${fmtInt(r.total_requests)}${fmtPct(r.success_rate ?? (r.total_requests ? r.success_requests/r.total_requests*100 : null))}${fmtAgo(r.last_used_at)}
+ ${r.group==='active' ? `` : ``} + +
无 Key
`; + $('kp-add').addEventListener('click', kpAddKey); + document.querySelectorAll('#content button[data-act]').forEach(b => b.addEventListener('click', () => kpKeyAction(b.dataset.act, b.dataset.id))); +} +function kpStatusPill(r) { + const g = r.group; + if (g === 'active') return '可用'; + if (g === 'cooldown') return '冷却'; + if (g === 'disabled') return '禁用'; + return `${esc(g)}`; +} +async function kpAddKey() { + const body = { key: $('kp-key').value.trim(), provider: $('kp-prov').value, label: $('kp-label').value.trim() || 'added', rank: parseInt($('kp-rank').value || '0', 10) }; + if (!body.key) { setErr('Key 不能为空'); return; } + try { await apiWrite('create_key', body); setErr(''); setSub('✓ 已添加'); renderKeyPool(); } + catch (e) { setErr(e.message); } +} +async function kpKeyAction(act, keyId) { + const verb = act === 'del' ? '删除' : (act === 'disable' ? '禁用' : '启用'); + if (!confirm(`${verb} key ${keyId} ?`)) return; + try { + if (act === 'del') await apiDel('delete_key', { key_id: keyId }); + else if (act === 'disable') await apiWrite('disable_key', { key_id: keyId }); + else await apiWrite('enable_key', { key_id: keyId }); + setSub(`✓ 已${verb}`); renderKeyPool(); + } catch (e) { setErr(e.message); } +} + +// ---------- Tab: 渠道 (写: 增删渠道 + 模型路由) ---------- +async function renderChannels() { + setSub('渠道 (config.jsonc) + 模型路由'); + const [chs, routes] = await Promise.all([api('list_channels'), api('list_routes')]); + const channels = chs.channels || []; + const overrides = routes.MODEL_OVERRIDE || {}; + const rankDefault = (routes.MODEL_PROVIDER_RANK_DEFAULT || {}).default_rank || []; + $('content').innerHTML = ` +

默认路由 rank

MODEL_PROVIDER_RANK_DEFAULT
+
+ + +
+

模型路由 (MODEL_OVERRIDE rank)

+
+ ${Object.keys(overrides).length ? Object.entries(overrides).map(([m, mo]) => ` + + + + `).join('') : ''}
模型rank (逗号分隔)操作
${esc(m)}
无模型 override
+

渠道 (ENDPOINT_API_KEYS)

${channels.length} 个 · 改动写 config.jsonc (~10s 生效)
+
+ ${channels.length ? channels.map(ch => ` + + + + + + `).join('') : ''}
渠道URL标志Key操作
${esc(ch.name)}${esc(ch.url||'—')}${[ch.anthropic_native?'原生':'',ch.use_session_identity_based_load_balance?'会话LB':'',ch.sse_jump_enable?'sse跳':''].filter(Boolean).map(x=>`${x}`).join(' ')||'—'}${ch.has_key?'✓ '+esc(ch.key_masked||''):''}
无渠道
+

+ 添加渠道

+
+
+
+
+
+
+
`; + $('ch-rankdef-save').addEventListener('click', async () => { + try { await apiWrite('set_rank_default', { providers: $('ch-rankdef').value.split(',').map(s => s.trim()).filter(Boolean) }); setSub('✓ 默认 rank 已保存'); } + catch (e) { setErr(e.message); } + }); + document.querySelectorAll('#content button[data-saverank]').forEach(b => b.addEventListener('click', async () => { + const m = b.dataset.saverank; + const v = document.querySelector(`.ch-rank-input[data-model="${m}"]`).value.split(',').map(s => s.trim()).filter(Boolean); + try { await apiWrite('set_model_rank', { model: m, providers: v }); setSub(`✓ ${m} rank 已保存`); } + catch (e) { setErr(e.message); } + })); + document.querySelectorAll('#content button[data-delch]').forEach(b => b.addEventListener('click', async () => { + const n = b.dataset.delch; if (!confirm(`删除渠道 ${n} ? (若被模型引用会拒绝)`)) return; + try { await apiDel('delete_channel', { name: n }); setSub(`✓ 已删除 ${n}`); renderChannels(); } + catch (e) { setErr(e.message); } + })); + $('ch-add').addEventListener('click', async () => { + const body = { name: $('ch-name').value.trim(), url: $('ch-url').value.trim(), key: $('ch-key').value.trim() }; + if ($('ch-native').value) body.anthropic_native = true; + if (!body.name || !body.url) { setErr('渠道名和 URL 必填'); return; } + try { await apiWrite('create_channel', body); setSub(`✓ 已添加 ${body.name}`); renderChannels(); } + catch (e) { setErr(e.message); } + }); +} + +// ---------- Tab: 用量日志 ---------- +let callsFilter = { status: '', q: '' }; +async function renderCalls() { + setSub('调用记录'); + const params = { limit: 50, window: '24h' }; + if (callsFilter.status) params.status = callsFilter.status; + if (callsFilter.q) params.q = callsFilter.q; + const d = await api('calls', params); + const rows = d.rows || []; + $('content').innerHTML = `

请求流水

共 ${fmtInt(d.total)} · 显示 ${rows.length}
+
+ + +
+
+ ${rows.length ? rows.map(r => ``).join('') : ''}
时间用户模型渠道状态Tokens消耗延迟
${fmtAgo(r.created_at)}${esc(r.user||'—')}${esc(r.model||'—')}${esc(r.provider||'—')}${callStatusPill(r.status_code)}${fmtInt(r.tokens)}${fmtMoney(r.cost)}${r.latency==null?'—':Number(r.latency).toFixed(2)+'s'}
`; + $('cf-go').addEventListener('click', () => { callsFilter.status = $('cf-status').value; callsFilter.q = $('cf-q').value.trim(); renderCalls(); }); + $('cf-q').addEventListener('keydown', e => { if (e.key === 'Enter') $('cf-go').click(); }); + document.querySelectorAll('tr.clickable').forEach(tr => tr.addEventListener('click', () => showCallDetail(tr.dataset.id))); +} +function callStatusPill(code) { if (code == null) return ''; const c = Number(code); if (c >= 200 && c < 300) return `${c}`; if (c === 429) return `${c}`; if (c >= 400 && c < 500) return `${c}`; return `${c}`; } +async function showCallDetail(id) { + let d; try { d = await api('call_detail', { id }); } catch (e) { setErr(e.message); return; } + const mask = document.createElement('div'); mask.className = 'drawer-mask'; + mask.innerHTML = `

调用详情 #${esc(d.id)}

+
时间
${fmtTime(d.created_at)}
UUID
${esc(d.request_uuid)}
用户
${esc(d.user)}
模型
${esc(d.requested_model)}
渠道
${esc(d.provider)}
状态
${callStatusPill(d.status_code)} ${esc(d.finish_reason||'')}
用量
${fmtInt(d.usage_parsed?.prompt_tokens)} 入 / ${fmtInt(d.usage_parsed?.completion_tokens)} 出 · 消耗 ${fmtMoney(d.usage_parsed?.cost)}
+
${esc(JSON.stringify(d.model_parsed || d.routing_parsed || {}, null, 2))}
`; + document.body.appendChild(mask); + mask.addEventListener('click', e => { if (e.target === mask || e.target.id === 'd-close') document.body.removeChild(mask); }); +} + +// ---------- Tab: 计费 (用户/令牌管理, 阶段C 写) ---------- +async function renderBilling() { + setSub('用户 / 令牌 / 余额'); + const [u, s, lg, tk] = await Promise.all([api('users'), api('stats'), api('ledger', { limit: 15 }), api('list_tokens')]); + const users = u.users || [], tokens = (tk.tokens || []), rows = lg.rows || []; + $('content').innerHTML = ` +
${[['总消耗','yel',fmtMoney(s.total_cost)],['总请求','',fmtInt(s.total_requests)],['用户','',fmtInt(users.length)],['令牌','cyn',fmtInt(tokens.length)]].map(x=>`
${x[0]}
${x[2]}
`).join('')}
+

+ 添加用户

+
+
+
+
+
+
+

用户

${users.length} 个 · 改 user_billing.json (即时生效)
+
+ ${users.length ? users.map(x => ` + + + + + + `).join('') : ''}
用户余额状态充值(±)操作
${esc(x.user)}
${esc(x.note||'')}
${fmtMoney(x.balance)}${x.status==='active'?'正常':`${esc(x.status)}`}
+ + + + +
无用户
+

令牌 (客户端 API Key)

${tokens.length} 个
+
+ ${tokens.length ? tokens.map(t => ` + + + + + + `).join('') : ''}
Key用户角色valid_keys操作
${esc(t.key_masked)} …${esc(t.key_tail)}${esc(t.user)}${esc(t.role)}${t.in_valid_keys?'':''}
无令牌
+

近期流水

${rows.length} 条
+ + ${rows.length ? rows.map(r => ``).join('') : ''}
时间用户模型消耗余额后
${fmtAgo(r.created_at)}${esc(r.user)}${esc(r.model)}${fmtMoney(r.cost)}${fmtMoney(r.balance_after)}
`; + $('bl-adduser').addEventListener('click', async () => { + try { await apiWrite('add_user', { user: $('bl-newuser').value.trim(), balance: parseFloat($('bl-newbal').value||'0'), note: $('bl-newnote').value.trim() }); setSub('✓ 用户已添加'); renderBilling(); } catch(e){ setErr(e.message); } + }); + document.querySelectorAll('#content button[data-rc]').forEach(b => b.addEventListener('click', async () => { + const u = b.dataset.rc, inp = document.querySelector(`.bl-rc[data-user="${u}"]`), amt = parseFloat(inp?.value||'0'); + if (isNaN(amt)) { setErr('充值金额无效'); return; } + try { await apiWrite('set_balance', { user: u, balance: amt, delta_mode: true }); setSub(`✓ ${u} ${amt>0?'+':''}${amt}`); renderBilling(); } catch(e){ setErr(e.message); } + })); + document.querySelectorAll('#content button[data-tg]').forEach(b => b.addEventListener('click', async () => { + const u = b.dataset.tg, ns = b.dataset.cur === 'active' ? 'banned' : 'active'; + if (!confirm(`${u} → ${ns}?`)) return; + try { await apiWrite('set_status', { user: u, status: ns }); setSub(`✓ ${u} ${ns}`); renderBilling(); } catch(e){ setErr(e.message); } + })); + document.querySelectorAll('#content button[data-ik]').forEach(b => b.addEventListener('click', async () => { + const u = b.dataset.ik, role = prompt(`为 ${u} 发新 key, 角色 (admin/user)`, 'user'); + if (!role) return; + try { const r = await apiWrite('issue_key', { user: u, role }); prompt(`新 key 已生成 (仅此一次, 复制保存):`, r.key); setSub(`✓ 已为 ${u} 发 key`); renderBilling(); } catch(e){ setErr(e.message); } + })); + document.querySelectorAll('#content button[data-du]').forEach(b => b.addEventListener('click', async () => { + const u = b.dataset.du; if (!confirm(`删除用户 ${u} 及其所有 key?`)) return; + try { await apiDel('delete_user', { user: u }); setSub(`✓ 已删 ${u}`); renderBilling(); } catch(e){ setErr(e.message); } + })); + document.querySelectorAll('#content button[data-rv]').forEach(b => b.addEventListener('click', async () => { + const key = b.dataset.rv; if (!confirm(`吊销 key …${key.slice(-8)}?`)) return; + try { await apiDel('revoke_key', { key }); setSub('✓ 已吊销'); renderBilling(); } catch(e){ setErr(e.message); } + })); +} + +// ---------- Tab: 模型 ---------- +async function renderModels() { + setSub('可用模型'); + const d = await api('models'); + const list = d.data || []; + $('content').innerHTML = `

模型

${list.length} 个
+ ${list.length ? list.map(m => ``).join('') : ''}
模型 ID归属
${esc(m.id)}${esc(m.owned_by||'—')}
`; +} + +// ---------- 启动 ---------- +renderShell(); +loadTab(true); +autoTimer = setInterval(() => loadTab(false), 30000); diff --git a/mobius/extension/best-api-console/frontend/style.css b/mobius/extension/best-api-console/frontend/style.css new file mode 100644 index 00000000..061c3830 --- /dev/null +++ b/mobius/extension/best-api-console/frontend/style.css @@ -0,0 +1,102 @@ +:root{ + --bg:#0d1017; --panel:#151a24; --panel2:#1b212d; --panel3:#202737; --border:#2b3342; + --text:#e6e9ef; --muted:#8a93a6; --faint:#5c6577; + --accent:#4f9cf9; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#a371f7; --cyan:#56d4dd; + --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; +} +*{box-sizing:border-box} +html,body{margin:0;padding:0;height:100%} +body{ + background:var(--bg);color:var(--text); + font-family:system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; + font-size:14px;line-height:1.5; +} +#app{display:flex;min-height:100vh} + +/* 侧边栏 */ +.sidebar{ + width:200px;flex-shrink:0;background:var(--panel);border-right:1px solid var(--border); + display:flex;flex-direction:column;padding:14px 10px;gap:4px; +} +.brand{display:flex;align-items:center;gap:9px;padding:6px 8px 14px;border-bottom:1px solid var(--border);margin-bottom:10px} +.brand img{width:26px;height:26px} +.brand .name{font-size:14px;font-weight:650} +.brand .sub{font-size:10px;color:var(--faint)} +.nav-item{ + display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:7px;cursor:pointer; + color:var(--muted);font-size:13px; +} +.nav-item .ico{width:18px;text-align:center;opacity:.9} +.nav-item:hover{background:var(--panel2);color:var(--text)} +.nav-item.active{background:rgba(79,156,249,.14);color:var(--accent)} +.nav-spacer{flex:1} +.nav-meta{padding:8px 10px;font-size:10px;color:var(--faint);border-top:1px solid var(--border)} + +/* 主区 */ +.main{flex:1;min-width:0;display:flex;flex-direction:column} +.topbar{ + position:sticky;top:0;z-index:5;background:rgba(13,16,23,.9);backdrop-filter:blur(8px); + border-bottom:1px solid var(--border);padding:11px 22px;display:flex;align-items:center;gap:12px; +} +.topbar h1{font-size:15px;margin:0;font-weight:600} +.topbar .sub{color:var(--muted);font-size:12px} +.spacer{flex:1} +.btn{ + background:var(--panel2);border:1px solid var(--border);color:var(--text); + padding:6px 12px;border-radius:6px;font-size:12px;cursor:pointer; +} +.btn:hover{border-color:var(--accent);color:var(--accent)} +.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff} +.btn.primary:hover{filter:brightness(1.08);color:#fff} +.content{padding:20px 22px;overflow:auto} +#err{color:var(--red);font-size:12px} +.pill{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:550;white-space:nowrap} +.pill.active{background:rgba(63,185,80,.15);color:var(--green)} +.pill.cooldown{background:rgba(210,153,34,.16);color:var(--yellow)} +.pill.disabled{background:rgba(248,81,73,.15);color:var(--red)} +.pill.ready{background:rgba(86,212,221,.16);color:var(--cyan)} +.pill.provider{background:rgba(79,156,249,.14);color:var(--accent)} +.dot{width:7px;height:7px;border-radius:50%;display:inline-block;background:currentColor} + +/* 卡片/网格 */ +.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-bottom:16px} +.card{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:13px 15px;min-width:0} +.card .k{color:var(--muted);font-size:11px;margin-bottom:6px;text-transform:uppercase;letter-spacing:.3px} +.card .v{font-size:22px;font-weight:650;font-family:var(--mono);overflow-wrap:anywhere} +.card .sub{color:var(--faint);font-size:11px;margin-top:4px} +.card.acc .v{color:var(--accent)} +.card.grn .v{color:var(--green)} +.card.yel .v{color:var(--yellow)} +.card.red .v{color:var(--red)} +.card.cyn .v{color:var(--cyan)} + +/* 表格 */ +.panel{background:var(--panel);border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:16px} +.panel>.hd{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px} +.panel>.hd h2{margin:0;font-size:13px;font-weight:600} +.hint{color:var(--faint);font-size:11px} +table{width:100%;border-collapse:collapse;font-size:13px} +th,td{padding:8px 11px;text-align:left;border-bottom:1px solid var(--border);white-space:nowrap;vertical-align:top} +th{color:var(--muted);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.4px;background:var(--panel2)} +td.num,th.num{text-align:right;font-family:var(--mono)} +td.mono{font-family:var(--mono);font-size:12px} +tbody tr{cursor:default} +tbody tr.clickable{cursor:pointer} +tbody tr:hover{background:var(--panel2)} +.errtxt{max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)} +.muted{color:var(--muted)}.faint{color:var(--faint)} +.empty{padding:30px;text-align:center;color:var(--faint)} +.bar{height:7px;background:var(--panel3);border-radius:99px;overflow:hidden;margin:8px 0 6px} +.bar span{display:block;height:100%;float:left} +.seg-a{background:var(--green)}.seg-c{background:var(--yellow)}.seg-d{background:var(--red)} +.loader{display:inline-block;width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} + +/* 抽屉 (调用详情) */ +.drawer-mask{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:20;display:flex;justify-content:flex-end} +.drawer{width:min(720px,92vw);background:var(--panel);border-left:1px solid var(--border);overflow:auto;padding:18px 22px} +.drawer h3{margin:0 0 4px;font-size:15px} +.drawer .kv{display:grid;grid-template-columns:120px minmax(0,1fr);gap:7px 12px;margin:14px 0;font-size:13px} +.drawer .kv dt{color:var(--muted)} +.drawer .kv dd{margin:0;overflow-wrap:anywhere} +.codebox{background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:10px;font-family:var(--mono);font-size:11px;color:var(--muted);white-space:pre-wrap;max-height:260px;overflow:auto;margin-top:8px} diff --git a/mobius/extension/bullet-heaven/backend/extension_backend_handler.js b/mobius/extension/bullet-heaven/backend/extension_backend_handler.js new file mode 100644 index 00000000..de11f4d6 --- /dev/null +++ b/mobius/extension/bullet-heaven/backend/extension_backend_handler.js @@ -0,0 +1,139 @@ +/** + * 弹幕割草实验室扩展后端。 + * 游戏模拟全部在浏览器中完成;后端仅保存最佳战绩和排行榜。 + */ +const path = require('path'); +const fs = require('fs/promises'); + +const STATE_FILE = 'leaderboard.json'; +const MAX_SCORE = 2_000_000_000; +const MAX_KILLS = 200_000; +const MAX_DURATION = 3_600; +const MAX_LEVEL = 200; + +function finiteInt(value, min, max) { + const number = Number(value); + if (!Number.isFinite(number)) return null; + const integer = Math.trunc(number); + return integer >= min && integer <= max ? integer : null; +} + +async function readRows(file) { + try { + const parsed = JSON.parse(await fs.readFile(file, 'utf8')); + return Array.isArray(parsed) ? parsed.filter((row) => row && typeof row === 'object') : []; + } catch { + return []; + } +} + +async function writeRows(file, rows) { + await fs.mkdir(path.dirname(file), { recursive: true }); + const temp = path.join(path.dirname(file), `.leaderboard-${process.pid}-${Date.now()}.tmp`); + await fs.writeFile(temp, JSON.stringify(rows, null, 2), 'utf8'); + await fs.rename(temp, file); +} + +function publicRow(row, rank) { + return { + rank, + username: row.username, + display_name: row.display_name || row.username, + score: finiteInt(row.score, 0, MAX_SCORE) || 0, + kills: finiteInt(row.kills, 0, MAX_KILLS) || 0, + level: finiteInt(row.level, 1, MAX_LEVEL) || 1, + duration: finiteInt(row.duration, 0, MAX_DURATION) || 0, + victory: row.victory === true, + runs: finiteInt(row.runs, 1, 1_000_000) || 1, + ts: finiteInt(row.ts, 0, Number.MAX_SAFE_INTEGER) || 0, + }; +} + +module.exports = async function bulletHeavenHandler({ + username, + display_name, + ext_main_payload, + ext_data_dir, + extension_name, + logger, +}) { + const payload = ext_main_payload && typeof ext_main_payload === 'object' ? ext_main_payload : {}; + const action = payload.action; + const stateFile = path.join(ext_data_dir, STATE_FILE); + + if (action === 'whoami') { + return { ok: true, username, display_name, extension_name }; + } + + if (action === 'get_leaderboard' || action === 'get_profile') { + const rows = (await readRows(stateFile)).sort((a, b) => Number(b.score || 0) - Number(a.score || 0) || Number(a.ts || 0) - Number(b.ts || 0)); + const ownIndex = rows.findIndex((row) => row.username === username); + return { + ok: true, + leaderboard: rows.slice(0, 10).map((row, index) => publicRow(row, index + 1)), + profile: ownIndex >= 0 ? publicRow(rows[ownIndex], ownIndex + 1) : null, + }; + } + + if (action === 'submit_run') { + const score = finiteInt(payload.score, 0, MAX_SCORE); + const kills = finiteInt(payload.kills, 0, MAX_KILLS); + const duration = finiteInt(payload.duration, 0, MAX_DURATION); + const level = finiteInt(payload.level, 1, MAX_LEVEL); + if (score === null || kills === null || duration === null || level === null) { + return { ok: false, error: 'invalid run result' }; + } + + const rows = await readRows(stateFile); + const existing = rows.find((row) => row.username === username); + const now = Date.now(); + const result = { + username, + display_name: String(display_name || username).slice(0, 80), + score, + kills, + duration, + level, + victory: payload.victory === true, + runs: ((existing && finiteInt(existing.runs, 1, 999_999)) || 0) + 1, + ts: now, + }; + + let isBest = true; + if (existing) { + isBest = score > Number(existing.score || 0); + if (isBest) { + Object.assign(existing, result); + } else { + existing.display_name = result.display_name; + existing.runs = result.runs; + existing.last_score = score; + existing.last_kills = kills; + existing.last_level = level; + existing.last_duration = duration; + existing.last_victory = result.victory; + existing.last_ts = now; + } + } else { + rows.push(result); + } + + rows.sort((a, b) => Number(b.score || 0) - Number(a.score || 0) || Number(a.ts || 0) - Number(b.ts || 0)); + const trimmed = rows.slice(0, 100); + await writeRows(stateFile, trimmed); + const ownIndex = trimmed.findIndex((row) => row.username === username); + + if (logger && logger.info) { + logger.info('submit_run', { username, score, kills, level, isBest }); + } + + return { + ok: true, + is_best: isBest, + rank: ownIndex >= 0 ? ownIndex + 1 : null, + leaderboard: trimmed.slice(0, 10).map((row, index) => publicRow(row, index + 1)), + }; + } + + return { ok: false, error: 'unknown action' }; +}; diff --git a/mobius/extension/bullet-heaven/extension.json b/mobius/extension/bullet-heaven/extension.json new file mode 100644 index 00000000..bea0f2ab --- /dev/null +++ b/mobius/extension/bullet-heaven/extension.json @@ -0,0 +1,10 @@ +{ + "name": "bullet-heaven", + "display_name": "弹幕割草实验室", + "description": "自由移动的俯视角高密度割草爽游:鼠标八向瞄准、WASD 移动、海量敌潮、武器叠加与夸张特效。", + "version": "0.1.0", + "icon": "favicon.svg", + "project": { + "sync": true + } +} diff --git a/mobius/extension/bullet-heaven/frontend/favicon.svg b/mobius/extension/bullet-heaven/frontend/favicon.svg new file mode 100644 index 00000000..e12270d7 --- /dev/null +++ b/mobius/extension/bullet-heaven/frontend/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobius/extension/bullet-heaven/frontend/index.html b/mobius/extension/bullet-heaven/frontend/index.html new file mode 100644 index 00000000..9e56a767 --- /dev/null +++ b/mobius/extension/bullet-heaven/frontend/index.html @@ -0,0 +1,150 @@ + + + + + + + 弹幕割草实验室 + + + + +
+ +
+
+
+ +
+
+ CHAOS SURVIVOR / 01 + 弹幕割草实验室 + LIVE +
+
+
等级1
+
击杀0
+
连杀×1
+
剩余150
+
得分0
+
+
+ + +
+
+ +
+
生命
100 / 100
+
进化
0 / 14
+
+ + + + + +
+ WASD 移动鼠标 八向瞄准SHIFT 冲刺P 暂停 +
+ + + +
+ + +
+
+
+ 另一个玩法,不守线,直接冲进怪堆里 +

弹幕割草
CHAOS SURVIVOR

+

角色可以满屏自由移动,枪口持续追随鼠标方向。怪物从四面八方包围,升级会把普通步枪逐渐叠成多重穿透、爆炸、闪电、导弹、无人机与环刃组成的移动军火库。

+
+
900同屏敌人上限
+
1600弹丸轨迹上限
+
24可叠加升级
+
150s一局快速战役
+
+
+ 多重齐射高爆弹头连锁闪电冰冻弹无人机旋转环刃追踪导弹弹射子弹 +
+ +

提示:鼠标停在哪里,角色就朝哪里持续射击;Shift 可以撞穿包围圈。

+
+ +
+
+ +
+
+ LEVEL UP · 火力进化 +

三选一,继续把屏幕塞满

+

选择后立刻恢复战斗

+
+ 按数字键 1 / 2 / 3 也可以选择 +
+
+ +
+
+ SIMULATION PAUSED +

战场已暂停

+ + +
+
+ +
+
+ RUN COMPLETE +

尸潮被打穿了

+

这次广告没有在最爽的时候切走。

+
+
最终得分0
+
击杀数量0
+
进化等级1
+
排行榜
+
+ +
+ + +
+
+
+ +
+
移动
+
瞄准
+
+
+ + + diff --git a/mobius/extension/bullet-heaven/frontend/main.js b/mobius/extension/bullet-heaven/frontend/main.js new file mode 100644 index 00000000..d4843f2f --- /dev/null +++ b/mobius/extension/bullet-heaven/frontend/main.js @@ -0,0 +1,1522 @@ +import { extCall } from '/extension/_sdk/ext.js'; + +const CONFIG = Object.freeze({ + duration: 150, + bossAt: 112, + maxEnemies: 900, + maxBullets: 1600, + maxHostiles: 520, + maxParticles: 2200, + maxPickups: 420, + cellSize: 76, +}); + +const ENEMY_TYPES = Object.freeze({ + grunt: { frame: 0, hp: 1, speed: 1, radius: 16, damage: 9, score: 11, xp: 1, color: '#85ef72' }, + runner: { frame: 1, hp: 0.56, speed: 1.9, radius: 13, damage: 8, score: 15, xp: 1, color: '#ff9e42' }, + tank: { frame: 2, hp: 4.8, speed: 0.56, radius: 24, damage: 17, score: 32, xp: 3, color: '#a984ff' }, + splitter: { frame: 0, hp: 2.2, speed: 0.92, radius: 20, damage: 12, score: 24, xp: 2, color: '#4cf1c5' }, + shooter: { frame: 3, hp: 3.2, speed: 0.68, radius: 20, damage: 11, score: 38, xp: 3, color: '#e96cff' }, + elite: { frame: 3, hp: 13, speed: 0.8, radius: 31, damage: 24, score: 180, xp: 12, color: '#ff5fa0' }, + boss: { frame: 4, hp: 1, speed: 0.43, radius: 74, damage: 38, score: 20000, xp: 80, color: '#ff3d60' }, +}); + +const canvas = document.getElementById('gameCanvas'); +const ctx = canvas.getContext('2d', { alpha: false, desynchronized: true }); +const els = { + shell: document.getElementById('gameShell'), + level: document.getElementById('levelValue'), + kills: document.getElementById('killsValue'), + combo: document.getElementById('comboValue'), + time: document.getElementById('timeValue'), + score: document.getElementById('scoreValue'), + hpFill: document.getElementById('hpFill'), + hpText: document.getElementById('hpText'), + xpFill: document.getElementById('xpFill'), + xpText: document.getElementById('xpText'), + arsenalList: document.getElementById('arsenalList'), + damageStat: document.getElementById('damageStat'), + rateStat: document.getElementById('rateStat'), + shotStat: document.getElementById('shotStat'), + speedStat: document.getElementById('speedStat'), + soundBtn: document.getElementById('soundBtn'), + pauseBtn: document.getElementById('pauseBtn'), + directorPanel: document.getElementById('directorPanel'), + directorToggle: document.getElementById('directorToggle'), + hordeBtn: document.getElementById('hordeBtn'), + overdriveBtn: document.getElementById('overdriveBtn'), + eliteBtn: document.getElementById('eliteBtn'), + nukeBtn: document.getElementById('nukeBtn'), + autoPick: document.getElementById('autoPickInput'), + bossHud: document.getElementById('bossHud'), + bossName: document.getElementById('bossName'), + bossHpText: document.getElementById('bossHpText'), + bossHpFill: document.getElementById('bossHpFill'), + toast: document.getElementById('toast'), + banner: document.getElementById('banner'), + damageFlash: document.getElementById('damageFlash'), + startOverlay: document.getElementById('startOverlay'), + upgradeOverlay: document.getElementById('upgradeOverlay'), + pauseOverlay: document.getElementById('pauseOverlay'), + resultOverlay: document.getElementById('resultOverlay'), + startBtn: document.getElementById('startBtn'), + resumeBtn: document.getElementById('resumeBtn'), + restartBtn: document.getElementById('restartBtn'), + againBtn: document.getElementById('againBtn'), + menuBtn: document.getElementById('menuBtn'), + upgradeOptions: document.getElementById('upgradeOptions'), + upgradeCountdown: document.getElementById('upgradeCountdown'), + leaderboardList: document.getElementById('leaderboardList'), + identityValue: document.getElementById('identityValue'), + resultEyebrow: document.getElementById('resultEyebrow'), + resultTitle: document.getElementById('resultTitle'), + resultDescription: document.getElementById('resultDescription'), + finalScore: document.getElementById('finalScore'), + finalKills: document.getElementById('finalKills'), + finalLevel: document.getElementById('finalLevel'), + finalRank: document.getElementById('finalRank'), + newBest: document.getElementById('newBestBadge'), + moveStick: document.getElementById('moveStick'), + aimStick: document.getElementById('aimStick'), +}; + +let width = 1280; +let height = 720; +let dpr = 1; +let nextEnemyId = 1; +let rafId = 0; +let autoPickTimer = 0; +let toastTimer = 0; +let audioCtx = null; +let muted = localStorage.getItem('bullet-heaven-muted') === '1'; +let lastRenderTs = 0; + +const spriteFrames = []; +const atlas = new Image(); +atlas.decoding = 'async'; +atlas.addEventListener('load', () => { + for (let frame = 0; frame < 5; frame += 1) { + const raster = document.createElement('canvas'); + raster.width = 224; + raster.height = 288; + const rasterCtx = raster.getContext('2d'); + rasterCtx.imageSmoothingEnabled = true; + rasterCtx.imageSmoothingQuality = 'high'; + rasterCtx.drawImage(atlas, frame * 280, 0, 280, 360, 0, 0, raster.width, raster.height); + spriteFrames[frame] = raster; + } +}); +atlas.src = '/extension/toy-toy-toy/assets/characters/zombie-atlas.svg?v=0.8.2'; + +const input = { + keys: new Set(), + pointerX: width * 0.7, + pointerY: height * 0.5, + pointerActive: false, + moveX: 0, + moveY: 0, + aimX: 1, + aimY: 0, +}; + +const state = { + mode: 'menu', + elapsed: 0, + score: 0, + kills: 0, + combo: 0, + maxCombo: 0, + comboTimer: 0, + level: 1, + xp: 0, + nextXp: 14, + pendingLevelUps: 0, + hp: 100, + maxHp: 100, + armor: 0, + regen: 0, + damage: 18, + fireRate: 12, + bulletSpeed: 760, + bulletSize: 4.2, + moveSpeed: 260, + multishot: 1, + spread: 0.105, + pierce: 0, + critChance: 0.06, + critDamage: 2, + explosion: 0, + chain: 0, + frost: 0, + ricochet: 0, + drones: 0, + orbit: 0, + missile: 0, + lifesteal: 0, + pickupRange: 90, + overdriveUntil: 0, + hordeUntil: 0, + invulnerableUntil: 0, + fireAccumulator: 0, + spawnAccumulator: 0, + droneAccumulator: 0, + missileAccumulator: 0, + healAccumulator: 0, + supplyAt: 16, + bossSpawned: false, + bossKilled: false, + boss: null, + shake: 0, + flash: 0, + hitStop: 0, + lastTs: 0, + uiAccumulator: 0, + cooldowns: { horde: 0, overdrive: 0, elite: 0, nuke: 0 }, + upgradeLevels: {}, + enemies: [], + bullets: [], + hostileBullets: [], + pickups: [], + particles: [], + shockwaves: [], + beams: [], + floaters: [], + stars: [], + player: { x: width * 0.5, y: height * 0.55, vx: 0, vy: 0, angle: 0, radius: 16, dashTime: 0, dashCooldown: 0, dashX: 1, dashY: 0 }, +}; + +function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } +function rand(min, max) { return min + Math.random() * (max - min); } +function pick(list) { return list[Math.floor(Math.random() * list.length)]; } +function distanceSq(ax, ay, bx, by) { const dx = ax - bx; const dy = ay - by; return dx * dx + dy * dy; } +function formatNumber(value) { return Math.round(value).toLocaleString('zh-CN'); } +function formatCompact(value) { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`; + if (value >= 10_000) return `${(value / 1000).toFixed(value >= 100_000 ? 0 : 1)}K`; + return Math.round(value).toString(); +} + +function ensureAudio() { + if (muted) return null; + if (!audioCtx) { + try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch { audioCtx = null; } + } + if (audioCtx?.state === 'suspended') audioCtx.resume().catch(() => {}); + return audioCtx; +} + +function tone(freq, duration = 0.06, type = 'square', gain = 0.025, slide = 0) { + const audio = ensureAudio(); + if (!audio) return; + const now = audio.currentTime; + const osc = audio.createOscillator(); + const amp = audio.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, now); + if (slide) osc.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), now + duration); + amp.gain.setValueAtTime(gain, now); + amp.gain.exponentialRampToValueAtTime(0.0001, now + duration); + osc.connect(amp).connect(audio.destination); + osc.start(now); + osc.stop(now + duration + 0.02); +} + +function showToast(message, duration = 2500) { + clearTimeout(toastTimer); + els.toast.textContent = message; + els.toast.classList.add('visible'); + toastTimer = window.setTimeout(() => els.toast.classList.remove('visible'), duration); +} + +function showBanner(message, color = '#ffe45c') { + els.banner.textContent = message; + els.banner.style.color = color; + els.banner.classList.remove('visible'); + void els.banner.offsetWidth; + els.banner.classList.add('visible'); +} + +function setMode(mode) { + state.mode = mode; + els.shell.dataset.state = mode; + els.startOverlay.classList.toggle('visible', mode === 'menu'); + els.upgradeOverlay.classList.toggle('visible', mode === 'upgrade'); + els.pauseOverlay.classList.toggle('visible', mode === 'paused'); + els.resultOverlay.classList.toggle('visible', mode === 'result'); + els.pauseBtn.textContent = mode === 'paused' ? '继续' : '暂停'; +} + +function resize() { + width = Math.max(320, window.innerWidth); + height = Math.max(520, window.innerHeight); + // 高密度弹幕的清晰度主要来自亮线叠加,1× 内部分辨率能显著降低低端 GPU / 软件栅格压力。 + dpr = 1; + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + state.player.x = clamp(state.player.x || width * 0.5, 35, width - 35); + state.player.y = clamp(state.player.y || height * 0.55, 105, height - 35); + if (!state.stars.length) { + for (let i = 0; i < 120; i += 1) state.stars.push({ x: Math.random(), y: Math.random(), size: rand(0.5, 1.8), phase: rand(0, Math.PI * 2) }); + } +} + +const UPGRADES = [ + { id: 'damage', icon: '▰', name: '口径膨胀', color: '#ffe45c', max: 8, desc: '每一发子弹伤害提高 32%,所有副武器也会一起变凶。', apply: () => { state.damage *= 1.32; } }, + { id: 'rate', icon: '»', name: '射速失控', color: '#70fff1', max: 8, desc: '基础射速提高 24%,弹道开始连成一条发光长河。', apply: () => { state.fireRate *= 1.24; } }, + { id: 'multishot', icon: '⑶', name: '扇形齐射', color: '#ff8ccf', max: 8, desc: '每次开火额外增加一枚弹丸,最多形成九重弹幕。', apply: () => { state.multishot += 1; } }, + { id: 'pierce', icon: '⇥', name: '无限穿透', color: '#7df6a5', max: 6, desc: '子弹可以继续贯穿一名敌人,专治扎堆尸潮。', apply: () => { state.pierce += 1; } }, + { id: 'crit', icon: '※', name: '红字暴击', color: '#ff5d79', max: 6, desc: '暴击率提高 9%,暴击倍率也会逐级上涨。', apply: () => { state.critChance += 0.09; state.critDamage += 0.08; } }, + { id: 'explosion', icon: '✺', name: '高爆弹头', color: '#ff9d4d', max: 6, desc: '命中产生范围爆炸,等级越高,冲击圈越大。', apply: () => { state.explosion += 1; } }, + { id: 'chain', icon: 'ϟ', name: '连锁闪电', color: '#c977ff', max: 6, desc: '命中时跳向附近更多目标,屏幕被闪电链切开。', apply: () => { state.chain += 1; } }, + { id: 'frost', icon: '❄', name: '绝对零度', color: '#6ecbff', max: 5, desc: '子弹有概率冻结敌人,给整片敌潮覆盖冰霜。', apply: () => { state.frost += 1; } }, + { id: 'ricochet', icon: '⌁', name: '智能弹射', color: '#ffca70', max: 5, desc: '命中后自动拐弯追击附近目标,一发子弹来回收割。', apply: () => { state.ricochet += 1; } }, + { id: 'drone', icon: '◇', name: '护航无人机', color: '#69b8ff', max: 6, desc: '增加一架环绕无人机,自动寻找最近敌人独立开火。', apply: () => { state.drones += 1; } }, + { id: 'orbit', icon: '◉', name: '旋转环刃', color: '#aaff75', max: 6, desc: '增加高速旋转的能量刃,贴身怪物会被持续切碎。', apply: () => { state.orbit += 1; } }, + { id: 'missile', icon: '➤', name: '追踪导弹', color: '#ff6f91', max: 6, desc: '定期发射自动追踪导弹,命中后制造大范围爆炸。', apply: () => { state.missile += 1; } }, + { id: 'bulletSize', icon: '●', name: '巨型弹丸', color: '#ffd66b', max: 5, desc: '弹丸体积与伤害同步增长,视觉密度更加离谱。', apply: () => { state.bulletSize *= 1.19; state.damage *= 1.1; } }, + { id: 'speed', icon: '↯', name: '动力外骨骼', color: '#78f5e7', max: 5, desc: '移动速度提高 14%,冲刺冷却也会略微缩短。', apply: () => { state.moveSpeed *= 1.14; } }, + { id: 'maxHp', icon: '♥', name: '生命扩容', color: '#ff7189', max: 5, desc: '最大生命提高 25,并立即回满新增部分生命。', apply: () => { state.maxHp += 25; state.hp += 25; } }, + { id: 'armor', icon: '⬡', name: '反应装甲', color: '#94a9c8', max: 5, desc: '每次受到的伤害降低,并增强冲刺时的撞击伤害。', apply: () => { state.armor += 0.08; } }, + { id: 'regen', icon: '✚', name: '纳米修复', color: '#66f5a2', max: 5, desc: '每秒自动恢复生命,击杀越密集越不怕贴脸。', apply: () => { state.regen += 0.8; } }, + { id: 'magnet', icon: '∪', name: '引力磁场', color: '#76d9ff', max: 5, desc: '扩大经验和强化拾取范围,远处掉落会主动飞过来。', apply: () => { state.pickupRange += 75; } }, + { id: 'lifesteal', icon: '♨', name: '收割吸血', color: '#ff4f75', max: 5, desc: '击杀有概率恢复生命,精英与 Boss 提供更多治疗。', apply: () => { state.lifesteal += 0.035; } }, + { id: 'critPower', icon: '‼', name: '弱点放大', color: '#ffba5d', max: 5, desc: '暴击倍率提高 45%,高密度红字会铺满战场。', apply: () => { state.critDamage += 0.45; } }, + { id: 'velocity', icon: '➟', name: '磁轨加速', color: '#84f4ff', max: 5, desc: '弹速提高 22%,并额外增加少量射速。', apply: () => { state.bulletSpeed *= 1.22; state.fireRate *= 1.05; } }, + { id: 'spread', icon: '⌇', name: '弹幕收束', color: '#d5a2ff', max: 4, desc: '多重射击更加集中,同时所有弹丸额外增伤。', apply: () => { state.spread *= 0.78; state.damage *= 1.12; } }, + { id: 'overclock', icon: '∞', name: '过载核心', color: '#fff173', max: 4, desc: '立即获得 6 秒无限弹匣,并永久提高伤害与射速。', apply: () => { state.damage *= 1.15; state.fireRate *= 1.1; state.overdriveUntil = Math.max(state.overdriveUntil, state.elapsed + 6); } }, + { id: 'heal', icon: '✚', name: '战地急救', color: '#7affac', max: 99, desc: '立即恢复 42% 最大生命,并短暂无敌。', apply: () => { state.hp = Math.min(state.maxHp, state.hp + state.maxHp * 0.42); state.invulnerableUntil = state.elapsed + 1.2; } }, +]; + +const UPGRADE_MAP = new Map(UPGRADES.map((upgrade) => [upgrade.id, upgrade])); + +function upgradeLevel(id) { return state.upgradeLevels[id] || 0; } + +function resetRun() { + Object.assign(state, { + elapsed: 0, score: 0, kills: 0, combo: 0, maxCombo: 0, comboTimer: 0, + level: 1, xp: 0, nextXp: 14, pendingLevelUps: 0, + hp: 100, maxHp: 100, armor: 0, regen: 0, + damage: 18, fireRate: 12, bulletSpeed: 760, bulletSize: 4.2, moveSpeed: 260, + multishot: 1, spread: 0.105, pierce: 0, critChance: 0.06, critDamage: 2, + explosion: 0, chain: 0, frost: 0, ricochet: 0, drones: 0, orbit: 0, + missile: 0, lifesteal: 0, pickupRange: 90, + overdriveUntil: 0, hordeUntil: 0, invulnerableUntil: 1.2, + fireAccumulator: 0, spawnAccumulator: 0, droneAccumulator: 0, missileAccumulator: 0, + healAccumulator: 0, supplyAt: 15, bossSpawned: false, bossKilled: false, boss: null, + shake: 0, flash: 0, hitStop: 0, uiAccumulator: 0, + cooldowns: { horde: 0, overdrive: 0, elite: 0, nuke: 0 }, + upgradeLevels: {}, enemies: [], bullets: [], hostileBullets: [], pickups: [], particles: [], shockwaves: [], beams: [], floaters: [], + }); + nextEnemyId = 1; + state.player = { x: width * 0.5, y: height * 0.57, vx: 0, vy: 0, angle: -Math.PI / 2, radius: 16, dashTime: 0, dashCooldown: 0, dashX: 1, dashY: 0 }; + input.pointerX = state.player.x + 180; + input.pointerY = state.player.y; + input.aimX = 1; + input.aimY = 0; + updateArsenal(); + syncHud(true); +} + +function startRun() { + ensureAudio(); + clearTimeout(autoPickTimer); + resetRun(); + setMode('running'); + state.lastTs = performance.now(); + showToast('枪口跟随鼠标自动开火;WASD 移动,Shift 冲刺撞开包围圈', 3600); + showBanner('SURVIVE THE SWARM', '#70fff1'); + tone(460, 0.08, 'triangle', 0.045, 180); +} + +function returnToMenu() { + clearTimeout(autoPickTimer); + setMode('menu'); + state.enemies.length = 0; + state.bullets.length = 0; + state.hostileBullets.length = 0; + state.pickups.length = 0; + state.particles.length = 0; + state.boss = null; + els.bossHud.classList.add('hidden'); + loadProfile(); +} + +function pauseGame() { + if (state.mode === 'running') setMode('paused'); + else if (state.mode === 'paused') { setMode('running'); state.lastTs = performance.now(); } +} + +function grantXp(amount) { + state.xp += amount; + while (state.xp >= state.nextXp) { + state.xp -= state.nextXp; + state.level += 1; + state.pendingLevelUps += 1; + state.nextXp = Math.round(12 + 7 * Math.pow(state.level, 1.12)); + } + if (state.pendingLevelUps > 0 && state.mode === 'running') openUpgrade(); +} + +function availableUpgrades() { + return UPGRADES.filter((upgrade) => upgradeLevel(upgrade.id) < upgrade.max); +} + +function chooseUpgradeOptions() { + const available = [...availableUpgrades()]; + for (let index = available.length - 1; index > 0; index -= 1) { + const swap = Math.floor(Math.random() * (index + 1)); + [available[index], available[swap]] = [available[swap], available[index]]; + } + const result = available.slice(0, 3); + if (result.length < 3) result.push(...UPGRADES.filter((upgrade) => upgrade.id === 'heal').slice(0, 3 - result.length)); + return result; +} + +function openUpgrade() { + if (state.pendingLevelUps <= 0) return; + setMode('upgrade'); + const options = chooseUpgradeOptions(); + els.upgradeOptions.innerHTML = options.map((upgrade, index) => { + const level = upgradeLevel(upgrade.id); + return ``; + }).join(''); + els.upgradeCountdown.textContent = els.autoPick.checked ? '挂机模式:2.5 秒后自动选择' : '选择后立刻恢复战斗'; + els.upgradeOptions.querySelectorAll('.upgrade-option').forEach((button) => { + button.addEventListener('click', () => selectUpgrade(button.dataset.upgrade)); + }); + clearTimeout(autoPickTimer); + if (els.autoPick.checked) autoPickTimer = window.setTimeout(() => selectUpgrade(pick(options).id), 2500); + tone(680, 0.12, 'triangle', 0.045, 280); +} + +function selectUpgrade(id) { + if (state.mode !== 'upgrade') return; + const upgrade = UPGRADE_MAP.get(id); + if (!upgrade) return; + clearTimeout(autoPickTimer); + upgrade.apply(); + if (upgrade.max < 90) state.upgradeLevels[id] = upgradeLevel(id) + 1; + state.pendingLevelUps = Math.max(0, state.pendingLevelUps - 1); + burst(state.player.x, state.player.y, upgrade.color, 34, 220); + addShockwave(state.player.x, state.player.y, upgrade.color, 12, 150, 0.55); + showBanner(upgrade.name, upgrade.color); + updateArsenal(); + syncHud(true); + tone(820, 0.1, 'triangle', 0.05, 330); + if (state.pendingLevelUps > 0) window.setTimeout(openUpgrade, 180); + else { setMode('running'); state.lastTs = performance.now(); } +} + +function updateArsenal() { + const items = [ + { icon: '▰', name: '脉冲步枪', detail: `${state.multishot} 发 · ${state.pierce} 穿透`, level: `Lv.${Math.max(1, upgradeLevel('damage') + upgradeLevel('rate') + 1)}`, color: '#ffe45c', active: true }, + { icon: '✺', name: '高爆弹头', detail: '范围冲击波', level: `Lv.${state.explosion}`, color: '#ff9d4d', active: state.explosion > 0 }, + { icon: 'ϟ', name: '连锁闪电', detail: '自动跳跃目标', level: `Lv.${state.chain}`, color: '#c977ff', active: state.chain > 0 }, + { icon: '◇', name: '护航无人机', detail: `${state.drones} 架独立射击`, level: `Lv.${state.drones}`, color: '#69b8ff', active: state.drones > 0 }, + { icon: '◉', name: '旋转环刃', detail: `${state.orbit} 枚近身切割`, level: `Lv.${state.orbit}`, color: '#aaff75', active: state.orbit > 0 }, + { icon: '➤', name: '追踪导弹', detail: '自动索敌爆炸', level: `Lv.${state.missile}`, color: '#ff6f91', active: state.missile > 0 }, + ].filter((item) => item.active); + els.arsenalList.innerHTML = items.map((item) => `
${item.icon}${item.name}${item.detail}${item.level}
`).join(''); +} + +function chooseEnemyType() { + const t = state.elapsed; + const roll = Math.random(); + if (t > 66 && roll < 0.045) return 'elite'; + if (t > 52 && roll < 0.14) return 'shooter'; + if (t > 38 && roll < 0.23) return 'splitter'; + if (t > 24 && roll < 0.36) return 'tank'; + if (t > 10 && roll < 0.57) return 'runner'; + return 'grunt'; +} + +function spawnEnemy(type = chooseEnemyType(), options = {}) { + if (state.enemies.length >= CONFIG.maxEnemies) return null; + const definition = ENEMY_TYPES[type]; + const margin = options.margin ?? rand(28, 75); + const side = options.side ?? Math.floor(Math.random() * 4); + let x; + let y; + if (side === 0) { x = rand(-margin, width + margin); y = -margin; } + else if (side === 1) { x = width + margin; y = rand(80, height + margin); } + else if (side === 2) { x = rand(-margin, width + margin); y = height + margin; } + else { x = -margin; y = rand(80, height + margin); } + const progression = 1 + state.elapsed * 0.014 + Math.pow(state.elapsed / CONFIG.duration, 2) * 1.1; + const eliteScale = options.eliteScale || 1; + const hp = type === 'boss' ? 92000 * (1 + state.level * 0.055) : 24 * definition.hp * progression * eliteScale; + const enemy = { + id: nextEnemyId++, type, x, y, vx: 0, vy: 0, radius: definition.radius * (options.scale || 1), + hp, maxHp: hp, speed: (78 + state.elapsed * 0.13) * definition.speed * (options.speed || 1), + damage: definition.damage, score: definition.score, xp: definition.xp, + frame: definition.frame, color: definition.color, phase: rand(0, Math.PI * 2), + hitFlash: 0, frozenUntil: 0, shootAt: state.elapsed + rand(1, 2.4), touchAt: 0, + dead: false, boss: type === 'boss', elite: type === 'elite', splitDepth: options.splitDepth || 0, + orbitHitAt: 0, + }; + state.enemies.push(enemy); + if (enemy.boss) { + state.boss = enemy; + state.bossSpawned = true; + els.bossHud.classList.remove('hidden'); + els.bossName.textContent = '吞城者 · OMEGA'; + showBanner('OMEGA ABOMINATION', '#ff526f'); + showToast('终局 Boss 从尸潮里挤进来了:继续移动,所有火力会自动锁定它附近的敌人', 3800); + state.shake = Math.max(state.shake, 1.5); + addShockwave(enemy.x, enemy.y, '#ff365f', 30, 360, 1.25); + tone(82, 0.7, 'sawtooth', 0.08, -25); + } + return enemy; +} + +function spawnBoss() { + if (state.bossSpawned) return state.boss; + return spawnEnemy('boss', { side: 0, margin: 110 }); +} + +function spawnHorde(amount) { + for (let i = 0; i < amount && state.enemies.length < CONFIG.maxEnemies; i += 1) { + const type = i % 19 === 0 && state.elapsed > 40 ? 'elite' : chooseEnemyType(); + spawnEnemy(type, { margin: rand(5, 110) }); + } +} + +function spawnPickup(x, y, type = 'xp', value = 1) { + if (state.pickups.length >= CONFIG.maxPickups) { + const existing = state.pickups.find((pickup) => pickup.type === 'xp'); + if (existing) existing.value += value; + return; + } + const colors = { xp: '#70fff1', heal: '#69ff9a', overdrive: '#ffe45c', magnet: '#73b7ff', nuke: '#ff6d8f' }; + state.pickups.push({ x, y, type, value, color: colors[type], radius: type === 'xp' ? 5 : 12, phase: rand(0, Math.PI * 2), life: type === 'xp' ? 28 : 20 }); +} + +function spawnPowerup(x = rand(100, width - 100), y = rand(130, height - 100), forcedType) { + spawnPickup(x, y, forcedType || pick(['heal', 'overdrive', 'magnet', 'nuke']), 1); +} + +function addParticle(x, y, color, speed = 100, life = 0.5, size = 3, angle = rand(0, Math.PI * 2)) { + if (state.particles.length >= CONFIG.maxParticles) return; + state.particles.push({ x, y, px: x, py: y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, color, life, maxLife: life, size, gravity: 0 }); +} + +function burst(x, y, color, count = 16, speed = 150) { + for (let i = 0; i < count; i += 1) addParticle(x, y, color, rand(speed * 0.25, speed), rand(0.24, 0.72), rand(1.5, 4.5)); +} + +function addShockwave(x, y, color, start = 5, end = 90, life = 0.45, widthValue = 4) { + state.shockwaves.push({ x, y, color, radius: start, start, end, life, maxLife: life, width: widthValue }); +} + +function addFloater(x, y, text, color = '#ffffff', size = 12, life = 0.65) { + if (state.floaters.length > 180) state.floaters.shift(); + state.floaters.push({ x, y, text, color, size, life, maxLife: life, vx: rand(-16, 16), vy: rand(-80, -50) }); +} + +function addBeam(x1, y1, x2, y2, color = '#c977ff', widthValue = 3, life = 0.14) { + state.beams.push({ x1, y1, x2, y2, color, width: widthValue, life, maxLife: life }); +} + +function fireBullet(x, y, angle, options = {}) { + if (state.bullets.length >= CONFIG.maxBullets) return null; + const speed = options.speed || state.bulletSpeed; + const bullet = { + x, y, px: x, py: y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + angle, + radius: options.radius || state.bulletSize, + damage: options.damage || state.damage, + life: options.life || 1.25, + pierce: options.pierce ?? state.pierce, + color: options.color || '#ffe45c', + kind: options.kind || 'primary', + explosive: options.explosive ?? state.explosion, + chain: options.chain ?? state.chain, + frost: options.frost ?? state.frost, + ricochet: options.ricochet ?? state.ricochet, + homing: options.homing || 0, + target: options.target || null, + hitIds: [], + dead: false, + }; + state.bullets.push(bullet); + return bullet; +} + +function firePrimary(dt) { + const multiplier = state.elapsed < state.overdriveUntil ? 3 : 1; + state.fireAccumulator += dt * state.fireRate * multiplier; + const shots = Math.min(8, Math.floor(state.fireAccumulator)); + if (shots <= 0) return; + state.fireAccumulator -= shots; + for (let shot = 0; shot < shots; shot += 1) { + const count = Math.min(9, state.multishot); + const baseAngle = state.player.angle; + for (let index = 0; index < count; index += 1) { + const offset = (index - (count - 1) / 2) * state.spread; + const jitter = count <= 2 ? rand(-0.012, 0.012) : rand(-0.007, 0.007); + const angle = baseAngle + offset + jitter; + const muzzle = 22; + fireBullet(state.player.x + Math.cos(angle) * muzzle, state.player.y + Math.sin(angle) * muzzle, angle, { + damage: state.damage * (state.elapsed < state.overdriveUntil ? 2 : 1), + radius: state.bulletSize * (state.elapsed < state.overdriveUntil ? 1.18 : 1), + color: state.elapsed < state.overdriveUntil ? '#70fff1' : '#ffe45c', + }); + } + if (shot === 0) { + state.player.vx -= Math.cos(baseAngle) * 2.1; + state.player.vy -= Math.sin(baseAngle) * 2.1; + addParticle(state.player.x + Math.cos(baseAngle) * 24, state.player.y + Math.sin(baseAngle) * 24, '#fff3a1', rand(40, 90), 0.11, rand(2, 5), baseAngle + Math.PI + rand(-0.5, 0.5)); + } + } + if (Math.random() < 0.22) tone(180 + Math.random() * 35, 0.025, 'square', 0.009, -35); +} + +function nearestEnemy(x, y, maxDistance = Infinity, excludeIds = null) { + let best = null; + let bestDistance = maxDistance * maxDistance; + for (const enemy of state.enemies) { + if (enemy.dead || (excludeIds && excludeIds.includes(enemy.id))) continue; + const d2 = distanceSq(x, y, enemy.x, enemy.y); + if (d2 < bestDistance) { bestDistance = d2; best = enemy; } + } + return best; +} + +function fireDrones(dt) { + if (!state.drones) return; + state.droneAccumulator += dt; + const interval = Math.max(0.13, 0.56 - state.drones * 0.045); + if (state.droneAccumulator < interval) return; + state.droneAccumulator %= interval; + for (let index = 0; index < state.drones; index += 1) { + const angle = state.elapsed * 1.35 + index * Math.PI * 2 / state.drones; + const x = state.player.x + Math.cos(angle) * (48 + state.drones * 2.5); + const y = state.player.y + Math.sin(angle) * (48 + state.drones * 2.5); + const target = nearestEnemy(x, y, 440); + if (!target) continue; + const aim = Math.atan2(target.y - y, target.x - x); + fireBullet(x, y, aim, { damage: state.damage * (0.42 + state.drones * 0.025), radius: 3.4, speed: state.bulletSpeed * 0.88, color: '#69b8ff', pierce: Math.floor(state.pierce / 2), explosive: 0, chain: 0, frost: 0, ricochet: 0, kind: 'drone' }); + addBeam(x, y, x + Math.cos(aim) * 18, y + Math.sin(aim) * 18, '#69b8ff', 2, 0.07); + } +} + +function fireMissiles(dt) { + if (!state.missile) return; + state.missileAccumulator += dt; + const interval = Math.max(0.42, 2.2 - state.missile * 0.25); + if (state.missileAccumulator < interval) return; + state.missileAccumulator %= interval; + const count = 1 + Math.floor((state.missile - 1) / 2); + for (let index = 0; index < count; index += 1) { + const target = nearestEnemy(state.player.x, state.player.y, Infinity); + if (!target) break; + const angle = state.player.angle + (index - (count - 1) / 2) * 0.3; + fireBullet(state.player.x, state.player.y, angle, { damage: state.damage * (3.8 + state.missile * 0.65), radius: 7, speed: 330, color: '#ff6f91', pierce: 0, explosive: 2 + state.missile, chain: 0, frost: 0, ricochet: 0, homing: 4.2, target, life: 3.2, kind: 'missile' }); + } + tone(110, 0.09, 'sawtooth', 0.025, 90); +} + +function fireHostile(enemy) { + if (state.hostileBullets.length >= CONFIG.maxHostiles) return; + const angle = Math.atan2(state.player.y - enemy.y, state.player.x - enemy.x); + const count = enemy.boss ? 18 : 3; + const spread = enemy.boss ? Math.PI * 2 / count : 0.14; + for (let index = 0; index < count; index += 1) { + const a = enemy.boss ? index * spread + state.elapsed * 0.35 : angle + (index - 1) * spread; + const speed = enemy.boss ? 155 : 220; + state.hostileBullets.push({ x: enemy.x, y: enemy.y, px: enemy.x, py: enemy.y, vx: Math.cos(a) * speed, vy: Math.sin(a) * speed, radius: enemy.boss ? 7 : 5, damage: enemy.boss ? 14 : 7, life: enemy.boss ? 5.5 : 3.2, color: enemy.boss ? '#ff3d60' : '#df70ff' }); + } + addShockwave(enemy.x, enemy.y, enemy.color, 5, enemy.radius * 1.4, 0.24, 2); +} + +function buildSpatialGrid() { + const grid = new Map(); + for (const enemy of state.enemies) { + if (enemy.dead) continue; + const cellX = Math.floor(enemy.x / CONFIG.cellSize); + const cellY = Math.floor(enemy.y / CONFIG.cellSize); + const key = `${cellX},${cellY}`; + let cell = grid.get(key); + if (!cell) { cell = []; grid.set(key, cell); } + cell.push(enemy); + } + return grid; +} + +function nearbyFromGrid(grid, x, y, radius = CONFIG.cellSize) { + const result = []; + const range = Math.ceil(radius / CONFIG.cellSize); + const cellX = Math.floor(x / CONFIG.cellSize); + const cellY = Math.floor(y / CONFIG.cellSize); + for (let dx = -range; dx <= range; dx += 1) { + for (let dy = -range; dy <= range; dy += 1) { + const cell = grid.get(`${cellX + dx},${cellY + dy}`); + if (cell) result.push(...cell); + } + } + return result; +} + +function damageEnemy(enemy, amount, options = {}) { + if (!enemy || enemy.dead) return false; + const critical = options.critical ?? (Math.random() < state.critChance); + const finalDamage = amount * (critical ? state.critDamage : 1); + enemy.hp -= finalDamage; + enemy.hitFlash = 0.08; + if (options.frost && Math.random() < Math.min(0.74, 0.12 + options.frost * 0.1)) enemy.frozenUntil = Math.max(enemy.frozenUntil, state.elapsed + 0.65 + options.frost * 0.18); + if (critical || enemy.boss || Math.random() < 0.14) addFloater(enemy.x, enemy.y - enemy.radius, `${critical ? '暴击 ' : ''}${formatCompact(finalDamage)}`, critical ? '#ffdc5e' : options.color || '#e7fbff', critical ? 15 : 11, critical ? 0.72 : 0.48); + if (enemy.hp <= 0) killEnemy(enemy, options); + return true; +} + +function killEnemy(enemy, options = {}) { + if (enemy.dead) return; + enemy.dead = true; + state.kills += 1; + state.combo += 1; + state.maxCombo = Math.max(state.maxCombo, state.combo); + state.comboTimer = 2.2; + const multiplier = 1 + Math.min(8, Math.floor(state.combo / 25)) * 0.25; + state.score += Math.round(enemy.score * multiplier * (enemy.boss ? 1 : 1 + state.elapsed / 280)); + if (state.lifesteal > 0 && Math.random() < state.lifesteal * (enemy.elite ? 3 : enemy.boss ? 8 : 1)) state.hp = Math.min(state.maxHp, state.hp + (enemy.boss ? 25 : enemy.elite ? 7 : 2)); + const xpCount = enemy.boss ? 14 : enemy.elite ? 5 : enemy.xp >= 3 ? 2 : 1; + for (let index = 0; index < xpCount; index += 1) spawnPickup(enemy.x + rand(-enemy.radius, enemy.radius), enemy.y + rand(-enemy.radius, enemy.radius), 'xp', Math.max(1, Math.ceil(enemy.xp / xpCount))); + if (enemy.elite) spawnPowerup(enemy.x, enemy.y); + if (enemy.type === 'splitter' && enemy.splitDepth < 1) { + for (let index = 0; index < 2; index += 1) spawnEnemy('runner', { side: 0, margin: 0, scale: 0.74, speed: 1.12, splitDepth: 1 }); + const spawned = state.enemies.slice(-2); + spawned.forEach((child, index) => { child.x = enemy.x + (index ? 15 : -15); child.y = enemy.y; child.hp *= 0.55; child.maxHp = child.hp; }); + } + const color = enemy.color; + burst(enemy.x, enemy.y, color, enemy.boss ? 150 : enemy.elite ? 42 : Math.min(24, 9 + enemy.radius / 2), enemy.boss ? 420 : 180); + addShockwave(enemy.x, enemy.y, color, enemy.radius * 0.3, enemy.radius * (enemy.boss ? 5 : 2.5), enemy.boss ? 1.2 : 0.38, enemy.boss ? 8 : 3); + if (enemy.boss) { + state.bossKilled = true; + state.boss = null; + els.bossHud.classList.add('hidden'); + state.shake = 2.6; + state.hitStop = 0.18; + showBanner('OMEGA ANNIHILATED', '#ffe45c'); + tone(65, 0.8, 'sawtooth', 0.1, 360); + window.setTimeout(() => finishRun(true), 1200); + } else if (enemy.elite) { + state.shake = Math.max(state.shake, 0.45); + state.hitStop = Math.max(state.hitStop, 0.025); + tone(120, 0.07, 'sawtooth', 0.025, -30); + } + if (state.combo > 0 && state.combo % 100 === 0) { + showBanner(`${state.combo} KILL RAMPAGE`, '#ffe45c'); + state.overdriveUntil = Math.max(state.overdriveUntil, state.elapsed + 3); + } +} + +function explode(x, y, radius, damage, color, grid, excludeId = null) { + addShockwave(x, y, color, 6, radius, 0.32 + radius / 600, Math.max(3, radius / 24)); + burst(x, y, color, Math.min(36, Math.round(radius / 4)), radius * 1.7); + const targets = nearbyFromGrid(grid, x, y, radius); + const radiusSq = radius * radius; + for (const target of targets) { + if (target.dead || target.id === excludeId || distanceSq(x, y, target.x, target.y) > radiusSq) continue; + damageEnemy(target, damage * (1 - Math.sqrt(distanceSq(x, y, target.x, target.y)) / radius * 0.45), { color, critical: false }); + } +} + +function chainLightning(source, count, damage, grid, excluded = []) { + let current = source; + const seen = [...excluded, source.id]; + for (let index = 0; index < count; index += 1) { + const candidates = nearbyFromGrid(grid, current.x, current.y, 185).filter((enemy) => !enemy.dead && !seen.includes(enemy.id)); + let target = null; + let best = 185 * 185; + for (const enemy of candidates) { + const d2 = distanceSq(current.x, current.y, enemy.x, enemy.y); + if (d2 < best) { best = d2; target = enemy; } + } + if (!target) break; + addBeam(current.x, current.y, target.x, target.y, '#c977ff', 2.5 + count * 0.2, 0.17); + damageEnemy(target, damage * Math.pow(0.76, index + 1), { color: '#d9a6ff', critical: false }); + seen.push(target.id); + current = target; + } +} + +function updatePlayer(dt, grid) { + const player = state.player; + player.dashCooldown = Math.max(0, player.dashCooldown - dt); + const keyboardX = (input.keys.has('d') || input.keys.has('arrowright') ? 1 : 0) - (input.keys.has('a') || input.keys.has('arrowleft') ? 1 : 0); + const keyboardY = (input.keys.has('s') || input.keys.has('arrowdown') ? 1 : 0) - (input.keys.has('w') || input.keys.has('arrowup') ? 1 : 0); + let moveX = keyboardX + input.moveX; + let moveY = keyboardY + input.moveY; + const moveLength = Math.hypot(moveX, moveY); + if (moveLength > 1) { moveX /= moveLength; moveY /= moveLength; } + + if (input.pointerActive) { + const dx = input.pointerX - player.x; + const dy = input.pointerY - player.y; + const length = Math.hypot(dx, dy) || 1; + input.aimX = dx / length; + input.aimY = dy / length; + } + player.angle = Math.atan2(input.aimY, input.aimX); + + if (player.dashTime > 0) { + player.dashTime -= dt; + player.vx = player.dashX * state.moveSpeed * 3.8; + player.vy = player.dashY * state.moveSpeed * 3.8; + state.invulnerableUntil = Math.max(state.invulnerableUntil, state.elapsed + 0.08); + addParticle(player.x, player.y, '#70fff1', rand(15, 70), rand(0.18, 0.35), rand(4, 9), player.angle + Math.PI + rand(-0.8, 0.8)); + for (const enemy of nearbyFromGrid(grid, player.x, player.y, 42)) { + if (!enemy.dead && distanceSq(player.x, player.y, enemy.x, enemy.y) < Math.pow(player.radius + enemy.radius + 12, 2)) damageEnemy(enemy, state.damage * (4 + state.armor * 10), { color: '#70fff1', critical: false }); + } + } else { + const response = 1 - Math.exp(-dt * 13); + player.vx += (moveX * state.moveSpeed - player.vx) * response; + player.vy += (moveY * state.moveSpeed - player.vy) * response; + if (!moveLength) { player.vx *= Math.pow(0.1, dt); player.vy *= Math.pow(0.1, dt); } + } + player.x = clamp(player.x + player.vx * dt, 28, width - 28); + player.y = clamp(player.y + player.vy * dt, 105, height - 28); +} + +function startDash() { + const player = state.player; + if (state.mode !== 'running' || player.dashCooldown > 0) return; + const keyboardX = (input.keys.has('d') || input.keys.has('arrowright') ? 1 : 0) - (input.keys.has('a') || input.keys.has('arrowleft') ? 1 : 0) + input.moveX; + const keyboardY = (input.keys.has('s') || input.keys.has('arrowdown') ? 1 : 0) - (input.keys.has('w') || input.keys.has('arrowup') ? 1 : 0) + input.moveY; + const length = Math.hypot(keyboardX, keyboardY); + player.dashX = length > 0.2 ? keyboardX / length : input.aimX; + player.dashY = length > 0.2 ? keyboardY / length : input.aimY; + player.dashTime = 0.2; + player.dashCooldown = Math.max(0.75, 2.15 - upgradeLevel('speed') * 0.12); + state.invulnerableUntil = state.elapsed + 0.28; + addShockwave(player.x, player.y, '#70fff1', 8, 75, 0.32, 4); + tone(260, 0.08, 'sawtooth', 0.035, 280); +} + +function updateEnemies(dt, grid) { + const player = state.player; + for (const enemy of state.enemies) { + if (enemy.dead) continue; + enemy.hitFlash = Math.max(0, enemy.hitFlash - dt); + const dx = player.x - enemy.x; + const dy = player.y - enemy.y; + const distance = Math.hypot(dx, dy) || 1; + let directionX = dx / distance; + let directionY = dy / distance; + let speed = enemy.speed * (state.elapsed < enemy.frozenUntil ? 0.35 : 1); + if (enemy.type === 'shooter' && distance < 280) { directionX *= -0.45; directionY *= -0.45; speed *= 0.8; } + if (enemy.boss) { + const orbit = Math.sin(state.elapsed * 0.8) * 0.22; + const ox = directionX * Math.cos(orbit) - directionY * Math.sin(orbit); + const oy = directionX * Math.sin(orbit) + directionY * Math.cos(orbit); + directionX = ox; directionY = oy; + } else { + const wobble = Math.sin(state.elapsed * (enemy.type === 'runner' ? 8 : 3) + enemy.phase) * 0.12; + const ox = directionX - directionY * wobble; + const oy = directionY + directionX * wobble; + directionX = ox; directionY = oy; + } + enemy.vx += (directionX * speed - enemy.vx) * Math.min(1, dt * 5.5); + enemy.vy += (directionY * speed - enemy.vy) * Math.min(1, dt * 5.5); + enemy.x += enemy.vx * dt; + enemy.y += enemy.vy * dt; + if ((enemy.type === 'shooter' || enemy.boss) && state.elapsed >= enemy.shootAt) { + fireHostile(enemy); + enemy.shootAt = state.elapsed + (enemy.boss ? rand(0.62, 1.05) : rand(1.8, 3.2)); + } + const touchDistance = player.radius + enemy.radius * 0.72; + if (distance < touchDistance && state.elapsed >= enemy.touchAt) { + enemy.touchAt = state.elapsed + 0.62; + hitPlayer(enemy.damage, enemy.x, enemy.y); + enemy.x -= directionX * 14; + enemy.y -= directionY * 14; + } + } + + if (state.orbit > 0) { + const count = state.orbit; + const orbitRadius = 64 + count * 4; + for (let index = 0; index < count; index += 1) { + const angle = state.elapsed * (2.4 + count * 0.08) + index * Math.PI * 2 / count; + const x = player.x + Math.cos(angle) * orbitRadius; + const y = player.y + Math.sin(angle) * orbitRadius; + for (const enemy of nearbyFromGrid(grid, x, y, 38)) { + if (enemy.dead || state.elapsed < enemy.orbitHitAt || distanceSq(x, y, enemy.x, enemy.y) > Math.pow(enemy.radius + 16, 2)) continue; + enemy.orbitHitAt = state.elapsed + 0.18; + damageEnemy(enemy, state.damage * (0.62 + state.orbit * 0.12), { color: '#aaff75', critical: false, frost: Math.floor(state.frost / 2) }); + addBeam(x - Math.cos(angle) * 15, y - Math.sin(angle) * 15, x + Math.cos(angle) * 15, y + Math.sin(angle) * 15, '#aaff75', 3, 0.08); + } + } + } +} + +function hitPlayer(amount, sourceX, sourceY) { + if (state.elapsed < state.invulnerableUntil || state.mode !== 'running') return; + const reduced = Math.max(1, amount * (1 - Math.min(0.62, state.armor))); + state.hp = Math.max(0, state.hp - reduced); + state.invulnerableUntil = state.elapsed + 0.52; + state.combo = Math.floor(state.combo * 0.55); + state.comboTimer = 0.8; + state.shake = Math.max(state.shake, 0.8); + state.flash = 1; + els.damageFlash.classList.add('active'); + window.setTimeout(() => els.damageFlash.classList.remove('active'), 80); + addFloater(state.player.x, state.player.y - 25, `-${Math.ceil(reduced)}`, '#ff526f', 18, 0.8); + const angle = Math.atan2(state.player.y - sourceY, state.player.x - sourceX); + state.player.vx += Math.cos(angle) * 180; + state.player.vy += Math.sin(angle) * 180; + burst(state.player.x, state.player.y, '#ff526f', 22, 230); + tone(95, 0.16, 'sawtooth', 0.07, -45); + if (state.hp <= 0) finishRun(false); +} + +function updateBullets(dt, grid) { + for (let index = state.bullets.length - 1; index >= 0; index -= 1) { + const bullet = state.bullets[index]; + bullet.life -= dt; + if (bullet.homing && bullet.target && !bullet.target.dead) { + const targetAngle = Math.atan2(bullet.target.y - bullet.y, bullet.target.x - bullet.x); + let delta = ((targetAngle - bullet.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; + bullet.angle += clamp(delta, -bullet.homing * dt, bullet.homing * dt); + const speed = Math.hypot(bullet.vx, bullet.vy); + bullet.vx = Math.cos(bullet.angle) * speed; + bullet.vy = Math.sin(bullet.angle) * speed; + } + bullet.px = bullet.x; + bullet.py = bullet.y; + bullet.x += bullet.vx * dt; + bullet.y += bullet.vy * dt; + if (bullet.kind === 'missile' && Math.random() < 0.85) addParticle(bullet.x, bullet.y, '#ff8a68', rand(5, 28), rand(0.2, 0.45), rand(2, 5), bullet.angle + Math.PI + rand(-0.4, 0.4)); + if (bullet.life <= 0 || bullet.x < -120 || bullet.x > width + 120 || bullet.y < -120 || bullet.y > height + 120) { + state.bullets.splice(index, 1); + continue; + } + const candidates = nearbyFromGrid(grid, bullet.x, bullet.y, bullet.radius + 48); + let hit = null; + for (const enemy of candidates) { + if (enemy.dead || bullet.hitIds.includes(enemy.id)) continue; + if (distanceSq(bullet.x, bullet.y, enemy.x, enemy.y) <= Math.pow(bullet.radius + enemy.radius * 0.72, 2)) { hit = enemy; break; } + } + if (!hit) continue; + bullet.hitIds.push(hit.id); + const critical = Math.random() < state.critChance; + damageEnemy(hit, bullet.damage, { critical, color: bullet.color, frost: bullet.frost }); + burst(bullet.x, bullet.y, bullet.color, bullet.kind === 'missile' ? 24 : 4, bullet.kind === 'missile' ? 180 : 65); + if (bullet.explosive > 0) { + const radius = bullet.kind === 'missile' ? 88 + bullet.explosive * 10 : 28 + bullet.explosive * 11; + explode(bullet.x, bullet.y, radius, bullet.damage * (bullet.kind === 'missile' ? 1.15 : 0.32 + bullet.explosive * 0.035), bullet.color, grid, hit.id); + } + if (bullet.chain > 0 && Math.random() < Math.min(0.86, 0.16 + bullet.chain * 0.11)) chainLightning(hit, Math.min(7, bullet.chain + 1), bullet.damage * 0.75, grid, bullet.hitIds); + if (bullet.ricochet > 0 && Math.random() < Math.min(0.92, 0.25 + bullet.ricochet * 0.13)) { + const target = nearestEnemy(bullet.x, bullet.y, 280, bullet.hitIds); + if (target) { + bullet.angle = Math.atan2(target.y - bullet.y, target.x - bullet.x); + const speed = Math.hypot(bullet.vx, bullet.vy) * 0.94; + bullet.vx = Math.cos(bullet.angle) * speed; + bullet.vy = Math.sin(bullet.angle) * speed; + bullet.life += 0.18; + addBeam(bullet.x, bullet.y, bullet.x + Math.cos(bullet.angle) * 26, bullet.y + Math.sin(bullet.angle) * 26, '#ffca70', 2, 0.09); + continue; + } + } + bullet.pierce -= 1; + if (bullet.pierce < 0) state.bullets.splice(index, 1); + } +} + +function updateHostileBullets(dt) { + const player = state.player; + for (let index = state.hostileBullets.length - 1; index >= 0; index -= 1) { + const bullet = state.hostileBullets[index]; + bullet.life -= dt; + bullet.px = bullet.x; bullet.py = bullet.y; + bullet.x += bullet.vx * dt; bullet.y += bullet.vy * dt; + if (bullet.life <= 0 || bullet.x < -50 || bullet.x > width + 50 || bullet.y < -50 || bullet.y > height + 50) { state.hostileBullets.splice(index, 1); continue; } + if (distanceSq(bullet.x, bullet.y, player.x, player.y) < Math.pow(bullet.radius + player.radius, 2)) { + hitPlayer(bullet.damage, bullet.x - bullet.vx, bullet.y - bullet.vy); + state.hostileBullets.splice(index, 1); + } + } +} + +function updatePickups(dt) { + const player = state.player; + for (let index = state.pickups.length - 1; index >= 0; index -= 1) { + const pickup = state.pickups[index]; + pickup.life -= dt; + pickup.phase += dt * 5; + const dx = player.x - pickup.x; + const dy = player.y - pickup.y; + const distance = Math.hypot(dx, dy) || 1; + const range = pickup.type === 'xp' ? state.pickupRange : state.pickupRange * 1.25; + if (distance < range) { + const speed = 180 + (range - distance) * 5; + pickup.x += dx / distance * speed * dt; + pickup.y += dy / distance * speed * dt; + } + if (distance < player.radius + pickup.radius + 8) { + collectPickup(pickup); + state.pickups.splice(index, 1); + continue; + } + if (pickup.life <= 0) state.pickups.splice(index, 1); + } +} + +function collectPickup(pickup) { + if (pickup.type === 'xp') { + grantXp(pickup.value); + if (Math.random() < 0.08) tone(760, 0.025, 'triangle', 0.008, 80); + } else if (pickup.type === 'heal') { + state.hp = Math.min(state.maxHp, state.hp + state.maxHp * 0.32); + showToast('急救包:恢复 32% 最大生命'); + addFloater(state.player.x, state.player.y - 30, '+HEAL', '#69ff9a', 16, 0.8); + tone(520, 0.12, 'triangle', 0.04, 360); + } else if (pickup.type === 'overdrive') { + state.overdriveUntil = Math.max(state.overdriveUntil, state.elapsed + 9); + showBanner('INFINITE MAGAZINE', '#ffe45c'); + showToast('无限弹匣:9 秒射速 ×3、伤害 ×2'); + } else if (pickup.type === 'magnet') { + for (const item of state.pickups) if (item.type === 'xp') { item.x = state.player.x + rand(-45, 45); item.y = state.player.y + rand(-45, 45); } + showBanner('VACUUM FIELD', '#73b7ff'); + showToast('引力爆发:全场经验正在被吸入'); + } else if (pickup.type === 'nuke') { + triggerNuke(false); + } + burst(pickup.x, pickup.y, pickup.color, 28, 190); +} + +function updateEffects(dt) { + for (let index = state.particles.length - 1; index >= 0; index -= 1) { + const particle = state.particles[index]; + particle.life -= dt; + particle.px = particle.x; particle.py = particle.y; + particle.vy += particle.gravity * dt; + particle.x += particle.vx * dt; particle.y += particle.vy * dt; + particle.vx *= Math.pow(0.12, dt); particle.vy *= Math.pow(0.12, dt); + if (particle.life <= 0) state.particles.splice(index, 1); + } + for (let index = state.shockwaves.length - 1; index >= 0; index -= 1) { + const wave = state.shockwaves[index]; + wave.life -= dt; + const progress = 1 - wave.life / wave.maxLife; + wave.radius = wave.start + (wave.end - wave.start) * (1 - Math.pow(1 - progress, 2)); + if (wave.life <= 0) state.shockwaves.splice(index, 1); + } + for (let index = state.beams.length - 1; index >= 0; index -= 1) { state.beams[index].life -= dt; if (state.beams[index].life <= 0) state.beams.splice(index, 1); } + for (let index = state.floaters.length - 1; index >= 0; index -= 1) { + const floater = state.floaters[index]; + floater.life -= dt; floater.x += floater.vx * dt; floater.y += floater.vy * dt; floater.vy *= Math.pow(0.25, dt); + if (floater.life <= 0) state.floaters.splice(index, 1); + } + state.shake *= Math.pow(0.035, dt); + state.flash = Math.max(0, state.flash - dt * 3); +} + +function updateSpawner(dt) { + const hordeMultiplier = state.elapsed < state.hordeUntil ? 8 : 1; + const baseRate = 7.5 + state.elapsed * 0.16 + Math.pow(state.elapsed / CONFIG.duration, 2) * 12; + state.spawnAccumulator += dt * baseRate * hordeMultiplier; + const count = Math.min(70, Math.floor(state.spawnAccumulator)); + state.spawnAccumulator -= count; + for (let index = 0; index < count; index += 1) spawnEnemy(); + if (!state.bossSpawned && state.elapsed >= CONFIG.bossAt) spawnBoss(); + if (state.elapsed >= state.supplyAt) { + state.supplyAt += rand(14, 19); + spawnPowerup(); + showToast('强化补给已落在战场上:靠近即可拾取', 1900); + } +} + +function update(dt) { + if (state.mode !== 'running') return; + if (state.hitStop > 0) { state.hitStop -= dt; updateEffects(dt * 0.35); return; } + state.elapsed += dt; + state.comboTimer -= dt; + if (state.comboTimer <= 0 && state.combo > 0) state.combo = Math.max(0, state.combo - Math.ceil(dt * 30)); + if (state.regen > 0) state.hp = Math.min(state.maxHp, state.hp + state.regen * dt); + for (const key of Object.keys(state.cooldowns)) state.cooldowns[key] = Math.max(0, state.cooldowns[key] - dt); + const grid = buildSpatialGrid(); + updatePlayer(dt, grid); + updateSpawner(dt); + firePrimary(dt); + fireDrones(dt); + fireMissiles(dt); + updateEnemies(dt, grid); + const updatedGrid = buildSpatialGrid(); + updateBullets(dt, updatedGrid); + updateHostileBullets(dt); + updatePickups(dt); + updateEffects(dt); + state.enemies = state.enemies.filter((enemy) => !enemy.dead); + state.uiAccumulator += dt; + if (state.uiAccumulator > 0.08) { state.uiAccumulator = 0; syncHud(); } + if (state.elapsed >= CONFIG.duration && !state.bossSpawned) spawnBoss(); +} + +function directorReady(key, cooldown) { + if (state.mode !== 'running') return false; + if (state.cooldowns[key] > 0) { showToast(`导演指令冷却中:${Math.ceil(state.cooldowns[key])} 秒`); return false; } + state.cooldowns[key] = cooldown; + return true; +} + +function triggerHorde() { + if (!directorReady('horde', 18)) return; + state.hordeUntil = state.elapsed + 7; + spawnHorde(120); + showBanner('EIGHTFOLD HORDE', '#ff526f'); + showToast('八倍围城:7 秒生成 ×8,已有 120 只怪贴进屏幕边缘', 3000); + state.shake = Math.max(state.shake, 0.75); +} + +function triggerOverdrive() { + if (!directorReady('overdrive', 17)) return; + state.overdriveUntil = state.elapsed + 10; + showBanner('BULLET OVERDRIVE', '#70fff1'); + showToast('无限弹匣:10 秒射速 ×3、伤害 ×2', 2800); + tone(420, 0.18, 'sawtooth', 0.045, 650); +} + +function triggerElite() { + if (!directorReady('elite', 20)) return; + for (let index = 0; index < 8; index += 1) spawnEnemy('elite', { side: index % 4, margin: 30 + index * 6, eliteScale: 0.9 }); + showBanner('ELITE AIRDROP ×8', '#c977ff'); + showToast('八名精英已空投:全部击杀会掉落强化补给', 3000); + state.shake = Math.max(state.shake, 0.55); +} + +function triggerNuke(checkCooldown = true) { + if (checkCooldown && !directorReady('nuke', 24)) return; + showBanner('ORBITAL PURGE', '#ffe45c'); + showToast('轨道清场:普通敌人全部蒸发,Boss 不会被秒杀', 2800); + state.shake = 2.1; + state.flash = 1; + addShockwave(state.player.x, state.player.y, '#ffe45c', 20, Math.hypot(width, height), 1.1, 13); + for (const enemy of state.enemies) { + if (enemy.dead) continue; + if (enemy.boss) damageEnemy(enemy, enemy.maxHp * 0.04, { critical: false, color: '#ffe45c' }); + else killEnemy(enemy, { color: '#ffe45c' }); + } + for (let index = 0; index < 170; index += 1) addParticle(state.player.x, state.player.y, index % 2 ? '#ffe45c' : '#ffffff', rand(150, 780), rand(0.4, 1.2), rand(2, 8)); + tone(55, 0.9, 'sawtooth', 0.11, 520); +} + +function syncHud(force = false) { + els.level.textContent = state.level; + els.kills.textContent = formatNumber(state.kills); + els.combo.textContent = `×${Math.max(1, state.combo)}`; + const remaining = Math.max(0, CONFIG.duration - state.elapsed); + els.time.textContent = remaining > 0 ? Math.ceil(remaining) : state.bossKilled ? 'CLEAR' : 'BOSS'; + els.score.textContent = formatNumber(state.score); + els.hpFill.style.width = `${clamp(state.hp / state.maxHp * 100, 0, 100)}%`; + els.hpText.textContent = `${Math.ceil(state.hp)} / ${state.maxHp}`; + els.xpFill.style.width = `${clamp(state.xp / state.nextXp * 100, 0, 100)}%`; + els.xpText.textContent = `${state.xp} / ${state.nextXp}`; + els.damageStat.textContent = formatCompact(state.damage * (state.elapsed < state.overdriveUntil ? 2 : 1)); + els.rateStat.textContent = `${(state.fireRate * (state.elapsed < state.overdriveUntil ? 3 : 1)).toFixed(1)}/s`; + els.shotStat.textContent = state.multishot; + els.speedStat.textContent = Math.round(state.moveSpeed); + els.hordeBtn.classList.toggle('active', state.elapsed < state.hordeUntil); + els.overdriveBtn.classList.toggle('active', state.elapsed < state.overdriveUntil); + const directorButtons = [['horde', els.hordeBtn], ['overdrive', els.overdriveBtn], ['elite', els.eliteBtn], ['nuke', els.nukeBtn]]; + for (const [key, button] of directorButtons) button.disabled = state.mode === 'running' && state.cooldowns[key] > 0; + if (state.boss && !state.boss.dead) { + els.bossHpFill.style.width = `${clamp(state.boss.hp / state.boss.maxHp * 100, 0, 100)}%`; + els.bossHpText.textContent = `${Math.ceil(state.boss.hp / state.boss.maxHp * 100)}% · ${formatCompact(state.boss.hp)} HP`; + } + if (force) updateArsenal(); +} + +function drawBackground() { + const gradient = ctx.createRadialGradient(state.player.x, state.player.y, 20, width * 0.5, height * 0.5, Math.max(width, height)); + gradient.addColorStop(0, state.elapsed < state.overdriveUntil ? '#102c38' : '#10203a'); + gradient.addColorStop(0.48, '#0a1326'); + gradient.addColorStop(1, '#050812'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + + ctx.save(); + ctx.globalAlpha = 0.35; + for (const star of state.stars) { + const glow = 0.35 + Math.sin(state.elapsed * 1.5 + star.phase) * 0.25; + ctx.fillStyle = `rgba(127,220,255,${glow})`; + ctx.fillRect(star.x * width, star.y * height, star.size, star.size); + } + ctx.restore(); + + const gridSize = 62; + const offsetX = ((-state.player.x * 0.05) % gridSize + gridSize) % gridSize; + const offsetY = ((-state.player.y * 0.05 + state.elapsed * 3) % gridSize + gridSize) % gridSize; + ctx.strokeStyle = 'rgba(91,158,196,0.085)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = offsetX; x < width; x += gridSize) { ctx.moveTo(x, 0); ctx.lineTo(x, height); } + for (let y = offsetY; y < height; y += gridSize) { ctx.moveTo(0, y); ctx.lineTo(width, y); } + ctx.stroke(); + + ctx.strokeStyle = 'rgba(112,255,241,0.075)'; + ctx.lineWidth = 2; + ctx.strokeRect(16, 92, width - 32, height - 110); +} + +function drawPickup(pickup) { + const pulse = 1 + Math.sin(pickup.phase) * 0.18; + ctx.save(); + ctx.translate(pickup.x, pickup.y); + ctx.rotate(pickup.phase * 0.18); + ctx.globalCompositeOperation = 'lighter'; + ctx.shadowColor = pickup.color; + ctx.shadowBlur = 0; + ctx.fillStyle = pickup.color; + if (pickup.type === 'xp') { + ctx.rotate(Math.PI / 4); + ctx.fillRect(-pickup.radius * pulse, -pickup.radius * pulse, pickup.radius * 2 * pulse, pickup.radius * 2 * pulse); + } else { + ctx.beginPath(); + for (let index = 0; index < 8; index += 1) { + const angle = index * Math.PI / 4; + const radius = index % 2 ? pickup.radius * 0.55 : pickup.radius * pulse; + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + if (!index) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = '900 10px system-ui'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText({ heal: '+', overdrive: '∞', magnet: 'U', nuke: '✺' }[pickup.type], 0, 0); + } + ctx.restore(); +} + +function drawEnemy(enemy) { + const frozen = state.elapsed < enemy.frozenUntil; + const bob = Math.sin(state.elapsed * (enemy.type === 'runner' ? 11 : 5) + enemy.phase) * Math.min(4, enemy.radius * 0.12); + const scale = enemy.radius / ENEMY_TYPES[enemy.type].radius; + ctx.save(); + ctx.translate(enemy.x, enemy.y + bob); + ctx.globalAlpha = enemy.hitFlash > 0 ? 0.58 : 1; + ctx.shadowColor = frozen ? '#6edbff' : enemy.color; + ctx.shadowBlur = enemy.boss ? 8 : enemy.elite ? 4 : 0; + ctx.fillStyle = 'rgba(0,0,0,0.42)'; + ctx.beginPath(); + ctx.ellipse(0, enemy.radius * 0.68, enemy.radius * 0.76, enemy.radius * 0.3, 0, 0, Math.PI * 2); + ctx.fill(); + if (spriteFrames[enemy.frame]) { + const sprite = spriteFrames[enemy.frame]; + const drawHeight = enemy.radius * (enemy.boss ? 3.3 : 3.05); + const drawWidth = drawHeight * (sprite.width / sprite.height); + ctx.drawImage(sprite, -drawWidth / 2, -drawHeight * 0.68, drawWidth, drawHeight); + } else { + ctx.fillStyle = enemy.color; + ctx.beginPath(); ctx.arc(0, 0, enemy.radius, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#101522'; ctx.beginPath(); ctx.arc(-enemy.radius * 0.3, -enemy.radius * 0.15, enemy.radius * 0.12, 0, Math.PI * 2); ctx.arc(enemy.radius * 0.3, -enemy.radius * 0.15, enemy.radius * 0.12, 0, Math.PI * 2); ctx.fill(); + } + if (frozen) { + ctx.globalCompositeOperation = 'screen'; + ctx.globalAlpha = 0.42; + ctx.fillStyle = '#63d9ff'; + ctx.beginPath(); ctx.arc(0, 0, enemy.radius * 1.1, 0, Math.PI * 2); ctx.fill(); + } + if (enemy.elite || enemy.boss) { + ctx.globalAlpha = 0.92; + ctx.strokeStyle = enemy.color; + ctx.lineWidth = enemy.boss ? 4 : 2; + ctx.beginPath(); ctx.arc(0, 0, enemy.radius * (1.05 + Math.sin(state.elapsed * 3 + enemy.phase) * 0.05), 0, Math.PI * 2); ctx.stroke(); + } + ctx.restore(); + if ((enemy.elite || enemy.boss || enemy.hp < enemy.maxHp) && !enemy.dead) { + const barWidth = enemy.radius * (enemy.boss ? 1.9 : 1.5); + ctx.fillStyle = 'rgba(0,0,0,0.62)'; + ctx.fillRect(enemy.x - barWidth / 2, enemy.y - enemy.radius * 1.35, barWidth, 4); + ctx.fillStyle = enemy.color; + ctx.fillRect(enemy.x - barWidth / 2, enemy.y - enemy.radius * 1.35, barWidth * clamp(enemy.hp / enemy.maxHp, 0, 1), 4); + } +} + +function drawPlayer() { + const player = state.player; + const invulnerable = state.elapsed < state.invulnerableUntil; + ctx.save(); + ctx.translate(player.x, player.y); + ctx.rotate(player.angle); + ctx.globalAlpha = invulnerable && Math.floor(state.elapsed * 20) % 2 ? 0.55 : 1; + ctx.shadowColor = state.elapsed < state.overdriveUntil ? '#70fff1' : '#65a8ff'; + ctx.shadowBlur = state.elapsed < state.overdriveUntil ? 10 : 5; + ctx.fillStyle = 'rgba(2,5,12,0.5)'; + ctx.beginPath(); ctx.ellipse(-3, 10, 22, 12, 0, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#162c49'; + ctx.strokeStyle = '#d8f8ff'; + ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(23, 0); ctx.lineTo(8, -15); ctx.lineTo(-16, -12); ctx.lineTo(-21, 0); ctx.lineTo(-16, 12); ctx.lineTo(8, 15); ctx.closePath(); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = '#70fff1'; + ctx.fillRect(4, -4, 27, 8); + ctx.fillStyle = '#ffe45c'; + ctx.beginPath(); ctx.arc(-3, 0, 6, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#65a8ff'; + ctx.fillRect(-17, -15, 7, 6); ctx.fillRect(-17, 9, 7, 6); + ctx.restore(); + + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.strokeStyle = state.elapsed < state.overdriveUntil ? 'rgba(112,255,241,0.7)' : 'rgba(255,228,92,0.28)'; + ctx.lineWidth = 1; + ctx.setLineDash([6, 9]); + ctx.beginPath(); ctx.moveTo(player.x + input.aimX * 28, player.y + input.aimY * 28); ctx.lineTo(player.x + input.aimX * 115, player.y + input.aimY * 115); ctx.stroke(); + ctx.restore(); +} + +function drawDronesAndOrbit() { + if (state.drones) { + for (let index = 0; index < state.drones; index += 1) { + const angle = state.elapsed * 1.35 + index * Math.PI * 2 / state.drones; + const x = state.player.x + Math.cos(angle) * (48 + state.drones * 2.5); + const y = state.player.y + Math.sin(angle) * (48 + state.drones * 2.5); + ctx.save(); ctx.translate(x, y); ctx.rotate(angle + Math.PI / 2); ctx.fillStyle = '#69b8ff'; ctx.beginPath(); ctx.moveTo(0, -8); ctx.lineTo(7, 6); ctx.lineTo(0, 3); ctx.lineTo(-7, 6); ctx.closePath(); ctx.fill(); ctx.restore(); + } + } + if (state.orbit) { + const radius = 64 + state.orbit * 4; + ctx.save(); ctx.strokeStyle = 'rgba(170,255,117,0.12)'; ctx.beginPath(); ctx.arc(state.player.x, state.player.y, radius, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); + for (let index = 0; index < state.orbit; index += 1) { + const angle = state.elapsed * (2.4 + state.orbit * 0.08) + index * Math.PI * 2 / state.orbit; + const x = state.player.x + Math.cos(angle) * radius; + const y = state.player.y + Math.sin(angle) * radius; + ctx.save(); ctx.translate(x, y); ctx.rotate(angle + state.elapsed * 8); ctx.fillStyle = '#e6ffc7'; ctx.beginPath(); ctx.moveTo(17, 0); ctx.lineTo(-8, -6); ctx.lineTo(-3, 0); ctx.lineTo(-8, 6); ctx.closePath(); ctx.fill(); ctx.restore(); + } + } +} + +function drawProjectiles() { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.lineCap = 'round'; + for (const bullet of state.bullets) { + ctx.strokeStyle = bullet.color; + ctx.lineWidth = bullet.radius * (bullet.kind === 'missile' ? 1.2 : 1.55); + ctx.shadowColor = bullet.color; + ctx.shadowBlur = 0; + ctx.beginPath(); ctx.moveTo(bullet.px, bullet.py); ctx.lineTo(bullet.x, bullet.y); ctx.stroke(); + if (bullet.kind === 'missile') { ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(bullet.x, bullet.y, bullet.radius * 0.75, 0, Math.PI * 2); ctx.fill(); } + } + for (const bullet of state.hostileBullets) { + ctx.strokeStyle = bullet.color; ctx.lineWidth = bullet.radius * 1.6; ctx.shadowColor = bullet.color; ctx.shadowBlur = 0; + ctx.beginPath(); ctx.moveTo(bullet.px, bullet.py); ctx.lineTo(bullet.x, bullet.y); ctx.stroke(); + } + for (const beam of state.beams) { + ctx.globalAlpha = clamp(beam.life / beam.maxLife, 0, 1); + ctx.strokeStyle = beam.color; ctx.lineWidth = beam.width * 2.5; ctx.shadowColor = beam.color; ctx.shadowBlur = 0; + ctx.beginPath(); ctx.moveTo(beam.x1, beam.y1); ctx.lineTo(beam.x2, beam.y2); ctx.stroke(); + ctx.strokeStyle = '#ffffff'; ctx.lineWidth = Math.max(1, beam.width * 0.55); ctx.beginPath(); ctx.moveTo(beam.x1, beam.y1); ctx.lineTo(beam.x2, beam.y2); ctx.stroke(); + } + ctx.restore(); +} + +function drawEffects() { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.lineCap = 'round'; + for (const particle of state.particles) { + ctx.globalAlpha = clamp(particle.life / particle.maxLife, 0, 1); + ctx.strokeStyle = particle.color; ctx.lineWidth = particle.size; ctx.shadowColor = particle.color; ctx.shadowBlur = 0; + ctx.beginPath(); ctx.moveTo(particle.px, particle.py); ctx.lineTo(particle.x, particle.y); ctx.stroke(); + } + for (const wave of state.shockwaves) { + ctx.globalAlpha = clamp(wave.life / wave.maxLife, 0, 1); + ctx.strokeStyle = wave.color; ctx.lineWidth = wave.width * (wave.life / wave.maxLife); ctx.shadowColor = wave.color; ctx.shadowBlur = 0; + ctx.beginPath(); ctx.arc(wave.x, wave.y, wave.radius, 0, Math.PI * 2); ctx.stroke(); + } + ctx.restore(); + ctx.save(); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + for (const floater of state.floaters) { + ctx.globalAlpha = clamp(floater.life / floater.maxLife, 0, 1); + ctx.fillStyle = floater.color; ctx.shadowColor = floater.color; ctx.shadowBlur = 0; + ctx.font = `950 ${floater.size}px system-ui, sans-serif`; + ctx.fillText(floater.text, floater.x, floater.y); + } + ctx.restore(); +} + +function render() { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + drawBackground(); + const shakeAmount = state.shake * 8; + ctx.save(); + ctx.translate(rand(-shakeAmount, shakeAmount), rand(-shakeAmount, shakeAmount)); + for (const pickup of state.pickups) drawPickup(pickup); + for (const enemy of state.enemies) drawEnemy(enemy); + drawDronesAndOrbit(); + drawProjectiles(); + drawPlayer(); + drawEffects(); + ctx.restore(); + if (state.elapsed < state.overdriveUntil && state.mode === 'running') { + ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = 0.035 + Math.sin(state.elapsed * 18) * 0.015; ctx.fillStyle = '#70fff1'; ctx.fillRect(0, 0, width, height); ctx.restore(); + } +} + +function loop(timestamp) { + const rawDt = state.lastTs ? (timestamp - state.lastTs) / 1000 : 0; + state.lastTs = timestamp; + const dt = Math.min(0.05, Math.max(0, rawDt)); + update(dt); + if (timestamp - lastRenderTs >= 25) { + lastRenderTs = timestamp; + render(); + } + rafId = requestAnimationFrame(loop); +} + +async function finishRun(victory) { + if (state.mode === 'result') return; + setMode('result'); + clearTimeout(autoPickTimer); + els.resultEyebrow.textContent = victory ? 'OMEGA ELIMINATED' : 'RUN TERMINATED'; + els.resultTitle.textContent = victory ? '尸潮被彻底打穿了' : '你被尸潮埋住了'; + els.resultDescription.textContent = victory ? '这次广告没有在最爽的时候切走,终局怪物也真的能被打死。' : '所有强化都会重新洗牌。下一局先叠移动、穿透或爆炸,更容易冲出包围。'; + els.finalScore.textContent = formatNumber(state.score); + els.finalKills.textContent = formatNumber(state.kills); + els.finalLevel.textContent = state.level; + els.finalRank.textContent = '—'; + els.newBest.classList.add('hidden'); + tone(victory ? 520 : 90, victory ? 0.5 : 0.7, victory ? 'triangle' : 'sawtooth', 0.075, victory ? 520 : -40); + try { + const result = await extCall({ action: 'submit_run', score: Math.round(state.score), kills: state.kills, duration: Math.round(state.elapsed), level: state.level, victory }); + if (result?.ok) { + els.finalRank.textContent = result.rank ? `#${result.rank}` : '—'; + els.newBest.classList.toggle('hidden', !result.is_best); + renderLeaderboard(result.leaderboard || []); + } + } catch { + // 离线时不阻塞结算,本局仍完整可玩。 + } +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); +} + +function renderLeaderboard(rows) { + if (!Array.isArray(rows) || !rows.length) { + els.leaderboardList.innerHTML = '
  • 还没有战绩,第一局由你来开榜。
  • '; + return; + } + els.leaderboardList.innerHTML = rows.slice(0, 10).map((row) => `
  • ${escapeHtml(row.display_name || row.username)}${formatCompact(row.score || 0)}Lv.${row.level || 1} · ${formatCompact(row.kills || 0)} 杀
  • `).join(''); +} + +async function loadProfile() { + try { + const [identity, profile] = await Promise.all([extCall({ action: 'whoami' }), extCall({ action: 'get_profile' })]); + if (identity?.ok) els.identityValue.textContent = identity.display_name || identity.username || '玩家'; + if (profile?.ok) renderLeaderboard(profile.leaderboard || []); + } catch { + els.identityValue.textContent = '离线玩家'; + els.leaderboardList.innerHTML = '
  • 排行榜暂时离线,不影响游戏。
  • '; + } +} + +function pointerPosition(event) { + const rect = canvas.getBoundingClientRect(); + input.pointerX = event.clientX - rect.left; + input.pointerY = event.clientY - rect.top; + input.pointerActive = true; +} + +function setupStick(element, kind) { + let pointerId = null; + const knob = element.querySelector('i'); + const updateStick = (event) => { + const rect = element.getBoundingClientRect(); + let x = event.clientX - (rect.left + rect.width / 2); + let y = event.clientY - (rect.top + rect.height / 2); + const max = rect.width * 0.34; + const length = Math.hypot(x, y); + if (length > max) { x = x / length * max; y = y / length * max; } + knob.style.transform = `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))`; + const normalizedX = x / max; + const normalizedY = y / max; + if (kind === 'move') { input.moveX = normalizedX; input.moveY = normalizedY; } + else if (Math.hypot(normalizedX, normalizedY) > 0.12) { const aimLength = Math.hypot(normalizedX, normalizedY); input.aimX = normalizedX / aimLength; input.aimY = normalizedY / aimLength; input.pointerActive = false; } + }; + const release = (event) => { + if (pointerId !== event.pointerId) return; + pointerId = null; + knob.style.transform = 'translate(-50%, -50%)'; + if (kind === 'move') { input.moveX = 0; input.moveY = 0; } + element.releasePointerCapture?.(event.pointerId); + }; + element.addEventListener('pointerdown', (event) => { pointerId = event.pointerId; element.setPointerCapture?.(event.pointerId); updateStick(event); event.preventDefault(); }); + element.addEventListener('pointermove', (event) => { if (pointerId === event.pointerId) { updateStick(event); event.preventDefault(); } }); + element.addEventListener('pointerup', release); + element.addEventListener('pointercancel', release); +} + +window.addEventListener('resize', resize); +canvas.addEventListener('pointermove', pointerPosition); +canvas.addEventListener('pointerdown', (event) => { pointerPosition(event); ensureAudio(); }); +window.addEventListener('keydown', (event) => { + const key = event.key.toLowerCase(); + if (['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift', 'p', 'escape', '1', '2', '3'].includes(key)) event.preventDefault(); + input.keys.add(key); + if (key === 'shift' && !event.repeat) startDash(); + if ((key === 'p' || key === 'escape') && !event.repeat && ['running', 'paused'].includes(state.mode)) pauseGame(); + if (state.mode === 'upgrade' && ['1', '2', '3'].includes(key)) els.upgradeOptions.querySelectorAll('.upgrade-option')[Number(key) - 1]?.click(); +}); +window.addEventListener('keyup', (event) => input.keys.delete(event.key.toLowerCase())); +window.addEventListener('blur', () => { input.keys.clear(); if (state.mode === 'running') pauseGame(); }); + +els.startBtn.addEventListener('click', startRun); +els.resumeBtn.addEventListener('click', pauseGame); +els.pauseBtn.addEventListener('click', pauseGame); +els.restartBtn.addEventListener('click', startRun); +els.againBtn.addEventListener('click', startRun); +els.menuBtn.addEventListener('click', returnToMenu); +els.soundBtn.addEventListener('click', () => { + muted = !muted; + localStorage.setItem('bullet-heaven-muted', muted ? '1' : '0'); + els.soundBtn.textContent = muted ? '声音 OFF' : '声音 ON'; + if (!muted) tone(620, 0.08, 'triangle', 0.04, 120); +}); +els.directorToggle.addEventListener('click', () => { + const collapsed = els.directorPanel.classList.toggle('collapsed'); + els.directorToggle.textContent = collapsed ? '+' : '−'; +}); +els.hordeBtn.addEventListener('click', triggerHorde); +els.overdriveBtn.addEventListener('click', triggerOverdrive); +els.eliteBtn.addEventListener('click', triggerElite); +els.nukeBtn.addEventListener('click', () => triggerNuke(true)); + +setupStick(els.moveStick, 'move'); +setupStick(els.aimStick, 'aim'); + +window.__bulletHeavenDebug = Object.freeze({ + snapshot: () => ({ mode: state.mode, elapsed: state.elapsed, enemies: state.enemies.length, bullets: state.bullets.length, hostileBullets: state.hostileBullets.length, pickups: state.pickups.length, particles: state.particles.length, kills: state.kills, level: state.level, hp: state.hp, bossSpawned: state.bossSpawned, bossHp: state.boss?.hp || 0, upgrades: { ...state.upgradeLevels } }), + start: startRun, + grantUpgrade(id, count = 1) { const upgrade = UPGRADE_MAP.get(id); if (!upgrade) return false; for (let index = 0; index < count; index += 1) { upgrade.apply(); if (upgrade.max < 90) state.upgradeLevels[id] = Math.min(upgrade.max, upgradeLevel(id) + 1); } updateArsenal(); syncHud(true); return true; }, + grantXp, + triggerHorde, + triggerOverdrive, + triggerElite, + triggerNuke: () => triggerNuke(false), + spawnBoss, + setHealth(value) { state.hp = clamp(Number(value) || 0, 0, state.maxHp); syncHud(); }, +}); + +resize(); +resetRun(); +setMode('menu'); +els.soundBtn.textContent = muted ? '声音 OFF' : '声音 ON'; +loadProfile(); +rafId = requestAnimationFrame(loop); diff --git a/mobius/extension/bullet-heaven/frontend/styles.css b/mobius/extension/bullet-heaven/frontend/styles.css new file mode 100644 index 00000000..751ea9f8 --- /dev/null +++ b/mobius/extension/bullet-heaven/frontend/styles.css @@ -0,0 +1,648 @@ +:root { + color-scheme: dark; + --bg: #070b16; + --panel: rgba(8, 14, 28, 0.88); + --panel-strong: rgba(9, 17, 34, 0.97); + --line: rgba(133, 188, 224, 0.2); + --line-strong: rgba(112, 255, 241, 0.62); + --text: #f1fbff; + --dim: #87a3b7; + --cyan: #70fff1; + --blue: #65a8ff; + --yellow: #ffe45c; + --orange: #ff9d4d; + --red: #ff526f; + --purple: #c977ff; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; +} + +* { box-sizing: border-box; } + +html, +body { + width: 100%; + min-width: 320px; + height: 100%; + margin: 0; + overflow: hidden; + background: var(--bg); + color: var(--text); +} + +button, +input { font: inherit; } + +button { color: inherit; } + +.game-shell { + position: relative; + width: 100vw; + height: 100vh; + min-height: 560px; + overflow: hidden; + isolation: isolate; + background: #070b16; + user-select: none; + touch-action: none; +} + +#gameCanvas, +.vignette, +.scanlines, +.damage-flash { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +#gameCanvas { display: block; cursor: crosshair; } + +.vignette, +.scanlines, +.damage-flash { pointer-events: none; } + +.vignette { + z-index: 4; + background: + radial-gradient(ellipse at center, transparent 42%, rgba(2, 5, 12, 0.25) 72%, rgba(1, 3, 9, 0.82) 100%), + linear-gradient(180deg, rgba(3, 7, 16, 0.35), transparent 25%, transparent 76%, rgba(2, 4, 10, 0.5)); +} + +.scanlines { + z-index: 5; + opacity: 0.09; + background: repeating-linear-gradient(180deg, transparent 0 3px, rgba(136, 241, 255, 0.08) 3px 4px); + mix-blend-mode: soft-light; +} + +.damage-flash { + z-index: 6; + opacity: 0; + background: radial-gradient(circle, transparent 20%, rgba(255, 35, 76, 0.72) 100%); + transition: opacity 0.18s ease; +} + +.damage-flash.active { opacity: 0.62; transition-duration: 0.02s; } + +.top-hud { + position: absolute; + z-index: 20; + inset: 0 0 auto 0; + min-height: 76px; + display: grid; + grid-template-columns: minmax(220px, 1fr) auto minmax(220px, 1fr); + align-items: center; + gap: 18px; + padding: 12px 18px 14px; + background: linear-gradient(180deg, rgba(3, 7, 16, 0.96), rgba(3, 7, 16, 0.65) 74%, transparent); + pointer-events: none; +} + +.top-hud button { pointer-events: auto; } + +.brand { + display: grid; + grid-template-columns: auto auto; + grid-template-rows: auto auto; + align-items: center; + justify-content: start; + gap: 2px 9px; +} + +.brand > span { + grid-column: 1 / -1; + color: var(--cyan); + font-size: 9px; + font-weight: 900; + letter-spacing: 0.2em; +} + +.brand strong { font-size: 18px; letter-spacing: 0.08em; } + +.brand em { + display: flex; + align-items: center; + gap: 5px; + color: #aebfcb; + font-size: 9px; + font-style: normal; + font-weight: 800; +} + +.brand em i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--red); + box-shadow: 0 0 13px var(--red); + animation: pulse 1.2s infinite; +} + +@keyframes pulse { 50% { opacity: 0.36; transform: scale(0.7); } } + +.metrics { + min-width: 520px; + display: flex; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(4, 10, 21, 0.78); + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(13px); +} + +.metrics > div { + min-width: 88px; + flex: 1; + display: grid; + gap: 1px; + padding: 7px 13px; + border-right: 1px solid var(--line); + text-align: center; +} + +.metrics > div:last-child { border-right: 0; min-width: 114px; } + +.metrics span { + color: var(--dim); + font-size: 9px; + font-weight: 900; + letter-spacing: 0.12em; +} + +.metrics b { + font-size: 19px; + font-variant-numeric: tabular-nums; + text-shadow: 0 0 20px rgba(112, 255, 241, 0.28); +} + +.metrics > div:first-child b { color: var(--cyan); } +.metrics > div:nth-child(3) b { color: var(--yellow); } + +.hud-actions { + justify-self: end; + display: flex; + gap: 8px; +} + +.hud-actions button, +#directorToggle { + min-width: 72px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 9px; + background: rgba(7, 15, 29, 0.78); + color: #b9cedb; + cursor: pointer; + font-size: 10px; + font-weight: 850; + transition: 0.18s ease; +} + +.hud-actions button:hover, +#directorToggle:hover { + border-color: var(--line-strong); + color: var(--cyan); + transform: translateY(-1px); +} + +.status-bars { + position: absolute; + z-index: 18; + top: 78px; + left: 50%; + width: min(650px, 52vw); + transform: translateX(-50%); + display: grid; + gap: 5px; + pointer-events: none; +} + +.bar-row { + display: grid; + grid-template-columns: 42px 1fr 78px; + align-items: center; + gap: 8px; + color: #9fb3c1; + font-size: 9px; + font-weight: 900; + letter-spacing: 0.08em; +} + +.bar-row > div { + height: 7px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); +} + +.bar-row i { + display: block; + width: 100%; + height: 100%; + border-radius: inherit; + transform-origin: left; + transition: width 0.15s ease; +} + +.bar-row b { text-align: right; color: #e5f6ff; font-variant-numeric: tabular-nums; } +.hp-row i { background: linear-gradient(90deg, #ff526f, #ffad5b); box-shadow: 0 0 13px rgba(255, 82, 111, 0.65); } +.xp-row i { background: linear-gradient(90deg, #65a8ff, #70fff1); box-shadow: 0 0 13px rgba(112, 255, 241, 0.65); } + +.arsenal-panel, +.director-panel { + position: absolute; + z-index: 20; + top: 100px; + width: 238px; + border: 1px solid var(--line); + border-radius: 15px; + background: linear-gradient(145deg, rgba(10, 18, 35, 0.9), rgba(5, 10, 21, 0.78)); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.34); + backdrop-filter: blur(14px); +} + +.arsenal-panel { left: 16px; padding: 13px; pointer-events: none; } +.director-panel { right: 16px; padding: 13px; } + +.panel-title { position: relative; display: grid; gap: 1px; margin-bottom: 10px; } +.panel-title span { color: var(--cyan); font-size: 8px; font-weight: 900; letter-spacing: 0.18em; } +.panel-title b { font-size: 14px; letter-spacing: 0.04em; } +.director-title { padding-right: 35px; } +#directorToggle { position: absolute; top: 0; right: 0; min-width: 28px; width: 28px; height: 28px; padding: 0; } + +.arsenal-list { display: grid; gap: 5px; } + +.arsenal-item { + display: grid; + grid-template-columns: 27px 1fr auto; + align-items: center; + gap: 7px; + padding: 6px 8px; + border: 1px solid rgba(143, 191, 222, 0.12); + border-radius: 9px; + background: rgba(255, 255, 255, 0.025); +} + +.arsenal-item i { + display: grid; + place-items: center; + width: 27px; + height: 27px; + border-radius: 8px; + background: color-mix(in srgb, var(--item-color) 20%, transparent); + color: var(--item-color); + font-style: normal; + font-weight: 950; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--item-color) 35%, transparent); +} + +.arsenal-item span { display: grid; gap: 1px; } +.arsenal-item span b { font-size: 10px; } +.arsenal-item span small { color: #718c9e; font-size: 8px; } +.arsenal-item > b { color: var(--item-color); font-size: 10px; } + +.combat-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px 12px; + margin-top: 10px; + padding-top: 9px; + border-top: 1px solid var(--line); + color: #7895a8; + font-size: 8px; + font-weight: 800; +} + +.combat-stats span { display: flex; justify-content: space-between; } +.combat-stats b { color: #dffbff; font-size: 9px; font-variant-numeric: tabular-nums; } + +.director-body { display: grid; gap: 7px; max-height: 400px; overflow: hidden; transition: max-height 0.25s ease, opacity 0.2s ease; } +.director-panel.collapsed .director-body { max-height: 0; opacity: 0; } + +.director-button { + display: grid; + grid-template-columns: 34px 1fr; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + background: rgba(255, 255, 255, 0.025); + text-align: left; + cursor: pointer; + transition: 0.18s ease; +} + +.director-button:hover { transform: translateX(-2px); border-color: var(--button-color); background: color-mix(in srgb, var(--button-color) 9%, transparent); } +.director-button.active { border-color: var(--button-color); box-shadow: inset 0 0 22px color-mix(in srgb, var(--button-color) 12%, transparent), 0 0 20px color-mix(in srgb, var(--button-color) 10%, transparent); } +.director-button:disabled { opacity: 0.42; cursor: default; transform: none; } +.director-button > i { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; background: color-mix(in srgb, var(--button-color) 17%, transparent); color: var(--button-color); font-style: normal; font-size: 18px; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--button-color) 32%, transparent); } +.director-button span { display: grid; gap: 2px; } +.director-button b { font-size: 10px; } +.director-button small { color: #7993a4; font-size: 8px; line-height: 1.3; } +.director-button.danger { --button-color: #ff526f; } +.director-button.energy { --button-color: #70fff1; } +.director-button.elite { --button-color: #c977ff; } +.director-button.nuke { --button-color: #ffe45c; } + +.auto-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 7px 4px 2px; + cursor: pointer; +} + +.auto-row span { display: grid; gap: 1px; } +.auto-row span b { font-size: 10px; } +.auto-row span small { color: #71899a; font-size: 8px; } +.auto-row input { display: none; } +.auto-row > i { position: relative; width: 34px; height: 19px; border-radius: 999px; background: rgba(255, 255, 255, 0.12); transition: 0.2s; } +.auto-row > i::after { content: ""; position: absolute; top: 3px; left: 3px; width: 13px; height: 13px; border-radius: 50%; background: #8ba0ad; transition: 0.2s; } +.auto-row input:checked + i { background: rgba(112, 255, 241, 0.28); box-shadow: inset 0 0 0 1px rgba(112, 255, 241, 0.4); } +.auto-row input:checked + i::after { left: 18px; background: var(--cyan); box-shadow: 0 0 10px var(--cyan); } + +.control-hint { + position: absolute; + z-index: 15; + left: 50%; + bottom: 17px; + display: flex; + gap: 13px; + transform: translateX(-50%); + color: rgba(190, 216, 229, 0.72); + font-size: 8px; + font-weight: 800; + pointer-events: none; +} + +kbd { + display: inline-grid; + place-items: center; + min-width: 23px; + height: 20px; + margin-right: 3px; + padding: 0 5px; + border: 1px solid rgba(255, 255, 255, 0.19); + border-bottom-width: 2px; + border-radius: 5px; + background: rgba(4, 9, 19, 0.78); + color: #d9f6ff; + font-size: 8px; +} + +.boss-hud { + position: absolute; + z-index: 25; + left: 50%; + bottom: 48px; + width: min(660px, 55vw); + transform: translateX(-50%); + padding: 10px 13px; + border: 1px solid rgba(255, 82, 111, 0.4); + border-radius: 12px; + background: rgba(15, 5, 15, 0.84); + box-shadow: 0 0 38px rgba(255, 30, 92, 0.18); + backdrop-filter: blur(12px); +} + +.boss-hud > div { display: flex; justify-content: space-between; margin-bottom: 5px; color: #ffb0c2; font-size: 10px; font-weight: 900; letter-spacing: 0.09em; } +.boss-hud section { height: 8px; overflow: hidden; border-radius: 999px; background: rgba(255, 255, 255, 0.08); } +.boss-hud section i { display: block; width: 100%; height: 100%; background: linear-gradient(90deg, #ff365f, #ff9b54, #ffe45c); box-shadow: 0 0 17px #ff526f; } + +.hidden { display: none !important; } + +.toast { + position: absolute; + z-index: 38; + left: 50%; + top: 126px; + max-width: min(660px, 74vw); + padding: 9px 14px; + border: 1px solid rgba(112, 255, 241, 0.28); + border-radius: 999px; + background: rgba(4, 11, 23, 0.88); + color: #dcfbff; + font-size: 10px; + font-weight: 800; + text-align: center; + opacity: 0; + transform: translate(-50%, -8px); + transition: 0.22s ease; + pointer-events: none; + backdrop-filter: blur(10px); +} + +.toast.visible { opacity: 1; transform: translate(-50%, 0); } + +.event-banner { + position: absolute; + z-index: 35; + left: 50%; + top: 31%; + color: var(--yellow); + font-size: clamp(28px, 4.2vw, 64px); + font-weight: 1000; + font-style: italic; + letter-spacing: -0.04em; + text-align: center; + text-shadow: 0 0 18px currentColor, 0 4px 0 rgba(0, 0, 0, 0.5); + opacity: 0; + transform: translate(-50%, 25px) scale(1.15) skewX(-7deg); + pointer-events: none; +} + +.event-banner.visible { animation: bannerIn 1.8s ease both; } +@keyframes bannerIn { 0% { opacity: 0; transform: translate(-50%, 25px) scale(1.35) skewX(-7deg); } 15%, 68% { opacity: 1; transform: translate(-50%, 0) scale(1) skewX(-7deg); } 100% { opacity: 0; transform: translate(-50%, -15px) scale(0.92) skewX(-7deg); } } + +.overlay { + position: absolute; + z-index: 80; + inset: 0; + display: none; + place-items: center; + padding: 88px 24px 28px; + overflow: auto; + background: radial-gradient(circle at 50% 38%, rgba(21, 42, 76, 0.32), rgba(2, 5, 12, 0.9) 72%); + backdrop-filter: blur(6px); +} + +.overlay.visible { display: grid; } + +.panel-card { + width: min(1110px, 94vw); + border: 1px solid rgba(138, 197, 230, 0.2); + border-radius: 24px; + background: + linear-gradient(145deg, rgba(15, 27, 50, 0.98), rgba(5, 10, 21, 0.98)), + var(--panel-strong); + box-shadow: 0 32px 100px rgba(0, 0, 0, 0.56), inset 0 1px rgba(255, 255, 255, 0.04); +} + +.start-card { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(270px, 0.62fr); overflow: hidden; } + +.start-copy { padding: clamp(28px, 4vw, 58px); } +.eyebrow { display: block; margin-bottom: 9px; color: var(--cyan); font-size: 10px; font-weight: 950; letter-spacing: 0.14em; } +.start-copy h1 { margin: 0; font-size: clamp(48px, 6.2vw, 88px); line-height: 0.85; letter-spacing: -0.07em; } +.start-copy h1 em { color: transparent; font-size: 0.53em; font-style: italic; letter-spacing: -0.04em; -webkit-text-stroke: 1px rgba(112, 255, 241, 0.68); } +.start-copy > p { max-width: 700px; margin: 23px 0 19px; color: #aac0ce; font-size: 13px; line-height: 1.8; } + +.feature-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 16px; } +.feature-grid div { display: grid; gap: 1px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 11px; background: rgba(255, 255, 255, 0.025); } +.feature-grid b { color: var(--yellow); font-size: 20px; font-style: italic; } +.feature-grid span { color: #7895a7; font-size: 8px; font-weight: 850; } + +.upgrade-preview { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; } +.upgrade-preview span { padding: 5px 9px; border: 1px solid rgba(112, 255, 241, 0.17); border-radius: 999px; background: rgba(112, 255, 241, 0.045); color: #9ed5d7; font-size: 8px; font-weight: 800; } + +.primary-button, +.secondary-button { + border-radius: 12px; + cursor: pointer; + transition: 0.18s ease; +} + +.primary-button { + width: 100%; + display: grid; + place-items: center; + gap: 2px; + padding: 13px 18px; + border: 1px solid rgba(112, 255, 241, 0.65); + background: linear-gradient(135deg, rgba(112, 255, 241, 0.23), rgba(101, 168, 255, 0.16)); + color: #eeffff; + box-shadow: inset 0 0 25px rgba(112, 255, 241, 0.08), 0 0 35px rgba(112, 255, 241, 0.1); +} + +.primary-button span { font-size: 15px; font-weight: 950; letter-spacing: 0.03em; } +.primary-button small { color: #8cb8c5; font-size: 8px; } +.primary-button:hover { transform: translateY(-2px); box-shadow: inset 0 0 32px rgba(112, 255, 241, 0.14), 0 10px 40px rgba(112, 255, 241, 0.16); } +.secondary-button { padding: 11px 17px; border: 1px solid var(--line); background: rgba(255, 255, 255, 0.025); color: #a9c0cc; font-size: 10px; font-weight: 850; } +.secondary-button:hover { border-color: rgba(112, 255, 241, 0.4); color: var(--cyan); } +.start-hint { margin: 10px 0 0 !important; color: #6f8999 !important; font-size: 8px !important; text-align: center; } + +.leaderboard-card { padding: 30px 25px; border-left: 1px solid var(--line); background: rgba(2, 7, 15, 0.38); } +.leaderboard-head { display: grid; gap: 2px; margin-bottom: 16px; } +.leaderboard-head span { color: var(--yellow); font-size: 8px; font-weight: 950; letter-spacing: 0.18em; } +.leaderboard-head b { font-size: 17px; } +.leaderboard-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; counter-reset: rank; } +.leaderboard-list li { counter-increment: rank; display: grid; grid-template-columns: 25px minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 34px; padding: 5px 8px; border: 1px solid rgba(255, 255, 255, 0.07); border-radius: 9px; background: rgba(255, 255, 255, 0.022); } +.leaderboard-list li::before { content: counter(rank, decimal-leading-zero); color: #668093; font-size: 8px; font-weight: 900; } +.leaderboard-list li:nth-child(1)::before { color: var(--yellow); } +.leaderboard-list li:nth-child(2)::before { color: #d7e5ef; } +.leaderboard-list li:nth-child(3)::before { color: #ff9d67; } +.leaderboard-list li span { min-width: 0; overflow: hidden; color: #bdd0dc; font-size: 9px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } +.leaderboard-list li b { color: var(--cyan); font-size: 10px; font-variant-numeric: tabular-nums; } +.leaderboard-list li small { color: #6f8797; font-size: 7px; } +.leaderboard-list li.empty { display: block; padding: 24px; color: #718a9b; text-align: center; } +.leaderboard-list li.empty::before { display: none; } +.identity-row { display: flex; justify-content: space-between; margin-top: 15px; padding-top: 13px; border-top: 1px solid var(--line); color: #718a9a; font-size: 8px; } +.identity-row b { color: #d8f5ff; } + +.upgrade-card { max-width: 980px; padding: 34px; text-align: center; } +.upgrade-card h2 { margin: 0; font-size: clamp(25px, 4vw, 42px); letter-spacing: -0.04em; } +.upgrade-card > p { margin: 7px 0 20px; color: #7895a5; font-size: 10px; } +.upgrade-card > small { display: block; margin-top: 13px; color: #607b8d; font-size: 8px; } +.upgrade-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.upgrade-option { position: relative; min-height: 238px; display: grid; grid-template-rows: auto auto auto 1fr auto; justify-items: start; gap: 8px; padding: 21px; overflow: hidden; border: 1px solid color-mix(in srgb, var(--upgrade-color) 28%, transparent); border-radius: 16px; background: linear-gradient(145deg, color-mix(in srgb, var(--upgrade-color) 9%, transparent), rgba(5, 10, 20, 0.78)); text-align: left; cursor: pointer; transition: 0.18s ease; } +.upgrade-option::after { content: ""; position: absolute; right: -36px; bottom: -50px; width: 130px; height: 130px; border-radius: 50%; background: var(--upgrade-color); opacity: 0.07; filter: blur(10px); } +.upgrade-option:hover { transform: translateY(-5px); border-color: var(--upgrade-color); box-shadow: 0 18px 45px color-mix(in srgb, var(--upgrade-color) 12%, transparent); } +.upgrade-option > i { display: grid; place-items: center; width: 52px; height: 52px; border-radius: 14px; background: color-mix(in srgb, var(--upgrade-color) 16%, transparent); color: var(--upgrade-color); font-size: 25px; font-style: normal; font-weight: 950; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--upgrade-color) 34%, transparent); } +.upgrade-option h3 { margin: 3px 0 0; font-size: 17px; } +.upgrade-option p { margin: 0; color: #9cb3c0; font-size: 10px; line-height: 1.6; } +.upgrade-option small { color: color-mix(in srgb, var(--upgrade-color) 75%, white); font-size: 9px; font-weight: 900; } +.upgrade-option kbd { position: absolute; top: 13px; right: 13px; } + +.compact-card { width: min(420px, 90vw); display: grid; gap: 11px; padding: 34px; text-align: center; } +.compact-card h2 { margin: 0 0 10px; } +.result-card { max-width: 720px; padding: 38px; text-align: center; } +.result-card h2 { margin: 0; font-size: clamp(32px, 5vw, 54px); } +.result-card > p { color: #8da5b4; font-size: 11px; } +.result-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 24px 0; } +.result-stats div { display: grid; gap: 4px; padding: 14px 8px; border: 1px solid var(--line); border-radius: 11px; background: rgba(255, 255, 255, 0.025); } +.result-stats span { color: #728a9b; font-size: 8px; font-weight: 850; } +.result-stats b { color: var(--cyan); font-size: 22px; font-variant-numeric: tabular-nums; } +.result-actions { display: grid; grid-template-columns: 1fr auto; gap: 9px; } +.new-best { display: inline-block; margin: -11px 0 15px; padding: 5px 13px; border: 1px solid rgba(255, 228, 92, 0.5); border-radius: 999px; color: var(--yellow); font-size: 10px; font-weight: 950; letter-spacing: 0.14em; box-shadow: 0 0 24px rgba(255, 228, 92, 0.15); } + +.mobile-controls { display: none; } + +@media (max-width: 1120px) { + .top-hud { grid-template-columns: 190px 1fr auto; gap: 10px; } + .metrics { min-width: 0; } + .metrics > div { min-width: 68px; padding-inline: 8px; } + .metrics > div:last-child { min-width: 88px; } + .arsenal-panel, + .director-panel { width: 205px; } + .start-card { grid-template-columns: minmax(0, 1fr) 270px; } +} + +@media (max-width: 900px) { + .top-hud { min-height: 64px; grid-template-columns: 1fr auto; padding: 9px 10px 12px; } + .brand > span, + .brand em { display: none; } + .brand strong { font-size: 14px; } + .metrics { grid-column: 1 / -1; grid-row: 2; width: 100%; } + .metrics > div { min-width: 0; padding: 4px 6px; } + .metrics span { font-size: 7px; } + .metrics b { font-size: 13px; } + .hud-actions { grid-column: 2; grid-row: 1; } + .hud-actions button { min-width: 55px; padding: 6px; font-size: 8px; } + .status-bars { top: 94px; width: calc(100vw - 24px); } + .arsenal-panel { top: 124px; left: 8px; width: 170px; padding: 9px; opacity: 0.84; } + .arsenal-list { gap: 3px; } + .arsenal-item { grid-template-columns: 23px 1fr auto; padding: 4px 6px; } + .arsenal-item i { width: 23px; height: 23px; } + .arsenal-item span small, + .combat-stats { display: none; } + .director-panel { top: 124px; right: 8px; width: 174px; padding: 9px; } + .director-button { grid-template-columns: 28px 1fr; padding: 5px; } + .director-button > i { width: 28px; height: 28px; font-size: 15px; } + .director-button small { display: none; } + .control-hint { display: none; } + .boss-hud { bottom: 112px; width: calc(100vw - 30px); } + .start-card { grid-template-columns: 1fr; } + .leaderboard-card { border-top: 1px solid var(--line); border-left: 0; } + .overlay { align-items: start; padding: 76px 12px 22px; } + .feature-grid { grid-template-columns: 1fr 1fr; } + .mobile-controls { position: absolute; z-index: 30; inset: auto 0 15px; display: flex; justify-content: space-between; padding: 0 22px; pointer-events: none; } + .stick { position: relative; width: 92px; height: 92px; border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 50%; background: rgba(4, 9, 19, 0.35); box-shadow: inset 0 0 25px rgba(112, 255, 241, 0.06); pointer-events: auto; touch-action: none; } + .stick i { position: absolute; left: 50%; top: 50%; width: 34px; height: 34px; border: 1px solid rgba(112, 255, 241, 0.5); border-radius: 50%; background: rgba(112, 255, 241, 0.17); box-shadow: 0 0 15px rgba(112, 255, 241, 0.18); transform: translate(-50%, -50%); } + .stick span { position: absolute; left: 50%; bottom: -15px; transform: translateX(-50%); color: #698493; font-size: 7px; font-weight: 850; } + .aim-stick { border-color: rgba(255, 228, 92, 0.25); } + .aim-stick i { border-color: rgba(255, 228, 92, 0.55); background: rgba(255, 228, 92, 0.13); } +} + +@media (max-width: 620px) { + .arsenal-panel { top: 122px; width: 142px; } + .panel-title b { font-size: 11px; } + .arsenal-item span b { font-size: 8px; } + .director-panel { top: 122px; width: 145px; } + .director-button b { font-size: 8px; } + .director-button { grid-template-columns: 24px 1fr; } + .director-button > i { width: 24px; height: 24px; font-size: 13px; } + .auto-row span small { display: none; } + .toast { top: 110px; max-width: 88vw; } + .start-copy { padding: 27px 20px; } + .start-copy h1 { font-size: 48px; } + .start-copy > p { font-size: 11px; } + .leaderboard-card { padding: 25px 20px; } + .upgrade-card { padding: 24px 15px; } + .upgrade-options { grid-template-columns: 1fr; } + .upgrade-option { min-height: 128px; grid-template-columns: 45px 1fr; grid-template-rows: auto auto auto; gap: 4px 10px; padding: 14px; } + .upgrade-option > i { grid-row: 1 / 4; width: 44px; height: 44px; font-size: 21px; } + .upgrade-option h3 { margin: 0; } + .upgrade-option p { font-size: 9px; } + .upgrade-option small { grid-column: 2; } + .result-stats { grid-template-columns: 1fr 1fr; } + .mobile-controls { padding-inline: 14px; } +} + +@media (hover: none) and (pointer: coarse) { + #gameCanvas { cursor: default; } +} 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 742cd209..ea48d84c 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": "双题材广告爽游实验室:恐怖尸潮割草与搞笑办公室 DDL 保卫战。", - "version": "0.4.0", + "description": "双题材十关电影化广告爽游:程序化角色配件、题材基地、进化主角与完整 HDR 战斗反馈。", + "version": "0.11.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 a0912fc7..d54ad818 100644 --- a/mobius/extension/toy-toy-toy/frontend/index.html +++ b/mobius/extension/toy-toy-toy/frontend/index.html @@ -6,11 +6,12 @@ 广告爽游实验室 - + @@ -30,6 +31,7 @@
    +
    关卡01/10
    得分0
    击杀0
    连击×1
    @@ -55,6 +57,15 @@
    +
    + 本局永久加成 +
    火力×1.00
    +
    射速×1.00
    +
    炮台1 / 8
    +
    碎片0 / 2
    + 选择 ×0 +
    +