自研 signal 基元 + 三个 Store(Player/App/Room 登录驱动子集) + PlatformSession 登录接入 + 只读视图。取向A(每Store一状态signal)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
170 lines
9.6 KiB
Markdown
170 lines
9.6 KiB
Markdown
# platform 响应式 Store(第一切片)设计
|
||
|
||
> 状态:已与用户确认(brainstorming 通过),待转 writing-plans。
|
||
> 适用工程:`cocoscreator_projects/YouleNexus`。
|
||
> 关联:架构 spec `2026-06-28-cocos-framework-design.md` §3(分层)/§4(数据流+sdk 接口)/§6(技术栈)/§9.3(Store 选型);数据结构 `docs/protocol/04`;上游 `NetClient`(已完成,事件 `open/login/message/slow/reconnecting/kicked/serverSwitch/close`)。
|
||
|
||
## 0. 目标与背景
|
||
|
||
Plan 3(platform 业务层)的**第一切片**:把旧 `04_Data`(GameData)/`06_Player`(C_Player)/`07_Desk`(Desk) 的状态,迁成**自研薄封装的响应式 Store**(状态变 → UI 自动刷新,告别手动刷 UI),并接上 `NetClient` 的 `login` 事件填充。后续切片(房间事件 handler、完整房间编排、UI 绑定)另立 spec。
|
||
|
||
**已确认决策**:
|
||
- **Store 选型**:自研薄封装 signal/observable(零依赖、Node 可测、与 Cocos 解耦;spec §9.3「避免重依赖」)。
|
||
- **数据范围**:登录驱动子集(只建 login 响应实际填充的字段,其余按功能长,YAGNI)。
|
||
- **响应式粒度**:每个 Store 一个状态 signal(取向 A,最简够用;细粒度绑定留到 Plan 4 做 UI 时按需引入)。
|
||
|
||
**红线**:State 字段名直接由服务器 login 响应填充,故**逐字对齐 `docs/protocol/04`**(playerid/bean/roomcard/roomcode/isbattle…);内部基元/类/方法名走现代命名。见 [[naming-convention-modern-internal]] 等价原则。
|
||
|
||
## 1. 模块结构
|
||
|
||
```
|
||
framework/
|
||
├─ core/
|
||
│ └─ reactive.ts # signal<T>() → Reactive<T> / ReadonlyReactive<T> 基元(基座,类比 EventBus)
|
||
└─ platform/
|
||
├─ stores/
|
||
│ ├─ types.ts # PlayerState / AppState / RoomState(登录驱动子集)
|
||
│ ├─ player-store.ts # PlayerStore(= C_Player)
|
||
│ ├─ room-store.ts # RoomStore(= Desk)
|
||
│ └─ app-store.ts # AppStore(= GameData:身份/服务器/连接相位)
|
||
├─ readonly.ts # ReadonlyPlayerStore / ReadonlyRoomStore / ReadonlyAppStore 只读视图接口
|
||
└─ session.ts # PlatformSession:订阅 NetClient 事件 → 填充三 Store
|
||
```
|
||
|
||
依赖方向:`platform → core`(用 reactive + EventBus + protocol/login 的 parseLoginResponse)。不碰 net 内部,只消费其事件总线。
|
||
|
||
## 2. 响应式基元(`core/reactive.ts`)
|
||
|
||
```ts
|
||
export type Unsubscribe = () => void;
|
||
|
||
export interface ReadonlyReactive<T> {
|
||
readonly value: T;
|
||
subscribe(fn: (value: T, prev: T) => void): Unsubscribe; // 返回退订函数
|
||
}
|
||
|
||
export interface Reactive<T> extends ReadonlyReactive<T> {
|
||
value: T; // 可写
|
||
}
|
||
|
||
/** 极小响应式信号:value 读写 + 订阅。Object.is 判等,值未变不通知;订阅者抛错隔离。 */
|
||
export function signal<T>(initial: T): Reactive<T>;
|
||
```
|
||
|
||
实现:持有 `current` + 订阅者 `Set`;setter `if (!Object.is(v, current))` 才更新并通知;通知时 `try/catch` 每个订阅者(故障隔离,复用 EventBus 思路);subscribe 返回退订闭包。
|
||
|
||
## 3. 三个 Store(取向 A:每 Store 一个状态 signal)
|
||
|
||
每个 Store:`private s = signal<XxxState>(初始)`;对外 `state: ReadonlyReactive<XxxState>`(= `this.s`)+ 类型化 getter + 领域更新方法(不可变 patch:`this.s.value = { ...this.s.value, ...partial }`)。更新方法仅供 session/后续 handler 调用。
|
||
|
||
### State 类型(`stores/types.ts`,登录驱动子集,字段名对齐 protocol/04)
|
||
|
||
```ts
|
||
// PlayerState(C_Player A 组,login 始终下发;openid 等仅 deviceLogin 下发,做可选)
|
||
export interface PlayerState {
|
||
playerid: number;
|
||
nickname: string; avatar: string; sex: number;
|
||
openid?: string; unionid?: string | number; province?: string; city?: string;
|
||
bean: number; roomcard: number; score: number; charm: number;
|
||
taskstate: number; advanced: number;
|
||
bankpower: number; bank: number; bankpwd: number;
|
||
ip: string; sign: string | null; tel: string | null;
|
||
invitecode: string | null; initCard: number | string; initBean: number | string;
|
||
}
|
||
// 注:login 响应的 agentid/channelid 旧客户端写入 GameData(AppStore),非 C_Player(protocol/04:183-184),故归 AppState。
|
||
|
||
// AppState(GameData 子集:身份 + 服务器 + 代理/版本 + 连接相位)
|
||
export type ConnectionPhase =
|
||
| 'idle' | 'connecting' | 'connected' | 'loggedIn'
|
||
| 'reconnecting' | 'slow' | 'kicked' | 'loginFailed';
|
||
export interface AppState {
|
||
identity: ChannelIdentity | null; // 来自 bootstrap(agentid/gameid/channelid/marketid/version)
|
||
servers: string[];
|
||
agentid: string | number; // login 响应回填(旧 GameData.AgentId,protocol/04:183)
|
||
channelid: string | number; // login 响应回填(旧 GameData.ChannelId,protocol/04:184)
|
||
agentname: string; // login 实测带
|
||
agentmode: number;
|
||
gameversion: number;
|
||
phase: ConnectionPhase;
|
||
}
|
||
|
||
// RoomState(Desk B 组,仅在房/重连时下发)
|
||
export interface RoomState {
|
||
inRoom: boolean;
|
||
roomcode: string | null;
|
||
isbattle: number; // 0未开局/1已开局
|
||
roommode: number; // 0普通/1星星场
|
||
seat: number; // 自己的座位(-1 未入座)
|
||
isowner: number; // 1房主/0非
|
||
asetcount: number; makewar: number; needprepare: number; infinite: number;
|
||
players: unknown[]; // 按座位下标,元素为座位玩家字段集(可含 null);完整建模留后续切片
|
||
deskinfo: unknown; // 子游戏对局快照,opaque 透传(见 protocol/05)
|
||
}
|
||
```
|
||
|
||
> `players` 元素与子游戏对局态在后续切片细化;本切片只保留数组与 `deskinfo` 透传,供重连判断与下一步消费。
|
||
|
||
### 更新方法(每 Store)
|
||
|
||
- `PlayerStore.applyLogin(raw: LoginResponseData)`:从 login A 组 patch PlayerState。
|
||
- `AppStore.applyLogin(raw)`:patch `agentname/agentmode/gameversion/agentid/channelid`;`setIdentity(id)`/`setServers(s)`(启动时由 bootstrap 调);`setPhase(p)`。
|
||
- `RoomStore.applyRecovery(raw)`:login 含 roomcode 时 patch B 组、`inRoom=true`;`clear()`:复位 `inRoom=false`。
|
||
|
||
## 4. 登录接入(`platform/session.ts`)
|
||
|
||
```ts
|
||
export class PlatformSession {
|
||
readonly app: AppStore;
|
||
readonly player: PlayerStore;
|
||
readonly room: RoomStore;
|
||
|
||
constructor(bus: EventBus<NetClientEvents>) { // 注入 NetClient.bus,便于测试
|
||
bus.on('open', () => this.app.setPhase('connected'));
|
||
bus.on('login', (d) => this.onLogin(d));
|
||
bus.on('reconnecting', () => this.app.setPhase('reconnecting'));
|
||
bus.on('slow', () => this.app.setPhase('slow'));
|
||
bus.on('kicked', () => this.app.setPhase('kicked'));
|
||
}
|
||
|
||
private onLogin(data: unknown): void {
|
||
const parsed = parseLoginResponse(data as LoginResponseData); // 复用 protocol/login
|
||
if (!parsed.ok) { this.app.setPhase('loginFailed'); return; }
|
||
this.player.applyLogin(parsed.raw);
|
||
this.app.applyLogin(parsed.raw);
|
||
parsed.inRoom ? this.room.applyRecovery(parsed.raw) : this.room.clear();
|
||
this.app.setPhase('loggedIn');
|
||
}
|
||
}
|
||
```
|
||
|
||
只依赖 `NetClient` 的事件总线(`EventBus<NetClientEvents>`),消费已联调通的真实 login 响应。`AppStore.setIdentity/setServers` 由启动编排在 `resolveBootstrap` 后调用(接线属后续启动切片,本切片只提供方法 + 测试)。
|
||
|
||
## 5. 只读视图(`platform/readonly.ts`)
|
||
|
||
```ts
|
||
export interface ReadonlyPlayerStore { readonly state: ReadonlyReactive<PlayerState>; }
|
||
export interface ReadonlyRoomStore { readonly state: ReadonlyReactive<RoomState>; }
|
||
export interface ReadonlyAppStore { readonly state: ReadonlyReactive<AppState>; }
|
||
```
|
||
|
||
三个 Store `implements` 各自只读接口(`state` 用 `ReadonlyReactive` 类型暴露,更新方法不在接口里)。将来 `GameContext`(sdk Plan)只把只读视图交给子游戏,强制零耦合(spec §4「子游戏拿只读 Store + 受限 facade」)。
|
||
|
||
## 6. 错误处理
|
||
|
||
- 基元订阅者抛错隔离(不影响其它订阅者,`console.error` 记录)。
|
||
- `onLogin` 中 `parseLoginResponse` 已处理 `state!=0`(`ok=false`)→ `loginFailed` 相位,不抛。
|
||
- Store patch 是纯内存操作,不涉 IO,无失败路径。
|
||
|
||
## 7. 测试(tsx + node:test,`framework-tests/platform/`)
|
||
|
||
- `reactive.test.ts`:value 读写;subscribe 收到 (value, prev);退订后不再收;`Object.is` 判等值未变不通知;一个订阅者抛错不影响其它。
|
||
- `player-store.test.ts` / `app-store.test.ts` / `room-store.test.ts`:`applyLogin/applyRecovery/clear/setPhase` 正确 patch;订阅者收到通知;getter 返回新值;`room.clear()` 复位 `inRoom`。
|
||
- `session.test.ts` 集成:注入 fake `EventBus<NetClientEvents>`,emit `login`(**用 Task 12 联调实测的真实 login 响应 JSON 作 fixture**)→ 三 Store 正确填充、`phase==='loggedIn'`;`state!=0` → `loginFailed`;含 `roomcode` → `room.inRoom===true`;`emit kicked/slow/reconnecting` → 对应 phase。
|
||
|
||
验收:`npm run test:framework` 全绿、`npm run typecheck:framework` exit 0;`PlatformSession` 在 mock bus 下喂真实 login 响应能正确产出三个响应式 Store。
|
||
|
||
## 8. 范围边界(YAGNI)
|
||
|
||
- **做**:reactive 基元、三个 Store(登录驱动子集)、PlatformSession 登录/相位接入、只读视图、测试。
|
||
- **不做(后续切片/Plan)**:房间事件 handler(join/leave/ready/dissolve/offline…)、`players` 座位玩家完整建模、房间操作发包 API、启动编排把 bootstrap 接到 AppStore、UI 绑定(Plan 4)、GameContext/IGameModule(sdk Plan)、`message` 事件路由分发。
|