自研 signal 基元 + 三个 Store(Player/App/Room 登录驱动子集) + PlatformSession 登录接入 + 只读视图。取向A(每Store一状态signal)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.6 KiB
platform 响应式 Store(第一切片)设计
状态:已与用户确认(brainstorming 通过),待转 writing-plans。 适用工程:
cocoscreator_projects/YouleNexus。 关联:架构 spec2026-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)
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)
// 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):patchagentname/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)
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)
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集成:注入 fakeEventBus<NetClientEvents>,emitlogin(用 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事件路由分发。