| 3 | import { Application, Container, Graphics, Rectangle, Sprite, Text, TextStyle, Texture, type Renderer } from "pixi.js"; |
3 | import { Application, Container, Graphics, Rectangle, Sprite, Text, TextStyle, Texture, type Renderer } from "pixi.js"; |
| 4 | import type { SpinStep } from "@spinza/game-core/client"; |
4 | import type { SpinStep } from "@spinza/game-core/client"; |
| 5 | import type { ClientDefinition } from "./types"; |
5 | import type { ClientDefinition } from "./types"; |
| 6 | −import { drawSymbol, hexColor, lighten } from "./symbol-art"; |
6 | +import { drawSymbol, hexColor } from "./symbol-art"; |
| 7 | import { formatSC } from "@spinza/shared"; |
7 | import { formatSC } from "@spinza/shared"; |
| 8 | |
8 | |
| 9 | /* --------------------------------------------------------------- helpers */ |
9 | /* --------------------------------------------------------------- helpers */ |
| 152 | const p = this.opts.def.presentation; |
152 | const p = this.opts.def.presentation; |
| 153 | const g = this.frame; |
153 | const g = this.frame; |
| 154 | g.clear(); |
154 | g.clear(); |
| 155 | − const pad = this.gap * 1.6; |
155 | + const pad = this.gap * 1.8; |
| 156 | const x = this.originX - pad; |
156 | const x = this.originX - pad; |
| 157 | const y = this.originY - pad; |
157 | const y = this.originY - pad; |
| 158 | const w = totalW + pad * 2; |
158 | const w = totalW + pad * 2; |
| 159 | const h = totalH + pad * 2; |
159 | const h = totalH + pad * 2; |
| 160 | const primary = hexColor(p.palette.primary); |
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 }; |
161 | + const secondary = hexColor(p.palette.secondary); |
| 162 | − const fc = frameColors[p.frame] ?? primary; |
162 | + const rows = this.rows; |
| 163 | − g.roundRect(x, y, w, h, 18).fill({ color: 0x000000, alpha: 0.38 }); |
163 | + const cols = this.cols; |
| 164 | − g.roundRect(x, y, w, h, 18).stroke({ color: fc, alpha: p.frame === "neon" ? 0.85 : 0.35, width: 2 }); |
164 | + switch (p.frame) { |
| 165 | − g.roundRect(x + 3, y + 3, w - 6, h - 6, 15).stroke({ color: 0xffffff, alpha: 0.06, width: 1 }); |
165 | + case "gold": { |
| 166 | − if (p.frame === "neon") g.roundRect(x - 3, y - 3, w + 6, h + 6, 21).stroke({ color: primary, alpha: 0.25, width: 6 }); |
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 | // Cell wells. |
248 | // Cell wells. |
| 168 | − for (let r = 0; r < this.cols; r++) |
249 | + for (let r = 0; r < cols; r++) |
| 169 | − for (let yy = 0; yy < this.rows; yy++) { |
250 | + for (let yy = 0; yy < 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 }); |
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 | /* ------------------------------------------------------------- backdrop */ |
255 | /* ------------------------------------------------------------- backdrop */ |
| 175 | |
256 | |
| |
257 | + /** Draw the game's world behind the reels: a static scene + a set of animated layers (see animateBackdrop). */ |
| 176 | private drawBackdrop() { |
258 | private drawBackdrop() { |
| 177 | const p = this.opts.def.presentation; |
259 | const p = this.opts.def.presentation; |
| 178 | const b = this.backdrop; |
260 | const b = this.backdrop; |
| 183 | const primary = hexColor(p.palette.primary); |
265 | const primary = hexColor(p.palette.primary); |
| 184 | const secondary = hexColor(p.palette.secondary); |
266 | const secondary = hexColor(p.palette.secondary); |
| 185 | const bg = hexColor(p.palette.bg); |
267 | const bg = hexColor(p.palette.bg); |
| 186 | − g.rect(0, 0, W, H).fill(bg); |
268 | + const surface = hexColor(p.palette.surface); |
| 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 }); |
| |
| 190 | const seed = hashString(this.opts.def.slug); |
269 | const seed = hashString(this.opts.def.slug); |
| 191 | const rnd = mulberry32(seed); |
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 | switch (p.backdrop) { |
285 | switch (p.backdrop) { |
| 193 | − case "grid": |
| |
| 194 | − case "circuit": |
| |
| 195 | case "vault": { |
286 | case "vault": { |
| 196 | − const step = Math.max(28, W / 22); |
287 | + // Rotating security rings + scanning grid. |
| 197 | − for (let x = 0; x <= W; x += step) g.moveTo(x, 0).lineTo(x, H).stroke({ color: primary, alpha: 0.06, width: 1 }); |
288 | + const grid = layer((gg) => { |
| 198 | − for (let y = 0; y <= H; y += step) g.moveTo(0, y).lineTo(W, y).stroke({ color: primary, alpha: 0.06, width: 1 }); |
289 | + const step = Math.max(30, W / 20); |
| 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 }); |
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 }); |
| 200 | − if (p.backdrop === "circuit") |
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 }); |
| 201 | − for (let i = 0; i < 12; i++) { |
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 | const x = rnd() * W; |
313 | const x = rnd() * W; |
| 203 | const y = rnd() * H; |
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 }); |
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 }); |
| 205 | − g.circle(x, y, 3).fill({ color: secondary, alpha: 0.35 }); |
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 | break; |
347 | break; |
| 208 | } |
348 | } |
| 209 | − case "nebula": |
| |
| 210 | case "universe": |
349 | case "universe": |
| 211 | case "gravity": { |
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 }); |
351 | + const stars = layer((gg) => { |
| 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 }); |
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 }); |
| 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 }); |
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 | break; |
375 | break; |
| 216 | } |
376 | } |
| 217 | − case "pillars": |
377 | + case "pillars": { |
| 218 | − case "temple": { |
| |
| 219 | const n = 6; |
378 | const n = 6; |
| 220 | for (let i = 0; i < n; i++) { |
379 | for (let i = 0; i < n; i++) { |
| 221 | const x = (W / (n + 1)) * (i + 1); |
380 | const x = (W / (n + 1)) * (i + 1); |
| 222 | − g.rect(x - 18, 0, 36, H).fill({ color: primary, alpha: 0.05 }); |
381 | + const col = layer((gg) => { |
| 223 | − g.rect(x - 26, H * 0.08, 52, 14).fill({ color: primary, alpha: 0.1 }); |
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 | break; |
397 | break; |
| 226 | } |
398 | } |
| 227 | case "aurora": { |
399 | case "aurora": { |
| 228 | − for (let k = 0; k < 4; k++) { |
400 | + for (let k = 0; k < 5; k++) { |
| 229 | − g.moveTo(0, H * (0.2 + k * 0.12)); |
401 | + const band = layer((gg) => { |
| 230 | − for (let x = 0; x <= W; x += 40) g.lineTo(x, H * (0.2 + k * 0.12) + Math.sin(x / 90 + k) * 30); |
402 | + gg.moveTo(-60, H * (0.14 + k * 0.1)); |
| 231 | − g.stroke({ color: k % 2 ? primary : secondary, alpha: 0.1, width: 26 }); |
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 | break; |
416 | break; |
| 234 | } |
417 | } |
| 235 | − case "lava": |
418 | + case "lava": { |
| 236 | − case "core": |
419 | + const cracks = layer((gg) => { |
| 237 | − case "reactor": { |
420 | + for (let i = 0; i < 14; i++) { |
| 238 | − for (let i = 0; i < 14; i++) { |
421 | + let x = rnd() * W; |
| 239 | − const x = rnd() * W; |
422 | + let y = H * 0.55 + rnd() * H * 0.45; |
| 240 | − const y = rnd() * H; |
423 | + gg.moveTo(x, y); |
| 241 | − g.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 220, y + (rnd() - 0.5) * 220).stroke({ color: primary, alpha: 0.18, width: 2 }); |
424 | + for (let k = 0; k < 5; k++) { |
| 242 | − } |
425 | + x += (rnd() - 0.5) * 120; |
| 243 | − g.circle(W / 2, H / 2, Math.min(W, H) * 0.42).stroke({ color: secondary, alpha: 0.12, width: 10 }); |
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 | break; |
438 | break; |
| 245 | } |
439 | } |
| 246 | case "skyline": { |
440 | case "skyline": { |
| 247 | − for (let x = 0; x < W; x += 30 + rnd() * 40) { |
441 | + const buildings = layer((gg) => { |
| 248 | − const h = 60 + rnd() * H * 0.45; |
442 | + for (let x = 0; x < W; x += 34 + rnd() * 40) { |
| 249 | − g.rect(x, H - h, 24 + rnd() * 40, h).fill({ color: 0x000000, alpha: 0.45 }); |
443 | + const h = 80 + rnd() * H * 0.5; |
| 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 }); |
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 | break; |
460 | break; |
| 253 | } |
461 | } |
| 254 | case "pyramid": { |
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 }); |
463 | + const pyr = layer((gg) => { |
| 256 | − g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.5, H]).fill({ color: 0x000000, alpha: 0.15 }); |
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 | break; |
478 | break; |
| 258 | } |
479 | } |
| 259 | case "arcade": { |
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 | break; |
492 | break; |
| 262 | } |
493 | } |
| 263 | case "asteroids": { |
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 }); |
495 | + const stars = layer((gg) => { |
| 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 }); |
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 | break; |
505 | break; |
| 267 | } |
506 | } |
| 268 | case "track": { |
507 | case "track": { |
| 269 | − for (let k = 0; k < 5; k++) { |
508 | + const road = layer((gg) => { |
| 270 | − g.moveTo(-50, H * 0.9 - k * 40); |
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 }); |
| 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); |
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 }); |
| 272 | − g.stroke({ color: k % 2 ? primary : 0xffffff, alpha: 0.06, width: 12 }); |
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 | break; |
546 | break; |
| 275 | } |
547 | } |
| 276 | case "abyss": { |
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 }); |
549 | + for (let i = 0; i < 4; i++) { |
| 278 | − g.rect(0, H * 0.6, W, H * 0.4).fill({ color: 0x000000, alpha: 0.35 }); |
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 | break; |
563 | break; |
| 280 | } |
564 | } |
| 281 | case "moon": { |
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 }); |
566 | + const stars = layer((gg) => { |
| 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 }); |
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 }); |
| 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 }); |
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 | break; |
609 | break; |
| 286 | } |
610 | } |
| 287 | case "minimal": { |
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 | break; |
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 | private initAmbient() { |
627 | private initAmbient() { |
| 296 | if (!this.app || this.opts.reduceMotion || this.opts.intensity === "low") return; |
628 | if (!this.app || this.opts.reduceMotion || this.opts.intensity === "low") return; |
| 297 | const p = this.opts.def.presentation; |
629 | const p = this.opts.def.presentation; |
| 349 | private buildTextures(renderer: Renderer) { |
681 | private buildTextures(renderer: Renderer) { |
| 350 | const size = 176; |
682 | const size = 176; |
| 351 | for (const s of this.opts.def.symbols) { |
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 | const tex = renderer.generateTexture({ target: art, resolution: 2, frame: new Rectangle(-size / 2, -size / 2, size, size) }); |
685 | const tex = renderer.generateTexture({ target: art, resolution: 2, frame: new Rectangle(-size / 2, -size / 2, size, size) }); |
| 354 | this.textures.set(s.id, tex); |
686 | this.textures.set(s.id, tex); |
| 355 | art.destroy({ children: true }); |
687 | art.destroy({ children: true }); |
| 447 | /** Start reels spinning (called when the request is sent). */ |
779 | /** Start reels spinning (called when the request is sent). */ |
| 448 | startSpin() { |
780 | startSpin() { |
| 449 | this.clearFx(); |
781 | this.clearFx(); |
| |
782 | + this.reelSpinning = []; |
| 450 | this.spinning = true; |
783 | this.spinning = true; |
| 451 | this.spinVel = 0; |
784 | this.spinVel = 0; |
| |
785 | + this.spinStartedAt = performance.now(); |
| 452 | } |
786 | } |
| |
787 | + private spinStartedAt = 0; |
| 453 | |
788 | |
| 454 | /** Land the given grid reel by reel. Resolves when the last reel stops. */ |
789 | /** Land the given grid reel by reel. Resolves when the last reel stops. */ |
| 455 | async landGrid(grid: string[][], opts: { minSpinMs?: number } = {}): Promise<void> { |
790 | async landGrid(grid: string[][], opts: { minSpinMs?: number } = {}): Promise<void> { |
| 459 | await this.dropIn(grid); |
794 | await this.dropIn(grid); |
| 460 | return; |
795 | return; |
| 461 | } |
796 | } |
| 462 | − const minSpin = (opts.minSpinMs ?? 500) * this.speed; |
797 | + const minSpin = (opts.minSpinMs ?? (this.opts.quick ? 380 : 780)) * this.speed; |
| 463 | − if (!this.spinning) { |
798 | + if (!this.spinning) this.startSpin(); |
| 464 | − this.startSpin(); |
799 | + // Make sure the reels have visibly spun for at least `minSpin` since the spin started. |
| 465 | − } |
800 | + const elapsed = performance.now() - this.spinStartedAt; |
| 466 | − await wait(minSpin); |
801 | + if (elapsed < minSpin) await wait(minSpin - elapsed); |
| 467 | − const stagger = (this.opts.quick ? 70 : 130) * this.speed; |
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 | for (let r = 0; r < this.cols; r++) { |
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 | await this.stopReel(r, grid[r]); |
813 | await this.stopReel(r, grid[r]); |
| 470 | this.opts.onReelStop?.(r); |
814 | this.opts.onReelStop?.(r); |
| |
815 | + if (scatterId) scattersSoFar += grid[r].filter((id) => id === scatterId).length; |
| 471 | if (r < this.cols - 1) await wait(stagger); |
816 | if (r < this.cols - 1) await wait(stagger); |
| 472 | } |
817 | } |
| 473 | this.spinning = false; |
818 | this.spinning = false; |
| 504 | this.particles.splice(i, 1); |
849 | this.particles.splice(i, 1); |
| 505 | } |
850 | } |
| 506 | } |
851 | } |
| |
852 | + // Animated backdrop layers (per game world). |
| |
853 | + this.animateBackdrop(dt); |
| 507 | // Reel spin: scroll symbols downward, wrapping with placeholder art. |
854 | // Reel spin: scroll symbols downward, wrapping with placeholder art. |
| 508 | if (this.spinning) { |
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 | const regular = this.opts.def.symbols.filter((s) => s.kind === "regular" || s.kind === "wild"); |
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 | for (let r = 0; r < this.cols; r++) { |
863 | for (let r = 0; r < this.cols; r++) { |
| 512 | if (this.reelSpinning[r] === false) continue; |
864 | if (this.reelSpinning[r] === false) continue; |
| 513 | this.reelSpinning[r] = true; |
865 | this.reelSpinning[r] = true; |
| 514 | const col = this.cells[r]; |
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 | for (const cell of col) { |
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 | if (cell.sprite.y - this.cellSize / 2 > bottom) { |
871 | if (cell.sprite.y - this.cellSize / 2 > bottom) { |
| 519 | − cell.sprite.y -= this.rows * (this.cellSize + this.gap); |
872 | + cell.sprite.y -= span; |
| 520 | − const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only |
873 | + const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only, never an outcome |
| 521 | this.swapTexture(cell, id); |
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 | private swapTexture(cell: Cell, id: string) { |
972 | private swapTexture(cell: Cell, id: string) { |
| 531 | const sp = cell.sprite.children[0] as Sprite; |
973 | const sp = cell.sprite.children[0] as Sprite; |
| 532 | sp.texture = this.textures.get(id) ?? this.textures.get("__")!; |
974 | sp.texture = this.textures.get(id) ?? this.textures.get("__")!; |
| 537 | private async stopReel(r: number, ids: string[]) { |
979 | private async stopReel(r: number, ids: string[]) { |
| 538 | this.reelSpinning[r] = false; |
980 | this.reelSpinning[r] = false; |
| 539 | const col = this.cells[r]; |
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 | col.forEach((cell, y) => { |
983 | col.forEach((cell, y) => { |
| 542 | this.swapTexture(cell, ids[y]); |
984 | this.swapTexture(cell, ids[y]); |
| 543 | (cell.sprite.children[0] as Sprite).alpha = 1; |
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 | await this.animate(dur, (t) => { |
994 | await this.animate(dur, (t) => { |
| 548 | const e = easeOutBack(t); |
995 | const e = easeOutBack(t); |
| |
996 | + flash.alpha = 1 - t; |
| 549 | col.forEach((cell, y) => { |
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 | private async dropIn(grid: string[][]) { |
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 | function polygonRandom(g: Graphics, cx: number, cy: number, r: number, rnd: () => number): Graphics { |
1341 | function polygonRandom(g: Graphics, cx: number, cy: number, r: number, rnd: () => number): Graphics { |
| 879 | const pts: number[] = []; |
1342 | const pts: number[] = []; |
| 880 | const n = 6 + Math.floor(rnd() * 4); |
1343 | const n = 6 + Math.floor(rnd() * 4); |