@energy8platform/game-engine 0.23.0 → 0.24.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/dist/core.cjs.js +2 -2
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +3 -3
- package/dist/core.esm.js +2 -2
- package/dist/core.esm.js.map +1 -1
- package/dist/host.cjs.js +35 -10
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +22 -9
- package/dist/host.esm.js +24 -11
- package/dist/host.esm.js.map +1 -1
- package/dist/index.cjs.js +2 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.esm.js +2 -2
- package/dist/index.esm.js.map +1 -1
- package/dist/shell.cjs.js +4 -4
- package/dist/shell.d.ts +1 -1
- package/dist/shell.esm.js +1 -1
- package/dist/slot.cjs.js +226 -75
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +137 -19
- package/dist/slot.esm.js +224 -76
- package/dist/slot.esm.js.map +1 -1
- package/package.json +2 -2
- package/src/core/GameApplication.ts +3 -3
- package/src/host/createSlotGame.ts +6 -3
- package/src/host/index.ts +11 -1
- package/src/host/shellConfig.ts +22 -12
- package/src/host/types.ts +13 -2
- package/src/shell/index.ts +4 -3
- package/src/slot/cascade/TumbleController.ts +6 -3
- package/src/slot/config/ReelSystemConfig.ts +19 -0
- package/src/slot/features/extra.ts +1 -2
- package/src/slot/features/symbols.ts +10 -10
- package/src/slot/features/types.ts +13 -5
- package/src/slot/features/wilds.ts +1 -1
- package/src/slot/grid/AnimatedSymbol.ts +20 -11
- package/src/slot/grid/ReelGrid.ts +80 -41
- package/src/slot/grid/SymbolCell.ts +14 -7
- package/src/slot/grid/SymbolView.ts +2 -2
- package/src/slot/grid/geometry.ts +155 -0
- package/src/slot/index.ts +3 -0
- package/src/slot/motion/SpinEngine.ts +1 -1
- package/src/slot/system/ReelSystem.ts +10 -0
- package/src/types.ts +1 -1
|
@@ -6,7 +6,7 @@ import { loadFonts, applyTextureDefaults, bootGuard } from './preboot';
|
|
|
6
6
|
import { showFatalError, installGlobalErrorHandlers } from './fatalError';
|
|
7
7
|
import type { CreateSlotGameOptions, SlotGameHandle } from './types';
|
|
8
8
|
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
9
|
-
import type { ShellMode } from '@energy8platform/pixi
|
|
9
|
+
import type { ShellMode } from '@energy8platform/shell/pixi';
|
|
10
10
|
import type { SceneApi, SlotSceneController } from './sceneController';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -143,7 +143,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
if (opts.shell) {
|
|
146
|
-
const { createPixiShell } = await import('@energy8platform/pixi
|
|
146
|
+
const { createPixiShell } = await import('@energy8platform/shell/pixi');
|
|
147
147
|
const { buildShellConfig } = await import('./shellConfig');
|
|
148
148
|
const { resolveReplayBonusId } = await import('./replay');
|
|
149
149
|
|
|
@@ -206,7 +206,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
206
206
|
// pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
|
|
207
207
|
// scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
|
|
208
208
|
// The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
|
|
209
|
-
shell =
|
|
209
|
+
const pixiShellCfg: import('@energy8platform/shell/pixi').PixiShellConfig = { ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer };
|
|
210
|
+
// The game may swap in its own shell (a custom renderer over the same core) via shellFactory;
|
|
211
|
+
// default is the built-in Pixi shell. The host drives whichever it gets through the Shell contract.
|
|
212
|
+
shell = (opts.shellFactory ?? createPixiShell)(pixiShellCfg);
|
|
210
213
|
// Scope the bar to the slot scene: show only when a SlotSceneController scene is current
|
|
211
214
|
// (hidden over the intro / non-slot scenes). Applies in BOTH base and replay modes.
|
|
212
215
|
shell.setVisible(!!gameScene());
|
package/src/host/index.ts
CHANGED
|
@@ -6,7 +6,17 @@ export type {
|
|
|
6
6
|
StakeIntegration,
|
|
7
7
|
SceneRegistration,
|
|
8
8
|
SceneNavData,
|
|
9
|
+
ShellFactory,
|
|
9
10
|
} from './types';
|
|
11
|
+
|
|
12
|
+
// Shell contract for authors plugging a custom renderer via createSlotGame({ shellFactory }).
|
|
13
|
+
// Implement `ShellRenderer`, build the shell with `createShell({ renderer, ...config })`, and the
|
|
14
|
+
// shell core drives bet/balance/overlays unchanged. `createPixiShell`/`PixiRenderer` are the built-in.
|
|
15
|
+
export { createShell, createPixiShell, PixiRenderer } from '@energy8platform/shell/pixi';
|
|
16
|
+
export type {
|
|
17
|
+
Shell, ShellRenderer, ShellSurface, SafeArea, ShellHost, ShellActions, ShellTokens,
|
|
18
|
+
ShellLayoutMode, OverlayRequest, OverlayHandle, ResolvedShellConfig, PixiShellConfig,
|
|
19
|
+
} from '@energy8platform/shell/pixi';
|
|
10
20
|
export { buildShellConfig, stakeForAction } from './shellConfig';
|
|
11
21
|
export type { SlotShellOptions } from './shellConfig';
|
|
12
22
|
export { resolveReplayBonusId } from './replay';
|
|
@@ -17,4 +27,4 @@ export type {
|
|
|
17
27
|
} from './sceneController';
|
|
18
28
|
// Social-casino word-swap. The shell auto-socializes all gameInfo/buyBonus text in social mode;
|
|
19
29
|
// authors only need this to socialize strings they render themselves (e.g. inside a custom DOM node).
|
|
20
|
-
export { socialize } from '@energy8platform/
|
|
30
|
+
export { socialize } from '@energy8platform/shell';
|
package/src/host/shellConfig.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// packages/game-engine/src/host/shellConfig.ts
|
|
2
|
-
// `socialize` / `createI18n` are
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import
|
|
2
|
+
// `socialize` / `createI18n` / `Lang` are renderer-agnostic helpers from the shell core
|
|
3
|
+
// (@energy8platform/shell). Renderer-agnostic types (GameInfoSection/Content, PaytableRow,
|
|
4
|
+
// GameMode) and the pixi-specific surface types come from the @energy8platform/shell/pixi entry.
|
|
5
|
+
import { socialize, createI18n } from '@energy8platform/shell';
|
|
6
|
+
import type { Lang } from '@energy8platform/shell';
|
|
7
|
+
import type { GameInfoContent, GameInfoSection, PaytableRow, GameMode } from '@energy8platform/shell/pixi';
|
|
6
8
|
import type {
|
|
7
|
-
PixiShellConfig, ShellMode, CurrencyConfig,
|
|
8
|
-
BonusOption, ShellFeatures,
|
|
9
|
-
} from '@energy8platform/pixi
|
|
9
|
+
PixiShellConfig, ShellMode, CurrencyConfig,
|
|
10
|
+
BonusOption, ShellFeatures,
|
|
11
|
+
} from '@energy8platform/shell/pixi';
|
|
10
12
|
import type { GameModel } from '@energy8platform/platform-core/game-spec';
|
|
11
13
|
import type { WinTier } from '../slot';
|
|
12
14
|
|
|
@@ -276,20 +278,26 @@ function sectionKey(s: GameInfoSection): string {
|
|
|
276
278
|
*/
|
|
277
279
|
export function mergeGameInfo(derived: GameInfoContent, override?: GameInfoContent): GameInfoContent {
|
|
278
280
|
if (!override) return derived;
|
|
281
|
+
// `GameInfoContent.sections` is typed with the core GameInfoSection (node?: unknown) because
|
|
282
|
+
// shell/pixi re-exports GameInfoContent unchanged from core. Host-built sections never set `node`,
|
|
283
|
+
// so they are structurally compatible with pixi's GameInfoSection (node?: Container). Cast once
|
|
284
|
+
// here so the rest of the function works against the pixi-widened type.
|
|
285
|
+
const derivedSections = (derived.sections ?? []) as GameInfoSection[];
|
|
286
|
+
const overrideSections = (override.sections ?? []) as GameInfoSection[];
|
|
279
287
|
const authorByKey = new Map<string, GameInfoSection>();
|
|
280
|
-
for (const s of
|
|
288
|
+
for (const s of overrideSections) authorByKey.set(sectionKey(s), s);
|
|
281
289
|
|
|
282
290
|
const out: GameInfoSection[] = [];
|
|
283
291
|
const used = new Set<string>();
|
|
284
292
|
// Keep derived order; swap in the author's version where identities collide.
|
|
285
|
-
for (const s of
|
|
293
|
+
for (const s of derivedSections) {
|
|
286
294
|
const k = sectionKey(s);
|
|
287
295
|
const replacement = authorByKey.get(k);
|
|
288
296
|
if (replacement) { out.push(replacement); used.add(k); }
|
|
289
297
|
else out.push(s);
|
|
290
298
|
}
|
|
291
299
|
// Append author sections whose identity wasn't in the derived set, in author order.
|
|
292
|
-
for (const s of
|
|
300
|
+
for (const s of overrideSections) {
|
|
293
301
|
const k = sectionKey(s);
|
|
294
302
|
if (!used.has(k)) { out.push(s); used.add(k); }
|
|
295
303
|
}
|
|
@@ -340,7 +348,7 @@ export function buildShellConfig(
|
|
|
340
348
|
opts: SlotShellOptions,
|
|
341
349
|
model: GameModel,
|
|
342
350
|
runtime: ShellRuntime,
|
|
343
|
-
): Omit<PixiShellConfig, 'app' | 'parent'> {
|
|
351
|
+
): Omit<PixiShellConfig, 'app' | 'parent' | 'gameInfo'> & { gameInfo: GameInfoContent } {
|
|
344
352
|
// Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
|
|
345
353
|
const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
|
|
346
354
|
// Stake requires the default to come from authenticate on every entry; spec default is the dev fallback.
|
|
@@ -360,7 +368,9 @@ export function buildShellConfig(
|
|
|
360
368
|
const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
|
|
361
369
|
// Pass t() through to spec-derived sections so symbol names, mode titles, and descriptions are
|
|
362
370
|
// pre-translated before they reach the shell renderer.
|
|
363
|
-
|
|
371
|
+
// Cast to { sections?: GameInfoSection[] } (pixi-widened) so downstream map/filter calls work
|
|
372
|
+
// against the pixi section union. Host-derived sections never set `node`, so the widening is safe.
|
|
373
|
+
let gameInfo: { sections?: GameInfoSection[] } = mergeGameInfo(defaultGameInfo(model, runtime, t), authored) as { sections?: GameInfoSection[] };
|
|
364
374
|
// The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
|
|
365
375
|
// wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
|
|
366
376
|
if (isSocial) {
|
package/src/host/types.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { ApplicationOptions } from 'pixi.js';
|
|
3
3
|
import type { GameModel } from '@energy8platform/platform-core/game-spec';
|
|
4
4
|
import type { AssetManifest, LoadingScreenConfig } from '@energy8platform/platform-core';
|
|
5
|
-
import type {
|
|
5
|
+
import type { Shell, PixiShellConfig } from '@energy8platform/shell/pixi';
|
|
6
6
|
import type { AudioConfig, ScaleMode, Orientation, SceneConstructor } from '../types';
|
|
7
7
|
import type { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge';
|
|
8
8
|
import type { GameApplication } from '../core';
|
|
@@ -58,14 +58,25 @@ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinRe
|
|
|
58
58
|
dev?: boolean;
|
|
59
59
|
stake?: StakeIntegration;
|
|
60
60
|
shell?: SlotShellOptions;
|
|
61
|
+
/** Override how the control-bar shell is built. The host resolves the full shell config (theme,
|
|
62
|
+
* features, gameInfo, currency, balance) and the Pixi mount (`app`/`parent`) and hands it to this
|
|
63
|
+
* factory; return any `Shell` — e.g. `createShell({ renderer: new MyRenderer(...), ...config })`
|
|
64
|
+
* to plug a custom renderer while the shell core still drives bet/balance/overlays. A custom
|
|
65
|
+
* renderer can ignore `app`/`parent` and mount elsewhere (a DOM overlay, another canvas).
|
|
66
|
+
* Default: the built-in Pixi shell (`createPixiShell`). */
|
|
67
|
+
shellFactory?: ShellFactory;
|
|
61
68
|
/** Double-tap on the play area to skip the current spin animation. Default `true`. Set `false`
|
|
62
69
|
* to disable the gesture (e.g. games where a tap means something else). */
|
|
63
70
|
skipGesture?: boolean;
|
|
64
71
|
onFatalError?: (message: string) => void;
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
/** Builds the shell the host drives. Receives the fully-resolved Pixi shell config (a custom
|
|
75
|
+
* renderer may ignore the `app`/`parent` mount fields). Must return a `Shell`. */
|
|
76
|
+
export type ShellFactory = (config: PixiShellConfig) => Shell;
|
|
77
|
+
|
|
67
78
|
export interface SlotGameHandle {
|
|
68
79
|
game: GameApplication;
|
|
69
80
|
stakeBridge: StakeBridge | null;
|
|
70
|
-
shell:
|
|
81
|
+
shell: Shell | null;
|
|
71
82
|
}
|
package/src/shell/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Re-export the renderer-agnostic branded game shell from
|
|
1
|
+
// Re-export the renderer-agnostic branded game shell from @energy8platform/shell/html so
|
|
2
2
|
// game-engine consumers can import it via @energy8platform/game-engine/shell.
|
|
3
3
|
export {
|
|
4
4
|
createGameShell,
|
|
5
5
|
removeGameShell,
|
|
6
6
|
GameShell,
|
|
7
|
-
} from '@energy8platform/
|
|
7
|
+
} from '@energy8platform/shell/html';
|
|
8
8
|
export type {
|
|
9
9
|
ShellConfig,
|
|
10
10
|
ShellMode,
|
|
@@ -17,4 +17,5 @@ export type {
|
|
|
17
17
|
GameInfoContent,
|
|
18
18
|
AutoplayOptions,
|
|
19
19
|
FreeSpinsState,
|
|
20
|
-
|
|
20
|
+
Lang,
|
|
21
|
+
} from '@energy8platform/shell/html';
|
|
@@ -147,8 +147,11 @@ export class TumbleController {
|
|
|
147
147
|
}
|
|
148
148
|
this._undim();
|
|
149
149
|
|
|
150
|
-
// 3. gravity: survivors slide down, new cells drop from above
|
|
151
|
-
const
|
|
150
|
+
// 3. gravity: survivors slide down, new cells drop from above (per-reel row step)
|
|
151
|
+
const rowStepOf = (col: number): number =>
|
|
152
|
+
this._grid.rowsOf(col) > 1
|
|
153
|
+
? this._grid.cellPosition(col, 1).y - this._grid.cellPosition(col, 0).y
|
|
154
|
+
: this._grid.cellSize(col).height;
|
|
152
155
|
const slides = this._cfg.gravity ? (step.drops ?? this.deriveDrops(step)) : [];
|
|
153
156
|
const anims: Promise<void>[] = [];
|
|
154
157
|
|
|
@@ -172,7 +175,7 @@ export class TumbleController {
|
|
|
172
175
|
cell.setState({ fresh: true });
|
|
173
176
|
cell.alpha = 1;
|
|
174
177
|
cell.scale.set(1);
|
|
175
|
-
cell.position.set(home.x, home.y -
|
|
178
|
+
cell.position.set(home.x, home.y - rowStepOf(n.col) * (this._grid.rowsOf(n.col) + 1));
|
|
176
179
|
const idx = (perCol[n.col] = (perCol[n.col] ?? 0) + 1);
|
|
177
180
|
anims.push(
|
|
178
181
|
(async () => {
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// Design notes are in docs/reels-analysis-and-design.md.
|
|
9
9
|
|
|
10
10
|
import type { CellFrameStyle } from '../grid/SymbolCell';
|
|
11
|
+
import { resolveGeometry, type CellSizeSpec, type ResolvedGeometry } from '../grid/geometry';
|
|
12
|
+
|
|
13
|
+
export type { CellSizeSpec, ResolvedGeometry };
|
|
11
14
|
|
|
12
15
|
/** Names of easing functions available in the engine's `Easing` map (see anim/easing-map.ts). */
|
|
13
16
|
export type EasingName =
|
|
@@ -41,8 +44,19 @@ export interface GridConfig {
|
|
|
41
44
|
rows: number;
|
|
42
45
|
/** Per-reel row counts (Megaways / variable-height reels). Length should equal `cols`. */
|
|
43
46
|
rowsPerReel?: number[];
|
|
47
|
+
/** Square cell size (shorthand: same width & height, all reels). */
|
|
44
48
|
cellSize: number;
|
|
49
|
+
/** Rectangular cells, uniform across reels. Override `cellSize` when set. */
|
|
50
|
+
cellWidth?: number;
|
|
51
|
+
cellHeight?: number;
|
|
52
|
+
/** Per-strip cell size (square scalar or {width,height}). Overrides the above for that reel. */
|
|
53
|
+
cellSizePerReel?: CellSizeSpec[];
|
|
54
|
+
/** Uniform gap (shorthand for both axes). */
|
|
45
55
|
gap: number;
|
|
56
|
+
/** Horizontal gap between adjacent reels. Scalar, or per-boundary (length cols-1). Overrides `gap`. */
|
|
57
|
+
colGap?: number | number[];
|
|
58
|
+
/** Vertical gap between rows. Scalar, or per-reel (length cols). Overrides `gap`. */
|
|
59
|
+
rowGap?: number | number[];
|
|
46
60
|
evaluation: EvaluationMode;
|
|
47
61
|
/** For Megaways: clamp per-reel rows to [minRows, maxRows]. */
|
|
48
62
|
minRows?: number;
|
|
@@ -553,6 +567,11 @@ export function effectiveRowsPerReel(grid: GridConfig): number[] {
|
|
|
553
567
|
return Array.from({ length: grid.cols }, () => grid.rows);
|
|
554
568
|
}
|
|
555
569
|
|
|
570
|
+
/** Resolve a `GridConfig` into a fully-populated per-reel geometry (rectangular / per-strip aware). */
|
|
571
|
+
export function resolveGridGeometry(grid: GridConfig): ResolvedGeometry {
|
|
572
|
+
return resolveGeometry(grid);
|
|
573
|
+
}
|
|
574
|
+
|
|
556
575
|
/** Total ways-to-win for a ways/megaways grid (product of per-reel heights). */
|
|
557
576
|
export function waysCount(grid: GridConfig): number {
|
|
558
577
|
return effectiveRowsPerReel(grid).reduce((a, b) => a * b, 1);
|
|
@@ -46,8 +46,7 @@ export const MultiplierSymbols: ReelFeature = {
|
|
|
46
46
|
await floatLabel(ctx.fx, x, y, `×${values[spots.indexOf(p)]}`, 0xffd24a, 500);
|
|
47
47
|
}),
|
|
48
48
|
);
|
|
49
|
-
const
|
|
50
|
-
const cy = (ctx.grid.rows * ctx.grid.cellSize) / 2 - ctx.grid.cellSize / 2;
|
|
49
|
+
const { x: cx, y: cy } = ctx.grid.center();
|
|
51
50
|
await floatLabel(ctx.fx, cx, cy, `×${capped}`, 0xffe24a, 900);
|
|
52
51
|
},
|
|
53
52
|
};
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type FeatureContext,
|
|
7
7
|
type ReelFeature,
|
|
8
8
|
cellCenter,
|
|
9
|
+
colStepOf,
|
|
9
10
|
floatLabel,
|
|
10
11
|
morphSymbol,
|
|
11
12
|
pickFromBoard,
|
|
@@ -96,17 +97,15 @@ export const GiantSymbol: ReelFeature = {
|
|
|
96
97
|
ctx.log?.(`Giant ${w}×${h} "${sym}"`);
|
|
97
98
|
const view = ctx.resolve(sym);
|
|
98
99
|
if (!view) return;
|
|
99
|
-
const
|
|
100
|
+
const cs = ctx.grid.cellSize(anchorCol);
|
|
101
|
+
const stepX = colStepOf(ctx.grid, anchorCol);
|
|
102
|
+
const step = rowStepOf(ctx.grid, anchorCol);
|
|
100
103
|
const tl = cellCenter(ctx.grid, anchorCol, 0);
|
|
101
104
|
const giant = new Container();
|
|
102
105
|
giant.addChild(view);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
((ctx.grid.cellSize * w) / (ctx.grid.cellSize * Math.max(w, h))) * view.scale.x,
|
|
107
|
-
((ctx.grid.cellSize * h) / (ctx.grid.cellSize * Math.max(w, h))) * view.scale.y,
|
|
108
|
-
);
|
|
109
|
-
giant.position.set(tl.x + ((w - 1) * step) / 2, tl.y + ((h - 1) * step) / 2);
|
|
106
|
+
// span w×h cells (footprint ignores gaps, matching the previous single-cell unit)
|
|
107
|
+
view.resize?.({ width: cs.width * w, height: cs.height * h });
|
|
108
|
+
giant.position.set(tl.x + ((w - 1) * stepX) / 2, tl.y + ((h - 1) * step) / 2);
|
|
110
109
|
// hide covered cells
|
|
111
110
|
for (let c = anchorCol; c < anchorCol + w; c++)
|
|
112
111
|
for (let r = 0; r < h; r++) ctx.grid.getCell(c, r).visible = false;
|
|
@@ -143,11 +142,12 @@ export const SplitSymbol: ReelFeature = {
|
|
|
143
142
|
left.map(async (p, i) => {
|
|
144
143
|
await Tween.delay((i % 6) * 30);
|
|
145
144
|
const { x, y } = cellCenter(ctx.grid, p.col, p.row);
|
|
145
|
+
const cs = ctx.grid.cellSize(p.col);
|
|
146
146
|
await pulseCell(ctx.grid.getCell(p.col, p.row), 1.12, 200);
|
|
147
147
|
await floatLabel(
|
|
148
148
|
ctx.fx,
|
|
149
|
-
x +
|
|
150
|
-
y -
|
|
149
|
+
x + cs.width / 3,
|
|
150
|
+
y - cs.height / 3,
|
|
151
151
|
`×${f.factor}`,
|
|
152
152
|
0x9b5cff,
|
|
153
153
|
600,
|
|
@@ -43,8 +43,16 @@ export function cellCenter(grid: ReelGrid, col: number, row: number): { x: numbe
|
|
|
43
43
|
return grid.cellPosition(col, row);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
/** Vertical row-to-row step for a reel (falls back to the reel's cell height for 1-row reels). */
|
|
47
|
+
export function rowStepOf(grid: ReelGrid, col = 0): number {
|
|
48
|
+
if (grid.rowsOf(col) > 1) return grid.cellPosition(col, 1).y - grid.cellPosition(col, 0).y;
|
|
49
|
+
return grid.cellSize(col).height;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Horizontal reel-to-reel step at a boundary (falls back to the reel's cell width for 1-col grids). */
|
|
53
|
+
export function colStepOf(grid: ReelGrid, col = 0): number {
|
|
54
|
+
if (col + 1 < grid.cols) return grid.cellPosition(col + 1, 0).x - grid.cellPosition(col, 0).x;
|
|
55
|
+
return grid.cellSize(col).width;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
/** A glowing ring drawn around a cell. Returns a disposer. */
|
|
@@ -57,9 +65,9 @@ export function glowRing(
|
|
|
57
65
|
): () => void {
|
|
58
66
|
if (fx.destroyed) return () => {};
|
|
59
67
|
const { x, y } = cellCenter(grid, col, row);
|
|
60
|
-
const
|
|
68
|
+
const { width, height } = grid.cellSize(col);
|
|
61
69
|
const g = new Graphics()
|
|
62
|
-
.roundRect(x -
|
|
70
|
+
.roundRect(x - width / 2, y - height / 2, width, height, 10)
|
|
63
71
|
.stroke({ color, width: 3, alpha: 0.9 });
|
|
64
72
|
fx.addChild(g);
|
|
65
73
|
return () => g.destroy();
|
|
@@ -118,7 +126,7 @@ export async function dropCell(
|
|
|
118
126
|
): Promise<void> {
|
|
119
127
|
if (cell.destroyed) return;
|
|
120
128
|
const home = grid.cellPosition(col, row);
|
|
121
|
-
cell.position.set(home.x, home.y - rowStepOf(grid) * (row + 2));
|
|
129
|
+
cell.position.set(home.x, home.y - rowStepOf(grid, col) * (row + 2));
|
|
122
130
|
cell.alpha = 1;
|
|
123
131
|
cell.scale.set(1);
|
|
124
132
|
await Tween.to(cell, { 'position.y': home.y }, ms, easingByName('easeOutBounce'));
|
|
@@ -3,25 +3,32 @@ import { SpriteAnimation } from '../../animation';
|
|
|
3
3
|
import type { SymbolView } from './SymbolView';
|
|
4
4
|
|
|
5
5
|
export interface SymbolTextures { base: Texture; idle?: Texture[]; win?: Texture[]; }
|
|
6
|
-
export interface AnimatedSymbolConfig {
|
|
6
|
+
export interface AnimatedSymbolConfig {
|
|
7
|
+
textures: SymbolTextures;
|
|
8
|
+
size: number | { width: number; height: number };
|
|
9
|
+
fps?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const dims = (size: number | { width: number; height: number }) =>
|
|
13
|
+
typeof size === 'number' ? { width: size, height: size } : size;
|
|
7
14
|
|
|
8
15
|
/** Built-in SymbolView: a static base sprite with optional idle/win spritesheet frames. */
|
|
9
16
|
export class AnimatedSymbol extends Container implements SymbolView {
|
|
10
17
|
private _base: Sprite;
|
|
11
18
|
private _anim: AnimatedSprite | null = null;
|
|
12
19
|
private _textures: SymbolTextures;
|
|
13
|
-
private
|
|
20
|
+
private _w = 0;
|
|
21
|
+
private _h = 0;
|
|
14
22
|
private _fps: number;
|
|
15
23
|
|
|
16
24
|
constructor(config: AnimatedSymbolConfig) {
|
|
17
25
|
super();
|
|
18
26
|
this._textures = config.textures;
|
|
19
|
-
this._size = config.size;
|
|
20
27
|
this._fps = config.fps ?? 24;
|
|
21
28
|
this._base = new Sprite(config.textures.base);
|
|
22
29
|
this._base.anchor.set(0.5);
|
|
23
30
|
this.addChild(this._base);
|
|
24
|
-
this.resize(
|
|
31
|
+
this.resize(config.size);
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
setTextures(t: SymbolTextures): void {
|
|
@@ -30,11 +37,13 @@ export class AnimatedSymbol extends Container implements SymbolView {
|
|
|
30
37
|
this.showStatic();
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
resize(size: number): void {
|
|
34
|
-
|
|
35
|
-
this.
|
|
36
|
-
this.
|
|
37
|
-
|
|
40
|
+
resize(size: number | { width: number; height: number }): void {
|
|
41
|
+
const { width, height } = dims(size);
|
|
42
|
+
this._w = width;
|
|
43
|
+
this._h = height;
|
|
44
|
+
this._base.width = width;
|
|
45
|
+
this._base.height = height;
|
|
46
|
+
if (this._anim) { this._anim.width = width; this._anim.height = height; }
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
showStatic(): void {
|
|
@@ -59,8 +68,8 @@ export class AnimatedSymbol extends Container implements SymbolView {
|
|
|
59
68
|
this._base.visible = false;
|
|
60
69
|
const a = SpriteAnimation.create(frames, { loop, autoPlay: true, onComplete });
|
|
61
70
|
a.anchor.set(0.5);
|
|
62
|
-
a.width = this.
|
|
63
|
-
a.height = this.
|
|
71
|
+
a.width = this._w;
|
|
72
|
+
a.height = this._h;
|
|
64
73
|
a.animationSpeed = this._fps / 60;
|
|
65
74
|
this.addChild(a);
|
|
66
75
|
this._anim = a;
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Container, Graphics, Sprite, type Texture } from 'pixi.js';
|
|
2
2
|
import { SymbolCell, type CellData, type CellFrameStyle } from './SymbolCell';
|
|
3
3
|
import type { SymbolResolver } from './SymbolView';
|
|
4
|
+
import {
|
|
5
|
+
resolveGeometry,
|
|
6
|
+
cellPositionOf,
|
|
7
|
+
type CellSizeSpec,
|
|
8
|
+
type ResolvedGeometry,
|
|
9
|
+
} from './geometry';
|
|
4
10
|
|
|
5
11
|
export interface DecorationConfig {
|
|
6
12
|
texture?: Texture;
|
|
@@ -12,8 +18,19 @@ export interface ReelGridConfig {
|
|
|
12
18
|
rows: number;
|
|
13
19
|
/** Per-reel row counts for Megaways / variable-height reels. Length must equal `cols`. */
|
|
14
20
|
rowsPerReel?: number[];
|
|
21
|
+
/** Square cell size (shorthand: same width & height, all reels). */
|
|
15
22
|
cellSize: number;
|
|
23
|
+
/** Rectangular cells, uniform across reels. Override `cellSize` when set. */
|
|
24
|
+
cellWidth?: number;
|
|
25
|
+
cellHeight?: number;
|
|
26
|
+
/** Per-strip cell size (square scalar or {width,height}). */
|
|
27
|
+
cellSizePerReel?: CellSizeSpec[];
|
|
28
|
+
/** Uniform gap (shorthand for both axes). */
|
|
16
29
|
gap?: number;
|
|
30
|
+
/** Horizontal gap between adjacent reels. Scalar, or per-boundary (length cols-1). */
|
|
31
|
+
colGap?: number | number[];
|
|
32
|
+
/** Vertical gap between rows. Scalar, or per-reel (length cols). */
|
|
33
|
+
rowGap?: number | number[];
|
|
17
34
|
resolve: SymbolResolver;
|
|
18
35
|
frameStyle?: CellFrameStyle;
|
|
19
36
|
decoration?: DecorationConfig;
|
|
@@ -21,17 +38,16 @@ export interface ReelGridConfig {
|
|
|
21
38
|
}
|
|
22
39
|
|
|
23
40
|
/**
|
|
24
|
-
* A grid of `SymbolCell`s. Supports uniform grids
|
|
25
|
-
*
|
|
41
|
+
* A grid of `SymbolCell`s. Supports uniform grids, variable-height reels (Megaways), and
|
|
42
|
+
* rectangular / per-strip cell sizes with per-strip gaps. All layout flows through a single
|
|
43
|
+
* resolved geometry (see grid/geometry.ts); every consumer reads positions via `cellPosition`
|
|
44
|
+
* and dimensions via `cellSize(col)` rather than assuming a square cell.
|
|
26
45
|
*/
|
|
27
46
|
export class ReelGrid extends Container {
|
|
28
47
|
readonly __uiComponent = true as const;
|
|
29
48
|
|
|
30
|
-
private
|
|
31
|
-
private
|
|
32
|
-
private _maxRows: number;
|
|
33
|
-
private _cellSize: number;
|
|
34
|
-
private _gap: number;
|
|
49
|
+
private _cfg: ReelGridConfig;
|
|
50
|
+
private _geom: ResolvedGeometry;
|
|
35
51
|
private _cells: SymbolCell[][] = [];
|
|
36
52
|
private _cellLayer = new Container();
|
|
37
53
|
private _resolve: SymbolResolver;
|
|
@@ -40,28 +56,18 @@ export class ReelGrid extends Container {
|
|
|
40
56
|
|
|
41
57
|
constructor(config: ReelGridConfig) {
|
|
42
58
|
super();
|
|
43
|
-
this.
|
|
44
|
-
this._rowsPerReel =
|
|
45
|
-
config.rowsPerReel && config.rowsPerReel.length === config.cols
|
|
46
|
-
? config.rowsPerReel.slice()
|
|
47
|
-
: Array.from({ length: config.cols }, () => config.rows);
|
|
48
|
-
this._maxRows = Math.max(1, ...this._rowsPerReel);
|
|
49
|
-
this._cellSize = config.cellSize;
|
|
50
|
-
this._gap = config.gap ?? 0;
|
|
59
|
+
this._cfg = { ...config };
|
|
51
60
|
this._resolve = config.resolve;
|
|
52
61
|
this._frameStyle = config.frameStyle;
|
|
62
|
+
this._geom = resolveGeometry(config);
|
|
53
63
|
|
|
54
|
-
if (config.decoration) {
|
|
64
|
+
if (config.decoration?.texture) {
|
|
55
65
|
const pad = config.decoration.padding ?? 0;
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
deco.height = h;
|
|
62
|
-
deco.position.set(-pad - this._cellSize / 2, -pad - this._cellSize / 2);
|
|
63
|
-
this.addChild(deco);
|
|
64
|
-
}
|
|
66
|
+
const deco = new Sprite(config.decoration.texture);
|
|
67
|
+
deco.width = this._geom.gridW + pad * 2;
|
|
68
|
+
deco.height = this._geom.gridH + pad * 2;
|
|
69
|
+
deco.position.set(this._geom.leftX - pad, this._geom.topY - pad);
|
|
70
|
+
this.addChild(deco);
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
this.addChild(this._cellLayer);
|
|
@@ -70,12 +76,19 @@ export class ReelGrid extends Container {
|
|
|
70
76
|
if (config.mask) this._applyMask();
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
private get _cols(): number {
|
|
80
|
+
return this._geom.cols;
|
|
81
|
+
}
|
|
82
|
+
private get _rowsPerReel(): number[] {
|
|
83
|
+
return this._geom.rowsPerReel;
|
|
84
|
+
}
|
|
85
|
+
|
|
73
86
|
private _buildCells(): void {
|
|
74
87
|
for (let c = 0; c < this._cols; c++) {
|
|
75
88
|
this._cells[c] = [];
|
|
76
89
|
for (let r = 0; r < this._rowsPerReel[c]; r++) {
|
|
77
90
|
const cell = new SymbolCell({
|
|
78
|
-
size: this.
|
|
91
|
+
size: this.cellSize(c),
|
|
79
92
|
resolve: this._resolve,
|
|
80
93
|
frameStyle: this._frameStyle,
|
|
81
94
|
});
|
|
@@ -87,10 +100,19 @@ export class ReelGrid extends Container {
|
|
|
87
100
|
}
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
/** Per-strip mask: one window per reel sized to that reel's own cell width × column height. */
|
|
90
104
|
private _applyMask(): void {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
105
|
+
const g = this._geom;
|
|
106
|
+
const m = new Graphics();
|
|
107
|
+
for (let c = 0; c < this._cols; c++) {
|
|
108
|
+
const rows = this._rowsPerReel[c];
|
|
109
|
+
const w = g.cellW[c];
|
|
110
|
+
const h = rows * g.cellH[c] + Math.max(0, rows - 1) * g.rowGap[c];
|
|
111
|
+
const x = g.colX[c] - w / 2;
|
|
112
|
+
const y = g.yOff[c] - g.cellH[c] / 2;
|
|
113
|
+
m.rect(x, y, w, h);
|
|
114
|
+
}
|
|
115
|
+
m.fill(0xffffff);
|
|
94
116
|
this._cellLayer.mask = m;
|
|
95
117
|
this.addChild(m);
|
|
96
118
|
this._mask = m;
|
|
@@ -101,10 +123,15 @@ export class ReelGrid extends Container {
|
|
|
101
123
|
}
|
|
102
124
|
/** Tallest reel's row count (the grid's visual height in rows). */
|
|
103
125
|
get rows(): number {
|
|
104
|
-
return this.
|
|
126
|
+
return this._geom.maxRows;
|
|
127
|
+
}
|
|
128
|
+
/** Cell dimensions (px) for a reel. Rectangular / per-strip aware. */
|
|
129
|
+
cellSize(col: number): { width: number; height: number } {
|
|
130
|
+
return { width: this._geom.cellW[col] ?? 0, height: this._geom.cellH[col] ?? 0 };
|
|
105
131
|
}
|
|
106
|
-
|
|
107
|
-
|
|
132
|
+
/** The fully-resolved grid geometry (read-only view). */
|
|
133
|
+
get geometry(): ResolvedGeometry {
|
|
134
|
+
return this._geom;
|
|
108
135
|
}
|
|
109
136
|
rowsOf(col: number): number {
|
|
110
137
|
return this._rowsPerReel[col] ?? 0;
|
|
@@ -113,12 +140,14 @@ export class ReelGrid extends Container {
|
|
|
113
140
|
return this._rowsPerReel.slice();
|
|
114
141
|
}
|
|
115
142
|
|
|
116
|
-
/** Cell centre position. Variable-height reels are centred
|
|
143
|
+
/** Cell centre position. Variable-height reels are centred about a shared centre line. */
|
|
117
144
|
cellPosition(col: number, row: number): { x: number; y: number } {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
return cellPositionOf(this._geom, col, row);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Grid bounding-box centre in local coords. */
|
|
149
|
+
center(): { x: number; y: number } {
|
|
150
|
+
return { x: this._geom.centerX, y: this._geom.centerY };
|
|
122
151
|
}
|
|
123
152
|
|
|
124
153
|
getCell(col: number, row: number): SymbolCell {
|
|
@@ -138,8 +167,8 @@ export class ReelGrid extends Container {
|
|
|
138
167
|
if (rowsPerReel.length !== this._cols) return;
|
|
139
168
|
this._cellLayer.removeChildren().forEach((c) => c.destroy());
|
|
140
169
|
this._cells = [];
|
|
141
|
-
this.
|
|
142
|
-
this.
|
|
170
|
+
this._cfg = { ...this._cfg, rowsPerReel: rowsPerReel.slice() };
|
|
171
|
+
this._geom = resolveGeometry(this._cfg);
|
|
143
172
|
this._buildCells();
|
|
144
173
|
if (this._mask) {
|
|
145
174
|
this._mask.destroy();
|
|
@@ -149,8 +178,18 @@ export class ReelGrid extends Container {
|
|
|
149
178
|
}
|
|
150
179
|
}
|
|
151
180
|
|
|
152
|
-
|
|
153
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Re-resolve geometry after a base cell-size change and reposition cells. Accepts a square
|
|
183
|
+
* scalar (updates `cellSize`) or explicit `{width,height}`. Per-strip overrides still apply.
|
|
184
|
+
* (Cell frames are not re-drawn here — a geometry change routes through a full rebuild upstream.)
|
|
185
|
+
*/
|
|
186
|
+
resize(size: number | { width: number; height: number }): void {
|
|
187
|
+
if (typeof size === 'number') {
|
|
188
|
+
this._cfg = { ...this._cfg, cellSize: size, cellWidth: undefined, cellHeight: undefined };
|
|
189
|
+
} else {
|
|
190
|
+
this._cfg = { ...this._cfg, cellWidth: size.width, cellHeight: size.height };
|
|
191
|
+
}
|
|
192
|
+
this._geom = resolveGeometry(this._cfg);
|
|
154
193
|
for (let c = 0; c < this._cols; c++) {
|
|
155
194
|
for (let r = 0; r < this._rowsPerReel[c]; r++) {
|
|
156
195
|
const { x, y } = this.cellPosition(c, r);
|