diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..63c7383 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.DS_Store +node_modules +dist +coverage +.vite +.env +.env.local +docs +apps/api/data +apps/api/dist +apps/web/dist +packages/*/dist diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..45c0e12 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +API_PORT=3001 +WEB_ORIGIN=http://localhost:5173 +WEB_DIST_DIR=../web/dist +SESSION_COOKIE_NAME=tinywaste_session +SESSION_SECRET=replace-with-a-long-random-secret +DATABASE_URL=./data/tinywaste.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83fab99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules +.DS_Store +dist +coverage +.vite +*.tsbuildinfo +.env +.env.local +apps/api/data +apps/api/drizzle +pnpm-lock.yaml + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b045c8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM node:20-bookworm-slim AS builder + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +RUN corepack enable + +COPY . . + +RUN pnpm install --frozen-lockfile +RUN pnpm build + +FROM node:20-bookworm-slim AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV API_PORT=3001 +ENV WEB_ORIGIN=http://localhost:3001 +ENV WEB_DIST_DIR=/app/apps/web/dist +ENV DATABASE_URL=/data/tinywaste.db +ENV SESSION_COOKIE_NAME=tinywaste_session + +COPY --from=builder /app /app + +RUN mkdir -p /data \ + && chmod +x /app/docker-entrypoint.sh + +WORKDIR /app/apps/api + +EXPOSE 3001 +VOLUME ["/data"] + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2844d61 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# TinyWaste + +TinyWaste 是基于 `docs/` 里的 GDD、系统设计、存档后端设计和 QA 清单落地的一版可运行在线生存游戏原型。它不是单机本地存档,而是账号登录 + 服务端判定 + 数据库存档的结构,用户登录后可以继续上次进度。 + +## 技术栈 + +- `pnpm workspace` +- `apps/web`: React 19 + Vite + TypeScript + React Query +- `apps/api`: Fastify + TypeScript +- `packages/game-core`: 纯逻辑模拟层,负责状态推进、战斗、事件、交易、制作 +- `packages/content`: 数据驱动内容层,承载地图、地点、敌人、事件、配方、任务 +- `SQLite + Drizzle ORM`: 用户、会话、主存档持久化 +- `HttpOnly Session Cookie`: 账号登录态 + +## 当前实现范围 + +- 用户注册 / 登录 / 登出 +- 每个账号一份主存档,所有行动都会回写数据库 +- 服务端权威推进:移动、地点行动、事件选择、战斗、制作、交易、休息、使用物品、装备切换 +- 基于文档设计的主线推进骨架: + - `home` + - `roadside` + - `rain_farm` + - `village_market` + - `city_edge` + - `subway_entrance` + - `sewer` + - `vault_gate` +- 前端已实现账号页、建档页、主游戏界面、日志、任务、库存、战斗和事件弹层 + +## GPT-Image-2 资源 + +地点视觉资源已放在 [apps/web/public/generated](/Users/virtheart/Documents/CloneProjects/TinyWaste/apps/web/public/generated)。这些图是按 `docs/awesome-gpt-image-2-prompts/README_zh-CN.md` 的思路为本项目生成并接入的,当前已覆盖主要地点背景图。 + +## 运行方式 + +1. 创建环境变量文件: + +```bash +cp .env.example .env +``` + +2. 安装依赖: + +```bash +pnpm install +``` + +3. 初始化数据库: + +```bash +pnpm db:migrate +``` + +4. 启动前后端开发环境: + +```bash +pnpm dev +``` + +5. 打开: + +- Web: [http://localhost:5173](http://localhost:5173) +- API: [http://localhost:3001/api/health](http://localhost:3001/api/health) + +## Docker 部署 + +项目现在支持用一个镜像同时承载前端和后端。容器启动时会自动执行数据库迁移,然后由 Fastify 同时提供 `/api/*` 和前端静态页面。 + +1. 构建镜像: + +```bash +docker build -t tinywaste . +``` + +2. 启动容器: + +```bash +docker run -d \ + --name tinywaste \ + -p 3001:3001 \ + -v tinywaste-data:/data \ + tinywaste +``` + +3. 打开: + +- 应用首页: [http://localhost:3001](http://localhost:3001) +- 健康检查: [http://localhost:3001/api/health](http://localhost:3001/api/health) + +说明: + +- 默认数据库文件在容器内 `/data/tinywaste.db`,上面的 volume 用来持久化存档数据。 +- 如果宿主机端口想改成 `8080`,只需要把参数改成 `-p 8080:3001`,容器内端口仍然是 `3001`。 +- `WEB_ORIGIN` 在 Docker 场景下一般不需要额外修改,因为前后端由同一个服务同源提供。 +- 如果没有传 `SESSION_SECRET`,容器会在启动时自动生成一个临时密钥,方便本地直接跑;正式环境建议显式传入固定长随机串。 + +## 常用命令 + +```bash +pnpm build +pnpm test +pnpm --filter @tinywaste/api db:migrate +``` + +## 目录结构 + +```text +apps/ + api/ Fastify API、认证、会话、存档持久化 + web/ React 前端 +packages/ + game-core/ 游戏核心模拟 + content/ 地图与内容数据 +docs/ 游戏设计、系统设计、运行发布、QA 与 GPT-Image-2 提示词参考 +``` + +## 后续扩展建议 + +- 增加多存档槽位与账号资料页 +- 给事件、敌人、道具补更多 GPT-Image-2 资产 +- 为 `game-core` 和 API 补更多集成测试 +- 接入正式生产数据库和 HTTPS Cookie 策略 diff --git a/apps/api/apps/api/data/tinywaste.db b/apps/api/apps/api/data/tinywaste.db new file mode 100644 index 0000000..3a61c6b Binary files /dev/null and b/apps/api/apps/api/data/tinywaste.db differ diff --git a/apps/api/migrations/0001_init.sql b/apps/api/migrations/0001_init.sql new file mode 100644 index 0000000..938109e --- /dev/null +++ b/apps/api/migrations/0001_init.sql @@ -0,0 +1,36 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS save_slots ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + slot_key TEXT NOT NULL, + label TEXT NOT NULL, + state_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(user_id, slot_key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_save_slots_user_id ON save_slots(user_id); + diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..c5b8eaf --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,31 @@ +{ + "name": "@tinywaste/api", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsup src/server.ts scripts/migrate.ts --format esm --sourcemap --clean --out-dir dist", + "start": "node dist/src/server.js", + "db:migrate:prod": "node dist/scripts/migrate.js", + "lint": "eslint src scripts --ext .ts", + "test": "vitest run", + "db:migrate": "tsx scripts/migrate.ts" + }, + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.1.0", + "@fastify/static": "^9.1.3", + "@tinywaste/content": "workspace:*", + "@tinywaste/game-core": "workspace:*", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.4.1", + "dotenv": "^17.2.3", + "drizzle-orm": "^0.44.7", + "fastify": "^5.6.1", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13" + } +} diff --git a/apps/api/scripts/migrate.ts b/apps/api/scripts/migrate.ts new file mode 100644 index 0000000..5ad1e8a --- /dev/null +++ b/apps/api/scripts/migrate.ts @@ -0,0 +1,54 @@ +import { mkdirSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import Database from 'better-sqlite3'; +import { config as loadDotenv } from 'dotenv'; + +loadDotenv({ + path: [resolve(process.cwd(), '.env'), resolve(process.cwd(), '../../.env')], +}); + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + throw new Error('DATABASE_URL is required.'); +} + +const dbPath = resolve(process.cwd(), databaseUrl); +mkdirSync(dirname(dbPath), { recursive: true }); + +const db = new Database(dbPath); +db.pragma('foreign_keys = ON'); +db.exec(` + CREATE TABLE IF NOT EXISTS _migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL + ); +`); + +const applied = new Set( + db + .prepare('SELECT name FROM _migrations') + .all() + .map((row) => String((row as { name: string }).name)), +); + +const migrationsDir = resolve(process.cwd(), 'migrations'); +const files = readdirSync(migrationsDir) + .filter((file) => file.endsWith('.sql')) + .sort(); + +for (const file of files) { + if (applied.has(file)) continue; + const sql = readFileSync(join(migrationsDir, file), 'utf8'); + db.exec('BEGIN'); + try { + db.exec(sql); + db.prepare('INSERT INTO _migrations (name, applied_at) VALUES (?, ?)').run(file, Date.now()); + db.exec('COMMIT'); + console.log(`Applied migration ${file}`); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + +db.close(); diff --git a/apps/api/src/features/auth/auth.repository.ts b/apps/api/src/features/auth/auth.repository.ts new file mode 100644 index 0000000..937b621 --- /dev/null +++ b/apps/api/src/features/auth/auth.repository.ts @@ -0,0 +1,51 @@ +import { and, eq, lt, or } from 'drizzle-orm'; +import type { db as database } from '../../shared/database/client'; +import { saveSlotsTable, sessionsTable, usersTable } from '../../shared/database/schema'; + +type DbClient = typeof database; + +export class AuthRepository { + constructor(private readonly db: DbClient) {} + + findUserByIdentifier(identifier: string) { + return this.db.query.usersTable.findFirst({ + where: or(eq(usersTable.email, identifier), eq(usersTable.username, identifier)), + }); + } + + findUserById(userId: string) { + return this.db.query.usersTable.findFirst({ + where: eq(usersTable.id, userId), + }); + } + + createUser(user: typeof usersTable.$inferInsert) { + this.db.insert(usersTable).values(user).run(); + return user; + } + + createSession(session: typeof sessionsTable.$inferInsert) { + this.db.insert(sessionsTable).values(session).run(); + return session; + } + + findSessionByTokenHash(tokenHash: string) { + return this.db.query.sessionsTable.findFirst({ + where: eq(sessionsTable.tokenHash, tokenHash), + }); + } + + deleteSessionByTokenHash(tokenHash: string) { + this.db.delete(sessionsTable).where(eq(sessionsTable.tokenHash, tokenHash)).run(); + } + + deleteExpiredSessions(now: number) { + this.db.delete(sessionsTable).where(lt(sessionsTable.expiresAt, now)).run(); + } + + getMainSaveSlot(userId: string) { + return this.db.query.saveSlotsTable.findFirst({ + where: and(eq(saveSlotsTable.userId, userId), eq(saveSlotsTable.slotKey, 'main')), + }); + } +} diff --git a/apps/api/src/features/auth/auth.routes.ts b/apps/api/src/features/auth/auth.routes.ts new file mode 100644 index 0000000..5883637 --- /dev/null +++ b/apps/api/src/features/auth/auth.routes.ts @@ -0,0 +1,28 @@ +import type { FastifyInstance } from 'fastify'; +import { loginSchema, registerSchema } from './auth.schemas'; +import { AuthService } from './auth.service'; + +export const registerAuthRoutes = (app: FastifyInstance, authService: AuthService) => { + app.get('/api/auth/me', async (request) => { + const user = await authService.getUserFromRequest(request); + return { user }; + }); + + app.post('/api/auth/register', async (request, reply) => { + const input = registerSchema.parse(request.body); + const user = await authService.register(input, reply); + return { user }; + }); + + app.post('/api/auth/login', async (request, reply) => { + const input = loginSchema.parse(request.body); + const user = await authService.login(input, reply); + return { user }; + }); + + app.post('/api/auth/logout', async (request, reply) => { + await authService.logout(request, reply); + return { ok: true }; + }); +}; + diff --git a/apps/api/src/features/auth/auth.schemas.ts b/apps/api/src/features/auth/auth.schemas.ts new file mode 100644 index 0000000..db457ea --- /dev/null +++ b/apps/api/src/features/auth/auth.schemas.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const registerSchema = z.object({ + email: z.string().email(), + username: z.string().trim().min(3).max(24), + password: z.string().min(8).max(128), +}); + +export const loginSchema = z.object({ + identifier: z.string().trim().min(3), + password: z.string().min(8).max(128), +}); + +export type RegisterInput = z.infer; +export type LoginInput = z.infer; + diff --git a/apps/api/src/features/auth/auth.service.ts b/apps/api/src/features/auth/auth.service.ts new file mode 100644 index 0000000..43212b7 --- /dev/null +++ b/apps/api/src/features/auth/auth.service.ts @@ -0,0 +1,132 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import bcrypt from 'bcryptjs'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { AppError } from '../../shared/errors'; +import { env } from '../../shared/env'; +import { AuthRepository } from './auth.repository'; +import type { LoginInput, RegisterInput } from './auth.schemas'; + +const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 14; + +export interface AuthUser { + id: string; + email: string; + username: string; +} + +const normalizeIdentifier = (value: string) => value.trim().toLowerCase(); +const hashSessionToken = (token: string) => createHash('sha256').update(token).digest('hex'); + +export class AuthService { + constructor(private readonly repository: AuthRepository) {} + + private sanitizeUser(user: { id: string; email: string; username: string }): AuthUser { + return { + id: user.id, + email: user.email, + username: user.username, + }; + } + + private issueSession(reply: FastifyReply, userId: string) { + this.repository.deleteExpiredSessions(Date.now()); + + const rawToken = randomBytes(32).toString('hex'); + const tokenHash = hashSessionToken(rawToken); + const now = Date.now(); + const session = { + id: randomUUID(), + userId, + tokenHash, + expiresAt: now + SESSION_TTL_MS, + createdAt: now, + }; + + this.repository.createSession(session); + reply.setCookie(env.sessionCookieName, rawToken, { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: SESSION_TTL_MS / 1000, + secure: false, + }); + } + + async register(input: RegisterInput, reply: FastifyReply) { + const email = normalizeIdentifier(input.email); + const username = input.username.trim(); + + const existing = await this.repository.findUserByIdentifier(email); + if (existing) { + throw new AppError(409, 'AUTH_EXISTS', '该邮箱或用户名已经被使用。'); + } + + const byUsername = await this.repository.findUserByIdentifier(username); + if (byUsername) { + throw new AppError(409, 'AUTH_EXISTS', '该邮箱或用户名已经被使用。'); + } + + const user = { + id: randomUUID(), + email, + username, + passwordHash: await bcrypt.hash(input.password, 10), + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + this.repository.createUser(user); + this.issueSession(reply, user.id); + return this.sanitizeUser(user); + } + + async login(input: LoginInput, reply: FastifyReply) { + const identifier = input.identifier.includes('@') + ? normalizeIdentifier(input.identifier) + : input.identifier.trim(); + const user = await this.repository.findUserByIdentifier(identifier); + if (!user) { + throw new AppError(401, 'AUTH_INVALID', '账号或密码不正确。'); + } + + const ok = await bcrypt.compare(input.password, user.passwordHash); + if (!ok) { + throw new AppError(401, 'AUTH_INVALID', '账号或密码不正确。'); + } + + this.issueSession(reply, user.id); + return this.sanitizeUser(user); + } + + async getUserFromRequest(request: FastifyRequest) { + const rawToken = request.cookies[env.sessionCookieName]; + if (!rawToken) return null; + + const session = await this.repository.findSessionByTokenHash(hashSessionToken(rawToken)); + if (!session) return null; + + if (session.expiresAt < Date.now()) { + this.repository.deleteSessionByTokenHash(session.tokenHash); + return null; + } + + const user = await this.repository.findUserById(session.userId); + return user ? this.sanitizeUser(user) : null; + } + + async requireUser(request: FastifyRequest) { + const user = await this.getUserFromRequest(request); + if (!user) { + throw new AppError(401, 'AUTH_REQUIRED', '请先登录。'); + } + return user; + } + + async logout(request: FastifyRequest, reply: FastifyReply) { + const rawToken = request.cookies[env.sessionCookieName]; + if (rawToken) { + this.repository.deleteSessionByTokenHash(hashSessionToken(rawToken)); + } + reply.clearCookie(env.sessionCookieName, { path: '/' }); + } +} diff --git a/apps/api/src/features/game/game.repository.ts b/apps/api/src/features/game/game.repository.ts new file mode 100644 index 0000000..09f1ad8 --- /dev/null +++ b/apps/api/src/features/game/game.repository.ts @@ -0,0 +1,48 @@ +import { and, eq } from 'drizzle-orm'; +import type { db as database } from '../../shared/database/client'; +import { saveSlotsTable } from '../../shared/database/schema'; + +type DbClient = typeof database; + +export class GameRepository { + constructor(private readonly db: DbClient) {} + + getMainSlot(userId: string) { + return this.db.query.saveSlotsTable.findFirst({ + where: and(eq(saveSlotsTable.userId, userId), eq(saveSlotsTable.slotKey, 'main')), + }); + } + + async upsertMainSlot(userId: string, stateJson: string, label: string) { + const existing = await this.getMainSlot(userId); + const now = Date.now(); + + if (existing) { + this.db + .update(saveSlotsTable) + .set({ + stateJson, + label, + revision: existing.revision + 1, + updatedAt: now, + }) + .where(eq(saveSlotsTable.id, existing.id)) + .run(); + return await this.getMainSlot(userId); + } + + const created = { + id: crypto.randomUUID(), + userId, + slotKey: 'main', + label, + stateJson, + revision: 1, + createdAt: now, + updatedAt: now, + }; + + this.db.insert(saveSlotsTable).values(created).run(); + return await this.getMainSlot(userId); + } +} diff --git a/apps/api/src/features/game/game.routes.ts b/apps/api/src/features/game/game.routes.ts new file mode 100644 index 0000000..80240c6 --- /dev/null +++ b/apps/api/src/features/game/game.routes.ts @@ -0,0 +1,27 @@ +import type { FastifyInstance } from 'fastify'; +import { AuthService } from '../auth/auth.service'; +import { createGameSchema, gameActionSchema } from './game.schemas'; +import { GameService } from './game.service'; + +export const registerGameRoutes = ( + app: FastifyInstance, + authService: AuthService, + gameService: GameService, +) => { + app.get('/api/game/state', async (request) => { + const user = await authService.requireUser(request); + return await gameService.getState(user.id); + }); + + app.post('/api/game/new', async (request) => { + const user = await authService.requireUser(request); + const input = createGameSchema.parse(request.body); + return { view: await gameService.createGame(user.id, input) }; + }); + + app.post('/api/game/action', async (request) => { + const user = await authService.requireUser(request); + const action = gameActionSchema.parse(request.body); + return { view: await gameService.applyAction(user.id, action) }; + }); +}; diff --git a/apps/api/src/features/game/game.schemas.ts b/apps/api/src/features/game/game.schemas.ts new file mode 100644 index 0000000..a22dd40 --- /dev/null +++ b/apps/api/src/features/game/game.schemas.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const createGameSchema = z.object({ + playerName: z.string().trim().min(2).max(24), +}); + +export const gameActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('travel'), toPlaceId: z.string().min(1) }), + z.object({ type: z.literal('perform-action'), actionId: z.string().min(1) }), + z.object({ type: z.literal('select-event-option'), optionId: z.string().min(1) }), + z.object({ + type: z.literal('combat'), + move: z.enum(['attack', 'defend', 'advance', 'retreat', 'escape']), + }), + z.object({ type: z.literal('craft'), recipeId: z.string().min(1) }), + z.object({ type: z.literal('use-item'), itemId: z.string().min(1) }), + z.object({ type: z.literal('equip-item'), itemId: z.string().min(1) }), + z.object({ type: z.literal('unequip-item'), slot: z.enum(['weapon', 'body', 'tool']) }), + z.object({ type: z.literal('trade'), offerId: z.string().min(1) }), + z.object({ type: z.literal('rest'), minutes: z.number().int().min(10).max(240) }), +]); + +export type CreateGameInput = z.infer; +export type GameActionInput = z.infer; + diff --git a/apps/api/src/features/game/game.service.ts b/apps/api/src/features/game/game.service.ts new file mode 100644 index 0000000..ef66ac9 --- /dev/null +++ b/apps/api/src/features/game/game.service.ts @@ -0,0 +1,49 @@ +import { + applyGameAction, + buildGameView, + createNewGameState, + type GameState, +} from '@tinywaste/game-core'; +import { gameContent } from '@tinywaste/content'; +import { AppError } from '../../shared/errors'; +import { GameRepository } from './game.repository'; +import type { CreateGameInput, GameActionInput } from './game.schemas'; + +export class GameService { + constructor(private readonly repository: GameRepository) {} + + async getState(userId: string) { + const slot = await this.repository.getMainSlot(userId); + if (!slot) { + return { hasSave: false as const, view: null }; + } + + const state = JSON.parse(slot.stateJson) as GameState; + return { + hasSave: true as const, + view: buildGameView(state, gameContent), + }; + } + + async createGame(userId: string, input: CreateGameInput) { + const state = createNewGameState(gameContent, input.playerName.trim()); + await this.repository.upsertMainSlot( + userId, + JSON.stringify(state), + `${input.playerName.trim()} 的进度`, + ); + return buildGameView(state, gameContent); + } + + async applyAction(userId: string, action: GameActionInput) { + const slot = await this.repository.getMainSlot(userId); + if (!slot) { + throw new AppError(404, 'GAME_NOT_FOUND', '还没有创建游戏进度。'); + } + + const state = JSON.parse(slot.stateJson) as GameState; + const result = applyGameAction(state, gameContent, action); + await this.repository.upsertMainSlot(userId, JSON.stringify(result.state), slot.label); + return buildGameView(result.state, gameContent); + } +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..ebea549 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,120 @@ +import { existsSync } from 'node:fs'; +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import cors from '@fastify/cors'; +import fastifyStatic from '@fastify/static'; +import { ZodError } from 'zod'; +import { db } from './shared/database/client'; +import { env } from './shared/env'; +import { formatZodError, isAppError } from './shared/errors'; +import { AuthRepository } from './features/auth/auth.repository'; +import { AuthService } from './features/auth/auth.service'; +import { registerAuthRoutes } from './features/auth/auth.routes'; +import { GameRepository } from './features/game/game.repository'; +import { GameService } from './features/game/game.service'; +import { registerGameRoutes } from './features/game/game.routes'; + +const app = Fastify({ logger: true }); + +await app.register(cookie, { + secret: env.sessionSecret, +}); + +await app.register(cors, { + origin: env.webOrigin, + credentials: true, +}); + +const authRepository = new AuthRepository(db); +const authService = new AuthService(authRepository); +const gameRepository = new GameRepository(db); +const gameService = new GameService(gameRepository); + +app.get('/api/health', async () => ({ + ok: true, + service: 'tinywaste-api', +})); + +registerAuthRoutes(app, authService); +registerGameRoutes(app, authService, gameService); + +const webDistAvailable = existsSync(env.webDistPath); + +if (webDistAvailable) { + await app.register(fastifyStatic, { + root: env.webDistPath, + prefix: '/', + maxAge: '30d', + immutable: true, + }); + + app.get('/', async (_request, reply) => + reply.sendFile('index.html', { + maxAge: 0, + immutable: false, + }), + ); + + app.setNotFoundHandler((request, reply) => { + const acceptsHtml = request.headers.accept?.includes('text/html'); + + if (request.method === 'GET' && acceptsHtml && !request.url.startsWith('/api/')) { + return reply.sendFile('index.html', { + maxAge: 0, + immutable: false, + }); + } + + return reply.status(404).send({ + error: { + code: 'NOT_FOUND', + message: '请求的资源不存在。', + }, + }); + }); +} + +app.setErrorHandler((error, _request, reply) => { + if (isAppError(error)) { + reply.status(error.statusCode).send({ + error: { + code: error.code, + message: error.message, + }, + }); + return; + } + + if (error instanceof ZodError) { + reply.status(400).send({ + error: { + code: 'VALIDATION_ERROR', + message: formatZodError(error), + }, + }); + return; + } + + app.log.error(error); + reply.status(500).send({ + error: { + code: 'INTERNAL_ERROR', + message: '服务器内部错误,请稍后再试。', + }, + }); +}); + +const start = async () => { + try { + await app.listen({ port: env.port, host: '0.0.0.0' }); + app.log.info(`API running on http://localhost:${env.port}`); + if (webDistAvailable) { + app.log.info(`Web bundle served from ${env.webDistPath}`); + } + } catch (error) { + app.log.error(error); + process.exit(1); + } +}; + +await start(); diff --git a/apps/api/src/shared/database/client.ts b/apps/api/src/shared/database/client.ts new file mode 100644 index 0000000..714574f --- /dev/null +++ b/apps/api/src/shared/database/client.ts @@ -0,0 +1,11 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { env } from '../env'; +import * as schema from './schema'; + +const sqlite = new Database(env.databasePath); +sqlite.pragma('foreign_keys = ON'); + +export const db = drizzle(sqlite, { schema }); +export { sqlite, schema }; + diff --git a/apps/api/src/shared/database/schema.ts b/apps/api/src/shared/database/schema.ts new file mode 100644 index 0000000..37cdc50 --- /dev/null +++ b/apps/api/src/shared/database/schema.ts @@ -0,0 +1,53 @@ +import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +export const usersTable = sqliteTable( + 'users', + { + id: text('id').primaryKey(), + email: text('email').notNull(), + username: text('username').notNull(), + passwordHash: text('password_hash').notNull(), + createdAt: integer('created_at', { mode: 'number' }).notNull(), + updatedAt: integer('updated_at', { mode: 'number' }).notNull(), + }, + (table) => ({ + usersEmailUnique: uniqueIndex('users_email_unique').on(table.email), + usersUsernameUnique: uniqueIndex('users_username_unique').on(table.username), + }), +); + +export const sessionsTable = sqliteTable( + 'sessions', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade' }), + tokenHash: text('token_hash').notNull(), + expiresAt: integer('expires_at', { mode: 'number' }).notNull(), + createdAt: integer('created_at', { mode: 'number' }).notNull(), + }, + (table) => ({ + sessionsTokenHashUnique: uniqueIndex('sessions_token_hash_unique').on(table.tokenHash), + }), +); + +export const saveSlotsTable = sqliteTable( + 'save_slots', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade' }), + slotKey: text('slot_key').notNull(), + label: text('label').notNull(), + stateJson: text('state_json').notNull(), + revision: integer('revision', { mode: 'number' }).notNull(), + createdAt: integer('created_at', { mode: 'number' }).notNull(), + updatedAt: integer('updated_at', { mode: 'number' }).notNull(), + }, + (table) => ({ + saveSlotsUserSlotUnique: uniqueIndex('save_slots_user_slot_unique').on(table.userId, table.slotKey), + }), +); + diff --git a/apps/api/src/shared/env.ts b/apps/api/src/shared/env.ts new file mode 100644 index 0000000..03b4bf5 --- /dev/null +++ b/apps/api/src/shared/env.ts @@ -0,0 +1,35 @@ +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { config as loadDotenv } from 'dotenv'; +import { z } from 'zod'; + +loadDotenv({ + path: [resolve(process.cwd(), '.env'), resolve(process.cwd(), '../../.env')], +}); + +const envSchema = z.object({ + API_PORT: z.coerce.number().int().positive().default(3001), + WEB_ORIGIN: z.string().url().default('http://localhost:5173'), + WEB_DIST_DIR: z.string().min(1).default('../web/dist'), + SESSION_COOKIE_NAME: z.string().min(3).default('tinywaste_session'), + SESSION_SECRET: z.string().min(16), + DATABASE_URL: z.string().min(1).default('./data/tinywaste.db'), +}); + +const parsed = envSchema.safeParse(process.env); +if (!parsed.success) { + throw new Error(`Invalid environment: ${parsed.error.message}`); +} + +const databasePath = resolve(process.cwd(), parsed.data.DATABASE_URL); +const webDistPath = resolve(process.cwd(), parsed.data.WEB_DIST_DIR); +mkdirSync(dirname(databasePath), { recursive: true }); + +export const env = { + port: parsed.data.API_PORT, + webOrigin: parsed.data.WEB_ORIGIN, + webDistPath, + sessionCookieName: parsed.data.SESSION_COOKIE_NAME, + sessionSecret: parsed.data.SESSION_SECRET, + databasePath, +}; diff --git a/apps/api/src/shared/errors.test.ts b/apps/api/src/shared/errors.test.ts new file mode 100644 index 0000000..d31e3fa --- /dev/null +++ b/apps/api/src/shared/errors.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { loginSchema, registerSchema } from '../features/auth/auth.schemas'; +import { formatZodError } from './errors'; + +describe('formatZodError', () => { + it('returns a friendly message for short passwords', () => { + const result = loginSchema.safeParse({ + identifier: 'tester', + password: '123', + }); + + expect(result.success).toBe(false); + if (result.success) return; + + expect(formatZodError(result.error)).toBe('密码 至少需要 8 个字符。'); + }); + + it('returns a friendly message for invalid email input', () => { + const result = registerSchema.safeParse({ + email: 'not-an-email', + username: 'tester', + password: '12345678', + }); + + expect(result.success).toBe(false); + if (result.success) return; + + expect(formatZodError(result.error)).toBe('请输入有效的邮箱地址。'); + }); +}); diff --git a/apps/api/src/shared/errors.ts b/apps/api/src/shared/errors.ts new file mode 100644 index 0000000..9675ce7 --- /dev/null +++ b/apps/api/src/shared/errors.ts @@ -0,0 +1,72 @@ +import { ZodError } from 'zod'; + +export class AppError extends Error { + constructor( + public readonly statusCode: number, + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'AppError'; + } +} + +export const isAppError = (value: unknown): value is AppError => value instanceof AppError; + +const FIELD_LABELS: Record = { + email: '邮箱', + username: '用户名', + identifier: '邮箱或用户名', + password: '密码', + playerName: '幸存者代号', + actionId: '动作', + toPlaceId: '目标地点', + optionId: '事件选项', + recipeId: '配方', + itemId: '物品', + slot: '装备槽位', + offerId: '交易项', + minutes: '休息时长', + move: '战斗动作', +}; + +const resolveFieldLabel = (path: Array) => { + if (path.length === 0) return '提交内容'; + const key = String(path[path.length - 1]); + return FIELD_LABELS[key] ?? key; +}; + +export const formatZodError = (error: ZodError) => { + const issue = error.issues[0]; + if (!issue) { + return '请求参数不合法。'; + } + + const fieldLabel = resolveFieldLabel(issue.path); + + switch (issue.code) { + case 'invalid_type': + return `${fieldLabel} 类型不正确。`; + case 'too_small': + if (issue.origin === 'string') { + return `${fieldLabel} 至少需要 ${issue.minimum} 个字符。`; + } + return `${fieldLabel} 不能小于 ${issue.minimum}。`; + case 'too_big': + if (issue.origin === 'string') { + return `${fieldLabel} 不能超过 ${issue.maximum} 个字符。`; + } + return `${fieldLabel} 不能大于 ${issue.maximum}。`; + case 'invalid_format': + if (issue.format === 'email') { + return '请输入有效的邮箱地址。'; + } + return `${fieldLabel} 格式不正确。`; + case 'invalid_value': + return `${fieldLabel} 的值不在允许范围内。`; + default: + return issue.message && issue.message !== 'Invalid input' + ? `${fieldLabel}:${issue.message}` + : `${fieldLabel} 不合法。`; + } +}; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..7031e0e --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src", "scripts"] +} + diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..d5b608d --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + scaffold + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..a861b5d --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,37 @@ +{ + "name": "@tinywaste/web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@tanstack/react-query": "^5.90.5", + "@tinywaste/content": "workspace:*", + "@tinywaste/game-core": "workspace:*", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-router-dom": "^7.9.6", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10", + "vitest": "^4.0.7" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/generated/city-edge.png b/apps/web/public/generated/city-edge.png new file mode 100644 index 0000000..f4aecc4 Binary files /dev/null and b/apps/web/public/generated/city-edge.png differ diff --git a/apps/web/public/generated/home-camp.png b/apps/web/public/generated/home-camp.png new file mode 100644 index 0000000..3d925dc Binary files /dev/null and b/apps/web/public/generated/home-camp.png differ diff --git a/apps/web/public/generated/old-store.png b/apps/web/public/generated/old-store.png new file mode 100644 index 0000000..05c658d Binary files /dev/null and b/apps/web/public/generated/old-store.png differ diff --git a/apps/web/public/generated/rain-farm.png b/apps/web/public/generated/rain-farm.png new file mode 100644 index 0000000..4bab32a Binary files /dev/null and b/apps/web/public/generated/rain-farm.png differ diff --git a/apps/web/public/generated/roadside.png b/apps/web/public/generated/roadside.png new file mode 100644 index 0000000..b3103c1 Binary files /dev/null and b/apps/web/public/generated/roadside.png differ diff --git a/apps/web/public/generated/sewer.png b/apps/web/public/generated/sewer.png new file mode 100644 index 0000000..ba40f0c Binary files /dev/null and b/apps/web/public/generated/sewer.png differ diff --git a/apps/web/public/generated/subway-entrance.png b/apps/web/public/generated/subway-entrance.png new file mode 100644 index 0000000..f28a504 Binary files /dev/null and b/apps/web/public/generated/subway-entrance.png differ diff --git a/apps/web/public/generated/ui/character-preview.png b/apps/web/public/generated/ui/character-preview.png new file mode 100644 index 0000000..56752ec Binary files /dev/null and b/apps/web/public/generated/ui/character-preview.png differ diff --git a/apps/web/public/generated/ui/equipment-atlas.png b/apps/web/public/generated/ui/equipment-atlas.png new file mode 100644 index 0000000..e5238ea Binary files /dev/null and b/apps/web/public/generated/ui/equipment-atlas.png differ diff --git a/apps/web/public/generated/ui/equipment/backpack.png b/apps/web/public/generated/ui/equipment/backpack.png new file mode 100644 index 0000000..9a62ba2 Binary files /dev/null and b/apps/web/public/generated/ui/equipment/backpack.png differ diff --git a/apps/web/public/generated/ui/equipment/body-coat.png b/apps/web/public/generated/ui/equipment/body-coat.png new file mode 100644 index 0000000..717a2e7 Binary files /dev/null and b/apps/web/public/generated/ui/equipment/body-coat.png differ diff --git a/apps/web/public/generated/ui/equipment/crowbar.png b/apps/web/public/generated/ui/equipment/crowbar.png new file mode 100644 index 0000000..4735102 Binary files /dev/null and b/apps/web/public/generated/ui/equipment/crowbar.png differ diff --git a/apps/web/public/generated/ui/equipment/head-wrap.png b/apps/web/public/generated/ui/equipment/head-wrap.png new file mode 100644 index 0000000..8f82156 Binary files /dev/null and b/apps/web/public/generated/ui/equipment/head-wrap.png differ diff --git a/apps/web/public/generated/ui/equipment/pipe-rifle.png b/apps/web/public/generated/ui/equipment/pipe-rifle.png new file mode 100644 index 0000000..b126ef4 Binary files /dev/null and b/apps/web/public/generated/ui/equipment/pipe-rifle.png differ diff --git a/apps/web/public/generated/ui/equipment/shiv.png b/apps/web/public/generated/ui/equipment/shiv.png new file mode 100644 index 0000000..02f48a2 Binary files /dev/null and b/apps/web/public/generated/ui/equipment/shiv.png differ diff --git a/apps/web/public/generated/ui/item-atlas.png b/apps/web/public/generated/ui/item-atlas.png new file mode 100644 index 0000000..644b727 Binary files /dev/null and b/apps/web/public/generated/ui/item-atlas.png differ diff --git a/apps/web/public/generated/ui/items/canned-beans.png b/apps/web/public/generated/ui/items/canned-beans.png new file mode 100644 index 0000000..3c2f7b3 Binary files /dev/null and b/apps/web/public/generated/ui/items/canned-beans.png differ diff --git a/apps/web/public/generated/ui/items/cloth-rag.png b/apps/web/public/generated/ui/items/cloth-rag.png new file mode 100644 index 0000000..55a44b2 Binary files /dev/null and b/apps/web/public/generated/ui/items/cloth-rag.png differ diff --git a/apps/web/public/generated/ui/items/crowbar.png b/apps/web/public/generated/ui/items/crowbar.png new file mode 100644 index 0000000..41b60ed Binary files /dev/null and b/apps/web/public/generated/ui/items/crowbar.png differ diff --git a/apps/web/public/generated/ui/items/herb-bundle.png b/apps/web/public/generated/ui/items/herb-bundle.png new file mode 100644 index 0000000..f7d8cd8 Binary files /dev/null and b/apps/web/public/generated/ui/items/herb-bundle.png differ diff --git a/apps/web/public/generated/ui/items/leather-coat.png b/apps/web/public/generated/ui/items/leather-coat.png new file mode 100644 index 0000000..494e680 Binary files /dev/null and b/apps/web/public/generated/ui/items/leather-coat.png differ diff --git a/apps/web/public/generated/ui/items/mushroom-cluster.png b/apps/web/public/generated/ui/items/mushroom-cluster.png new file mode 100644 index 0000000..d0db40f Binary files /dev/null and b/apps/web/public/generated/ui/items/mushroom-cluster.png differ diff --git a/apps/web/public/generated/ui/items/parts-wire.png b/apps/web/public/generated/ui/items/parts-wire.png new file mode 100644 index 0000000..a3255a4 Binary files /dev/null and b/apps/web/public/generated/ui/items/parts-wire.png differ diff --git a/apps/web/public/generated/ui/items/rad-pills.png b/apps/web/public/generated/ui/items/rad-pills.png new file mode 100644 index 0000000..fe39c73 Binary files /dev/null and b/apps/web/public/generated/ui/items/rad-pills.png differ diff --git a/apps/web/public/generated/ui/items/scrap-metal.png b/apps/web/public/generated/ui/items/scrap-metal.png new file mode 100644 index 0000000..e3db6f3 Binary files /dev/null and b/apps/web/public/generated/ui/items/scrap-metal.png differ diff --git a/apps/web/public/generated/ui/items/shiv.png b/apps/web/public/generated/ui/items/shiv.png new file mode 100644 index 0000000..e591344 Binary files /dev/null and b/apps/web/public/generated/ui/items/shiv.png differ diff --git a/apps/web/public/generated/ui/items/water-dirty.png b/apps/web/public/generated/ui/items/water-dirty.png new file mode 100644 index 0000000..969d76f Binary files /dev/null and b/apps/web/public/generated/ui/items/water-dirty.png differ diff --git a/apps/web/public/generated/ui/items/water-purified.png b/apps/web/public/generated/ui/items/water-purified.png new file mode 100644 index 0000000..4e75bb0 Binary files /dev/null and b/apps/web/public/generated/ui/items/water-purified.png differ diff --git a/apps/web/public/generated/vault-gate.png b/apps/web/public/generated/vault-gate.png new file mode 100644 index 0000000..3d965b9 Binary files /dev/null and b/apps/web/public/generated/vault-gate.png differ diff --git a/apps/web/public/generated/village-market.png b/apps/web/public/generated/village-market.png new file mode 100644 index 0000000..2f0a785 Binary files /dev/null and b/apps/web/public/generated/village-market.png differ diff --git a/apps/web/public/icons.svg b/apps/web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/apps/web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/App.css b/apps/web/src/App.css new file mode 100644 index 0000000..4cd6c6b --- /dev/null +++ b/apps/web/src/App.css @@ -0,0 +1,1763 @@ +:root { + --bg: #070604; + --bg-soft: #0d0b09; + --panel: rgba(16, 13, 10, 0.94); + --panel-strong: rgba(13, 11, 8, 0.98); + --panel-border: rgba(166, 114, 53, 0.38); + --panel-border-soft: rgba(255, 255, 255, 0.08); + --text: #efe2ce; + --muted: #a38f73; + --accent: #d58b36; + --accent-soft: rgba(213, 139, 54, 0.16); + --safe: #8bc96d; + --warn: #e0b466; + --danger: #e16d4c; + --line: rgba(255, 255, 255, 0.08); + --shadow: 0 24px 70px rgba(0, 0, 0, 0.5); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + min-width: 320px; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(201, 132, 57, 0.12), transparent 24%), + radial-gradient(circle at bottom right, rgba(117, 88, 49, 0.14), transparent 30%), + linear-gradient(180deg, #050404 0%, #090806 48%, #050404 100%); + overflow: hidden; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.18; + background: + linear-gradient(rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)), + radial-gradient(circle at 20% 20%, rgba(213, 139, 54, 0.08), transparent 16%), + radial-gradient(circle at 80% 70%, rgba(213, 139, 54, 0.06), transparent 18%); +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; +} + +img { + display: block; + max-width: 100%; +} + +button, +input, +.panel, +.subpanel, +.auth-panel, +.auth-hero, +.new-game-card, +.loading-card, +.route-card, +.action-card, +.trade-card, +.recipe-card, +.inventory-card, +.quest-card, +.quick-slot, +.stat-meter, +.hud-chip, +.command-dock, +.top-hud, +.brand-block, +.clock-block, +.route-summary-card, +.loadout-slot, +.character-figure, +.objective-card, +.mini-intel-card, +.attribute-card, +.effect-card, +.map-node-card { + border: 1px solid var(--panel-border-soft); +} + +.top-hud, +.panel, +.subpanel, +.auth-panel, +.auth-hero, +.new-game-card, +.loading-card, +.command-dock { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)), + var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.app-shell { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 0.75rem; + min-height: 100dvh; + padding: 0.75rem; + overflow: hidden; +} + +.panel, +.subpanel { + min-height: 0; + overflow: hidden; + border-radius: 24px; + border-color: var(--panel-border); +} + +.panel { + padding: 0.75rem; +} + +.subpanel { + padding: 0.75rem; +} + +.panel-header { + display: flex; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.7rem; +} + +.panel-header h2 { + margin: 0; + font-size: 0.9rem; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.panel-header p { + margin: 0.25rem 0 0; + color: var(--muted); + font-size: 0.76rem; + line-height: 1.45; +} + +.eyebrow, +.intel-kicker { + margin: 0; + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.error-copy { + margin: 0; + color: #f08d73; + line-height: 1.45; +} + +.primary-button, +.secondary-button, +.small-button, +.rest-button, +.combat-button, +.modal-option, +.panel-tabs button, +.command-tabs button, +.dock-tab { + border-radius: 14px; + transition: transform 150ms ease, opacity 150ms ease, background 150ms ease, border-color 150ms ease; +} + +.primary-button:hover, +.secondary-button:hover, +.small-button:hover, +.rest-button:hover, +.combat-button:hover, +.modal-option:hover, +.panel-tabs button:hover, +.command-tabs button:hover, +.dock-tab:hover { + transform: translateY(-1px); +} + +.primary-button { + min-height: 48px; + border: 1px solid rgba(228, 158, 75, 0.42); + background: linear-gradient(135deg, #eeab52, #c86d22); + color: #160f08; + font-weight: 700; +} + +.secondary-button, +.rest-button, +.combat-button, +.modal-option, +.small-button.secondary, +.panel-tabs button, +.command-tabs button, +.dock-tab { + min-height: 42px; + border: 1px solid var(--panel-border-soft); + background: rgba(255, 255, 255, 0.04); + color: var(--text); +} + +.small-button { + min-height: 34px; + border: 1px solid rgba(215, 138, 51, 0.34); + background: rgba(215, 138, 51, 0.13); + color: var(--text); +} + +.primary-button:disabled, +.secondary-button:disabled, +.small-button:disabled, +.rest-button:disabled, +.combat-button:disabled, +.modal-option:disabled, +.panel-tabs button:disabled, +.command-tabs button:disabled, +.dock-tab:disabled { + opacity: 0.72; + transform: none; +} + +.hud-chip { + display: inline-flex; + align-items: center; + min-height: 2.15rem; + padding: 0 0.8rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.03); + color: rgba(239, 226, 206, 0.8); + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.hud-chip.safe { + color: #addb7f; + border-color: rgba(141, 194, 106, 0.28); + background: rgba(141, 194, 106, 0.08); +} + +.hud-chip.accent { + color: #f0b55a; + border-color: rgba(215, 138, 51, 0.34); + background: rgba(215, 138, 51, 0.12); +} + +.hud-chip.muted { + color: var(--muted); +} + +.asset-thumb { + display: grid; + place-items: center; + width: 72px; + min-width: 72px; + aspect-ratio: 1; + overflow: hidden; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)), + rgba(8, 7, 6, 0.72); +} + +.asset-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.asset-thumb span { + color: var(--accent); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.12em; +} + +.top-hud { + display: grid; + grid-template-columns: 280px 110px minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: stretch; + border-color: var(--panel-border); + border-radius: 22px; + padding: 0.65rem; +} + +.brand-block, +.clock-block { + border-radius: 16px; + background: rgba(11, 10, 8, 0.78); +} + +.brand-block { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0 1rem; +} + +.brand-mark { + display: grid; + place-items: center; + width: 52px; + height: 52px; + border-radius: 14px; + border: 1px solid rgba(215, 138, 51, 0.32); + background: rgba(215, 138, 51, 0.11); + color: var(--accent); + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.18em; +} + +.brand-copy strong, +.clock-block strong { + display: block; + font-size: 1.05rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.clock-block { + display: grid; + align-content: center; + justify-items: center; + padding: 0.5rem; + color: var(--muted); +} + +.clock-block span { + font-size: 0.72rem; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.status-strip { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0.55rem; + min-width: 0; +} + +.stat-meter { + min-width: 0; + border-radius: 16px; + background: rgba(8, 7, 6, 0.72); + padding: 0.65rem 0.75rem; +} + +.stat-meter > div:first-child { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.stat-meter span { + min-width: 0; + color: var(--muted); + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.stat-meter strong { + display: inline-flex; + align-items: baseline; + gap: 0.12rem; + font-size: 1rem; + white-space: nowrap; +} + +.stat-meter strong small { + color: var(--muted); + font-size: 0.68rem; +} + +.meter-track { + height: 7px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); +} + +.meter-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #f2b55e, #df7a27); +} + +.tone-health .meter-fill { + background: linear-gradient(90deg, #9dda74, #53b96f); +} + +.tone-danger .meter-fill { + background: linear-gradient(90deg, #f4cc76, #eb6650); +} + +.top-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.45rem; + flex-wrap: wrap; +} + +.game-grid { + min-height: 0; + display: grid; + grid-template-columns: 304px minmax(0, 1fr) 396px; + gap: 0.75rem; +} + +.panel-route { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) minmax(0, 0.95fr); + gap: 0.75rem; +} + +.route-summary-card { + padding: 1rem; + border-radius: 18px; + border-color: rgba(215, 138, 51, 0.32); + background: + linear-gradient(180deg, rgba(215, 138, 51, 0.12), rgba(255, 255, 255, 0)), + rgba(7, 7, 7, 0.46); +} + +.route-summary-card h2, +.viewport-copy h2, +.auth-hero-copy h1, +.new-game-copy h1 { + margin: 0.42rem 0 0; + font-size: clamp(1.55rem, 3.4vw, 3.8rem); + line-height: 0.96; + letter-spacing: -0.05em; + text-wrap: balance; +} + +.route-summary-card h2 { + font-size: clamp(1.9rem, 3vw, 2.8rem); +} + +.route-summary-card p, +.viewport-copy p, +.auth-hero-copy p, +.new-game-copy p, +.modal-copy, +.route-card p, +.action-card p, +.trade-card p, +.recipe-card p, +.inventory-card p, +.quest-card p, +.mini-intel-card p, +.objective-card p, +.effect-card p { + color: rgba(239, 226, 206, 0.78); + line-height: 1.5; +} + +.route-summary-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.55rem; + margin-top: 0.85rem; +} + +.route-summary-meta div, +.combat-health, +.mini-intel-card { + padding: 0.78rem; + border-radius: 16px; + background: rgba(255, 255, 255, 0.04); +} + +.route-summary-meta small { + display: block; + margin-bottom: 0.25rem; + color: var(--muted); + font-size: 0.66rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.route-summary-meta strong { + font-size: 0.94rem; +} + +.intel-meta { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.85rem; +} + +.route-list, +.map-node-grid, +.action-grid, +.trade-list, +.recipe-list, +.log-list, +.objective-list, +.inventory-list, +.quest-list, +.loadout-scroll, +.combat-log, +.modal-option-list { + min-height: 0; + overflow: auto; + padding-right: 0.2rem; +} + +.route-list, +.map-node-grid, +.trade-list, +.recipe-list, +.log-list, +.objective-list, +.inventory-list, +.quest-list, +.loadout-scroll, +.combat-log, +.modal-option-list { + display: grid; + gap: 0.55rem; +} + +.route-card, +.action-card, +.trade-card, +.recipe-card, +.inventory-card, +.quest-card, +.quick-slot, +.objective-card, +.loadout-slot, +.attribute-card, +.effect-card, +.map-node-card { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)), + rgba(9, 8, 7, 0.52); + color: var(--text); + border-radius: 16px; +} + +.route-card { + display: grid; + grid-template-columns: 80px minmax(0, 1fr) auto; + gap: 0.7rem; + width: 100%; + padding: 0.65rem; + text-align: left; +} + +.route-card-thumb { + min-height: 88px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background-position: center; + background-size: cover; +} + +.route-card-copy strong, +.action-card strong, +.trade-card strong, +.recipe-card strong, +.inventory-card strong, +.quest-card strong, +.quick-slot strong, +.objective-card strong, +.slot-copy strong, +.attribute-card strong, +.effect-card strong, +.mini-intel-card strong { + font-size: 0.94rem; +} + +.route-card p, +.action-card p, +.trade-card p, +.recipe-card p, +.inventory-card p, +.objective-card p, +.mini-intel-card p { + margin: 0.35rem 0 0; + font-size: 0.82rem; +} + +.route-card-detail, +.action-card-meta, +.recipe-copy small, +.inventory-copy small, +.quick-slot-copy span { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + color: var(--muted); + font-size: 0.72rem; +} + +.route-card-state { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + min-width: 64px; + color: var(--muted); + font-size: 0.72rem; +} + +.route-card-state em, +.action-card em, +.trade-card em, +.recipe-actions em, +.inventory-card em { + color: #f1b675; + font-style: normal; +} + +.risk-dots { + display: flex; + gap: 0.28rem; + margin-top: 0.58rem; +} + +.risk-dots span { + width: 13px; + height: 13px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.03); +} + +.route-card.tone-safe .risk-dots span.active { + background: var(--safe); + border-color: rgba(141, 194, 106, 0.6); +} + +.route-card.tone-warn .risk-dots span.active { + background: var(--warn); + border-color: rgba(224, 180, 102, 0.6); +} + +.route-card.tone-danger .risk-dots span.active { + background: var(--danger); + border-color: rgba(225, 109, 76, 0.6); +} + +.world-map-panel { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} + +.map-node-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.map-node-card { + padding: 0.75rem; +} + +.map-node-card.current { + border-color: rgba(215, 138, 51, 0.46); + background: rgba(215, 138, 51, 0.11); +} + +.map-node-card span, +.action-kind, +.quest-card header span { + display: inline-flex; + align-items: center; + min-height: 1.75rem; + width: fit-content; + padding: 0 0.7rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.03); + color: rgba(239, 226, 206, 0.74); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.map-node-card small, +.trade-card small, +.action-card small, +.recipe-copy small, +.inventory-copy small, +.slot-copy small, +.attribute-card small { + display: block; + margin-top: 0.28rem; + color: var(--muted); + font-size: 0.72rem; +} + +.panel-command { + display: grid; + grid-template-rows: 248px auto minmax(0, 1fr); + gap: 0.75rem; +} + +.viewport-stage, +.auth-hero, +.new-game-card { + position: relative; + overflow: hidden; + border-radius: 20px; + background-color: #12100d; + background-position: center; + background-size: cover; +} + +.viewport-stage { + border: 1px solid rgba(215, 138, 51, 0.25); +} + +.viewport-copy, +.auth-hero-copy, +.new-game-copy { + position: relative; + z-index: 1; +} + +.viewport-copy { + display: flex; + flex-direction: column; + justify-content: flex-end; + height: 100%; + max-width: 36rem; + padding: 1rem; +} + +.viewport-copy h2 { + font-size: clamp(1.85rem, 3.1vw, 3.1rem); +} + +.viewport-copy p, +.auth-hero-copy p, +.new-game-copy p, +.modal-copy { + max-width: 42rem; +} + +.viewport-flags { + position: absolute; + top: 0.85rem; + right: 0.85rem; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.45rem; + z-index: 1; +} + +.viewport-flags span { + display: inline-flex; + align-items: center; + min-height: 1.9rem; + padding: 0 0.7rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + background: rgba(6, 6, 6, 0.34); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.command-error { + margin: 0; +} + +.command-surface { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 0.75rem; +} + +.command-tabs, +.panel-tabs { + display: flex; + gap: 0.45rem; +} + +.command-tabs button, +.panel-tabs button { + flex: 1; + justify-content: center; +} + +.command-tabs button.active, +.panel-tabs button.active, +.dock-tab.active { + border-color: rgba(215, 138, 51, 0.35); + background: rgba(215, 138, 51, 0.1); + color: var(--text); +} + +.command-tabs-static .active, +.panel-tabs .active { + pointer-events: none; +} + +.command-stage { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1.4fr) 320px; + gap: 0.75rem; +} + +.stage-main, +.command-side-column, +.mission-panel, +.rest-panel { + min-height: 0; + display: grid; +} + +.stage-main { + grid-template-rows: auto minmax(0, 1fr) auto minmax(0, 0.85fr); +} + +.action-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.6rem; +} + +.action-card, +.trade-card, +.recipe-card, +.inventory-card { + width: 100%; + text-align: left; + padding: 0.8rem; +} + +.action-card { + min-height: 108px; +} + +.action-card-top, +.inventory-title-row, +.objective-card, +.combat-health { + display: flex; + justify-content: space-between; + gap: 0.65rem; +} + +.trade-block { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + margin-top: 0.55rem; +} + +.action-card.utility { + border-color: rgba(215, 138, 51, 0.24); + background: + linear-gradient(180deg, rgba(215, 138, 51, 0.08), rgba(255, 255, 255, 0)), + rgba(9, 8, 7, 0.52); +} + +.trade-list.compact, +.recipe-list, +.inventory-list, +.quest-list { + align-content: start; +} + +.trade-card small, +.action-card small, +.recipe-actions em { + margin-top: 0.28rem; +} + +.command-side-column { + grid-template-rows: minmax(0, 0.9fr) minmax(0, 1fr); + gap: 0.75rem; +} + +.objective-panel, +.log-panel, +.intel-panel { + grid-template-rows: auto minmax(0, 1fr); +} + +.objective-card { + align-items: flex-start; + padding: 0.8rem; +} + +.objective-card.done { + border-color: rgba(141, 194, 106, 0.3); +} + +.objective-card span { + color: #f2bb6a; + font-size: 0.8rem; + font-weight: 600; +} + +.log-list.expanded { + gap: 0.2rem; +} + +.mini-intel-card strong, +.effect-card strong { + display: block; +} + +.intel-stack { + display: grid; + gap: 0.55rem; +} + +.panel-loadout { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 0.75rem; +} + +.loadout-scroll { + display: grid; + gap: 0.75rem; +} + +.loadout-matrix { + display: grid; + grid-template-columns: 124px minmax(0, 1fr) 124px; + gap: 0.75rem; + min-height: 370px; +} + +.slot-column { + display: grid; + gap: 0.6rem; + align-content: stretch; +} + +.loadout-slot { + display: grid; + gap: 0.55rem; + padding: 0.7rem; +} + +.slot-thumb { + width: 100%; + min-width: 0; + aspect-ratio: 1; +} + +.slot-copy span { + display: block; + margin-bottom: 0.26rem; + color: var(--muted); + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.loadout-slot.equipped { + border-color: rgba(215, 138, 51, 0.35); +} + +.character-figure { + position: relative; + overflow: hidden; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0.2)), + rgba(6, 6, 5, 0.9); +} + +.character-figure img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; +} + +.character-figure-overlay { + position: absolute; + right: 1rem; + bottom: 1rem; + padding: 0.65rem 0.75rem; + border-radius: 14px; + background: rgba(8, 8, 7, 0.74); + border: 1px solid rgba(255, 255, 255, 0.08); + text-align: right; +} + +.character-figure-overlay span { + display: block; + color: var(--muted); + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.character-meta-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0.75rem; +} + +.attribute-panel, +.effects-panel, +.quick-slot-panel { + grid-template-rows: auto minmax(0, 1fr); +} + +.attribute-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.5rem; +} + +.attribute-card { + padding: 0.75rem; + text-align: center; +} + +.attribute-card span { + display: block; + margin-bottom: 0.28rem; + color: var(--muted); + font-size: 0.68rem; + letter-spacing: 0.12em; +} + +.attribute-card strong { + font-size: 1.15rem; +} + +.effects-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +} + +.effect-card { + padding: 0.75rem; +} + +.effect-card p { + margin: 0.35rem 0 0; + font-size: 0.78rem; +} + +.effect-card.tone-good { + border-color: rgba(141, 194, 106, 0.24); +} + +.effect-card.tone-warn { + border-color: rgba(224, 180, 102, 0.24); +} + +.effect-card.tone-danger { + border-color: rgba(225, 109, 76, 0.24); +} + +.quick-slot-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.5rem; +} + +.quick-slot { + min-height: 118px; + padding: 0.65rem; + display: grid; + align-content: space-between; + gap: 0.45rem; +} + +.quick-slot .asset-thumb { + width: 100%; + min-width: 0; +} + +.quick-slot-copy span { + margin-top: 0.3rem; + color: #f1c27b; +} + +.quick-slot.locked { + place-items: center; + border-style: dashed; +} + +.quick-slot-lock { + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.14em; +} + +.inventory-card { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto; + align-items: start; + gap: 0.75rem; +} + +.inventory-card.equipped { + border-color: rgba(215, 138, 51, 0.35); +} + +.inventory-title-row span { + color: #f1c27b; +} + +.inventory-actions, +.recipe-actions, +.rest-actions, +.combat-actions, +.new-game-actions { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.recipe-card { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto; + gap: 0.75rem; +} + +.recipe-card.craftable { + border-color: rgba(141, 194, 106, 0.28); +} + +.recipe-actions { + align-content: start; +} + +.mission-panel, +.rest-panel { + grid-template-rows: auto minmax(0, 1fr); +} + +.quest-card { + padding: 0.75rem; +} + +.quest-card header { + display: flex; + justify-content: space-between; + gap: 0.65rem; + margin-bottom: 0.55rem; +} + +.quest-card header div small { + display: block; + margin-top: 0.25rem; + color: var(--muted); + font-size: 0.72rem; +} + +.quest-card.state-completed { + border-color: rgba(141, 194, 106, 0.32); +} + +.quest-steps { + display: grid; + gap: 0.32rem; +} + +.quest-steps span { + color: rgba(239, 226, 206, 0.76); + font-size: 0.78rem; +} + +.quest-steps .done { + color: #a8e288; +} + +.rest-actions { + display: grid; + grid-template-columns: 1fr; +} + +.panel-link-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +} + +.panel-link-button { + width: 100%; +} + +.log-entry { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.7rem; + padding: 0.72rem 0; + border-bottom: 1px solid var(--line); +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-entry span { + color: var(--muted); + font-size: 0.72rem; +} + +.log-entry p { + margin: 0; +} + +.tone-good p { + color: #b4e88f; +} + +.tone-warn p { + color: #f3c17a; +} + +.tone-bad p, +.tone-danger p { + color: #f38b74; +} + +.command-dock { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + gap: 0.75rem; + align-items: center; + border-radius: 18px; + border-color: var(--panel-border); + padding: 0.65rem 0.8rem; +} + +.operator-card { + display: flex; + align-items: center; + gap: 0.7rem; +} + +.operator-emblem { + display: grid; + place-items: center; + width: 46px; + height: 46px; + border-radius: 14px; + border: 1px solid rgba(215, 138, 51, 0.38); + background: rgba(215, 138, 51, 0.12); + color: var(--accent); + font-weight: 700; + font-size: 1rem; +} + +.operator-copy span { + display: block; + color: var(--muted); + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.dock-nav-groups { + display: grid; + gap: 0.55rem; +} + +.dock-tabs, +.dock-metrics { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.dock-tabs { + justify-content: center; +} + +.dock-tab, +.dock-metrics span { + display: inline-flex; + align-items: center; + min-height: 2.1rem; + padding: 0 0.85rem; + border-radius: 999px; + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.dock-metrics span { + border: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(255, 255, 255, 0.03); + color: var(--muted); +} + +.auth-shell, +.new-game-shell, +.loading-shell { + min-height: 100dvh; + display: grid; + place-items: center; + padding: 0.75rem; +} + +.auth-shell { + grid-template-columns: minmax(0, 1.42fr) minmax(340px, 430px); + gap: 0.75rem; +} + +.auth-hero, +.new-game-card { + min-height: calc(100dvh - 1.5rem); + padding: 1.4rem; + border-radius: 24px; + border-color: var(--panel-border); +} + +.auth-hero-copy, +.new-game-copy { + display: flex; + flex-direction: column; + justify-content: flex-end; + height: 100%; + max-width: 36rem; +} + +.auth-panel, +.loading-card { + border-radius: 24px; + border-color: var(--panel-border); +} + +.auth-panel { + width: 100%; + padding: 1.25rem; +} + +.auth-panel label, +.new-game-copy label { + display: grid; + gap: 0.35rem; + margin-bottom: 0.8rem; + color: var(--muted); +} + +.auth-panel input, +.new-game-copy input { + min-height: 48px; + padding: 0 0.95rem; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: var(--text); +} + +.tab-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem; + margin-bottom: 1rem; +} + +.tab-row button { + min-height: 42px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + color: var(--text); +} + +.tab-row button.active { + border-color: rgba(215, 138, 51, 0.36); + background: rgba(215, 138, 51, 0.1); +} + +.new-game-card { + display: grid; + align-items: end; +} + +.new-game-overlay { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(4, 4, 4, 0.12), rgba(4, 4, 4, 0.78)), + linear-gradient(90deg, rgba(215, 138, 51, 0.12), transparent 55%); +} + +.loading-card { + width: min(100%, 360px); + padding: 1.35rem; +} + +.loading-line { + width: 76px; + height: 6px; + margin-bottom: 1rem; + border-radius: 999px; + background: linear-gradient(90deg, #e7aa56, #c56c24); +} + +.modal-shell { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 1rem; + background: rgba(4, 4, 4, 0.8); + backdrop-filter: blur(8px); + z-index: 20; +} + +.modal-card { + width: min(100%, 760px); + border-radius: 22px; + border: 1px solid rgba(215, 138, 51, 0.24); + background: rgba(13, 11, 9, 0.97); + box-shadow: var(--shadow); + padding: 1rem; +} + +.modal-card-wide { + width: min(100%, 1180px); +} + +.modal-header span { + display: inline-flex; + align-items: center; + min-height: 1.75rem; + padding: 0 0.7rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.03); + color: rgba(239, 226, 206, 0.74); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.modal-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.modal-header-copy { + min-width: 0; +} + +.modal-header h3 { + margin: 0.25rem 0 0; + font-size: 1.5rem; +} + +.modal-close { + min-height: 36px; + padding: 0 0.8rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.modal-option { + width: 100%; + min-height: 60px; + text-align: left; + padding: 0.9rem; +} + +.modal-option span { + display: block; + margin-top: 0.35rem; + color: var(--muted); +} + +.combat-shell { + display: grid; + gap: 0.85rem; +} + +.combat-log { + max-height: 220px; +} + +.combat-log p { + margin: 0; + padding: 0.7rem 0.85rem; + border-radius: 14px; + background: rgba(255, 255, 255, 0.03); +} + +.combat-actions { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.5rem; +} + +.system-overlay { + display: grid; + gap: 0.75rem; +} + +.overlay-summary-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.55rem; +} + +.overlay-summary-card, +.overlay-note-card, +.overlay-loadout-row { + padding: 0.8rem; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); +} + +.overlay-summary-card span, +.overlay-loadout-row span { + display: block; + margin-bottom: 0.26rem; + color: var(--muted); + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.overlay-summary-card strong { + font-size: 1.02rem; +} + +.system-overlay-grid { + display: grid; + gap: 0.75rem; + min-height: min(72dvh, 760px); +} + +.inventory-overlay-grid, +.blueprint-overlay-grid, +.log-overlay-grid { + grid-template-columns: minmax(0, 1.4fr) 320px; +} + +.mission-overlay-grid { + grid-template-columns: minmax(0, 1.2fr) 340px; +} + +.overlay-main-panel, +.overlay-side-stack, +.overlay-side-panel { + min-height: 0; + display: grid; +} + +.overlay-main-panel, +.overlay-side-panel { + grid-template-rows: auto minmax(0, 1fr); +} + +.overlay-side-stack { + gap: 0.75rem; + grid-template-rows: repeat(2, minmax(0, 1fr)); +} + +.overlay-list { + min-height: 0; +} + +.overlay-loadout-stack, +.overlay-note-stack { + display: grid; + gap: 0.55rem; + align-content: start; +} + +.overlay-loadout-row.equipped { + border-color: rgba(215, 138, 51, 0.28); +} + +.overlay-loadout-row small, +.overlay-note-card p { + display: block; + margin-top: 0.3rem; + color: rgba(239, 226, 206, 0.76); + line-height: 1.5; +} + +.empty-state { + padding: 0.9rem; + border: 1px dashed rgba(255, 255, 255, 0.12); + border-radius: 14px; + color: var(--muted); +} + +.route-list::-webkit-scrollbar, +.map-node-grid::-webkit-scrollbar, +.action-grid::-webkit-scrollbar, +.trade-list::-webkit-scrollbar, +.recipe-list::-webkit-scrollbar, +.log-list::-webkit-scrollbar, +.objective-list::-webkit-scrollbar, +.inventory-list::-webkit-scrollbar, +.quest-list::-webkit-scrollbar, +.loadout-scroll::-webkit-scrollbar, +.combat-log::-webkit-scrollbar, +.modal-option-list::-webkit-scrollbar { + width: 8px; +} + +.route-list::-webkit-scrollbar-thumb, +.map-node-grid::-webkit-scrollbar-thumb, +.action-grid::-webkit-scrollbar-thumb, +.trade-list::-webkit-scrollbar-thumb, +.recipe-list::-webkit-scrollbar-thumb, +.log-list::-webkit-scrollbar-thumb, +.objective-list::-webkit-scrollbar-thumb, +.inventory-list::-webkit-scrollbar-thumb, +.quest-list::-webkit-scrollbar-thumb, +.loadout-scroll::-webkit-scrollbar-thumb, +.combat-log::-webkit-scrollbar-thumb, +.modal-option-list::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(215, 138, 51, 0.26); +} + +@media (max-width: 1580px) { + .top-hud { + grid-template-columns: 250px 104px minmax(0, 1fr); + } + + .top-actions { + grid-column: 1 / -1; + justify-content: flex-start; + } + + .game-grid { + grid-template-columns: 286px minmax(0, 1fr) 372px; + } + + .loadout-matrix { + grid-template-columns: 112px minmax(0, 1fr) 112px; + } +} + +@media (max-width: 1340px) { + body { + overflow: auto; + } + + .app-shell { + overflow: visible; + } + + .top-hud { + grid-template-columns: 1fr; + } + + .status-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .game-grid { + grid-template-columns: 1fr; + } + + .command-stage, + .character-meta-grid, + .auth-shell, + .command-dock { + grid-template-columns: 1fr; + } + + .command-side-column { + grid-template-rows: auto; + } + + .command-dock { + align-items: start; + } +} + +@media (max-width: 860px) { + .app-shell, + .auth-shell, + .new-game-shell, + .loading-shell { + padding: 0.55rem; + } + + .status-strip, + .action-grid, + .attribute-grid, + .effects-grid, + .combat-actions, + .overlay-summary-grid, + .panel-link-grid { + grid-template-columns: 1fr; + } + + .quick-slot-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .route-card, + .inventory-card, + .recipe-card, + .loadout-matrix, + .inventory-overlay-grid, + .blueprint-overlay-grid, + .mission-overlay-grid, + .log-overlay-grid { + grid-template-columns: 1fr; + } + + .route-card-state { + align-items: flex-start; + } + + .map-node-grid { + grid-template-columns: 1fr; + } + + .auth-hero, + .new-game-card { + min-height: 420px; + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..025a0f3 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,62 @@ +import './App.css'; +import { AuthScreen } from './features/auth/components/AuthScreen'; +import { NewGameScreen } from './features/auth/components/NewGameScreen'; +import { GameHud } from './features/game/components/GameHud'; +import { LoadingScreen } from './features/shared/components/LoadingScreen'; +import { useGameSession } from './features/session/useGameSession'; + +function App() { + const session = useGameSession(); + + if (session.isCheckingSession) { + return ; + } + + if (!session.user) { + return ( + + ); + } + + if (session.isSyncingGame) { + return ; + } + + if (!session.hasSave || !session.view) { + return ( + session.startNewGame()} + /> + ); + } + + return ( + + ); +} + +export default App; diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts new file mode 100644 index 0000000..94ae399 --- /dev/null +++ b/apps/web/src/api.ts @@ -0,0 +1,66 @@ +import type { GameAction, GameView } from '@tinywaste/game-core'; + +export interface AuthUser { + id: string; + email: string; + username: string; +} + +interface ApiErrorResponse { + error?: { + code?: string; + message?: string; + }; +} + +const apiFetch = async (path: string, init?: RequestInit): Promise => { + const response = await fetch(path, { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + ...init, + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as ApiErrorResponse | null; + throw new Error(payload?.error?.message ?? 'Request failed.'); + } + + return (await response.json()) as T; +}; + +export const getMe = () => apiFetch<{ user: AuthUser | null }>('/api/auth/me'); + +export const login = (payload: { identifier: string; password: string }) => + apiFetch<{ user: AuthUser }>('/api/auth/login', { + method: 'POST', + body: JSON.stringify(payload), + }); + +export const register = (payload: { email: string; username: string; password: string }) => + apiFetch<{ user: AuthUser }>('/api/auth/register', { + method: 'POST', + body: JSON.stringify(payload), + }); + +export const logout = () => + apiFetch<{ ok: true }>('/api/auth/logout', { + method: 'POST', + }); + +export const getGameState = () => + apiFetch<{ hasSave: boolean; view: GameView | null }>('/api/game/state'); + +export const createGame = (payload: { playerName: string }) => + apiFetch<{ view: GameView }>('/api/game/new', { + method: 'POST', + body: JSON.stringify(payload), + }); + +export const applyGameAction = (payload: GameAction) => + apiFetch<{ view: GameView }>('/api/game/action', { + method: 'POST', + body: JSON.stringify(payload), + }); diff --git a/apps/web/src/assets/hero.png b/apps/web/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/apps/web/src/assets/hero.png differ diff --git a/apps/web/src/assets/react.svg b/apps/web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/assets/vite.svg b/apps/web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/apps/web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/apps/web/src/features/auth/components/AuthScreen.tsx b/apps/web/src/features/auth/components/AuthScreen.tsx new file mode 100644 index 0000000..002f789 --- /dev/null +++ b/apps/web/src/features/auth/components/AuthScreen.tsx @@ -0,0 +1,87 @@ +import type { AuthFormState, AuthMode } from '../../session/useGameSession'; +import { getPlaceHeroStyle } from '../../game/uiAssets'; + +export function AuthScreen({ + authMode, + authForm, + authError, + busy, + onChange, + onModeChange, + onSubmit, +}: { + authMode: AuthMode; + authForm: AuthFormState; + authError: string | null; + busy: boolean; + onChange: (patch: Partial) => void; + onModeChange: (mode: AuthMode) => void; + onSubmit: () => void; +}) { + return ( +
+
+
+

Wasteland Access Node

+

接入云端避难所网络。

+

+ 这不是展示页,而是在线生存终端。登录后,你的移动、搜刮、战斗和任务推进都会交给服务端落库,并在下次接入时继续沿用同一份进度。 +

+
+
+ +
+
+ + +
+ + {authMode === 'register' ? ( + <> + + + + ) : ( + + )} + + + + {authError ?

{authError}

: null} + + +
+
+ ); +} diff --git a/apps/web/src/features/auth/components/NewGameScreen.tsx b/apps/web/src/features/auth/components/NewGameScreen.tsx new file mode 100644 index 0000000..aa62526 --- /dev/null +++ b/apps/web/src/features/auth/components/NewGameScreen.tsx @@ -0,0 +1,48 @@ +import type { AuthUser } from '../../../api'; +import { getPlaceHeroStyle } from '../../game/uiAssets'; + +export function NewGameScreen({ + user, + playerName, + busy, + gameError, + onPlayerNameChange, + onStart, + onLogout, +}: { + user: AuthUser; + playerName: string; + busy: boolean; + gameError: string | null; + onPlayerNameChange: (value: string) => void; + onStart: () => void; + onLogout: () => void; +}) { + return ( +
+
+
+
+

Cloud Save Channel Ready

+

{user.username},登记你的幸存者呼号。

+

+ 下一步会建立主存档,并从避难点开始你的正式在线进度。之后每一次行动都会写入数据库,而不是只停留在本地页面里。 +

+ + {gameError ?

{gameError}

: null} +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/features/game/components/AssetThumb.tsx b/apps/web/src/features/game/components/AssetThumb.tsx new file mode 100644 index 0000000..f608a36 --- /dev/null +++ b/apps/web/src/features/game/components/AssetThumb.tsx @@ -0,0 +1,15 @@ +export function AssetThumb({ + src, + label, + className = '', +}: { + src?: string; + label: string; + className?: string; +}) { + return ( +
+ {src ? {label} : {label.slice(0, 2).toUpperCase()}} +
+ ); +} diff --git a/apps/web/src/features/game/components/BottomDock.tsx b/apps/web/src/features/game/components/BottomDock.tsx new file mode 100644 index 0000000..10c9766 --- /dev/null +++ b/apps/web/src/features/game/components/BottomDock.tsx @@ -0,0 +1,66 @@ +import type { OverlayPanel } from '../types'; +import type { GameView } from '@tinywaste/game-core'; +import { getActiveQuestCount, getSupplyCounts } from '../hudModel'; + +export function BottomDock({ + activePanel, + onOpenPanel, + view, +}: { + activePanel: OverlayPanel | null; + onOpenPanel: (panel: OverlayPanel) => void; + view: GameView; +}) { + const supplyCounts = getSupplyCounts(view); + const activeQuestCount = getActiveQuestCount(view); + + return ( +
+
+
{view.player.name.slice(0, 1)}
+
+ ACTIVE SURVIVOR + {view.player.name} +
+
+ +
+
+ + + + + +
+ +
+ 水 {supplyCounts.water} + 食物 {supplyCounts.food} + 药品 {supplyCounts.medicine} + 材料 {supplyCounts.material} + 任务 {activeQuestCount} +
+
+
+ ); +} diff --git a/apps/web/src/features/game/components/CommandCenter.tsx b/apps/web/src/features/game/components/CommandCenter.tsx new file mode 100644 index 0000000..ec64aea --- /dev/null +++ b/apps/web/src/features/game/components/CommandCenter.tsx @@ -0,0 +1,197 @@ +import type { GameAction, GameView } from '@tinywaste/game-core'; +import { getObjectiveRows } from '../hudModel'; +import { getPlaceHeroStyle } from '../uiAssets'; +import type { OverlayPanel } from '../types'; +import { EmptyState } from './EmptyState'; +import { PanelHeader } from '../../shared/components/PanelHeader'; + +function ObjectivePanel({ view }: { view: GameView }) { + const objectives = getObjectiveRows(view); + + return ( +
+ +
+ {objectives.length ? ( + objectives.map((objective) => ( +
+
+ {objective.title} +

{objective.detail}

+
+ {objective.progress} +
+ )) + ) : ( + + )} +
+
+ ); +} + +function LogPanel({ view }: { view: GameView }) { + return ( +
+ +
+ {view.logs.map((entry) => ( +
+ {entry.minute}m +

{entry.message}

+
+ ))} +
+
+ ); +} + +export function CommandCenter({ + gameError, + isWorking, + onOpenPanel, + onSendAction, + view, +}: { + gameError: string | null; + isWorking: boolean; + onOpenPanel: (panel: OverlayPanel) => void; + onSendAction: (action: GameAction) => void; + view: GameView; +}) { + const utilityCards = [ + { + id: 'utility-rest', + name: '短休 30m', + kind: 'system', + desc: '不离开当前扇区,快速回收一段精力窗口。', + meta: ['30 分钟', '恢复向', '低风险'], + onClick: () => onSendAction({ type: 'rest', minutes: 30 }), + }, + { + id: 'utility-blueprints', + name: '打开蓝图栈', + kind: 'system', + desc: '在独立系统面板中检查配方、缺口与制作链。', + meta: ['模式切换', '制作', '即时'], + onClick: () => onOpenPanel('blueprints'), + }, + { + id: 'utility-logs', + name: '复盘日志', + kind: 'system', + desc: '在独立日志面板中回放最近行动、事件与消耗。', + meta: ['模式切换', '日志', '回放'], + onClick: () => onOpenPanel('logs'), + }, + ]; + + return ( +
+
+
+

Live Visual Feed

+

{view.header.placeName}

+

{view.header.placeDesc}

+
+
+ {view.pendingEvent ? '事件待处理' : '无待定事件'} + {view.combat ? `战斗回合 ${view.combat.round}` : `行动 ${view.place.actions.length}`} + {view.place.tradeOffers.length ? `可交易 ${view.place.tradeOffers.length}` : '无交易站'} +
+
+ + {gameError ?

{gameError}

: null} + +
+
+ + + +
+ +
+
+ +
+ {view.place.actions.map((action) => ( + + ))} + {utilityCards.map((card) => ( + + ))} +
+ +
+ + {view.place.tradeOffers.length ? ( +
+ {view.place.tradeOffers.map((offer) => ( + + ))} +
+ ) : ( + + )} +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/features/game/components/EmptyState.tsx b/apps/web/src/features/game/components/EmptyState.tsx new file mode 100644 index 0000000..ca76619 --- /dev/null +++ b/apps/web/src/features/game/components/EmptyState.tsx @@ -0,0 +1,3 @@ +export function EmptyState({ text }: { text: string }) { + return
{text}
; +} diff --git a/apps/web/src/features/game/components/GameHud.tsx b/apps/web/src/features/game/components/GameHud.tsx new file mode 100644 index 0000000..9487416 --- /dev/null +++ b/apps/web/src/features/game/components/GameHud.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import type { GameAction, GameView } from '@tinywaste/game-core'; +import { BottomDock } from './BottomDock'; +import { CommandCenter } from './CommandCenter'; +import { LoadoutPanel } from './LoadoutPanel'; +import { RoutePanel } from './RoutePanel'; +import { TopHud } from './TopHud'; +import { SystemOverlay } from './SystemOverlay'; +import { ModalShell } from '../../shared/components/ModalShell'; +import type { OverlayPanel } from '../types'; + +export function GameHud({ + accountName, + gameError, + isRestarting, + isWorking, + logoutPending, + onLogout, + onRestart, + onSendAction, + view, +}: { + accountName: string; + gameError: string | null; + isRestarting: boolean; + isWorking: boolean; + logoutPending: boolean; + onLogout: () => void; + onRestart: (playerName?: string) => void; + onSendAction: (action: GameAction) => void; + view: GameView; +}) { + const [activePanel, setActivePanel] = useState(null); + + return ( +
+ + +
+ + + +
+ + + + setActivePanel(null)} + onSendAction={onSendAction} + panel={activePanel} + view={view} + /> + + {view.pendingEvent ? ( + +

{view.pendingEvent.text}

+
+ {view.pendingEvent.options.map((option) => ( + + ))} +
+
+ ) : null} + + {view.combat ? ( + +
+
+ 敌方生命 + + {view.combat.enemyLife}/{view.combat.enemyMaxLife} + +
+
+ {view.combat.log.map((line, index) => ( +

+ R{line.round} · {line.message} +

+ ))} +
+
+ {view.combat.availableMoves.map((entry) => ( + + ))} +
+
+
+ ) : null} + + {view.gameOver || view.victory ? ( + +

+ {view.victory + ? '你已经完成了当前在线版本的主线闭环,接下来可以继续扩内容,也可以直接重开试另一条路线。' + : '这个存档已经进入失败状态,你可以直接重开一个新的幸存者。'} +

+ +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/features/game/components/LoadoutPanel.tsx b/apps/web/src/features/game/components/LoadoutPanel.tsx new file mode 100644 index 0000000..77a03e3 --- /dev/null +++ b/apps/web/src/features/game/components/LoadoutPanel.tsx @@ -0,0 +1,158 @@ +import type { GameAction, GameView } from '@tinywaste/game-core'; +import { useMemo } from 'react'; +import { CHARACTER_PREVIEW_ART, getItemArt } from '../uiAssets'; +import { getAttributeRows, getLoadoutEntries, getStatusEffects, getVisibleInventory } from '../hudModel'; +import type { OverlayPanel } from '../types'; +import { AssetThumb } from './AssetThumb'; +import { PanelHeader } from '../../shared/components/PanelHeader'; + +export function LoadoutPanel({ + isWorking, + onOpenPanel, + onSendAction, + view, +}: { + isWorking: boolean; + onOpenPanel: (panel: OverlayPanel) => void; + onSendAction: (action: GameAction) => void; + view: GameView; +}) { + const loadoutEntries = useMemo(() => getLoadoutEntries(view), [view]); + const attributeRows = useMemo(() => getAttributeRows(view), [view]); + const statusEffects = useMemo(() => getStatusEffects(view), [view]); + const quickSlots = useMemo(() => getVisibleInventory(view), [view]); + + return ( +
+
+ + + +
+ +
+ + +
+
+ {loadoutEntries.left.map((entry) => ( +
+ +
+ {entry.label} + {entry.title} + {entry.subtitle} +
+
+ ))} +
+ +
+ 幸存者角色立绘 +
+ ACTIVE SURVIVOR + {view.player.name} +
+
+ +
+ {loadoutEntries.right.map((entry) => ( +
+ +
+ {entry.label} + {entry.title} + {entry.subtitle} +
+
+ ))} +
+
+ +
+
+ +
+ {attributeRows.map((entry) => ( +
+ {entry.label} + {entry.value} + {entry.name} +
+ ))} +
+
+ +
+ +
+ {statusEffects.map((effect) => ( +
+ {effect.label} +

{effect.detail}

+
+ ))} +
+
+
+ +
+ +
+ {quickSlots.map((item) => ( +
+ +
+ {item.name} + x{item.count} +
+
+ ))} + {Array.from({ length: Math.max(0, 8 - quickSlots.length) }).map((_, index) => ( +
+
LOCK
+
+ ))} +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ {[30, 60, 120].map((minutes) => ( + + ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/features/game/components/RoutePanel.tsx b/apps/web/src/features/game/components/RoutePanel.tsx new file mode 100644 index 0000000..55ef639 --- /dev/null +++ b/apps/web/src/features/game/components/RoutePanel.tsx @@ -0,0 +1,101 @@ +import type { GameAction, GameView } from '@tinywaste/game-core'; +import { edgeDestination, getActiveQuestCount, getCurrentPlace, getRiskDots, getRouteRiskTier } from '../hudModel'; +import { getPlaceThumbStyle } from '../uiAssets'; +import { PanelHeader } from '../../shared/components/PanelHeader'; + +export function RoutePanel({ + isWorking, + onSendAction, + view, +}: { + isWorking: boolean; + onSendAction: (action: GameAction) => void; + view: GameView; +}) { + const currentPlace = getCurrentPlace(view); + const activeQuestCount = getActiveQuestCount(view); + + return ( +
+ + +
+ CURRENT SECTOR +

{currentPlace?.name ?? view.header.placeName}

+

{view.header.placeDesc}

+
+
+ 风险 + {view.header.riskLabel} +
+
+ 服务 + {view.place.services.length || 1} 项 +
+
+ 任务 + {activeQuestCount} +
+
+
+ {(currentPlace?.tags ?? []).map((tag) => ( + + {tag} + + ))} +
+
+ +
+ {view.map.edges.map((edge) => { + const destination = edgeDestination(view, edge); + if (!destination) return null; + + const riskTier = getRouteRiskTier(edge.risk); + const riskDots = getRiskDots(edge.risk); + + return ( + + ); + })} +
+ +
+ +
+ {view.map.places.map((place) => ( +
+ {place.name} + {place.visited ? '已探明' : '待深入'} + {place.tags.join(' · ') || 'unknown'} +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/features/game/components/StatMeter.tsx b/apps/web/src/features/game/components/StatMeter.tsx new file mode 100644 index 0000000..c7d56fc --- /dev/null +++ b/apps/web/src/features/game/components/StatMeter.tsx @@ -0,0 +1,28 @@ +export function StatMeter({ + label, + value, + maxValue, + tone, +}: { + label: string; + value: number; + maxValue: number; + tone: 'health' | 'neutral' | 'danger'; +}) { + const percentage = Math.max(0, Math.min(100, (value / maxValue) * 100)); + + return ( +
+
+ {label} + + {Math.round(value)} + /{Math.round(maxValue)} + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/features/game/components/SystemOverlay.tsx b/apps/web/src/features/game/components/SystemOverlay.tsx new file mode 100644 index 0000000..6c9a6e9 --- /dev/null +++ b/apps/web/src/features/game/components/SystemOverlay.tsx @@ -0,0 +1,286 @@ +import type { GameAction, GameView } from '@tinywaste/game-core'; +import { getActiveQuestCount, getLoadoutEntries, getSupplyCounts } from '../hudModel'; +import type { OverlayPanel } from '../types'; +import { ModalShell } from '../../shared/components/ModalShell'; +import { PanelHeader } from '../../shared/components/PanelHeader'; +import { InventoryCard } from './cards/InventoryCard'; +import { QuestCard } from './cards/QuestCard'; +import { RecipeCard } from './cards/RecipeCard'; +import { EmptyState } from './EmptyState'; + +function OverlayHeaderStats({ view }: { view: GameView }) { + const supplyCounts = getSupplyCounts(view); + const activeQuestCount = getActiveQuestCount(view); + + return ( +
+
+ 容量 + + {view.player.inventoryUsage}/{view.player.inventoryCapacity} + +
+
+ + {supplyCounts.water} +
+
+ 食物 + {supplyCounts.food} +
+
+ 药品 + {supplyCounts.medicine} +
+
+ 任务 + {activeQuestCount} +
+
+ ); +} + +function InventoryOverlay({ + isWorking, + onSendAction, + view, +}: { + isWorking: boolean; + onSendAction: (action: GameAction) => void; + view: GameView; +}) { + const loadoutEntries = getLoadoutEntries(view); + + return ( +
+ + +
+
+ +
+ {view.player.inventory.map((item) => ( + onSendAction({ type: 'equip-item', itemId: item.itemId })} + onUnequip={() => onSendAction({ type: 'unequip-item', slot: item.equipSlot! })} + onUse={() => onSendAction({ type: 'use-item', itemId: item.itemId })} + /> + ))} +
+
+ +
+
+ +
+ {[...loadoutEntries.left, ...loadoutEntries.right].map((entry) => ( +
+
+ {entry.label} + {entry.title} +
+ {entry.subtitle} +
+ ))} +
+
+ +
+ +
+
+ 容量模型 +

当前采用体积上限而不是无限背包,保证拾取与远行补给之间存在真实取舍。

+
+
+ 使用优先级 +

饮水和药品保持在快捷栏可视范围,材料与低频物资放到背包面板深层管理。

+
+
+
+
+
+
+ ); +} + +function MissionOverlay({ isWorking, onSendAction, view }: { isWorking: boolean; onSendAction: (action: GameAction) => void; view: GameView }) { + return ( +
+ + +
+
+ +
+ {view.quests.length ? ( + view.quests.map((quest) => ) + ) : ( + + )} +
+
+ +
+
+ +
+ {[30, 60, 120].map((minutes) => ( + + ))} +
+
+ +
+ +
+
+ 生命周期 +

任务分为 locked、active、completed,推进由服务端根据状态、地点、物品与标记自动结算。

+
+
+ 引导方式 +

新手引导应尽量通过任务链驱动,而不是频繁弹出说明窗口。

+
+
+
+
+
+
+ ); +} + +function BlueprintOverlay({ + isWorking, + onSendAction, + view, +}: { + isWorking: boolean; + onSendAction: (action: GameAction) => void; + view: GameView; +}) { + const craftableCount = view.recipes.filter((recipe) => recipe.craftable).length; + + return ( +
+ + +
+
+ +
+ {view.recipes.map((recipe) => ( + onSendAction({ type: 'craft', recipeId: recipe.id })} + recipe={recipe} + /> + ))} +
+
+ +
+
+ +
+
+ 可制作 +

当前有 {craftableCount} 条蓝图可以立即执行。

+
+
+ 系统定位 +

蓝图栈属于中频决策,适合独立面板打开,而不是长期占用主 HUD 面积。

+
+
+
+
+
+
+ ); +} + +function LogOverlay({ view }: { view: GameView }) { + return ( +
+ + +
+
+ +
+ {view.logs.map((entry) => ( +
+ {entry.minute}m +

{entry.message}

+
+ ))} +
+
+ +
+
+ +
+
+ 主屏只保留摘要 +

常驻 HUD 只呈现决策必要信息,完整日志集中在独立面板中查看。

+
+
+ 可追溯性 +

所有时间推进、资源变化、战斗结果与事件分支都需要留下结构化日志。

+
+
+
+
+
+
+ ); +} + +export function SystemOverlay({ + isWorking, + onClose, + onSendAction, + panel, + view, +}: { + isWorking: boolean; + onClose: () => void; + onSendAction: (action: GameAction) => void; + panel: OverlayPanel | null; + view: GameView; +}) { + if (!panel) return null; + + const metaByPanel: Record = { + inventory: { title: '背包系统', subtitle: 'Inventory Overlay' }, + missions: { title: '任务系统', subtitle: 'Mission Overlay' }, + blueprints: { title: '制作与蓝图系统', subtitle: 'Blueprint Overlay' }, + logs: { title: '日志系统', subtitle: 'Log Overlay' }, + }; + + return ( + + {panel === 'inventory' ? : null} + {panel === 'missions' ? : null} + {panel === 'blueprints' ? : null} + {panel === 'logs' ? : null} + + ); +} diff --git a/apps/web/src/features/game/components/TopHud.tsx b/apps/web/src/features/game/components/TopHud.tsx new file mode 100644 index 0000000..d5fed8f --- /dev/null +++ b/apps/web/src/features/game/components/TopHud.tsx @@ -0,0 +1,59 @@ +import type { GameView } from '@tinywaste/game-core'; +import { getAlertCount, getTimeLabels } from '../hudModel'; +import { STAT_LABELS, STAT_ORDER } from '../uiAssets'; +import { StatMeter } from './StatMeter'; + +export function TopHud({ + accountName, + logoutPending, + onLogout, + view, +}: { + accountName: string; + logoutPending: boolean; + onLogout: () => void; + view: GameView; +}) { + const { dayLabel, clockLabel } = getTimeLabels(view.header.timeLabel); + const alertCount = getAlertCount(view); + + return ( +
+
+
TW
+
+

Persistent Wasteland Interface

+ TinyWaste Online +
+
+ +
+ {dayLabel} + {clockLabel} +
+ +
+ {STAT_ORDER.map((statKey) => ( + + ))} +
+ +
+ {view.header.riskLabel} + 账号 {accountName} + + {alertCount ? `警报 ${alertCount}` : '链路稳定'} + + +
+
+ ); +} diff --git a/apps/web/src/features/game/components/cards/InventoryCard.tsx b/apps/web/src/features/game/components/cards/InventoryCard.tsx new file mode 100644 index 0000000..2932800 --- /dev/null +++ b/apps/web/src/features/game/components/cards/InventoryCard.tsx @@ -0,0 +1,52 @@ +import type { InventoryViewEntry } from '@tinywaste/game-core'; +import { getInventoryStateLabel } from '../../hudModel'; +import { getItemArt } from '../../uiAssets'; +import { AssetThumb } from '../AssetThumb'; + +export function InventoryCard({ + item, + busy, + onUse, + onEquip, + onUnequip, +}: { + item: InventoryViewEntry; + busy: boolean; + onUse: () => void; + onEquip: () => void; + onUnequip: () => void; +}) { + const art = getItemArt(item.itemId, item.type); + + return ( +
+ +
+
+ {item.name} + x{item.count} +
+

{item.desc}

+ + {item.type} · 体积 {item.volume} · {getInventoryStateLabel(item)} + +
+
+ {item.canUse ? ( + + ) : null} + {item.equipSlot ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/features/game/components/cards/QuestCard.tsx b/apps/web/src/features/game/components/cards/QuestCard.tsx new file mode 100644 index 0000000..758b3d7 --- /dev/null +++ b/apps/web/src/features/game/components/cards/QuestCard.tsx @@ -0,0 +1,23 @@ +import type { QuestView } from '@tinywaste/game-core'; +import { getQuestProgress } from '../../hudModel'; + +export function QuestCard({ quest }: { quest: QuestView }) { + return ( +
+
+
+ {quest.title} + {quest.desc} +
+ {getQuestProgress(quest)} +
+
+ {quest.steps.map((step) => ( + + {step.done ? '✓' : '○'} {step.text} + + ))} +
+
+ ); +} diff --git a/apps/web/src/features/game/components/cards/RecipeCard.tsx b/apps/web/src/features/game/components/cards/RecipeCard.tsx new file mode 100644 index 0000000..2dec125 --- /dev/null +++ b/apps/web/src/features/game/components/cards/RecipeCard.tsx @@ -0,0 +1,37 @@ +import type { RecipeView } from '@tinywaste/game-core'; +import { getItemArt } from '../../uiAssets'; +import { AssetThumb } from '../AssetThumb'; + +export function RecipeCard({ + recipe, + busy, + onCraft, +}: { + recipe: RecipeView; + busy: boolean; + onCraft: () => void; +}) { + const primaryOutput = recipe.outputs[0]; + + return ( +
+ +
+ {recipe.name} +

{recipe.desc}

+ + {recipe.timeCostMin} 分钟 · 精力 -{recipe.energyCost} + + + 需求:{recipe.inputs.map((input) => `${input.name} ${input.owned}/${input.count}`).join(' · ')} + +
+
+ + {recipe.disabledReason ? {recipe.disabledReason} : null} +
+
+ ); +} diff --git a/apps/web/src/features/game/hudModel.ts b/apps/web/src/features/game/hudModel.ts new file mode 100644 index 0000000..a4dc5d9 --- /dev/null +++ b/apps/web/src/features/game/hudModel.ts @@ -0,0 +1,206 @@ +import type { EdgeView, GameView, InventoryViewEntry, QuestView } from '@tinywaste/game-core'; +import { getEquipmentArt, getItemArt } from './uiAssets'; + +export interface EquipmentPreviewEntry { + id: string; + label: string; + title: string; + subtitle: string; + art?: string; + isEquipped: boolean; +} + +export function edgeDestination(view: GameView, edge: EdgeView) { + return view.map.places.find((place) => place.id === edge.to); +} + +export function getTimeLabels(timeLabel: string) { + const timeSegments = timeLabel.split('·').map((part) => part.trim()); + return { + dayLabel: timeSegments[0] ?? 'Day 1', + clockLabel: timeSegments[1] ?? timeLabel, + }; +} + +export function getActiveQuestCount(view: GameView) { + return view.quests.filter((quest) => quest.state === 'active').length; +} + +export function getAlertCount(view: GameView) { + return Number(Boolean(view.pendingEvent)) + Number(Boolean(view.combat)); +} + +export function getCurrentPlace(view: GameView) { + return view.map.places.find((place) => place.current) ?? null; +} + +export function getVisibleInventory(view: GameView, limit = 6) { + return view.player.inventory.slice(0, limit); +} + +export function getObjectiveRows(view: GameView) { + return view.quests + .filter((quest) => quest.state === 'active' || quest.state === 'completed') + .map((quest) => { + const doneCount = quest.steps.filter((step) => step.done).length; + const nextStep = quest.steps.find((step) => !step.done)?.text ?? quest.steps.at(-1)?.text ?? '待机'; + + return { + id: quest.id, + title: quest.title, + detail: nextStep, + progress: `${doneCount}/${quest.steps.length}`, + done: quest.state === 'completed', + }; + }); +} + +export function getAttributeRows(view: GameView) { + const { attributes } = view.player; + + return [ + { label: 'STR', name: '力量', value: attributes.str }, + { label: 'AGI', name: '敏捷', value: attributes.agi }, + { label: 'INT', name: '智力', value: attributes.int }, + { label: 'PER', name: '感知', value: attributes.per }, + { label: 'LCK', name: '幸运', value: attributes.luck }, + ]; +} + +export function getStatusEffects(view: GameView) { + const { stats, maxStats } = view.player; + const effects: { id: string; label: string; detail: string; tone: 'good' | 'warn' | 'danger' }[] = []; + + if (stats.life / maxStats.life <= 0.55) { + effects.push({ id: 'life', label: '创伤', detail: '生命处于低位', tone: 'danger' }); + } + if (stats.thirst / maxStats.thirst <= 0.5) { + effects.push({ id: 'thirst', label: '脱水', detail: '口渴已压缩行动余量', tone: 'warn' }); + } + if (stats.hunger / maxStats.hunger <= 0.5) { + effects.push({ id: 'hunger', label: '饥饿', detail: '需要补充稳定热量', tone: 'warn' }); + } + if (stats.energy / maxStats.energy <= 0.45) { + effects.push({ id: 'energy', label: '疲劳', detail: '精力不足,适合休整', tone: 'warn' }); + } + if (stats.sanity / maxStats.sanity <= 0.45) { + effects.push({ id: 'sanity', label: '精神波动', detail: '理智偏低,事件风险提升', tone: 'danger' }); + } + if (stats.radiation / maxStats.radiation >= 0.32) { + effects.push({ id: 'radiation', label: '污染累积', detail: '辐射正在侵蚀身体', tone: 'danger' }); + } + + if (!effects.length) { + effects.push({ id: 'stable', label: '稳定', detail: '当前幸存者状态平稳', tone: 'good' }); + } + + return effects.slice(0, 4); +} + +export function getSupplyCounts(view: GameView) { + const counts = { + water: 0, + food: 0, + medicine: 0, + material: 0, + }; + + for (const item of view.player.inventory) { + if (item.type === 'water') counts.water += item.count; + if (item.type === 'food') counts.food += item.count; + if (item.type === 'medicine') counts.medicine += item.count; + if (item.type === 'material') counts.material += item.count; + } + + return counts; +} + +export function getKitItem(view: GameView) { + return view.player.inventory.find((item) => item.type === 'medicine' || item.type === 'water') ?? null; +} + +export function getLoadoutEntries(view: GameView) { + const inventoryById = new Map(view.player.inventory.map((item) => [item.itemId, item])); + const weaponItem = view.player.equipment.weapon ? inventoryById.get(view.player.equipment.weapon) : undefined; + const bodyItem = view.player.equipment.body ? inventoryById.get(view.player.equipment.body) : undefined; + const toolItem = view.player.equipment.tool ? inventoryById.get(view.player.equipment.tool) : undefined; + const kitItem = getKitItem(view); + + const left: EquipmentPreviewEntry[] = [ + { + id: 'head', + label: 'Head', + title: '遮面兜帽', + subtitle: '造型预置位', + art: getEquipmentArt(undefined, 'head'), + isEquipped: false, + }, + { + id: 'body', + label: 'Body', + title: bodyItem?.name ?? '未装备护甲', + subtitle: bodyItem ? '已挂载在躯干槽' : '建议保留一件抗风装备', + art: getEquipmentArt(bodyItem?.itemId, 'body'), + isEquipped: Boolean(bodyItem), + }, + { + id: 'back', + label: 'Back', + title: '远行背包', + subtitle: `容量 ${view.player.inventoryUsage}/${view.player.inventoryCapacity}`, + art: getEquipmentArt(undefined, 'back'), + isEquipped: true, + }, + ]; + + const right: EquipmentPreviewEntry[] = [ + { + id: 'weapon', + label: 'Weapon', + title: weaponItem?.name ?? '未装备主武器', + subtitle: weaponItem ? '武器槽在线' : '可通过蓝图制作远程装备', + art: getEquipmentArt(weaponItem?.itemId, 'weapon'), + isEquipped: Boolean(weaponItem), + }, + { + id: 'tool', + label: 'Tool', + title: toolItem?.name ?? '未挂载工具', + subtitle: toolItem ? '工具槽在线' : '撬棍可作为早期万能工具', + art: getEquipmentArt(toolItem?.itemId, 'tool'), + isEquipped: Boolean(toolItem), + }, + { + id: 'kit', + label: 'Kit', + title: kitItem?.name ?? '未配置急救包', + subtitle: kitItem ? `携带 ${kitItem.count} 件应急补给` : '建议随身携带水或药物', + art: kitItem ? getItemArt(kitItem.itemId, kitItem.type) : getEquipmentArt(undefined, 'kit'), + isEquipped: Boolean(kitItem), + }, + ]; + + return { left, right }; +} + +export function getRouteRiskTier(risk: number) { + if (risk <= 0.35) return { label: '低风险', tone: 'safe' as const }; + if (risk <= 0.65) return { label: '中风险', tone: 'warn' as const }; + return { label: '高风险', tone: 'danger' as const }; +} + +export function getRiskDots(risk: number) { + const lit = Math.max(1, Math.min(6, Math.round(risk * 6))); + return Array.from({ length: 6 }, (_, index) => index < lit); +} + +export function getInventoryStateLabel(item: InventoryViewEntry) { + if (item.equipped) return '已装备'; + if (item.canUse) return '可立即使用'; + if (item.equipSlot) return '可挂载'; + return '物资储备'; +} + +export function getQuestProgress(quest: QuestView) { + return `${quest.steps.filter((step) => step.done).length}/${quest.steps.length}`; +} diff --git a/apps/web/src/features/game/types.ts b/apps/web/src/features/game/types.ts new file mode 100644 index 0000000..74fbe0d --- /dev/null +++ b/apps/web/src/features/game/types.ts @@ -0,0 +1 @@ +export type OverlayPanel = 'inventory' | 'missions' | 'blueprints' | 'logs'; diff --git a/apps/web/src/features/game/uiAssets.ts b/apps/web/src/features/game/uiAssets.ts new file mode 100644 index 0000000..22d0ff6 --- /dev/null +++ b/apps/web/src/features/game/uiAssets.ts @@ -0,0 +1,107 @@ +import type { CSSProperties } from 'react'; +import type { EquipSlot } from '@tinywaste/game-core'; + +const GENERATED_ROOT = '/generated'; +const GENERATED_UI_ROOT = `${GENERATED_ROOT}/ui`; + +export const PLACE_ART: Record = { + home: `${GENERATED_ROOT}/home-camp.png`, + roadside: `${GENERATED_ROOT}/roadside.png`, + old_store: `${GENERATED_ROOT}/old-store.png`, + village_market: `${GENERATED_ROOT}/village-market.png`, + rain_farm: `${GENERATED_ROOT}/rain-farm.png`, + city_edge: `${GENERATED_ROOT}/city-edge.png`, + subway_entrance: `${GENERATED_ROOT}/subway-entrance.png`, + sewer: `${GENERATED_ROOT}/sewer.png`, + vault_gate: `${GENERATED_ROOT}/vault-gate.png`, +}; + +export const STAT_LABELS: Record = { + life: '生命', + hunger: '饥饿', + thirst: '口渴', + energy: '精力', + sanity: '理智', + radiation: '辐射', +}; + +export const STAT_ORDER = ['life', 'hunger', 'thirst', 'energy', 'sanity', 'radiation'] as const; + +const ITEM_ART: Record = { + water_dirty: `${GENERATED_UI_ROOT}/items/water-dirty.png`, + water_purified: `${GENERATED_UI_ROOT}/items/water-purified.png`, + canned_beans: `${GENERATED_UI_ROOT}/items/canned-beans.png`, + cloth_rag: `${GENERATED_UI_ROOT}/items/cloth-rag.png`, + herb_bundle: `${GENERATED_UI_ROOT}/items/herb-bundle.png`, + mushroom_cluster: `${GENERATED_UI_ROOT}/items/mushroom-cluster.png`, + scrap_metal: `${GENERATED_UI_ROOT}/items/scrap-metal.png`, + parts_wire: `${GENERATED_UI_ROOT}/items/parts-wire.png`, + rad_pills: `${GENERATED_UI_ROOT}/items/rad-pills.png`, + shiv: `${GENERATED_UI_ROOT}/items/shiv.png`, + crowbar: `${GENERATED_UI_ROOT}/items/crowbar.png`, + leather_coat: `${GENERATED_UI_ROOT}/items/leather-coat.png`, + field_bandage: `${GENERATED_UI_ROOT}/items/cloth-rag.png`, + herbal_tea: `${GENERATED_UI_ROOT}/items/water-purified.png`, + jerky: `${GENERATED_UI_ROOT}/items/canned-beans.png`, + mushroom_stew: `${GENERATED_UI_ROOT}/items/canned-beans.png`, +}; + +const ITEM_TYPE_FALLBACK_ART: Record = { + water: `${GENERATED_UI_ROOT}/items/water-purified.png`, + food: `${GENERATED_UI_ROOT}/items/canned-beans.png`, + medicine: `${GENERATED_UI_ROOT}/items/rad-pills.png`, + material: `${GENERATED_UI_ROOT}/items/scrap-metal.png`, + weapon: `${GENERATED_UI_ROOT}/items/shiv.png`, + tool: `${GENERATED_UI_ROOT}/items/crowbar.png`, + armor: `${GENERATED_UI_ROOT}/items/leather-coat.png`, +}; + +const SLOT_FALLBACK_ART: Record = { + body: `${GENERATED_UI_ROOT}/equipment/body-coat.png`, + weapon: `${GENERATED_UI_ROOT}/equipment/pipe-rifle.png`, + tool: `${GENERATED_UI_ROOT}/equipment/crowbar.png`, + head: `${GENERATED_UI_ROOT}/equipment/head-wrap.png`, + back: `${GENERATED_UI_ROOT}/equipment/backpack.png`, + kit: `${GENERATED_UI_ROOT}/items/rad-pills.png`, +}; + +const ITEM_TO_EQUIPMENT_ART: Record = { + pipe_rifle: `${GENERATED_UI_ROOT}/equipment/pipe-rifle.png`, + crowbar: `${GENERATED_UI_ROOT}/equipment/crowbar.png`, + shiv: `${GENERATED_UI_ROOT}/equipment/shiv.png`, + leather_coat: `${GENERATED_UI_ROOT}/equipment/body-coat.png`, +}; + +export function getPlaceHeroStyle(placeId: string): CSSProperties { + return { + backgroundImage: `linear-gradient(180deg, rgba(7, 7, 7, 0.12), rgba(7, 7, 7, 0.84)), linear-gradient(90deg, rgba(220, 146, 65, 0.18), rgba(10, 10, 10, 0.05) 58%), url(${PLACE_ART[placeId] ?? ''})`, + }; +} + +export function getPlaceThumbStyle(placeId: string): CSSProperties { + return { + backgroundImage: `linear-gradient(180deg, rgba(9, 9, 9, 0.05), rgba(9, 9, 9, 0.72)), url(${PLACE_ART[placeId] ?? ''})`, + }; +} + +export function getItemArt(itemId: string, itemType?: string) { + return ITEM_ART[itemId] ?? (itemType ? ITEM_TYPE_FALLBACK_ART[itemType] : undefined); +} + +export function getEquipmentArt(itemId?: string, slot?: EquipSlot | 'head' | 'back' | 'kit') { + if (itemId && ITEM_TO_EQUIPMENT_ART[itemId]) { + return ITEM_TO_EQUIPMENT_ART[itemId]; + } + + if (itemId && ITEM_ART[itemId]) { + return ITEM_ART[itemId]; + } + + if (slot) { + return SLOT_FALLBACK_ART[slot]; + } + + return undefined; +} + +export const CHARACTER_PREVIEW_ART = `${GENERATED_UI_ROOT}/character-preview.png`; diff --git a/apps/web/src/features/session/useGameSession.ts b/apps/web/src/features/session/useGameSession.ts new file mode 100644 index 0000000..8310cce --- /dev/null +++ b/apps/web/src/features/session/useGameSession.ts @@ -0,0 +1,175 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import type { GameAction, GameView } from '@tinywaste/game-core'; +import { + applyGameAction, + createGame, + getGameState, + getMe, + login, + logout, + register, + type AuthUser, +} from '../../api'; + +export type AuthMode = 'login' | 'register'; + +export interface AuthFormState { + email: string; + username: string; + identifier: string; + password: string; +} + +export function useGameSession() { + const queryClient = useQueryClient(); + const [authMode, setAuthMode] = useState('login'); + const [authError, setAuthError] = useState(null); + const [gameError, setGameError] = useState(null); + const [authForm, setAuthForm] = useState({ + email: '', + username: '', + identifier: '', + password: '', + }); + const [newGameName, setNewGameNameState] = useState('灰烬拾荒者'); + + const meQuery = useQuery({ + queryKey: ['me'], + queryFn: getMe, + }); + + const gameQuery = useQuery({ + queryKey: ['game-state'], + queryFn: getGameState, + enabled: Boolean(meQuery.data?.user), + }); + + const authMutation = useMutation({ + mutationFn: async () => { + setAuthError(null); + if (authMode === 'login') { + return login({ + identifier: authForm.identifier, + password: authForm.password, + }); + } + + return register({ + email: authForm.email, + username: authForm.username, + password: authForm.password, + }); + }, + onSuccess: ({ user }) => { + queryClient.setQueryData(['me'], { user }); + queryClient.invalidateQueries({ queryKey: ['game-state'] }); + setAuthForm((current) => ({ ...current, password: '' })); + }, + onError: (error) => { + setAuthError(error instanceof Error ? error.message : '认证失败。'); + }, + }); + + const logoutMutation = useMutation({ + mutationFn: logout, + onSuccess: () => { + queryClient.setQueryData(['me'], { user: null }); + queryClient.setQueryData(['game-state'], { hasSave: false, view: null }); + }, + }); + + const createGameMutation = useMutation({ + mutationFn: createGame, + onMutate: () => { + setGameError(null); + }, + onSuccess: ({ view }) => { + setGameError(null); + queryClient.setQueryData(['game-state'], { hasSave: true, view }); + }, + onError: (error) => { + setGameError(error instanceof Error ? error.message : '建档失败。'); + }, + }); + + const actionMutation = useMutation({ + mutationFn: applyGameAction, + onMutate: () => { + setGameError(null); + }, + onSuccess: ({ view }) => { + setGameError(null); + queryClient.setQueryData(['game-state'], { hasSave: true, view }); + }, + onError: (error) => { + setGameError(error instanceof Error ? error.message : '行动失败。'); + }, + }); + + const isWorking = + authMutation.isPending || + logoutMutation.isPending || + createGameMutation.isPending || + actionMutation.isPending; + + const handleAuthChange = (patch: Partial) => { + if (authError) { + setAuthError(null); + } + setAuthForm((current) => ({ ...current, ...patch })); + }; + + const handleAuthModeChange = (mode: AuthMode) => { + setAuthError(null); + setAuthMode(mode); + }; + + const setNewGameName = (value: string) => { + if (gameError) { + setGameError(null); + } + setNewGameNameState(value); + }; + + const submitAuth = () => { + authMutation.mutate(); + }; + + const startNewGame = (playerName = newGameName) => { + createGameMutation.mutate({ playerName }); + }; + + const sendAction = (action: GameAction) => { + actionMutation.mutate(action); + }; + + const user: AuthUser | null = meQuery.data?.user ?? null; + const gameState = gameQuery.data ?? { hasSave: false, view: null as GameView | null }; + + return { + actionMutation, + authError, + authForm, + authMode, + createGameMutation, + gameError, + gameQuery, + handleAuthChange, + handleAuthModeChange, + isCheckingSession: meQuery.isLoading, + isSyncingGame: gameQuery.isLoading, + isWorking, + logoutPending: logoutMutation.isPending, + meQuery, + newGameName, + sendAction, + setNewGameName, + startNewGame, + submitAuth, + user, + view: gameState.view, + hasSave: gameState.hasSave, + logout: () => logoutMutation.mutate(), + }; +} diff --git a/apps/web/src/features/shared/components/LoadingScreen.tsx b/apps/web/src/features/shared/components/LoadingScreen.tsx new file mode 100644 index 0000000..076d73c --- /dev/null +++ b/apps/web/src/features/shared/components/LoadingScreen.tsx @@ -0,0 +1,10 @@ +export function LoadingScreen({ label }: { label: string }) { + return ( +
+
+
+

{label}

+
+
+ ); +} diff --git a/apps/web/src/features/shared/components/ModalShell.tsx b/apps/web/src/features/shared/components/ModalShell.tsx new file mode 100644 index 0000000..936bdd4 --- /dev/null +++ b/apps/web/src/features/shared/components/ModalShell.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; + +export function ModalShell({ + title, + subtitle, + children, + onClose, + size = 'default', +}: { + title: string; + subtitle: string; + children: ReactNode; + onClose?: () => void; + size?: 'default' | 'wide'; +}) { + return ( +
+
+
+
+ {subtitle} +

{title}

+
+ {onClose ? ( + + ) : null} +
+ {children} +
+
+ ); +} diff --git a/apps/web/src/features/shared/components/PanelHeader.tsx b/apps/web/src/features/shared/components/PanelHeader.tsx new file mode 100644 index 0000000..e4b4504 --- /dev/null +++ b/apps/web/src/features/shared/components/PanelHeader.tsx @@ -0,0 +1,18 @@ +export function PanelHeader({ + title, + subtitle, + compact = false, +}: { + title: string; + subtitle: string; + compact?: boolean; +}) { + return ( +
+
+

{title}

+

{subtitle}

+
+
+ ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..56e6ab6 --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,26 @@ +@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap'); + +html, +body, +#root { + height: 100%; +} + +html { + font-family: 'Chakra Petch', sans-serif; +} + +body { + min-width: 320px; +} + +code, +pre, +button, +input { + font-family: inherit; +} + +#root { + min-height: 100vh; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..a8126d0 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; +import './index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json new file mode 100644 index 0000000..7f42e5f --- /dev/null +++ b/apps/web/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/apps/web/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..e0adf09 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:3001', + }, + }, +}) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..e4a50a6 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +if [ -z "${SESSION_SECRET:-}" ]; then + export SESSION_SECRET="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))")" + echo "SESSION_SECRET not set. Generated a temporary secret for this container run." >&2 +fi + +node dist/scripts/migrate.js +exec node dist/src/server.js diff --git a/docs/12-前端HUD架构与资产管线.md b/docs/12-前端HUD架构与资产管线.md new file mode 100644 index 0000000..a93a03b --- /dev/null +++ b/docs/12-前端HUD架构与资产管线.md @@ -0,0 +1,169 @@ +# 12 前端 HUD 架构与资产管线 + +本文档记录当前 TinyWaste Online Web 客户端的 HUD 架构落地方式,目标是让后续继续扩内容、补系统、换美术时,仍然能保持模块边界清晰、资产可追溯、状态流可解释。 + +## 12.1 技术栈结论 + +当前重制版继续沿用并确认以下技术栈,不做二次摇摆: + +- 前端:React 19 + Vite + TypeScript +- 客户端数据层:TanStack Query +- 后端:Fastify + TypeScript +- 存档与账号:Session Cookie + Drizzle ORM + SQLite +- 共享规则层:`packages/game-core` +- 共享内容层:`packages/content` + +选择原因: + +- React + Vite 适合构建高密度单屏 HUD,开发迭代成本低。 +- React Query 负责会话、存档和行动回写,能把“在线游戏状态”与“界面状态”明确分层。 +- `game-core` 与 `content` 把规则和内容从 UI 中剥离,符合文档 08 的分层目标。 +- Fastify + Drizzle 让账号与云存档可以继续扩展为多槽、迁移、审计和反作弊校验。 + +## 12.2 当前前端分层 + +按照文档 08 的 Presentation / Application / Domain / Data 思路,前端当前对应如下: + +- Presentation:`apps/web/src/features/game/components/*`、`apps/web/src/features/auth/components/*` +- Application:`apps/web/src/features/session/useGameSession.ts` +- Data:`apps/web/src/api.ts` +- Domain:`packages/game-core`、`packages/content` + +职责约束: + +- `App.tsx` 只负责入口编排,不再直接堆叠完整 HUD 细节。 +- `useGameSession.ts` 只负责编排查询、认证、建档、行动提交与错误状态。 +- HUD 组件只消费 `GameView` 和回调,不直接触碰接口实现。 +- 游戏规则与内容定义禁止回流到 `apps/web` 内硬编码。 + +## 12.3 组件结构 + +当前 HUD 结构: + +```text +App +├─ AuthScreen / NewGameScreen / LoadingScreen +└─ GameHud + ├─ TopHud + ├─ RoutePanel + ├─ CommandCenter + ├─ LoadoutPanel + ├─ BottomDock + └─ ModalShell (事件 / 战斗 / 胜负) +``` + +卡片级组件: + +- `AssetThumb` +- `StatMeter` +- `InventoryCard` +- `RecipeCard` +- `QuestCard` +- `EmptyState` +- `PanelHeader` + +这样拆分后的收益: + +- 左、中、右、底四个 HUD 区块可以独立重做,而不需要重新读一遍整个页面。 +- 小卡片组件可被更多系统复用,例如未来的交易、仓库、NPC 商店、任务详情。 +- `GameHud` 能承载局部交互状态,比如 `commandTab`、`sideTab`,而不会污染全局会话逻辑。 + +## 12.4 状态流 + +当前在线游戏状态流: + +```text +浏览器 -> /api/auth/me -> 用户会话 +浏览器 -> /api/game/state -> 当前云端存档 +用户动作 -> /api/game/action -> 服务端结算 -> 返回新 GameView +UI 组件 <- Query Cache <- 最新 GameView +``` + +具体规则: + +- 认证通过后才启用 `game-state` 查询。 +- 创建新游戏、旅行、制作、战斗、交易、休息都走服务端 authoritative action。 +- Query Cache 只保存最新服务端视图,不在前端自行推演核心规则。 +- HUD 内部 tab 只属于本地展示状态,不影响云端存档。 + +## 12.5 视觉资产组织 + +所有当前接入的 GPT-image-2 资产位于: + +- `apps/web/public/generated/`:地点背景 +- `apps/web/public/generated/ui/character-preview.png`:角色立绘 +- `apps/web/public/generated/ui/item-atlas.png`:物资图集原图 +- `apps/web/public/generated/ui/equipment-atlas.png`:装备图集原图 +- `apps/web/public/generated/ui/items/*`:切分后的物资图标 +- `apps/web/public/generated/ui/equipment/*`:切分后的装备槽素材 + +对应映射入口: + +- `apps/web/src/features/game/uiAssets.ts` + +当前资产策略: + +- 地点图使用整图背景,服务于中央场景和左侧路线卡。 +- 物资与装备图使用 atlas + 裁切结果,服务于快捷栏、库存卡、角色槽位。 +- 所有引用都走 `uiAssets.ts`,避免组件里散落硬编码路径。 + +## 12.6 HUD 信息职责 + +### 12.6.1 TopHud + +- 时间 +- 生存状态条 +- 风险标签 +- 账号状态 + +### 12.6.2 RoutePanel + +- 当前地点摘要 +- 路线列表 +- 风险强度 +- 节点式世界图 + +### 12.6.3 CommandCenter + +- 场景主视觉 +- 行动矩阵 +- 交易终端 +- 实时日志 +- 当前目标 +- 蓝图视图 + +### 12.6.4 LoadoutPanel + +- 角色 / 背包 / 任务三态切换 +- 角色立绘与槽位矩阵 +- 属性与状态效果 +- 快捷栏 +- 完整库存管理 +- 任务与休整 + +### 12.6.5 BottomDock + +- 游戏化底部导航 +- 当前角色标识 +- 核心补给统计 + +## 12.7 当前与参考图的对齐策略 + +对齐的不是“像一张网站海报”,而是“像一套可操作的游戏终端”: + +- 维持单屏阅读,整页不滚动,滚动只发生在内部面板。 +- 中央区优先展示场景与决策,避免全文字堆叠。 +- 右侧区优先视觉化角色与装备,而不是继续做普通列表。 +- 底部区承担模式切换和系统导航,强化“游戏 HUD”心智。 + +## 12.8 下一步扩展建议 + +下一轮优先项: + +1. 继续补 GPT-image-2 资产: + - 头部、侧武器、弹药、医疗、食物第二批图集 + - 天气、辐射、事件状态专用小图标 +2. 将 `LoadoutPanel` 的 tab 与底部导航进一步联动为统一模式系统。 +3. 为 `CommandCenter` 增加战斗专属视图和事件专属视图,而不是完全依赖弹层。 +4. 为物资卡补充更精细的分类过滤和排序逻辑。 +5. 引入可配置的 HUD 主题参数,支撑不同章节或区域切换皮肤。 diff --git a/docs/13-运行时引擎与核心系统深化设计.md b/docs/13-运行时引擎与核心系统深化设计.md new file mode 100644 index 0000000..bc1384b --- /dev/null +++ b/docs/13-运行时引擎与核心系统深化设计.md @@ -0,0 +1,299 @@ +# 13 运行时引擎与核心系统深化设计 + +本文档补足“把游戏做好”所需要的核心系统设计,不只讨论 UI,而是把运行时引擎、生命周期、背包系统、生命系统、事件与在线存档串成一套完整方案。 + +## 13.1 当前产品定位 + +TinyWaste Online 不是网页式信息展示项目,而是一款: + +- 服务端权威结算的在线废土生存游戏 +- 以单屏 HUD 为常驻界面 +- 以系统弹窗/面板处理中频与低频操作 +- 以时间推进驱动状态、资源、事件和任务 + +这意味着架构设计要同时满足: + +- 决策节奏像游戏 +- 数据流像在线产品 +- 规则层像可测试的模拟器 + +## 13.2 运行时引擎分层 + +建议把“引擎”理解为运行时规则框架,而不是 3D 图形引擎。 + +### 13.2.1 Runtime Core + +职责: + +- 接收玩家动作 +- 推进时间 +- 执行效果链 +- 更新世界状态 +- 产出新 `GameView` + +当前仓库中主要由 `packages/game-core/src/engine.ts` 承担。 + +### 13.2.2 View Model Layer + +职责: + +- 把内部 `GameState` 投影成适合 HUD 使用的 `GameView` +- 控制哪些信息常驻、哪些信息放到弹窗层 +- 限制 UI 对领域状态的直接依赖 + +### 13.2.3 Content Layer + +职责: + +- 维护物品、地点、敌人、任务、配方、事件定义 +- 所有玩法扩展优先靠数据增量,而不是写死分支逻辑 + +### 13.2.4 Persistence Layer + +职责: + +- 用户身份 +- 云存档 +- 版本迁移 +- 存档校验 + +## 13.3 游戏生命周期 + +游戏生命周期建议明确分成 7 个阶段: + +1. `boot` + - 客户端初始化、检查账号会话、读取配置 +2. `session-ready` + - 已确认账号身份,开始拉取云端存档 +3. `save-ready` + - 存档进入内存,HUD 可以渲染 +4. `decision` + - 玩家阅读信息、选择动作 +5. `resolving` + - 服务端执行动作、推进时间、写入日志 +6. `interrupt` + - 战斗、事件、死亡、胜利等强制中断态 +7. `persisted` + - 新状态完成落库,回到下一轮 `decision` + +生命周期原则: + +- UI 永远不直接修改权威状态 +- 所有中断态都要可恢复 +- 任意动作完成后都应保证日志、时间、任务和存档状态同步 + +## 13.4 HUD 生命周期 + +主 HUD 只保留高频信息: + +- 时间 +- 生存状态 +- 当前地点 +- 行动入口 +- 角色装备摘要 +- 快捷栏 + +中频系统改用面板弹出: + +- 背包 +- 蓝图 +- 任务 +- 完整日志 + +设计原因: + +- 更像真实游戏而不是网页后台 +- 减少主屏认知负担 +- 保证一屏内只承载“下一步决策需要的信息” + +## 13.5 动作执行生命周期 + +一条标准动作链应为: + +1. 玩家发起动作 +2. 服务端校验动作是否合法 +3. 推进时间 +4. 结算状态消耗 +5. 结算地点资源与热度 +6. 结算掉落/交易/配方结果 +7. 检查事件、战斗、任务推进 +8. 写入日志 +9. 写入存档 +10. 返回新 `GameView` + +这里最重要的是顺序一致性。只要顺序稳定,玩家就能理解“为什么会掉血、为什么这里空了、为什么事件触发了”。 + +## 13.6 背包系统设计 + +### 13.6.1 当前正确方向 + +当前采用体积容量,而不是无限背包,这是对的。 + +原因: + +- 废土游戏的核心就是取舍 +- 容量限制会自然驱动路线规划、补给管理和交易行为 +- 比单纯格子背包更贴合生存题材 + +### 13.6.2 背包分层 + +建议将背包拆成 3 层: + +- `Quick Slots` + - 高频立即使用 + - 水、药、近战、少量工具 +- `Field Bag` + - 当前随身背包 + - 受体积限制 +- `Shelter Storage` + - 基地仓储 + - 用于长线积累 + +当前项目已经有 `Quick Slots + Inventory` 雏形,下一步建议补 `Shelter Storage`。 + +### 13.6.3 物品状态 + +每个背包物品后续建议支持: + +- `itemId` +- `count` +- `durability` +- `quality` +- `boundFlag`(任务物品或不可丢弃) +- `sourceTag`(掉落来源,便于日志和分析) + +### 13.6.4 背包交互原则 + +- 主 HUD 只显示快捷栏和容量摘要 +- 完整背包在弹窗面板中查看 +- 装备/使用/整理/分类都应在背包面板完成 +- 背包面板必须优先展示“对当前生存决策有帮助的信息” + +## 13.7 生命系统设计 + +生命系统不应只是一根血条,而是一组相互耦合的生存状态。 + +### 13.7.1 核心状态 + +- 生命 `life` +- 饥饿 `hunger` +- 口渴 `thirst` +- 精力 `energy` +- 理智 `sanity` +- 辐射 `radiation` + +### 13.7.2 设计原则 + +- 生命是最终硬失败边界 +- 饥饿与口渴是持续性压力来源 +- 精力限制连续行动长度 +- 理智影响事件与长期稳定性 +- 辐射是高风险区域的长期代价 + +### 13.7.3 阈值式生命系统 + +建议后续把状态效果从“单纯数值显示”升级成阈值逻辑: + +- `safe` +- `strained` +- `critical` +- `collapsed` + +每个阶段绑定不同惩罚: + +- 命中修正 +- 额外时间成本 +- 事件风险提升 +- 治疗效率下降 +- 逃跑成功率下降 + +### 13.7.4 生命系统与 UI + +主 HUD 只显示数值和条形图还不够,应该同步显示: + +- 当前生存威胁 +- 原因解释 +- 恢复路径提示 + +例如: + +- “脱水:口渴已压缩行动余量” +- “疲劳:建议短休或回避高风险搜刮” + +## 13.8 战斗系统设计 + +战斗不应该吞掉整个游戏,而应该是风险系统的一种表现。 + +### 13.8.1 当前定位是对的 + +当前仓库使用“距离 + 回合 + 动作选择”的轻量模式,这是合理的。 + +### 13.8.2 下一步建议 + +- 加入更清晰的命中区间提示 +- 加入武器与状态联动的解释文本 +- 区分“战斗结束”和“战后结算”两个阶段 +- 把战斗弹窗升级为更完整的战斗面板 + +### 13.8.3 战斗生命周期 + +1. 遭遇初始化 +2. 玩家选择动作 +3. 双方结算 +4. 更新距离与生命 +5. 写入战斗日志 +6. 判断胜负/逃跑 +7. 战后掉落与任务推进 + +## 13.9 事件系统设计 + +事件是废土题材最重要的内容增幅器。 + +要求: + +- 事件必须能解释触发原因 +- 事件必须能绑定状态、物品、任务和地点条件 +- 事件必须能写出可追溯日志 +- 事件应该偏向“抉择”而不是“随机弹窗骚扰” + +## 13.10 在线存档设计 + +作为账号制在线游戏,存档系统应被视为引擎的一部分。 + +### 13.10.1 必须保证 + +- 动作完成后立即得到权威新状态 +- 存档和视图状态保持一致 +- 任意时刻掉线后能恢复到最近稳定节点 + +### 13.10.2 建议增加 + +- 多存档槽 +- 存档版本迁移日志 +- 后端动作审计 +- 非法状态回滚策略 + +## 13.11 当前代码层面的下一批重点 + +1. 把 `engine.ts` 继续拆成子模块: + - `time-system` + - `inventory-system` + - `combat-system` + - `event-system` + - `quest-system` + - `view-projection` +2. 为状态阈值、背包容量、战斗结算分别补单元测试 +3. 引入基地仓库与背包转移逻辑 +4. 为背包、任务、蓝图面板补筛选与分类 +5. 为状态系统引入更明确的阶段化惩罚定义 + +## 13.12 结论 + +真正像游戏的关键,不是把更多信息摆上屏,而是: + +- 明确主循环 +- 明确系统边界 +- 明确高频与低频交互分层 +- 明确权威状态和表现层职责 + +这也是本轮把背包、任务、蓝图、日志转成系统弹窗的原因。主 HUD 应该负责“让玩家立刻决定下一步”,而完整系统面板负责“深入管理和规划”。 diff --git a/docs/README.md b/docs/README.md index cb486e3..d7d39c8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,8 @@ - 数值平衡:[05-数值与经济.md](./05-%E6%95%B0%E5%80%BC%E4%B8%8E%E7%BB%8F%E6%B5%8E.md) - 交互与界面:[07-UX%E4%B8%8E%E7%95%8C%E9%9D%A2%E4%BF%A1%E6%81%AF%E6%9E%B6%E6%9E%84.md](./07-UX%E4%B8%8E%E7%95%8C%E9%9D%A2%E4%BF%A1%E6%81%AF%E6%9E%B6%E6%9E%84.md) - 技术实现:[08-技术架构与代码导读.md](./08-%E6%8A%80%E6%9C%AF%E6%9E%B6%E6%9E%84%E4%B8%8E%E4%BB%A3%E7%A0%81%E5%AF%BC%E8%AF%BB.md)、[09-存档与后端.md](./09-%E5%AD%98%E6%A1%A3%E4%B8%8E%E5%90%8E%E7%AB%AF.md)、[10-本地运行与发布.md](./10-%E6%9C%AC%E5%9C%B0%E8%BF%90%E8%A1%8C%E4%B8%8E%E5%8F%91%E5%B8%83.md) + - 前端 HUD 实现:[12-前端HUD架构与资产管线.md](./12-%E5%89%8D%E7%AB%AFHUD%E6%9E%B6%E6%9E%84%E4%B8%8E%E8%B5%84%E4%BA%A7%E7%AE%A1%E7%BA%BF.md) + - 运行时与核心系统深化:[13-运行时引擎与核心系统深化设计.md](./13-%E8%BF%90%E8%A1%8C%E6%97%B6%E5%BC%95%E6%93%8E%E4%B8%8E%E6%A0%B8%E5%BF%83%E7%B3%BB%E7%BB%9F%E6%B7%B1%E5%8C%96%E8%AE%BE%E8%AE%A1.md) - 测试与验收:[11-QA%E6%B5%8B%E8%AF%95%E7%94%A8%E4%BE%8B%E4%B8%8E%E9%AA%8C%E6%94%B6%E6%B8%85%E5%8D%95.md](./11-QA%E6%B5%8B%E8%AF%95%E7%94%A8%E4%BE%8B%E4%B8%8E%E9%AA%8C%E6%94%B6%E6%B8%85%E5%8D%95.md) ## 图与源文件 @@ -28,4 +30,3 @@ - 时间推进:游戏内时间前进触发状态消耗、资源生长、事件刷新等。 - 风险收益:一次出行/探索的收益(资源/信息)与风险(伤害/污染/时间消耗)之间的平衡。 - 数据驱动:绝大多数内容由配置定义,代码仅提供通用规则与执行器。 - diff --git a/docs/generated/tinywaste-hud-reference.png b/docs/generated/tinywaste-hud-reference.png new file mode 100644 index 0000000..056d5f6 Binary files /dev/null and b/docs/generated/tinywaste-hud-reference.png differ diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..3014fca --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,30 @@ +import js from '@eslint/js' +import globals from 'globals' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['**/dist/**', '**/node_modules/**']), + { + files: ['**/*.{ts,tsx}'], + extends: [js.configs.recommended, tseslint.configs.recommended], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, + { + files: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'], + languageOptions: { + globals: { + ...globals.node, + describe: 'readonly', + it: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + }, + }, + }, +]) diff --git a/package.json b/package.json new file mode 100644 index 0000000..a452b27 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "tinywaste", + "private": true, + "version": "0.1.0", + "packageManager": "pnpm@10.33.2", + "scripts": { + "dev": "pnpm --parallel --filter @tinywaste/game-core --filter @tinywaste/content --filter @tinywaste/api --filter @tinywaste/web dev", + "build": "pnpm -r --if-present build", + "lint": "pnpm -r --if-present lint", + "test": "pnpm -r --if-present test", + "db:migrate": "pnpm --filter @tinywaste/api db:migrate" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "eslint": "^10.2.1", + "globals": "^17.5.0", + "tsup": "^8.5.0", + "tsx": "^4.20.6", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vitest": "^4.0.7" + } +} + diff --git a/packages/content/package.json b/packages/content/package.json new file mode 100644 index 0000000..6f83368 --- /dev/null +++ b/packages/content/package.json @@ -0,0 +1,24 @@ +{ + "name": "@tinywaste/content", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "dev": "tsup src/index.ts --format esm --dts --watch --out-dir dist", + "build": "tsup src/index.ts --format esm --dts --clean --out-dir dist", + "lint": "eslint src --ext .ts", + "test": "vitest run" + }, + "dependencies": { + "@tinywaste/game-core": "workspace:*" + } +} + diff --git a/packages/content/src/gameplay.test.ts b/packages/content/src/gameplay.test.ts new file mode 100644 index 0000000..759be89 --- /dev/null +++ b/packages/content/src/gameplay.test.ts @@ -0,0 +1,45 @@ +import { applyGameAction, buildGameView, createNewGameState } from '@tinywaste/game-core'; +import { describe, expect, it } from 'vitest'; +import { gameContent } from './index'; + +describe('TinyWaste gameplay loop', () => { + it('creates a new survivor at the shelter with a starter loadout', () => { + const state = createNewGameState(gameContent, '测试幸存者'); + + expect(state.player.placeId).toBe('home'); + expect(state.player.inventory.some((entry) => entry.itemId === 'shiv')).toBe(true); + expect(state.player.quests.q_tutorial_water.state).toBe('active'); + }); + + it('collects rain water and advances time when performing the base scavenging action', () => { + const state = createNewGameState(gameContent, '测试幸存者'); + const initialWater = state.player.inventory.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0; + const initialMinutes = state.world.time.totalMinutes; + + const result = applyGameAction(state, gameContent, { + type: 'perform-action', + actionId: 'home_rain_barrel', + }); + + const currentWater = result.state.player.inventory.find((entry) => entry.itemId === 'water_dirty')?.count ?? 0; + + expect(result.state.world.time.totalMinutes).toBeGreaterThan(initialMinutes); + expect(currentWater).toBeGreaterThan(initialWater); + expect(result.state.world.logs.at(-1)?.message).toContain('浑水'); + }); + + it('updates the current place after travel and exposes the new location in the game view', () => { + const state = createNewGameState(gameContent, '测试幸存者'); + const initialView = buildGameView(state, gameContent); + + const result = applyGameAction(state, gameContent, { + type: 'travel', + toPlaceId: initialView.map.edges[0]?.to ?? 'roadside', + }); + const view = buildGameView(result.state, gameContent); + + expect(result.state.player.placeId).toBe('roadside'); + expect(view.header.placeName).toBe('近郊土路'); + expect(view.map.places.find((place) => place.current)?.id).toBe('roadside'); + }); +}); diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts new file mode 100644 index 0000000..fec20a9 --- /dev/null +++ b/packages/content/src/index.ts @@ -0,0 +1,1244 @@ +import type { GameContent } from '@tinywaste/game-core'; + +export const gameContent: GameContent = { + version: '0.1.0', + inventoryCapacity: 36, + items: { + water_dirty: { + id: 'water_dirty', + name: '浑水', + desc: '简单过滤前的桶装浑水。能解渴,但会让身体吃点苦头。', + type: 'water', + stackLimit: 8, + volume: 1, + baseValue: 4, + effects: [ + { type: 'change-stat', stat: 'thirst', amount: 18 }, + { type: 'change-stat', stat: 'radiation', amount: 4 }, + ], + }, + water_purified: { + id: 'water_purified', + name: '净水', + desc: '烧开并过滤后的安全饮用水。', + type: 'water', + stackLimit: 8, + volume: 1, + baseValue: 9, + effects: [ + { type: 'change-stat', stat: 'thirst', amount: 28 }, + { type: 'change-stat', stat: 'radiation', amount: -2 }, + ], + }, + canned_beans: { + id: 'canned_beans', + name: '罐装豆子', + desc: '味道一般,但稳定可靠。', + type: 'food', + stackLimit: 6, + volume: 1, + baseValue: 10, + effects: [ + { type: 'change-stat', stat: 'hunger', amount: 22 }, + { type: 'change-stat', stat: 'energy', amount: 4 }, + ], + }, + jerky: { + id: 'jerky', + name: '风干肉条', + desc: '便携,耐放,口感像旧皮带。', + type: 'food', + stackLimit: 8, + volume: 1, + baseValue: 7, + effects: [{ type: 'change-stat', stat: 'hunger', amount: 14 }], + }, + herbal_tea: { + id: 'herbal_tea', + name: '草根热茶', + desc: '温热、苦涩,但能让心跳慢下来。', + type: 'water', + stackLimit: 6, + volume: 1, + baseValue: 12, + effects: [ + { type: 'change-stat', stat: 'thirst', amount: 10 }, + { type: 'change-stat', stat: 'sanity', amount: 8 }, + ], + }, + field_bandage: { + id: 'field_bandage', + name: '野战绷带', + desc: '沾了草药的简易绷带,止血比止痛更靠谱。', + type: 'medicine', + stackLimit: 5, + volume: 1, + baseValue: 15, + effects: [ + { type: 'change-stat', stat: 'life', amount: 18 }, + { type: 'change-stat', stat: 'sanity', amount: 2 }, + ], + }, + rad_pills: { + id: 'rad_pills', + name: '抗辐片', + desc: '味道像粉笔,但能压住身体里的灼烧感。', + type: 'medicine', + stackLimit: 4, + volume: 1, + baseValue: 20, + effects: [ + { type: 'change-stat', stat: 'radiation', amount: -18 }, + { type: 'change-stat', stat: 'sanity', amount: -4 }, + ], + }, + mushroom_stew: { + id: 'mushroom_stew', + name: '蘑菇炖罐', + desc: '热量不高,但足够让人重新像个人。', + type: 'food', + stackLimit: 4, + volume: 2, + baseValue: 18, + effects: [ + { type: 'change-stat', stat: 'hunger', amount: 18 }, + { type: 'change-stat', stat: 'sanity', amount: 6 }, + ], + }, + scrap_metal: { + id: 'scrap_metal', + name: '废金属', + desc: '废墟社会的硬通货之一。', + type: 'material', + stackLimit: 12, + volume: 1, + baseValue: 3, + }, + wood_plank: { + id: 'wood_plank', + name: '旧木板', + desc: '潮湿、开裂,但还能用。', + type: 'material', + stackLimit: 10, + volume: 1, + baseValue: 2, + }, + cloth_rag: { + id: 'cloth_rag', + name: '破布', + desc: '包扎、擦拭、打火都能凑合。', + type: 'material', + stackLimit: 12, + volume: 1, + baseValue: 2, + }, + herb_bundle: { + id: 'herb_bundle', + name: '草药束', + desc: '来自雨后的田边和破墙根。', + type: 'material', + stackLimit: 10, + volume: 1, + baseValue: 5, + }, + mushroom_cluster: { + id: 'mushroom_cluster', + name: '菌簇', + desc: '颜色看着危险,但煮透了还能吃。', + type: 'material', + stackLimit: 8, + volume: 1, + baseValue: 4, + }, + filter_charcoal: { + id: 'filter_charcoal', + name: '过滤炭芯', + desc: '净水和自制滤芯的核心。', + type: 'material', + stackLimit: 8, + volume: 1, + baseValue: 6, + }, + parts_wire: { + id: 'parts_wire', + name: '导线零件', + desc: '断头的电线、卡扣和焊片,能救急。', + type: 'material', + stackLimit: 10, + volume: 1, + baseValue: 6, + }, + ammo_rifle: { + id: 'ammo_rifle', + name: '步枪弹', + desc: '数量永远嫌少。', + type: 'ammo', + stackLimit: 20, + volume: 1, + baseValue: 5, + }, + shiv: { + id: 'shiv', + name: '磨尖铁片', + desc: '贴身时很有说服力。', + type: 'weapon', + stackLimit: 1, + volume: 2, + baseValue: 16, + equipSlot: 'weapon', + combat: { + damageMin: 7, + damageMax: 11, + range: 1, + hitBonus: 0.04, + critChance: 0.08, + critMult: 1.6, + }, + }, + crowbar: { + id: 'crowbar', + name: '撬棍', + desc: '开门、撬箱、打人都很好使。', + type: 'tool', + stackLimit: 1, + volume: 3, + baseValue: 22, + equipSlot: 'tool', + combat: { + damageMin: 9, + damageMax: 14, + range: 1, + hitBonus: 0.02, + critChance: 0.06, + critMult: 1.5, + }, + }, + pipe_rifle: { + id: 'pipe_rifle', + name: '管式步枪', + desc: '看着像会炸膛,但比徒手讲道理要强。', + type: 'weapon', + stackLimit: 1, + volume: 4, + baseValue: 36, + equipSlot: 'weapon', + combat: { + damageMin: 15, + damageMax: 23, + range: 4, + hitBonus: 0.12, + critChance: 0.1, + critMult: 1.8, + }, + }, + leather_coat: { + id: 'leather_coat', + name: '旧皮衣', + desc: '不轻,但能扛风也能扛爪子。', + type: 'armor', + stackLimit: 1, + volume: 3, + baseValue: 26, + equipSlot: 'body', + combat: { + damageMin: 0, + damageMax: 0, + range: 0, + hitBonus: 0, + armor: 2, + dodgeBonus: 0.02, + radiationResist: 1, + }, + }, + field_filter: { + id: 'field_filter', + name: '野外滤芯', + desc: '套在呼吸面罩和水瓶上的双用滤芯。', + type: 'quest', + stackLimit: 1, + volume: 2, + baseValue: 30, + }, + metro_keycard: { + id: 'metro_keycard', + name: '地铁门禁卡', + desc: '塑料边缘已经卷起,但芯片还活着。', + type: 'quest', + stackLimit: 1, + volume: 1, + baseValue: 0, + }, + }, + buffs: { + warm_rest: { + id: 'warm_rest', + name: '稍微缓过来了', + desc: '这点休息让身体没那么紧绷。', + durationMin: 180, + modifiers: { sanity: 0.01 }, + }, + }, + recipes: { + purify_water: { + id: 'purify_water', + name: '净化浑水', + desc: '把能喝和不能喝的差别,再往前推一步。', + workbenchType: 'craft', + inputs: [ + { itemId: 'water_dirty', count: 1 }, + { itemId: 'filter_charcoal', count: 1 }, + ], + outputs: [{ itemId: 'water_purified', count: 1 }], + timeCostMin: 18, + energyCost: 3, + }, + craft_bandage: { + id: 'craft_bandage', + name: '缠一卷绷带', + desc: '草药和破布混一混,也比流血强。', + workbenchType: 'craft', + inputs: [ + { itemId: 'cloth_rag', count: 2 }, + { itemId: 'herb_bundle', count: 1 }, + ], + outputs: [{ itemId: 'field_bandage', count: 1 }], + timeCostMin: 16, + energyCost: 4, + }, + craft_shiv: { + id: 'craft_shiv', + name: '再磨一把铁片', + desc: '野外最便宜的威慑力。', + workbenchType: 'craft', + inputs: [ + { itemId: 'scrap_metal', count: 2 }, + { itemId: 'cloth_rag', count: 1 }, + ], + outputs: [{ itemId: 'shiv', count: 1 }], + timeCostMin: 22, + energyCost: 5, + }, + brew_tea: { + id: 'brew_tea', + name: '煮一壶草根茶', + desc: '喝下去之前像药,喝下去之后也像药。', + workbenchType: 'craft', + inputs: [ + { itemId: 'water_purified', count: 1 }, + { itemId: 'herb_bundle', count: 1 }, + ], + outputs: [{ itemId: 'herbal_tea', count: 1 }], + timeCostMin: 14, + energyCost: 2, + }, + cook_stew: { + id: 'cook_stew', + name: '炖一锅菌菇罐头', + desc: '废土里的高级菜。', + workbenchType: 'craft', + inputs: [ + { itemId: 'mushroom_cluster', count: 1 }, + { itemId: 'canned_beans', count: 1 }, + ], + outputs: [{ itemId: 'mushroom_stew', count: 1 }], + timeCostMin: 28, + energyCost: 5, + }, + assemble_filter: { + id: 'assemble_filter', + name: '组装野外滤芯', + desc: '没有它,往深处走就是拿器官打赌。', + workbenchType: 'craft', + requirements: [{ kind: 'quest-state', questId: 'q_signal_hunt', state: 'completed' }], + inputs: [ + { itemId: 'filter_charcoal', count: 2 }, + { itemId: 'parts_wire', count: 1 }, + { itemId: 'cloth_rag', count: 1 }, + ], + outputs: [{ itemId: 'field_filter', count: 1 }], + timeCostMin: 30, + energyCost: 6, + }, + repair_rifle: { + id: 'repair_rifle', + name: '装一把管式步枪', + desc: '有点像工程,有点像祈祷。', + workbenchType: 'craft', + requirements: [{ kind: 'flag', flag: 'met_trader', value: true }], + inputs: [ + { itemId: 'scrap_metal', count: 4 }, + { itemId: 'parts_wire', count: 2 }, + { itemId: 'wood_plank', count: 1 }, + ], + outputs: [{ itemId: 'pipe_rifle', count: 1 }], + timeCostMin: 44, + energyCost: 8, + }, + }, + places: { + home: { + id: 'home', + name: '临时避难点', + desc: '一间被塑料布和铁皮拼起来的临时屋。这里不安全,但至少属于你。', + tags: ['home', 'safe'], + dangerLevel: 1, + services: ['craft', 'rest'], + actions: [ + { + id: 'home_rain_barrel', + name: '检查雨水桶', + kind: 'resource', + desc: '夜里结的露和前天的雨还剩一点。', + timeCostMin: 18, + energyCost: 2, + risk: 0.05, + rewardHint: '稳定获取水', + stock: { initial: 2, max: 4, refreshMin: 240, amountPerRefresh: 1 }, + lootTable: [{ itemId: 'water_dirty', min: 1, max: 2, chance: 1 }], + onEmptyText: '桶里只有一层灰。', + }, + { + id: 'home_salvage_corner', + name: '翻整理堆角', + kind: 'scavenge', + desc: '破屋的角落总会冒出一点还没用上的东西。', + timeCostMin: 22, + energyCost: 4, + risk: 0.08, + rewardHint: '少量基础材料', + stock: { initial: 2, max: 3, refreshMin: 360, amountPerRefresh: 1 }, + lootTable: [ + { itemId: 'cloth_rag', min: 1, max: 2, chance: 0.8 }, + { itemId: 'scrap_metal', min: 1, max: 2, chance: 0.7 }, + { itemId: 'wood_plank', min: 1, max: 1, chance: 0.5 }, + ], + onEmptyText: '这个角落已经被翻干净了。', + }, + { + id: 'home_listen_radio', + name: '调一下旧收音机', + kind: 'investigate', + desc: '噪音里时不时会蹦出半截有用的话。', + timeCostMin: 14, + energyCost: 1, + risk: 0.1, + rewardHint: '可能得到线索', + eventPool: ['home_radio_note'], + }, + ], + }, + roadside: { + id: 'roadside', + name: '近郊土路', + desc: '从避难点延伸出去的一段土路,车辙里总积着灰和碎玻璃。', + tags: ['road'], + dangerLevel: 2, + services: [], + actions: [ + { + id: 'roadside_forage', + name: '路边翻草丛', + kind: 'resource', + desc: '有时能翻到蘑菇,有时能翻到麻烦。', + timeCostMin: 20, + energyCost: 4, + risk: 0.18, + rewardHint: '草药与菌类', + stock: { initial: 2, max: 3, refreshMin: 300, amountPerRefresh: 1 }, + lootTable: [ + { itemId: 'herb_bundle', min: 1, max: 2, chance: 0.75 }, + { itemId: 'mushroom_cluster', min: 1, max: 1, chance: 0.55 }, + ], + }, + { + id: 'roadside_car', + name: '搜废车后备箱', + kind: 'scavenge', + desc: '锁已经坏了,剩下的问题是谁先把它搜空。', + timeCostMin: 28, + energyCost: 5, + risk: 0.22, + rewardHint: '零件、金属、偶发补给', + stock: { initial: 2, max: 3, refreshMin: 360, amountPerRefresh: 1 }, + lootTable: [ + { itemId: 'parts_wire', min: 1, max: 1, chance: 0.55 }, + { itemId: 'scrap_metal', min: 1, max: 2, chance: 0.85 }, + { itemId: 'water_dirty', min: 1, max: 1, chance: 0.35 }, + ], + eventPool: ['road_bandit_glimpse'], + }, + ], + }, + old_store: { + id: 'old_store', + name: '废弃便利店', + desc: '玻璃门早就没了,里面剩下的是空包装、鼠窝和一点没被发现的东西。', + tags: ['ruins'], + dangerLevel: 4, + services: [], + actions: [ + { + id: 'store_shelves', + name: '翻货架', + kind: 'scavenge', + desc: '越靠里,越有可能翻到别人没拿走的东西。', + timeCostMin: 32, + energyCost: 6, + risk: 0.32, + rewardHint: '食物与材料', + stock: { initial: 3, max: 4, refreshMin: 420, amountPerRefresh: 1 }, + lootTable: [ + { itemId: 'canned_beans', min: 1, max: 1, chance: 0.45 }, + { itemId: 'scrap_metal', min: 1, max: 2, chance: 0.8 }, + { itemId: 'filter_charcoal', min: 1, max: 1, chance: 0.35 }, + ], + eventPool: ['store_rats', 'store_basement_cache'], + }, + { + id: 'store_pharmacy', + name: '撬药柜', + kind: 'investigate', + desc: '药柜门板卡着,幸运的话还会有剩药。', + timeCostMin: 24, + energyCost: 5, + risk: 0.26, + rewardHint: '药物与线索', + stock: { initial: 1, max: 1, refreshMin: 9999, amountPerRefresh: 0 }, + lootTable: [ + { itemId: 'field_bandage', min: 1, max: 1, chance: 0.65 }, + { itemId: 'rad_pills', min: 1, max: 1, chance: 0.4 }, + ], + eventPool: ['store_clinic_note'], + }, + ], + }, + village_market: { + id: 'village_market', + name: '村庄集市', + desc: '用铁皮棚和布幔围起来的集市,所有人都在盯着所有人。', + tags: ['settlement', 'safe'], + dangerLevel: 2, + services: ['trade', 'rest', 'info'], + actions: [ + { + id: 'market_rumors', + name: '打听风声', + kind: 'investigate', + desc: '消息要么值钱,要么要命。', + timeCostMin: 18, + energyCost: 2, + risk: 0.08, + rewardHint: '任务线索', + eventPool: ['market_broker'], + }, + { + id: 'market_herbs', + name: '逛草药摊', + kind: 'resource', + desc: '有人把烂根和能治命的东西摆在了一起。', + timeCostMin: 14, + energyCost: 1, + risk: 0.04, + rewardHint: '草药和茶材', + stock: { initial: 1, max: 2, refreshMin: 360, amountPerRefresh: 1 }, + lootTable: [ + { itemId: 'herb_bundle', min: 1, max: 2, chance: 1 }, + { itemId: 'filter_charcoal', min: 1, max: 1, chance: 0.4 }, + ], + }, + ], + tradeOffers: [ + { + id: 'offer_clean_water', + name: '用废金属换净水', + desc: '三个废金属换一瓶净水。', + costs: [{ itemId: 'scrap_metal', count: 3 }], + gives: [{ itemId: 'water_purified', count: 1 }], + }, + { + id: 'offer_bandage', + name: '用草药换绷带', + desc: '草药和破布能换别人做好的绷带。', + costs: [ + { itemId: 'herb_bundle', count: 1 }, + { itemId: 'cloth_rag', count: 1 }, + ], + gives: [{ itemId: 'field_bandage', count: 1 }], + }, + { + id: 'offer_ammo', + name: '换一点子弹', + desc: '凑零件的人永远知道弹药的价。', + costs: [ + { itemId: 'scrap_metal', count: 4 }, + { itemId: 'parts_wire', count: 1 }, + ], + gives: [{ itemId: 'ammo_rifle', count: 4 }], + }, + ], + }, + rain_farm: { + id: 'rain_farm', + name: '雨棚农地', + desc: '一片被破塑料顶棚勉强护住的试验田,泥里长着蘑菇和希望。', + tags: ['resource'], + dangerLevel: 3, + services: [], + actions: [ + { + id: 'farm_mushrooms', + name: '采菌', + kind: 'resource', + desc: '棚下潮得发冷,菌类倒是长得很好。', + timeCostMin: 24, + energyCost: 4, + risk: 0.18, + rewardHint: '菌类与草药', + stock: { initial: 3, max: 4, refreshMin: 300, amountPerRefresh: 1 }, + lootTable: [ + { itemId: 'mushroom_cluster', min: 1, max: 2, chance: 1 }, + { itemId: 'herb_bundle', min: 1, max: 1, chance: 0.6 }, + ], + }, + { + id: 'farm_pump', + name: '抽一桶井水', + kind: 'resource', + desc: '水有铁锈味,但至少是水。', + timeCostMin: 20, + energyCost: 3, + risk: 0.12, + rewardHint: '稳定水源', + stock: { initial: 2, max: 3, refreshMin: 240, amountPerRefresh: 1 }, + lootTable: [{ itemId: 'water_dirty', min: 1, max: 2, chance: 1 }], + eventPool: ['farm_old_diary'], + }, + ], + }, + city_edge: { + id: 'city_edge', + name: '城市边缘', + desc: '从这里开始,楼和阴影都不再给人安全感。', + tags: ['ruins'], + dangerLevel: 5, + services: [], + modifiers: { sanity: 0.005 }, + actions: [ + { + id: 'city_checkpoint', + name: '搜检查站', + kind: 'scavenge', + desc: '旧路障下埋着箱子和更旧的记录。', + timeCostMin: 34, + energyCost: 6, + risk: 0.34, + rewardHint: '零件、弹药、主线线索', + stock: { initial: 2, max: 3, refreshMin: 480, amountPerRefresh: 1 }, + lootTable: [ + { itemId: 'parts_wire', min: 1, max: 2, chance: 0.8 }, + { itemId: 'ammo_rifle', min: 2, max: 4, chance: 0.45 }, + { itemId: 'wood_plank', min: 1, max: 1, chance: 0.5 }, + ], + eventPool: ['city_signal'], + }, + { + id: 'city_rooftops', + name: '观察高楼天线', + kind: 'investigate', + desc: '远处有规律的闪点,不像废墟会自己发出来的。', + timeCostMin: 26, + energyCost: 4, + risk: 0.24, + rewardHint: '得到通往地铁的情报', + eventPool: ['city_rooftop_flash'], + }, + ], + }, + subway_entrance: { + id: 'subway_entrance', + name: '老地铁口', + desc: '闸机早已没电,黑洞洞的入口像喉咙。', + tags: ['tunnel'], + dangerLevel: 6, + services: [], + modifiers: { sanity: 0.01, radiation: 0.004 }, + actions: [ + { + id: 'subway_lockers', + name: '撬储物柜', + kind: 'scavenge', + desc: '还没被撬开的柜子,不是幸运就是圈套。', + timeCostMin: 32, + energyCost: 6, + risk: 0.38, + rewardHint: '装备与零件', + stock: { initial: 2, max: 2, refreshMin: 9999, amountPerRefresh: 0 }, + lootTable: [ + { itemId: 'filter_charcoal', min: 1, max: 1, chance: 0.6 }, + { itemId: 'parts_wire', min: 1, max: 2, chance: 0.65 }, + { itemId: 'leather_coat', min: 1, max: 1, chance: 0.2 }, + ], + eventPool: ['subway_power'], + }, + { + id: 'subway_concourse', + name: '摸进候车大厅', + kind: 'investigate', + desc: '有旧广播在循环,像是在等谁回应。', + timeCostMin: 28, + energyCost: 5, + risk: 0.3, + rewardHint: '推进主线', + eventPool: ['subway_echo'], + }, + ], + }, + sewer: { + id: 'sewer', + name: '污染下水道', + desc: '潮湿、温热、充满不该活动的东西。', + tags: ['radiated', 'tunnel'], + dangerLevel: 7, + services: [], + modifiers: { radiation: 0.012, sanity: 0.014 }, + actions: [ + { + id: 'sewer_tunnel', + name: '深入排水隧道', + kind: 'scavenge', + desc: '越往里越像在别人胃里找路。', + timeCostMin: 36, + energyCost: 7, + risk: 0.42, + rewardHint: '高风险高收益', + stock: { initial: 2, max: 3, refreshMin: 600, amountPerRefresh: 1 }, + lootTable: [ + { itemId: 'scrap_metal', min: 2, max: 3, chance: 0.8 }, + { itemId: 'parts_wire', min: 1, max: 1, chance: 0.55 }, + { itemId: 'mushroom_cluster', min: 1, max: 1, chance: 0.4 }, + ], + eventPool: ['sewer_stalker'], + }, + { + id: 'sewer_office', + name: '搜维护办公室', + kind: 'investigate', + desc: '门牌还在,里面的秩序早就没了。', + timeCostMin: 30, + energyCost: 5, + risk: 0.32, + rewardHint: '关键任务物品', + stock: { initial: 1, max: 1, refreshMin: 9999, amountPerRefresh: 0 }, + eventPool: ['sewer_keycard'], + }, + ], + }, + vault_gate: { + id: 'vault_gate', + name: '避难所入口', + desc: '混凝土门板像一整面沉默的墙,门缝里却有电流声。', + tags: ['vault'], + dangerLevel: 8, + services: [], + actions: [ + { + id: 'vault_console', + name: '接入门禁终端', + kind: 'investigate', + desc: '键盘坏了一半,但屏幕还亮着。', + timeCostMin: 24, + energyCost: 3, + risk: 0.18, + rewardHint: '终局推进', + eventPool: ['vault_console_event'], + }, + ], + }, + }, + edges: [ + { from: 'home', to: 'roadside', travelTimeMin: 24, risk: 1.1 }, + { from: 'home', to: 'rain_farm', travelTimeMin: 34, risk: 1.3 }, + { from: 'roadside', to: 'home', travelTimeMin: 24, risk: 1.0 }, + { from: 'roadside', to: 'old_store', travelTimeMin: 20, risk: 1.4 }, + { from: 'roadside', to: 'village_market', travelTimeMin: 28, risk: 1.2 }, + { from: 'rain_farm', to: 'home', travelTimeMin: 34, risk: 1.2 }, + { from: 'rain_farm', to: 'village_market', travelTimeMin: 26, risk: 1.3 }, + { from: 'old_store', to: 'roadside', travelTimeMin: 20, risk: 1.4 }, + { from: 'village_market', to: 'roadside', travelTimeMin: 28, risk: 1.2 }, + { from: 'village_market', to: 'rain_farm', travelTimeMin: 26, risk: 1.3 }, + { + from: 'village_market', + to: 'city_edge', + travelTimeMin: 32, + risk: 1.8, + conditions: [{ kind: 'flag', flag: 'met_trader', value: true }], + }, + { from: 'city_edge', to: 'village_market', travelTimeMin: 32, risk: 1.7 }, + { + from: 'city_edge', + to: 'subway_entrance', + travelTimeMin: 30, + risk: 2.2, + conditions: [{ kind: 'flag', flag: 'got_subway_code', value: true }], + }, + { from: 'subway_entrance', to: 'city_edge', travelTimeMin: 30, risk: 2.0 }, + { + from: 'subway_entrance', + to: 'sewer', + travelTimeMin: 26, + risk: 2.5, + conditions: [{ kind: 'flag', flag: 'power_rerouted', value: true }], + }, + { from: 'sewer', to: 'subway_entrance', travelTimeMin: 26, risk: 2.4 }, + { + from: 'sewer', + to: 'vault_gate', + travelTimeMin: 34, + risk: 2.8, + conditions: [{ kind: 'has-item', itemId: 'field_filter', count: 1 }], + }, + { from: 'vault_gate', to: 'sewer', travelTimeMin: 34, risk: 2.8 }, + ], + events: { + home_radio_note: { + id: 'home_radio_note', + title: '电波里的碎句', + text: '旧收音机里混着白噪声和一句重复播报的短句:“……有交易,有水,有人知道旧线……”', + trigger: 'action', + options: [ + { + id: 'radio_focus', + text: '把频率记下来', + successText: '你把“旧线”和“集市”记在了心里。', + successEffects: [ + { type: 'log', message: '你决定去村庄集市碰碰运气。', tone: 'good' }, + ], + }, + ], + }, + road_bandit_glimpse: { + id: 'road_bandit_glimpse', + title: '有人在看你', + text: '你在废车玻璃里看到一道影子迅速缩回墙后。', + trigger: 'action', + options: [ + { + id: 'bandit_ignore', + text: '装作没看到,快走', + successEffects: [{ type: 'change-stat', stat: 'sanity', amount: -2 }], + successText: '你没回头,但后颈一直发紧。', + }, + { + id: 'bandit_search', + text: '过去查看', + check: { attribute: 'per', difficulty: 58 }, + successEffects: [ + { type: 'add-item', itemId: 'scrap_metal', count: 2 }, + { type: 'add-item', itemId: 'parts_wire', count: 1 }, + ], + failureEffects: [{ type: 'change-stat', stat: 'life', amount: -6 }], + successText: '人已经跑了,只留下半袋零件。', + failureText: '你刚探出头,一块飞来的碎砖擦破了手臂。', + }, + ], + }, + store_rats: { + id: 'store_rats', + title: '货架下的尖叫', + text: '翻动纸箱时,一群肥大的变异鼠突然从货架阴影里扑了出来。', + trigger: 'action', + options: [ + { + id: 'store_rats_fight', + text: '抄家伙迎上去', + successEffects: [{ type: 'start-combat', enemyId: 'rat_swarm' }], + }, + { + id: 'store_rats_backoff', + text: '放弃这片货架', + timeCostMin: 6, + successEffects: [{ type: 'change-stat', stat: 'sanity', amount: -3 }], + successText: '你退了出来,鼠群在身后继续尖叫。', + }, + ], + }, + store_basement_cache: { + id: 'store_basement_cache', + title: '地下储藏门', + text: '货架尽头有道半掩的地下门,门后传来一股潮湿冷气。', + trigger: 'action', + options: [ + { + id: 'basement_pry', + text: '用撬棍撬开', + check: { attribute: 'str', difficulty: 56 }, + successEffects: [ + { type: 'add-item', itemId: 'filter_charcoal', count: 1 }, + { type: 'add-item', itemId: 'water_dirty', count: 1 }, + ], + successText: '你在阴冷的地下格里摸到了炭芯和一瓶没漏完的水。', + failureEffects: [{ type: 'change-stat', stat: 'life', amount: -7 }], + failureText: '门板反弹回来,狠狠砸在你的前臂上。', + }, + { + id: 'basement_leave', + text: '先记住位置', + successEffects: [{ type: 'change-stat', stat: 'sanity', amount: 2 }], + }, + ], + }, + store_clinic_note: { + id: 'store_clinic_note', + title: '药柜里的便签', + text: '药柜里夹着一张潮掉的便签:“如果还能读到这张纸,去集市找老罗,他知道哪条地铁线还通电。”', + trigger: 'action', + options: [ + { + id: 'clinic_note_keep', + text: '把便签收起来', + successEffects: [{ type: 'set-flag', flag: 'clinic_note_found', value: true }], + }, + ], + }, + market_broker: { + id: 'market_broker', + title: '老罗', + text: '一个披着旧军大衣的老人用指节敲了敲桌面:“想往城里 deeper 走?拿出点诚意。”', + trigger: 'arrive', + options: [ + { + id: 'broker_trade_info', + text: '递上一瓶净水换消息', + conditions: [{ kind: 'has-item', itemId: 'water_purified', count: 1 }], + successEffects: [ + { type: 'remove-item', itemId: 'water_purified', count: 1 }, + { type: 'set-flag', flag: 'met_trader', value: true }, + { type: 'unlock-recipe', recipeId: 'repair_rifle' }, + { type: 'unlock-recipe', recipeId: 'brew_tea' }, + ], + successText: '老罗收下水,告诉你城边检查站还能收到旧地铁维护信号。', + }, + { + id: 'broker_smalltalk', + text: '先听,不交易', + successEffects: [{ type: 'set-flag', flag: 'met_trader', value: true }], + successText: '你没换到全部消息,但至少混了个脸熟。', + }, + ], + }, + farm_old_diary: { + id: 'farm_old_diary', + title: '农棚日志', + text: '抽水机旁边夹着一本湿掉的日志,上面反复写着:“雨季之后,地下味道变了。”', + trigger: 'action', + options: [ + { + id: 'farm_diary_keep', + text: '记下这句', + successEffects: [{ type: 'change-stat', stat: 'sanity', amount: 2 }], + }, + ], + }, + city_signal: { + id: 'city_signal', + title: '检查站残讯', + text: '在检查站的桌板底下,你找到一块还能亮的终端屏。上面显示:“转入旧维护口,代号 4A。”', + trigger: 'action', + options: [ + { + id: 'city_signal_record', + text: '记录地铁口代号', + successEffects: [ + { type: 'set-flag', flag: 'got_subway_code', value: true }, + { type: 'add-item', itemId: 'parts_wire', count: 1 }, + ], + successText: '你记下了通往老地铁口的维护代号。', + }, + ], + }, + city_rooftop_flash: { + id: 'city_rooftop_flash', + title: '屋顶闪点', + text: '高楼天线之间,一道不自然的蓝白闪光规律地亮了一下。', + trigger: 'action', + options: [ + { + id: 'rooftop_track', + text: '沿着闪光方向观察', + check: { attribute: 'per', difficulty: 60 }, + successEffects: [{ type: 'set-flag', flag: 'got_subway_code', value: true }], + successText: '你确认闪点来自旧地铁维护站方向。', + failureEffects: [{ type: 'change-stat', stat: 'sanity', amount: -3 }], + failureText: '你盯得太久,反而开始怀疑自己是不是看错了。', + }, + ], + }, + subway_power: { + id: 'subway_power', + title: '断续供电箱', + text: '储物柜后方藏着一套维护供电箱,灯带忽明忽暗。', + trigger: 'action', + options: [ + { + id: 'subway_reroute_power', + text: '重新跳线', + check: { attribute: 'int', difficulty: 64 }, + timeCostMin: 20, + successEffects: [ + { type: 'set-flag', flag: 'power_rerouted', value: true }, + { type: 'add-item', itemId: 'parts_wire', count: 1 }, + ], + successText: '几秒后,深处的绿灯终于亮了起来。', + failureEffects: [{ type: 'change-stat', stat: 'life', amount: -5 }], + failureText: '一阵爆火花蹿过来,烫得你手背发麻。', + }, + ], + }, + subway_echo: { + id: 'subway_echo', + title: '深处回音', + text: '大厅深处的回音像有人在一层层敲门。你意识到再往下,需要呼吸过滤。', + trigger: 'action', + options: [ + { + id: 'subway_echo_note', + text: '把这点记进脑子里', + successEffects: [{ type: 'change-stat', stat: 'sanity', amount: 2 }], + }, + ], + }, + sewer_stalker: { + id: 'sewer_stalker', + title: '水声不对', + text: '隧道里的水声忽然变成了有节奏的拖拽声,有东西在接近。', + trigger: 'action', + options: [ + { + id: 'sewer_stalker_fight', + text: '准备迎击', + successEffects: [{ type: 'start-combat', enemyId: 'glow_stalker' }], + }, + { + id: 'sewer_stalker_hide', + text: '缩进管道死角', + check: { attribute: 'agi', difficulty: 58 }, + successEffects: [{ type: 'change-stat', stat: 'sanity', amount: 1 }], + failureEffects: [{ type: 'change-stat', stat: 'life', amount: -8 }], + successText: '那团影子滑了过去,留下刺鼻的潮气。', + failureText: '你刚蹲下就被爪子扫中了肩膀。', + }, + ], + }, + sewer_keycard: { + id: 'sewer_keycard', + title: '维护办公室抽屉', + text: '最底层抽屉卡得很死,里面露出一截发黄塑料边。', + trigger: 'action', + options: [ + { + id: 'sewer_take_card', + text: '用力拽出来', + successEffects: [ + { type: 'add-item', itemId: 'metro_keycard', count: 1 }, + { type: 'set-flag', flag: 'got_keycard', value: true }, + ], + successText: '你拿到了地铁门禁卡,背面印着避难所后勤编号。', + }, + ], + }, + vault_console_event: { + id: 'vault_console_event', + title: '门禁终端', + text: '屏幕上跳出一行老旧提示:“输入权限或插入后勤卡。过滤状态需达标。”', + trigger: 'action', + options: [ + { + id: 'vault_insert_card', + text: '插入门禁卡,尝试启动', + conditions: [ + { kind: 'has-item', itemId: 'metro_keycard', count: 1 }, + { kind: 'has-item', itemId: 'field_filter', count: 1 }, + ], + successEffects: [{ type: 'set-flag', flag: 'vault_opened', value: true }], + successText: '门板深处传来沉重的机械回声,锁止结构开始一层层回退。', + }, + { + id: 'vault_step_back', + text: '先退一步,确认准备', + successEffects: [{ type: 'change-stat', stat: 'sanity', amount: 2 }], + }, + ], + }, + }, + enemies: { + rat_swarm: { + id: 'rat_swarm', + name: '变异鼠群', + desc: '不大,但数量和牙都很多。', + life: 24, + damageMin: 4, + damageMax: 8, + armor: 0, + range: 1, + hitRate: 0.52, + escapePressure: 0.12, + lootTable: [ + { itemId: 'cloth_rag', min: 1, max: 1, chance: 0.45 }, + { itemId: 'mushroom_cluster', min: 1, max: 1, chance: 0.3 }, + ], + }, + scavenger: { + id: 'scavenger', + name: '落单拾荒者', + desc: '又瘦又急,眼神比刀子先到。', + life: 34, + damageMin: 6, + damageMax: 10, + armor: 1, + range: 2, + hitRate: 0.58, + escapePressure: 0.18, + lootTable: [ + { itemId: 'scrap_metal', min: 1, max: 2, chance: 0.9 }, + { itemId: 'water_dirty', min: 1, max: 1, chance: 0.35 }, + ], + }, + feral_dog: { + id: 'feral_dog', + name: '疯狗', + desc: '骨架外露,动作快得像要散架。', + life: 28, + damageMin: 5, + damageMax: 11, + armor: 0, + range: 1, + hitRate: 0.6, + escapePressure: 0.2, + lootTable: [{ itemId: 'jerky', min: 1, max: 1, chance: 0.3 }], + }, + tunnel_raider: { + id: 'tunnel_raider', + name: '地铁劫徒', + desc: '知道该在什么时候要命,也知道该在什么时候要弹药。', + life: 42, + damageMin: 8, + damageMax: 13, + armor: 2, + range: 3, + hitRate: 0.64, + escapePressure: 0.25, + lootTable: [ + { itemId: 'ammo_rifle', min: 2, max: 4, chance: 0.55 }, + { itemId: 'parts_wire', min: 1, max: 1, chance: 0.5 }, + ], + }, + glow_stalker: { + id: 'glow_stalker', + name: '荧光潜伏者', + desc: '像被辐射烫过又被下水道养大的东西。', + life: 48, + damageMin: 9, + damageMax: 15, + armor: 1, + range: 1, + hitRate: 0.66, + escapePressure: 0.28, + lootTable: [ + { itemId: 'scrap_metal', min: 2, max: 3, chance: 0.8 }, + { itemId: 'filter_charcoal', min: 1, max: 1, chance: 0.45 }, + ], + }, + }, + quests: { + q_tutorial_water: { + id: 'q_tutorial_water', + title: '先把水烧开', + desc: '你需要先证明自己能活过今天。', + type: 'tutorial', + autoStart: [], + steps: [ + { id: 'q1_step1', text: '做出 1 瓶净水', kind: 'have-item', itemId: 'water_purified', count: 1 }, + { id: 'q1_step2', text: '去一趟村庄集市', kind: 'reach-place', placeId: 'village_market' }, + ], + rewards: [{ type: 'unlock-recipe', recipeId: 'cook_stew' }], + }, + q_market_contact: { + id: 'q_market_contact', + title: '和老罗搭上线', + desc: '想往城市深处走,集市里的消息是唯一捷径。', + type: 'main', + autoStart: [{ kind: 'quest-state', questId: 'q_tutorial_water', state: 'completed' }], + steps: [ + { id: 'q2_step1', text: '在集市见到老罗', kind: 'flag', flag: 'met_trader', value: true }, + { id: 'q2_step2', text: '前往城市边缘', kind: 'reach-place', placeId: 'city_edge' }, + ], + rewards: [{ type: 'unlock-recipe', recipeId: 'repair_rifle' }], + }, + q_signal_hunt: { + id: 'q_signal_hunt', + title: '追踪旧信号', + desc: '检查站和高楼天线都在指向同一个地方:老地铁口。', + type: 'main', + autoStart: [{ kind: 'quest-state', questId: 'q_market_contact', state: 'completed' }], + steps: [ + { id: 'q3_step1', text: '找到地铁代号', kind: 'flag', flag: 'got_subway_code', value: true }, + { id: 'q3_step2', text: '前往老地铁口', kind: 'reach-place', placeId: 'subway_entrance' }, + { id: 'q3_step3', text: '恢复地铁供电', kind: 'flag', flag: 'power_rerouted', value: true }, + ], + rewards: [{ type: 'unlock-recipe', recipeId: 'assemble_filter' }], + }, + q_sewer_route: { + id: 'q_sewer_route', + title: '从下水道进去', + desc: '深处的空气和污水会先杀死没有准备的人。', + type: 'main', + autoStart: [{ kind: 'quest-state', questId: 'q_signal_hunt', state: 'completed' }], + steps: [ + { id: 'q4_step1', text: '做出野外滤芯', kind: 'have-item', itemId: 'field_filter', count: 1 }, + { id: 'q4_step2', text: '进入污染下水道', kind: 'reach-place', placeId: 'sewer' }, + { id: 'q4_step3', text: '找到门禁卡', kind: 'have-item', itemId: 'metro_keycard', count: 1 }, + ], + rewards: [{ type: 'log', message: '现在,你有资格去试试那道混凝土门。', tone: 'good' }], + }, + q_vault_gate: { + id: 'q_vault_gate', + title: '打开避难所', + desc: '最后的门槛不会问你准备好了没。', + type: 'main', + autoStart: [{ kind: 'quest-state', questId: 'q_sewer_route', state: 'completed' }], + steps: [ + { id: 'q5_step1', text: '抵达避难所入口', kind: 'reach-place', placeId: 'vault_gate' }, + { id: 'q5_step2', text: '启动门禁终端', kind: 'flag', flag: 'vault_opened', value: true }, + ], + rewards: [{ type: 'log', message: '厚重门板正在开启。', tone: 'good' }], + }, + }, + travelEventPool: ['road_wanderer'], +}; + +gameContent.events.road_wanderer = { + id: 'road_wanderer', + title: '路边的求助声', + text: '一辆翻覆的车旁边,有人虚弱地问你有没有水。', + trigger: 'travel', + options: [ + { + id: 'wanderer_share', + text: '给他一瓶浑水', + conditions: [{ kind: 'has-item', itemId: 'water_dirty', count: 1 }], + successEffects: [ + { type: 'remove-item', itemId: 'water_dirty', count: 1 }, + { type: 'add-item', itemId: 'herb_bundle', count: 1 }, + ], + successText: '那人把最后一束干草药塞给了你。', + }, + { + id: 'wanderer_leave', + text: '继续赶路', + successEffects: [{ type: 'change-stat', stat: 'sanity', amount: -2 }], + successText: '你没有回头,但那声“算了”跟了你一路。', + }, + ], +}; + +export default gameContent; diff --git a/packages/content/tsconfig.json b/packages/content/tsconfig.json new file mode 100644 index 0000000..8ab54e7 --- /dev/null +++ b/packages/content/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"] +} + diff --git a/packages/game-core/package.json b/packages/game-core/package.json new file mode 100644 index 0000000..5c8b555 --- /dev/null +++ b/packages/game-core/package.json @@ -0,0 +1,24 @@ +{ + "name": "@tinywaste/game-core", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "dev": "tsup src/index.ts --format esm --dts --watch --out-dir dist", + "build": "tsup src/index.ts --format esm --dts --clean --out-dir dist", + "lint": "eslint src --ext .ts", + "test": "vitest run" + }, + "dependencies": { + "zod": "^4.1.12" + } +} + diff --git a/packages/game-core/src/engine.ts b/packages/game-core/src/engine.ts new file mode 100644 index 0000000..b79abab --- /dev/null +++ b/packages/game-core/src/engine.ts @@ -0,0 +1,1193 @@ +import { createRandomSource } from './rng'; +import type { + ActionPreview, + ActionResult, + CombatState, + ConditionRule, + EdgeView, + Effect, + EnemyDefinition, + GameAction, + GameContent, + GameState, + GameView, + InventoryEntry, + InventoryViewEntry, + LogEntry, + LogTone, + PendingEventView, + PlaceActionDefinition, + PlaceDefinition, + PlayerState, + QuestDefinition, + QuestRuntimeState, + QuestStep, + QuestView, + RecipeDefinition, + RecipeView, + SaveMeta, + StatLine, + StatusId, + TradeOfferView, + WorldState, +} from './types'; + +const SAVE_VERSION = 1; + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); + +const copyStats = (stats: StatLine): StatLine => ({ ...stats }); +const cloneGameState = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +const formatTime = (totalMinutes: number) => { + const day = Math.floor(totalMinutes / (24 * 60)) + 1; + const minuteOfDay = totalMinutes % (24 * 60); + const hours = Math.floor(minuteOfDay / 60) + .toString() + .padStart(2, '0'); + const minutes = (minuteOfDay % 60).toString().padStart(2, '0'); + return `Day ${day} · ${hours}:${minutes}`; +}; + +const dangerLabel = (danger: number) => { + if (danger <= 2) return '低风险'; + if (danger <= 4) return '中风险'; + if (danger <= 6) return '高风险'; + return '致命风险'; +}; + +const createLogEntry = (state: GameState, message: string, tone: LogTone = 'info'): LogEntry => ({ + id: `log_${state.world.logs.length + 1}_${state.world.time.totalMinutes}`, + minute: state.world.time.totalMinutes, + tone, + message, +}); + +const pushLog = (state: GameState, message: string, tone: LogTone = 'info') => { + state.world.logs.push(createLogEntry(state, message, tone)); + if (state.world.logs.length > 120) { + state.world.logs = state.world.logs.slice(-120); + } +}; + +const getItemCount = (player: PlayerState, itemId: string) => + player.inventory + .filter((entry) => entry.itemId === itemId) + .reduce((total, entry) => total + entry.count, 0); + +const getInventoryUsage = (state: GameState, content: GameContent) => + state.player.inventory.reduce((total, entry) => { + const item = content.items[entry.itemId]; + return total + item.volume * entry.count; + }, 0); + +const sortInventory = (inventory: InventoryEntry[]) => + [...inventory].sort((a, b) => a.itemId.localeCompare(b.itemId)); + +const removeItem = (player: PlayerState, itemId: string, count: number) => { + let remaining = count; + player.inventory = player.inventory.flatMap((entry) => { + if (entry.itemId !== itemId || remaining <= 0) { + return [entry]; + } + + if (entry.count <= remaining) { + remaining -= entry.count; + return []; + } + + const updated = { ...entry, count: entry.count - remaining }; + remaining = 0; + return [updated]; + }); + + return remaining === 0; +}; + +const addItem = (state: GameState, content: GameContent, itemId: string, count: number) => { + const item = content.items[itemId]; + let added = 0; + + while (added < count) { + const currentUsage = getInventoryUsage(state, content); + if (currentUsage + item.volume > content.inventoryCapacity) { + break; + } + + const stack = state.player.inventory.find( + (entry) => entry.itemId === itemId && entry.count < item.stackLimit, + ); + + if (stack) { + stack.count += 1; + } else { + state.player.inventory.push({ itemId, count: 1, durability: null }); + } + + added += 1; + } + + state.player.inventory = sortInventory(state.player.inventory); + return added; +}; + +const hasConditions = (state: GameState, conditions: ConditionRule[] | undefined) => { + if (!conditions || conditions.length === 0) return true; + return conditions.every((condition) => { + switch (condition.kind) { + case 'flag': + return (state.world.flags[condition.flag] ?? false) === (condition.value ?? true); + case 'has-item': + return getItemCount(state.player, condition.itemId) >= condition.count; + case 'quest-state': + return state.player.quests[condition.questId]?.state === condition.state; + case 'place': + return state.player.placeId === condition.placeId; + case 'day-at-least': + return state.world.time.day >= condition.day; + case 'stat-at-most': + return state.player.stats[condition.stat] <= condition.value; + case 'stat-at-least': + return state.player.stats[condition.stat] >= condition.value; + default: + return false; + } + }); +}; + +const getConditionFailure = (condition: ConditionRule | undefined, content: GameContent) => { + if (!condition) return undefined; + + switch (condition.kind) { + case 'flag': + return `需要触发世界标记 ${condition.flag}`; + case 'has-item': + return `缺少 ${content.items[condition.itemId]?.name ?? condition.itemId} x${condition.count}`; + case 'quest-state': + return `需要任务条件:${condition.questId}`; + case 'place': + return `需要前往 ${content.places[condition.placeId]?.name ?? condition.placeId}`; + case 'day-at-least': + return `至少生存到 Day ${condition.day}`; + case 'stat-at-most': + return `${condition.stat} 需要不高于 ${condition.value}`; + case 'stat-at-least': + return `${condition.stat} 需要不低于 ${condition.value}`; + default: + return '条件不足'; + } +}; + +const getCurrentPlace = (state: GameState, content: GameContent) => content.places[state.player.placeId]; + +const ensurePlaceRuntime = (state: GameState, content: GameContent, placeId: string) => { + if (state.world.places[placeId]) { + return state.world.places[placeId]; + } + + const place = content.places[placeId]; + const stocks: Record = {}; + for (const action of place.actions) { + if (action.stock) { + stocks[action.id] = action.stock.initial; + } + } + + state.world.places[placeId] = { + placeId, + heat: 0, + stocks, + lastRefreshMinute: state.world.time.totalMinutes, + visited: placeId === state.player.placeId, + }; + + return state.world.places[placeId]; +}; + +const applyStatChange = (state: GameState, stat: StatusId, amount: number) => { + const max = state.player.maxStats[stat]; + state.player.stats[stat] = clamp(state.player.stats[stat] + amount, 0, max); +}; + +const applyEffects = (state: GameState, content: GameContent, effects: Effect[]) => { + for (const effect of effects) { + switch (effect.type) { + case 'add-item': { + const added = addItem(state, content, effect.itemId, effect.count); + const item = content.items[effect.itemId]; + if (added > 0) { + pushLog(state, `获得 ${item.name} x${added}`, 'good'); + } + if (added < effect.count) { + pushLog(state, `${item.name} 有 ${effect.count - added} 件因为背包已满而丢失`, 'warn'); + } + break; + } + case 'remove-item': + removeItem(state.player, effect.itemId, effect.count); + pushLog(state, `消耗 ${content.items[effect.itemId].name} x${effect.count}`, 'info'); + break; + case 'change-stat': + applyStatChange(state, effect.stat, effect.amount); + break; + case 'add-buff': { + const existing = state.player.buffs.find((buff) => buff.buffId === effect.buffId); + if (existing) { + existing.remainingMin = Math.max(existing.remainingMin, effect.durationMin); + existing.stacks += effect.stacks ?? 1; + } else { + state.player.buffs.push({ + buffId: effect.buffId, + remainingMin: effect.durationMin, + stacks: effect.stacks ?? 1, + }); + } + pushLog(state, `获得状态:${content.buffs[effect.buffId].name}`, 'good'); + break; + } + case 'remove-buff': + state.player.buffs = state.player.buffs.filter((buff) => buff.buffId !== effect.buffId); + break; + case 'set-flag': + state.world.flags[effect.flag] = effect.value; + break; + case 'unlock-recipe': + if (!state.player.knownRecipes.includes(effect.recipeId)) { + state.player.knownRecipes.push(effect.recipeId); + pushLog(state, `解锁配方:${content.recipes[effect.recipeId].name}`, 'good'); + } + break; + case 'activate-quest': + if (state.player.quests[effect.questId]) { + state.player.quests[effect.questId].state = 'active'; + pushLog(state, `任务已激活:${content.quests[effect.questId].title}`, 'good'); + } + break; + case 'start-combat': + if (!state.world.activeCombat) { + const enemy = content.enemies[effect.enemyId]; + state.world.activeCombat = createCombat(enemy); + pushLog(state, `遭遇敌人:${enemy.name}`, 'combat'); + } + break; + case 'log': + pushLog(state, effect.message, effect.tone ?? 'info'); + break; + } + } +}; + +const createCombat = (enemy: EnemyDefinition): CombatState => ({ + enemyId: enemy.id, + enemyLife: enemy.life, + distance: Math.max(2, enemy.range), + round: 1, + playerGuarding: false, + enemyGuarding: false, + log: [{ round: 1, message: `${enemy.name} 出现了。` }], + rewardClaimed: false, +}); + +const getPlayerWeapon = (state: GameState, content: GameContent) => { + const equipped = state.player.equipment.weapon; + if (equipped) return content.items[equipped]; + return undefined; +}; + +const getPlayerArmor = (state: GameState, content: GameContent) => { + const equipped = state.player.equipment.body; + if (equipped) return content.items[equipped]; + return undefined; +}; + +const getStatusPenalty = (state: GameState) => { + let hitPenalty = 0; + let riskBonus = 0; + let armorPenalty = 0; + + if (state.player.stats.energy < 30) hitPenalty -= 0.1; + if (state.player.stats.thirst < 30) { + hitPenalty -= 0.08; + riskBonus += 0.1; + } + if (state.player.stats.hunger < 30) hitPenalty -= 0.05; + if (state.player.stats.sanity < 25) hitPenalty -= 0.06; + if (state.player.stats.radiation > 60) armorPenalty -= 1; + + return { hitPenalty, riskBonus, armorPenalty }; +}; + +const updateTimeFields = (state: GameState) => { + state.world.time.day = Math.floor(state.world.time.totalMinutes / (24 * 60)) + 1; + state.world.time.minuteOfDay = state.world.time.totalMinutes % (24 * 60); +}; + +const refreshPlaceStocks = (state: GameState, content: GameContent, deltaMin: number) => { + for (const place of Object.values(content.places)) { + const runtime = ensurePlaceRuntime(state, content, place.id); + runtime.heat = Math.max(0, runtime.heat - deltaMin / 120); + + for (const action of place.actions) { + if (!action.stock) continue; + const current = runtime.stocks[action.id] ?? action.stock.initial; + const growth = Math.floor(deltaMin / action.stock.refreshMin) * action.stock.amountPerRefresh; + runtime.stocks[action.id] = clamp(current + growth, 0, action.stock.max); + } + + runtime.lastRefreshMinute = state.world.time.totalMinutes; + } +}; + +const tickBuffs = (state: GameState, content: GameContent, minutes: number) => { + state.player.buffs = state.player.buffs.flatMap((buff) => { + const definition = content.buffs[buff.buffId]; + const remaining = buff.remainingMin - minutes; + if (definition.modifiers) { + for (const [stat, value] of Object.entries(definition.modifiers) as [StatusId, number][]) { + applyStatChange(state, stat, value * minutes); + } + } + + if (remaining <= 0) { + pushLog(state, `${definition.name} 已结束`, 'info'); + return []; + } + + return [{ ...buff, remainingMin: remaining }]; + }); +}; + +const handleThresholdDamage = (state: GameState) => { + if (state.player.stats.thirst <= 0) { + applyStatChange(state, 'life', -6); + } else if (state.player.stats.thirst < 10) { + applyStatChange(state, 'life', -2); + } + + if (state.player.stats.hunger <= 0) { + applyStatChange(state, 'life', -4); + } + + if (state.player.stats.sanity <= 0) { + state.world.gameOver = true; + state.world.deathReason = '理智崩溃'; + } + + if (state.player.stats.life <= 0) { + state.world.gameOver = true; + state.world.deathReason = '生命耗尽'; + } +}; + +const advanceTime = (state: GameState, content: GameContent, minutes: number, reason: string) => { + const place = getCurrentPlace(state, content); + const modifiers = place.modifiers ?? {}; + const hungerDecay = 0.03 + (modifiers.hunger ?? 0); + const thirstDecay = 0.05 + (modifiers.thirst ?? 0); + const energyDecay = 0.02 + (modifiers.energy ?? 0); + const sanityDecay = 0.01 + (modifiers.sanity ?? 0); + const radiationGain = Math.max(0, modifiers.radiation ?? 0); + + applyStatChange(state, 'hunger', -hungerDecay * minutes); + applyStatChange(state, 'thirst', -thirstDecay * minutes); + applyStatChange(state, 'energy', -energyDecay * minutes); + applyStatChange(state, 'sanity', -sanityDecay * minutes); + applyStatChange(state, 'radiation', radiationGain * minutes); + tickBuffs(state, content, minutes); + + state.world.time.totalMinutes += minutes; + updateTimeFields(state); + refreshPlaceStocks(state, content, minutes); + handleThresholdDamage(state); + + pushLog(state, `${reason},时间推进 ${minutes} 分钟`, 'info'); +}; + +const getEdge = (content: GameContent, from: string, to: string) => + content.edges.find((edge) => edge.from === from && edge.to === to); + +const rollLoot = (state: GameState, content: GameContent, lootTable: PlaceActionDefinition['lootTable']) => { + if (!lootTable || lootTable.length === 0) return; + const rng = createRandomSource(state.meta.rngState); + for (const loot of lootTable) { + if ((loot.chance ?? 1) <= 0 || !rng.chance(loot.chance ?? 1)) continue; + const amount = rng.int(loot.min, loot.max); + if (amount > 0) { + addItem(state, content, loot.itemId, amount); + pushLog(state, `找到 ${content.items[loot.itemId].name} x${amount}`, 'good'); + } + } + state.meta.rngState = rng.state; +}; + +const triggerEventFromPool = ( + state: GameState, + content: GameContent, + pool: string[] | undefined, + source: 'travel' | 'place' | 'arrival', +) => { + if (!pool || pool.length === 0) return; + const candidates = pool + .map((id) => content.events[id]) + .filter((event) => hasConditions(state, event.conditions)); + + if (candidates.length === 0) return; + + const rng = createRandomSource(state.meta.rngState); + const selected = rng.pick(candidates); + state.meta.rngState = rng.state; + state.world.pendingEvent = { eventId: selected.id, source }; +}; + +const getQuestStepDone = (state: GameState, step: QuestStep) => { + switch (step.kind) { + case 'reach-place': + return state.player.placeId === step.placeId; + case 'have-item': + return getItemCount(state.player, step.itemId) >= step.count; + case 'flag': + return (state.world.flags[step.flag] ?? false) === (step.value ?? true); + case 'survive-day': + return state.world.time.day >= step.day; + default: + return false; + } +}; + +const reconcileQuests = (state: GameState, content: GameContent) => { + for (const quest of Object.values(content.quests)) { + const runtime = state.player.quests[quest.id]; + + if (runtime.state === 'locked' && hasConditions(state, quest.autoStart)) { + runtime.state = 'active'; + pushLog(state, `接到任务:${quest.title}`, 'good'); + } + + if (runtime.state !== 'active') continue; + + const step = quest.steps[runtime.currentStepIndex]; + if (!step) continue; + + if (getQuestStepDone(state, step)) { + runtime.currentStepIndex += 1; + pushLog(state, `任务推进:${quest.title} - ${step.text}`, 'good'); + + if (runtime.currentStepIndex >= quest.steps.length) { + runtime.state = 'completed'; + pushLog(state, `任务完成:${quest.title}`, 'good'); + applyEffects(state, content, quest.rewards); + } + } + } +}; + +const withUpdatedMeta = (state: GameState) => { + state.meta.updatedAt = new Date().toISOString(); +}; + +const doTravel = (state: GameState, content: GameContent, toPlaceId: string) => { + const edge = getEdge(content, state.player.placeId, toPlaceId); + if (!edge) { + pushLog(state, '这条路径还没被打通。', 'warn'); + return; + } + + const condition = edge.conditions?.find((rule) => !hasConditions(state, [rule])); + if (condition) { + pushLog(state, getConditionFailure(condition, content) ?? '无法通行', 'warn'); + return; + } + + const { riskBonus } = getStatusPenalty(state); + advanceTime(state, content, edge.travelTimeMin, `前往 ${content.places[toPlaceId].name}`); + applyStatChange(state, 'energy', -(4 + edge.risk * 2)); + state.player.placeId = toPlaceId; + ensurePlaceRuntime(state, content, toPlaceId).visited = true; + + const rng = createRandomSource(state.meta.rngState); + if (rng.chance(Math.min(0.75, edge.risk * 0.18 + riskBonus))) { + state.meta.rngState = rng.state; + triggerEventFromPool(state, content, edge.eventPool ?? content.travelEventPool, 'travel'); + } else { + state.meta.rngState = rng.state; + triggerEventFromPool(state, content, content.places[toPlaceId].arrivalEventPool, 'arrival'); + } +} + +const doPlaceAction = (state: GameState, content: GameContent, actionId: string) => { + const place = getCurrentPlace(state, content); + const action = place.actions.find((entry) => entry.id === actionId); + if (!action) { + pushLog(state, '这个行动入口不存在。', 'warn'); + return; + } + + const failedCondition = action.requires?.find((rule) => !hasConditions(state, [rule])); + if (failedCondition) { + pushLog(state, getConditionFailure(failedCondition, content) ?? '条件不足', 'warn'); + return; + } + + const runtime = ensurePlaceRuntime(state, content, place.id); + if (action.stock && (runtime.stocks[action.id] ?? 0) <= 0) { + pushLog(state, action.onEmptyText ?? '这里已经被你搜刮空了。', 'warn'); + return; + } + + advanceTime(state, content, action.timeCostMin, action.name); + applyStatChange(state, 'energy', -action.energyCost); + runtime.heat += action.risk * 2 + 0.5; + + if (action.stock) { + runtime.stocks[action.id] = Math.max(0, (runtime.stocks[action.id] ?? 0) - 1); + } + + applyEffects(state, content, action.guaranteedEffects ?? []); + rollLoot(state, content, action.lootTable); + + const { riskBonus } = getStatusPenalty(state); + const rng = createRandomSource(state.meta.rngState); + state.meta.rngState = rng.state; + if (action.eventPool && rng.chance(Math.min(0.8, action.risk + runtime.heat * 0.04 + riskBonus))) { + triggerEventFromPool(state, content, action.eventPool, 'place'); + } +}; + +const doSelectEventOption = (state: GameState, content: GameContent, optionId: string) => { + if (!state.world.pendingEvent) { + pushLog(state, '当前没有待处理事件。', 'warn'); + return; + } + + const event = content.events[state.world.pendingEvent.eventId]; + const option = event.options.find((entry) => entry.id === optionId); + if (!option) { + pushLog(state, '事件选项不存在。', 'warn'); + return; + } + + const failedCondition = option.conditions?.find((rule) => !hasConditions(state, [rule])); + if (failedCondition) { + pushLog(state, getConditionFailure(failedCondition, content) ?? '条件不足', 'warn'); + return; + } + + if (option.timeCostMin) { + advanceTime(state, content, option.timeCostMin, `处理事件:${event.title}`); + } + + let success = true; + if (option.check) { + const rng = createRandomSource(state.meta.rngState); + const score = state.player.attributes[option.check.attribute] * 10 + rng.int(0, 30); + state.meta.rngState = rng.state; + success = score >= option.check.difficulty; + } + + if (success) { + applyEffects(state, content, option.successEffects); + if (option.successText) { + pushLog(state, option.successText, 'good'); + } + } else { + applyEffects(state, content, option.failureEffects ?? []); + if (option.failureText) { + pushLog(state, option.failureText, 'warn'); + } + } + + state.world.pendingEvent = null; +}; + +const doCraft = (state: GameState, content: GameContent, recipeId: string) => { + if (!state.player.knownRecipes.includes(recipeId)) { + pushLog(state, '你还不会这个配方。', 'warn'); + return; + } + + const recipe = content.recipes[recipeId]; + const failedRequirement = recipe.requirements?.find((rule) => !hasConditions(state, [rule])); + if (failedRequirement) { + pushLog(state, getConditionFailure(failedRequirement, content) ?? '条件不足', 'warn'); + return; + } + + const missingInput = recipe.inputs.find((input) => getItemCount(state.player, input.itemId) < input.count); + if (missingInput) { + pushLog( + state, + `缺少 ${content.items[missingInput.itemId].name} x${missingInput.count}`, + 'warn', + ); + return; + } + + for (const input of recipe.inputs) { + removeItem(state.player, input.itemId, input.count); + } + + advanceTime(state, content, recipe.timeCostMin, `制作 ${recipe.name}`); + applyStatChange(state, 'energy', -recipe.energyCost); + + for (const output of recipe.outputs) { + addItem(state, content, output.itemId, output.count); + } + + applyEffects(state, content, recipe.sideEffects ?? []); + pushLog(state, `制作完成:${recipe.name}`, 'good'); +}; + +const doUseItem = (state: GameState, content: GameContent, itemId: string) => { + const item = content.items[itemId]; + if (!item.effects || item.effects.length === 0) { + pushLog(state, '这个物品不能直接使用。', 'warn'); + return; + } + + if (!removeItem(state.player, itemId, 1)) { + pushLog(state, '背包里没有这个物品。', 'warn'); + return; + } + + applyEffects(state, content, item.effects); + pushLog(state, `使用了 ${item.name}`, 'good'); +}; + +const doEquipItem = (state: GameState, content: GameContent, itemId: string) => { + const item = content.items[itemId]; + if (!item.equipSlot) { + pushLog(state, '这个物品不能装备。', 'warn'); + return; + } + + if (getItemCount(state.player, itemId) <= 0) { + pushLog(state, '背包里没有这个物品。', 'warn'); + return; + } + + state.player.equipment[item.equipSlot] = itemId; + pushLog(state, `已装备 ${item.name}`, 'good'); +}; + +const doUnequip = (state: GameState, slot: 'weapon' | 'body' | 'tool') => { + if (!state.player.equipment[slot]) return; + state.player.equipment[slot] = undefined; +}; + +const doTrade = (state: GameState, content: GameContent, offerId: string) => { + const place = getCurrentPlace(state, content); + const offer = place.tradeOffers?.find((entry) => entry.id === offerId); + if (!offer) { + pushLog(state, '这里没有这笔交易。', 'warn'); + return; + } + + const missing = offer.costs.find((cost) => getItemCount(state.player, cost.itemId) < cost.count); + if (missing) { + pushLog(state, `缺少 ${content.items[missing.itemId].name} x${missing.count}`, 'warn'); + return; + } + + for (const cost of offer.costs) { + removeItem(state.player, cost.itemId, cost.count); + } + + for (const reward of offer.gives) { + addItem(state, content, reward.itemId, reward.count); + } + + advanceTime(state, content, 20, `交易:${offer.name}`); + pushLog(state, `完成交易:${offer.name}`, 'good'); +}; + +const doRest = (state: GameState, content: GameContent, minutes: number) => { + const place = getCurrentPlace(state, content); + if (!place.services.includes('rest')) { + pushLog(state, '这里不适合休息。', 'warn'); + return; + } + + advanceTime(state, content, minutes, '休息'); + applyStatChange(state, 'energy', Math.max(18, minutes * 0.4)); + applyStatChange(state, 'sanity', Math.max(6, minutes * 0.14)); + applyStatChange(state, 'life', Math.max(4, minutes * 0.08)); + pushLog(state, '你稍微喘了口气。', 'good'); +}; + +const doCombatMove = (state: GameState, content: GameContent, move: 'attack' | 'defend' | 'advance' | 'retreat' | 'escape') => { + if (!state.world.activeCombat) { + pushLog(state, '当前没有战斗。', 'warn'); + return; + } + + const combat = state.world.activeCombat; + const enemy = content.enemies[combat.enemyId]; + const weapon = getPlayerWeapon(state, content); + const armor = getPlayerArmor(state, content); + const { hitPenalty, armorPenalty } = getStatusPenalty(state); + const rng = createRandomSource(state.meta.rngState); + const playerWeaponStats = weapon?.combat ?? { + damageMin: 4, + damageMax: 8, + range: 1, + hitBonus: 0, + critChance: 0.04, + critMult: 1.5, + }; + + let resolved = ''; + + if (move === 'attack') { + const hitChance = clamp( + 0.62 + + playerWeaponStats.hitBonus + + hitPenalty + + (playerWeaponStats.range >= combat.distance ? 0.14 : -0.1 * (combat.distance - playerWeaponStats.range)), + 0.15, + 0.92, + ); + if (rng.chance(hitChance)) { + let damage = rng.int(playerWeaponStats.damageMin, playerWeaponStats.damageMax); + if (rng.chance(playerWeaponStats.critChance ?? 0.05)) { + damage = Math.round(damage * (playerWeaponStats.critMult ?? 1.5)); + } + damage = Math.max(1, damage - enemy.armor - (combat.enemyGuarding ? 2 : 0)); + combat.enemyLife -= damage; + resolved = `你命中 ${enemy.name},造成 ${damage} 点伤害。`; + } else { + resolved = `你朝 ${enemy.name} 出手,但落空了。`; + } + } else if (move === 'defend') { + combat.playerGuarding = true; + resolved = '你稳住身形,准备挡下下一波攻击。'; + } else if (move === 'advance') { + combat.distance = Math.max(1, combat.distance - 1); + resolved = `你逼近了 ${enemy.name}。距离变为 ${combat.distance}。`; + } else if (move === 'retreat') { + combat.distance = Math.min(6, combat.distance + 1); + resolved = `你向后拉开身位。距离变为 ${combat.distance}。`; + } else if (move === 'escape') { + const escapeChance = clamp(0.38 + state.player.attributes.agi * 0.05 + combat.distance * 0.08 - enemy.escapePressure, 0.1, 0.92); + if (rng.chance(escapeChance)) { + advanceTime(state, content, 12, '撤离战斗'); + applyStatChange(state, 'energy', -6); + pushLog(state, `你成功甩开了 ${enemy.name}。`, 'good'); + state.world.activeCombat = null; + state.meta.rngState = rng.state; + return; + } + resolved = '你试图脱身,但对方死咬不放。'; + } + + combat.log.push({ round: combat.round, message: resolved }); + pushLog(state, resolved, move === 'attack' ? 'combat' : 'info'); + + if (combat.enemyLife <= 0) { + if (!combat.rewardClaimed) { + for (const loot of enemy.lootTable) { + if ((loot.chance ?? 1) <= 0 || !rng.chance(loot.chance ?? 1)) continue; + const amount = rng.int(loot.min, loot.max); + if (amount > 0) addItem(state, content, loot.itemId, amount); + } + combat.rewardClaimed = true; + } + + pushLog(state, `你击败了 ${enemy.name}。`, 'good'); + state.world.activeCombat = null; + state.meta.rngState = rng.state; + return; + } + + const enemyHitChance = clamp(enemy.hitRate + (enemy.range >= combat.distance ? 0.08 : -0.06) - (armor?.combat?.dodgeBonus ?? 0), 0.2, 0.88); + if (rng.chance(enemyHitChance)) { + let damage = rng.int(enemy.damageMin, enemy.damageMax); + const armorValue = (armor?.combat?.armor ?? 0) + (combat.playerGuarding ? 3 : 0) + armorPenalty; + damage = Math.max(1, damage - armorValue); + applyStatChange(state, 'life', -damage); + const retaliation = `${enemy.name} 反击命中,造成 ${damage} 点伤害。`; + combat.log.push({ round: combat.round, message: retaliation }); + pushLog(state, retaliation, 'combat'); + } else { + const retaliation = `${enemy.name} 的攻击没有命中。`; + combat.log.push({ round: combat.round, message: retaliation }); + pushLog(state, retaliation, 'combat'); + } + + applyStatChange(state, 'energy', -5); + handleThresholdDamage(state); + combat.round += 1; + combat.playerGuarding = false; + combat.enemyGuarding = rng.chance(0.25); + + state.meta.rngState = rng.state; +}; + +const buildActionPreview = (state: GameState, content: GameContent, action: PlaceActionDefinition): ActionPreview => { + const failed = action.requires?.find((rule) => !hasConditions(state, [rule])); + const runtime = ensurePlaceRuntime(state, content, state.player.placeId); + const remainingStock = action.stock ? runtime.stocks[action.id] ?? action.stock.initial : null; + + let disabledReason = failed ? getConditionFailure(failed, content) : undefined; + if (!disabledReason && action.stock && remainingStock !== null && remainingStock <= 0) { + disabledReason = action.onEmptyText ?? '这里已经空了'; + } + + return { + id: action.id, + name: action.name, + kind: action.kind, + desc: action.desc, + timeCostMin: action.timeCostMin, + energyCost: action.energyCost, + risk: action.risk, + rewardHint: action.rewardHint, + disabledReason, + remainingStock, + }; +}; + +const buildRecipeView = (state: GameState, content: GameContent, recipe: RecipeDefinition): RecipeView => { + const requirementFailure = recipe.requirements?.find((rule) => !hasConditions(state, [rule])); + const missing = recipe.inputs.find((input) => getItemCount(state.player, input.itemId) < input.count); + const craftable = state.player.knownRecipes.includes(recipe.id) && !requirementFailure && !missing; + + return { + id: recipe.id, + name: recipe.name, + desc: recipe.desc, + timeCostMin: recipe.timeCostMin, + energyCost: recipe.energyCost, + craftable, + disabledReason: + !state.player.knownRecipes.includes(recipe.id) + ? '尚未解锁' + : requirementFailure + ? getConditionFailure(requirementFailure, content) + : missing + ? `缺少 ${content.items[missing.itemId].name}` + : undefined, + inputs: recipe.inputs.map((input) => ({ + itemId: input.itemId, + name: content.items[input.itemId].name, + count: input.count, + owned: getItemCount(state.player, input.itemId), + })), + outputs: recipe.outputs.map((output) => ({ + itemId: output.itemId, + name: content.items[output.itemId].name, + count: output.count, + })), + }; +}; + +const buildTradeView = (state: GameState, content: GameContent, place: PlaceDefinition): TradeOfferView[] => + (place.tradeOffers ?? []).map((offer) => { + const missing = offer.costs.find((cost) => getItemCount(state.player, cost.itemId) < cost.count); + + return { + id: offer.id, + name: offer.name, + desc: offer.desc, + available: !missing, + disabledReason: missing ? `缺少 ${content.items[missing.itemId].name}` : undefined, + gives: offer.gives.map((entry) => ({ + itemId: entry.itemId, + name: content.items[entry.itemId].name, + count: entry.count, + })), + costs: offer.costs.map((entry) => ({ + itemId: entry.itemId, + name: content.items[entry.itemId].name, + count: entry.count, + owned: getItemCount(state.player, entry.itemId), + })), + }; + }); + +const buildQuestView = (state: GameState, quest: QuestDefinition): QuestView => { + const runtime = state.player.quests[quest.id]; + return { + id: quest.id, + title: quest.title, + desc: quest.desc, + type: quest.type, + state: runtime.state, + currentStepIndex: runtime.currentStepIndex, + steps: quest.steps.map((step, index) => ({ + text: step.text, + done: index < runtime.currentStepIndex || getQuestStepDone(state, step), + })), + }; +}; + +const buildPendingEventView = (state: GameState, content: GameContent): PendingEventView | null => { + if (!state.world.pendingEvent) return null; + const event = content.events[state.world.pendingEvent.eventId]; + + return { + id: event.id, + title: event.title, + text: event.text, + options: event.options.map((option) => { + const failed = option.conditions?.find((rule) => !hasConditions(state, [rule])); + return { + id: option.id, + text: option.text, + disabledReason: failed ? getConditionFailure(failed, content) : undefined, + }; + }), + }; +}; + +export const createNewGameState = ( + content: GameContent, + playerName: string, + seed = Math.floor(Math.random() * 1_000_000_000), +): GameState => { + const meta: SaveMeta = { + saveVersion: SAVE_VERSION, + contentVersion: content.version, + seed, + rngState: seed, + difficulty: 'standard', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const player: PlayerState = { + name: playerName, + placeId: 'home', + stats: { + life: 88, + hunger: 72, + thirst: 68, + energy: 70, + sanity: 64, + radiation: 12, + }, + maxStats: { + life: 100, + hunger: 100, + thirst: 100, + energy: 100, + sanity: 100, + radiation: 100, + }, + attributes: { + str: 4, + agi: 5, + int: 6, + per: 5, + luck: 4, + }, + inventory: [ + { itemId: 'water_dirty', count: 2, durability: null }, + { itemId: 'canned_beans', count: 2, durability: null }, + { itemId: 'cloth_rag', count: 2, durability: null }, + { itemId: 'scrap_metal', count: 2, durability: null }, + { itemId: 'rad_pills', count: 1, durability: null }, + { itemId: 'shiv', count: 1, durability: null }, + { itemId: 'crowbar', count: 1, durability: null }, + ], + equipment: { + weapon: 'shiv', + tool: 'crowbar', + }, + buffs: [], + knownRecipes: ['purify_water', 'craft_bandage', 'craft_shiv'], + quests: Object.fromEntries( + Object.values(content.quests).map((quest) => [ + quest.id, + { + questId: quest.id, + state: quest.autoStart && quest.autoStart.length === 0 ? 'active' : 'locked', + currentStepIndex: 0, + } satisfies QuestRuntimeState, + ]), + ), + }; + + const world: WorldState = { + time: { + totalMinutes: 8 * 60, + day: 1, + minuteOfDay: 8 * 60, + }, + flags: {}, + places: {}, + logs: [], + pendingEvent: null, + activeCombat: null, + gameOver: false, + victory: false, + }; + + const state: GameState = { meta, player, world }; + for (const place of Object.keys(content.places)) { + ensurePlaceRuntime(state, content, place); + } + + pushLog(state, '你在避难点醒来,空气里全是尘土和铁锈味。', 'info'); + reconcileQuests(state, content); + return state; +}; + +export const applyGameAction = ( + currentState: GameState, + content: GameContent, + action: GameAction, +): ActionResult => { + const state = cloneGameState(currentState); + if (state.world.gameOver || state.world.victory) { + return { state, changed: false }; + } + + if (state.world.pendingEvent && action.type !== 'select-event-option') { + pushLog(state, '先处理当前事件,再做别的决定。', 'warn'); + return { state, changed: false }; + } + + if (state.world.activeCombat && action.type !== 'combat') { + pushLog(state, '战斗还没结束,先解决眼前的威胁。', 'warn'); + return { state, changed: false }; + } + + switch (action.type) { + case 'travel': + doTravel(state, content, action.toPlaceId); + break; + case 'perform-action': + doPlaceAction(state, content, action.actionId); + break; + case 'select-event-option': + doSelectEventOption(state, content, action.optionId); + break; + case 'combat': + doCombatMove(state, content, action.move); + break; + case 'craft': + doCraft(state, content, action.recipeId); + break; + case 'use-item': + doUseItem(state, content, action.itemId); + break; + case 'equip-item': + doEquipItem(state, content, action.itemId); + break; + case 'unequip-item': + doUnequip(state, action.slot); + break; + case 'trade': + doTrade(state, content, action.offerId); + break; + case 'rest': + doRest(state, content, action.minutes); + break; + } + + reconcileQuests(state, content); + withUpdatedMeta(state); + + if (state.player.placeId === 'vault_gate' && state.world.flags.vault_opened) { + state.world.victory = true; + pushLog(state, '你终于接近了真正的避难所核心。TinyWaste MVP 通关。', 'good'); + } + + return { state, changed: true }; +}; + +export const buildGameView = (state: GameState, content: GameContent): GameView => { + const place = getCurrentPlace(state, content); + const inventory: InventoryViewEntry[] = state.player.inventory.map((entry) => { + const item = content.items[entry.itemId]; + return { + ...entry, + name: item.name, + type: item.type, + desc: item.desc, + volume: item.volume, + equipSlot: item.equipSlot, + canUse: Boolean(item.effects?.length), + equipped: Object.values(state.player.equipment).includes(entry.itemId), + }; + }); + + const edges: EdgeView[] = content.edges + .filter((edge) => edge.from === state.player.placeId) + .map((edge) => { + const failed = edge.conditions?.find((rule) => !hasConditions(state, [rule])); + return { + from: edge.from, + to: edge.to, + travelTimeMin: edge.travelTimeMin, + risk: edge.risk, + blockedReason: failed ? getConditionFailure(failed, content) : undefined, + }; + }); + + return { + meta: state.meta, + header: { + placeName: place.name, + placeDesc: place.desc, + timeLabel: formatTime(state.world.time.totalMinutes), + riskLabel: dangerLabel(place.dangerLevel), + }, + player: { + name: state.player.name, + stats: copyStats(state.player.stats), + maxStats: copyStats(state.player.maxStats), + attributes: { ...state.player.attributes }, + equipment: { ...state.player.equipment }, + inventory, + inventoryUsage: getInventoryUsage(state, content), + inventoryCapacity: content.inventoryCapacity, + }, + map: { + places: Object.values(content.places).map((entry) => ({ + id: entry.id, + name: entry.name, + desc: entry.desc, + dangerLevel: entry.dangerLevel, + tags: entry.tags, + visited: state.world.places[entry.id]?.visited ?? false, + current: entry.id === state.player.placeId, + })), + edges, + }, + place: { + id: place.id, + name: place.name, + desc: place.desc, + services: place.services, + actions: place.actions.map((action) => buildActionPreview(state, content, action)), + tradeOffers: buildTradeView(state, content, place), + }, + recipes: Object.values(content.recipes).map((recipe) => buildRecipeView(state, content, recipe)), + quests: Object.values(content.quests).map((quest) => buildQuestView(state, quest)), + pendingEvent: buildPendingEventView(state, content), + combat: state.world.activeCombat + ? { + enemyId: state.world.activeCombat.enemyId, + enemyName: content.enemies[state.world.activeCombat.enemyId].name, + enemyLife: state.world.activeCombat.enemyLife, + enemyMaxLife: content.enemies[state.world.activeCombat.enemyId].life, + distance: state.world.activeCombat.distance, + round: state.world.activeCombat.round, + log: state.world.activeCombat.log.slice(-8), + availableMoves: [ + { id: 'combat', move: 'attack', label: '攻击' }, + { id: 'combat', move: 'defend', label: '防御' }, + { id: 'combat', move: 'advance', label: '前进' }, + { id: 'combat', move: 'retreat', label: '后退' }, + { id: 'combat', move: 'escape', label: '逃跑' }, + ], + } + : null, + logs: state.world.logs.slice(-18), + flags: { ...state.world.flags }, + gameOver: state.world.gameOver, + victory: state.world.victory, + deathReason: state.world.deathReason, + }; +}; diff --git a/packages/game-core/src/index.ts b/packages/game-core/src/index.ts new file mode 100644 index 0000000..ba14194 --- /dev/null +++ b/packages/game-core/src/index.ts @@ -0,0 +1,3 @@ +export * from './engine'; +export * from './rng'; +export * from './types'; diff --git a/packages/game-core/src/rng.test.ts b/packages/game-core/src/rng.test.ts new file mode 100644 index 0000000..33550b8 --- /dev/null +++ b/packages/game-core/src/rng.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { createRandomSource } from './rng'; + +describe('createRandomSource', () => { + it('produces deterministic sequences for the same seed', () => { + const a = createRandomSource(42); + const b = createRandomSource(42); + + expect([a.next(), a.next(), a.int(1, 10), a.chance(0.5)]).toEqual([ + b.next(), + b.next(), + b.int(1, 10), + b.chance(0.5), + ]); + }); + + it('always picks from the provided values', () => { + const rng = createRandomSource(7); + const options = ['home', 'roadside', 'market'] as const; + + for (let index = 0; index < 20; index += 1) { + expect(options).toContain(rng.pick(options)); + } + }); +}); diff --git a/packages/game-core/src/rng.ts b/packages/game-core/src/rng.ts new file mode 100644 index 0000000..86f41fa --- /dev/null +++ b/packages/game-core/src/rng.ts @@ -0,0 +1,35 @@ +export interface RandomSource { + state: number; + next(): number; + int(min: number, max: number): number; + chance(probability: number): boolean; + pick(values: T[]): T; +} + +export const createRandomSource = (seed: number): RandomSource => { + let state = seed >>> 0; + + const next = () => { + state += 0x6d2b79f5; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + + return { + get state() { + return state >>> 0; + }, + next, + int(min, max) { + return Math.floor(next() * (max - min + 1)) + min; + }, + chance(probability) { + return next() <= probability; + }, + pick(values) { + return values[Math.floor(next() * values.length)]!; + }, + }; +}; diff --git a/packages/game-core/src/systems/combat-factory.ts b/packages/game-core/src/systems/combat-factory.ts new file mode 100644 index 0000000..69e161f --- /dev/null +++ b/packages/game-core/src/systems/combat-factory.ts @@ -0,0 +1,12 @@ +import type { CombatState, EnemyDefinition } from '../types'; + +export const createCombat = (enemy: EnemyDefinition): CombatState => ({ + enemyId: enemy.id, + enemyLife: enemy.life, + distance: Math.max(2, enemy.range), + round: 1, + playerGuarding: false, + enemyGuarding: false, + log: [{ round: 1, message: `${enemy.name} 出现了。` }], + rewardClaimed: false, +}); diff --git a/packages/game-core/src/systems/effect-system.ts b/packages/game-core/src/systems/effect-system.ts new file mode 100644 index 0000000..31a64e3 --- /dev/null +++ b/packages/game-core/src/systems/effect-system.ts @@ -0,0 +1,78 @@ +import type { Effect, GameContent, GameState, StatusId } from '../types'; +import { createCombat } from './combat-factory'; +import { addItem, removeItem } from './inventory-system'; +import { pushLog } from './log-system'; +import { clamp } from './shared'; + +export const applyStatChange = (state: GameState, stat: StatusId, amount: number) => { + const max = state.player.maxStats[stat]; + state.player.stats[stat] = clamp(state.player.stats[stat] + amount, 0, max); +}; + +export const applyEffects = (state: GameState, content: GameContent, effects: Effect[]) => { + for (const effect of effects) { + switch (effect.type) { + case 'add-item': { + const added = addItem(state, content, effect.itemId, effect.count); + const item = content.items[effect.itemId]; + if (added > 0) { + pushLog(state, `获得 ${item.name} x${added}`, 'good'); + } + if (added < effect.count) { + pushLog(state, `${item.name} 有 ${effect.count - added} 件因为背包已满而丢失`, 'warn'); + } + break; + } + case 'remove-item': + removeItem(state.player, effect.itemId, effect.count); + pushLog(state, `消耗 ${content.items[effect.itemId].name} x${effect.count}`, 'info'); + break; + case 'change-stat': + applyStatChange(state, effect.stat, effect.amount); + break; + case 'add-buff': { + const existing = state.player.buffs.find((buff) => buff.buffId === effect.buffId); + if (existing) { + existing.remainingMin = Math.max(existing.remainingMin, effect.durationMin); + existing.stacks += effect.stacks ?? 1; + } else { + state.player.buffs.push({ + buffId: effect.buffId, + remainingMin: effect.durationMin, + stacks: effect.stacks ?? 1, + }); + } + pushLog(state, `获得状态:${content.buffs[effect.buffId].name}`, 'good'); + break; + } + case 'remove-buff': + state.player.buffs = state.player.buffs.filter((buff) => buff.buffId !== effect.buffId); + break; + case 'set-flag': + state.world.flags[effect.flag] = effect.value; + break; + case 'unlock-recipe': + if (!state.player.knownRecipes.includes(effect.recipeId)) { + state.player.knownRecipes.push(effect.recipeId); + pushLog(state, `解锁配方:${content.recipes[effect.recipeId].name}`, 'good'); + } + break; + case 'activate-quest': + if (state.player.quests[effect.questId]) { + state.player.quests[effect.questId].state = 'active'; + pushLog(state, `任务已激活:${content.quests[effect.questId].title}`, 'good'); + } + break; + case 'start-combat': + if (!state.world.activeCombat) { + const enemy = content.enemies[effect.enemyId]; + state.world.activeCombat = createCombat(enemy); + pushLog(state, `遭遇敌人:${enemy.name}`, 'combat'); + } + break; + case 'log': + pushLog(state, effect.message, effect.tone ?? 'info'); + break; + } + } +}; diff --git a/packages/game-core/src/systems/inventory-system.ts b/packages/game-core/src/systems/inventory-system.ts new file mode 100644 index 0000000..04abcbd --- /dev/null +++ b/packages/game-core/src/systems/inventory-system.ts @@ -0,0 +1,122 @@ +import type { GameContent, GameState, InventoryEntry, PlayerState } from '../types'; + +export const sortInventory = (inventory: InventoryEntry[]) => + [...inventory].sort((a, b) => a.itemId.localeCompare(b.itemId)); + +export const getItemCountFromEntries = (inventory: InventoryEntry[], itemId: string) => + inventory + .filter((entry) => entry.itemId === itemId) + .reduce((total, entry) => total + entry.count, 0); + +export const getItemCount = (player: PlayerState, itemId: string) => getItemCountFromEntries(player.inventory, itemId); + +export const getContainerUsage = (inventory: InventoryEntry[], content: GameContent) => + inventory.reduce((total, entry) => { + const item = content.items[entry.itemId]; + return total + item.volume * entry.count; + }, 0); + +export const getInventoryUsage = (state: GameState, content: GameContent) => + getContainerUsage(state.player.inventory, content); + +export const getStorageUsage = (state: GameState, content: GameContent) => + getContainerUsage(state.player.storage, content); + +export const removeItemFromEntries = (inventory: InventoryEntry[], itemId: string, count: number) => { + let remaining = count; + const nextInventory = inventory.flatMap((entry) => { + if (entry.itemId !== itemId || remaining <= 0) { + return [entry]; + } + + if (entry.count <= remaining) { + remaining -= entry.count; + return []; + } + + const updated = { ...entry, count: entry.count - remaining }; + remaining = 0; + return [updated]; + }); + + return { + inventory: sortInventory(nextInventory), + removedCount: count - remaining, + success: remaining === 0, + }; +}; + +export const removeItem = (player: PlayerState, itemId: string, count: number) => { + const result = removeItemFromEntries(player.inventory, itemId, count); + player.inventory = result.inventory; + return result.success; +}; + +export const removeItemFromStorage = (state: GameState, itemId: string, count: number) => { + const result = removeItemFromEntries(state.player.storage, itemId, count); + state.player.storage = result.inventory; + return result.success; +}; + +export const addItemToEntries = ( + inventory: InventoryEntry[], + content: GameContent, + itemId: string, + count: number, + capacity: number, +) => { + const item = content.items[itemId]; + const nextInventory = inventory.map((entry) => ({ ...entry })); + let addedCount = 0; + + while (addedCount < count) { + const currentUsage = getContainerUsage(nextInventory, content); + if (currentUsage + item.volume > capacity) { + break; + } + + const stack = nextInventory.find( + (entry) => entry.itemId === itemId && entry.count < item.stackLimit && entry.durability == null, + ); + + if (stack) { + stack.count += 1; + } else { + nextInventory.push({ itemId, count: 1, durability: null }); + } + + addedCount += 1; + } + + return { + inventory: sortInventory(nextInventory), + addedCount, + }; +}; + +export const addItem = (state: GameState, content: GameContent, itemId: string, count: number) => { + const result = addItemToEntries( + state.player.inventory, + content, + itemId, + count, + content.inventoryCapacity, + ); + state.player.inventory = result.inventory; + return result.addedCount; +}; + +export const addItemToStorage = (state: GameState, content: GameContent, itemId: string, count: number) => { + const result = addItemToEntries( + state.player.storage, + content, + itemId, + count, + content.storageCapacity, + ); + state.player.storage = result.inventory; + return result.addedCount; +}; + +export const isItemEquipped = (player: PlayerState, itemId: string) => + Object.values(player.equipment).includes(itemId); diff --git a/packages/game-core/src/systems/log-system.ts b/packages/game-core/src/systems/log-system.ts new file mode 100644 index 0000000..d4c583c --- /dev/null +++ b/packages/game-core/src/systems/log-system.ts @@ -0,0 +1,15 @@ +import type { GameState, LogEntry, LogTone } from '../types'; + +export const createLogEntry = (state: GameState, message: string, tone: LogTone = 'info'): LogEntry => ({ + id: `log_${state.world.logs.length + 1}_${state.world.time.totalMinutes}`, + minute: state.world.time.totalMinutes, + tone, + message, +}); + +export const pushLog = (state: GameState, message: string, tone: LogTone = 'info') => { + state.world.logs.push(createLogEntry(state, message, tone)); + if (state.world.logs.length > 120) { + state.world.logs = state.world.logs.slice(-120); + } +}; diff --git a/packages/game-core/src/systems/place-system.ts b/packages/game-core/src/systems/place-system.ts new file mode 100644 index 0000000..968d381 --- /dev/null +++ b/packages/game-core/src/systems/place-system.ts @@ -0,0 +1,75 @@ +import type { ConditionRule, GameContent, GameState } from '../types'; +import { getItemCount } from './inventory-system'; + +export const hasConditions = (state: GameState, conditions: ConditionRule[] | undefined) => { + if (!conditions || conditions.length === 0) return true; + return conditions.every((condition) => { + switch (condition.kind) { + case 'flag': + return (state.world.flags[condition.flag] ?? false) === (condition.value ?? true); + case 'has-item': + return getItemCount(state.player, condition.itemId) >= condition.count; + case 'quest-state': + return state.player.quests[condition.questId]?.state === condition.state; + case 'place': + return state.player.placeId === condition.placeId; + case 'day-at-least': + return state.world.time.day >= condition.day; + case 'stat-at-most': + return state.player.stats[condition.stat] <= condition.value; + case 'stat-at-least': + return state.player.stats[condition.stat] >= condition.value; + default: + return false; + } + }); +}; + +export const getConditionFailure = (condition: ConditionRule | undefined, content: GameContent) => { + if (!condition) return undefined; + + switch (condition.kind) { + case 'flag': + return `需要触发世界标记 ${condition.flag}`; + case 'has-item': + return `缺少 ${content.items[condition.itemId]?.name ?? condition.itemId} x${condition.count}`; + case 'quest-state': + return `需要任务条件:${condition.questId}`; + case 'place': + return `需要前往 ${content.places[condition.placeId]?.name ?? condition.placeId}`; + case 'day-at-least': + return `至少生存到 Day ${condition.day}`; + case 'stat-at-most': + return `${condition.stat} 需要不高于 ${condition.value}`; + case 'stat-at-least': + return `${condition.stat} 需要不低于 ${condition.value}`; + default: + return '条件不足'; + } +}; + +export const getCurrentPlace = (state: GameState, content: GameContent) => content.places[state.player.placeId]; + +export const ensurePlaceRuntime = (state: GameState, content: GameContent, placeId: string) => { + if (state.world.places[placeId]) { + return state.world.places[placeId]; + } + + const place = content.places[placeId]; + const stocks: Record = {}; + for (const action of place.actions) { + if (action.stock) { + stocks[action.id] = action.stock.initial; + } + } + + state.world.places[placeId] = { + placeId, + heat: 0, + stocks, + lastRefreshMinute: state.world.time.totalMinutes, + visited: placeId === state.player.placeId, + }; + + return state.world.places[placeId]; +}; diff --git a/packages/game-core/src/systems/quest-system.ts b/packages/game-core/src/systems/quest-system.ts new file mode 100644 index 0000000..535debd --- /dev/null +++ b/packages/game-core/src/systems/quest-system.ts @@ -0,0 +1,63 @@ +import type { GameContent, GameState, QuestDefinition, QuestStep } from '../types'; +import { applyEffects } from './effect-system'; +import { getItemCount } from './inventory-system'; +import { pushLog } from './log-system'; +import { hasConditions } from './place-system'; + +export const getQuestStepDone = (state: GameState, step: QuestStep) => { + switch (step.kind) { + case 'reach-place': + return state.player.placeId === step.placeId; + case 'have-item': + return getItemCount(state.player, step.itemId) >= step.count; + case 'flag': + return (state.world.flags[step.flag] ?? false) === (step.value ?? true); + case 'survive-day': + return state.world.time.day >= step.day; + default: + return false; + } +}; + +export const buildQuestView = (state: GameState, quest: QuestDefinition) => { + const runtime = state.player.quests[quest.id]; + return { + id: quest.id, + title: quest.title, + desc: quest.desc, + type: quest.type, + state: runtime.state, + currentStepIndex: runtime.currentStepIndex, + steps: quest.steps.map((step, index) => ({ + text: step.text, + done: index < runtime.currentStepIndex || getQuestStepDone(state, step), + })), + }; +}; + +export const reconcileQuests = (state: GameState, content: GameContent) => { + for (const quest of Object.values(content.quests)) { + const runtime = state.player.quests[quest.id]; + + if (runtime.state === 'locked' && hasConditions(state, quest.autoStart)) { + runtime.state = 'active'; + pushLog(state, `接到任务:${quest.title}`, 'good'); + } + + if (runtime.state !== 'active') continue; + + const step = quest.steps[runtime.currentStepIndex]; + if (!step) continue; + + if (getQuestStepDone(state, step)) { + runtime.currentStepIndex += 1; + pushLog(state, `任务推进:${quest.title} - ${step.text}`, 'good'); + + if (runtime.currentStepIndex >= quest.steps.length) { + runtime.state = 'completed'; + pushLog(state, `任务完成:${quest.title}`, 'good'); + applyEffects(state, content, quest.rewards); + } + } + } +}; diff --git a/packages/game-core/src/systems/shared.ts b/packages/game-core/src/systems/shared.ts new file mode 100644 index 0000000..223a43b --- /dev/null +++ b/packages/game-core/src/systems/shared.ts @@ -0,0 +1,24 @@ +import type { StatLine } from '../types'; + +export const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); + +export const copyStats = (stats: StatLine): StatLine => ({ ...stats }); + +export const cloneGameState = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +export const formatTime = (totalMinutes: number) => { + const day = Math.floor(totalMinutes / (24 * 60)) + 1; + const minuteOfDay = totalMinutes % (24 * 60); + const hours = Math.floor(minuteOfDay / 60) + .toString() + .padStart(2, '0'); + const minutes = (minuteOfDay % 60).toString().padStart(2, '0'); + return `Day ${day} · ${hours}:${minutes}`; +}; + +export const dangerLabel = (danger: number) => { + if (danger <= 2) return '低风险'; + if (danger <= 4) return '中风险'; + if (danger <= 6) return '高风险'; + return '致命风险'; +}; diff --git a/packages/game-core/src/systems/survival-system.ts b/packages/game-core/src/systems/survival-system.ts new file mode 100644 index 0000000..7c08035 --- /dev/null +++ b/packages/game-core/src/systems/survival-system.ts @@ -0,0 +1,356 @@ +import { statusIds, type GameContent, type GameState, type StatusId, type StatusTier, type SurvivalStateView } from '../types'; +import { applyStatChange } from './effect-system'; +import { pushLog } from './log-system'; +import { ensurePlaceRuntime, getCurrentPlace } from './place-system'; +import { clamp } from './shared'; + +const SURVIVAL_COPY: Record< + StatusId, + Record> +> = { + life: { + safe: { + summary: '状态稳定', + impact: '可以承受一次中等失误。', + recovery: '保持补给与休整节奏。', + }, + strained: { + summary: '轻度创伤', + impact: '高风险行动会更快把你推入重伤区。', + recovery: '用绷带或短休稳住生命线。', + }, + critical: { + summary: '重度创伤', + impact: '战斗、防御和撤离都会连锁恶化。', + recovery: '立刻治疗,避免继续深入。', + }, + collapsed: { + summary: '生命耗尽', + impact: '当前幸存者已经无法继续行动。', + recovery: '需要立刻回滚到有效治疗前的状态。', + }, + }, + hunger: { + safe: { + summary: '热量充足', + impact: '制作和远行还在舒适区。', + recovery: '维持稳定食物循环。', + }, + strained: { + summary: '饥饿累积', + impact: '精力恢复和行动效率开始下降。', + recovery: '尽快补充主食或高热量食品。', + }, + critical: { + summary: '严重饥饿', + impact: '连续行动会快速拖垮精力与命中。', + recovery: '优先回营或立刻进食。', + }, + collapsed: { + summary: '饥饿崩溃', + impact: '身体开始直接消耗生命维持运转。', + recovery: '必须立刻进食并停止冒险。', + }, + }, + thirst: { + safe: { + summary: '水分稳定', + impact: '出行窗口仍然充裕。', + recovery: '保持饮水储备在快捷栏。', + }, + strained: { + summary: '轻度脱水', + impact: '路途风险和判断失误开始上升。', + recovery: '优先喝水或返回避难所。', + }, + critical: { + summary: '严重脱水', + impact: '探索容错快速缩窄,生命会被持续挤压。', + recovery: '立刻饮水,避免继续移动。', + }, + collapsed: { + summary: '脱水崩溃', + impact: '生命会在每次时间推进中直接流失。', + recovery: '必须第一时间补水。', + }, + }, + energy: { + safe: { + summary: '体能稳定', + impact: '还能支持一轮完整行动。', + recovery: '保持休整与行动节奏平衡。', + }, + strained: { + summary: '疲劳显现', + impact: '命中与远行收益开始下降。', + recovery: '安排短休,减少连续高压行动。', + }, + critical: { + summary: '极度疲劳', + impact: '下一次遭遇很可能直接变成失控局面。', + recovery: '优先休息,而不是继续搜刮。', + }, + collapsed: { + summary: '体能枯竭', + impact: '会反向拖低理智并伤及生命。', + recovery: '必须停下恢复。', + }, + }, + sanity: { + safe: { + summary: '精神稳定', + impact: '事件选择还在可控范围内。', + recovery: '维持安全节奏与安定补给。', + }, + strained: { + summary: '精神波动', + impact: '高压事件更容易滚向坏结局。', + recovery: '休息、热食与安定环境都有帮助。', + }, + critical: { + summary: '理智滑坡', + impact: '事件风险和战斗容错明显下降。', + recovery: '尽快离开高压地点并恢复精神状态。', + }, + collapsed: { + summary: '理智崩溃', + impact: '当前存档进入强制失败边界。', + recovery: '必须在崩溃前完成恢复。', + }, + }, + radiation: { + safe: { + summary: '污染可控', + impact: '身体还能处理当前辐射剂量。', + recovery: '保持净水与抗辐射药物储备。', + }, + strained: { + summary: '污染累积', + impact: '长期收益开始被健康成本侵蚀。', + recovery: '尽快净化、回避热区。', + }, + critical: { + summary: '高辐射负载', + impact: '生命与治疗效率会持续受损。', + recovery: '立刻服药并退出污染区域。', + }, + collapsed: { + summary: '辐射失控', + impact: '身体正在快速失守。', + recovery: '必须马上清辐与撤离。', + }, + }, +}; + +export const STATUS_LABELS: Record = { + life: '生命', + hunger: '饥饿', + thirst: '口渴', + energy: '精力', + sanity: '理智', + radiation: '辐射', +}; + +const STATUS_SEVERITY: Record = { + safe: 0, + strained: 1, + critical: 2, + collapsed: 3, +}; + +export const getStatusTier = (stat: StatusId, value: number, maxValue: number): StatusTier => { + const ratio = maxValue <= 0 ? 0 : value / maxValue; + + if (stat === 'radiation') { + if (ratio >= 1) return 'collapsed'; + if (ratio >= 0.82) return 'critical'; + if (ratio >= 0.42) return 'strained'; + return 'safe'; + } + + if (ratio <= 0) return 'collapsed'; + if (ratio <= 0.32) return 'critical'; + if (ratio <= 0.6) return 'strained'; + return 'safe'; +}; + +export const describeStatus = ( + stat: StatusId, + value: number, + maxValue: number, +): SurvivalStateView => { + const tier = getStatusTier(stat, value, maxValue); + const copy = SURVIVAL_COPY[stat][tier]; + + return { + stat, + label: STATUS_LABELS[stat], + tier, + summary: copy.summary, + impact: copy.impact, + recovery: copy.recovery, + }; +}; + +export const buildSurvivalView = (state: GameState) => { + const statuses = statusIds.map((stat) => + describeStatus(stat, state.player.stats[stat], state.player.maxStats[stat]), + ); + const alerts = statuses + .filter((entry) => entry.tier !== 'safe') + .sort((left, right) => { + const severityDelta = STATUS_SEVERITY[right.tier] - STATUS_SEVERITY[left.tier]; + if (severityDelta !== 0) return severityDelta; + return statusIds.indexOf(left.stat) - statusIds.indexOf(right.stat); + }); + + return { + headline: alerts.length + ? `${alerts[0].label}告警:${alerts[0].summary}` + : '生存状态稳定,适合继续行动。', + statuses, + alerts, + }; +}; + +const tierPenalty = (tier: StatusTier, values: Record, number>) => { + if (tier === 'safe') return 0; + return values[tier]; +}; + +export const getStatusPenalty = (state: GameState) => { + const thirstTier = getStatusTier('thirst', state.player.stats.thirst, state.player.maxStats.thirst); + const hungerTier = getStatusTier('hunger', state.player.stats.hunger, state.player.maxStats.hunger); + const energyTier = getStatusTier('energy', state.player.stats.energy, state.player.maxStats.energy); + const sanityTier = getStatusTier('sanity', state.player.stats.sanity, state.player.maxStats.sanity); + const radiationTier = getStatusTier( + 'radiation', + state.player.stats.radiation, + state.player.maxStats.radiation, + ); + + return { + hitPenalty: + tierPenalty(energyTier, { strained: -0.05, critical: -0.11, collapsed: -0.18 }) + + tierPenalty(thirstTier, { strained: -0.04, critical: -0.1, collapsed: -0.16 }) + + tierPenalty(hungerTier, { strained: -0.02, critical: -0.06, collapsed: -0.1 }) + + tierPenalty(sanityTier, { strained: -0.02, critical: -0.08, collapsed: -0.14 }), + riskBonus: + tierPenalty(thirstTier, { strained: 0.04, critical: 0.1, collapsed: 0.18 }) + + tierPenalty(sanityTier, { strained: 0.02, critical: 0.08, collapsed: 0.16 }), + armorPenalty: + tierPenalty(radiationTier, { strained: 0, critical: -1, collapsed: -2 }), + }; +}; + +const updateTimeFields = (state: GameState) => { + state.world.time.day = Math.floor(state.world.time.totalMinutes / (24 * 60)) + 1; + state.world.time.minuteOfDay = state.world.time.totalMinutes % (24 * 60); +}; + +const refreshPlaceStocks = (state: GameState, content: GameContent, deltaMin: number) => { + for (const place of Object.values(content.places)) { + const runtime = ensurePlaceRuntime(state, content, place.id); + runtime.heat = Math.max(0, runtime.heat - deltaMin / 120); + + for (const action of place.actions) { + if (!action.stock) continue; + const current = runtime.stocks[action.id] ?? action.stock.initial; + const growth = Math.floor(deltaMin / action.stock.refreshMin) * action.stock.amountPerRefresh; + runtime.stocks[action.id] = clamp(current + growth, 0, action.stock.max); + } + + runtime.lastRefreshMinute = state.world.time.totalMinutes; + } +}; + +const tickBuffs = (state: GameState, content: GameContent, minutes: number) => { + state.player.buffs = state.player.buffs.flatMap((buff) => { + const definition = content.buffs[buff.buffId]; + const remaining = buff.remainingMin - minutes; + if (definition.modifiers) { + for (const [stat, value] of Object.entries(definition.modifiers) as [StatusId, number][]) { + applyStatChange(state, stat, value * minutes); + } + } + + if (remaining <= 0) { + pushLog(state, `${definition.name} 已结束`, 'info'); + return []; + } + + return [{ ...buff, remainingMin: remaining }]; + }); +}; + +const handleThresholdDamage = (state: GameState) => { + const thirstTier = getStatusTier('thirst', state.player.stats.thirst, state.player.maxStats.thirst); + const hungerTier = getStatusTier('hunger', state.player.stats.hunger, state.player.maxStats.hunger); + const energyTier = getStatusTier('energy', state.player.stats.energy, state.player.maxStats.energy); + const sanityTier = getStatusTier('sanity', state.player.stats.sanity, state.player.maxStats.sanity); + const radiationTier = getStatusTier( + 'radiation', + state.player.stats.radiation, + state.player.maxStats.radiation, + ); + + if (thirstTier === 'collapsed') { + applyStatChange(state, 'life', -6); + } else if (thirstTier === 'critical') { + applyStatChange(state, 'life', -2); + } + + if (hungerTier === 'collapsed') { + applyStatChange(state, 'life', -4); + } else if (hungerTier === 'critical') { + applyStatChange(state, 'energy', -2); + } + + if (energyTier === 'collapsed') { + applyStatChange(state, 'life', -1); + applyStatChange(state, 'sanity', -2); + } + + if (radiationTier === 'collapsed') { + applyStatChange(state, 'life', -4); + applyStatChange(state, 'sanity', -2); + } else if (radiationTier === 'critical') { + applyStatChange(state, 'life', -1); + } + + if (sanityTier === 'collapsed') { + state.world.gameOver = true; + state.world.deathReason = '理智崩溃'; + } + + if (state.player.stats.life <= 0) { + state.world.gameOver = true; + state.world.deathReason = '生命耗尽'; + } +}; + +export const advanceTime = (state: GameState, content: GameContent, minutes: number, reason: string) => { + const place = getCurrentPlace(state, content); + const modifiers = place.modifiers ?? {}; + const hungerDecay = 0.03 + (modifiers.hunger ?? 0); + const thirstDecay = 0.05 + (modifiers.thirst ?? 0); + const energyDecay = 0.02 + (modifiers.energy ?? 0); + const sanityDecay = 0.01 + (modifiers.sanity ?? 0); + const radiationGain = Math.max(0, modifiers.radiation ?? 0); + + applyStatChange(state, 'hunger', -hungerDecay * minutes); + applyStatChange(state, 'thirst', -thirstDecay * minutes); + applyStatChange(state, 'energy', -energyDecay * minutes); + applyStatChange(state, 'sanity', -sanityDecay * minutes); + applyStatChange(state, 'radiation', radiationGain * minutes); + tickBuffs(state, content, minutes); + + state.world.time.totalMinutes += minutes; + updateTimeFields(state); + refreshPlaceStocks(state, content, minutes); + handleThresholdDamage(state); + + pushLog(state, `${reason},时间推进 ${minutes} 分钟`, 'info'); +}; + +export const canAccessShelterStorage = (state: GameState) => state.player.placeId === 'home'; diff --git a/packages/game-core/src/types.ts b/packages/game-core/src/types.ts new file mode 100644 index 0000000..048c1fd --- /dev/null +++ b/packages/game-core/src/types.ts @@ -0,0 +1,519 @@ +export const statusIds = [ + 'life', + 'hunger', + 'thirst', + 'energy', + 'sanity', + 'radiation', +] as const; + +export type StatusId = (typeof statusIds)[number]; +export type StatusTier = 'safe' | 'strained' | 'critical' | 'collapsed'; +export type ItemType = + | 'food' + | 'water' + | 'material' + | 'tool' + | 'weapon' + | 'armor' + | 'ammo' + | 'quest' + | 'medicine'; + +export type EquipSlot = 'weapon' | 'body' | 'tool'; +export type ServiceId = 'craft' | 'rest' | 'trade' | 'heal' | 'info'; +export type ActionKind = 'resource' | 'scavenge' | 'investigate' | 'rest' | 'service'; +export type QuestType = 'main' | 'side' | 'tutorial'; +export type QuestState = 'locked' | 'active' | 'completed'; +export type EventTrigger = 'action' | 'travel' | 'arrive'; +export type LogTone = 'info' | 'good' | 'warn' | 'bad' | 'combat'; + +export interface StatLine { + life: number; + hunger: number; + thirst: number; + energy: number; + sanity: number; + radiation: number; +} + +export interface Attributes { + str: number; + agi: number; + int: number; + per: number; + luck: number; +} + +export interface CombatStats { + damageMin: number; + damageMax: number; + range: number; + hitBonus: number; + armor?: number; + dodgeBonus?: number; + radiationResist?: number; + critChance?: number; + critMult?: number; +} + +export interface ItemDefinition { + id: string; + name: string; + desc: string; + type: ItemType; + tags?: string[]; + stackLimit: number; + volume: number; + baseValue: number; + equipSlot?: EquipSlot; + combat?: CombatStats; + effects?: Effect[]; +} + +export interface InventoryEntry { + itemId: string; + count: number; + durability?: number | null; +} + +export interface BuffDefinition { + id: string; + name: string; + desc: string; + durationMin: number; + modifiers?: Partial>; + hitBonus?: number; + armorBonus?: number; +} + +export interface ActiveBuff { + buffId: string; + remainingMin: number; + stacks: number; +} + +export type ConditionRule = + | { kind: 'flag'; flag: string; value?: boolean } + | { kind: 'has-item'; itemId: string; count: number } + | { kind: 'quest-state'; questId: string; state: QuestState } + | { kind: 'place'; placeId: string } + | { kind: 'day-at-least'; day: number } + | { kind: 'stat-at-most'; stat: StatusId; value: number } + | { kind: 'stat-at-least'; stat: StatusId; value: number }; + +export type Effect = + | { type: 'add-item'; itemId: string; count: number } + | { type: 'remove-item'; itemId: string; count: number } + | { type: 'change-stat'; stat: StatusId; amount: number } + | { type: 'add-buff'; buffId: string; durationMin: number; stacks?: number } + | { type: 'remove-buff'; buffId: string } + | { type: 'set-flag'; flag: string; value: boolean } + | { type: 'unlock-recipe'; recipeId: string } + | { type: 'activate-quest'; questId: string } + | { type: 'start-combat'; enemyId: string } + | { type: 'log'; message: string; tone?: LogTone }; + +export interface LootEntry { + itemId: string; + min: number; + max: number; + chance?: number; +} + +export interface PlaceActionDefinition { + id: string; + name: string; + kind: ActionKind; + desc: string; + timeCostMin: number; + energyCost: number; + risk: number; + rewardHint: string; + requires?: ConditionRule[]; + stock?: { + initial: number; + max: number; + refreshMin: number; + amountPerRefresh: number; + }; + lootTable?: LootEntry[]; + guaranteedEffects?: Effect[]; + eventPool?: string[]; + onEmptyText?: string; +} + +export interface TradeOfferDefinition { + id: string; + name: string; + desc: string; + gives: { itemId: string; count: number }[]; + costs: { itemId: string; count: number }[]; +} + +export interface PlaceDefinition { + id: string; + name: string; + desc: string; + tags: string[]; + dangerLevel: number; + services: ServiceId[]; + modifiers?: Partial>; + actions: PlaceActionDefinition[]; + arrivalEventPool?: string[]; + tradeOffers?: TradeOfferDefinition[]; +} + +export interface MapEdge { + from: string; + to: string; + travelTimeMin: number; + risk: number; + conditions?: ConditionRule[]; + eventPool?: string[]; +} + +export interface SkillCheck { + attribute: keyof Attributes; + difficulty: number; +} + +export interface EventOptionDefinition { + id: string; + text: string; + conditions?: ConditionRule[]; + timeCostMin?: number; + check?: SkillCheck; + successEffects: Effect[]; + failureEffects?: Effect[]; + successText?: string; + failureText?: string; +} + +export interface EventDefinition { + id: string; + title: string; + text: string; + trigger: EventTrigger; + options: EventOptionDefinition[]; + conditions?: ConditionRule[]; +} + +export interface EnemyDefinition { + id: string; + name: string; + desc: string; + life: number; + damageMin: number; + damageMax: number; + armor: number; + range: number; + hitRate: number; + escapePressure: number; + lootTable: LootEntry[]; +} + +export type QuestStep = + | { id: string; text: string; kind: 'reach-place'; placeId: string } + | { id: string; text: string; kind: 'have-item'; itemId: string; count: number } + | { id: string; text: string; kind: 'flag'; flag: string; value?: boolean } + | { id: string; text: string; kind: 'survive-day'; day: number }; + +export interface QuestDefinition { + id: string; + title: string; + desc: string; + type: QuestType; + autoStart?: ConditionRule[]; + steps: QuestStep[]; + rewards: Effect[]; +} + +export interface QuestRuntimeState { + questId: string; + state: QuestState; + currentStepIndex: number; +} + +export interface CombatLogEntry { + round: number; + message: string; +} + +export interface CombatState { + enemyId: string; + enemyLife: number; + distance: number; + round: number; + playerGuarding: boolean; + enemyGuarding: boolean; + log: CombatLogEntry[]; + rewardClaimed: boolean; +} + +export interface PendingEvent { + eventId: string; + source: 'travel' | 'place' | 'arrival'; +} + +export interface PlaceRuntimeState { + placeId: string; + heat: number; + stocks: Record; + lastRefreshMinute: number; + visited: boolean; +} + +export interface WorldTime { + totalMinutes: number; + day: number; + minuteOfDay: number; +} + +export interface LogEntry { + id: string; + minute: number; + tone: LogTone; + message: string; +} + +export interface PlayerState { + name: string; + placeId: string; + stats: StatLine; + maxStats: StatLine; + attributes: Attributes; + inventory: InventoryEntry[]; + storage: InventoryEntry[]; + equipment: Partial>; + buffs: ActiveBuff[]; + knownRecipes: string[]; + quests: Record; +} + +export interface WorldState { + time: WorldTime; + flags: Record; + places: Record; + logs: LogEntry[]; + pendingEvent: PendingEvent | null; + activeCombat: CombatState | null; + gameOver: boolean; + victory: boolean; + deathReason?: string; +} + +export interface SaveMeta { + saveVersion: number; + contentVersion: string; + seed: number; + rngState: number; + difficulty: 'standard'; + createdAt: string; + updatedAt: string; +} + +export interface GameState { + meta: SaveMeta; + player: PlayerState; + world: WorldState; +} + +export interface GameContent { + version: string; + inventoryCapacity: number; + storageCapacity: number; + items: Record; + buffs: Record; + recipes: Record; + places: Record; + edges: MapEdge[]; + events: Record; + enemies: Record; + quests: Record; + travelEventPool: string[]; +} + +export interface RecipeDefinition { + id: string; + name: string; + desc: string; + workbenchType: ServiceId | 'hand'; + requirements?: ConditionRule[]; + inputs: { itemId: string; count: number }[]; + outputs: { itemId: string; count: number }[]; + timeCostMin: number; + energyCost: number; + sideEffects?: Effect[]; +} + +export type GameAction = + | { type: 'travel'; toPlaceId: string } + | { type: 'perform-action'; actionId: string } + | { type: 'select-event-option'; optionId: string } + | { type: 'combat'; move: 'attack' | 'defend' | 'advance' | 'retreat' | 'escape' } + | { type: 'craft'; recipeId: string } + | { type: 'use-item'; itemId: string } + | { type: 'equip-item'; itemId: string } + | { type: 'unequip-item'; slot: EquipSlot } + | { type: 'stash-item'; itemId: string; count: number } + | { type: 'retrieve-item'; itemId: string; count: number } + | { type: 'trade'; offerId: string } + | { type: 'rest'; minutes: number }; + +export interface ActionResult { + state: GameState; + changed: boolean; +} + +export interface ActionPreview { + id: string; + name: string; + kind: ActionKind; + desc: string; + timeCostMin: number; + energyCost: number; + risk: number; + rewardHint: string; + disabledReason?: string; + remainingStock?: number | null; +} + +export interface InventoryViewEntry extends InventoryEntry { + name: string; + type: ItemType; + desc: string; + volume: number; + equipSlot?: EquipSlot; + canUse: boolean; + equipped: boolean; +} + +export interface SurvivalStateView { + stat: StatusId; + label: string; + tier: StatusTier; + summary: string; + impact: string; + recovery: string; +} + +export interface SurvivalView { + headline: string; + statuses: SurvivalStateView[]; + alerts: SurvivalStateView[]; +} + +export interface RecipeView { + id: string; + name: string; + desc: string; + timeCostMin: number; + energyCost: number; + craftable: boolean; + disabledReason?: string; + inputs: { itemId: string; name: string; count: number; owned: number }[]; + outputs: { itemId: string; name: string; count: number }[]; +} + +export interface TradeOfferView { + id: string; + name: string; + desc: string; + available: boolean; + disabledReason?: string; + gives: { itemId: string; name: string; count: number }[]; + costs: { itemId: string; name: string; count: number; owned: number }[]; +} + +export interface QuestView { + id: string; + title: string; + desc: string; + type: QuestType; + state: QuestState; + currentStepIndex: number; + steps: { text: string; done: boolean }[]; +} + +export interface CombatView { + enemyId: string; + enemyName: string; + enemyLife: number; + enemyMaxLife: number; + distance: number; + round: number; + log: CombatLogEntry[]; + availableMoves: { id: GameAction['type']; move?: string; label: string }[]; +} + +export interface PendingEventView { + id: string; + title: string; + text: string; + options: { id: string; text: string; disabledReason?: string }[]; +} + +export interface MapPlaceView { + id: string; + name: string; + desc: string; + dangerLevel: number; + tags: string[]; + visited: boolean; + current: boolean; +} + +export interface EdgeView { + from: string; + to: string; + travelTimeMin: number; + risk: number; + blockedReason?: string; +} + +export interface GameView { + meta: SaveMeta; + header: { + placeName: string; + placeDesc: string; + timeLabel: string; + riskLabel: string; + }; + player: { + name: string; + stats: StatLine; + maxStats: StatLine; + attributes: Attributes; + equipment: Partial>; + inventory: InventoryViewEntry[]; + inventoryUsage: number; + inventoryCapacity: number; + storage: InventoryViewEntry[]; + storageUsage: number; + storageCapacity: number; + storageAccessible: boolean; + }; + map: { + places: MapPlaceView[]; + edges: EdgeView[]; + }; + place: { + id: string; + name: string; + desc: string; + services: ServiceId[]; + actions: ActionPreview[]; + tradeOffers: TradeOfferView[]; + }; + recipes: RecipeView[]; + quests: QuestView[]; + survival: SurvivalView; + pendingEvent: PendingEventView | null; + combat: CombatView | null; + logs: LogEntry[]; + flags: Record; + gameOver: boolean; + victory: boolean; + deathReason?: string; +} diff --git a/packages/game-core/src/view-projection.ts b/packages/game-core/src/view-projection.ts new file mode 100644 index 0000000..9bf978a --- /dev/null +++ b/packages/game-core/src/view-projection.ts @@ -0,0 +1,246 @@ +import type { + ActionPreview, + EdgeView, + GameContent, + GameState, + GameView, + InventoryEntry, + InventoryViewEntry, + PendingEventView, + PlaceDefinition, + PlaceActionDefinition, + RecipeDefinition, + RecipeView, + TradeOfferView, +} from './types'; +import { canAccessShelterStorage, buildSurvivalView } from './systems/survival-system'; +import { copyStats, dangerLabel, formatTime } from './systems/shared'; +import { getConditionFailure, getCurrentPlace, hasConditions, ensurePlaceRuntime } from './systems/place-system'; +import { buildQuestView } from './systems/quest-system'; +import { getContainerUsage, getInventoryUsage, getItemCount, getItemCountFromEntries, isItemEquipped } from './systems/inventory-system'; + +const buildInventoryEntries = ( + entries: InventoryEntry[], + state: GameState, + content: GameContent, +): InventoryViewEntry[] => + entries.map((entry) => { + const item = content.items[entry.itemId]; + return { + ...entry, + name: item.name, + type: item.type, + desc: item.desc, + volume: item.volume, + equipSlot: item.equipSlot, + canUse: Boolean(item.effects?.length), + equipped: isItemEquipped(state.player, entry.itemId), + }; + }); + +const buildActionPreview = ( + state: GameState, + content: GameContent, + action: PlaceActionDefinition, +): ActionPreview => { + const failed = action.requires?.find((rule) => !hasConditions(state, [rule])); + const runtime = ensurePlaceRuntime(state, content, state.player.placeId); + const remainingStock = action.stock ? runtime.stocks[action.id] ?? action.stock.initial : null; + + let disabledReason = failed ? getConditionFailure(failed, content) : undefined; + if (!disabledReason && action.stock && remainingStock !== null && remainingStock <= 0) { + disabledReason = action.onEmptyText ?? '这里已经空了'; + } + + return { + id: action.id, + name: action.name, + kind: action.kind, + desc: action.desc, + timeCostMin: action.timeCostMin, + energyCost: action.energyCost, + risk: action.risk, + rewardHint: action.rewardHint, + disabledReason, + remainingStock, + }; +}; + +const getRecipeOwnedCount = (state: GameState, recipeInputId: string) => { + const bagCount = getItemCount(state.player, recipeInputId); + if (!canAccessShelterStorage(state)) { + return bagCount; + } + + return bagCount + getItemCountFromEntries(state.player.storage, recipeInputId); +}; + +const buildRecipeView = (state: GameState, content: GameContent, recipe: RecipeDefinition): RecipeView => { + const requirementFailure = recipe.requirements?.find((rule) => !hasConditions(state, [rule])); + const missing = recipe.inputs.find((input) => getRecipeOwnedCount(state, input.itemId) < input.count); + const craftable = state.player.knownRecipes.includes(recipe.id) && !requirementFailure && !missing; + + return { + id: recipe.id, + name: recipe.name, + desc: recipe.desc, + timeCostMin: recipe.timeCostMin, + energyCost: recipe.energyCost, + craftable, + disabledReason: + !state.player.knownRecipes.includes(recipe.id) + ? '尚未解锁' + : requirementFailure + ? getConditionFailure(requirementFailure, content) + : missing + ? `缺少 ${content.items[missing.itemId].name}` + : undefined, + inputs: recipe.inputs.map((input) => ({ + itemId: input.itemId, + name: content.items[input.itemId].name, + count: input.count, + owned: getRecipeOwnedCount(state, input.itemId), + })), + outputs: recipe.outputs.map((output) => ({ + itemId: output.itemId, + name: content.items[output.itemId].name, + count: output.count, + })), + }; +}; + +const buildTradeView = (state: GameState, content: GameContent, place: PlaceDefinition): TradeOfferView[] => + (place.tradeOffers ?? []).map((offer) => { + const missing = offer.costs.find((cost) => getItemCount(state.player, cost.itemId) < cost.count); + + return { + id: offer.id, + name: offer.name, + desc: offer.desc, + available: !missing, + disabledReason: missing ? `缺少 ${content.items[missing.itemId].name}` : undefined, + gives: offer.gives.map((entry) => ({ + itemId: entry.itemId, + name: content.items[entry.itemId].name, + count: entry.count, + })), + costs: offer.costs.map((entry) => ({ + itemId: entry.itemId, + name: content.items[entry.itemId].name, + count: entry.count, + owned: getItemCount(state.player, entry.itemId), + })), + }; + }); + +const buildPendingEventView = (state: GameState, content: GameContent): PendingEventView | null => { + if (!state.world.pendingEvent) return null; + const event = content.events[state.world.pendingEvent.eventId]; + + return { + id: event.id, + title: event.title, + text: event.text, + options: event.options.map((option) => { + const failed = option.conditions?.find((rule) => !hasConditions(state, [rule])); + return { + id: option.id, + text: option.text, + disabledReason: failed ? getConditionFailure(failed, content) : undefined, + }; + }), + }; +}; + +export const buildGameView = (state: GameState, content: GameContent): GameView => { + const place = getCurrentPlace(state, content); + const inventory = buildInventoryEntries(state.player.inventory, state, content); + const storage = buildInventoryEntries(state.player.storage, state, content).map((entry) => ({ + ...entry, + equipped: false, + })); + + const edges: EdgeView[] = content.edges + .filter((edge) => edge.from === state.player.placeId) + .map((edge) => { + const failed = edge.conditions?.find((rule) => !hasConditions(state, [rule])); + return { + from: edge.from, + to: edge.to, + travelTimeMin: edge.travelTimeMin, + risk: edge.risk, + blockedReason: failed ? getConditionFailure(failed, content) : undefined, + }; + }); + + return { + meta: state.meta, + header: { + placeName: place.name, + placeDesc: place.desc, + timeLabel: formatTime(state.world.time.totalMinutes), + riskLabel: dangerLabel(place.dangerLevel), + }, + player: { + name: state.player.name, + stats: copyStats(state.player.stats), + maxStats: copyStats(state.player.maxStats), + attributes: { ...state.player.attributes }, + equipment: { ...state.player.equipment }, + inventory, + inventoryUsage: getInventoryUsage(state, content), + inventoryCapacity: content.inventoryCapacity, + storage, + storageUsage: getContainerUsage(state.player.storage, content), + storageCapacity: content.storageCapacity, + storageAccessible: canAccessShelterStorage(state), + }, + map: { + places: Object.values(content.places).map((entry) => ({ + id: entry.id, + name: entry.name, + desc: entry.desc, + dangerLevel: entry.dangerLevel, + tags: entry.tags, + visited: state.world.places[entry.id]?.visited ?? false, + current: entry.id === state.player.placeId, + })), + edges, + }, + place: { + id: place.id, + name: place.name, + desc: place.desc, + services: place.services, + actions: place.actions.map((action) => buildActionPreview(state, content, action)), + tradeOffers: buildTradeView(state, content, place), + }, + recipes: Object.values(content.recipes).map((recipe) => buildRecipeView(state, content, recipe)), + quests: Object.values(content.quests).map((quest) => buildQuestView(state, quest)), + survival: buildSurvivalView(state), + pendingEvent: buildPendingEventView(state, content), + combat: state.world.activeCombat + ? { + enemyId: state.world.activeCombat.enemyId, + enemyName: content.enemies[state.world.activeCombat.enemyId].name, + enemyLife: state.world.activeCombat.enemyLife, + enemyMaxLife: content.enemies[state.world.activeCombat.enemyId].life, + distance: state.world.activeCombat.distance, + round: state.world.activeCombat.round, + log: state.world.activeCombat.log.slice(-8), + availableMoves: [ + { id: 'combat', move: 'attack', label: '攻击' }, + { id: 'combat', move: 'defend', label: '防御' }, + { id: 'combat', move: 'advance', label: '逼近' }, + { id: 'combat', move: 'retreat', label: '后撤' }, + { id: 'combat', move: 'escape', label: '脱离' }, + ], + } + : null, + logs: state.world.logs.slice(-40).reverse(), + flags: { ...state.world.flags }, + gameOver: state.world.gameOver, + victory: state.world.victory, + deathReason: state.world.deathReason, + }; +}; diff --git a/packages/game-core/tsconfig.json b/packages/game-core/tsconfig.json new file mode 100644 index 0000000..8ab54e7 --- /dev/null +++ b/packages/game-core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"] +} + diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a3dcfc1 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - apps/* + - packages/* +allowBuilds: + better-sqlite3: true + esbuild: true diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..635f030 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "allowSyntheticDefaultImports": true, + "ignoreDeprecations": "6.0", + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +}