@uzuhq/code-sdk 0.7.7 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -9
- package/dist/dev-globals.d.ts +1 -1
- package/dist/{dev-hooks-ClWM8HzI.d.ts → dev-hooks-D6CbhPDP.d.ts} +57 -24
- package/dist/index.d.ts +10 -4
- package/dist/index.js +443 -102
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -223,7 +223,7 @@ import type { GameLogic } from '@uzuhq/code-sdk';
|
|
|
223
223
|
|
|
224
224
|
const logic: GameLogic<MyState> = {
|
|
225
225
|
setup({ players, ctx }) {
|
|
226
|
-
// 初期 state を生成。ctx.random は SeededRandom、ctx.
|
|
226
|
+
// 初期 state を生成。ctx.random は SeededRandom、ctx.time はゲーム内時刻 (必ず 0)。
|
|
227
227
|
// players は配役を受け取る参加者だけで、観測者は含まれない
|
|
228
228
|
return { players: {}, items: [] };
|
|
229
229
|
},
|
|
@@ -235,24 +235,24 @@ const logic: GameLogic<MyState> = {
|
|
|
235
235
|
state.players[playerId].x += payload.dx;
|
|
236
236
|
// ctx.emit でイベント発火 (購読側は events で predict を宣言する)
|
|
237
237
|
ctx.emit('sound', { sound: 'step' });
|
|
238
|
-
// 時刻は ctx.
|
|
238
|
+
// 時刻は ctx.after(d) を使う。Date.now() は epoch が違う (Unix ms vs ゲーム内時刻)
|
|
239
239
|
},
|
|
240
240
|
},
|
|
241
241
|
|
|
242
242
|
// サーバーでのみ走る。実時刻・乱数・fetch などクライアントが再現できない処理。
|
|
243
243
|
// actions と同名にすると「同じ action のサーバー側の続き」になる。
|
|
244
244
|
serverActions: {
|
|
245
|
-
async notifyExternal({ state, payload, playerId }) {
|
|
245
|
+
async notifyExternal({ state, payload, playerId, ctx }) {
|
|
246
246
|
await fetch('https://example.com/notify', {
|
|
247
247
|
method: 'POST',
|
|
248
248
|
body: JSON.stringify({ playerId, ...payload }),
|
|
249
249
|
});
|
|
250
|
-
state.notifiedAt =
|
|
250
|
+
state.notifiedAt = ctx.time;
|
|
251
251
|
},
|
|
252
252
|
},
|
|
253
253
|
|
|
254
254
|
update({ state, ctx }) {
|
|
255
|
-
// 毎 tick 実行。ctx.tick / ctx.random / ctx.
|
|
255
|
+
// 毎 tick 実行。ctx.tick / ctx.random / ctx.time / ctx.after / ctx.emit / ctx.playerInputs が使える
|
|
256
256
|
},
|
|
257
257
|
|
|
258
258
|
tickRate: 10, // 秒間 tick 数 (default: 0 = tick なし)
|
|
@@ -287,10 +287,10 @@ deadlines: {
|
|
|
287
287
|
},
|
|
288
288
|
```
|
|
289
289
|
|
|
290
|
-
| |
|
|
291
|
-
| --------- |
|
|
292
|
-
| `at` |
|
|
293
|
-
| `handler` | サーバーでのみ走る。`ctx` は `{
|
|
290
|
+
| | |
|
|
291
|
+
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
292
|
+
| `at` | 締切の[ゲーム内時刻](#ゲーム内時計) (`GameTime`)。`null` / `undefined` で締切なし (optional chain の結果をそのまま返せる)。**state だけから決まる軽い純関数にすること** (state が変わるたびに呼ばれる) |
|
|
293
|
+
| `handler` | サーバーでのみ走る。`ctx` は `{ time, after, random, emit }` |
|
|
294
294
|
|
|
295
295
|
締切は state から導出するので、**予約を張り替える処理を書かなくてよい**。action が throw して
|
|
296
296
|
state が巻き戻れば締切も一緒に巻き戻る。発火直前に `at` を評価し直すので、二重発火を
|
|
@@ -299,6 +299,49 @@ handler 側で弾く必要も無い。
|
|
|
299
299
|
時刻をきっかけに何かを起こすなら `tickRate` で毎秒ポーリングせずこちらを使う。
|
|
300
300
|
ポーリング中は Durable Object が hibernate できない。
|
|
301
301
|
|
|
302
|
+
### ゲーム内時計
|
|
303
|
+
|
|
304
|
+
**時刻は Unix epoch ではない。** `ctx.time` はゲーム開始からの経過 ms (`GameTime`) で、
|
|
305
|
+
[緊急一時停止](#緊急一時停止)中は進まない。`Date.now()` とは桁も意味も違う。
|
|
306
|
+
|
|
307
|
+
| API | 意味 |
|
|
308
|
+
| ---------------------------- | ----------------------------------------------------------------- |
|
|
309
|
+
| `ctx.time` | そのハンドラが走っているゲーム内時刻。`setup` では必ず `0` |
|
|
310
|
+
| `ctx.after(d)` | 今から `d` ms 後のゲーム内時刻。締切を state に置くときに使う |
|
|
311
|
+
| `gameTime()` | 描画側で読む現在のゲーム内時刻 (推定値)。`performance.now()` 基準 |
|
|
312
|
+
| `plus(t, d)` / `minus(a, b)` | 時刻に長さを足す / 2 つの時刻の差を取る |
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
import { gameTime, minus } from '@uzuhq/code-sdk';
|
|
316
|
+
|
|
317
|
+
// 締切を置く
|
|
318
|
+
actions: {
|
|
319
|
+
startPhase: ({ state, ctx }) => { state.phaseEndsAt = ctx.after(5 * 60_000); },
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
// 残り時間を描く
|
|
323
|
+
const remain = Math.ceil(minus(state.phaseEndsAt, gameTime()) / 1000);
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`GameTime` は `number` の brand 型なので、実行時はただの数値。state は素の JSON のまま。
|
|
327
|
+
`Date.now()` を `GameTime` の場所に入れると TypeScript が弾く。
|
|
328
|
+
|
|
329
|
+
### 緊急一時停止
|
|
330
|
+
|
|
331
|
+
プレイヤーが UZU メニューから全員のタイマーを止められる。**シナリオは停止を知らないし、
|
|
332
|
+
知る必要も無い。** 停止中は `update()` が呼ばれず、action はサーバーが弾き、締切も来ない。
|
|
333
|
+
|
|
334
|
+
演出だけを止めたいときに限り、読み取り専用で参照できる。
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
import { isPaused, onPauseChange } from '@uzuhq/code-sdk';
|
|
338
|
+
|
|
339
|
+
onPauseChange((paused) => (paused ? engine.stop() : engine.start()));
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`GameLogic` からは触れない。停止を state に持ち込むと「停止中は state が変わらない」という
|
|
343
|
+
前提が崩れる。
|
|
344
|
+
|
|
302
345
|
#### `serverOnly(handler)`
|
|
303
346
|
|
|
304
347
|
> **Deprecated**: `serverActions` に直接書く。
|
package/dist/dev-globals.d.ts
CHANGED
|
@@ -79,19 +79,38 @@ declare function applyJsonMergePatch(target: Record<string, unknown>, patch: Jso
|
|
|
79
79
|
*/
|
|
80
80
|
declare function applyJsonPatch(target: Record<string, unknown>, ops: JsonPatchOp[]): void;
|
|
81
81
|
//#endregion
|
|
82
|
-
//#region ../engine-core/src/
|
|
82
|
+
//#region ../engine-core/src/game-time.d.ts
|
|
83
83
|
/**
|
|
84
84
|
* @docs
|
|
85
|
-
* -
|
|
86
|
-
*
|
|
85
|
+
* - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
|
|
86
|
+
*
|
|
87
|
+
* ゲーム内時刻。ゲーム開始からの ms で、停止中は進まない。**Unix epoch ではない。**
|
|
87
88
|
*
|
|
88
|
-
*
|
|
89
|
+
* 実行時はただの number。brand はコンパイル時だけの区別なので、state は素の JSON の
|
|
90
|
+
* まま保たれる (structuredClone / json-patch / JSON.stringify がそのまま通る)。
|
|
91
|
+
* 値を作れるのは `ctx.after` / `plus` だけになるので、`Date.now()` を混ぜると型で落ちる。
|
|
89
92
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* 残り 2 つのランタイムでビルドが落ちる (engine-core の tsconfig は lib を ES2022 だけに
|
|
93
|
-
* 絞ってある)。
|
|
93
|
+
* 型が効くのは TypeScript を書いているシナリオだけだが、JS のシナリオでも epoch が
|
|
94
|
+
* 違うぶんは残る (`Date.now()` を混ぜれば締切が 56,000 日後になって初回で気付く)。
|
|
94
95
|
*/
|
|
96
|
+
declare const gameTimeBrand: unique symbol;
|
|
97
|
+
/** ゲーム内時刻 (ゲーム開始からの ms)。停止中は進まない。 */
|
|
98
|
+
type GameTime = number & {
|
|
99
|
+
readonly [gameTimeBrand]: 'GameTime';
|
|
100
|
+
};
|
|
101
|
+
/** 長さ (ms)。差や間隔はこちら。 */
|
|
102
|
+
type Duration = number;
|
|
103
|
+
/**
|
|
104
|
+
* `GameTime` に長さを足す。
|
|
105
|
+
*
|
|
106
|
+
* 算術をすると brand が落ちる (`number + number = number`) ので、`GameTime` を
|
|
107
|
+
* 作り直す道はこの関数と `ctx.after` に限られる。
|
|
108
|
+
*/
|
|
109
|
+
declare const plus: (t: GameTime, d: Duration) => GameTime;
|
|
110
|
+
/** 2 つの `GameTime` の差 (ms)。 */
|
|
111
|
+
declare const minus: (a: GameTime, b: GameTime) => Duration;
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region ../engine-core/src/types.d.ts
|
|
95
114
|
/**
|
|
96
115
|
* roster に載る席。
|
|
97
116
|
*
|
|
@@ -128,20 +147,24 @@ interface SeededRandom {
|
|
|
128
147
|
*/
|
|
129
148
|
interface ActionContext {
|
|
130
149
|
/**
|
|
131
|
-
*
|
|
150
|
+
* 現在のゲーム内時刻。先読みではクロックオフセットからの推定値。
|
|
132
151
|
*
|
|
133
152
|
* 推定なのでサーバーとは数十 ms ずれる。時刻での分岐に使うと境界で判定が割れるので、
|
|
134
|
-
* 分岐は `update()` (サーバー専用)
|
|
153
|
+
* 分岐は `update()` / `deadlines` (サーバー専用) へ寄せる。
|
|
135
154
|
*
|
|
136
155
|
* 同じ action を再適用しても値は変わらない (初回予測時の値を使い回す)。
|
|
137
156
|
*/
|
|
138
|
-
|
|
157
|
+
time: GameTime;
|
|
158
|
+
/** 今から `d` ms 後のゲーム内時刻。締切を state へ置くときに使う。 */
|
|
159
|
+
after(d: Duration): GameTime;
|
|
139
160
|
emit: Emit;
|
|
140
161
|
}
|
|
141
162
|
/** `deadlines` handler の実行文脈。サーバーでしか走らないので tick 以外を渡せる。 */
|
|
142
163
|
interface DeadlineContext {
|
|
143
|
-
/**
|
|
144
|
-
|
|
164
|
+
/** 発火時点のゲーム内時刻。サーバー専用なので常に正確。 */
|
|
165
|
+
time: GameTime;
|
|
166
|
+
/** 今から `d` ms 後のゲーム内時刻。次の締切を state へ置き直すときに使う。 */
|
|
167
|
+
after(d: Duration): GameTime;
|
|
145
168
|
random: SeededRandom;
|
|
146
169
|
emit: Emit;
|
|
147
170
|
}
|
|
@@ -162,17 +185,21 @@ interface DeadlineArgs<S> {
|
|
|
162
185
|
*/
|
|
163
186
|
interface Deadline<S> {
|
|
164
187
|
/**
|
|
165
|
-
*
|
|
188
|
+
* 締切のゲーム内時刻。締切が無いときは null / undefined。
|
|
166
189
|
*
|
|
167
190
|
* `state.timer?.endsAt` のような optional chain の結果をそのまま返せるよう
|
|
168
191
|
* undefined も受ける。数値以外は「締切なし」として同じに扱う。
|
|
169
192
|
*
|
|
170
|
-
*
|
|
193
|
+
* 戻り値が `GameTime` なのは強制点。`Date.now() + 尺` も素の number も返せないので、
|
|
194
|
+
* 値を得る道が `ctx.after()` / `plus()` に限られ、state の時刻フィールドを
|
|
195
|
+
* `GameTime` で持つ動機が自動的に生まれる。
|
|
196
|
+
*
|
|
197
|
+
* state が変わるたびに呼ばれるので、state だけから決まる軽い純関数にすること。
|
|
171
198
|
* ここで実時刻や乱数を読むと、呼ばれるたびに答えが変わって予約が暴れる。
|
|
172
199
|
*/
|
|
173
200
|
at(args: {
|
|
174
201
|
state: S;
|
|
175
|
-
}):
|
|
202
|
+
}): GameTime | null | undefined;
|
|
176
203
|
/**
|
|
177
204
|
* `at` の時刻を過ぎたときにサーバーで呼ばれる。
|
|
178
205
|
*
|
|
@@ -185,8 +212,10 @@ interface Deadline<S> {
|
|
|
185
212
|
interface ServerActionContext {
|
|
186
213
|
tick: number;
|
|
187
214
|
random: SeededRandom;
|
|
188
|
-
/**
|
|
189
|
-
|
|
215
|
+
/** 現在のゲーム内時刻。同じ dispatch の `actions` に渡る `ctx.time` と同一値。 */
|
|
216
|
+
time: GameTime;
|
|
217
|
+
/** 今から `d` ms 後のゲーム内時刻。 */
|
|
218
|
+
after(d: Duration): GameTime;
|
|
190
219
|
emit: Emit;
|
|
191
220
|
}
|
|
192
221
|
/** @deprecated `ServerActionContext` を使う。 */
|
|
@@ -230,8 +259,10 @@ type ServerOnlyAction<S> = ServerActionHandler<S> & {
|
|
|
230
259
|
interface UpdateContext {
|
|
231
260
|
random: SeededRandom;
|
|
232
261
|
tick: number;
|
|
233
|
-
/**
|
|
234
|
-
|
|
262
|
+
/** 現在のゲーム内時刻。update はサーバーでしか走らないので常に正確。 */
|
|
263
|
+
time: GameTime;
|
|
264
|
+
/** 今から `d` ms 後のゲーム内時刻。 */
|
|
265
|
+
after(d: Duration): GameTime;
|
|
235
266
|
emit: Emit;
|
|
236
267
|
playerInputs: Record<string, Record<string, any>>;
|
|
237
268
|
}
|
|
@@ -246,8 +277,10 @@ interface UpdateArgs<S> {
|
|
|
246
277
|
*/
|
|
247
278
|
interface SetupContext {
|
|
248
279
|
random: SeededRandom;
|
|
249
|
-
/**
|
|
250
|
-
|
|
280
|
+
/** ゲーム内時刻。setup は時計の起点なので必ず `0`。 */
|
|
281
|
+
time: GameTime;
|
|
282
|
+
/** 今から `d` ms 後のゲーム内時刻。setup では `d` そのものになる。 */
|
|
283
|
+
after(d: Duration): GameTime;
|
|
251
284
|
}
|
|
252
285
|
/** `setup()` の引数。 */
|
|
253
286
|
interface SetupArgs {
|
|
@@ -454,7 +487,7 @@ declare global {
|
|
|
454
487
|
//#endregion
|
|
455
488
|
//#region src/dev-prediction-traps.d.ts
|
|
456
489
|
interface PredictionWarning {
|
|
457
|
-
/** 呼び出した action
|
|
490
|
+
/** 呼び出した action 名。描画経路では `'(render)'`。 */
|
|
458
491
|
action: string;
|
|
459
492
|
/** 呼ばれた API 名 (`'Date.now()'` など) */
|
|
460
493
|
api: string;
|
|
@@ -679,4 +712,4 @@ interface DevHooksCtx<S = unknown> {
|
|
|
679
712
|
declare function createDevHooks<S>(ctx: DevHooksCtx<S>): UzuDevHooks<S>;
|
|
680
713
|
declare function attachDevHooks<S>(ctx: DevHooksCtx<S>): void;
|
|
681
714
|
//#endregion
|
|
682
|
-
export {
|
|
715
|
+
export { minus as $, DEFAULT_ICON_URLS as A, ServerActionContext as B, SetFn as C, ActionContext as D, ActionArgs as E, GameLogic as F, ServerOnlyActionContext as G, ServerActionMap as H, SERVER_TIME as I, SetupContext as J, ServerOnlyActionHandlerFn as K, Seat as L, DeadlineArgs as M, DeadlineContext as N, ActionHandler as O, Emit as P, GameTime as Q, SeededRandom as R, SendAction as S, Operation as T, ServerEvent as U, ServerActionHandler as V, ServerOnlyAction as W, UpdateContext as X, UpdateArgs as Y, Duration as Z, PayloadMap as _, attachDevHooks as a, PlayersChangedMessage as b, getPredictionWarnings as c, ConnectionCallbacks as d, plus as et, ConnectionState as f, PatchFn as g, GameConfig as h, UzuDevHooks as i, applyJsonPatch as it, Deadline as j, ActionMap as k, BridgeChannel as l, EventSubscription as m, RunHandle as n, JsonPatchOp as nt, createDevHooks as o, EventHandler as p, SetupArgs as q, SyncHandle as r, applyJsonMergePatch as rt, PredictionWarning as s, DevHooksCtx as t, JsonMergePatch as tt, BridgeMessage as u, PlayScreenMessage as v, SyncConfig as w, SeatKind as x, PlayerVoiceState as y, ServerActionArgs as z };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as minus, A as DEFAULT_ICON_URLS, B as ServerActionContext, C as SetFn, D as ActionContext, E as ActionArgs, F as GameLogic, G as ServerOnlyActionContext, H as ServerActionMap, I as SERVER_TIME, J as SetupContext, K as ServerOnlyActionHandlerFn, L as Seat, M as DeadlineArgs, N as DeadlineContext, O as ActionHandler, P as Emit, Q as GameTime, R as SeededRandom, S as SendAction, T as Operation, U as ServerEvent, V as ServerActionHandler, W as ServerOnlyAction, X as UpdateContext, Y as UpdateArgs, Z as Duration, _ as PayloadMap, a as attachDevHooks, b as PlayersChangedMessage, c as getPredictionWarnings, d as ConnectionCallbacks, et as plus, f as ConnectionState, g as PatchFn, h as GameConfig, i as UzuDevHooks, it as applyJsonPatch, j as Deadline, k as ActionMap, l as BridgeChannel, m as EventSubscription, n as RunHandle, nt as JsonPatchOp, o as createDevHooks, p as EventHandler, q as SetupArgs, r as SyncHandle, rt as applyJsonMergePatch, s as PredictionWarning, t as DevHooksCtx, tt as JsonMergePatch, u as BridgeMessage, v as PlayScreenMessage, w as SyncConfig, x as SeatKind, y as PlayerVoiceState, z as ServerActionArgs } from "./dev-hooks-D6CbhPDP.js";
|
|
2
2
|
//#region ../engine-core/src/random.d.ts
|
|
3
3
|
declare class SeededRandomImpl implements SeededRandom {
|
|
4
4
|
private _state;
|
|
@@ -32,12 +32,18 @@ declare function isServerOnlyAction<S>(handler: ActionHandler<S> | ServerActionH
|
|
|
32
32
|
//#endregion
|
|
33
33
|
//#region src/server-clock.d.ts
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
35
|
+
* ゲーム内時刻の推定値。停止中は凍る。
|
|
36
36
|
*
|
|
37
37
|
* カウントダウン描画のように毎秒/毎フレーム呼ぶ用途を想定しているので、
|
|
38
38
|
* state の到着とは無関係にいつでも呼べる。
|
|
39
39
|
*/
|
|
40
|
-
declare function
|
|
40
|
+
declare function gameTime(): GameTime;
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/pause-state.d.ts
|
|
43
|
+
/** 緊急停止中か。 */
|
|
44
|
+
declare function isPaused(): boolean;
|
|
45
|
+
/** 停止状態の変化を購読する。 */
|
|
46
|
+
declare function onPauseChange(cb: (paused: boolean) => void): void;
|
|
41
47
|
//#endregion
|
|
42
48
|
//#region src/room.d.ts
|
|
43
49
|
type RoomMessageHandler = (data: Record<string, unknown>) => void;
|
|
@@ -199,4 +205,4 @@ declare function onPlayersChanged(handler: PlayersChangedHandler): void;
|
|
|
199
205
|
declare function run<S, A extends ActionMap<S> = ActionMap<S>, SA extends ServerActionMap<S> = Record<never, never>>(config: GameConfig<S, A, SA>): void;
|
|
200
206
|
declare function sync<S>(config: SyncConfig<S>): void;
|
|
201
207
|
//#endregion
|
|
202
|
-
export { type ActionArgs, type ActionContext, type ActionHandler, type ActionMap, type BridgeChannel, type BridgeMessage, type ConnectionCallbacks, type ConnectionState, DEFAULT_ICON_URLS, type Deadline, type DeadlineArgs, type DeadlineContext, type DevHooksCtx, type Emit, type EventHandler, type EventSubscription, type GameConfig, type GameLogic, type JsonMergePatch, type JsonPatchOp, type Operation, type PatchFn, type PayloadMap, type PlayScreenMessage, type PlayerVoiceState, type PlayersChangedMessage, type PredictionWarning, type ReconnectableWSOptions, ReconnectableWebSocket, Room, type RoomLike, type RunHandle, SERVER_TIME, type Seat, type SeatKind, type SeededRandom, SeededRandomImpl, type SendAction, type ServerActionArgs, type ServerActionContext, type ServerActionHandler, type ServerActionMap, type ServerEvent, type ServerOnlyAction, type ServerOnlyActionContext, type ServerOnlyActionHandlerFn, type SetFn, type SetupArgs, type SetupContext, type SyncConfig, type SyncHandle, type UpdateArgs, type UpdateContext, type UzuDevHooks, applyJsonMergePatch, applyJsonPatch, attachDevHooks, changeRoom, createDevHooks, firstFrameReady, gameReady, getPredictionWarnings, getRoom, init, isHosted, isServerOnlyAction, on, onPlayersChanged, onRoom, playBgm, playSound, run, send,
|
|
208
|
+
export { type ActionArgs, type ActionContext, type ActionHandler, type ActionMap, type BridgeChannel, type BridgeMessage, type ConnectionCallbacks, type ConnectionState, DEFAULT_ICON_URLS, type Deadline, type DeadlineArgs, type DeadlineContext, type DevHooksCtx, type Duration, type Emit, type EventHandler, type EventSubscription, type GameConfig, type GameLogic, type GameTime, type JsonMergePatch, type JsonPatchOp, type Operation, type PatchFn, type PayloadMap, type PlayScreenMessage, type PlayerVoiceState, type PlayersChangedMessage, type PredictionWarning, type ReconnectableWSOptions, ReconnectableWebSocket, Room, type RoomLike, type RunHandle, SERVER_TIME, type Seat, type SeatKind, type SeededRandom, SeededRandomImpl, type SendAction, type ServerActionArgs, type ServerActionContext, type ServerActionHandler, type ServerActionMap, type ServerEvent, type ServerOnlyAction, type ServerOnlyActionContext, type ServerOnlyActionHandlerFn, type SetFn, type SetupArgs, type SetupContext, type SyncConfig, type SyncHandle, type UpdateArgs, type UpdateContext, type UzuDevHooks, applyJsonMergePatch, applyJsonPatch, attachDevHooks, changeRoom, createDevHooks, firstFrameReady, gameReady, gameTime, getPredictionWarnings, getRoom, init, isHosted, isPaused, isServerOnlyAction, minus, on, onPauseChange, onPlayersChanged, onRoom, playBgm, playSound, plus, run, send, serverOnly, setMicEnabled, stopBgm, sync };
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,170 @@ const DEFAULT_ICON_URLS = [
|
|
|
8
8
|
"https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/43f45d11-da38-4d6e-637d-3df78e583500/original"
|
|
9
9
|
];
|
|
10
10
|
//#endregion
|
|
11
|
+
//#region ../engine-core/src/game-time.ts
|
|
12
|
+
/**
|
|
13
|
+
* `GameTime` に長さを足す。
|
|
14
|
+
*
|
|
15
|
+
* 算術をすると brand が落ちる (`number + number = number`) ので、`GameTime` を
|
|
16
|
+
* 作り直す道はこの関数と `ctx.after` に限られる。
|
|
17
|
+
*/
|
|
18
|
+
const plus = (t, d) => t + d;
|
|
19
|
+
/** 2 つの `GameTime` の差 (ms)。 */
|
|
20
|
+
const minus = (a, b) => a - b;
|
|
21
|
+
/**
|
|
22
|
+
* エンジン内部専用。作家コードから呼ばない。
|
|
23
|
+
*
|
|
24
|
+
* 時計の実装 (play-server の facet / dev-server / ソロ / SDK の補間) だけがここを通る。
|
|
25
|
+
*/
|
|
26
|
+
const asGameTime = (ms) => ms;
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region ../engine-core/src/game-clock.ts
|
|
29
|
+
/**
|
|
30
|
+
* @docs
|
|
31
|
+
* - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
|
|
32
|
+
*
|
|
33
|
+
* ゲーム内時計の実体。3 つのサーバー実装 (play-server の facet / cli の dev-server /
|
|
34
|
+
* SDK のソロ) が同じ挙動になるよう、時計の算術と凍結の出入りをここ 1 箇所に置く。
|
|
35
|
+
*
|
|
36
|
+
* ここに置かないもの: 永続化・tick ループ・broadcast・無人凍結の発火条件。
|
|
37
|
+
* それらはランタイムごとに手段が違うので、各実装が持つ。
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* ゲーム開始からの経過時間を数える時計。停止中は進まない。
|
|
41
|
+
*
|
|
42
|
+
* 実体はスカラー 3 本。シナリオの state は 1 バイトも書き換えない (どのフィールドが
|
|
43
|
+
* 時刻かエンジンは知らないので書き換えられない)。
|
|
44
|
+
*/
|
|
45
|
+
var GameClock = class {
|
|
46
|
+
constructor() {
|
|
47
|
+
this.startedAtWall = 0;
|
|
48
|
+
this.pausedTotalMs = 0;
|
|
49
|
+
this.pausedAtWall = null;
|
|
50
|
+
this.frozenBy = /* @__PURE__ */ new Set();
|
|
51
|
+
}
|
|
52
|
+
/** 時計を 0 から始め直す。`setup()` を呼ぶ直前に通す。 */
|
|
53
|
+
restart() {
|
|
54
|
+
this.startedAtWall = Date.now();
|
|
55
|
+
this.pausedTotalMs = 0;
|
|
56
|
+
this.pausedAtWall = null;
|
|
57
|
+
this.frozenBy.clear();
|
|
58
|
+
}
|
|
59
|
+
get frozen() {
|
|
60
|
+
return this.frozenBy.size > 0;
|
|
61
|
+
}
|
|
62
|
+
isFrozenBy(reason) {
|
|
63
|
+
return this.frozenBy.has(reason);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 現在のゲーム内時刻。停止中は `pausedAtWall` で凍るので同じ値を返し続ける。
|
|
67
|
+
*
|
|
68
|
+
* IMPORTANT: [withGameClock] の中で呼んではいけない。実装が `Date.now()` を読むので、
|
|
69
|
+
* 差し替え済みの時刻を実時刻として引き算し、大きく負の値になる。
|
|
70
|
+
* ハンドラへ渡す時刻は必ず外で 1 回取ってから渡すこと。
|
|
71
|
+
*/
|
|
72
|
+
now() {
|
|
73
|
+
return asGameTime((this.pausedAtWall ?? Date.now()) - this.startedAtWall - this.pausedTotalMs);
|
|
74
|
+
}
|
|
75
|
+
/** ゲーム内時刻を、alarm / setTimeout が使う実時刻へ直す。 */
|
|
76
|
+
toWall(at) {
|
|
77
|
+
return at + this.startedAtWall + this.pausedTotalMs;
|
|
78
|
+
}
|
|
79
|
+
/** 実際に理由を足したら true。呼び出し側が永続化や tick 停止の要否に使う。 */
|
|
80
|
+
freeze(reason) {
|
|
81
|
+
if (this.frozenBy.has(reason)) return false;
|
|
82
|
+
const wasFrozen = this.frozen;
|
|
83
|
+
this.frozenBy.add(reason);
|
|
84
|
+
if (!wasFrozen) this.pausedAtWall = Date.now();
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
/** 実際に理由を外したら true。 */
|
|
88
|
+
unfreeze(reason) {
|
|
89
|
+
if (!this.frozenBy.delete(reason)) return false;
|
|
90
|
+
if (!this.frozen && this.pausedAtWall !== null) {
|
|
91
|
+
this.pausedTotalMs += Date.now() - this.pausedAtWall;
|
|
92
|
+
this.pausedAtWall = null;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
snapshot() {
|
|
97
|
+
return {
|
|
98
|
+
startedAtWall: this.startedAtWall,
|
|
99
|
+
pausedTotalMs: this.pausedTotalMs,
|
|
100
|
+
pausedAtWall: this.pausedAtWall,
|
|
101
|
+
frozenBy: [...this.frozenBy]
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 永続化した内容から復元する。欠損は「未停止」に倒す。
|
|
106
|
+
*
|
|
107
|
+
* 読めなかったせいで世界が止まったままになる方が、動き出すより悪い。
|
|
108
|
+
*/
|
|
109
|
+
restore(saved) {
|
|
110
|
+
this.startedAtWall = saved.startedAtWall ?? 0;
|
|
111
|
+
this.pausedTotalMs = saved.pausedTotalMs ?? 0;
|
|
112
|
+
this.pausedAtWall = saved.pausedAtWall ?? null;
|
|
113
|
+
this.frozenBy.clear();
|
|
114
|
+
for (const reason of saved.frozenBy ?? []) this.frozenBy.add(reason);
|
|
115
|
+
if (this.frozen && this.pausedAtWall === null) this.frozenBy.clear();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* 作家の**同期**ハンドラを、時刻 API を差し替えた状態で走らせる。
|
|
120
|
+
*
|
|
121
|
+
* `sdk/src/dev-prediction-traps.ts` の runPredicted と同じ形。あちらは dev で警告を
|
|
122
|
+
* 出すための差し替えだが、こちらはゲーム内時計を返すための差し替え。
|
|
123
|
+
* `await` を挟まないので、JS が単一スレッドである以上ほかの部屋と混ざらない。
|
|
124
|
+
*
|
|
125
|
+
* IMPORTANT: 3 実装すべてで通すこと。ここが片方だけだと、生 `Date.now()` を書いた
|
|
126
|
+
* シナリオが「dev では壊れるのに本番では動く」(あるいはその逆) になる。
|
|
127
|
+
*
|
|
128
|
+
* 適用しないもの:
|
|
129
|
+
* - `serverActions` — async なので `await` をまたぐと復元が効かない
|
|
130
|
+
* - `deadlines[].at()` — state だけの純関数という契約があり、毎 state 変更で呼ばれる
|
|
131
|
+
*
|
|
132
|
+
* IMPORTANT: `fn` は**同期で完結すること**。`await` をまたぐと差し替えたまま制御が
|
|
133
|
+
* 抜け、無関係な処理まで偽の `Date.now` を読む。`fn` が Promise を返す形にしたく
|
|
134
|
+
* なったら、それは `serverActions` 側へ寄せるべき処理という合図。
|
|
135
|
+
*
|
|
136
|
+
* IMPORTANT: `fn` の中で [GameClock.now] を呼ばないこと。差し替えた `Date.now` を
|
|
137
|
+
* 実時刻として引き算してしまう。ハンドラへ渡す ctx は呼び出す前に組み立てる。
|
|
138
|
+
*/
|
|
139
|
+
function withGameClock(time, fn) {
|
|
140
|
+
const realNow = Date.now;
|
|
141
|
+
const RealDate = Date;
|
|
142
|
+
Date.now = () => time;
|
|
143
|
+
globalThis.Date = new Proxy(RealDate, { construct: (target, args) => Reflect.construct(target, args.length === 0 ? [time] : args) });
|
|
144
|
+
try {
|
|
145
|
+
return fn();
|
|
146
|
+
} finally {
|
|
147
|
+
globalThis.Date = RealDate;
|
|
148
|
+
Date.now = realNow;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* `deadlines[].at()` が返した値を締切として使えるかに正規化する。使えなければ null。
|
|
153
|
+
*
|
|
154
|
+
* `null` / `undefined` は「締切なし」で正常なので黙って捨てる。**数値だが有限でない
|
|
155
|
+
* (NaN / Infinity) 場合だけは記録に残す。** これはほぼ確実に `undefined` を含む算術の
|
|
156
|
+
* 結果で、旧 `ctx.now` を読んでいるシナリオを新エンジンで動かすと
|
|
157
|
+
* `undefined + 尺 = NaN` になり、締切が「二度と来ない」形で静かに死ぬ。
|
|
158
|
+
* 黙って捨てるとログにも例外にも出ないので、key ごとに 1 回だけ必ず出す。
|
|
159
|
+
*
|
|
160
|
+
* `report` を受け取るのは、engine-core が `console` を含むどのランタイム API にも
|
|
161
|
+
* 依存できないため (3 ランタイムが lib ES2022 だけでこのファイルを共有している)。
|
|
162
|
+
*
|
|
163
|
+
* @param warned 既に警告した key。呼び出し側がインスタンスごとに持つ。
|
|
164
|
+
*/
|
|
165
|
+
function resolveDeadline(at, key, warned, report) {
|
|
166
|
+
if (at === null || at === void 0) return null;
|
|
167
|
+
if (typeof at === "number" && Number.isFinite(at)) return asGameTime(at);
|
|
168
|
+
if (!warned.has(key)) {
|
|
169
|
+
warned.add(key);
|
|
170
|
+
report(`deadline "${key}" の at() が締切に使えない値を返した: ${String(at)}。この締切は二度と発火しない。廃止された ctx.now を読んでいないか確認すること`);
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
11
175
|
//#region ../engine-core/src/json-patch.ts
|
|
12
176
|
function unescapePointer(token) {
|
|
13
177
|
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
@@ -101,35 +265,119 @@ function isServerOnlyAction(handler) {
|
|
|
101
265
|
/**
|
|
102
266
|
* @docs
|
|
103
267
|
* - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
|
|
268
|
+
* - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
|
|
104
269
|
*
|
|
105
|
-
*
|
|
270
|
+
* サーバーのゲーム内時計の推定値をクライアント全体へ配る。
|
|
106
271
|
*
|
|
107
|
-
* state に入っている `endsAt`
|
|
108
|
-
*
|
|
109
|
-
* (残り時間が恒久的に狂う / 期限判定がサーバーと食い違う)。読み取り側も同じ時計に
|
|
110
|
-
* 揃えるための関数。
|
|
272
|
+
* state に入っている `endsAt` のような締切はサーバーのゲーム内時計で打たれている。
|
|
273
|
+
* 読み取り側も同じ時計に揃えないと、残り時間が恒久的に狂う。
|
|
111
274
|
*
|
|
112
275
|
* ```ts
|
|
113
|
-
* const remain = Math.ceil((state.game.timerEndsAt
|
|
276
|
+
* const remain = Math.ceil(minus(state.game.timerEndsAt, gameTime()) / 1000);
|
|
114
277
|
* ```
|
|
115
278
|
*
|
|
279
|
+
* 基準は `performance.now()` (単調時計)。`Date.now()` を長さの計算に使うと、端末の時計が
|
|
280
|
+
* NTP やユーザー操作でずれた瞬間に全部のカウントダウンが飛ぶ。POSIX / Rust / C++ が
|
|
281
|
+
* 揃って禁じている形なので、ここでも採らない。
|
|
282
|
+
*
|
|
116
283
|
* オフセットは transport がサーバーからのメッセージを受けるたびに更新する。
|
|
117
|
-
* 未接続 /
|
|
284
|
+
* 未接続 / 未観測ならオフセット 0 のまま (オフラインでも壊れない)。
|
|
118
285
|
*/
|
|
119
286
|
let offset = 0;
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
287
|
+
/** 停止中に凍らせた値。null なら進んでいる。 */
|
|
288
|
+
let frozenAt = null;
|
|
289
|
+
/**
|
|
290
|
+
* transport から呼ぶ内部関数。サーバーが打刻したゲーム内時刻を観測してオフセットを更新する。
|
|
291
|
+
*
|
|
292
|
+
* 下り片道遅延を無視するので推定は実サーバー時刻より僅かに遅れる。
|
|
293
|
+
* 表示用途では無視できる誤差なので、精度より単純さを取る。
|
|
294
|
+
*/
|
|
295
|
+
function observeGameTime(t) {
|
|
296
|
+
if (typeof t !== "number" || !Number.isFinite(t)) return;
|
|
297
|
+
offset = t - performance.now();
|
|
124
298
|
}
|
|
125
299
|
/**
|
|
126
|
-
*
|
|
300
|
+
* ゲーム内時刻の推定値。停止中は凍る。
|
|
127
301
|
*
|
|
128
302
|
* カウントダウン描画のように毎秒/毎フレーム呼ぶ用途を想定しているので、
|
|
129
303
|
* state の到着とは無関係にいつでも呼べる。
|
|
130
304
|
*/
|
|
131
|
-
function
|
|
132
|
-
return
|
|
305
|
+
function gameTime() {
|
|
306
|
+
return asGameTime(frozenAt ?? performance.now() + offset);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* 時計を凍らせる。
|
|
310
|
+
*
|
|
311
|
+
* 停止中はサーバーの broadcast が止まって打刻が来ないので、`__pause_state` を受けた
|
|
312
|
+
* 時点の値で明示的に止める。解除後は通常の打刻でオフセットが入れ替わるため、
|
|
313
|
+
* 特別な補正は要らない。
|
|
314
|
+
*/
|
|
315
|
+
function freezeGameTime() {
|
|
316
|
+
frozenAt = gameTime();
|
|
317
|
+
}
|
|
318
|
+
function unfreezeGameTime() {
|
|
319
|
+
frozenAt = null;
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/pause-state.ts
|
|
323
|
+
/**
|
|
324
|
+
* @docs
|
|
325
|
+
* - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
|
|
326
|
+
*
|
|
327
|
+
* 緊急停止の状態を SDK 全体で 1 箇所に持つ。
|
|
328
|
+
*
|
|
329
|
+
* 停止は state ではない。シナリオの state に持ち込むと「停止中は state を変えるコードが
|
|
330
|
+
* 1 行も走らない」という不変条件が崩れるので、`GameLogic` へは渡さず読み取り専用の
|
|
331
|
+
* 関数として出す。用途は演出の停止 (rAF ループを止める等) に限る。
|
|
332
|
+
*
|
|
333
|
+
* ゲーム内時計の凍結もここから駆動する。停止フラグと時計の凍結が別々に動くと、
|
|
334
|
+
* 「止まっているのにカウントダウンだけ進む」がいつか必ず起きる。
|
|
335
|
+
*/
|
|
336
|
+
let paused = false;
|
|
337
|
+
let pausedBy = null;
|
|
338
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
339
|
+
/** transport から呼ぶ内部関数。停止状態が変わったときだけ通知する。 */
|
|
340
|
+
function setPauseState(next, by) {
|
|
341
|
+
pausedBy = next ? by : null;
|
|
342
|
+
if (next === paused) return;
|
|
343
|
+
paused = next;
|
|
344
|
+
if (paused) freezeGameTime();
|
|
345
|
+
else unfreezeGameTime();
|
|
346
|
+
for (const cb of listeners) try {
|
|
347
|
+
cb(paused);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
console.warn("[uzu] onPauseChange listener threw:", err);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** 緊急停止中か。 */
|
|
353
|
+
function isPaused() {
|
|
354
|
+
return paused;
|
|
355
|
+
}
|
|
356
|
+
/** 停止を要求した playerId。停止していなければ null。 */
|
|
357
|
+
function getPausedBy() {
|
|
358
|
+
return pausedBy;
|
|
359
|
+
}
|
|
360
|
+
/** 停止状態の変化を購読する。 */
|
|
361
|
+
function onPauseChange(cb) {
|
|
362
|
+
listeners.add(cb);
|
|
363
|
+
}
|
|
364
|
+
let requester = null;
|
|
365
|
+
function setPauseRequester(fn) {
|
|
366
|
+
requester = fn;
|
|
367
|
+
}
|
|
368
|
+
function requestPause() {
|
|
369
|
+
if (!requester) {
|
|
370
|
+
console.warn("[uzu] requestPause: サーバーへ接続していないので停止できません");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
requester(true);
|
|
374
|
+
}
|
|
375
|
+
function requestResume() {
|
|
376
|
+
if (!requester) {
|
|
377
|
+
console.warn("[uzu] requestResume: サーバーへ接続していないので解除できません");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
requester(false);
|
|
133
381
|
}
|
|
134
382
|
//#endregion
|
|
135
383
|
//#region src/room.ts
|
|
@@ -578,61 +826,75 @@ function replaceRoot(doc, value) {
|
|
|
578
826
|
}
|
|
579
827
|
//#endregion
|
|
580
828
|
//#region src/dev-prediction-traps.ts
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
return ()
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
];
|
|
829
|
+
/** 描画経路の警告に使う擬似 action 名。 */
|
|
830
|
+
const RENDER_CONTEXT = "(render)";
|
|
831
|
+
const CLOCK_TRAPS = [{ install: (report) => {
|
|
832
|
+
const RealDate = Date;
|
|
833
|
+
const realNow = Date.now;
|
|
834
|
+
globalThis.Date = new Proxy(RealDate, { construct: (target, args) => {
|
|
835
|
+
if (args.length === 0) report("new Date()");
|
|
836
|
+
return Reflect.construct(target, args);
|
|
837
|
+
} });
|
|
838
|
+
Date.now = () => {
|
|
839
|
+
report("Date.now()");
|
|
840
|
+
return realNow();
|
|
841
|
+
};
|
|
842
|
+
return () => {
|
|
843
|
+
Date.now = realNow;
|
|
844
|
+
globalThis.Date = RealDate;
|
|
845
|
+
};
|
|
846
|
+
} }, { install: (report) => {
|
|
847
|
+
const real = globalThis.performance?.now;
|
|
848
|
+
if (!real) return () => {};
|
|
849
|
+
const bound = real.bind(globalThis.performance);
|
|
850
|
+
globalThis.performance.now = () => {
|
|
851
|
+
report("performance.now()");
|
|
852
|
+
return bound();
|
|
853
|
+
};
|
|
854
|
+
return () => {
|
|
855
|
+
globalThis.performance.now = real;
|
|
856
|
+
};
|
|
857
|
+
} }];
|
|
858
|
+
/** 乱数系。先読みでだけ問題になるので描画経路には張らない。 */
|
|
859
|
+
const RANDOM_TRAPS = [{ install: (report) => {
|
|
860
|
+
const real = Math.random;
|
|
861
|
+
Math.random = () => {
|
|
862
|
+
report("Math.random()");
|
|
863
|
+
return real();
|
|
864
|
+
};
|
|
865
|
+
return () => {
|
|
866
|
+
Math.random = real;
|
|
867
|
+
};
|
|
868
|
+
} }, { install: (report) => {
|
|
869
|
+
const real = globalThis.crypto?.randomUUID;
|
|
870
|
+
if (!real) return () => {};
|
|
871
|
+
const bound = real.bind(globalThis.crypto);
|
|
872
|
+
globalThis.crypto.randomUUID = () => {
|
|
873
|
+
report("crypto.randomUUID()");
|
|
874
|
+
return bound();
|
|
875
|
+
};
|
|
876
|
+
return () => {
|
|
877
|
+
globalThis.crypto.randomUUID = real;
|
|
878
|
+
};
|
|
879
|
+
} }];
|
|
880
|
+
const TRAPS = [...CLOCK_TRAPS, ...RANDOM_TRAPS];
|
|
633
881
|
const warnings = [];
|
|
634
882
|
const reported = /* @__PURE__ */ new Set();
|
|
635
883
|
const printWarning = (action, api) => {
|
|
884
|
+
if (action === RENDER_CONTEXT) {
|
|
885
|
+
console.groupCollapsed(`⚠️ [uzu-code] 描画中に ${api} を呼びました`);
|
|
886
|
+
console.log([
|
|
887
|
+
`${api} は Unix epoch の実時刻を返します。state に入っている締切は`,
|
|
888
|
+
"ゲーム内時刻 (ゲーム開始からの経過 ms) なので、引き算すると桁が合いません。",
|
|
889
|
+
"また実時刻は緊急停止中も進むので、止まっているのにカウントダウンだけ動きます。",
|
|
890
|
+
"",
|
|
891
|
+
"直し方: 時刻は gameTime() から取る。",
|
|
892
|
+
" import { gameTime, minus } from '@uzuhq/code-sdk';",
|
|
893
|
+
" const remain = Math.ceil(minus(state.timerEndsAt, gameTime()) / 1000);"
|
|
894
|
+
].join("\n"));
|
|
895
|
+
console.groupEnd();
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
636
898
|
console.groupCollapsed(`⚠️ [uzu-code] action "${action}" が先読み中に ${api} を呼びました`);
|
|
637
899
|
console.log([
|
|
638
900
|
"先読み (楽観的更新) は「サーバーと同じコードを同じ入力で走らせれば同じ結果になる」",
|
|
@@ -670,12 +932,24 @@ const notifyParent = (action, api) => {
|
|
|
670
932
|
* Flutter native ホスト (本番) では計装せず素通しする。判定基準は dev hooks と同じ。
|
|
671
933
|
*/
|
|
672
934
|
const runPredicted = (action, run) => {
|
|
935
|
+
runInstrumented(action, run);
|
|
936
|
+
};
|
|
937
|
+
/**
|
|
938
|
+
* `onState` から同期で走る描画を計装する。
|
|
939
|
+
*
|
|
940
|
+
* 乱数は描画で使ってよいので、時刻の罠だけ張る。ここで拾えるのは同期の描画だけで、
|
|
941
|
+
* `requestAnimationFrame` の中は原理的に拾えない。
|
|
942
|
+
*/
|
|
943
|
+
const runRendered = (run) => {
|
|
944
|
+
runInstrumented(RENDER_CONTEXT, run);
|
|
945
|
+
};
|
|
946
|
+
const runInstrumented = (action, run) => {
|
|
673
947
|
if (typeof window === "undefined" || window.FlutterHost) {
|
|
674
948
|
run();
|
|
675
949
|
return;
|
|
676
950
|
}
|
|
677
951
|
const report = (api) => {
|
|
678
|
-
const key = `${action}
|
|
952
|
+
const key = `${action}\u0000${api}`;
|
|
679
953
|
if (reported.has(key)) return;
|
|
680
954
|
reported.add(key);
|
|
681
955
|
warnings.push({
|
|
@@ -685,7 +959,7 @@ const runPredicted = (action, run) => {
|
|
|
685
959
|
printWarning(action, api);
|
|
686
960
|
notifyParent(action, api);
|
|
687
961
|
};
|
|
688
|
-
const restores = TRAPS.map((trap) => trap.install(report));
|
|
962
|
+
const restores = (action === RENDER_CONTEXT ? CLOCK_TRAPS : TRAPS).map((trap) => trap.install(report));
|
|
689
963
|
try {
|
|
690
964
|
run();
|
|
691
965
|
} finally {
|
|
@@ -810,7 +1084,7 @@ function createOptimisticActionClient(config) {
|
|
|
810
1084
|
let actionSeq = 0;
|
|
811
1085
|
/**
|
|
812
1086
|
* 送信済みだがサーバー未確認の action キュー。
|
|
813
|
-
* `
|
|
1087
|
+
* `time` は送信時に推定した値。再適用でも同じ値を使う (取り直すと表示がガタつく)。
|
|
814
1088
|
*/
|
|
815
1089
|
const pendingActions = [];
|
|
816
1090
|
/**
|
|
@@ -866,7 +1140,7 @@ function createOptimisticActionClient(config) {
|
|
|
866
1140
|
displayState = structuredClone(confirmedState);
|
|
867
1141
|
let i = 0;
|
|
868
1142
|
while (i < pendingActions.length) {
|
|
869
|
-
const { action, payload,
|
|
1143
|
+
const { action, payload, time } = pendingActions[i];
|
|
870
1144
|
const handler = logic.actions[action];
|
|
871
1145
|
if (!handler || isServerOnlyAction(handler)) {
|
|
872
1146
|
pendingActions.splice(i, 1);
|
|
@@ -879,7 +1153,8 @@ function createOptimisticActionClient(config) {
|
|
|
879
1153
|
payload,
|
|
880
1154
|
playerId,
|
|
881
1155
|
ctx: {
|
|
882
|
-
|
|
1156
|
+
time,
|
|
1157
|
+
after: (d) => plus(time, d),
|
|
883
1158
|
emit: noopEmit
|
|
884
1159
|
}
|
|
885
1160
|
}));
|
|
@@ -906,12 +1181,16 @@ function createOptimisticActionClient(config) {
|
|
|
906
1181
|
};
|
|
907
1182
|
return {
|
|
908
1183
|
send(type, payload) {
|
|
1184
|
+
if (isPaused()) {
|
|
1185
|
+
console.warn(`[uzu] 緊急停止中は action を送れません (action="${type}")`);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
909
1188
|
actionSeq++;
|
|
910
1189
|
const seq = actionSeq;
|
|
911
1190
|
const handler = logic.actions[type];
|
|
912
1191
|
if (!handler && !logic.serverActions?.[type]) console.error(`[uzu] unknown action: "${type}". logic.actions / logic.serverActions のどちらにも登録されていません。`);
|
|
913
1192
|
const target = displayState;
|
|
914
|
-
const
|
|
1193
|
+
const time = gameTime();
|
|
915
1194
|
const predictEmit = (eventName, data) => {
|
|
916
1195
|
const subscription = events?.[eventName];
|
|
917
1196
|
if (!subscription?.predict) return;
|
|
@@ -929,7 +1208,8 @@ function createOptimisticActionClient(config) {
|
|
|
929
1208
|
payload: payload ?? {},
|
|
930
1209
|
playerId,
|
|
931
1210
|
ctx: {
|
|
932
|
-
|
|
1211
|
+
time,
|
|
1212
|
+
after: (d) => plus(time, d),
|
|
933
1213
|
emit: predictEmit
|
|
934
1214
|
}
|
|
935
1215
|
}));
|
|
@@ -937,7 +1217,7 @@ function createOptimisticActionClient(config) {
|
|
|
937
1217
|
seq,
|
|
938
1218
|
action: type,
|
|
939
1219
|
payload: payload ?? {},
|
|
940
|
-
|
|
1220
|
+
time
|
|
941
1221
|
});
|
|
942
1222
|
onState(target, playerId);
|
|
943
1223
|
} catch {}
|
|
@@ -947,8 +1227,8 @@ function createOptimisticActionClient(config) {
|
|
|
947
1227
|
seq
|
|
948
1228
|
});
|
|
949
1229
|
},
|
|
950
|
-
|
|
951
|
-
|
|
1230
|
+
observeGameTime(t) {
|
|
1231
|
+
observeGameTime(t);
|
|
952
1232
|
},
|
|
953
1233
|
applyState(state, options = {}) {
|
|
954
1234
|
handleAck(options.ack, options.from, options.events ?? []);
|
|
@@ -1013,6 +1293,10 @@ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, se
|
|
|
1013
1293
|
}
|
|
1014
1294
|
}
|
|
1015
1295
|
});
|
|
1296
|
+
setPauseRequester((paused) => {
|
|
1297
|
+
console.log(`[SDK ServerAction] ➡ send ${paused ? "__pause" : "__resume"}`);
|
|
1298
|
+
ws.send(JSON.stringify({ type: paused ? "__pause" : "__resume" }));
|
|
1299
|
+
});
|
|
1016
1300
|
/** サーバー seq (delta の連続性チェック用) */
|
|
1017
1301
|
let serverSeq = 0;
|
|
1018
1302
|
/** フル state 再要求中フラグ (多重要求防止) */
|
|
@@ -1052,7 +1336,13 @@ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, se
|
|
|
1052
1336
|
}
|
|
1053
1337
|
const msgType = parsed.type;
|
|
1054
1338
|
console.log(`[SDK ServerAction] ⬅ recv type=${msgType}`);
|
|
1055
|
-
client.
|
|
1339
|
+
client.observeGameTime(parsed.gameTime);
|
|
1340
|
+
if (msgType === "__pause_state") {
|
|
1341
|
+
const frozen = parsed.frozen === true;
|
|
1342
|
+
console.log(`[SDK ServerAction] ${frozen ? "⏸" : "▶️"} pause_state frozen=${frozen}`);
|
|
1343
|
+
setPauseState(frozen, parsed.by ?? null);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1056
1346
|
if (msgType === "__room_init") {
|
|
1057
1347
|
console.log(`[SDK ServerAction] ✅ Room init myId=${parsed.myId}`);
|
|
1058
1348
|
return;
|
|
@@ -1130,6 +1420,7 @@ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, se
|
|
|
1130
1420
|
serverSeq = parsed.seq ?? 0;
|
|
1131
1421
|
requestStatePending = false;
|
|
1132
1422
|
console.log(`[SDK ServerAction] 🔄 State restored (reconnect/late join) seq=${serverSeq}`);
|
|
1423
|
+
setPauseState(parsed.frozen === true, parsed.pausedBy ?? null);
|
|
1133
1424
|
client.reset(parsed.state);
|
|
1134
1425
|
return;
|
|
1135
1426
|
}
|
|
@@ -1139,8 +1430,21 @@ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, se
|
|
|
1139
1430
|
//#region src/run/local-server-action.ts
|
|
1140
1431
|
function runLocalServerAction(config) {
|
|
1141
1432
|
const { logic, inputs, events } = config;
|
|
1142
|
-
const onState = (next, id) =>
|
|
1433
|
+
const onState = (next, id) => {
|
|
1434
|
+
observeGameTime(gameTime());
|
|
1435
|
+
config.onState(next, id, "player");
|
|
1436
|
+
};
|
|
1143
1437
|
const tickRate = logic.tickRate ?? 0;
|
|
1438
|
+
const clock = new GameClock();
|
|
1439
|
+
const gameTime = () => clock.now();
|
|
1440
|
+
const frozen = () => clock.frozen;
|
|
1441
|
+
/** ハンドラへ渡す ctx の時刻部分。1 回の呼び出し内で値が動かないよう束ねる。 */
|
|
1442
|
+
const timeCtx = (t) => ({
|
|
1443
|
+
time: t,
|
|
1444
|
+
after: (d) => plus(t, d)
|
|
1445
|
+
});
|
|
1446
|
+
/** at() が壊れた値を返したと既に記録した締切。ログを 1 回に絞るため。 */
|
|
1447
|
+
const warnedDeadlines = /* @__PURE__ */ new Set();
|
|
1144
1448
|
const random = new SeededRandomImpl(Math.floor(Math.random() * 4294967295));
|
|
1145
1449
|
const players = Array.from({ length: config.playerCount }, (_, i) => ({
|
|
1146
1450
|
id: `local_${i}`,
|
|
@@ -1154,14 +1458,15 @@ function runLocalServerAction(config) {
|
|
|
1154
1458
|
if (!logic.deadlines) return null;
|
|
1155
1459
|
let earliest = null;
|
|
1156
1460
|
for (const [key, deadline] of Object.entries(logic.deadlines)) {
|
|
1157
|
-
let
|
|
1461
|
+
let raw;
|
|
1158
1462
|
try {
|
|
1159
|
-
|
|
1463
|
+
raw = deadline.at({ state });
|
|
1160
1464
|
} catch (err) {
|
|
1161
1465
|
console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
|
|
1162
1466
|
continue;
|
|
1163
1467
|
}
|
|
1164
|
-
|
|
1468
|
+
const at = resolveDeadline(raw, key, warnedDeadlines, (m) => console.error(`[Deadline] ❌ ${m}`));
|
|
1469
|
+
if (at === null) continue;
|
|
1165
1470
|
if (earliest === null || at < earliest) earliest = at;
|
|
1166
1471
|
}
|
|
1167
1472
|
return earliest;
|
|
@@ -1169,28 +1474,28 @@ function runLocalServerAction(config) {
|
|
|
1169
1474
|
const fireDue = () => {
|
|
1170
1475
|
wakeupTimer = null;
|
|
1171
1476
|
wakeupAt = null;
|
|
1172
|
-
if (!logic.deadlines) return;
|
|
1173
|
-
const
|
|
1477
|
+
if (!logic.deadlines || frozen()) return;
|
|
1478
|
+
const time = gameTime();
|
|
1174
1479
|
const evts = [];
|
|
1175
1480
|
const firedKeys = [];
|
|
1176
1481
|
for (const [key, deadline] of Object.entries(logic.deadlines)) {
|
|
1177
1482
|
const pending = [];
|
|
1178
1483
|
let snapshot = null;
|
|
1179
1484
|
try {
|
|
1180
|
-
const at = deadline.at({ state });
|
|
1181
|
-
if (
|
|
1485
|
+
const at = resolveDeadline(deadline.at({ state }), key, warnedDeadlines, (m) => console.error(`[Deadline] ❌ ${m}`));
|
|
1486
|
+
if (at === null || at > time) continue;
|
|
1182
1487
|
snapshot = structuredClone(state);
|
|
1183
|
-
deadline.handler({
|
|
1488
|
+
withGameClock(time, () => deadline.handler({
|
|
1184
1489
|
state,
|
|
1185
1490
|
ctx: {
|
|
1186
|
-
|
|
1491
|
+
...timeCtx(time),
|
|
1187
1492
|
random,
|
|
1188
1493
|
emit: (name, data) => pending.push({
|
|
1189
1494
|
name,
|
|
1190
1495
|
data: data ?? {}
|
|
1191
1496
|
})
|
|
1192
1497
|
}
|
|
1193
|
-
});
|
|
1498
|
+
}));
|
|
1194
1499
|
evts.push(...pending);
|
|
1195
1500
|
firedKeys.push(key);
|
|
1196
1501
|
} catch (err) {
|
|
@@ -1205,29 +1510,35 @@ function runLocalServerAction(config) {
|
|
|
1205
1510
|
onState(state, myId);
|
|
1206
1511
|
};
|
|
1207
1512
|
const syncWakeup = () => {
|
|
1208
|
-
const next = nextDeadline();
|
|
1513
|
+
const next = frozen() ? null : nextDeadline();
|
|
1209
1514
|
if (next === wakeupAt) return;
|
|
1210
1515
|
if (wakeupTimer) clearTimeout(wakeupTimer);
|
|
1211
1516
|
wakeupTimer = null;
|
|
1212
1517
|
wakeupAt = next;
|
|
1213
1518
|
if (next === null) return;
|
|
1214
|
-
wakeupTimer = setTimeout(fireDue, Math.max(0, next - Date.now()));
|
|
1519
|
+
wakeupTimer = setTimeout(fireDue, Math.max(0, clock.toWall(next) - Date.now()));
|
|
1215
1520
|
};
|
|
1216
1521
|
const dispatchEvents = (evts) => {
|
|
1217
1522
|
for (const e of evts) events?.[e.name]?.handler(e.data);
|
|
1218
1523
|
};
|
|
1524
|
+
clock.restart();
|
|
1525
|
+
const setupTime = gameTime();
|
|
1219
1526
|
const setupArgs = {
|
|
1220
1527
|
players,
|
|
1221
1528
|
seats: players,
|
|
1222
1529
|
ctx: {
|
|
1223
1530
|
random,
|
|
1224
|
-
|
|
1531
|
+
...timeCtx(setupTime)
|
|
1225
1532
|
}
|
|
1226
1533
|
};
|
|
1227
|
-
let state = logic.setup(setupArgs);
|
|
1534
|
+
let state = withGameClock(setupTime, () => logic.setup(setupArgs));
|
|
1228
1535
|
let tick = 0;
|
|
1229
1536
|
const playerInputs = {};
|
|
1230
1537
|
const dispatchAction = (type, payload) => {
|
|
1538
|
+
if (frozen()) {
|
|
1539
|
+
console.warn(`[SDK LocalServerAction] 緊急停止中は action を実行しません (action="${type}")`);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1231
1542
|
const plain = logic.actions[type];
|
|
1232
1543
|
const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
|
|
1233
1544
|
const server = logic.serverActions?.[type] ?? legacyServerOnly;
|
|
@@ -1242,18 +1553,18 @@ function runLocalServerAction(config) {
|
|
|
1242
1553
|
name,
|
|
1243
1554
|
data: data ?? {}
|
|
1244
1555
|
});
|
|
1245
|
-
const
|
|
1556
|
+
const time = gameTime();
|
|
1246
1557
|
if (plain && !legacyServerOnly) {
|
|
1247
1558
|
try {
|
|
1248
|
-
plain({
|
|
1559
|
+
withGameClock(time, () => plain({
|
|
1249
1560
|
state,
|
|
1250
1561
|
payload: payload ?? {},
|
|
1251
1562
|
playerId: myId,
|
|
1252
1563
|
ctx: {
|
|
1253
|
-
|
|
1564
|
+
...timeCtx(time),
|
|
1254
1565
|
emit: plainEmit
|
|
1255
1566
|
}
|
|
1256
|
-
});
|
|
1567
|
+
}));
|
|
1257
1568
|
} catch (err) {
|
|
1258
1569
|
console.warn("[SDK LocalServerAction] Action error:", err);
|
|
1259
1570
|
return;
|
|
@@ -1272,7 +1583,7 @@ function runLocalServerAction(config) {
|
|
|
1272
1583
|
ctx: {
|
|
1273
1584
|
tick,
|
|
1274
1585
|
random,
|
|
1275
|
-
|
|
1586
|
+
...timeCtx(time),
|
|
1276
1587
|
emit: serverEmit
|
|
1277
1588
|
}
|
|
1278
1589
|
});
|
|
@@ -1285,26 +1596,42 @@ function runLocalServerAction(config) {
|
|
|
1285
1596
|
onState(state, myId);
|
|
1286
1597
|
})();
|
|
1287
1598
|
};
|
|
1599
|
+
/**
|
|
1600
|
+
* ソロの停止 / 解除。サーバーが居ないので自分の時計を直接触る。
|
|
1601
|
+
*
|
|
1602
|
+
* `__uzu_dev.pauseTick()` とは別物。あちらは tick を止めるだけの開発者ツールで、
|
|
1603
|
+
* ゲーム内時計も action ゲートも持たない。流用しない。
|
|
1604
|
+
*/
|
|
1605
|
+
const applyPause = (paused) => {
|
|
1606
|
+
if (!(paused ? clock.freeze("emergency-stop") : clock.unfreeze("emergency-stop"))) return;
|
|
1607
|
+
observeGameTime(gameTime());
|
|
1608
|
+
setPauseState(paused, paused ? myId : null);
|
|
1609
|
+
observeGameTime(gameTime());
|
|
1610
|
+
syncWakeup();
|
|
1611
|
+
};
|
|
1612
|
+
setPauseRequester(applyPause);
|
|
1288
1613
|
inputs(dispatchAction);
|
|
1289
1614
|
syncWakeup();
|
|
1290
1615
|
onState(state, myId);
|
|
1291
1616
|
if (tickRate > 0) setInterval(() => {
|
|
1617
|
+
if (frozen()) return;
|
|
1292
1618
|
const tickEvents = [];
|
|
1293
1619
|
const tickEmit = (name, data) => tickEvents.push({
|
|
1294
1620
|
name,
|
|
1295
1621
|
data: data ?? {}
|
|
1296
1622
|
});
|
|
1623
|
+
const time = gameTime();
|
|
1297
1624
|
try {
|
|
1298
|
-
logic.update({
|
|
1625
|
+
withGameClock(time, () => logic.update({
|
|
1299
1626
|
state,
|
|
1300
1627
|
ctx: {
|
|
1301
1628
|
random,
|
|
1302
1629
|
tick,
|
|
1303
|
-
|
|
1630
|
+
...timeCtx(time),
|
|
1304
1631
|
emit: tickEmit,
|
|
1305
1632
|
playerInputs
|
|
1306
1633
|
}
|
|
1307
|
-
});
|
|
1634
|
+
}));
|
|
1308
1635
|
} catch (err) {
|
|
1309
1636
|
console.error(`[SDK LocalServerAction] tick error at tick=${tick}:`, err);
|
|
1310
1637
|
tick++;
|
|
@@ -1615,6 +1942,12 @@ function init(opts) {
|
|
|
1615
1942
|
const { x: fallbackX, y: fallbackY } = calcHudInsets(params);
|
|
1616
1943
|
document.documentElement.style.setProperty("--uzu-hud-inset-x", `${hudX ?? fallbackX}px`);
|
|
1617
1944
|
document.documentElement.style.setProperty("--uzu-hud-inset-y", `${hudY ?? fallbackY}px`);
|
|
1945
|
+
onPauseChange((paused) => {
|
|
1946
|
+
sendRaw("sdk", "pauseState", {
|
|
1947
|
+
paused,
|
|
1948
|
+
by: getPausedBy()
|
|
1949
|
+
});
|
|
1950
|
+
});
|
|
1618
1951
|
sendRaw("sdk", "ready", {});
|
|
1619
1952
|
_initialized = true;
|
|
1620
1953
|
}
|
|
@@ -1716,7 +2049,7 @@ function run(config) {
|
|
|
1716
2049
|
serverTime: 0,
|
|
1717
2050
|
myId: myPlayerId
|
|
1718
2051
|
};
|
|
1719
|
-
origOnState(state, myPlayerId, mySeatKind);
|
|
2052
|
+
runRendered(() => origOnState(state, myPlayerId, mySeatKind));
|
|
1720
2053
|
notifyDevSnapshot(state);
|
|
1721
2054
|
}
|
|
1722
2055
|
};
|
|
@@ -1848,6 +2181,14 @@ function handleMessage(msg) {
|
|
|
1848
2181
|
_playersChangedHandlers.forEach((fn) => fn(players));
|
|
1849
2182
|
return;
|
|
1850
2183
|
}
|
|
2184
|
+
case "requestPause":
|
|
2185
|
+
console.log(`[SDK] ⏸ handleMessage sdk/requestPause`);
|
|
2186
|
+
requestPause();
|
|
2187
|
+
return;
|
|
2188
|
+
case "requestResume":
|
|
2189
|
+
console.log(`[SDK] ▶️ handleMessage sdk/requestResume`);
|
|
2190
|
+
requestResume();
|
|
2191
|
+
return;
|
|
1851
2192
|
}
|
|
1852
2193
|
return;
|
|
1853
2194
|
}
|
|
@@ -1886,4 +2227,4 @@ function calcHudInsets(params) {
|
|
|
1886
2227
|
};
|
|
1887
2228
|
}
|
|
1888
2229
|
//#endregion
|
|
1889
|
-
export { DEFAULT_ICON_URLS, ReconnectableWebSocket, Room, SERVER_TIME, SeededRandomImpl, applyJsonMergePatch, applyJsonPatch, attachDevHooks, changeRoom, createDevHooks, firstFrameReady, gameReady, getPredictionWarnings, getRoom, init, isHosted, isServerOnlyAction, on, onPlayersChanged, onRoom, playBgm, playSound, run, send,
|
|
2230
|
+
export { DEFAULT_ICON_URLS, ReconnectableWebSocket, Room, SERVER_TIME, SeededRandomImpl, applyJsonMergePatch, applyJsonPatch, attachDevHooks, changeRoom, createDevHooks, firstFrameReady, gameReady, gameTime, getPredictionWarnings, getRoom, init, isHosted, isPaused, isServerOnlyAction, minus, on, onPauseChange, onPlayersChanged, onRoom, playBgm, playSound, plus, run, send, serverOnly, setMicEnabled, stopBgm, sync };
|