| 3 |
3 |
import { Application, Container, Graphics, Rectangle, Sprite, Text, TextStyle, Texture, type Renderer } from "pixi.js"; |
| 4 |
4 |
import type { SpinStep } from "@spinza/game-core/client"; |
| 5 |
5 |
import type { ClientDefinition } from "./types"; |
| 6 |
|
−import { drawSymbol, hexColor, lighten } from "./symbol-art"; |
|
6 |
+import { drawSymbol, hexColor } from "./symbol-art"; |
| 7 |
7 |
import { formatSC } from "@spinza/shared"; |
| 8 |
8 |
|
| 9 |
9 |
/* --------------------------------------------------------------- helpers */ |
| 152 |
152 |
const p = this.opts.def.presentation; |
| 153 |
153 |
const g = this.frame; |
| 154 |
154 |
g.clear(); |
| 155 |
|
− const pad = this.gap * 1.6; |
|
155 |
+ const pad = this.gap * 1.8; |
| 156 |
156 |
const x = this.originX - pad; |
| 157 |
157 |
const y = this.originY - pad; |
| 158 |
158 |
const w = totalW + pad * 2; |
| 159 |
159 |
const h = totalH + pad * 2; |
| 160 |
160 |
const primary = hexColor(p.palette.primary); |
| 161 |
|
− const frameColors: Record<string, number> = { metal: 0x9aa3b5, glass: 0xffffff, gold: 0xe0c070, stone: 0x8a8578, ice: 0xbfe9ff, carbon: 0x5c6270, neon: primary, obsidian: 0x3a3a44 }; |
| 162 |
|
− const fc = frameColors[p.frame] ?? primary; |
| 163 |
|
− g.roundRect(x, y, w, h, 18).fill({ color: 0x000000, alpha: 0.38 }); |
| 164 |
|
− g.roundRect(x, y, w, h, 18).stroke({ color: fc, alpha: p.frame === "neon" ? 0.85 : 0.35, width: 2 }); |
| 165 |
|
− g.roundRect(x + 3, y + 3, w - 6, h - 6, 15).stroke({ color: 0xffffff, alpha: 0.06, width: 1 }); |
| 166 |
|
− if (p.frame === "neon") g.roundRect(x - 3, y - 3, w + 6, h + 6, 21).stroke({ color: primary, alpha: 0.25, width: 6 }); |
|
161 |
+ const secondary = hexColor(p.palette.secondary); |
|
162 |
+ const rows = this.rows; |
|
163 |
+ const cols = this.cols; |
|
164 |
+ switch (p.frame) { |
|
165 |
+ case "gold": { |
|
166 |
+ g.roundRect(x, y, w, h, 10).fill({ color: 0x120e07, alpha: 0.7 }); |
|
167 |
+ g.roundRect(x, y, w, h, 10).stroke({ color: 0xe0c070, alpha: 0.8, width: 3 }); |
|
168 |
+ g.roundRect(x + 6, y + 6, w - 12, h - 12, 7).stroke({ color: 0x8a6d2e, alpha: 0.7, width: 1.5 }); |
|
169 |
+ for (const [cx, cy] of [[x, y], [x + w, y], [x, y + h], [x + w, y + h]]) { |
|
170 |
+ g.circle(cx, cy, 9).fill({ color: 0x1a1408 }).circle(cx, cy, 9).stroke({ color: 0xf3e2ad, width: 2 }); |
|
171 |
+ g.circle(cx, cy, 3).fill(0xf3e2ad); |
|
172 |
+ } |
|
173 |
+ // Top ornament. |
|
174 |
+ g.moveTo(x + w / 2 - 40, y).lineTo(x + w / 2, y - 10).lineTo(x + w / 2 + 40, y).stroke({ color: 0xe0c070, width: 2 }); |
|
175 |
+ break; |
|
176 |
+ } |
|
177 |
+ case "stone": { |
|
178 |
+ g.roundRect(x, y, w, h, 6).fill({ color: 0x14120d, alpha: 0.8 }); |
|
179 |
+ g.roundRect(x, y, w, h, 6).stroke({ color: 0x8a8578, alpha: 0.6, width: 5 }); |
|
180 |
+ // Stone blocks along the border. |
|
181 |
+ const bw = 34; |
|
182 |
+ for (let i = 0; i < Math.floor(w / bw); i++) { |
|
183 |
+ g.moveTo(x + i * bw, y).lineTo(x + i * bw, y - 6).stroke({ color: 0x000000, alpha: 0.5, width: 2 }); |
|
184 |
+ g.moveTo(x + i * bw, y + h).lineTo(x + i * bw, y + h + 6).stroke({ color: 0x000000, alpha: 0.5, width: 2 }); |
|
185 |
+ } |
|
186 |
+ g.moveTo(x + 8, y + 12).lineTo(x + 22, y + 30).stroke({ color: 0x000000, alpha: 0.6, width: 2 }); |
|
187 |
+ g.moveTo(x + w - 10, y + h - 40).lineTo(x + w - 26, y + h - 18).stroke({ color: 0x000000, alpha: 0.6, width: 2 }); |
|
188 |
+ break; |
|
189 |
+ } |
|
190 |
+ case "ice": { |
|
191 |
+ g.roundRect(x, y, w, h, 22).fill({ color: 0x9fdcff, alpha: 0.07 }); |
|
192 |
+ g.roundRect(x, y, w, h, 22).stroke({ color: 0xdff6ff, alpha: 0.55, width: 2 }); |
|
193 |
+ g.roundRect(x - 4, y - 4, w + 8, h + 8, 26).stroke({ color: 0xbfe9ff, alpha: 0.18, width: 6 }); |
|
194 |
+ // Frost shards on corners. |
|
195 |
+ for (const [cx, cy, d] of [[x, y, 1], [x + w, y, -1]]) { |
|
196 |
+ g.moveTo(cx, cy + 30).lineTo(cx + 18 * d, cy + 4).lineTo(cx + 40 * d, cy).stroke({ color: 0xffffff, alpha: 0.5, width: 2 }); |
|
197 |
+ g.moveTo(cx + 8 * d, cy + 44).lineTo(cx + 26 * d, cy + 14).stroke({ color: 0xffffff, alpha: 0.3, width: 1.5 }); |
|
198 |
+ } |
|
199 |
+ break; |
|
200 |
+ } |
|
201 |
+ case "carbon": { |
|
202 |
+ g.roundRect(x, y, w, h, 12).fill({ color: 0x07080b, alpha: 0.85 }); |
|
203 |
+ for (let i = 0; i < h; i += 6) g.moveTo(x, y + i).lineTo(x + w, y + i).stroke({ color: 0xffffff, alpha: 0.025, width: 1 }); |
|
204 |
+ g.roundRect(x, y, w, h, 12).stroke({ color: 0x5c6270, alpha: 0.8, width: 2 }); |
|
205 |
+ g.roundRect(x, y, w, h, 12).stroke({ color: primary, alpha: 0.35, width: 1 }); |
|
206 |
+ // Hex bolts. |
|
207 |
+ for (const [cx, cy] of [[x + 14, y + 14], [x + w - 14, y + 14], [x + 14, y + h - 14], [x + w - 14, y + h - 14]]) polygonN(g, cx, cy, 6, 6).fill({ color: 0x2a2f3a }).stroke({ color: 0x9aa3b5, alpha: 0.5, width: 1 }); |
|
208 |
+ break; |
|
209 |
+ } |
|
210 |
+ case "neon": { |
|
211 |
+ g.roundRect(x, y, w, h, 18).fill({ color: 0x03040a, alpha: 0.55 }); |
|
212 |
+ g.roundRect(x - 6, y - 6, w + 12, h + 12, 24).stroke({ color: primary, alpha: 0.18, width: 12 }); |
|
213 |
+ g.roundRect(x - 2, y - 2, w + 4, h + 4, 20).stroke({ color: primary, alpha: 0.5, width: 5 }); |
|
214 |
+ g.roundRect(x, y, w, h, 18).stroke({ color: 0xffffff, alpha: 0.9, width: 1.5 }); |
|
215 |
+ // Secondary neon underline. |
|
216 |
+ g.moveTo(x + 24, y + h + 12).lineTo(x + w - 24, y + h + 12).stroke({ color: secondary, alpha: 0.7, width: 3 }); |
|
217 |
+ break; |
|
218 |
+ } |
|
219 |
+ case "glass": { |
|
220 |
+ g.roundRect(x, y, w, h, 20).fill({ color: 0xffffff, alpha: 0.04 }); |
|
221 |
+ g.roundRect(x, y, w, h, 20).stroke({ color: 0xffffff, alpha: 0.35, width: 1.5 }); |
|
222 |
+ g.roundRect(x + 10, y + 8, w - 20, h * 0.18, 14).fill({ color: 0xffffff, alpha: 0.04 }); |
|
223 |
+ g.moveTo(x + 30, y + 4).lineTo(x + w * 0.4, y + 4).stroke({ color: 0xffffff, alpha: 0.5, width: 2 }); |
|
224 |
+ break; |
|
225 |
+ } |
|
226 |
+ case "obsidian": { |
|
227 |
+ g.roundRect(x, y, w, h, 4).fill({ color: 0x000000, alpha: 0.7 }); |
|
228 |
+ g.roundRect(x, y, w, h, 4).stroke({ color: 0xffffff, alpha: 0.14, width: 1 }); |
|
229 |
+ g.moveTo(x + w * 0.2, y - 10).lineTo(x + w * 0.8, y - 10).stroke({ color: 0xe8cf8f, alpha: 0.6, width: 1 }); |
|
230 |
+ g.moveTo(x + w * 0.35, y + h + 10).lineTo(x + w * 0.65, y + h + 10).stroke({ color: 0xe8cf8f, alpha: 0.35, width: 1 }); |
|
231 |
+ break; |
|
232 |
+ } |
|
233 |
+ case "metal": |
|
234 |
+ default: { |
|
235 |
+ g.roundRect(x, y, w, h, 14).fill({ color: 0x0b0e15, alpha: 0.75 }); |
|
236 |
+ g.roundRect(x, y, w, h, 14).stroke({ color: 0x9aa3b5, alpha: 0.5, width: 3 }); |
|
237 |
+ g.roundRect(x + 4, y + 4, w - 8, h - 8, 11).stroke({ color: 0xffffff, alpha: 0.08, width: 1 }); |
|
238 |
+ g.roundRect(x, y, w, h * 0.35, 14).fill({ color: 0xffffff, alpha: 0.03 }); |
|
239 |
+ // Rivets. |
|
240 |
+ for (let i = 0; i <= cols; i++) { |
|
241 |
+ const rx = x + (w / cols) * i; |
|
242 |
+ g.circle(Math.min(Math.max(rx, x + 10), x + w - 10), y + 8, 3).fill({ color: 0xc8d0dc, alpha: 0.55 }); |
|
243 |
+ g.circle(Math.min(Math.max(rx, x + 10), x + w - 10), y + h - 8, 3).fill({ color: 0xc8d0dc, alpha: 0.55 }); |
|
244 |
+ } |
|
245 |
+ break; |
|
246 |
+ } |
|
247 |
+ } |
| 167 |
248 |
// Cell wells. |
| 168 |
|
− for (let r = 0; r < this.cols; r++) |
| 169 |
|
− for (let yy = 0; yy < this.rows; yy++) { |
| 170 |
|
− g.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(yy) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).fill({ color: 0xffffff, alpha: 0.025 }); |
|
249 |
+ for (let r = 0; r < cols; r++) |
|
250 |
+ for (let yy = 0; yy < rows; yy++) { |
|
251 |
+ g.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(yy) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).fill({ color: 0xffffff, alpha: p.frame === "obsidian" ? 0.012 : 0.03 }); |
| 171 |
252 |
} |
| 172 |
253 |
} |
| 173 |
254 |
|
| 174 |
255 |
/* ------------------------------------------------------------- backdrop */ |
| 175 |
256 |
|
|
257 |
+ /** Draw the game's world behind the reels: a static scene + a set of animated layers (see animateBackdrop). */ |
| 176 |
258 |
private drawBackdrop() { |
| 177 |
259 |
const p = this.opts.def.presentation; |
| 178 |
260 |
const b = this.backdrop; |
| 183 |
265 |
const primary = hexColor(p.palette.primary); |
| 184 |
266 |
const secondary = hexColor(p.palette.secondary); |
| 185 |
267 |
const bg = hexColor(p.palette.bg); |
| 186 |
|
− g.rect(0, 0, W, H).fill(bg); |
| 187 |
|
− // Vignette glow blobs. |
| 188 |
|
− g.ellipse(W * 0.3, H * 0.2, W * 0.5, H * 0.45).fill({ color: primary, alpha: 0.08 }); |
| 189 |
|
− g.ellipse(W * 0.75, H * 0.85, W * 0.5, H * 0.4).fill({ color: secondary, alpha: 0.07 }); |
|
268 |
+ const surface = hexColor(p.palette.surface); |
| 190 |
269 |
const seed = hashString(this.opts.def.slug); |
| 191 |
270 |
const rnd = mulberry32(seed); |
|
271 |
+ const items: { g: Container; a: number; b: number; c: number }[] = []; |
|
272 |
+ const layer = (fn: (gg: Graphics) => void): Graphics => { |
|
273 |
+ const gg = new Graphics(); |
|
274 |
+ fn(gg); |
|
275 |
+ b.addChild(gg); |
|
276 |
+ return gg; |
|
277 |
+ }; |
|
278 |
+ // Base: vertical gradient bg → surface → bg. |
|
279 |
+ g.rect(0, 0, W, H).fill(bg); |
|
280 |
+ g.rect(0, 0, W, H * 0.55).fill({ color: surface, alpha: 0.55 }); |
|
281 |
+ g.ellipse(W * 0.5, H * 0.35, W * 0.7, H * 0.5).fill({ color: primary, alpha: 0.07 }); |
|
282 |
+ g.ellipse(W * 0.8, H * 0.95, W * 0.55, H * 0.35).fill({ color: secondary, alpha: 0.07 }); |
|
283 |
+ b.addChild(g); |
|
284 |
+ |
| 192 |
285 |
switch (p.backdrop) { |
| 193 |
|
− case "grid": |
| 194 |
|
− case "circuit": |
| 195 |
286 |
case "vault": { |
| 196 |
|
− const step = Math.max(28, W / 22); |
| 197 |
|
− for (let x = 0; x <= W; x += step) g.moveTo(x, 0).lineTo(x, H).stroke({ color: primary, alpha: 0.06, width: 1 }); |
| 198 |
|
− for (let y = 0; y <= H; y += step) g.moveTo(0, y).lineTo(W, y).stroke({ color: primary, alpha: 0.06, width: 1 }); |
| 199 |
|
− if (p.backdrop === "vault") for (let i = 1; i <= 4; i++) g.circle(W / 2, H / 2, Math.min(W, H) * 0.2 * i).stroke({ color: primary, alpha: 0.05, width: 2 }); |
| 200 |
|
− if (p.backdrop === "circuit") |
| 201 |
|
− for (let i = 0; i < 12; i++) { |
|
287 |
+ // Rotating security rings + scanning grid. |
|
288 |
+ const grid = layer((gg) => { |
|
289 |
+ const step = Math.max(30, W / 20); |
|
290 |
+ for (let x = 0; x <= W; x += step) gg.moveTo(x, -H).lineTo(x, H * 2).stroke({ color: primary, alpha: 0.07, width: 1 }); |
|
291 |
+ for (let y = -H; y <= H * 2; y += step) gg.moveTo(0, y).lineTo(W, y).stroke({ color: primary, alpha: 0.07, width: 1 }); |
|
292 |
+ }); |
|
293 |
+ void grid; |
|
294 |
+ for (let i = 1; i <= 3; i++) { |
|
295 |
+ const ring = layer((gg) => { |
|
296 |
+ const r = Math.min(W, H) * 0.22 * i; |
|
297 |
+ gg.circle(0, 0, r).stroke({ color: primary, alpha: 0.1, width: 2 }); |
|
298 |
+ for (let k = 0; k < 8; k++) { |
|
299 |
+ const a = (k / 8) * Math.PI * 2; |
|
300 |
+ gg.moveTo(Math.cos(a) * (r - 14), Math.sin(a) * (r - 14)).lineTo(Math.cos(a) * (r + 14), Math.sin(a) * (r + 14)).stroke({ color: secondary, alpha: 0.35, width: 3 }); |
|
301 |
+ } |
|
302 |
+ }); |
|
303 |
+ ring.position.set(W / 2, H / 2); |
|
304 |
+ items.push({ g: ring, a: (i % 2 ? 1 : -1) * 0.12 / i, b: 0, c: 0 }); |
|
305 |
+ } |
|
306 |
+ this.anim = { kind: "vault", items, t: 0 }; |
|
307 |
+ break; |
|
308 |
+ } |
|
309 |
+ case "grid": |
|
310 |
+ case "circuit": { |
|
311 |
+ const traces = layer((gg) => { |
|
312 |
+ for (let i = 0; i < 18; i++) { |
| 202 |
313 |
const x = rnd() * W; |
| 203 |
314 |
const y = rnd() * H; |
| 204 |
|
− g.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 200, y).lineTo(x + (rnd() - 0.5) * 200, y + (rnd() - 0.5) * 160).stroke({ color: secondary, alpha: 0.12, width: 2 }); |
| 205 |
|
− g.circle(x, y, 3).fill({ color: secondary, alpha: 0.35 }); |
|
315 |
+ gg.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 240, y).lineTo(x + (rnd() - 0.5) * 240, y + (rnd() - 0.5) * 180).stroke({ color: primary, alpha: 0.16, width: 2 }); |
|
316 |
+ gg.circle(x, y, 3).fill({ color: secondary, alpha: 0.5 }); |
| 206 |
317 |
} |
|
318 |
+ }); |
|
319 |
+ void traces; |
|
320 |
+ for (let i = 0; i < 3; i++) { |
|
321 |
+ const pulse = layer((gg) => gg.rect(0, 0, W, 3).fill({ color: secondary, alpha: 0.35 })); |
|
322 |
+ pulse.y = (H / 3) * i; |
|
323 |
+ items.push({ g: pulse, a: 40 + i * 25, b: i, c: 0 }); |
|
324 |
+ } |
|
325 |
+ this.anim = { kind: "grid", items, t: 0 }; |
|
326 |
+ break; |
|
327 |
+ } |
|
328 |
+ case "nebula": { |
|
329 |
+ for (let i = 0; i < 6; i++) { |
|
330 |
+ const cloud = layer((gg) => gg.ellipse(0, 0, 90 + rnd() * W * 0.3, 40 + rnd() * H * 0.25).fill({ color: i % 2 ? primary : secondary, alpha: 0.07 })); |
|
331 |
+ cloud.position.set(rnd() * W, rnd() * H); |
|
332 |
+ items.push({ g: cloud, a: (rnd() - 0.5) * 12, b: (rnd() - 0.5) * 6, c: 0 }); |
|
333 |
+ } |
|
334 |
+ const stars = layer((gg) => { |
|
335 |
+ for (let i = 0; i < 120; i++) gg.circle(rnd() * W, rnd() * H, rnd() * 1.6 + 0.3).fill({ color: 0xffffff, alpha: 0.3 + rnd() * 0.6 }); |
|
336 |
+ }); |
|
337 |
+ void stars; |
|
338 |
+ // Black hole. |
|
339 |
+ const hole = layer((gg) => { |
|
340 |
+ gg.circle(0, 0, Math.min(W, H) * 0.09).fill(0x000000); |
|
341 |
+ gg.circle(0, 0, Math.min(W, H) * 0.11).stroke({ color: primary, alpha: 0.7, width: 4 }); |
|
342 |
+ gg.ellipse(0, 0, Math.min(W, H) * 0.22, Math.min(W, H) * 0.05).stroke({ color: secondary, alpha: 0.35, width: 2 }); |
|
343 |
+ }); |
|
344 |
+ hole.position.set(W * 0.78, H * 0.22); |
|
345 |
+ items.push({ g: hole, a: 0, b: 0, c: 0.6 }); |
|
346 |
+ this.anim = { kind: "nebula", items, t: 0 }; |
| 207 |
347 |
break; |
| 208 |
348 |
} |
| 209 |
|
− case "nebula": |
| 210 |
349 |
case "universe": |
| 211 |
350 |
case "gravity": { |
| 212 |
|
− for (let i = 0; i < 6; i++) g.ellipse(rnd() * W, rnd() * H, 80 + rnd() * W * 0.3, 40 + rnd() * H * 0.25).fill({ color: i % 2 ? primary : secondary, alpha: 0.05 }); |
| 213 |
|
− for (let i = 0; i < 90; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.6 + 0.3).fill({ color: 0xffffff, alpha: 0.25 + rnd() * 0.5 }); |
| 214 |
|
− if (p.backdrop === "gravity" || p.backdrop === "universe") for (let i = 1; i <= 3; i++) g.ellipse(W / 2, H / 2, W * 0.28 * i, H * 0.18 * i).stroke({ color: primary, alpha: 0.08, width: 1.5 }); |
|
351 |
+ const stars = layer((gg) => { |
|
352 |
+ for (let i = 0; i < 110; i++) gg.circle(rnd() * W, rnd() * H, rnd() * 1.5 + 0.3).fill({ color: 0xffffff, alpha: 0.3 + rnd() * 0.6 }); |
|
353 |
+ }); |
|
354 |
+ void stars; |
|
355 |
+ for (let i = 1; i <= 3; i++) { |
|
356 |
+ const orbit = layer((gg) => { |
|
357 |
+ gg.ellipse(0, 0, W * 0.24 * i, H * 0.16 * i).stroke({ color: i % 2 ? primary : secondary, alpha: 0.14, width: 1.5 }); |
|
358 |
+ gg.circle(W * 0.24 * i, 0, 5).fill({ color: i % 2 ? secondary : primary, alpha: 0.9 }); |
|
359 |
+ }); |
|
360 |
+ orbit.position.set(W / 2, H / 2); |
|
361 |
+ orbit.rotation = rnd() * Math.PI; |
|
362 |
+ items.push({ g: orbit, a: (i % 2 ? 0.25 : -0.18) / i, b: 0, c: 0 }); |
|
363 |
+ } |
|
364 |
+ if (p.backdrop === "universe") { |
|
365 |
+ const burst = layer((gg) => { |
|
366 |
+ for (let k = 0; k < 24; k++) { |
|
367 |
+ const a = (k / 24) * Math.PI * 2; |
|
368 |
+ gg.moveTo(Math.cos(a) * 40, Math.sin(a) * 40).lineTo(Math.cos(a) * Math.max(W, H), Math.sin(a) * Math.max(W, H)).stroke({ color: primary, alpha: 0.05, width: 2 }); |
|
369 |
+ } |
|
370 |
+ }); |
|
371 |
+ burst.position.set(W / 2, H / 2); |
|
372 |
+ items.push({ g: burst, a: 0.05, b: 0, c: 0 }); |
|
373 |
+ } |
|
374 |
+ this.anim = { kind: "gravity", items, t: 0 }; |
| 215 |
375 |
break; |
| 216 |
376 |
} |
| 217 |
|
− case "pillars": |
| 218 |
|
− case "temple": { |
|
377 |
+ case "pillars": { |
| 219 |
378 |
const n = 6; |
| 220 |
379 |
for (let i = 0; i < n; i++) { |
| 221 |
380 |
const x = (W / (n + 1)) * (i + 1); |
| 222 |
|
− g.rect(x - 18, 0, 36, H).fill({ color: primary, alpha: 0.05 }); |
| 223 |
|
− g.rect(x - 26, H * 0.08, 52, 14).fill({ color: primary, alpha: 0.1 }); |
|
381 |
+ const col = layer((gg) => { |
|
382 |
+ gg.rect(-20, 0, 40, H).fill({ color: primary, alpha: 0.06 }); |
|
383 |
+ gg.rect(-30, H * 0.06, 60, 16).fill({ color: primary, alpha: 0.14 }); |
|
384 |
+ gg.rect(-30, H * 0.92, 60, 16).fill({ color: primary, alpha: 0.14 }); |
|
385 |
+ gg.rect(-12, 0, 6, H).fill({ color: 0xffffff, alpha: 0.03 }); |
|
386 |
+ }); |
|
387 |
+ col.position.set(x, 0); |
|
388 |
+ items.push({ g: col, a: 0.8 + rnd(), b: rnd() * 6, c: 1 }); |
| 224 |
389 |
} |
|
390 |
+ // Lantern glows. |
|
391 |
+ for (let i = 0; i < 4; i++) { |
|
392 |
+ const lamp = layer((gg) => gg.circle(0, 0, 40).fill({ color: 0xffb347, alpha: 0.12 })); |
|
393 |
+ lamp.position.set(W * (0.15 + 0.23 * i), H * 0.25); |
|
394 |
+ items.push({ g: lamp, a: 2 + rnd() * 2, b: rnd() * 6, c: 1 }); |
|
395 |
+ } |
|
396 |
+ this.anim = { kind: "pillars", items, t: 0 }; |
| 225 |
397 |
break; |
| 226 |
398 |
} |
| 227 |
399 |
case "aurora": { |
| 228 |
|
− for (let k = 0; k < 4; k++) { |
| 229 |
|
− g.moveTo(0, H * (0.2 + k * 0.12)); |
| 230 |
|
− for (let x = 0; x <= W; x += 40) g.lineTo(x, H * (0.2 + k * 0.12) + Math.sin(x / 90 + k) * 30); |
| 231 |
|
− g.stroke({ color: k % 2 ? primary : secondary, alpha: 0.1, width: 26 }); |
|
400 |
+ for (let k = 0; k < 5; k++) { |
|
401 |
+ const band = layer((gg) => { |
|
402 |
+ gg.moveTo(-60, H * (0.14 + k * 0.1)); |
|
403 |
+ for (let x = -60; x <= W + 60; x += 30) gg.lineTo(x, H * (0.14 + k * 0.1) + Math.sin(x / 110 + k) * 34); |
|
404 |
+ gg.stroke({ color: k % 2 ? primary : secondary, alpha: 0.13, width: 34 }); |
|
405 |
+ }); |
|
406 |
+ items.push({ g: band, a: 0.4 + k * 0.12, b: k, c: 0 }); |
| 232 |
407 |
} |
|
408 |
+ // Mountains. |
|
409 |
+ const mountains = layer((gg) => { |
|
410 |
+ gg.moveTo(0, H); |
|
411 |
+ for (let x = 0; x <= W; x += 60) gg.lineTo(x, H * 0.78 - Math.abs(Math.sin(x / 140)) * H * 0.18); |
|
412 |
+ gg.lineTo(W, H).fill({ color: 0x06090f, alpha: 0.9 }); |
|
413 |
+ }); |
|
414 |
+ void mountains; |
|
415 |
+ this.anim = { kind: "aurora", items, t: 0 }; |
| 233 |
416 |
break; |
| 234 |
417 |
} |
| 235 |
|
− case "lava": |
| 236 |
|
− case "core": |
| 237 |
|
− case "reactor": { |
| 238 |
|
− for (let i = 0; i < 14; i++) { |
| 239 |
|
− const x = rnd() * W; |
| 240 |
|
− const y = rnd() * H; |
| 241 |
|
− g.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 220, y + (rnd() - 0.5) * 220).stroke({ color: primary, alpha: 0.18, width: 2 }); |
| 242 |
|
− } |
| 243 |
|
− g.circle(W / 2, H / 2, Math.min(W, H) * 0.42).stroke({ color: secondary, alpha: 0.12, width: 10 }); |
|
418 |
+ case "lava": { |
|
419 |
+ const cracks = layer((gg) => { |
|
420 |
+ for (let i = 0; i < 14; i++) { |
|
421 |
+ let x = rnd() * W; |
|
422 |
+ let y = H * 0.55 + rnd() * H * 0.45; |
|
423 |
+ gg.moveTo(x, y); |
|
424 |
+ for (let k = 0; k < 5; k++) { |
|
425 |
+ x += (rnd() - 0.5) * 120; |
|
426 |
+ y += (rnd() - 0.5) * 80; |
|
427 |
+ gg.lineTo(x, y); |
|
428 |
+ } |
|
429 |
+ gg.stroke({ color: primary, alpha: 0.35, width: 3 }); |
|
430 |
+ } |
|
431 |
+ }); |
|
432 |
+ items.push({ g: cracks, a: 1.5, b: 0, c: 0.5 }); |
|
433 |
+ const glow = layer((gg) => gg.rect(0, H * 0.6, W, H * 0.4).fill({ color: primary, alpha: 0.12 })); |
|
434 |
+ items.push({ g: glow, a: 0.7, b: 1, c: 0.6 }); |
|
435 |
+ const volcano = layer((gg) => gg.poly([W * 0.15, H * 0.62, W * 0.42, H * 0.12, W * 0.7, H * 0.62]).fill({ color: 0x0a0505, alpha: 0.85 })); |
|
436 |
+ void volcano; |
|
437 |
+ this.anim = { kind: "lava", items, t: 0 }; |
| 244 |
438 |
break; |
| 245 |
439 |
} |
| 246 |
440 |
case "skyline": { |
| 247 |
|
− for (let x = 0; x < W; x += 30 + rnd() * 40) { |
| 248 |
|
− const h = 60 + rnd() * H * 0.45; |
| 249 |
|
− g.rect(x, H - h, 24 + rnd() * 40, h).fill({ color: 0x000000, alpha: 0.45 }); |
| 250 |
|
− for (let i = 0; i < 5; i++) g.rect(x + 4 + rnd() * 30, H - h + 8 + rnd() * (h - 16), 4, 6).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.6 }); |
|
441 |
+ const buildings = layer((gg) => { |
|
442 |
+ for (let x = 0; x < W; x += 34 + rnd() * 40) { |
|
443 |
+ const h = 80 + rnd() * H * 0.5; |
|
444 |
+ gg.rect(x, H - h, 28 + rnd() * 44, h).fill({ color: 0x05060c, alpha: 0.85 }); |
|
445 |
+ } |
|
446 |
+ }); |
|
447 |
+ void buildings; |
|
448 |
+ for (let i = 0; i < 60; i++) { |
|
449 |
+ const win = layer((gg) => gg.rect(0, 0, 5, 7).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.85 })); |
|
450 |
+ win.position.set(rnd() * W, H * 0.45 + rnd() * H * 0.5); |
|
451 |
+ items.push({ g: win, a: 0.4 + rnd() * 1.5, b: rnd() * 6, c: 0 }); |
| 251 |
452 |
} |
|
453 |
+ // Neon sign strokes. |
|
454 |
+ const signs = layer((gg) => { |
|
455 |
+ gg.roundRect(W * 0.08, H * 0.18, 120, 40, 6).stroke({ color: primary, alpha: 0.7, width: 3 }); |
|
456 |
+ gg.roundRect(W * 0.7, H * 0.28, 150, 34, 6).stroke({ color: secondary, alpha: 0.7, width: 3 }); |
|
457 |
+ }); |
|
458 |
+ void signs; |
|
459 |
+ this.anim = { kind: "skyline", items, t: 0 }; |
| 252 |
460 |
break; |
| 253 |
461 |
} |
| 254 |
462 |
case "pyramid": { |
| 255 |
|
− g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.05, H]).fill({ color: primary, alpha: 0.06 }); |
| 256 |
|
− g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.5, H]).fill({ color: 0x000000, alpha: 0.15 }); |
|
463 |
+ const pyr = layer((gg) => { |
|
464 |
+ gg.poly([W * 0.5, H * 0.08, W * 0.98, H, W * 0.02, H]).fill({ color: primary, alpha: 0.08 }); |
|
465 |
+ gg.poly([W * 0.5, H * 0.08, W * 0.98, H, W * 0.5, H]).fill({ color: 0x000000, alpha: 0.2 }); |
|
466 |
+ for (let i = 1; i < 8; i++) gg.moveTo(W * 0.5 - (W * 0.48 * i) / 8, H * 0.08 + ((H * 0.92) * i) / 8).lineTo(W * 0.5 + (W * 0.48 * i) / 8, H * 0.08 + ((H * 0.92) * i) / 8).stroke({ color: primary, alpha: 0.08, width: 1 }); |
|
467 |
+ }); |
|
468 |
+ void pyr; |
|
469 |
+ const eye = layer((gg) => { |
|
470 |
+ gg.circle(0, 0, 26).stroke({ color: secondary, alpha: 0.8, width: 3 }); |
|
471 |
+ gg.circle(0, 0, 9).fill({ color: secondary, alpha: 0.9 }); |
|
472 |
+ }); |
|
473 |
+ eye.position.set(W * 0.5, H * 0.3); |
|
474 |
+ items.push({ g: eye, a: 1.2, b: 0, c: 0.5 }); |
|
475 |
+ const beam = layer((gg) => gg.poly([W * 0.5 - 4, H * 0.3, W * 0.5 + 4, H * 0.3, W * 0.62, H, W * 0.38, H]).fill({ color: secondary, alpha: 0.12 })); |
|
476 |
+ items.push({ g: beam, a: 0.9, b: 2, c: 0.3 }); |
|
477 |
+ this.anim = { kind: "pyramid", items, t: 0 }; |
| 257 |
478 |
break; |
| 258 |
479 |
} |
| 259 |
480 |
case "arcade": { |
| 260 |
|
− for (let i = 0; i < 60; i++) g.rect(Math.floor((rnd() * W) / 24) * 24, Math.floor((rnd() * H) / 24) * 24, 22, 22).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.08 }); |
|
481 |
+ for (let i = 0; i < 70; i++) { |
|
482 |
+ const px = layer((gg) => gg.rect(0, 0, 22, 22).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.4 })); |
|
483 |
+ px.position.set(Math.floor((rnd() * W) / 24) * 24, Math.floor((rnd() * H) / 24) * 24); |
|
484 |
+ items.push({ g: px, a: 0, b: rnd() * 6, c: 0 }); |
|
485 |
+ } |
|
486 |
+ // Bezel stripes. |
|
487 |
+ const stripes = layer((gg) => { |
|
488 |
+ for (let i = 0; i < 4; i++) gg.rect(0, H - 24 - i * 30, W, 10).fill({ color: i % 2 ? primary : secondary, alpha: 0.25 }); |
|
489 |
+ }); |
|
490 |
+ void stripes; |
|
491 |
+ this.anim = { kind: "arcade", items, t: 0 }; |
| 261 |
492 |
break; |
| 262 |
493 |
} |
| 263 |
494 |
case "asteroids": { |
| 264 |
|
− for (let i = 0; i < 16; i++) polygonRandom(g, rnd() * W, rnd() * H, 10 + rnd() * 40, rnd).fill({ color: 0xffffff, alpha: 0.05 }); |
| 265 |
|
− for (let i = 0; i < 60; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.5 }); |
|
495 |
+ const stars = layer((gg) => { |
|
496 |
+ for (let i = 0; i < 80; i++) gg.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.6 }); |
|
497 |
+ }); |
|
498 |
+ void stars; |
|
499 |
+ for (let i = 0; i < 14; i++) { |
|
500 |
+ const rock = layer((gg) => polygonRandom(gg, 0, 0, 14 + rnd() * 44, rnd).fill({ color: 0x2b2e36, alpha: 0.9 }).stroke({ color: primary, alpha: 0.35, width: 1.5 })); |
|
501 |
+ rock.position.set(rnd() * W, rnd() * H); |
|
502 |
+ items.push({ g: rock, a: (rnd() - 0.5) * 30, b: (rnd() - 0.5) * 20, c: (rnd() - 0.5) * 0.6 }); |
|
503 |
+ } |
|
504 |
+ this.anim = { kind: "asteroids", items, t: 0 }; |
| 266 |
505 |
break; |
| 267 |
506 |
} |
| 268 |
507 |
case "track": { |
| 269 |
|
− for (let k = 0; k < 5; k++) { |
| 270 |
|
− g.moveTo(-50, H * 0.9 - k * 40); |
| 271 |
|
− g.bezierCurveTo(W * 0.3, H * 0.1 - k * 30, W * 0.7, H * 1.1 - k * 30, W + 50, H * 0.2 - k * 40); |
| 272 |
|
− g.stroke({ color: k % 2 ? primary : 0xffffff, alpha: 0.06, width: 12 }); |
|
508 |
+ const road = layer((gg) => { |
|
509 |
+ gg.moveTo(-50, H * 0.9).bezierCurveTo(W * 0.3, H * 0.15, W * 0.7, H * 1.15, W + 50, H * 0.25).stroke({ color: 0x0c0f16, alpha: 1, width: 90 }); |
|
510 |
+ gg.moveTo(-50, H * 0.9).bezierCurveTo(W * 0.3, H * 0.15, W * 0.7, H * 1.15, W + 50, H * 0.25).stroke({ color: primary, alpha: 0.25, width: 4 }); |
|
511 |
+ }); |
|
512 |
+ void road; |
|
513 |
+ for (let i = 0; i < 10; i++) { |
|
514 |
+ const dash = layer((gg) => gg.rect(0, -3, 40, 6).fill({ color: 0xffffff, alpha: 0.35 })); |
|
515 |
+ dash.position.set((W / 10) * i, H * 0.55 + Math.sin(i) * 40); |
|
516 |
+ items.push({ g: dash, a: 220 + i * 10, b: 0, c: 0 }); |
|
517 |
+ } |
|
518 |
+ const flag = layer((gg) => { |
|
519 |
+ for (let i = 0; i < 6; i++) for (let j = 0; j < 2; j++) gg.rect(W * 0.82 + i * 14, H * 0.1 + j * 14, 14, 14).fill({ color: (i + j) % 2 ? 0xffffff : 0x000000, alpha: 0.5 }); |
|
520 |
+ }); |
|
521 |
+ void flag; |
|
522 |
+ this.anim = { kind: "track", items, t: 0 }; |
|
523 |
+ break; |
|
524 |
+ } |
|
525 |
+ case "reactor": |
|
526 |
+ case "core": { |
|
527 |
+ for (let i = 1; i <= 3; i++) { |
|
528 |
+ const ring = layer((gg) => { |
|
529 |
+ polygonN(gg, 0, 0, Math.min(W, H) * 0.16 * i, 6).stroke({ color: i === 2 ? secondary : primary, alpha: 0.35, width: 3 }); |
|
530 |
+ for (let k = 0; k < 6; k++) { |
|
531 |
+ const a = (k / 6) * Math.PI * 2; |
|
532 |
+ gg.circle(Math.cos(a) * Math.min(W, H) * 0.16 * i, Math.sin(a) * Math.min(W, H) * 0.16 * i, 4).fill({ color: secondary, alpha: 0.8 }); |
|
533 |
+ } |
|
534 |
+ }); |
|
535 |
+ ring.position.set(W / 2, H / 2); |
|
536 |
+ items.push({ g: ring, a: (i % 2 ? 0.2 : -0.14) / i, b: i, c: 0 }); |
| 273 |
537 |
} |
|
538 |
+ const coreGlow = layer((gg) => gg.circle(0, 0, Math.min(W, H) * 0.12).fill({ color: primary, alpha: 0.35 })); |
|
539 |
+ coreGlow.position.set(W / 2, H / 2); |
|
540 |
+ items.push({ g: coreGlow, a: 0, b: 1, c: 0 }); |
|
541 |
+ const pipes = layer((gg) => { |
|
542 |
+ for (const yy of [H * 0.12, H * 0.88]) gg.rect(0, yy - 5, W, 10).fill({ color: 0x1a1e27, alpha: 0.9 }).rect(0, yy - 2, W, 4).fill({ color: primary, alpha: 0.2 }); |
|
543 |
+ }); |
|
544 |
+ void pipes; |
|
545 |
+ this.anim = { kind: "reactor", items, t: 0 }; |
| 274 |
546 |
break; |
| 275 |
547 |
} |
| 276 |
548 |
case "abyss": { |
| 277 |
|
− for (let i = 0; i < 40; i++) g.circle(rnd() * W, rnd() * H, 2 + rnd() * 10).stroke({ color: primary, alpha: 0.12, width: 1 }); |
| 278 |
|
− g.rect(0, H * 0.6, W, H * 0.4).fill({ color: 0x000000, alpha: 0.35 }); |
|
549 |
+ for (let i = 0; i < 4; i++) { |
|
550 |
+ const depth = layer((gg) => gg.rect(0, 0, W, H * 0.25).fill({ color: 0x000000, alpha: 0.12 * (i + 1) })); |
|
551 |
+ depth.y = (H * 0.25) * i; |
|
552 |
+ void depth; |
|
553 |
+ } |
|
554 |
+ for (let k = 0; k < 4; k++) { |
|
555 |
+ const ray = layer((gg) => gg.poly([W * (0.2 + k * 0.2) - 30, 0, W * (0.2 + k * 0.2) + 30, 0, W * (0.2 + k * 0.2) + 120, H, W * (0.2 + k * 0.2) - 120, H]).fill({ color: secondary, alpha: 0.05 })); |
|
556 |
+ items.push({ g: ray, a: 0.3 + k * 0.1, b: k, c: 0 }); |
|
557 |
+ } |
|
558 |
+ const ruins = layer((gg) => { |
|
559 |
+ for (let i = 0; i < 5; i++) gg.rect(W * 0.1 + i * W * 0.18, H * 0.7 + rnd() * 40, 30, H).fill({ color: 0x03060a, alpha: 0.9 }); |
|
560 |
+ }); |
|
561 |
+ void ruins; |
|
562 |
+ this.anim = { kind: "abyss", items, t: 0 }; |
| 279 |
563 |
break; |
| 280 |
564 |
} |
| 281 |
565 |
case "moon": { |
| 282 |
|
− g.circle(W * 0.8, H * 0.2, Math.min(W, H) * 0.18).fill({ color: lighten(p.palette.primary, 0.1), alpha: 0.12 }); |
| 283 |
|
− for (let i = 0; i < 8; i++) g.circle(W * 0.8 + (rnd() - 0.5) * 120, H * 0.2 + (rnd() - 0.5) * 120, 4 + rnd() * 14).fill({ color: 0x000000, alpha: 0.12 }); |
| 284 |
|
− for (let i = 0; i < 70; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.5 }); |
|
566 |
+ const stars = layer((gg) => { |
|
567 |
+ for (let i = 0; i < 90; i++) gg.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.6 }); |
|
568 |
+ }); |
|
569 |
+ void stars; |
|
570 |
+ const earth = layer((gg) => { |
|
571 |
+ gg.circle(0, 0, 44).fill({ color: 0x3b82f6, alpha: 0.7 }); |
|
572 |
+ gg.ellipse(-10, -6, 18, 10).fill({ color: 0x86efac, alpha: 0.5 }); |
|
573 |
+ gg.circle(0, 0, 44).stroke({ color: 0xffffff, alpha: 0.3, width: 2 }); |
|
574 |
+ }); |
|
575 |
+ earth.position.set(W * 0.82, H * 0.18); |
|
576 |
+ items.push({ g: earth, a: 0, b: 0, c: 0.05 }); |
|
577 |
+ const ground = layer((gg) => { |
|
578 |
+ gg.ellipse(W / 2, H * 1.05, W * 0.8, H * 0.3).fill({ color: 0x1a1d24, alpha: 0.95 }); |
|
579 |
+ for (let i = 0; i < 9; i++) gg.ellipse(rnd() * W, H * 0.82 + rnd() * H * 0.15, 10 + rnd() * 30, 4 + rnd() * 10).fill({ color: 0x000000, alpha: 0.35 }); |
|
580 |
+ }); |
|
581 |
+ void ground; |
|
582 |
+ const dome = layer((gg) => { |
|
583 |
+ gg.arc(0, 0, 70, Math.PI, 0).fill({ color: primary, alpha: 0.15 }); |
|
584 |
+ gg.arc(0, 0, 70, Math.PI, 0).stroke({ color: primary, alpha: 0.6, width: 2 }); |
|
585 |
+ }); |
|
586 |
+ dome.position.set(W * 0.2, H * 0.85); |
|
587 |
+ void dome; |
|
588 |
+ this.anim = { kind: "moon", items, t: 0 }; |
|
589 |
+ break; |
|
590 |
+ } |
|
591 |
+ case "temple": { |
|
592 |
+ const steps = layer((gg) => { |
|
593 |
+ for (let i = 0; i < 6; i++) gg.rect(W * 0.1 + i * 26, H * 0.35 + i * (H * 0.1), W * 0.8 - i * 52, H * 0.1).fill({ color: 0x0d0f0a, alpha: 0.85 }).stroke({ color: primary, alpha: 0.15, width: 1 }); |
|
594 |
+ }); |
|
595 |
+ void steps; |
|
596 |
+ const vines = layer((gg) => { |
|
597 |
+ for (let i = 0; i < 8; i++) { |
|
598 |
+ const x = rnd() * W; |
|
599 |
+ gg.moveTo(x, 0).bezierCurveTo(x + 40, H * 0.2, x - 40, H * 0.4, x + 10, H * 0.6).stroke({ color: 0x2f7d4a, alpha: 0.5, width: 3 }); |
|
600 |
+ } |
|
601 |
+ }); |
|
602 |
+ void vines; |
|
603 |
+ for (let i = 0; i < 4; i++) { |
|
604 |
+ const torch = layer((gg) => gg.circle(0, 0, 34).fill({ color: 0xffa040, alpha: 0.18 })); |
|
605 |
+ torch.position.set(W * (0.12 + i * 0.25), H * 0.32); |
|
606 |
+ items.push({ g: torch, a: 4 + rnd() * 3, b: rnd() * 6, c: 0.5 }); |
|
607 |
+ } |
|
608 |
+ this.anim = { kind: "temple", items, t: 0 }; |
| 285 |
609 |
break; |
| 286 |
610 |
} |
| 287 |
611 |
case "minimal": { |
| 288 |
|
− g.moveTo(W * 0.1, H * 0.5).lineTo(W * 0.9, H * 0.5).stroke({ color: primary, alpha: 0.18, width: 1 }); |
|
612 |
+ const line = layer((gg) => gg.moveTo(W * 0.08, H * 0.5).lineTo(W * 0.92, H * 0.5).stroke({ color: 0xe8cf8f, alpha: 0.18, width: 1 })); |
|
613 |
+ items.push({ g: line, a: 0, b: 0, c: 0 }); |
|
614 |
+ this.anim = { kind: "minimal", items, t: 0 }; |
| 289 |
615 |
break; |
| 290 |
616 |
} |
| 291 |
617 |
} |
| 292 |
|
− b.addChild(g); |
|
618 |
+ // Vault scan line (moving) is handled as its own small anim group when the backdrop is a vault. |
|
619 |
+ if (p.backdrop === "vault") { |
|
620 |
+ const scan = layer((gg) => gg.rect(0, 0, W, 2).fill({ color: secondary, alpha: 0.45 })); |
|
621 |
+ this.vaultScan = scan; |
|
622 |
+ } else this.vaultScan = null; |
| 293 |
623 |
} |
| 294 |
624 |
|
|
625 |
+ private vaultScan: Graphics | null = null; |
|
626 |
+ |
| 295 |
627 |
private initAmbient() { |
| 296 |
628 |
if (!this.app || this.opts.reduceMotion || this.opts.intensity === "low") return; |
| 297 |
629 |
const p = this.opts.def.presentation; |
| 349 |
681 |
private buildTextures(renderer: Renderer) { |
| 350 |
682 |
const size = 176; |
| 351 |
683 |
for (const s of this.opts.def.symbols) { |
| 352 |
|
− const art = drawSymbol(s.style, { size, tier: s.tier, kind: s.kind }); |
|
684 |
+ const art = drawSymbol(s.style, { size, tier: s.tier, kind: s.kind, frame: this.opts.def.presentation.frame }); |
| 353 |
685 |
const tex = renderer.generateTexture({ target: art, resolution: 2, frame: new Rectangle(-size / 2, -size / 2, size, size) }); |
| 354 |
686 |
this.textures.set(s.id, tex); |
| 355 |
687 |
art.destroy({ children: true }); |
| 447 |
779 |
/** Start reels spinning (called when the request is sent). */ |
| 448 |
780 |
startSpin() { |
| 449 |
781 |
this.clearFx(); |
|
782 |
+ this.reelSpinning = []; |
| 450 |
783 |
this.spinning = true; |
| 451 |
784 |
this.spinVel = 0; |
|
785 |
+ this.spinStartedAt = performance.now(); |
| 452 |
786 |
} |
|
787 |
+ private spinStartedAt = 0; |
| 453 |
788 |
|
| 454 |
789 |
/** Land the given grid reel by reel. Resolves when the last reel stops. */ |
| 455 |
790 |
async landGrid(grid: string[][], opts: { minSpinMs?: number } = {}): Promise<void> { |
| 459 |
794 |
await this.dropIn(grid); |
| 460 |
795 |
return; |
| 461 |
796 |
} |
| 462 |
|
− const minSpin = (opts.minSpinMs ?? 500) * this.speed; |
| 463 |
|
− if (!this.spinning) { |
| 464 |
|
− this.startSpin(); |
| 465 |
|
− } |
| 466 |
|
− await wait(minSpin); |
| 467 |
|
− const stagger = (this.opts.quick ? 70 : 130) * this.speed; |
|
797 |
+ const minSpin = (opts.minSpinMs ?? (this.opts.quick ? 380 : 780)) * this.speed; |
|
798 |
+ if (!this.spinning) this.startSpin(); |
|
799 |
+ // Make sure the reels have visibly spun for at least `minSpin` since the spin started. |
|
800 |
+ const elapsed = performance.now() - this.spinStartedAt; |
|
801 |
+ if (elapsed < minSpin) await wait(minSpin - elapsed); |
|
802 |
+ const stagger = (this.opts.quick ? 80 : 160) * this.speed; |
|
803 |
+ // Anticipation: when the landed reels already show (trigger − 1) scatters, the remaining reels slow down and glow. |
|
804 |
+ const scatterId = this.opts.def.scatter?.id; |
|
805 |
+ const triggerAt = scatterId ? Math.min(...Object.keys(this.opts.def.scatter!.triggers).map(Number)) : Infinity; |
|
806 |
+ let scattersSoFar = 0; |
| 468 |
807 |
for (let r = 0; r < this.cols; r++) { |
|
808 |
+ const anticipate = scatterId !== undefined && scattersSoFar >= triggerAt - 1 && r < this.cols && !this.opts.quick; |
|
809 |
+ if (anticipate) { |
|
810 |
+ this.anticipationGlow(r); |
|
811 |
+ await wait(650 * this.speed); |
|
812 |
+ } |
| 469 |
813 |
await this.stopReel(r, grid[r]); |
| 470 |
814 |
this.opts.onReelStop?.(r); |
|
815 |
+ if (scatterId) scattersSoFar += grid[r].filter((id) => id === scatterId).length; |
| 471 |
816 |
if (r < this.cols - 1) await wait(stagger); |
| 472 |
817 |
} |
| 473 |
818 |
this.spinning = false; |
| 504 |
849 |
this.particles.splice(i, 1); |
| 505 |
850 |
} |
| 506 |
851 |
} |
|
852 |
+ // Animated backdrop layers (per game world). |
|
853 |
+ this.animateBackdrop(dt); |
| 507 |
854 |
// Reel spin: scroll symbols downward, wrapping with placeholder art. |
| 508 |
855 |
if (this.spinning) { |
| 509 |
|
− this.spinVel = Math.min(2600, this.spinVel + 9000 * dt); |
|
856 |
+ const maxVel = this.cellSize * (this.opts.quick ? 26 : 20); |
|
857 |
+ // Quick spin-up, then cruise. A short "pull back" at the very start reads as a physical launch. |
|
858 |
+ const since = (performance.now() - this.spinStartedAt) / 1000; |
|
859 |
+ const target = since < 0.08 ? -this.cellSize * 3 : maxVel; |
|
860 |
+ this.spinVel += (target - this.spinVel) * Math.min(1, dt * 14); |
| 510 |
861 |
const regular = this.opts.def.symbols.filter((s) => s.kind === "regular" || s.kind === "wild"); |
|
862 |
+ const blur = Math.min(1, Math.max(0, this.spinVel / maxVel)); |
| 511 |
863 |
for (let r = 0; r < this.cols; r++) { |
| 512 |
864 |
if (this.reelSpinning[r] === false) continue; |
| 513 |
865 |
this.reelSpinning[r] = true; |
| 514 |
866 |
const col = this.cells[r]; |
| 515 |
|
− const bottom = this.originY + this.rows * (this.cellSize + this.gap); |
|
867 |
+ const span = this.rows * (this.cellSize + this.gap); |
|
868 |
+ const bottom = this.originY + span; |
| 516 |
869 |
for (const cell of col) { |
| 517 |
|
− cell.sprite.y += this.spinVel * dt * (0.9 + r * 0.04); |
|
870 |
+ cell.sprite.y += this.spinVel * dt * (0.92 + r * 0.05); |
| 518 |
871 |
if (cell.sprite.y - this.cellSize / 2 > bottom) { |
| 519 |
|
− cell.sprite.y -= this.rows * (this.cellSize + this.gap); |
| 520 |
|
− const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only |
|
872 |
+ cell.sprite.y -= span; |
|
873 |
+ const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only, never an outcome |
| 521 |
874 |
this.swapTexture(cell, id); |
|
875 |
+ } else if (cell.sprite.y + this.cellSize / 2 < this.originY - this.gap) { |
|
876 |
+ cell.sprite.y += span; |
| 522 |
877 |
} |
|
878 |
+ // Motion blur: stretch vertically and fade while fast. |
|
879 |
+ const sp = cell.sprite.children[0] as Sprite; |
|
880 |
+ sp.alpha = 1 - 0.45 * blur; |
|
881 |
+ cell.sprite.scale.set(1 - 0.06 * blur, 1 + 0.55 * blur); |
| 523 |
882 |
} |
| 524 |
|
− // Motion blur feel. |
| 525 |
|
− for (const cell of col) (cell.sprite.children[0] as Sprite).alpha = 0.85; |
| 526 |
883 |
} |
| 527 |
884 |
} |
| 528 |
885 |
} |
| 529 |
886 |
|
|
887 |
+ /* --------------------------------------------------------- backdrop anim */ |
|
888 |
+ |
|
889 |
+ private anim: { kind: string; items: { g: Container; a: number; b: number; c: number }[]; t: number } | null = null; |
|
890 |
+ |
|
891 |
+ private animateBackdrop(dt: number) { |
|
892 |
+ if (this.vaultScan && !this.opts.reduceMotion) { |
|
893 |
+ this.vaultScan.y += dt * 70; |
|
894 |
+ if (this.vaultScan.y > this.height) this.vaultScan.y = 0; |
|
895 |
+ } |
|
896 |
+ if (!this.anim || this.opts.reduceMotion) return; |
|
897 |
+ const A = this.anim; |
|
898 |
+ A.t += dt; |
|
899 |
+ const W = this.width; |
|
900 |
+ const H = this.height; |
|
901 |
+ switch (A.kind) { |
|
902 |
+ case "vault": |
|
903 |
+ case "gravity": |
|
904 |
+ case "universe": |
|
905 |
+ case "reactor": |
|
906 |
+ case "core": |
|
907 |
+ for (const it of A.items) it.g.rotation += dt * it.a; |
|
908 |
+ if (A.kind === "reactor" || A.kind === "core") for (const it of A.items) it.g.alpha = 0.35 + Math.sin(A.t * 1.6 + it.b) * 0.25; |
|
909 |
+ break; |
|
910 |
+ case "grid": |
|
911 |
+ case "circuit": |
|
912 |
+ case "arcade": |
|
913 |
+ for (const it of A.items) { |
|
914 |
+ it.g.y += dt * it.a; |
|
915 |
+ if (it.g.y > H + 40) it.g.y = -40; |
|
916 |
+ if (A.kind === "arcade") it.g.alpha = 0.2 + Math.abs(Math.sin(A.t * 2 + it.b)) * 0.5; |
|
917 |
+ } |
|
918 |
+ break; |
|
919 |
+ case "nebula": |
|
920 |
+ case "asteroids": |
|
921 |
+ case "moon": |
|
922 |
+ for (const it of A.items) { |
|
923 |
+ it.g.x += dt * it.a; |
|
924 |
+ it.g.y += dt * it.b; |
|
925 |
+ if (it.g.x > W + 60) it.g.x = -60; |
|
926 |
+ if (it.g.x < -60) it.g.x = W + 60; |
|
927 |
+ if (it.g.y > H + 60) it.g.y = -60; |
|
928 |
+ if (it.g.y < -60) it.g.y = H + 60; |
|
929 |
+ it.g.rotation += dt * it.c; |
|
930 |
+ } |
|
931 |
+ break; |
|
932 |
+ case "aurora": |
|
933 |
+ case "abyss": |
|
934 |
+ for (const it of A.items) { |
|
935 |
+ it.g.x = Math.sin(A.t * it.a + it.b) * 26; |
|
936 |
+ it.g.alpha = 0.5 + Math.sin(A.t * it.a * 1.3 + it.b) * 0.25; |
|
937 |
+ } |
|
938 |
+ break; |
|
939 |
+ case "lava": |
|
940 |
+ case "pyramid": |
|
941 |
+ case "temple": |
|
942 |
+ for (const it of A.items) it.g.alpha = it.c + Math.sin(A.t * it.a + it.b) * it.c * 0.8; |
|
943 |
+ break; |
|
944 |
+ case "skyline": |
|
945 |
+ for (const it of A.items) it.g.alpha = Math.sin(A.t * it.a + it.b) > 0.3 ? 0.85 : 0.2; |
|
946 |
+ break; |
|
947 |
+ case "track": |
|
948 |
+ for (const it of A.items) { |
|
949 |
+ it.g.x -= dt * it.a; |
|
950 |
+ if (it.g.x < -80) it.g.x = W + 80; |
|
951 |
+ } |
|
952 |
+ break; |
|
953 |
+ case "minimal": |
|
954 |
+ for (const it of A.items) it.g.alpha = 0.12 + Math.sin(A.t * 0.6) * 0.06; |
|
955 |
+ break; |
|
956 |
+ case "pillars": |
|
957 |
+ for (const it of A.items) it.g.alpha = it.c + Math.sin(A.t * it.a + it.b) * 0.05; |
|
958 |
+ break; |
|
959 |
+ } |
|
960 |
+ } |
|
961 |
+ |
|
962 |
+ private anticipationGlow(reel: number) { |
|
963 |
+ const g = new Graphics(); |
|
964 |
+ const p = hexColor(this.opts.def.presentation.palette.glow); |
|
965 |
+ g.roundRect(this.cellX(reel) - this.cellSize / 2 - 4, this.originY - 4, this.cellSize + 8, this.rows * (this.cellSize + this.gap) - this.gap + 8, 14).stroke({ color: p, alpha: 0.9, width: 4 }); |
|
966 |
+ this.fxLayer.addChild(g); |
|
967 |
+ void this.animate(650 * this.speed, (t) => { |
|
968 |
+ g.alpha = 0.4 + Math.sin(t * Math.PI * 6) * 0.4; |
|
969 |
+ }).then(() => g.destroy()); |
|
970 |
+ } |
|
971 |
+ |
| 530 |
972 |
private swapTexture(cell: Cell, id: string) { |
| 531 |
973 |
const sp = cell.sprite.children[0] as Sprite; |
| 532 |
974 |
sp.texture = this.textures.get(id) ?? this.textures.get("__")!; |
| 537 |
979 |
private async stopReel(r: number, ids: string[]) { |
| 538 |
980 |
this.reelSpinning[r] = false; |
| 539 |
981 |
const col = this.cells[r]; |
| 540 |
|
− // Snap: place final symbols slightly above target, then ease into place with a bounce. |
|
982 |
+ // Snap: place final symbols above target, then drop into place with a bounce and a squash. |
| 541 |
983 |
col.forEach((cell, y) => { |
| 542 |
984 |
this.swapTexture(cell, ids[y]); |
| 543 |
985 |
(cell.sprite.children[0] as Sprite).alpha = 1; |
| 544 |
|
− cell.sprite.y = this.cellY(y) - this.cellSize * 0.35; |
|
986 |
+ cell.sprite.scale.set(1, 1.3); |
|
987 |
+ cell.sprite.y = this.cellY(y) - this.cellSize * 0.6; |
| 545 |
988 |
}); |
| 546 |
|
− const dur = (this.opts.quick ? 120 : 220) * this.speed; |
|
989 |
+ // Landing flash on the reel. |
|
990 |
+ const flash = new Graphics(); |
|
991 |
+ flash.roundRect(this.cellX(r) - this.cellSize / 2 - 2, this.originY - 2, this.cellSize + 4, this.rows * (this.cellSize + this.gap) - this.gap + 4, 12).fill({ color: 0xffffff, alpha: 0.18 }); |
|
992 |
+ this.fxLayer.addChild(flash); |
|
993 |
+ const dur = (this.opts.quick ? 140 : 260) * this.speed; |
| 547 |
994 |
await this.animate(dur, (t) => { |
| 548 |
995 |
const e = easeOutBack(t); |
|
996 |
+ flash.alpha = 1 - t; |
| 549 |
997 |
col.forEach((cell, y) => { |
| 550 |
|
− cell.sprite.y = this.cellY(y) - this.cellSize * 0.35 * (1 - e); |
|
998 |
+ cell.sprite.y = this.cellY(y) - this.cellSize * 0.6 * (1 - e); |
|
999 |
+ const squash = t < 0.5 ? 1 + (1 - t * 2) * 0.3 : 1 - Math.sin((t - 0.5) * Math.PI) * 0.08; |
|
1000 |
+ cell.sprite.scale.set(1 + (1 - squash) * 0.5, squash); |
| 551 |
1001 |
}); |
| 552 |
1002 |
}); |
| 553 |
|
− col.forEach((cell, y) => (cell.sprite.y = this.cellY(y))); |
|
1003 |
+ flash.destroy(); |
|
1004 |
+ col.forEach((cell, y) => { |
|
1005 |
+ cell.sprite.y = this.cellY(y); |
|
1006 |
+ cell.sprite.scale.set(1); |
|
1007 |
+ }); |
| 554 |
1008 |
} |
| 555 |
1009 |
|
| 556 |
1010 |
private async dropIn(grid: string[][]) { |
| 875 |
1329 |
}; |
| 876 |
1330 |
} |
| 877 |
1331 |
|
|
1332 |
+function polygonN(g: Graphics, cx: number, cy: number, r: number, sides: number): Graphics { |
|
1333 |
+ const pts: number[] = []; |
|
1334 |
+ for (let i = 0; i < sides; i++) { |
|
1335 |
+ const a = -Math.PI / 2 + (i * Math.PI * 2) / sides; |
|
1336 |
+ pts.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r); |
|
1337 |
+ } |
|
1338 |
+ return g.poly(pts); |
|
1339 |
+} |
|
1340 |
+ |
| 878 |
1341 |
function polygonRandom(g: Graphics, cx: number, cy: number, r: number, rnd: () => number): Graphics { |
| 879 |
1342 |
const pts: number[] = []; |
| 880 |
1343 |
const n = 6 + Math.floor(rnd() * 4); |