PageSourceSearch

https://hexbattle.io/shop-data.js?v=374

js hexbattle.io collected 2026-09-25 03:37:56 UTC 164,251 bytes, 3,585 lines download raw bytes

1// @ts-check
2// Hex Battle Infinite — Shop data + mini-preview renderers
3// Extracted from index.html on 2026-04-20 (second code-split, after vfx.js).
4//
5// Exposes as globals (classic-script lexical sharing):
6//   SHOP_ITEMS                — catalog: colors, themes, skins, victoryFx
7//   _drawMiniVfxPreview       — compact loop hinting at full-screen VFX
8//   _miniHexPath              — stroked hex path builder (72×72 previews)
9//   _drawMiniHex              — filled hex preview (themes/colors)
10//   _strokeMiniHex            — stroked hex outline
11//   _drawMiniFxPreview        — per-hex burst mini preview (skin/theme)
12//   iapPriceLabel             — localized-or-neutral price string (v155.1)
13//   legendaryTrioOdds         — computed odds table for the random bundle (v155.1)
14//   canGrantLegendaryTrio     — pre-purchase pool-size gate (v155.1)
15//   legendaryTrioOddsText     — Guideline 3.1.1 disclosure copy (v155.1)
16//
17// Depends on globals declared elsewhere:
18//   _phxDrawPhoenix   (from vfx.js — must load BEFORE this file)
19//   state read by callers: none (pure renderers)
20//
21// Load order in index.html:
22//   <script src="vfx.js"></script>          ← loads first (defines _phxDrawPhoenix, _bbRand)
23//   <script src="shop-data.js"></script>    ← THIS FILE
24//   <script> …main inline script… </script>
25//
26// When adding new shop items, update the SHOP_ITEMS object below and
27// add the matching mini-preview case in _drawMiniVfxPreview /
28// _drawMiniFxPreview. All touchpoints in the actual game (theme draw,
29// skin hex-draw branch, burst dispatch) still live in index.html.
30
31// Shop catalog — items players can buy
32const SHOP_ITEMS = {
33  colors: [
34    // Starter collection — free
35    { id:'color_skyblue', name:'Sky Blue', icon:'', preview:'#60a5fa', price:0, desc:'Clean and classic' },
36    { id:'color_pink', name:'Bubblegum', icon:'', preview:'#f472b6', price:0, desc:'Sweet and bold' },
37    { id:'color_lavender', name:'Lavender Haze', icon:'', preview:'#a78bfa', price:0, desc:'Calm and regal' },
38    // v153.63 — Renamed off-brand "Spearmint" / "Coral Reef" per the
39    // brand rules (no mint/coral, no rainbow / beach-towel naming).
40    // IDs preserved (color_mint / color_coral) so equipped-state
41    // lookups in player profiles still resolve. Display names + descs
42    // now sit inside the brand voice: confident, single-word, no
43    // flavor-text marketing copy.
44    { id:'color_mint', name:'Voltage', icon:'', preview:'#34d399', price:0, desc:'Loud and dangerous' },
45    { id:'color_gold', name:'Sunflare', icon:'', preview:'#fbbf24', price:0, desc:'Warm and radiant' },
46    { id:'color_coral', name:'Ember', icon:'', preview:'#f87171', price:0, desc:'Hot and unrelenting' },
47    // Premium collection
48    { id:'color_crimson', name:'Blood Moon', icon:'', preview:'#dc2626', price:150, badge:'new', desc:'Deep red dominance' },
49    { id:'color_electric', name:'Plasma Blue', icon:'', preview:'#2563eb', price:150, desc:'Charged with intensity' },
50    { id:'color_lime', name:'Toxic', icon:'', preview:'#84cc16', price:150, desc:'Unmissable on the board' },
51    { id:'color_ice', name:'Frost Bite', icon:'', preview:'#a5f3fc', price:200, desc:'Cold, clean, lethal' },
52    { id:'color_violet', name:'Ultraviolet', icon:'', preview:'#8b5cf6', price:250, badge:'hot', desc:'Beyond the visible spectrum' },
53    { id:'color_emerald', name:'Emerald Cut', icon:'', preview:'#059669', price:250, desc:'Rare gem-tier green' },
54    // Drop 10 — Pandas
55    { id:'color_panda_jade', name:'Bamboo Jade', icon:'', preview:'#7bbf6a', price:200, badge:'new', desc:'Soft jade green' },
56    // v153.131 — Pastel Set. Six coordinated soft tones, all 200 coins.
57    // Players who buy one usually want the rest — they're a curated drop
58    // meant to be played as a palette. Names reflect dessert/sky/garden
59    // motifs without slipping into AI-marketing prose.
60    { id:'color_cotton_candy',   name:'Cotton Candy',   icon:'', preview:'#ffb8d4', price:200, badge:'new', desc:'Sweet and soft' },
61    { id:'color_cloud_nine',     name:'Cloud Nine',     icon:'', preview:'#a9d4ff', price:200, badge:'new', desc:'
61Calm sky tone' },
62    { id:'color_mint_cream',     name:'Mint Cream',     icon:'', preview:'#b8f0d4', price:200, badge:'new', desc:'Cool and gentle' },
63    { id:'color_buttercream',    name:'Buttercream',    icon:'', preview:'#ffe9a8', price:200, badge:'new', desc:'Warm and mellow' },
64    { id:'color_lilac_dream',    name:'Lilac Dream',    icon:'', preview:'#d4c4ff', price:200, badge:'new', desc:'Dreamy lavender' },
65    { id:'color_peach_whisper',  name:'Peach Whisper',  icon:'', preview:'#ffcaa8', price:200, badge:'new', desc:'Quiet, warm peach' },
66  ],
67  // v153.53 — Theme descriptions rewritten. Pre-fix every entry ran 8-25
68  // words of marketing prose with em-dash continuation lists, the exact
69  // pattern AI copywriters fall into when asked to "make it evocative."
70  // New rule: ≤6 words, no em-dashes, no purple prose, no marketing
71  // verbs ("Forge", "Battle"). Each description is just what the player
72  // will SEE on the board.
73  themes: [
74    { id:'theme_cosmic', name:'Cosmic', icon:'🌌', price:0, desc:'The default. Stars, soft purple drift.', themeKey:'cosmic' },
75    { id:'theme_neon', name:'Neon Nights', icon:'⚡', price:300, desc:'Cyberpunk grid, scan-line glow.', themeKey:'neon' },
76    { id:'theme_ocean', name:'Deep Abyss', icon:'🌊', price:300, desc:'Underwater. Slow drift, blue light.', themeKey:'ocean' },
77    { id:'theme_forest', name:'Ancient Grove', icon:'🌲', price:300, desc:'Old-growth canopy, dappled light.', themeKey:'forest' },
78    { id:'theme_void', name:'Event Horizon', icon:'🕳️', price:400, desc:'Black hole edge. Pure dark.', themeKey:'void' },
79    { id:'theme_arctic', name:'Permafrost', icon:'❄️', price:400, desc:'Snow drift, glacier blue.', themeKey:'arctic' },
80    { id:'theme_circuit', name:'Mainframe', icon:'⚙️', price:500, badge:'new', desc:'Circuit board pulse, copper traces.', themeKey:'circuit' },
81    { id:'theme_sunset', name:'Golden Hour', icon:'🌅', price:500, desc:'Warm orange, soft horizon.', themeKey:'sunset' },
82    { id:'theme_lava', name:'Inferno Core', icon:'🌋', price:600, badge:'legendary', desc:'Volcanic. Molten cracks, ember haze.', themeKey:'lava' },
83    { id:'theme_sakura', name:'Sakura Storm', icon:'🌸', price:600, badge:'new', desc:'Cherry blossom petals, plum sky.', themeKey:'sakura' },
84    { id:'theme_synthwave', name:'Synthwave', icon:'🌆', price:500, badge:'new', desc:'80s neon grid, retro pink sun.', themeKey:'synthwave' },
85    { id:'theme_bioluminescent', name:'Bioluminescent', icon:'🦑', price:600, badge:'legendary', desc:'Deep-sea glow. Drifting jellyfish.', themeKey:'bioluminescent' },
86    // v153.130 — Deep Sea was previously orphaned (renderer existed in
87    // themes.js but no shop entry, so it could never be unlocked).
88    { id:'theme_deepsea', name:'Deep Sea', icon:'🌊', price:600, badge:'legendary', desc:'Bioluminescent shafts pierce the dark.', themeKey:'deepsea' },
89    { id:'theme_gilded_tomb', name:'Gilded Tomb', icon:'🏛️', price:600, badge:'legendary', desc:'Egyptian dusk. Pyramids, sand haze.', themeKey:'gilded_tomb' },
90    { id:'theme_tempest_sky', name:'Tempest Sky', icon:'⚡', price:600, badge:'legendary', desc:'Live thunderstorm. Rain, lightning.', themeKey:'tempest_sky' },
91    { id:'theme_clockwork_forge', name:'Clockwork Forge', icon:'⚙️', price:600, badge:'legendary', desc:'Steampunk gears, brass and copper.', themeKey:'clockwork_forge' },
92    { id:'theme_singularity', name:'Singularity', icon:'🌀', price:700, badge:'legendary', desc:'Black hole. Stars streak inward.', themeKey:'singularity' },
93    { id:'theme_aurora_veil', name:'Aurora Veil', icon:'🌌', price:700, badge:'legendary', desc:'Arctic sky. Aurora ribbons drift.', themeKey:'aurora_veil' },
94    { id:'theme_champions_hall', name:"Champion's Hall", icon:'🏆', price:700, badge:'legendary', desc:'Trophy hall. Navy, gold radiance.', themeKey:'champions_hall' },
95    { id:'theme_bamboo_grove', name:'Bamboo Grove', icon:'🎋', price:700, badge:'legendary', desc:'Misty dawn forest. Jade stalks.', themeKey:'bamboo_grove' },
96    // v153.132 — Pastel Set capstone theme.
97    { id:'theme_pastel_dreams', name:'Pastel Dreams', icon:'☁️', price:600, badge:'new', desc:'Dawn sky. Soft bokeh, pearly hexes.', themeKey:'pastel_dreams' },
98  ],
99  // v91 — Placement Effects category removed (per-hex ambient particles were the main perf killer).
100  // Empty array keeps the SHOP_ITEMS shape intact for any code that still iterates over it.
101  effects: [],
102  // v153.53 — Skin descriptions tightened. Same rule as themes: ≤8 words,
103  // no em-dashes, no marketing voice. State what the hex looks like.
104  skins: [
105    { id:'skin_default', name:'Classic', icon:'⬡', price:0, desc:'The default hex.' },
106    { id:'skin_gold', name:'24 Karat', icon:'👑', price:800, badge:'legendary', desc:'Molten gold with sparkle aura.' },
107    { id:'skin_neon_glow', name:'Cyberstrike', icon:'💎', price:700, badge:'hot', desc:'Electric arcs and pulsing glow.' },
108    { id:'skin_crystal', name:'Prismatic', icon:'🔮', price:600, desc:'Refracting light bands and sparkles.' },
109    { id:'skin_pixel', name:'Glitch', icon:'👾', price:500, desc:'Retro scanlines, CRT pixel grid.' },
110    { id:'skin_gradient', name:'Aurora', icon:'🌈', price:900, badge:'legendary', desc:'Flowing rainbow gradient border.' },
111    { id:'skin_obsidian', name:'Obsidian', icon:'🗿', price:450, badge:'new', desc:'Volcanic black stone, gold seams.' },
112    { id:'skin_plasma', name:'Plasma Core', icon:'🔮', price:850, badge:'legendary', desc:'Glass shell, rotating plasma inside.' },
113    { id:'skin_origami', name:'Origami', icon:'🎴', price:350, badge:'new', desc:'Folded paper facets, matte violet.' },
114    { id:'skin_magma', name:'Magma', icon:'🌋', price:750, badge:'legendary', desc:'
114Cracked obsidian, pulsing molten veins.' },
115    { id:'skin_circuit_board', name:'Circuit Board', icon:'🖥️', price:700, badge:'new', desc:'PCB substrate with rotating chip core.' },
116    { id:'skin_galaxy', name:'Galaxy', icon:'🌌', price:900, badge:'legendary', desc:'Tiny cosmos. Spiral arms, stars.' },
117    { id:'skin_stained_glass', name:'Stained Glass', icon:'🪟', price:750, badge:'legendary', desc:'Cathedral window. Jewel panels, gold caming.' },
118    { id:'skin_storm_core', name:'Storm Core', icon:'⛈️', price:750, badge:'legendary', desc:'Living thundercloud with electric eye.' },
119    { id:'skin_brass_cog', name:'Brass Cog', icon:'⚙️', price:750, badge:'legendary', desc:'Brass plate, rotating cog, rivets.' },
120    { id:'skin_gravity_well', name:'Gravity Well', icon:'🌀', price:800, badge:'legendary', desc:'Tiny black hole. Accretion disc spins.' },
121    { id:'skin_aurora_ribbon', name:'Aurora Ribbon', icon:'🌈', price:800, badge:'legendary', desc:'Arctic sky. Aurora ribbons drift.' },
122    { id:'skin_laurel_crown', name:'Laurel Crown', icon:'👑', price:800, badge:'legendary', desc:"Champion's medal. Gold plate, laurel wreath." },
123    { id:'skin_panda_face', name:'Panda', icon:'🐼', price:800, badge:'legendary', desc:'Tiny panda face. Drowsy cute energy.' },
124    // v153.132 — Pastel Set capstone skin. Glossy candy-glazed hex with
125    // pearly highlight + bright wet-dot.
126    { id:'skin_pastel_glaze', name:'Pastel Glaze', icon:'☁️', price:800, badge:'new', desc:'Candy-glazed hex with pearly highlight.' },
127  ],
128  // v88 — Victory FX: the full-screen celebration that plays when a game ends.
129  // Fires for BOTH winner AND loser (so losers see why they lost and the
130  // Results screen feels like a proper outro). Default = confetti.
131  victoryFx: [
132    // v153.51 — Hexburst is the new default. On-brand replacement for
133    // generic confetti. Brand-color hex shower + ring pulses + flare.
134    // v153.53 — VFX descriptions tightened. ≤8 words, no em-dashes, no
135    // marketing prose. Players see what they get without the AI poetry.
136    { id:'vfx_hexburst', name:'Hex Burst', price:0, desc:'Brand-color hex shower with ring pulses.' },
137    { id:'vfx_confetti', name:'Classic Confetti', icon:'🎉', price:0, desc:'Rainbow paper shower.' },
138    { id:'vfx_fireworks', name:'Fireworks', icon:'🎆', price:400, badge:'new', desc:'Multi-burst shells paint the sky.' },
139    { id:'vfx_petals', name:'Hex Petals', icon:'🌸', price:450, desc:'Soft pastel hex shards drift down.' },
140    { id:'vfx_golden_rain', name:'Golden Rain', icon:'💰', price:500, badge:'new', desc:'Cascading gold streaks.' },
141    { id:'vfx_shockwave', name:'Shockwave Cascade', icon:'💥', price:550, badge:'new', desc:'Rings detonate, cracked-glass shards fly.' },
142    { id:'vfx_nebula', name:'Nebula Wave', icon:'🌌', price:600, badge:'legendary', desc:'Cosmic gradient pulse fills the arena.' },
143    { id:'vfx_nebula_bloom', name:'Nebula Bloom', icon:'✨', price:650, badge:'legendary', desc:'Color clouds bloom, drifting stars.' },
144    { id:'vfx_lasershow', name:'Laser Show', icon:'💫', price:700, badge:'legendary', desc:'Neon lasers sweep across the board.' },
145    { id:'vfx_lightning_chain', name:'Lightning Chain', icon:'⚡', price:800, badge:'legendary', desc:'Electric arcs chain hex-to-hex.' },
146    { id:'vfx_bloom_burst', name:'Bloom Burst', icon:'🌸', price:550, badge:'new', desc:'Flowers erupt and tumble. Petal rain.' },
147    { id:'vfx_phoenix_rise', name:'Phoenix Rise', icon:'🔥', price:850, badge:'legendary', desc:'Flaming phoenixes rise from each hex.' },
148    { id:'vfx_coin_cascade', name:'Coin Cascade', icon:'🪙', price:500, badge:'new', desc:'Spinning coins rain, bounce, stack.' },
149    { id:'vfx_kaiju_stomp', name:'Kaiju Stomp', icon:'🦖', price:900, badge:'legendary', desc:'A giant foot slams. Cracks spider out.' },
150    { id:'vfx_dragon_sweep', name:'Dragon Sweep', icon:'🐉', price:950, badge:'legendary', desc:'A dragon torches the winning line.' },
151    { id:'vfx_thunderstrike', name:'Thunderstrike', icon:'⚡', price:950, badge:'legendary', desc:'Six forked bolts smash the winning line.' },
152    { id:'vfx_boiler_burst', name:'Boiler Burst', icon:'💥', price:950, badge:'legendary', desc:'Steampunk boilers detonate. Brass shrapnel.' },
153    // Drop 7 — Singularity Collapse: swarm particles are pulled into each winning hex from the arena edges,
154    // collapse inward through a vortex, implode into a white-out flash, then detonate in a rainbow nova
155    // with triple shockwave rings, gravitational lensing beams, and drifting debris during fade.
156    { id:'vfx_singularity_collapse', name:'Singularity Collapse', icon:'🌀', price:1100, badge:'legendary', desc:'Particles vortex inward, implode, rainbow nova.' },
157    // Drop 8 — Aurora Sweep: a slow atmospheric celebration — 5 undulating aurora ribbons in the winner's color (blended with green/teal/violet) drift horizontally across the full screen over ~5 seconds. No shake, no burst — the pure sweep IS the celebration. Per-hex bursts rise as soft aurora wisps from each winning cell.
158    { id:'vfx_aurora_sweep', name:'Aurora Sweep', icon:'✨', price:1100, badge:'legendary', desc:'Five aurora ribbons drift across the screen.' },
159    // Drop 9 — Champion's Announce: the WINNER moment. Six letters of "WINNER" slam in from offscreen one at a time with brief impact hits, rest center-screen glowing, then shatter into gold flakes + a confetti burst in the winner's color. Typography hero mechanic — completely new motion.
160    { id:'vfx_champions_announce', name:'Champion\u2019s Announce', icon:'🏆', price:1100, badge:'legendary', desc:"WINNER slams in. Letters shatter to flakes." },
161    // Drop 10 — Panda Stampede: pure adorable celebration. Plump panda silhouettes tumble-roll across the screen, bamboo shoots spring up from every winning hex with leaves unfurling at the top, jade leaves shower down. Cute energy, not cinematic — the comedic counterpoint to the explosive drops.
162    { id:'vfx_panda_stampede', name:'Panda Stampede', icon:'🐼', price:1100, badge:'legendary', desc:"Pandas tumble-roll. Bamboo, jade leaves." },
163    // v153.132 — Pastel Set capstone VFX. Soft 3.5s celebration:
164    // pastel rings bloom, hex petals drift, pearly halo pulses.
165    { id:'vfx_pastel_bloom', name:'Pastel Bloom', icon:'☁️', price:600, badge:'new', desc:'Soft pastel rings bloom. Hex petals drift.' },
166  ],
167  // v153.136 — Lobby Backgrounds. Animated home-screen designs that play
168  // behind every lobby surface. Equipping one calls setMenuBgVariant()
169  // to swap the active variant in MENU_BG_VARIANTS. Two free starters
170  // (Live Match = the simgame default, Drift = the OG sparse variant)
171  // and two premium drops on launch (Constellation, Pulse Grid). All
172  // variants honor the active theme's palette + ambient particles via
173  // _menuBgThemeColors() so they layer cleanly with the rest of the
174  // theme system. Each item's `variantId` field maps to the registry key.
175  lobbyBgs: [
176    { id:'lobbybg_live_match',   name:'Live Match',    icon:'⬡',  price:0,   variantId:'simgame',       desc:'A simulated game playing out behind you.' },
177    { id:'lobbybg_drift',        name:'Drift',         icon:'·',  price:0,   variantId:'sparse',        desc:'Sparse hexes. Quiet and minimal.' },
178    { id:'lobbybg_constellation',name:'Constellation', icon:'✦',  price:500, badge:'new',         variantId:'constellation', desc:'Stars connected by faint constellation lines.' },
179    { id:'lobbybg_pulse_grid',   name:'Pulse Grid',    icon:'◉',  price:500, badge:'new',         variantId:'pulse_grid',    desc:'Hex grid breathing in radial pulses.' },
180    { id:'lobbybg_aurora_streams', name:'Aurora Streams', icon:'≋', price:600, badge:'new', variantId:'aurora_streams', desc:'Northern-lights ribbons drift across the sky.' },
181  ],
182  // v121 — Taunts: curated animated emotes players fling at opponents mid-game.
183  // Broadcast via Firebase RTDB (hexbattle/<room>/taunts). Opponent sees a
184  // corner bubble animate in for ~2s, then fade. Anti-spam on the send side.
185  // Fields: id, name, icon (emoji), anim (CSS keyframe key), label (optional
186  // 1-2 word tagline), price (0 = free starter), badge (optional).
187  taunts: [
188    // ── Free starters (everyone) ──
189    { id:'taunt_gg',     name:'GG',          icon:'👏', anim:'bounce',  label:'GG',          price:0,   desc:"Classy exit or cheeky jab." },
190    { id:'taunt_laugh',  name:'LOL',         icon:'😂', anim:'shake',   label:'LOL',         price:0,   desc:'Respectful laughter (mostly)' },
191    { id:'taunt_fire',   name:'On Fire',     icon:'🔥', anim:'pulse',   label:'HOT',         price:0,   desc:'Let them know the heat is up' },
192    { id:'taunt_think',  name:'Thinking',    icon:'🤔', anim:'rotate',  label:'Hmm…',        price:0,   desc:'Passive-aggressive pondering' },
193    // ── Tier 1 · 250 coins ──
194    { id:'taunt_shock',  name:'No Way!',     icon:'😱', anim:'shake',   label:'WHAT?!',      price:250, badge:'new',      desc:'Fake your outrage, watch them doubt themselves' },
195    { id:'taunt_crying', name:'Sad',         icon:'😭', anim:'drip',    label:'Noooo',       price:250,                  desc:'Tears of joy. Their tears.' },
196    { id:'taunt_flex',   name:'Flex',        icon:'💪', anim:'pulse',   label:'FLEX',        price:250, badge:'hot',      desc:'Remind them who built this arena' },
197    { id:'taunt_shrug',  name:'Shrug',       icon:'🤷', anim:'sway',    label:'Oh well',     price:250,                  desc:'Plausible deniability at scale' },
198    // ── Tier 2 · 400 coins ──
199    { id:'taunt_boom',   name:'Boom',        icon:'💥', anim:'explode', label:'BOOM',        price:400, badge:'new',      desc:'Celebrate a kill shot with a bang' },
200    { id:'taunt_crown',  name:'King Me',     icon:'👑', anim:'drop',    label:'KING',        price:400,                  desc:'Claim the throne out loud' },
201    { id:'taunt_ez',     name:'Too Easy',    icon:'😎', anim:'slide',   label:'Too Easy',    price:400, badge:'hot',      desc:'Shades engaged, stakes low' },
202    { id:'taunt_rekt',   name:'REKT',        icon:'💀', anim:'shake',   label:'REKT',        price:400,                  desc:'Shakes the opponent\u2019s screen. Politely.' },
203    // ── Tier 3 · 600 coins · legendary ──
204    { id:'taunt_mic_drop',   name:'Mic Drop',     icon:'🎤', anim:'mic',   label:'Mic Drop',  price:600, badge:'legendary', desc:"Drops from the top with a thud." },
205    { id:'taunt_chef_kiss',  name:'Chef\u2019s Kiss', icon:'🤌', anim:'kiss',  label:'Molto bene',price:600, badge:'legendary', desc:'When the move is simply *magnifique*' },
206    { id:'taunt_dance',      name:'Victory Dance', icon:'🕺', anim:'dance', label:'Dance',     price:600, badge:'legendary', desc:'Pure celebratory chaos. Loops until fade.' },
207  ]
208};
209
210// v154.45 — DUAL-CURRENCY IAP CATALOG (Coins + Gems).
211//
212// Coins  = soft currency. Earned through play. Buys ~102 existing cosmetics.
213// Gems   = hard currency. Bought with real money (or dripped via daily
214//          login / achievements). Buys premium Mythic cosmetics, bundles,
215//          boosters, and via coin packs converts to coins.
216//
217// Product IDs map 1:1 to App Store Connect. FROZEN once published — don't
218// rename, only add/deprecate. The IAP module (iap.js) reads this catalog
219// to render the shop UI; purchases route through RevenueCat (or native
220// StoreKit fallback), which validates receipts with Apple before
221// crediting the player's account. See MONETIZATION.md for the strategy.
222const IAP_BUNDLES = {
223  // 6 gem tiers — value-per-dollar scales up so bigger spends feel like
224  // a better deal. The $0.99 entry is intentionally the LOWEST friction
225  // pain point a player can convert at; first-buy conversion is the
226  // hardest step in the funnel, so the cheap door matters.
227  gems: [
228    { id:'hexbattle.gems.starter',  productId:'hexbattle.gems.starter',  gems:80,   priceUsd:'0.99',  label:'Starter',  badge:null,             desc:'80 Gems. Your first taste.' },
229    { id:'hexbattle.gems.handful',  productId:'hexbattle.gems.handful',  gems:250,  priceUsd:'2.99',  label:'Handful',  badge:null,             desc:'250 Gems. Most buys land here.' },
230    { id:'hexbattle.gems.stack',    productId:'hexbattle.gems.stack',    gems:500,  priceUsd:'4.99',  label:'Stack',    badge:'best_value',     desc:'500 Gems. Sweet spot.' },
231    { id:'hexbattle.gems.crate',    productId:'hexbattle.gems.crate',    gems:1100, priceUsd:'9.99',  label:'Crate',    badge:null,             desc:'1,100 Gems. Ready to commit.' },
232    { id:'hexbattle.gems.vault',    productId:'hexbattle.gems.vault',    gems:2500, priceUsd:'19.99', label:'Vault',    badge:'most_popular',   desc:'2,500 Gems. Stocked up.' },
233    { id:'hexbattle.gems.treasury', productId:'hexbattle.gems.treasury', gems:7000, priceUsd:'49.99', label:'Treasury', badge:'whale',          desc:'7,000 Gems. Whale tier — full vault.' },
234  ],
235  // One-time premium offers. Each grants gems + cosmetics + perks per the
236  // grants/perks shape consumed by iap.js _iapGrantEntitlements.
237  premium: [
238    {
239      id:'hexbattle.bundle.starter',
240      productId:'hexbattle.bundle.starter',
241      label:'Starter Pack',
242      priceUsd:'4.99',
243      gems:500,
244      grants:{
245        skins:['skin_crystal','skin_magma','skin_obsidian'],
246        victoryFx:['vfx_supernova'],
247      },
248      badge:'one_time',
249      validityDays:7,
250      desc:'500 Gems + 3 legendary skins + a legendary Victory FX. Available for 7 days only.',
251    },
252    {
253      id:'hexbattle.founder.lifetime',
254      productId:'hexbattle.founder.lifetime',
255      label:'Founders Pass',
256      priceUsd:'9.99',
257      gems:500,
258      grants:{
259        skins:['skin_founder'],
260        avatarFrames:['frame_founder'],
261      },
262      perks:{
263        coinBonusPct:25,
264        gemBonusPct:10,      // v154.45 — Founders also get +10% gems on IAP purchases
265        badge:'founder',
266      },
267      badge:'lifetime',
268      supplyCap:5000,
269      desc:'Lifetime Founder status. +25% coins on every win + 10% extra Gems on every IAP, forever. Exclusive Founder skin + frame. First 5,000 buyers only.',
270    },
271    // v154.49 — PULLED: Remove Ads SKU. The audit caught that the game ships
272    // ZERO ads anywhere — selling removal of a thing that doesn't exist is
273    // App Store metadata fraud (§3.1.1). When rewarded ads ship (AdMob/Unity),
274    // restore this SKU. Until then, the noAds perk is meaningless.
275    // {
276    //   id:'hexbattle.removeAds.lifetime',
277    //   productId:'hexbattle.removeAds.lifetime',
278    //   label:'Remove Ads',
279    //   priceUsd:'2.99',
280    //   gems:0,
281    //   grants:{},
282    //   perks:{ noAds:true },
283    //   badge:'utility',
284    //   desc:'Permanently remove rewarded-ad prompts. One-time purchase.',
285    // },
286  ],
287};
288
289// v154.45 — GEM SHOP CATALOG.
290//
291// Items purchasable with GEMS only (never with coins, never with real
292// money directly). Three sections:
293//   • mythics — premium-exclusive cosmetics, brand-new "Mythic" tier
294//   • bundles — curated multi-item packs at gem discount
295//   • coinPacks — convert gems to coins
296//   • boosters — time-limited consumable perks
297//
298// IMPORTANT: Mythic IDs use the `mythic_` / `vfx_mythic_` / etc. prefix
299// so they're trivially filterable from coin-shop renders that grep
300// SHOP_ITEMS for prefix patterns.
301const GEM_SHOP_ITEMS = {
302  mythics: [
303    // Hex skins — gem-only Mythic tier
304    { id:'mythic_cosmic_drift',    category:'skins',     name:'Cosmic Drift',    gems:250, badge:'mythic', desc:'Drifting particles, never the same twice.' },
305    { id:'mythic_auroral_pulse',   category:'skins',     name:'Auroral Pulse',   gems:250, badge:'mythic', desc:'Ribbons of light, slow-pulse aurora.' },
306    { id:'mythic_singularity',     category:'skins',     name:'Singularity',     gems:350, badge:'mythic', desc:'A collapsing point of dark energy.' },
307    // Victory FX
308    { id:'vfx_mythic_phoenix',     category:'victoryFx', name:'Phoenix Rebirth', gems:300, badge:'mythic', desc:'Risen from the ashes of your last loss.' },
309    { id:'vfx_mythic_galactic',    category:'victoryFx', name:'Galactic Bloom',  gems:350, badge:'mythic', desc:'A spiral galaxy unfurls over the board.' },
310    // Themes — v154.49: marked comingSoon. Pre-fix the THEMES registry
311    // had no entries for these IDs, so equipping silently fell back to
312    // the free 'cosmic' theme. Players paid 200 gems for nothing.
313    // RE-ENABLE when themes.js gets dedicated quantum/nebula renderers.
314    { id:'theme_mythic_quantum',   category:'themes',    name:'Quantum',         gems:200, badge:'mythic', desc:'Probability haze. Each frame is a maybe.',     comingSoon:true },
315    { id:'theme_mythic_nebula',    category:'themes',    name:'Nebula',          gems:200, badge:'mythic', desc:'Drifting deep-space color fields.',           comingSoon:true },
316    // Avatar frames — v154.49: marked comingSoon. There is currently no
317    // FRAME_REGISTRY and no avatar/HUD code that reads equipped.avatarFrames.
318    // RE-ENABLE when the frame renderer ships.
319    { id:'frame_gold',             category:'avatarFrames', name:'Gold Frame',   gems:150, badge:'mythic', desc:'A glowing gold ring around your avatar.',     comingSoon:true },
320    { id:'frame_diamond',          category:'avatarFrames', name:'Diamond Frame',gems:200, badge:'mythic', desc:'Refractive diamond, catches every angle.',    comingSoon:true },
321    // Premium taunts — v154.49: wired properly into the picker via the new
322    // _premiumTaunts() merge in taunts.js. Each carries icon + anim so the
323    // bubble actually renders distinctly. Pro GG uses an applause clap +
324    // emphasis bounce; Strut uses a wave + spin combo via CSS.
325    { id:'taunt_premium_gg',       category:'taunts',    name:'Pro GG',          gems:80,  badge:'mythic', desc:'A respectful "good game" with style.',        icon:'👏', anim:'pulse', label:'GG' },
326    { id:'taunt_premium_dance',    category:'taunts',    name:'Victory Strut',   gems:120, badge:'mythic', desc:'Smooth animated victory dance.',             icon:'💃', anim:'spin',  label:'Strut' },
327    // Profile flair — v154.49: marked comingSoon. No profileFlair renderer
328    // wired into the name/HUD anywhere. RE-ENABLE once flair shows up next
329    // to the player name in the lobby + HUD + leaderboard.
330    { id:'flair_crown',            category:'profileFlair', name:'Crown Tag',    gems:100, badge:'mythic', desc:'A small crown next to your name everywhere.', comingSoon:true },
331  ],
332  bundles: [
333    {
334      id:'bundle_legendary_trio',
335      name:'Legendary Trio',
336      gems:400,
337      // ── v155.1 — LOOT-BOX ODDS DISCLOSURE (audit-2026-08 ship-blocker #3 /
338      //    UX_FLOW_AUDIT RISK 1, App Store Guideline 3.1.1) ──────────────
339      //
340      // This bundle is a randomised-item purchase paid for with a currency
341      // bought for real money, which makes it a loot box under 3.1.1
342      // whatever we call it in the marketing copy:
343      //   "Apps offering 'loot boxes' or other mechanisms that provide
344      //    randomized virtual items for purchase must disclose the odds of
345      //    receiving each type of item to customers prior to purchase."
346      // It shipped with NO odds anywhere in the UI, while MONETIZATION.md
347      // simultaneously claimed the game had no loot boxes. Both are fixed:
348      // the doc now names this bundle, and `desc` below is a live getter so
349      // the odds render on the shop tile itself (the gem-bundle tile has no
350      // detail sheet — `b.desc` at index.html:26044 is the only copy slot,
351      // so the disclosure has to live there to reach the player).
352      //
353      // The old copy also claimed "~33% off vs gems-buying separately."
354      // That comparison cannot be substantiated: the pool is COIN-shop
355      // items with no gem price at all, so there is no "buying separately"
356      // gem figure to discount from. Removed rather than re-derived.
357      //
358      // `randomBadge` makes the pool filter data-driven. index.html:26360
359      // currently hardcodes `i.badge === 'legendary'`; the helpers below
360      // read this field so the odds and the grant can never disagree about
361      // what the pool is (see PHASE0_SHOP.md for the call-site change).
362      get desc() { return legendaryTrioOddsText(); },
363      // Resolved at purchase time — picks 3 random items the player doesn't own.
364      // MUST be gated by canGrantLegendaryTrio() BEFORE spendGems(): a player
365      // holding every legendary used to pay 400 gems, receive nothing, and get
366      // a success toast (UX_FLOW_AUDIT finding #6).
367      randomFrom:{ skins:3 },
368      randomBadge:'legendary',
369    },
370    {
371      id:'bundle_mythic_starter',
372      name:'Mythic Starter',
373      gems:600,
374      desc:'2 Mythic skins + 1 Mythic Victory FX. ~25% off the gem-sum.',
375      grants:{
376        skins:['mythic_cosmic_drift','mythic_auroral_pulse'],
377        victoryFx:['vfx_mythic_phoenix'],
378      },
379    },
380  ],
381  coinPacks: [
382    { id:'coinpack_small',  gems:50,  coins:500,    label:'Small',  desc:'500 Coins.' },
383    { id:'coinpack_medium', gems:150, coins:1700,   label:'Medium', desc:'1,700 Coins.' },
384    { id:'coinpack_large',  gems:400, coins:5000,   label:'Large',  desc:'5,000 Coins.' },
385    { id:'coinpack_mega',   gems:900, coins:12000,  label:'Mega',   desc:'12,000 Coins. Best rate.' },
386  ],
387  boosters: [
388    { id:'booster_2x_coins_24h', gems:100, durationHours:24, effect:'coinMultiplier:2', label:'Double Coins · 24h', desc:'2× coins from every win for 24 hours.' },
389    { id:'booster_2x_xp_48h',    gems:150, durationHours:48, effect:'xpMultiplier:2',   label:'Double XP · 48h',    desc:'2× XP from every game for 48 hours.' },
390    { id:'booster_chest_instant',gems:50,  durationHours:0,  effect:'chestRefresh:1',   label:'Instant Daily Chest',desc:'Skip the 24h cooldown — open your chest now.' },
391  ],
392};
393
394// ═══════════════════════════════════════════════════════════════════
395// v155.1 — RANDOMISED-BUNDLE ODDS + POOL GATE
396// ═══════════════════════════════════════════════════════════════════
397// Addresses audit-2026-08 MASTER_UPGRADE_PLAN ship-blocker #3 and
398// UX_FLOW_AUDIT findings #6 / RISK 1.
399//
400// TWO separate bugs live in the same 12 lines of index.html (:26356-26366):
401//
402//   1. No odds are disclosed anywhere. Guideline 3.1.1 requires them for
403//      any randomised item bought with real money (gems are).
404//   2. No pool-size pre-check. `spendGems()` runs first, THEN the loop
405//      `for (let i = 0; i < count && pool.length; i++)` quietly does
406//      nothing when the pool is empty — and the success toast fires
407//      regardless. A completionist pays 400 gems for zero items and is
408//      told it worked.
409//
410// The maths is deliberately boring, which is the point — a disclosable
411// mechanic beats a clever one. The draw is:
412//   • uniform (no rarity weighting, no pity timer, no server roll)
413//   • WITHOUT replacement (`pool.splice`) so duplicates are impossible
414//   • over the UNOWNED subset only (`!isItemOwned`)
415//
416// For a uniform draw of k items without replacement from a pool of n,
417// P(any given pool item is in the result) = k / n. That is the number
418// we publish. With the current catalog: 12 legendary skins, k = 3 →
419// 25.0% each. Owning items shrinks n and RAISES the odds on the rest,
420// which is why the disclosure is computed live rather than hardcoded —
421// a hardcoded "25%" would go stale the day a 13th legendary skin ships.
422//
423// These helpers are pure w.r.t. the catalog and take ownership as a
424// parameter so they can be unit-tested without a browser.
425
426/** Bundle id of the only randomised product in the catalog. */
427const LEGENDARY_TRIO_ID = 'bundle_legendary_trio';
428
429/**
430 * Normalise the many shapes ownership arrives in into a predicate.
431 * Accepts: a predicate fn, an id→truthy map (`_myPurchases`), an array
432 * of ids, or nothing (falls back to the global `isItemOwned` when the
433 * game is loaded, else "owns nothing" for tests).
434 * @param {any} owned
435 * @returns {(id:string)=>boolean}
436 */
437function _shopOwnedPredicate(owned) {
438  if (typeof owned === 'function') return owned;
439  if (Array.isArray(owned)) {
440    const ids = owned.slice();
441    return function (id) { return ids.indexOf(id) >= 0; };
442  }
443  if (owned && typeof owned === 'object') {
444    return function (id) { return !!owned[id]; };
445  }
446  if (typeof isItemOwned === 'function') return isItemOwned;
447  return function () { return false; };
448}
449
450/**
451 * Read the draw spec off the catalog entry so the odds, the disclosure
452 * and the grant all derive from ONE source. Returns null if the bundle
453 * was pulled from the catalog.
454 */
455function _legendaryTrioSpec() {
456  const list = (typeof GEM_SHOP_ITEMS === 'object' && GEM_SHOP_ITEMS && GEM_SHOP_ITEMS.bundles) || [];
457  let bundle = null;
458  for (let i = 0; i < list.length; i++) {
459    if (list[i] && list[i].id === LEGENDARY_TRIO_ID) { bundle = list[i]; break; }
460  }
461  if (!bundle || !bundle.randomFrom) return null;
462  const category = Object.keys(bundle.randomFrom)[0];
463  if (!category) return null;
464  return {
465    bundle: bundle,
466    category: category,
467    drawCount: Number(bundle.randomFrom[category]) || 0,
468    badge: bundle.randomBadge || 'legendary',
469  };
470}
471
472/**
473 * The full odds table. This is the object the disclosure copy, the
474 * purchase gate and any future review-notes screenshot all read from.
475 *
476 * @param {any} [owned] - predicate / map / array; omit to use the live player
477 * @returns {null | {
478 *   bundleId: string, category: string, drawCount: number,
479 *   catalogPool: number, availablePool: number, perItemPct: number,
480 *   canDraw: boolean,
481 *   items: Array<{id:string,name:string,pct:number}>,
482 *   catalogItems: Array<{id:string,name:string,owned:boolean}>
483 * }}
484 */
485function legendaryTrioOdds(owned) {
486  const spec = _legendaryTrioSpec();
487  if (!spec) return null;
488  const isOwnedFn = _shopOwnedPredicate(owned);
489  const catalog = ((typeof SHOP_ITEMS === 'object' && SHOP_ITEMS && SHOP_ITEMS[spec.category]) || [])
490    .filter(function (i) { return i && i.badge === spec.badge; });
491  const available = catalog.filter(function (i) { return !isOwnedFn(i.id); });
492  const n = available.length;
493  // Uniform draw without replacement: every item in the pool has an equal
494  // k/n chance of landing in the result set. Clamp k so a short pool
495  // reports the truth (100% each) rather than >100%.
496  const k = Math.min(spec.drawCount, n);
497  const perItemPct = n > 0 ? (k / n) * 100 : 0;
498  return {
499    bundleId: LEGENDARY_TRIO_ID,
500    category: spec.category,
501    drawCount: spec.drawCount,
502    catalogPool: catalog.length,
503    availablePool: n,
504    perItemPct: perItemPct,
505    canDraw: n >= spec.drawCount && spec.drawCount > 0,
506    items: available.map(function (i) { return { id: i.id, name: i.name, pct: perItemPct }; }),
507    catalogItems: catalog.map(function (i) { return { id: i.id, name: i.name, owned: isOwnedFn(i.id) }; }),
508  };
509}
510
511/**
512 * PRE-PURCHASE GATE. Call this BEFORE spendGems(). `ok:false` means the
513 * charge must not happen — `message` is player-facing and explains what
514 * to do instead.
515 *
516 * @param {any} [owned]
517 * @returns {{ok:boolean, poolSize:number, drawCount:number, reason:string, message:string}}
518 */
519function canGrantLegendaryTrio(owned) {
520  const o = legendaryTrioOdds(owned);
521  if (!o) {
522    return { ok: false, poolSize: 0, drawCount: 0, reason: 'no-bundle',
523             message: 'This bundle isn’t available right now.' };
524  }
525  if (o.canDraw) {
526    return { ok: true, poolSize: o.availablePool, drawCount: o.drawCount, reason: '', message: '' };
527  }
528  const n = o.availablePool;
529  const message = n === 0
530    ? 'You already own every Legendary skin — this bundle has nothing left to give you.'
531    : 'Only ' + n + ' Legendary skin' + (n === 1 ? '' : 's') + ' left to unlock, and the Legendary Trio draws '
532      + o.drawCount + '. Grab the last ' + (n === 1 ? 'one' : n + '') + ' with Coins instead.';
533  return { ok: false, poolSize: n, drawCount: o.drawCount, reason: 'pool-too-small', message: message };
534}
535
536/**
537 * Guideline 3.1.1 disclosure copy. Rendered on the shop tile via the
538 * `desc` getter on the catalog entry, so it is visible PRIOR TO
539 * PURCHASE with no extra tap — which is what the guideline asks for.
540 *
541 * Percentages are formatted to one decimal and only when they aren't
542 * whole, so the common case reads "25%" not "25.0%".
543 * @param {any} [owned]
544 */
545function legendaryTrioOddsText(owned) {
546  const o = legendaryTrioOdds(owned);
547  if (!o) return '';
548  const noun = o.category === 'skins' ? 'Legendary hex skin' : 'Legendary item';
549  if (!o.canDraw) {
550    // Short pool — say so plainly instead of advertising a draw that
551    // cannot happen. The tile CTA is disabled in this state too.
552    return o.availablePool === 0
553      ? 'You own every ' + noun + ' — nothing left for this bundle to draw. Unavailable.'
554      : 'Needs ' + o.drawCount + ' unowned ' + noun + 's to draw from; you have ' + o.availablePool + '. Unavailable.';
555  }
556  const pct = _fmtPct(o.perItemPct);
557  return o.drawCount + ' ' + noun + 's, drawn at random from the Coin shop. '
558    + 'Odds: ' + o.availablePool + ' ' + noun + 's are in your pool, each with an equal '
559    + pct + ' chance (' + o.drawCount + ' in ' + o.availablePool + '). '
560    + 'No duplicates — every draw is a different skin, and skins you already own are removed from the pool, '
561    + 'which raises the odds on the rest.';
562}
563
564/** 25 → "25%", 42.857 → "42.9%". Keeps the disclosure readable. */
565function _fmtPct(p) {
566  const r = Math.round(p * 10) / 10;
567  return (r === Math.round(r) ? String(Math.round(r)) : r.toFixed(1)) + '%';
568}
569
570// ═══════════════════════════════════════════════════════════════════
571// v155.1 — LOCALIZED IAP PRICE DISPLAY
572// ═══════════════════════════════════════════════════════════════════
573// Addresses audit-2026-08 ship-blocker #3 (second half) / UX_FLOW_AUDIT
574// RISK 2, Guideline 2.3.1 + 3.1.2 metadata accuracy.
575//
576// `priceUsd` above is a hardcoded USD string. The 8 IAPs are configured
577// for 175 App Store regions, so a player in Japan sees "$0.99" in the
578// shop and "Â¥160" on the StoreKit sheet a tap later. That mismatch is a
579// well-worn misleading-pricing rejection.
580//
581// The real price string comes from StoreKit (`priceString`), surfaced by
582// iap.js → `iapLocalizedPrice(productId)`. This helper is the ONE place
583// that decides what to paint when the store hasn't answered yet.
584//
585// THE FALLBACK RULE: never render a currency symbol we haven't been told
586// is correct. Showing "Buy" to a player in Japan is mildly less
587// informative; showing "$0.99" is wrong. Only the US storefront (and the
588// US territories that bill through it) get the hardcoded USD string.
589const IAP_PRICE_PENDING_LABEL = 'Buy';
590
591// Regions that transact in USD through the United States storefront.
592// Deliberately conservative — a region missing from this list just gets
593// the neutral label, which is safe. A region wrongly IN it would show a
594// wrong currency, which is the bug we're fixing.
595const IAP_USD_REGIONS = ['US', 'AS', 'GU', 'MP', 'PR', 'UM', 'VI'];
596
597/**
598 * Every ISO-3166 region the device advertises, across `navigator.language`,
599 * `navigator.languages` and the resolved `Intl` locale. These are LANGUAGE
600 * signals, not the App Store storefront (a US-storefront device set to
601 * Japanese reads `ja-JP`), which is exactly why they are only ever used to
602 * decide whether the USD FALLBACK is safe — never to convert, format or
603 * infer a price ourselves. Money formatting is StoreKit's job, full stop.
604 * @returns {Array<string>} de-duped two-letter regions; empty when undeterminable
605 */
606function _iapDeviceRegions() {
607  const tags = [];
608  try {
609    if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
610      const rl = Intl.DateTimeFormat().resolvedOptions().locale;
611      if (rl) tags.push(rl);
612    }
613  } catch (_) {}
614  try {
615    if (typeof navigator !== 'undefined') {
616      if (navigator.language) tags.push(navigator.language);
617      const list = navigator.languages;
618      if (list && list.length) for (let i = 0; i < list.length; i++) tags.push(list[i]);
619    }
620  } catch (_) {}
621  const out = [];
622  for (let i = 0; i < tags.length; i++) {
623    const parts = String(tags[i]).split(/[-_]/);
624    for (let j = 1; j < parts.length; j++) {
625      if (/^[A-Za-z]{2}$/.test(parts[j])) {
626        const r = parts[j].toUpperCase();
627        if (out.indexOf(r) < 0) out.push(r);
628        break;
629      }
630    }
631  }
632  return out;
633}
634
635/**
636 * True only when EVERY region signal the device gives us bills in USD.
637 *
638 * Unanimity matters. The obvious version — "take the first region we can
639 * find" — is wrong in a common real case: `Intl.DateTimeFormat()` can
640 * resolve to a fallback like `en-US` on a device whose `navigator.language`
641 * is `ja-JP`, and picking the first signal then happily prints "$0.99" to
642 * a player being charged ¥160. One dissenting signal is enough to fall
643 * back to the neutral label, because the cost of the two failure modes is
644 * not symmetric: a neutral label is merely less informative, a wrong
645 * currency symbol is a misleading-pricing rejection (Guideline 2.3.1).
646 * No region signal at all → also not safe.
647 */
648function iapUsdFallbackIsSafe() {
649  const regions = _iapDeviceRegions();
650  if (!regions.length) return false;
651  for (let i = 0; i < regions.length; i++) {
652    if (IAP_USD_REGIONS.indexOf(regions[i]) < 0) return false;
653  }
654  return true;
655}
656
657/**
658 * The string to paint on a price button/label.
659 *   1. StoreKit's localized `priceString` when we have it (always correct)
660 *   2. the catalog `$X.XX` ONLY on a USD storefront
661 *   3. a neutral, currency-free label everywhere else
662 *
663 * @param {any} item - an IAP_BUNDLES entry (needs productId + priceUsd)
664 * @param {string} [localized] - optional pre-resolved price string
665 * @returns {string}
666 */
667function iapPriceLabel(item, localized) {
668  if (!item) return IAP_PRICE_PENDING_LABEL;
669  if (typeof localized === 'string' && localized) return localized;
670  let live = '';
671  try {
672    if (typeof iapLocalizedPrice === 'function') live = iapLocalizedPrice(item.productId) || '';
673  } catch (_) {}
674  if (live) return live;
675  if (item.priceUsd && iapUsdFallbackIsSafe()) return '$' + item.priceUsd;
676  return IAP_PRICE_PENDING_LABEL;
677}
678
679// Expose for index.html + tests (classic scripts share lexical scope, but
680// the explicit window assignment keeps the contract greppable).
681try {
682  window.legendaryTrioOdds = legendaryTrioOdds;
683  window.canGrantLegendaryTrio = canGrantLegendaryTrio;
684  window.legendaryTrioOddsText = legendaryTrioOddsText;
685  window.iapPriceLabel = iapPriceLabel;
686  window.iapUsdFallbackIsSafe = iapUsdFallbackIsSafe;
687} catch (_) {}
688
689// v88 — Victory FX mini preview: a compact 72×72 loop that hints at
690// the full-screen celebration. Each vfx has its own signature motion.
691function _drawMiniVfxPreview(ctx2, cx, cy, w, h, type, now) {
692  const t = (now % 3000) / 3000; // 0..1 looping phase
693  const cyc = Math.sin(now * 0.003) * 0.5 + 0.5;
694
695  if (type === 'vfx_hexburst') {
696    // v153.130 — Default Victory FX preview was MISSING — tile rendered
697    // blank in the shop. Mini hint: brand-color hex shower from center
698    // + 2 expanding ring pulses + a center flare. Mirrors the full-
699    // screen effect's signature beats (see vfx.js _drawHexBurst).
700    // Two staggered ring pulses at brand purple/pink.
701    const rings = [
702      { phase: t,            color: '#7c6ff7' },
703      { phase: (t + 0.5) % 1, color: '#ff7cc4' },
704    ];
705    rings.forEach(rg => {
706      const r = rg.phase * Math.min(w, h) * 0.5;
707      const alpha = 1 - rg.phase;
708      ctx2.globalAlpha = Math.max(0, alpha);
709      ctx2.strokeStyle = rg.color;
710      ctx2.lineWidth = 1.5;
711      ctx2.beginPath();
712      ctx2.arc(cx, cy, Math.max(1, r), 0, Math.PI * 2);
713      ctx2.stroke();
714    });
715    ctx2.globalAlpha = 1;
716    // Hex shower — 8 small hexes flying outward from center.
717    const palette = ['#7c6ff7', '#ff7cc4', '#62e9ff', '#ffc857'];
718    for (let i = 0; i < 8; i++) {
719      const a = (Math.PI * 2 * i) / 8 + t * Math.PI * 0.5;
720      const dist = (((t + i * 0.13) % 1)) * Math.min(w, h) * 0.42;
721      const px = cx + Math.cos(a) * dist;
722      const py = cy + Math.sin(a) * dist;
723      const fade = 1 - ((t + i * 0.13) % 1);
724      ctx2.save();
725      ctx2.translate(px, py);
726      ctx2.rotate(a);
727      ctx2.globalAlpha = fade;
728      ctx2.fillStyle = palette[i % palette.length];
729      ctx2.beginPath();
730      const sz = 3;
731      for (let k = 0; k < 6; k++) {
732        const aa = (Math.PI / 3) * k;
733        const hx = Math.cos(aa) * sz, hy = Math.sin(aa) * sz;
734        k === 0 ? ctx2.moveTo(hx, hy) : ctx2.lineTo(hx, hy);
735      }
736      ctx2.closePath();
737      ctx2.fill();
738      ctx2.restore();
739    }
740    ctx2.globalAlpha = 1;
741    // Center flare — pulsing brand-color glow at the origin.
742    const flareR = 4 + cyc * 3;
743    const flare = ctx2.createRadialGradient(cx, cy, 0, cx, cy, flareR * 2);
744    flare.addColorStop(0, 'rgba(255,255,255,0.9)');
745    flare.addColorStop(0.4, 'rgba(124,111,247,0.6)');
746    flare.addColorStop(1, 'rgba(124,111,247,0)');
747    ctx2.fillStyle = flare;
748    ctx2.beginPath();
749    ctx2.arc(cx, cy, flareR * 2, 0, Math.PI * 2);
750    ctx2.fill();
751  } else if (type === 'vfx_mythic_phoenix') {
752    // v154.54 — AUDIT H2: Mythic Phoenix Rebirth preview was blank (no branch).
753    // Warm ember updraft + radial fire glow + a bright rising core, mirroring
754    // the full-screen _drawMythicPhoenix beats.
755    const glow = ctx2.createRadialGradient(cx, cy + h * 0.2, 0, cx, cy + h * 0.2, Math.min(w, h) * 0.6);
756    glow.addColorStop(0, 'rgba(255,180,80,0.55)');
757    glow.addColorStop(0.5, 'rgba(255,90,40,0.30)');
758    glow.addColorStop(1, 'rgba(255,60,30,0)');
759    ctx2.fillStyle = glow;
760    ctx2.fillRect(cx - w / 2, cy - h / 2, w, h);
761    const embers = ['#ffe28a', '#ffb347', '#ff7a2c', '#ff5a2c'];
762    for (let i = 0; i < 12; i++) {
763      const ph = (t + i * 0.0833) % 1;            // upward travel 0..1
764      const sway = Math.sin((ph * 6 + i) * 1.2) * w * 0.10;
765      const px = cx + sway + ((i % 3) - 1) * w * 0.12;
766      const py = cy + h * 0.45 - ph * h * 0.9;    // rise from bottom
767      const fade = (1 - ph) * (0.4 + 0.6 * cyc);
768      const sz = 1.4 + (1 - ph) * 2.2;
769      ctx2.globalAlpha = Math.max(0, fade);
770      ctx2.fillStyle = embers[i % embers.length];
771      ctx2.beginPath();
772      ctx2.arc(px, py, sz, 0, Math.PI * 2);
773      ctx2.fill();
774    }
775    ctx2.globalAlpha = 1;
776    const coreR = 3 + cyc * 2.5;
777    const core = ctx2.createRadialGradient(cx, cy, 0, cx, cy, coreR * 2.2);
778    core.addColorStop(0, 'rgba(255,255,255,0.95)');
779    core.addColorStop(0.45, 'rgba(255,200,90,0.7)');
780    core.addColorStop(1, 'rgba(255,120,40,0)');
781    ctx2.fillStyle = core;
782    ctx2.beginPath();
783    ctx2.arc(cx, cy, coreR * 2.2, 0, Math.PI * 2);
784    ctx2.fill();
785  } else if (type === 'vfx_mythic_galactic') {
786    // v154.54 — AUDIT H2: Mythic Galactic Bloom preview was blank (no branch).
787    // Rotating spiral of twinkling stars over a nebula glow.
788    const neb = ctx2.createRadialGradient(cx, cy, 0, cx, cy, Math.min(w, h) * 0.55);
789    neb.addColorStop(0, 'rgba(124,111,247,0.55)');
790    neb.addColorStop(0.5, 'rgba(98,233,255,0.22)');
791    neb.addColorStop(1, 'rgba(124,111,247,0)');
792    ctx2.fillStyle = neb;
793    ctx2.fillRect(cx - w / 2, cy - h / 2, w, h);
794    const rot = now * 0.0012;
795    const arms = 2, stars = 26;
796    const maxR = Math.min(w, h) * 0.46;
797    for (let i = 0; i < stars; i++) {
798      const f = i / stars;                        // 0..1 along the arm
799      const arm = (i % arms) * (Math.PI * 2 / arms);
800      const a = arm + f * Math.PI * 2.2 + rot;    // spiral winding
801      const r = f * maxR;
802      const px = cx + Math.cos(a) * r;
803      const py = cy + Math.sin(a) * r;
804      const tw = 0.5 + 0.5 * Math.sin(now * 0.006 + i);   // twinkle
805      ctx2.globalAlpha = (1 - f * 0.4) * (0.5 + 0.5 * tw);
806      ctx2.fillStyle = i % 3 === 0 ? '#ffffff' : (i % 3 === 1 ? '#9fd0ff' : '#c9b6ff');
807      ctx2.beginPath();
808      ctx2.arc(px, py, 1 + (1 - f) * 1.6, 0, Math.PI * 2);
809      ctx2.fill();
810    }
811    ctx2.globalAlpha = 1;
812    const gc = ctx2.createRadialGradient(cx, cy, 0, cx, cy, 6);
813    gc.addColorStop(0, 'rgba(255,255,255,0.95)');
814    gc.addColorStop(1, 'rgba(200,180,255,0)');
815    ctx2.fillStyle = gc;
816    ctx2.beginPath();
817    ctx2.arc(cx, cy, 6, 0, Math.PI * 2);
818    ctx2.fill();
819  } else if (type === 'vfx_confetti') {
820    // Colorful rectangles falling + spinning
821    const palette = ['#ff7cc4','#ffc857','#62e9ff','#4dd6a8','#ff6b6b'];
822    for (let i = 0; i < 10; i++) {
823      const seed = i * 0.137 + t;
824      const x = ((seed * 83) % 1) * w;
825      const y = ((t + i * 0.1) % 1) * h;
826      ctx2.save();
827      ctx2.translate(x, y);
828      ctx2.rotate((now * 0.003 + i) % (Math.PI * 2));
829      ctx2.fillStyle = palette[i % palette.length];
830      ctx2.fillRect(-3, -5, 6, 10);
831      ctx2.restore();
832    }
833  } else if (type === 'vfx_fireworks') {
834    // 2 staggered firework bursts
835    const bursts = [
836      { phase: t, color: '#ff7cc4', x: w * 0.35, y: h * 0.45 },
837      { phase: (t + 0.5) % 1, color: '#ffc857', x: w * 0.65, y: h * 0.55 },
838    ];
839    bursts.forEach(b => {
840      const r = b.phase * Math.min(w, h) * 0.45;
841      const alpha = 1 - b.phase;
842      ctx2.globalAlpha = Math.max(0, alpha);
843      for (let i = 0; i < 12; i++) {
844        const a = (Math.PI * 2 * i) / 12;
845        const sx = b.x + Math.cos(a) * r;
846        const sy = b.y + Math.sin(a) * r;
847        ctx2.fillStyle = b.color;
848        ctx2.beginPath();
849        ctx2.arc(sx, sy, 1.4, 0, Math.PI * 2);
850        ctx2.fill();
851      }
852      ctx2.globalAlpha = 1;
853    });
854  } else if (type === 'vfx_golden_rain') {
855    // Vertical gold streaks
856    ctx2.strokeStyle = '#ffd93d';
857    ctx2.lineWidth = 1.2;
858    for (let i = 0; i < 10; i++) {
859      const x = (i / 10) * w + Math.sin(now * 0.001 + i) * 3;
860      const y1 = ((t * 1.5 + i * 0.11) % 1) * h - 12;
861      ctx2.globalAlpha = 0.7;
862      ctx2.beginPath();
863      ctx2.moveTo(x, y1);
864      ctx2.lineTo(x, y1 + 14);
865      ctx2.stroke();
866    }
867    ctx2.globalAlpha = 1;
868  } else if (type === 'vfx_petals') {
869    // Drifting hex petals
870    const palette = ['#ffb5c5','#d4c5ff','#b6f0d1','#ffe4b8'];
871    for (let i = 0; i < 7; i++) {
872      const seed = i * 0.214 + t;
873      const x = ((seed * 71) % 1) * w + Math.sin(now * 0.002 + i) * 4;
874      const y = ((t + i * 0.14) % 1) * h;
875      const size = 4;
876      ctx2.save();
877      ctx2.translate(x, y);
878      ctx2.rotate(now * 0.001 + i);
879      ctx2.fillStyle = palette[i % palette.length];
880      ctx2.beginPath();
881      for (let k = 0; k < 6; k++) {
882        const a = (Math.PI / 3) * k;
883        const px = Math.cos(a) * size, py = Math.sin(a) * size;
884        k === 0 ? ctx2.moveTo(px, py) : ctx2.lineTo(px, py);
885      }
886      ctx2.closePath();
887      ctx2.fill();
888      ctx2.restore();
889    }
890  } else if (type === 'vfx_nebula') {
891    // Pulsing radial gradient wave
892    const rMax = Math.hypot(w, h) * 0.6;
893    const r = (t % 1) * rMax;
894    const alpha = Math.max(0, 1 - t);
895    const g = ctx2.createRadialGradient(cx, cy, 0, cx, cy, Math.max(0.01, r));
896    g.addColorStop(0,    `rgba(139,124,255,${alpha * 0.9})`);
897    g.addColorStop(0.5,  `rgba(255,124,196,${alpha * 0.5})`);
898    g.addColorStop(1,    'rgba(0,0,0,0)');
899    ctx2.fillStyle = g;
900    ctx2.fillRect(0, 0, w, h);
901    // sparkle dots
902    for (let i = 0; i < 8; i++) {
903      const a = (i / 8) * Math.PI * 2 + now * 0.0015;
904      const rr = Math.min(w, h) * 0.32 * cyc;
905      ctx2.fillStyle = 'rgba(255,255,255,' + (0.4 + cyc * 0.4) + ')';
906      ctx2.beginPath();
907      ctx2.arc(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr, 1.2, 0, Math.PI * 2);
908      ctx2.fill();
909    }
910  } else if (type === 'vfx_lasershow') {
911    // Crossing neon beams
912    ctx2.globalCompositeOperation = 'lighter';
913    const beams = 4;
914    for (let i = 0; i < beams; i++) {
915      const ang = (Math.PI / beams) * i + now * 0.0008;
916      const len = Math.max(w, h);
917      ctx2.save();
918      ctx2.translate(cx, cy);
919      ctx2.rotate(ang);
920      const grad = ctx2.createLinearGradient(-len/2, 0, len/2, 0);
921      const hue = (i * 90 + now * 0.05) % 360;
922      grad.addColorStop(0,   `hsla(${hue},100%,60%,0)`);
923      grad.addColorStop(0.5, `hsla(${hue},100%,70%,0.85)`);
924      grad.addColorStop(1,   `hsla(${hue},100%,60%,0)`);
925      ctx2.fillStyle = grad;
926      ctx2.fillRect(-len/2, -1.2, len, 2.4);
927      ctx2.restore();
928    }
929    ctx2.globalCompositeOperation = 'source-over';
930  } else if (type === 'vfx_shockwave') {
931    // v90 — Shockwave Cascade preview: 3 staggered rings + radial shards.
932    for (let i = 0; i < 3; i++) {
933      const rP = ((t + i * 0.33) % 1);
934      ctx2.globalAlpha = (1 - rP) * 0.9;
935      ctx2.strokeStyle = '#8b7cff';
936      ctx2.lineWidth = 3 - rP * 2.5;
937      ctx2.beginPath();
938      ctx2.arc(cx, cy, 3 + rP * Math.min(w, h) * 0.42, 0, Math.PI * 2);
939      ctx2.stroke();
940    }
941    // White inner flash pulsing
942    ctx2.globalAlpha = 0.3 + cyc * 0.5;
943    const sGrad = ctx2.createRadialGradient(cx, cy, 0, cx, cy, 14);
944    sGrad.addColorStop(0, '#ffffff');
945    sGrad.addColorStop(1, 'rgba(139,124,255,0)');
946    ctx2.fillStyle = sGrad;
947    ctx2.beginPath(); ctx2.arc(cx, cy, 14, 0, Math.PI * 2); ctx2.fill();
948    // 6 radial shards
949    ctx2.globalAlpha = 0.8;
950    ctx2.strokeStyle = '#ffffff';
951    ctx2.lineWidth = 1.4;
952    ctx2.lineCap = 'round';
953    for (let k = 0; k < 6; k++) {
954      const a = (k / 6) * Math.PI * 2 + t * 0.3;
955      const len = Math.min(w, h) * 0.38 * (0.6 + cyc * 0.4);
956      ctx2.beginPath();
957      ctx2.moveTo(cx + Math.cos(a) * 8, cy + Math.sin(a) * 8);
958      ctx2.lineTo(cx + Math.cos(a) * len, cy + Math.sin(a) * len);
959      ctx2.stroke();
960    }
961  } else if (type === 'vfx_nebula_bloom') {
962    // v90 — Nebula Bloom preview: soft bloomed cloud + drifting stars.
963    const palette = ['#b565ff', '#ff7cc4', '#62e9ff', '#8b7cff'];
964    for (let i = 0; i < 2; i++) {
965      const bP = ((t + i * 0.5) % 1);
966      const r = 6 + bP * Math.min(w, h) * 0.42;
967      const c = palette[i % palette.length];
968      ctx2.globalAlpha = (1 - bP) * 0.8;
969      const g = ctx2.createRadialGradient(cx, cy, 0, cx, cy, r);
970      g.addColorStop(0, 'rgba(255,255,255,0.85)');
971      g.addColorStop(0.4, c);
972      g.addColorStop(1, c + '00');
973      ctx2.fillStyle = g;
974      ctx2.beginPath(); ctx2.arc(cx, cy, r, 0, Math.PI * 2); ctx2.fill();
975    }
976    // 4-point sparkle rotating
977    ctx2.save();
978    ctx2.translate(cx, cy);
979    ctx2.rotate(now * 0.002);
980    ctx2.globalAlpha = 0.85;
981    ctx2.fillStyle = '#ff7cc4';
982    const hs = Math.min(w, h) * 0.3;
983    ctx2.beginPath();
984    ctx2.moveTo(0, -hs); ctx2.lineTo(hs * 0.15, 0);
985    ctx2.lineTo(0, hs); ctx2.lineTo(-hs * 0.15, 0);
986    ctx2.closePath(); ctx2.fill();
987    ctx2.beginPath();
988    ctx2.moveTo(-hs, 0); ctx2.lineTo(0, hs * 0.15);
989    ctx2.lineTo(hs, 0); ctx2.lineTo(0, -hs * 0.15);
990    ctx2.closePath(); ctx2.fill();
991    ctx2.restore();
992    // Drifting white stars
993    for (let k = 0; k < 6; k++) {
994      const starP = ((t + k * 0.17) % 1);
995      const a = (k / 6) * Math.PI * 2;
996      const d = Math.min(w, h) * 0.15 + starP * Math.min(w, h) * 0.3;
997      const sx = cx + Math.cos(a) * d;
998      const sy = cy + Math.sin(a) * d;
999      ctx2.globalAlpha = (1 - starP) * 0.9;
1000      ctx2.fillStyle = '#ffffff';
1001      ctx2.beginPath(); ctx2.arc(sx, sy, 1.5, 0, Math.PI * 2); ctx2.fill();
1002    }
1003  } else if (type === 'vfx_lightning_chain') {
1004    // v90 — Lightning Chain preview: zigzag bolt + flickering tendrils.
1005    // Six mini "hex" dots with an electric chain zipping between them.
1006    const ltC = '#62e9ff';
1007    const dots = 6;
1008    const pad = 8;
1009    const spanW = w - pad * 2;
1010    const pts = [];
1011    for (let i = 0; i < dots; i++) {
1012      const px = pad + (i / (dots - 1)) * spanW;
1013      const py = cy + Math.sin(i * 0.9 + now * 0.003) * 4;
1014      pts.push({ x: px, y: py });
1015    }
1016    // Dots
1017    pts.forEach(p => {
1018      ctx2.globalAlpha = 0.85;
1019      ctx2.fillStyle = '#ffffff';
1020      ctx2.beginPath(); ctx2.arc(p.x, p.y, 2.2, 0, Math.PI * 2); ctx2.fill();
1021    });
1022    // Zigzag chain bolt with glow (runs left→right)
1023    const chainT = (t * 2) % 1; // faster sweep
1024    const reachable = Math.min(Math.floor(chainT * (dots - 1)) + 1, dots);
1025    ctx2.save();
1026    ctx2.shadowColor = ltC;
1027    ctx2.shadowBlur = 6;
1028    ctx2.globalAlpha = 0.9;
1029    ctx2.strokeStyle = ltC;
1030    ctx2.lineWidth = 2.2;
1031    ctx2.lineCap = 'round';
1032    ctx2.beginPath();
1033    ctx2.moveTo(pts[0].x, pts[0].y);
1034    for (let i = 1; i < reachable; i++) {
1035      const p0 = pts[i - 1], p1 = pts[i];
1036      const midX = (p0.x + p1.x) / 2 + (Math.sin(now * 0.02 + i) * 3);
1037      const midY = (p0.y + p1.y) / 2 + (Math.cos(now * 0.018 + i) * 3);
1038      ctx2.lineTo(midX, midY);
1039      ctx2.lineTo(p1.x, p1.y);
1040    }
1041    ctx2.stroke();
1042    ctx2.shadowBlur = 0;
1043    // Bright inner streak
1044    ctx2.globalAlpha = 0.95;
1045    ctx2.strokeStyle = '#ffffff';
1046    ctx2.lineWidth = 0.9;
1047    ctx2.stroke();
1048    ctx2.restore();
1049    // Tendril flickers around dots
1050    for (let i = 0; i < dots; i++) {
1051      const p = pts[i];
1052      const flick = Math.sin(now * 0.01 + i * 2) * 0.5 + 0.5;
1053      if (flick < 0.4) continue;
1054      ctx2.save();
1055      ctx2.shadowColor = ltC;
1056      ctx2.shadowBlur = 4;
1057      ctx2.globalAlpha = flick * 0.8;
1058      ctx2.strokeStyle = ltC;
1059      ctx2.lineWidth = 1;
1060      ctx2.beginPath();
1061      const a = (i * 1.7 + now * 0.005) % (Math.PI * 2);
1062      ctx2.moveTo(p.x, p.y);
1063      ctx2.lineTo(p.x + Math.cos(a) * 6, p.y + Math.sin(a) * 6);
1064      ctx2.stroke();
1065      ctx2.restore();
1066    }
1067  } else if (type === 'vfx_bloom_burst') {
1068    // v103 — Bloom Burst preview: central bloom + flowers fanning out with petals drifting.
1069    const palette = ['#ff7cc4', '#ffc857', '#b565ff', '#62e9ff'];
1070    // Central soft shockwave bloom
1071    const bloomR = Math.min(w, h) * 0.4;
1072    for (let i = 0; i < 2; i++) {
1073      const bP = ((t + i * 0.5) % 1);
1074      const r = 4 + bP * bloomR;
1075      ctx2.globalAlpha = (1 - bP) * 0.55;
1076      const g = ctx2.createRadialGradient(cx, cy, 0, cx, cy, r);
1077      g.addColorStop(0, palette[0]);
1078      g.addColorStop(0.5, palette[2] + 'aa');
1079      g.addColorStop(1, palette[0] + '00');
1080      ctx2.fillStyle = g;
1081      ctx2.beginPath(); ctx2.arc(cx, cy, r, 0, Math.PI * 2); ctx2.fill();
1082    }
1083    // Expanding ring
1084    const rP = (t % 1);
1085    ctx2.globalAlpha = (1 - rP) * 0.85;
1086    ctx2.strokeStyle = palette[1];
1087    ctx2.lineWidth = 1.8;
1088    ctx2.beginPath(); ctx2.arc(cx, cy, 3 + rP * Math.min(w, h) * 0.45, 0, Math.PI * 2); ctx2.stroke();
1089    // Flowers fanning outward
1090    const flowers = 7;
1091    for (let i = 0; i < flowers; i++) {
1092      const fP = ((t + i * 0.14) % 1);
1093      const a = (i / flowers) * Math.PI * 2 + now * 0.0004;
1094      const dist = fP * Math.min(w, h) * 0.48;
1095      // upward gravity-ish: initial upward bias, then fall
1096      const yOff = -dist * 0.4 + (fP > 0.5 ? (fP - 0.5) * Math.min(w, h) * 0.6 : 0);
1097      const fx = cx + Math.cos(a) * dist;
1098      const fy = cy + Math.sin(a) * dist + yOff;
1099      const fcol = palette[i % palette.length];
1100      const facc = palette[(i + 2) % palette.length];
1101      const fsz = Math.min(w, h) * (0.13 - fP * 0.05);
1102      const spin = (i % 2 === 0 ? 1 : -1) * fP * Math.PI * 2;
1103      ctx2.globalAlpha = (1 - fP) * 0.95;
1104      ctx2.save();
1105      ctx2.translate(fx, fy);
1106      ctx2.rotate(spin);
1107      // 6 petals
1108      for (let p = 0; p < 6; p++) {
1109        ctx2.save();
1110        ctx2.rotate((p / 6) * Math.PI * 2);
1111        const grad = ctx2.createRadialGradient(0, -fsz * 0.5, 0, 0, -fsz * 0.5, fsz * 0.55);
1112        grad.addColorStop(0, 'rgba(255,255,255,0.7)');
1113        grad.addColorStop(0.45, fcol);
1114        grad.addColorStop(1, fcol);
1115        ctx2.fillStyle = grad;
1116        ctx2.beginPath();
1117        ctx2.ellipse(0, -fsz * 0.5, fsz * 0.2, fsz * 0.45, 0, 0, Math.PI * 2);
1118        ctx2.fill();
1119        ctx2.restore();
1120      }
1121      // center disc
1122      ctx2.fillStyle = facc;
1123      ctx2.beginPath(); ctx2.arc(0, 0, fsz * 0.22, 0, Math.PI * 2); ctx2.fill();
1124      ctx2.fillStyle = '#fff';
1125      ctx2.globalAlpha *= 0.9;
1126      ctx2.beginPath(); ctx2.arc(0, 0, fsz * 0.1, 0, Math.PI * 2); ctx2.fill();
1127      ctx2.restore();
1128    }
1129    // Drifting petals falling from top
1130    for (let k = 0; k < 5; k++) {
1131      const pP = ((t * 0.8 + k * 0.21) % 1);
1132      const sideBase = ((k * 137) % 100) / 100 - 0.5;
1133      const px = cx + sideBase * w * 0.8 + Math.sin(pP * Math.PI * 2 + k) * 6;
1134      const py = -6 + pP * (h + 16);
1135      const pCol = palette[k % palette.length];
1136      ctx2.globalAlpha = pP < 0.1 ? pP * 10 * 0.85 : (1 - pP) * 0.95;
1137      ctx2.save();
1138      ctx2.translate(px, py);
1139      ctx2.rotate(pP * Math.PI * 4 * (k % 2 === 0 ? 1 : -1));
1140      const pg = ctx2.createLinearGradient(0, -4, 0, 4);
1141      pg.addColorStop(0, 'rgba(255,255,255,0.7)');
1142      pg.addColorStop(1, pCol);
1143      ctx2.fillStyle = pg;
1144      ctx2.beginPath();
1145      ctx2.ellipse(0, 0, 1.8, 3.2, 0, 0, Math.PI * 2);
1146      ctx2.fill();
1147      ctx2.restore();
1148    }
1149  } else if (type === 'vfx_phoenix_rise') {
1150    // v106 — Phoenix Rise v2 preview: phoenix flies diagonally across the
1151    // mini panel leaving a curved molten fire trail, embers drift up.
1152    // Dim plum backdrop
1153    const bg = ctx2.createLinearGradient(0, 0, 0, h);
1154    bg.addColorStop(0, '#1a0608');
1155    bg.addColorStop(1, '#340d12');
1156    ctx2.fillStyle = bg;
1157    ctx2.fillRect(0, 0, w, h);
1158    // Warm sky glow pulse in center
1159    const pulse = (Math.sin(now * 0.002) + 1) * 0.5;
1160    ctx2.globalAlpha = 0.45 + pulse * 0.25;
1161    const sky = ctx2.createRadialGradient(cx, cy, 0, cx, cy, Math.max(w, h) * 0.6);
1162    sky.addColorStop(0, 'rgba(255,107,61,0.6)');
1163    sky.addColorStop(0.6, 'rgba(255,61,127,0)');
1164    ctx2.fillStyle = sky;
1165    ctx2.fillRect(0, 0, w, h);
1166    ctx2.globalAlpha = 1;
1167    // Curved flight path (looping) — goes from left-low to right-high with an arc
1168    const loopT = t;
1169    // 6 waypoints across the preview for curve shape
1170    const waypoints = [];
1171    const n = 6;
1172    for (let i = 0; i < n; i++) {
1173      const p = i / (n - 1);
1174      const wx = w * (0.1 + p * 0.8);
1175      const wy = h * 0.5 + Math.sin(p * Math.PI * 1.2) * h * 0.3 * (i % 2 === 0 ? 1 : -1);
1176      waypoints.push({ x: wx, y: wy });
1177    }
1178    // Sample the curve
1179    const mSamples = [];
1180    const sps = 10;
1181    for (let i = 0; i < waypoints.length - 1; i++) {
1182      const p0 = waypoints[i - 1] || waypoints[i];
1183      const p1 = waypoints[i];
1184      const p2 = waypoints[i + 1];
1185      const p3 = waypoints[i + 2] || p2;
1186      const c1x = p1.x + (p2.x - p0.x) * 0.22;
1187      const c1y = p1.y + (p2.y - p0.y) * 0.22 - 6;
1188      const c2x = p2.x - (p3.x - p1.x) * 0.22;
1189      const c2y = p2.y - (p3.y - p1.y) * 0.22 - 6;
1190      for (let s = 0; s <= sps; s++) {
1191        if (i > 0 && s === 0) continue;
1192        const tc = s / sps;
1193        const mt = 1 - tc;
1194        mSamples.push({
1195          x: mt * mt * mt * p1.x + 3 * mt * mt * tc * c1x + 3 * mt * tc * tc * c2x + tc * tc * tc * p2.x,
1196          y: mt * mt * mt * p1.y + 3 * mt * mt * tc * c1y + 3 * mt * tc * tc * c2y + tc * tc * tc * p2.y,
1197        });
1198      }
1199    }
1200    // Draw trail prefix up to loopT along the full samples
1201    const drawN = Math.floor(mSamples.length * loopT);
1202    if (drawN > 1) {
1203      // Outer glow
1204      ctx2.globalAlpha = 0.55;
1205      ctx2.strokeStyle = '#ff6b3d';
1206      ctx2.lineWidth = 10;
1207      ctx2.lineCap = 'round';
1208      ctx2.lineJoin = 'round';
1209      ctx2.beginPath();
1210      ctx2.moveTo(mSamples[0].x, mSamples[0].y);
1211      for (let i = 1; i < drawN; i++) ctx2.lineTo(mSamples[i].x, mSamples[i].y);
1212      ctx2.stroke();
1213      // Hot core
1214      ctx2.globalAlpha = 0.95;
1215      ctx2.strokeStyle = '#ffd86b';
1216      ctx2.lineWidth = 4;
1217      ctx2.stroke();
1218      // White center
1219      ctx2.globalAlpha = 1;
1220      ctx2.strokeStyle = '#ffffff';
1221      ctx2.lineWidth = 1.5;
1222      ctx2.stroke();
1223    }
1224    // Phoenix at tip of trail
1225    if (drawN > 1) {
1226      const tip = mSamples[drawN - 1];
1227      const prev = mSamples[drawN - 2];
1228      const ang = Math.atan2(tip.y - prev.y, tip.x - prev.x);
1229      const phxSize = Math.min(w, h) * 0.38;
1230      ctx2.save();
1231      ctx2.translate(tip.x, tip.y);
1232      ctx2.rotate(ang);
1233      if (typeof _phxDrawPhoenix === 'function') {
1234        _phxDrawPhoenix(ctx2, phxSize, now * 0.02);
1235      }
1236      ctx2.restore();
1237    }
1238    // Embers drifting up (mini count)
1239    for (let em = 0; em < 9; em++) {
1240      const eP = ((t * 0.9 + em * 0.11) % 1);
1241      const seed = (em * 137) % 100 / 100;
1242      const sx = cx + (seed - 0.5) * w * 0.7;
1243      const sy = h + 4 - eP * (h + 10);
1244      const sz = 1.2 + seed * 1.6;
1245      ctx2.globalAlpha = eP < 0.1 ? eP * 8 * 0.85 : (1 - eP) * 0.85;
1246      const ec = seed > 0.66 ? '#ffd86b' : (seed > 0.33 ? '#ff6b3d' : '#ff3d7f');
1247      ctx2.fillStyle = ec;
1248      ctx2.beginPath(); ctx2.arc(sx, sy, sz * 1.7, 0, Math.PI * 2); ctx2.fill();
1249      ctx2.globalAlpha *= 1.1;
1250      ctx2.fillStyle = '#fff';
1251      ctx2.beginPath(); ctx2.arc(sx, sy, sz * 0.6, 0, Math.PI * 2); ctx2.fill();
1252    }
1253    ctx2.globalAlpha = 1;
1254  } else if (type === 'vfx_coin_cascade') {
1255    // v104 — Coin Cascade preview: rain of spinning coins bouncing at bottom.
1256    // Warm gold backdrop
1257    const bg = ctx2.createLinearGradient(0, 0, 0, h);
1258    bg.addColorStop(0, '#1a0a02');
1259    bg.addColorStop(1, '#3a1a04');
1260    ctx2.fillStyle = bg; ctx2.fillRect(0, 0, w, h);
1261    // Warm glow at bottom
1262    ctx2.globalAlpha = 0.6;
1263    const glow = ctx2.createRadialGradient(cx, h * 0.9, 0, cx, h * 0.9, w * 0.7);
1264    glow.addColorStop(0, 'rgba(255,200,80,0.5)');
1265    glow.addColorStop(1, 'rgba(255,200,80,0)');
1266    ctx2.fillStyle = glow; ctx2.fillRect(0, 0, w, h);
1267    ctx2.globalAlpha = 1;
1268    // 9 coins each on independent loops — fall + bounce
1269    const nCoins = 9;
1270    const floorY = h * 0.8;
1271    for (let i = 0; i < nCoins; i++) {
1272      const loopP = ((t + i * 0.11) % 1);
1273      const seed = ((i * 137) % 100) / 100;
1274      const xStart = (seed * 0.85 + 0.07) * w + (seed - 0.5) * 3;
1275      // Fall: y = -8 → floor, bounce visualized as decaying sine envelope
1276      // Use (1 - loopP)^2 to make initial fall fast, then a bounce as sine
1277      const fall = loopP < 0.75 ? (loopP / 0.75) * (loopP / 0.75) : 1;
1278      const bounce = loopP > 0.75 ? Math.abs(Math.sin((loopP - 0.75) / 0.25 * Math.PI)) * (1 - (loopP - 0.75) / 0.25) * 8 : 0;
1279      const cy2 = -8 + fall * (floorY + 8) - bounce;
1280      const cx2 = xStart + Math.sin(loopP * Math.PI) * (seed - 0.5) * 10;
1281      const spin = loopP * Math.PI * 6 + i * 0.8;
1282      const squish = Math.abs(Math.cos(spin));
1283      const coinR = 4 + (i % 3) * 1.5;
1284      const coinW = coinR * squish;
1285      ctx2.globalAlpha = loopP > 0.95 ? (1 - loopP) / 0.05 : 1;
1286      ctx2.save();
1287      ctx2.translate(cx2, cy2);
1288      // Edge
1289      ctx2.fillStyle = '#8a5a18';
1290      ctx2.beginPath();
1291      ctx2.ellipse(0, 0, coinW + 1, coinR + 1, 0, 0, Math.PI * 2);
1292      ctx2.fill();
1293      // Face
1294      const fg = ctx2.createLinearGradient(-coinW, -coinR, coinW, coinR);
1295      fg.addColorStop(0,   '#ffe488');
1296      fg.addColorStop(0.5, '#ffc038');
1297      fg.addColorStop(1,   '#b87018');
1298      ctx2.fillStyle = fg;
1299      ctx2.beginPath();
1300      ctx2.ellipse(0, 0, coinW, coinR, 0, 0, Math.PI * 2);
1301      ctx2.fill();
1302      // Glint
1303      if (squish > 0.55) {
1304        ctx2.globalAlpha *= squish;
1305        ctx2.fillStyle = 'rgba(255,255,255,0.7)';
1306        ctx2.beginPath();
1307        ctx2.ellipse(-coinW * 0.3, -coinR * 0.3, coinW * 0.3, coinR * 0.15, -0.4, 0, Math.PI * 2);
1308        ctx2.fill();
1309      }
1310      ctx2.restore();
1311    }
1312    ctx2.globalAlpha = 1;
1313    // "$" sparkle dust bottom
1314    for (let k = 0; k < 7; k++) {
1315      const dP = ((t * 1.3 + k * 0.13) % 1);
1316      const dx = ((k * 71) % w);
1317      const dy = floorY - dP * h * 0.4;
1318      ctx2.globalAlpha = (1 - dP) * 0.6;
1319      ctx2.fillStyle = k % 2 === 0 ? '#fff8c0' : '#ffd060';
1320      ctx2.beginPath(); ctx2.arc(dx, dy, 1.2, 0, Math.PI * 2); ctx2.fill();
1321    }
1322    ctx2.globalAlpha = 1;
1323  } else if (type === 'vfx_kaiju_stomp') {
1324    // v105 — Kaiju Stomp preview: foot descends, impacts, shake + cracks + dust.
1325    // Loop phase: 0–0.35 descend, 0.35 impact flash, 0.35–0.85 crack + dust, 0.85–1 settle
1326    const impactAt = 0.35;
1327    const preImp = t < impactAt;
1328    // Dark background with rumble vignette
1329    const bg = ctx2.createLinearGradient(0, 0, 0, h);
1330    bg.addColorStop(0, '#0a0a10');
1331    bg.addColorStop(1, '#1a0a05');
1332    ctx2.fillStyle = bg; ctx2.fillRect(0, 0, w, h);
1333    // Shake offset post-impact
1334    let shX = 0, shY = 0;
1335    if (!preImp) {
1336      const shT = (t - impactAt);
1337      const mag = 3.5 * Math.exp(-shT * 12);
1338      shX = (Math.sin(now * 0.05) - 0.5) * mag;
1339      shY = (Math.cos(now * 0.07) - 0.5) * mag;
1340    }
1341    ctx2.save();
1342    ctx2.translate(shX, shY);
1343    // Impact flash
1344    if (!preImp && t < impactAt + 0.1) {
1345      const fp = (t - impactAt) / 0.1;
1346      ctx2.globalAlpha = (1 - fp) * 0.9;
1347      ctx2.fillStyle = '#fff8d0';
1348      ctx2.fillRect(0, 0, w, h);
1349      ctx2.globalAlpha = 1;
1350    }
1351    // Foot
1352    const impactX = cx;
1353    const impactY = h * 0.72;
1354    const footW = w * 0.5;
1355    const footH = footW * 1.1;
1356    let footY;
1357    if (preImp) {
1358      const fp = t / impactAt;
1359      footY = -footH + (fp * fp * fp) * (impactY - footH * 0.2 + footH);
1360    } else {
1361      const after = (t - impactAt) / (1 - impactAt);
1362      const lift = Math.sin(Math.min(after, 1) * Math.PI) * 2;
1363      footY = impactY - footH * 0.2 - lift;
1364    }
1365    ctx2.fillStyle = '#0a0a0a';
1366    // Leg
1367    ctx2.fillRect(impactX - footW * 0.28, footY - 120, footW * 0.56, 120 + footH * 0.4);
1368    // Pad
1369    ctx2.beginPath();
1370    ctx2.ellipse(impactX, footY + footH * 0.55, footW * 0.48, footH * 0.35, 0, 0, Math.PI * 2);
1371    ctx2.fill();
1372    // 3 toes
1373    for (let t2 = -1; t2 <= 1; t2++) {
1374      ctx2.beginPath();
1375      ctx2.ellipse(impactX + t2 * footW * 0.22, footY + footH * 0.78, footW * 0.13, footH * 0.17, 0, 0, Math.PI * 2);
1376      ctx2.fillStyle = '#0a0a0a';
1377      ctx2.fill();
1378    }
1379    // Cracks (after impact)
1380    if (!preImp) {
1381      const cp = Math.min((t - impactAt) / 0.3, 1);
1382      ctx2.strokeStyle = '#050505';
1383      ctx2.lineWidth = 1.6;
1384      ctx2.lineCap = 'round';
1385      for (let k = 0; k < 5; k++) {
1386        const a = (k / 5) * Math.PI * 2 + 0.2;
1387        const reach = w * 0.45 * cp;
1388        ctx2.beginPath();
1389        ctx2.moveTo(impactX, impactY);
1390        const m1x = impactX + Math.cos(a + 0.3) * reach * 0.4;
1391        const m1y = impactY + Math.sin(a + 0.3) * reach * 0.4;
1392        const m2x = impactX + Math.cos(a - 0.2) * reach * 0.75;
1393        const m2y = impactY + Math.sin(a - 0.2) * reach * 0.75;
1394        const m3x = impactX + Math.cos(a) * reach;
1395        const m3y = impactY + Math.sin(a) * reach;
1396        ctx2.lineTo(m1x, m1y);
1397        ctx2.lineTo(m2x, m2y);
1398        ctx2.lineTo(m3x, m3y);
1399        ctx2.stroke();
1400      }
1401    }
1402    // Dust plumes (after impact)
1403    if (!preImp) {
1404      const dp = Math.min((t - impactAt) / 0.5, 1);
1405      for (let d = 0; d < 10; d++) {
1406        const da = (d / 10) * Math.PI * 2 + d * 0.3;
1407        const dDist = w * 0.35 * dp;
1408        const dx = impactX + Math.cos(da) * dDist;
1409        const dy = impactY + Math.sin(da) * dDist * 0.4 - h * 0.3 * dp;
1410        const dR = 4 + dp * 8;
1411        ctx2.globalAlpha = (1 - dp) * 0.55;
1412        ctx2.fillStyle = d % 2 === 0 ? '#a08868' : '#807060';
1413        ctx2.beginPath(); ctx2.arc(dx, dy, dR, 0, Math.PI * 2); ctx2.fill();
1414      }
1415      ctx2.globalAlpha = 1;
1416    }
1417    ctx2.restore();
1418  } else if (type === 'vfx_dragon_sweep') {
1419    // v106 — Dragon Sweep preview: tiny dragon flies a curved trail,
1420    // breathes fire, ember burst at the tip. Loop phase 0..1.
1421    // Dark sky backdrop
1422    const bg = ctx2.createLinearGradient(0, 0, 0, h);
1423    bg.addColorStop(0, '#1a0810');
1424    bg.addColorStop(1, '#3a1208');
1425    ctx2.fillStyle = bg; ctx2.fillRect(0, 0, w, h);
1426    // Sky glow pulse
1427    const skyPulse = Math.sin(t * Math.PI);
1428    ctx2.save();
1429    ctx2.globalAlpha = skyPulse * 0.25;
1430    const sg = ctx2.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, w * 0.7);
1431    sg.addColorStop(0, 'rgba(255,160,60,0.8)');
1432    sg.addColorStop(1, 'rgba(255,60,20,0)');
1433    ctx2.fillStyle = sg;
1434    ctx2.fillRect(0, 0, w, h);
1435    ctx2.restore();
1436    // Waypoints for the flight path (curved arc)
1437    const dPts = [
1438      { x: w * 0.1,  y: h * 0.82 },
1439      { x: w * 0.28, y: h * 0.58 },
1440      { x: w * 0.48, y: h * 0.38 },
1441      { x: w * 0.7,  y: h * 0.32 },
1442      { x: w * 0.88, y: h * 0.5 },
1443    ];
1444    // Sample the path using cubic bezier
1445    const samplePath = (u) => {
1446      const tot = dPts.length - 1;
1447      const seg = Math.min(Math.floor(u * tot), tot - 1);
1448      const localT = u * tot - seg;
1449      const p1 = dPts[seg];
1450      const p2 = dPts[seg + 1];
1451      const prev = dPts[seg - 1] || p1;
1452      const next = dPts[seg + 2] || p2;
1453      const c1x = p1.x + (p2.x - prev.x) * 0.22;
1454      const c1y = p1.y + (p2.y - prev.y) * 0.22;
1455      const c2x = p2.x - (next.x - p1.x) * 0.22;
1456      const c2y = p2.y - (next.y - p1.y) * 0.22;
1457      const mt = 1 - localT;
1458      const x = mt*mt*mt*p1.x + 3*mt*mt*localT*c1x + 3*mt*localT*localT*c2x + localT*localT*localT*p2.x;
1459      const y = mt*mt*mt*p1.y + 3*mt*mt*localT*c1y + 3*mt*localT*localT*c2y + localT*localT*localT*p2.y;
1460      // Derivative
1461      const dx = 3*mt*mt*(c1x-p1.x) + 6*mt*localT*(c2x-c1x) + 3*localT*localT*(p2.x-c2x);
1462      const dy = 3*mt*mt*(c1y-p1.y) + 6*mt*localT*(c2y-c1y) + 3*localT*localT*(p2.y-c2y);
1463      return { x, y, ang: Math.atan2(dy, dx) };
1464    };
1465    // Trail fraction — goes 0..1 then loops
1466    const frac = Math.min(t * 1.1, 1);
1467    // Progressive trail stroke
1468    ctx2.save();
1469    ctx2.lineCap = 'round';
1470    ctx2.lineJoin = 'round';
1471    ctx2.beginPath();
1472    const first = samplePath(0);
1473    ctx2.moveTo(first.x, first.y);
1474    const steps = 28;
1475    for (let i = 1; i <= steps; i++) {
1476      const sp = samplePath((i / steps) * frac);
1477      ctx2.lineTo(sp.x, sp.y);
1478    }
1479    ctx2.strokeStyle = 'rgba(255,60,20,0.55)';
1480    ctx2.lineWidth = 9;
1481    ctx2.stroke();
1482    ctx2.strokeStyle = 'rgba(255,180,60,0.88)';
1483    ctx2.lineWidth = 5;
1484    ctx2.stroke();
1485    ctx2.strokeStyle = '#fff0b0';
1486    ctx2.lineWidth = 2;
1487    ctx2.stroke();
1488    ctx2.restore();
1489    // Dragon at the tip (simplified — just the head + mini body + wing)
1490    const dragon = samplePath(frac);
1491    ctx2.save();
1492    ctx2.translate(dragon.x, dragon.y);
1493    ctx2.rotate(dragon.ang);
1494    // Body (short)
1495    ctx2.fillStyle = '#2a8040';
1496    ctx2.beginPath();
1497    ctx2.moveTo(-16, 0);
1498    ctx2.quadraticCurveTo(-8, -3, 0, -2);
1499    ctx2.lineTo(0, 2);
1500    ctx2.quadraticCurveTo(-8, 3, -16, 0);
1501    ctx2.closePath();
1502    ctx2.fill();
1503    // Wing (single flap)
1504    const flap = (Math.sin(now * 0.01) + 1) * 0.5;
1505    ctx2.fillStyle = 'rgba(42,128,64,0.85)';
1506    ctx2.beginPath();
1507    ctx2.moveTo(-6, -1);
1508    ctx2.quadraticCurveTo(-5, -6 - flap * 4, 2, -4 - flap * 2);
1509    ctx2.quadraticCurveTo(-2, -1, -6, -1);
1510    ctx2.closePath();
1511    ctx2.fill();
1512    // Head
1513    ctx2.fillStyle = '#2a8040';
1514    ctx2.beginPath();
1515    ctx2.ellipse(3, 0, 4, 2.5, 0, 0, Math.PI * 2);
1516    ctx2.fill();
1517    // Horn
1518    ctx2.fillStyle = '#ffc048';
1519    ctx2.beginPath();
1520    ctx2.moveTo(2, -2);
1521    ctx2.lineTo(1, -5);
1522    ctx2.lineTo(3, -2);
1523    ctx2.closePath();
1524    ctx2.fill();
1525    // Eye
1526    ctx2.fillStyle = '#fff';
1527    ctx2.beginPath(); ctx2.arc(3.5, -0.5, 0.8, 0, Math.PI * 2); ctx2.fill();
1528    ctx2.fillStyle = '#1a0805';
1529    ctx2.beginPath(); ctx2.arc(3.5, -0.5, 0.4, 0, Math.PI * 2); ctx2.fill();
1530    // Fire breath
1531    const fg = ctx2.createRadialGradient(10, 0, 0, 10, 0, 6);
1532    fg.addColorStop(0, '#fff');
1533    fg.addColorStop(0.3, '#ffd060');
1534    fg.addColorStop(0.7, '#ff6020');
1535    fg.addColorStop(1, 'rgba(180,40,20,0)');
1536    ctx2.fillStyle = fg;
1537    ctx2.beginPath();
1538    ctx2.ellipse(10, 0, 6, 2.5, 0, 0, Math.PI * 2);
1539    ctx2.fill();
1540    ctx2.restore();
1541    // Embers drifting up
1542    for (let e = 0; e < 7; e++) {
1543      const ep = ((t + e * 0.14) % 1);
1544      const pt = samplePath(Math.max(0, frac - 0.15 - e * 0.08));
1545      const ex = pt.x + Math.sin(ep * 6 + e) * 3;
1546      const ey = pt.y - ep * h * 0.3;
1547      const eA = (1 - ep) * 0.9;
1548      ctx2.globalAlpha = eA;
1549      ctx2.fillStyle = e % 2 === 0 ? '#ffd060' : '#ff6020';
1550      ctx2.beginPath(); ctx2.arc(ex, ey, 1.6 * (1 - ep), 0, Math.PI * 2); ctx2.fill();
1551    }
1552    ctx2.globalAlpha = 1;
1553  } else if (type === 'vfx_thunderstrike') {
1554    // Drop 5 — Thunderstrike preview: dark storm sky, rolling clouds, a forked
1555    // lightning bolt striking down at the horizon every ~1.1s with a screen
1556    // flash, afterglow rings and rain streaks.
1557    const cycle = 1.1;
1558    const lt = (now % (cycle * 1000)) / 1000;   // 0..cycle
1559    const cyc = lt / cycle;                     // 0..1
1560    // Strike phases
1561    const strikeStart = 0.12, strikeEnd = 0.22;
1562    const inStrike = cyc >= strikeStart && cyc <= strikeEnd;
1563    const strikeU = inStrike ? (cyc - strikeStart) / (strikeEnd - strikeStart) : 0;
1564    const afterGlow = cyc > strikeStart ? Math.max(0, 1 - (cyc - strikeStart) / 0.75) : 0;
1565    // Dark storm sky
1566    const sky = ctx2.createLinearGradient(0, 0, 0, h);
1567    sky.addColorStop(0, '#05070f');
1568    sky.addColorStop(0.55, '#0e1425');
1569    sky.addColorStop(1, '#1a2038');
1570    ctx2.fillStyle = sky; ctx2.fillRect(0, 0, w, h);
1571    // Horizon glow — lifts with the strike
1572    ctx2.save();
1573    ctx2.globalAlpha = 0.25 + afterGlow * 0.55;
1574    const hz = ctx2.createRadialGradient(w * 0.5, h * 0.6, 0, w * 0.5, h * 0.6, w * 0.7);
1575    hz.addColorStop(0, 'rgba(180,220,255,0.9)');
1576    hz.addColorStop(1, 'rgba(60,80,160,0)');
1577    ctx2.fillStyle = hz;
1578    ctx2.fillRect(0, 0, w, h);
1579    ctx2.restore();
1580    // Cloud bands (two layered drifting bands)
1581    ctx2.save();
1582    for (let c = 0; c < 2; c++) {
1583      const cy2 = h * (0.18 + c * 0.18);
1584      const drift = (now * (0.015 + c * 0.01)) % (w * 2);
1585      ctx2.globalAlpha = 0.45 - c * 0.12;
1586      ctx2.fillStyle = c === 0 ? '#1a1e30' : '#252a42';
1587      for (let k = -1; k < 3; k++) {
1588        const bx = k * w * 0.9 - drift % (w * 0.9);
1589        ctx2.beginPath();
1590        ctx2.ellipse(bx + w * 0.2, cy2, w * 0.3, h * 0.09 - c * 0.02, 0, 0, Math.PI * 2);
1591        ctx2.fill();
1592        ctx2.beginPath();
1593        ctx2.ellipse(bx + w * 0.55, cy2 + 4, w * 0.22, h * 0.07, 0, 0, Math.PI * 2);
1594        ctx2.fill();
1595      }
1596    }
1597    ctx2.restore();
1598    // Rain streaks (always present)
1599    ctx2.save();
1600    ctx2.globalAlpha = 0.35;
1601    ctx2.strokeStyle = '#7fa4d4';
1602    ctx2.lineWidth = 0.7;
1603    for (let r = 0; r < 22; r++) {
1604      const rx = ((r * 17 + now * 0.25) % (w + 40)) - 20;
1605      const ry = ((r * 31 + now * 0.8) % (h + 20)) - 10;
1606      ctx2.beginPath();
1607      ctx2.moveTo(rx, ry);
1608      ctx2.lineTo(rx - 3, ry + 10);
1609      ctx2.stroke();
1610    }
1611    ctx2.restore();
1612    // Pre-compute a seeded bolt path (changes each cycle)
1613    const boltSeed = Math.floor(now / (cycle * 1000));
1614    let seed = boltSeed * 9301 + 49297;
1615    const rand = () => {
1616      seed = (seed * 9301 + 49297) % 233280;
1617      return seed / 233280;
1618    };
1619    const boltX = w * 0.5 + (rand() - 0.5) * w * 0.3;
1620    const boltPts = [];
1621    const segs = 7;
1622    for (let i = 0; i <= segs; i++) {
1623      const py = (i / segs) * h * 0.72;
1624      const px = boltX + (i === 0 || i === segs ? 0 : (rand() - 0.5) * w * 0.14);
1625      boltPts.push({ x: px, y: py });
1626    }
1627    // Screen flash during strike
1628    if (inStrike) {
1629      const flashA = Math.sin(strikeU * Math.PI) * 0.9;
1630      ctx2.save();
1631      ctx2.globalAlpha = flashA;
1632      ctx2.fillStyle = '#e8f4ff';
1633      ctx2.fillRect(0, 0, w, h);
1634      ctx2.restore();
1635    }
1636    // The bolt itself — only visible near strike window
1637    if (cyc >= strikeStart - 0.02 && cyc <= strikeEnd + 0.15) {
1638      const boltAlpha = cyc < strikeStart
1639        ? (cyc - (strikeStart - 0.02)) / 0.02
1640        : cyc <= strikeEnd ? 1 : Math.max(0, 1 - (cyc - strikeEnd) / 0.15);
1641      ctx2.save();
1642      ctx2.lineCap = 'round';
1643      ctx2.lineJoin = 'round';
1644      // Violet halo
1645      ctx2.globalAlpha = boltAlpha * 0.55;
1646      ctx2.strokeStyle = '#8a6cff';
1647      ctx2.lineWidth = 7;
1648      ctx2.beginPath();
1649      ctx2.moveTo(boltPts[0].x, boltPts[0].y);
1650      for (let i = 1; i < boltPts.length; i++) ctx2.lineTo(boltPts[i].x, boltPts[i].y);
1651      ctx2.stroke();
1652      // Cyan mid
1653      ctx2.globalAlpha = boltAlpha * 0.9;
1654      ctx2.strokeStyle = '#62e4ff';
1655      ctx2.lineWidth = 3.2;
1656      ctx2.stroke();
1657      // White core
1658      ctx2.globalAlpha = boltAlpha;
1659      ctx2.strokeStyle = '#ffffff';
1660      ctx2.lineWidth = 1.3;
1661      ctx2.stroke();
1662      // One branch fork
1663      const fi = 3;
1664      const fp = boltPts[fi];
1665      const fx = fp.x + (rand() - 0.5) * w * 0.25;
1666      const fy = fp.y + h * 0.18;
1667      ctx2.globalAlpha = boltAlpha * 0.85;
1668      ctx2.strokeStyle = '#aecfff';
1669      ctx2.lineWidth = 1.8;
1670      ctx2.beginPath();
1671      ctx2.moveTo(fp.x, fp.y);
1672      ctx2.lineTo((fp.x + fx) * 0.5 + (rand() - 0.5) * 6, (fp.y + fy) * 0.5);
1673      ctx2.lineTo(fx, fy);
1674      ctx2.stroke();
1675      ctx2.restore();
1676    }
1677    // Impact bloom and shockwave rings at ground
1678    const gx = boltPts[boltPts.length - 1].x;
1679    const gy = boltPts[boltPts.length - 1].y;
1680    if (cyc >= strikeStart && cyc <= strikeStart + 0.5) {
1681      const sU = (cyc - strikeStart) / 0.5;
1682      // Bloom
1683      ctx2.save();
1684      ctx2.globalAlpha = (1 - sU) * 0.85;
1685      const bl = ctx2.createRadialGradient(gx, gy, 0, gx, gy, 18 + sU * 22);
1686      bl.addColorStop(0, '#ffffff');
1687      bl.addColorStop(0.4, '#9edcff');
1688      bl.addColorStop(1, 'rgba(60,120,220,0)');
1689      ctx2.fillStyle = bl;
1690      ctx2.beginPath(); ctx2.arc(gx, gy, 18 + sU * 22, 0, Math.PI * 2); ctx2.fill();
1691      // Shockwave rings
1692      for (let rr = 0; rr < 2; rr++) {
1693        const delay = rr * 0.1;
1694        if (sU > delay) {
1695          const ru = (sU - delay) / (1 - delay);
1696          ctx2.globalAlpha = (1 - ru) * 0.7;
1697          ctx2.strokeStyle = '#b8e8ff';
1698          ctx2.lineWidth = 1.2;
1699          ctx2.beginPath(); ctx2.arc(gx, gy, 4 + ru * 26, 0, Math.PI * 2); ctx2.stroke();
1700        }
1701      }
1702      ctx2.restore();
1703    }
1704    // Sparks raining off the impact — always a few
1705    for (let s = 0; s < 6; s++) {
1706      const sp = ((t + s * 0.17) % 1);
1707      const sa = Math.sin(sp * Math.PI);
1708      ctx2.globalAlpha = sa * 0.85;
1709      ctx2.fillStyle = s % 2 === 0 ? '#e8f4ff' : '#62e4ff';
1710      const sx = gx + Math.sin(s * 1.7) * 10 + (rand() - 0.5) * 8;
1711      const sy = gy + sp * 10;
1712      ctx2.beginPath(); ctx2.arc(sx, sy, 1.1, 0, Math.PI * 2); ctx2.fill();
1713    }
1714    ctx2.globalAlpha = 1;
1715  } else if (type === 'vfx_boiler_burst') {
1716    // Drop 6 — Boiler Burst preview: a tiny brass boiler pressurizes with
1717    // cracks + pressure gauge redline, then EXPLODES every ~1.3s with
1718    // screen flash, shockwave rings, spinning gear shrapnel, steam plumes,
1719    // and golden sparks.
1720    const cycle = 1.3;
1721    const lt = (now % (cycle * 1000)) / 1000;
1722    const cyc = lt / cycle;
1723    const explodeCyc = 0.55; // when boom happens
1724    const isBuilding = cyc < explodeCyc;
1725    const ep = isBuilding ? 0 : (cyc - explodeCyc) / (1 - explodeCyc);
1726    // Dark foundry background
1727    const bg = ctx2.createLinearGradient(0, 0, 0, h);
1728    bg.addColorStop(0,   '#1a0e04');
1729    bg.addColorStop(0.6, '#2a1a08');
1730    bg.addColorStop(1,   '#10060a');
1731    ctx2.fillStyle = bg; ctx2.fillRect(0, 0, w, h);
1732    // Warm stage-wash (stronger during build)
1733    ctx2.save();
1734    ctx2.globalAlpha = isBuilding ? 0.35 : Math.max(0, 0.35 - ep * 0.4);
1735    const wg2 = ctx2.createRadialGradient(cx, cy, 0, cx, cy, Math.max(w, h) * 0.7);
1736    wg2.addColorStop(0, 'rgba(255,140,50,0.6)');
1737    wg2.addColorStop(1, 'rgba(255,140,50,0)');
1738    ctx2.fillStyle = wg2;
1739    ctx2.fillRect(0, 0, w, h);
1740    ctx2.restore();
1741    // Seeded RNG per cycle
1742    const boilSeed = Math.floor(now / (cycle * 1000));
1743    let rs = boilSeed * 9301 + 49297;
1744    const rng = () => {
1745      rs = (rs * 9301 + 49297) % 233280;
1746      return rs / 233280;
1747    };
1748    // Pre-compute shard angles
1749    const shardCount = 10;
1750    const shards = [];
1751    for (let i = 0; i < shardCount; i++) {
1752      shards.push({
1753        a: rng() * Math.PI * 2,
1754        v: 0.8 + rng() * 0.7,
1755        rotStart: rng() * Math.PI * 2,
1756        size: 2.2 + rng() * 1.8,
1757      });
1758    }
1759    // ── PRESSURE BUILD phase ──
1760    if (isBuilding) {
1761      const bp = cyc / explodeCyc;
1762      const shiver = bp > 0.6 ? (Math.sin(now * 0.09) * 1.2 * (bp - 0.6) / 0.4) : 0;
1763      const bx = cx + shiver;
1764      const by = cy + shiver * 0.3;
1765      const bW = Math.min(w, h) * 0.42;
1766      const bH = Math.min(w, h) * 0.52;
1767      ctx2.save();
1768      // Boiler body
1769      const bg2 = ctx2.createLinearGradient(bx - bW * 0.5, by, bx + bW * 0.5, by);
1770      bg2.addColorStop(0,   '#6a4a14');
1771      bg2.addColorStop(0.5, '#d5a848');
1772      bg2.addColorStop(1,   '#5a3c0e');
1773      ctx2.fillStyle = bg2;
1774      ctx2.beginPath();
1775      ctx2.moveTo(bx - bW * 0.5, by);
1776      ctx2.lineTo(bx - bW * 0.5, by + bH * 0.5);
1777      ctx2.lineTo(bx + bW * 0.5, by + bH * 0.5);
1778      ctx2.lineTo(bx + bW * 0.5, by);
1779      ctx2.arc(bx, by, bW * 0.5, 0, Math.PI, true);
1780      ctx2.closePath();
1781      ctx2.fill();
1782      ctx2.strokeStyle = '#2a1a08';
1783      ctx2.lineWidth = 1.3;
1784      ctx2.stroke();
1785      // Glowing cracks (later half)
1786      if (bp > 0.5) {
1787        const cp = (bp - 0.5) / 0.5;
1788        const glow = 0.5 + Math.abs(Math.sin(now * 0.04)) * 0.5;
1789        ctx2.globalAlpha = cp * glow;
1790        ctx2.strokeStyle = '#ffd060';
1791        ctx2.lineWidth = 1.2;
1792        ctx2.lineCap = 'round';
1793        for (let cc = 0; cc < 3; cc++) {
1794          ctx2.beginPath();
1795          let px = bx + (rng() - 0.5) * bW * 0.6;
1796          let py = by + (rng() - 0.3) * bH * 0.5;
1797          ctx2.moveTo(px, py);
1798          for (let k = 0; k < 3; k++) {
1799            px += (rng() - 0.5) * 8;
1800            py += (rng() - 0.5) * 5;
1801            ctx2.lineTo(px, py);
1802          }
1803          ctx2.stroke();
1804        }
1805        ctx2.globalAlpha = 1;
1806      }
1807      // Pressure gauge
1808      ctx2.fillStyle = '#1a1004';
1809      ctx2.beginPath();
1810      ctx2.arc(bx, by + bH * 0.15, bW * 0.13, 0, Math.PI * 2);
1811      ctx2.fill();
1812      ctx2.strokeStyle = '#f2cf6a';
1813      ctx2.lineWidth = 0.8;
1814      ctx2.stroke();
1815      const needleA = -Math.PI * 0.75 + bp * Math.PI * 1.5;
1816      ctx2.strokeStyle = bp > 0.7 ? '#ff3010' : '#ffd060';
1817      ctx2.lineWidth = 1.1;
1818      ctx2.lineCap = 'round';
1819      ctx2.beginPath();
1820      ctx2.moveTo(bx, by + bH * 0.15);
1821      ctx2.lineTo(bx + Math.cos(needleA) * bW * 0.1, by + bH * 0.15 + Math.sin(needleA) * bW * 0.1);
1822      ctx2.stroke();
1823      // Steam whistle puffs
1824      if (bp > 0.25) {
1825        for (let pf = 0; pf < 3; pf++) {
1826          const pfPh = ((cyc * 5 + pf * 0.33) % 1);
1827          const pfA = (1 - pfPh) * Math.min(1, (bp - 0.25) / 0.3) * 0.65;
1828          const pfY = by - bW * 0.5 - 2 - pfPh * bH * 0.6;
1829          const pfX = bx + (pf - 1) * 3 + Math.sin(pfPh * 6) * 3;
1830          const pfR = 2 + pfPh * 6;
1831          ctx2.globalAlpha = pfA;
1832          const pfG = ctx2.createRadialGradient(pfX, pfY, 0, pfX, pfY, pfR);
1833          pfG.addColorStop(0,   'rgba(255,255,255,0.85)');
1834          pfG.addColorStop(1,   'rgba(180,180,200,0)');
1835          ctx2.fillStyle = pfG;
1836          ctx2.beginPath();
1837          ctx2.arc(pfX, pfY, pfR, 0, Math.PI * 2);
1838          ctx2.fill();
1839        }
1840      }
1841      ctx2.restore();
1842    }
1843    // ── EXPLOSION phase ──
1844    if (!isBuilding) {
1845      // Full-screen flash (first 15%)
1846      if (ep < 0.2) {
1847        const fi = Math.max(0, 1 - ep / 0.2) * 0.85;
1848        ctx2.save();
1849        ctx2.globalAlpha = fi;
1850        ctx2.fillStyle = '#fff4d0';
1851        ctx2.fillRect(0, 0, w, h);
1852        ctx2.restore();
1853      }
1854      // Impact bloom
1855      if (ep < 0.55) {
1856        const ih = ep / 0.55;
1857        const iR = Math.min(w, h) * (0.2 + ih * 0.7);
1858        ctx2.save();
1859        ctx2.globalAlpha = (1 - ih) * 0.9;
1860        const ig = ctx2.createRadialGradient(cx, cy, 0, cx, cy, iR);
1861        ig.addColorStop(0,   'rgba(255,255,255,0.98)');
1862        ig.addColorStop(0.25,'rgba(255,220,140,0.9)');
1863        ig.addColorStop(0.55,'rgba(255,150,50,0.65)');
1864        ig.addColorStop(1,   'rgba(120,40,10,0)');
1865        ctx2.fillStyle = ig;
1866        ctx2.beginPath(); ctx2.arc(cx, cy, iR, 0, Math.PI * 2); ctx2.fill();
1867        ctx2.restore();
1868      }
1869      // Concussion rings (2)
1870      for (let rr = 0; rr < 2; rr++) {
1871        const delay = rr * 0.1;
1872        if (ep > delay) {
1873          const ru = (ep - delay) / (1 - delay);
1874          if (ru < 1) {
1875            ctx2.save();
1876            ctx2.globalAlpha = (1 - ru) * 0.75;
1877            ctx2.strokeStyle = rr === 0 ? '#ffb040' : 'rgba(255,230,160,0.85)';
1878            ctx2.lineWidth = (3 - rr) * (1 - ru) + 0.8;
1879            ctx2.beginPath();
1880            ctx2.arc(cx, cy, 6 + ru * Math.min(w, h) * 0.55, 0, Math.PI * 2);
1881            ctx2.stroke();
1882            ctx2.restore();
1883          }
1884        }
1885      }
1886      // Gear shrapnel (spinning, with gravity)
1887      for (let i = 0; i < shards.length; i++) {
1888        const s = shards[i];
1889        const d = ep * Math.min(w, h) * 0.5 * s.v;
1890        const gx2 = cx + Math.cos(s.a) * d;
1891        const gy2 = cy + Math.sin(s.a) * d - ep * Math.min(w, h) * 0.18 + ep * ep * Math.min(w, h) * 0.35;
1892        const ga = Math.max(0, 1 - ep * 1.05);
1893        ctx2.save();
1894        ctx2.globalAlpha = ga;
1895        ctx2.translate(gx2, gy2);
1896        ctx2.rotate(s.rotStart + ep * 12);
1897        const ts = 8;
1898        ctx2.beginPath();
1899        for (let t = 0; t < ts * 2; t++) {
1900          const ta = (t / (ts * 2)) * Math.PI * 2;
1901          const tr = (t % 2 === 0) ? s.size : s.size * 0.68;
1902          const tx = Math.cos(ta) * tr, ty = Math.sin(ta) * tr;
1903          if (t === 0) ctx2.moveTo(tx, ty); else ctx2.lineTo(tx, ty);
1904        }
1905        ctx2.closePath();
1906        const gg = ctx2.createRadialGradient(0, 0, 0, 0, 0, s.size);
1907        gg.addColorStop(0,   '#f2cf6a');
1908        gg.addColorStop(0.7, '#a87a26');
1909        gg.addColorStop(1,   '#3a2708');
1910        ctx2.fillStyle = gg;
1911        ctx2.fill();
1912        ctx2.strokeStyle = '#2a1a08';
1913        ctx2.lineWidth = 0.4;
1914        ctx2.stroke();
1915        ctx2.restore();
1916      }
1917      // Steam plumes (4 rising)
1918      for (let s = 0; s < 4; s++) {
1919        const sang = -Math.PI * 0.5 + (s - 1.5) * 0.3;
1920        const spp = Math.min(ep / 0.75, 1);
1921        const sx = cx + Math.cos(sang) * Math.min(w, h) * 0.4 * spp;
1922        const sy = cy + Math.sin(sang) * Math.min(w, h) * 0.5 * spp - Math.min(w, h) * 0.08 * spp;
1923        const sa2 = (1 - spp) * 0.5;
1924        const sr2 = 3 + spp * Math.min(w, h) * 0.2;
1925        ctx2.save();
1926        ctx2.globalAlpha = sa2;
1927        const sg = ctx2.createRadialGradient(sx, sy, 0, sx, sy, sr2);
1928        sg.addColorStop(0,   'rgba(255,255,255,0.85)');
1929        sg.addColorStop(0.55,'rgba(220,220,230,0.4)');
1930        sg.addColorStop(1,   'rgba(180,180,200,0)');
1931        ctx2.fillStyle = sg;
1932        ctx2.beginPath();
1933        ctx2.arc(sx, sy, sr2, 0, Math.PI * 2);
1934        ctx2.fill();
1935        ctx2.restore();
1936      }
1937      // Golden sparks
1938      if (ep < 0.65) {
1939        const kp = ep / 0.65;
1940        for (let k = 0; k < 8; k++) {
1941          const kang = (k / 8) * Math.PI * 2 + rng() * 0.3;
1942          const kd = kp * Math.min(w, h) * 0.42;
1943          const kx = cx + Math.cos(kang) * kd;
1944          const ky = cy + Math.sin(kang) * kd - kp * Math.min(w, h) * 0.15 + kp * kp * Math.min(w, h) * 0.25;
1945          const ka = (1 - kp) * 0.95;
1946          ctx2.save();
1947          ctx2.globalAlpha = ka;
1948          ctx2.strokeStyle = k % 2 === 0 ? '#ffd060' : '#ffffff';
1949          ctx2.lineWidth = 1.3;
1950          ctx2.lineCap = 'round';
1951          const kLen = 5 * (1 - kp * 0.5);
1952          ctx2.beginPath();
1953          ctx2.moveTo(kx, ky);
1954          ctx2.lineTo(kx + Math.cos(kang) * kLen, ky + Math.sin(kang) * kLen);
1955          ctx2.stroke();
1956          ctx2.restore();
1957        }
1958      }
1959    }
1960    ctx2.globalAlpha = 1;
1961  } else if (type === 'vfx_singularity_collapse') {
1962    // Drop 7 — Singularity Collapse preview: a tight loop showing the
1963    // gather → implode → white-out → rainbow nova → pulse-out cycle.
1964    const cycle = 2.4; // seconds per full loop
1965    const lt = (now % (cycle * 1000)) / 1000;
1966    const p = lt / cycle; // 0..1
1967    // Phase boundaries
1968    const GATHER_END = 0.45;
1969    const FLASH_END  = 0.54;
1970    const NOVA_END   = 0.82;
1971    // Deep-space backdrop
1972    const bg = ctx2.createRadialGradient(cx, cy, 0, cx, cy, Math.max(w, h) * 0.7);
1973    bg.addColorStop(0,   '#0a0220');
1974    bg.addColorStop(0.6, '#060010');
1975    bg.addColorStop(1,   '#000000');
1976    ctx2.fillStyle = bg; ctx2.fillRect(0, 0, w, h);
1977    // Seeded RNG per cycle
1978    const seed0 = Math.floor(now / (cycle * 1000));
1979    let rs = seed0 * 9301 + 49297;
1980    const rng = () => { rs = (rs * 9301 + 49297) % 233280; return rs / 233280; };
1981    const maxR = Math.min(w, h) * 0.48;
1982    // ── Background star streaks pulled toward center ──
1983    for (let s = 0; s < 14; s++) {
1984      const ang = rng() * Math.PI * 2;
1985      const startD = maxR * (0.55 + rng() * 0.5);
1986      let dd;
1987      if (p < GATHER_END) {
1988        const gp = p / GATHER_END;
1989        dd = startD * (1 - gp * 0.95);
1990      } else if (p < FLASH_END) {
1991        dd = startD * 0.05;
1992      } else {
1993        dd = startD * ((p - FLASH_END) / (1 - FLASH_END)) * 0.9;
1994      }
1995      const sx = cx + Math.cos(ang) * dd;
1996      const sy = cy + Math.sin(ang) * dd;
1997      const tailLen = (p < FLASH_END ? 4 + (GATHER_END - Math.min(p, GATHER_END)) * 0 + p * 18 : 3) * (0.6 + rng() * 0.8);
1998      const tx = cx + Math.cos(ang) * (dd + tailLen);
1999      const ty = cy + Math.sin(ang) * (dd + tailLen);
2000      ctx2.save();
2001      ctx2.globalAlpha = p < FLASH_END ? 0.65 * (1 - Math.abs(p - GATHER_END) / GATHER_END) + 0.3 : 0.35;
2002      ctx2.strokeStyle = '#b98cff';
2003      ctx2.lineWidth = 0.9;
2004      ctx2.beginPath(); ctx2.moveTo(sx, sy); ctx2.lineTo(tx, ty); ctx2.stroke();
2005      ctx2.restore();
2006    }
2007    // ── GATHER phase: swarm particles spiral inward ──
2008    if (p < FLASH_END) {
2009      const gp = Math.min(1, p / GATHER_END);
2010      const swarmCount = 22;
2011      for (let i = 0; i < swarmCount; i++) {
2012        const baseAng = (i / swarmCount) * Math.PI * 2 + rng() * 0.4;
2013        const startD = maxR * (0.75 + rng() * 0.3);
2014        const spiralTurns = 1.6 + rng() * 0.9;
2015        const ang = baseAng + gp * spiralTurns * Math.PI * 2;
2016        const dd = startD * (1 - Math.pow(gp, 1.5));
2017        const sx = cx + Math.cos(ang) * dd;
2018        const sy = cy + Math.sin(ang) * dd;
2019        ctx2.save();
2020        ctx2.globalAlpha = 0.85 * (0.4 + gp * 0.6);
2021        const hue = 260 + rng() * 30; // violet
2022        ctx2.fillStyle = gp > 0.8 ? '#ffffff' : `hsl(${hue}, 80%, ${55 + gp * 30}%)`;
2023        const rr = 0.9 + (1 - gp) * 1.2;
2024        ctx2.beginPath(); ctx2.arc(sx, sy, rr, 0, Math.PI * 2); ctx2.fill();
2025        // Trail
2026        const tx = cx + Math.cos(ang - 0.25) * (dd + 2.2);
2027        const ty = cy + Math.sin(ang - 0.25) * (dd + 2.2);
2028        ctx2.globalAlpha *= 0.5;
2029        ctx2.strokeStyle = `hsl(${hue}, 80%, 70%)`;
2030        ctx2.lineWidth = 0.8;
2031        ctx2.beginPath(); ctx2.moveTo(sx, sy); ctx2.lineTo(tx, ty); ctx2.stroke();
2032        ctx2.restore();
2033      }
2034      // Core pulsing violet glow (grows as particles arrive)
2035      const coreR = 2 + gp * 10;
2036      const coreG = ctx2.createRadialGradient(cx, cy, 0, cx, cy, coreR * 2);
2037      coreG.addColorStop(0, `rgba(220,180,255,${0.6 + gp * 0.35})`);
2038      coreG.addColorStop(0.5, `rgba(140,80,220,${0.4 + gp * 0.3})`);
2039      coreG.addColorStop(1, 'rgba(60,20,120,0)');
2040      ctx2.fillStyle = coreG;
2041      ctx2.beginPath(); ctx2.arc(cx, cy, coreR * 2, 0, Math.PI * 2); ctx2.fill();
2042      // Dark void dot at exact center
2043      ctx2.fillStyle = '#000000';
2044      ctx2.beginPath(); ctx2.arc(cx, cy, Math.max(0.6, 2 - gp * 1.4), 0, Math.PI * 2); ctx2.fill();
2045      // Lensing rings tightening
2046      ctx2.save();
2047      ctx2.strokeStyle = `rgba(200,150,255,${0.25 + gp * 0.4})`;
2048      ctx2.lineWidth = 0.8;
2049      for (let r2 = 0; r2 < 2; r2++) {
2050        const rr = maxR * 0.6 * (1 - gp * 0.85) + r2 * 3;
2051        ctx2.beginPath(); ctx2.arc(cx, cy, rr, 0, Math.PI * 2); ctx2.stroke();
2052      }
2053      ctx2.restore();
2054    }
2055    // ── FLASH phase: blinding white-out ──
2056    if (p >= GATHER_END && p < FLASH_END + 0.05) {
2057      const fp = (p - GATHER_END) / (FLASH_END - GATHER_END + 0.05);
2058      const flashA = Math.sin(fp * Math.PI);
2059      ctx2.save();
2060      ctx2.globalAlpha = Math.max(0, flashA) * 0.95;
2061      const fg = ctx2.createRadialGradient(cx, cy, 0, cx, cy, Math.max(w, h) * 0.7);
2062      fg.addColorStop(0,   '#ffffff');
2063      fg.addColorStop(0.4, '#f0d8ff');
2064      fg.addColorStop(1,   'rgba(180,120,255,0)');
2065      ctx2.fillStyle = fg; ctx2.fillRect(0, 0, w, h);
2066      ctx2.restore();
2067    }
2068    // ── NOVA phase: rainbow particle explosion + shockwaves ──
2069    if (p >= FLASH_END) {
2070      const np = (p - FLASH_END) / (NOVA_END - FLASH_END);
2071      const novaCount = 24;
2072      for (let i = 0; i < novaCount; i++) {
2073        const ang = (i / novaCount) * Math.PI * 2 + rng() * 0.3;
2074        const spd = 0.7 + rng() * 0.7;
2075        const dd = np * maxR * spd;
2076        // Ballistic drop
2077        const px = cx + Math.cos(ang) * dd;
2078        const py = cy + Math.sin(ang) * dd + np * np * 4;
2079        const a = Math.max(0, 1 - np) * 0.95;
2080        const hue = (i / novaCount) * 360;
2081        ctx2.save();
2082        ctx2.globalAlpha = a;
2083        ctx2.fillStyle = `hsl(${hue}, 90%, 65%)`;
2084        ctx2.beginPath(); ctx2.arc(px, py, 1.5 + (1 - np) * 0.8, 0, Math.PI * 2); ctx2.fill();
2085        // Streak tail
2086        const tx = cx + Math.cos(ang) * dd * 0.78;
2087        const ty = cy + Math.sin(ang) * dd * 0.78;
2088        ctx2.globalAlpha = a * 0.55;
2089        ctx2.strokeStyle = `hsl(${hue}, 90%, 75%)`;
2090        ctx2.lineWidth = 0.9;
2091        ctx2.beginPath(); ctx2.moveTo(px, py); ctx2.lineTo(tx, ty); ctx2.stroke();
2092        ctx2.restore();
2093      }
2094      // Triple shockwave rings
2095      for (let r = 0; r < 3; r++) {
2096        const rp = Math.max(0, Math.min(1, np - r * 0.12));
2097        if (rp <= 0) continue;
2098        const rr = rp * maxR * 1.05;
2099        const ra = Math.max(0, 1 - rp) * 0.85;
2100        ctx2.save();
2101        ctx2.globalAlpha = ra;
2102        ctx2.strokeStyle = r === 0 ? '#ffffff' : (r === 1 ? '#e0c8ff' : '#a060ff');
2103        ctx2.lineWidth = 1.2;
2104        ctx2.beginPath(); ctx2.arc(cx, cy, rr, 0, Math.PI * 2); ctx2.stroke();
2105        ctx2.restore();
2106      }
2107      // Residual violet glow
2108      ctx2.save();
2109      ctx2.globalAlpha = Math.max(0, 1 - np) * 0.5;
2110      const rg = ctx2.createRadialGradient(cx, cy, 0, cx, cy, maxR * 0.6);
2111      rg.addColorStop(0, 'rgba(220,180,255,0.5)');
2112      rg.addColorStop(1, 'rgba(100,40,180,0)');
2113      ctx2.fillStyle = rg;
2114      ctx2.beginPath(); ctx2.arc(cx, cy, maxR * 0.6, 0, Math.PI * 2); ctx2.fill();
2115      ctx2.restore();
2116    }
2117    ctx2.globalAlpha = 1;
2118  } else if (type === 'vfx_aurora_sweep') {
2119    // Drop 8 — Aurora Sweep preview: 3 drifting aurora ribbons
2120    // (green/teal/violet) undulate across a midnight backdrop with a
2121    // subtle star dust. Loops continuously so the shop tile feels alive.
2122    // Matches the in-game sweep aesthetic (just simplified for the tile).
2123    // Midnight backdrop
2124    const sg = ctx2.createLinearGradient(0, 0, 0, h);
2125    sg.addColorStop(0,   '#0a1438');
2126    sg.addColorStop(0.6, '#050820');
2127    sg.addColorStop(1,   '#02030e');
2128    ctx2.fillStyle = sg;
2129    ctx2.fillRect(0, 0, w, h);
2130    // Tiny star dust — static deterministic dots
2131    ctx2.fillStyle = '#ffffff';
2132    for (let s = 0; s < 14; s++) {
2133      const ss = s * 137.508;
2134      const sx = (ss * 0.618) % w;
2135      const sy = ((ss * 0.382) % (h * 0.6));
2136      const tw = 0.4 + Math.sin(now * 0.0015 + s) * 0.3;
2137      ctx2.globalAlpha = tw;
2138      ctx2.fillRect(sx, sy, 1, 1);
2139    }
2140    ctx2.globalAlpha = 1;
2141    // Aurora ribbons — 3 bands with additive blending
2142    ctx2.save();
2143    ctx2.globalCompositeOperation = 'lighter';
2144    const ribs = [
2145      { col:'80,255,170',  yBase:h*0.25, amp:h*0.05, thick:h*0.22, speed:0.0018, phase:0.0 },
2146      { col:'110,220,255', yBase:h*0.45, amp:h*0.07, thick:h*0.24, speed:0.0014, phase:1.3 },
2147      { col:'190,120,255', yBase:h*0.65, amp:h*0.05, thick:h*0.20, speed:0.0022, phase:2.6 },
2148    ];
2149    // Horizontal drift progress (loops every 3.2s)
2150    const drift = ((now % 3200) / 3200 - 0.5) * w * 0.4;
2151    for (let r = 0; r < ribs.length; r++) {
2152      const rb = ribs[r];
2153      ctx2.beginPath();
2154      const seg = 14;
2155      for (let i = 0; i <= seg; i++) {
2156        const u = i / seg;
2157        const x = u * w + drift;
2158        const y = rb.yBase
2159                + Math.sin(u * Math.PI * 2.0 + rb.phase + now * rb.speed) * rb.amp
2160                + Math.sin(u * Math.PI * 5.0 + rb.phase * 1.7) * rb.amp * 0.3;
2161        if (i === 0) ctx2.moveTo(x, y); else ctx2.lineTo(x, y);
2162      }
2163      const gy0 = rb.yBase - rb.thick * 0.5;
2164      const gy1 = rb.yBase + rb.thick * 0.7;
2165      const g = ctx2.createLinearGradient(0, gy0, 0, gy1);
2166      g.addColorStop(0,    `rgba(${rb.col},0)`);
2167      g.addColorStop(0.3,  `rgba(${rb.col},0.55)`);
2168      g.addColorStop(0.6,  `rgba(${rb.col},0.45)`);
2169      g.addColorStop(1,    `rgba(${rb.col},0)`);
2170      ctx2.strokeStyle = g;
2171      ctx2.lineWidth = rb.thick;
2172      ctx2.lineCap = 'round';
2173      ctx2.stroke();
2174    }
2175    ctx2.restore();
2176  } else if (type === 'vfx_champions_announce') {
2177    // Drop 9 — Champion's Announce preview: a looping WINNER pulse on a
2178    // deep-navy background with a soft gold halo, plus a drift of gold
2179    // flakes. Short loop (~2.4s) keeps the tile alive without stealing
2180    // attention from the full-screen moment in-game.
2181    // Backdrop
2182    const bg = ctx2.createRadialGradient(w * 0.5, h * 0.45, 0, w * 0.5, h * 0.45, Math.max(w, h));
2183    bg.addColorStop(0, '#111634');
2184    bg.addColorStop(0.7, '#070a1e');
2185    bg.addColorStop(1, '#03040e');
2186    ctx2.fillStyle = bg;
2187    ctx2.fillRect(0, 0, w, h);
2188    // Loop phase — 0..1 every 2400ms
2189    const loop = (now % 2400) / 2400;
2190    // WINNER text — scales in (0..0.45), holds (0.45..0.75), fades (0.75..1)
2191    let tscale = 1, talpha = 1;
2192    if (loop < 0.45) {
2193      const e = 1 - Math.pow(1 - loop / 0.45, 3);
2194      tscale = 0.5 + 0.5 * e;
2195      talpha = e;
2196    } else if (loop < 0.75) {
2197      tscale = 1 + Math.sin((loop - 0.45) * 20) * 0.02;
2198      talpha = 1;
2199    } else {
2200      const f = (loop - 0.75) / 0.25;
2201      tscale = 1;
2202      talpha = 1 - f;
2203    }
2204    // Halo behind the text
2205    if (talpha > 0.02) {
2206      const haloR = Math.min(w, h) * 0.55 * tscale;
2207      const halo = ctx2.createRadialGradient(w * 0.5, h * 0.5, 0, w * 0.5, h * 0.5, haloR);
2208      halo.addColorStop(0, `rgba(255,220,120,${0.35 * talpha})`);
2209      halo.addColorStop(1, 'rgba(255,220,120,0)');
2210      ctx2.fillStyle = halo;
2211      ctx2.fillRect(0, 0, w, h);
2212    }
2213    // Text — small "WINNER" scaled for the preview tile
2214    ctx2.save();
2215    ctx2.translate(w * 0.5, h * 0.5);
2216    ctx2.scale(tscale, tscale);
2217    ctx2.globalAlpha = talpha;
2218    ctx2.textAlign = 'center';
2219    ctx2.textBaseline = 'middle';
2220    const fpx = Math.max(10, Math.min(w, h) * 0.22);
2221    ctx2.font = `900 ${fpx}px system-ui, -apple-system, "Helvetica Neue", sans-serif`;
2222    ctx2.fillStyle = '#ffe28a';
2223    ctx2.fillText('WINNER', 0, 0);
2224    ctx2.strokeStyle = '#6b4a12';
2225    ctx2.lineWidth = Math.max(1, fpx * 0.045);
2226    ctx2.strokeText('WINNER', 0, 0);
2227    ctx2.restore();
2228    // A few drifting gold flakes — deterministic + looping
2229    ctx2.save();
2230    for (let i = 0; i < 6; i++) {
2231      const p = ((loop + i * 0.17) % 1);
2232      const fx = (i * 73.3) % w;
2233      const fy = h * (0.15 + 0.75 * p);
2234      const rot = p * Math.PI * 2 + i;
2235      ctx2.save();
2236      ctx2.translate(fx, fy);
2237      ctx2.rotate(rot);
2238      ctx2.globalAlpha = 0.7 * (1 - Math.abs(p - 0.5) * 1.2);
2239      ctx2.fillStyle = '#ffd96a';
2240      ctx2.fillRect(-2, -1.2, 4, 1.2);
2241      ctx2.fillStyle = '#a87c20';
2242      ctx2.fillRect(-2, 0, 4, 1.2);
2243      ctx2.restore();
2244    }
2245    ctx2.restore();
2246    ctx2.globalAlpha = 1;
2247  } else if (type === 'vfx_panda_stampede') {
2248    // Drop 10 — Panda Stampede preview: jade backdrop, two tiny pandas
2249    // tumble across, bamboo shoot pulses up from below, leaves drift.
2250    // Loop ~2.4s.
2251    ctx2.fillStyle = '#1a3520';
2252    ctx2.fillRect(0, 0, w, h);
2253    const loop = (now % 2400) / 2400;
2254    // Bamboo shoot pulse from bottom — grows up over first half, holds, fades
2255    const shootProg = Math.min(1, loop * 2.2);
2256    const shootApex = h * 0.3 + (1 - shootProg) * h * 0.6;
2257    const shootX = w * 0.5;
2258    ctx2.fillStyle = '#5fa050';
2259    ctx2.fillRect(shootX - 2, shootApex, 4, h - shootApex);
2260    // 2 leaves at apex
2261    if (shootProg > 0.5) {
2262      const u = (shootProg - 0.5) * 2;
2263      ctx2.save(); ctx2.translate(shootX, shootApex);
2264      ctx2.fillStyle = '#7bbf6a';
2265      ctx2.beginPath(); ctx2.ellipse(-3 - u * 4, -1, 5 * u, 1.6, -0.5, 0, Math.PI * 2); ctx2.fill();
2266      ctx2.beginPath(); ctx2.ellipse(3 + u * 4, -1, 5 * u, 1.6, 0.5, 0, Math.PI * 2); ctx2.fill();
2267      ctx2.restore();
2268    }
2269    // 2 tumbling pandas — one each direction
2270    for (let i = 0; i < 2; i++) {
2271      const dir = i === 0 ? 1 : -1;
2272      const startX = dir > 0 ? -10 : w + 10;
2273      const px = startX + dir * loop * (w + 20);
2274      const py = h * (0.35 + i * 0.30) + Math.sin(loop * Math.PI * 4 + i) * 2;
2275      const rot = loop * Math.PI * 4 * dir;
2276      const s = 6;
2277      ctx2.save();
2278      ctx2.translate(px, py);
2279      ctx2.rotate(rot);
2280      ctx2.fillStyle = '#f4ede4';
2281      ctx2.beginPath(); ctx2.arc(0, 0, s, 0, Math.PI * 2); ctx2.fill();
2282      ctx2.fillStyle = '#1a1410';
2283      ctx2.beginPath(); ctx2.arc(-s * 0.55, -s * 0.55, s * 0.32, 0, Math.PI * 2); ctx2.fill();
2284      ctx2.beginPath(); ctx2.arc(s * 0.55, -s * 0.55, s * 0.32, 0, Math.PI * 2); ctx2.fill();
2285      // tiny eye patches
2286      ctx2.beginPath(); ctx2.ellipse(-s * 0.30, 0, s * 0.18, s * 0.26, -0.4, 0, Math.PI * 2); ctx2.fill();
2287      ctx2.beginPath(); ctx2.ellipse(s * 0.30, 0, s * 0.18, s * 0.26, 0.4, 0, Math.PI * 2); ctx2.fill();
2288      ctx2.restore();
2289    }
2290    // Falling leaves
2291    for (let i = 0; i < 5; i++) {
2292      const lx = ((i * 73.3 + loop * 30) % w);
2293      const ly = ((i * 41.7 + loop * h * 1.4) % h);
2294      ctx2.save(); ctx2.translate(lx, ly); ctx2.rotate(loop * 4 + i);
2295      ctx2.fillStyle = i % 2 ? '#7bbf6a' : '#5fa050';
2296      ctx2.globalAlpha = 0.7;
2297      ctx2.beginPath(); ctx2.ellipse(0, 0, 3, 1, 0, 0, Math.PI * 2); ctx2.fill();
2298      ctx2.restore();
2299    }
2300    ctx2.globalAlpha = 1;
2301  } else if (type === 'vfx_pastel_bloom') {
2302    // v153.132 — Pastel Bloom preview: 3 expanding pastel rings + a few
2303    // soft hex petals drifting + a pearly center halo. Mirrors the
2304    // full-screen FX's gentle, slow-bloom feel.
2305    const pastel = ['#ffb8d4', '#a9d4ff', '#b8f0d4', '#ffe9a8', '#d4c4ff'];
2306    // Pastel wash backdrop
2307    const washGrad = ctx2.createLinearGradient(0, 0, 0, h);
2308    washGrad.addColorStop(0,   'rgba(255,184,212,0.18)');
2309    washGrad.addColorStop(0.5, 'rgba(212,196,255,0.14)');
2310    washGrad.addColorStop(1,   'rgba(184,240,212,0.16)');
2311    ctx2.fillStyle = washGrad;
2312    ctx2.fillRect(0, 0, w, h);
2313    // 3 staggered concentric rings
2314    for (let i = 0; i < 3; i++) {
2315      const rPhase = (t + i * 0.33) % 1;
2316      const r = rPhase * Math.min(w, h) * 0.55;
2317      const ringAlpha = (1 - rPhase) * 0.85;
2318      ctx2.globalAlpha = ringAlpha;
2319      ctx2.strokeStyle = pastel[i % pastel.length];
2320      ctx2.lineWidth = 1.6;
2321      ctx2.beginPath();
2322      ctx2.arc(cx, cy, Math.max(1, r), 0, Math.PI * 2);
2323      ctx2.stroke();
2324    }
2325    ctx2.globalAlpha = 1;
2326    // 6 small drifting hex petals
2327    for (let i = 0; i < 6; i++) {
2328      const seed = i * 0.171 + t;
2329      const px = ((seed * 67) % 1) * w + Math.sin(now * 0.001 + i) * 2;
2330      const py = ((t + i * 0.16) % 1) * h;
2331      const sz = 3;
2332      ctx2.save();
2333      ctx2.translate(px, py);
2334      ctx2.rotate(now * 0.0008 + i);
2335      ctx2.fillStyle = pastel[i % pastel.length];
2336      ctx2.globalAlpha = 0.85;
2337      ctx2.beginPath();
2338      for (let k = 0; k < 6; k++) {
2339        const a = (Math.PI / 3) * k;
2340        const x = Math.cos(a) * sz, y = Math.sin(a) * sz * 0.65;
2341        k === 0 ? ctx2.moveTo(x, y) : ctx2.lineTo(x, y);
2342      }
2343      ctx2.closePath();
2344      ctx2.fill();
2345      ctx2.restore();
2346    }
2347    ctx2.globalAlpha = 1;
2348    // Pulsing pearly center halo
2349    const haloR = 4 + cyc * 4;
2350    const halo = ctx2.createRadialGradient(cx, cy, 0, cx, cy, haloR * 2);
2351    halo.addColorStop(0,   'rgba(255,255,255,0.95)');
2352    halo.addColorStop(0.5, 'rgba(255,200,228,0.55)');
2353    halo.addColorStop(1,   'rgba(212,196,255,0)');
2354    ctx2.fillStyle = halo;
2355    ctx2.beginPath();
2356    ctx2.arc(cx, cy, haloR * 2, 0, Math.PI * 2);
2357    ctx2.fill();
2358  }
2359}
2360
2361function _miniHexPath(ctx2, cx, cy, r) {
2362  ctx2.beginPath();
2363  for (let i = 0; i < 6; i++) {
2364    const a = Math.PI / 3 * i - Math.PI / 6;
2365    const x = cx + r * Math.cos(a), y = cy + r * Math.sin(a);
2366    i === 0 ? ctx2.moveTo(x, y) : ctx2.lineTo(x, y);
2367  }
2368  ctx2.closePath();
2369}
2370
2371function _drawMiniHex(ctx2, cx, cy, r, color1, color2, customGrad) {
2372  _miniHexPath(ctx2, cx, cy, r);
2373  if (customGrad) { ctx2.fillStyle = customGrad; }
2374  else if (color2) {
2375    const g = ctx2.createRadialGradient(cx, cy - r * 0.3, 0, cx, cy, r);
2376    g.addColorStop(0, color1); g.addColorStop(1, color2);
2377    ctx2.fillStyle = g;
2378  } else { ctx2.fillStyle = color1; }
2379  ctx2.fill();
2380}
2381
2382function _strokeMiniHex(ctx2, cx, cy, r) {
2383  _miniHexPath(ctx2, cx, cy, r); ctx2.stroke();
2384}
2385
2386function _drawMiniFxPreview(ctx2, cx, cy, sz, fxType, phase, now) {
2387  const spread = sz * phase * 1.8;
2388  const alpha = (1 - phase) * 0.7;
2389  if (fxType === 'fx_fire') {
2390    for (let i = 0; i < 6; i++) {
2391      const a = (i / 6) * Math.PI * 2 + phase * 2;
2392      ctx2.globalAlpha = alpha;
2393      ctx2.fillStyle = '#ff6420';
2394      ctx2.beginPath(); ctx2.arc(cx + Math.cos(a) * spread, cy + Math.sin(a) * spread - phase * sz * 0.5, 2.5 * (1 - phase), 0, Math.PI * 2); ctx2.fill();
2395    }
2396  } else if (fxType === 'fx_stars') {
2397    for (let i = 0; i < 5; i++) {
2398      const a = (i / 5) * Math.PI * 2;
2399      const twinkle = Math.sin(now * 0.01 + i) * 0.5 + 0.5;
2400      ctx2.globalAlpha = alpha * twinkle;
2401      ctx2.fillStyle = '#ffd700';
2402      ctx2.beginPath(); ctx2.arc(cx + Math.cos(a) * spread, cy + Math.sin(a) * spread, 2 * (1 - phase * 0.5), 0, Math.PI * 2); ctx2.fill();
2403    }
2404  } else if (fxType === 'fx_ice') {
2405    for (let i = 0; i < 5; i++) {
2406      const a = (i / 5) * Math.PI * 2;
2407      ctx2.globalAlpha = alpha;
2408      ctx2.fillStyle = '#a8d8ea';
2409      ctx2.save(); ctx2.translate(cx + Math.cos(a) * spread, cy + Math.sin(a) * spread); ctx2.rotate(a);
2410      ctx2.fillRect(-1, -3, 2, 6); ctx2.restore();
2411    }
2412  } else if (fxType === 'fx_lightning') {
2413    for (let i = 0; i < 4; i++) {
2414      const a = (i / 4) * Math.PI * 2 + phase;
2415      ctx2.globalAlpha = alpha * 0.8;
2416      ctx2.strokeStyle = '#60e0ff'; ctx2.lineWidth = 1.5 * (1 - phase);
2417      ctx2.beginPath(); ctx2.moveTo(cx, cy);
2418      let bx = cx, by = cy;
2419      for (let s = 0; s < 3; s++) {
2420        bx += Math.cos(a) * spread * 0.3 + (Math.random() - 0.5) * sz * 0.2;
2421        by += Math.sin(a) * spread * 0.3 + (Math.random() - 0.5) * sz * 0.2;
2422        ctx2.lineTo(bx, by);
2423      }
2424      ctx2.stroke();
2425    }
2426  } else if (fxType === 'fx_hearts') {
2427    for (let i = 0; i < 4; i++) {
2428      const a = (i / 4) * Math.PI * 2;
2429      ctx2.globalAlpha = alpha;
2430      ctx2.fillStyle = '#ff6b95';
2431      ctx2.beginPath(); ctx2.arc(cx + Math.cos(a) * spread, cy + Math.sin(a) * spread - phase * sz * 0.8, 2, 0, Math.PI * 2); ctx2.fill();
2432    }
2433  } else if (fxType === 'fx_confetti') {
2434    const cols = ['#ff6b6b','#ffd93d','#6bcb77','#60a5fa','#f472b6'];
2435    for (let i = 0; i < 8; i++) {
2436      const a = (i / 8) * Math.PI * 2;
2437      ctx2.globalAlpha = alpha * 0.9;
2438      ctx2.fillStyle = cols[i % cols.length];
2439      ctx2.save(); ctx2.translate(cx + Math.cos(a) * spread, cy + Math.sin(a) * spread + phase * phase * sz * 0.5);
2440      ctx2.rotate(phase * 4 + i); ctx2.fillRect(-2, -1, 4, 2); ctx2.restore();
2441    }
2442  }
2443}
2444
2445// ──────────────────────────────────────────────────────────────────────────
2446// v111 — VFX_REGISTRY: single source of truth for victory-FX dispatch.
2447// Each entry binds a `vfx_*` id to:
2448//   • init        — full-screen initializer (called once when VFX launches)
2449//   • draw        — per-frame draw (returns truthy while still alive)
2450//   • burstType   — the per-hex burst.type string (rendered on each winning hex)
2451//   • flashColor  — the initial impact-flash color for that burst type
2452//
2453// Entries WITHOUT init/draw (vfx_shockwave, vfx_nebula_bloom, vfx_lightning_chain)
2454// are per-hex-only VFX — they have no full-screen overlay, so launchVictoryFx
2455// falls back to confetti for their full-screen phase.
2456//
2457// Classic-script scope: vfx.js loads BEFORE shop-data.js, so all `_init*`
2458// / `_draw*` identifiers below resolve to the functions defined there.
2459// ──────────────────────────────────────────────────────────────────────────
2460const VFX_REGISTRY = {
2461  // v153.51 — vfx_hexburst: signature default. Brand-colored hex shower
2462  // anchored on the winning chain center, with three concentric ring
2463  // pulses + a hex-shaped center flare. On-brand replacement for the
2464  // generic confetti default (which still ships, just opt-in).
2465  vfx_hexburst:             { init: _initHexBurst,       draw: _drawHexBurst,       burstType: 'hex_burst',             flashColor: '#7c6ff7' },
2466  vfx_confetti:             { init: _initConfettiPieces, draw: _drawConfettiPieces, burstType: 'confetti_burst',        flashColor: '#ffd93d' },
2467  vfx_fireworks:            { init: _initFireworks,      draw: _drawFireworks,      burstType: 'fireworks_burst',       flashColor: '#ffd93d' },
2468  vfx_golden_rain:          { init: _initGoldenRain,     draw: _drawGoldenRain,     burstType: 'golden_rain_burst',     flashColor: '#ffd93d' },
2469  vfx_petals:               { init: _initPetals,         draw: _drawPetals,         burstType: 'petals_burst',          flashColor: '#ffb3d9' },
2470  vfx_nebula:               { init: _initNebula,         draw: _drawNebulaFull,     burstType: 'nebula_burst',          flashColor: '#b565ff' },
2471  vfx_lasershow:            { init: _initLaserShow,      draw: _drawLaserShow,      burstType: 'lasershow_burst',       flashColor: '#62e9ff' },
2472  vfx_shockwave:            { init: null,                draw: null,                burstType: 'shockwave_burst',       flashColor: '#ffffff' },
2473  vfx_nebula_bloom:         { init: null,                draw: null,                burstType: 'nebula_bloom_burst',    flashColor: '#b565ff' },
2474  vfx_lightning_chain:      { init: null,                draw: null,                burstType: 'lightning_chain_burst', flashColor: '#62e9ff' },
2475  vfx_bloom_burst:          { init: _initBloomBurst,     draw: _drawBloomBurst,     burstType: 'bloom_burst',           flashColor: '#ff7cc4' },
2476  vfx_phoenix_rise:         { init: _initPhoenixRise,    draw: _drawPhoenixRise,    burstType: 'phoenix_rise',          flashColor: '#ffb020' },
2477  vfx_coin_cascade:         { init: _initCoinCascade,    draw: _drawCoinCascade,    burstType: 'coin_cascade',          flashColor: '#ffd040' },
2478  vfx_kaiju_stomp:          { init: _initKaijuStomp,     draw: _drawKaijuStomp,     burstType: 'kaiju_stomp',           flashColor: '#fff8d0' },
2479  vfx_dragon_sweep:         { init: _initDragonSweep,    draw: _drawDragonSweep,    burstType: 'dragon_sweep',          flashColor: '#ffb040' },
2480  vfx_thunderstrike:        { init: _initThunderstrike,  draw: _drawThunderstrike,  burstType: 'thunderstrike',         flashColor: '#c8e8ff' },
2481  vfx_boiler_burst:         { init: _initBoilerBurst,    draw: _drawBoilerBurst,    burstType: 'boiler_burst',          flashColor: '#ffb040' },
2482  vfx_singularity_collapse: { init: _initSingularityCollapse, draw: _drawSingularityCollapse, burstType: 'singularity_burst', flashColor: '#e0c8ff' },
2483  vfx_aurora_sweep:         { init: _initAuroraSweep,         draw: _drawAuroraSweep,         burstType: 'aurora_sweep_burst',    flashColor: '#80ffd0' },
2484  vfx_champions_announce:   { init: _initChampionsAnnounce,   draw: _drawChampionsAnnounce,   burstType: 'champions_announce_burst', flashColor: '#ffe28a' },
2485  vfx_panda_stampede:       { init: _initPandaStampede,       draw: _drawPandaStampede,       burstType: 'panda_burst',           flashColor: '#bfe8a8' },
2486  // v153.132 — Pastel Set capstone VFX. Reuses petals_burst per-hex
2487  // since it's already pastel-toned; flash is soft cotton-candy pink.
2488  vfx_pastel_bloom:         { init: _initPastelBloom,         draw: _drawPastelBloom,         burstType: 'petals_burst',          flashColor: '#ffb8d4' },
2489  // v154.46 — MYTHIC tier Victory FX. Gem-only premium celebrations.
2490  vfx_mythic_phoenix:       { init: _initMythicPhoenix,       draw: _drawMythicPhoenix,       burstType: 'phoenix_rise',          flashColor: '#ffe28a' },
2491  vfx_mythic_galactic:      { init: _initMythicGalactic,      draw: _drawMythicGalactic,      burstType: 'nebula_burst',          flashColor: '#fff8d0' },
2492};
2493
2494// v111 — FLASH_COLORS: impact-flash color lookup for ALL burst types,
2495// including non-vfx placement/skill bursts (fire_place, star_burst, etc.)
2496// that aren't part of VFX_REGISTRY. Auto-merges the vfx burst colors so
2497// adding a new vfx only needs a VFX_REGISTRY entry, never a FLASH_COLORS edit.
2498const FLASH_COLORS = Object.assign(
2499  {
2500    // Placement / skill / power-up bursts (NOT victory FX)
2501    fire_place:       '#ff6420',
2502    star_burst:       '#ffd700',
2503    ice_crystal:      '#80d4ff',
2504    lightning_strike: '#60e0ff',
2505    heart_pop:        '#ff6090',
2506  },
2507  // Auto-populate vfx bursts from the registry
2508  Object.fromEntries(
2509    Object.values(VFX_REGISTRY).map(e => [e.burstType, e.flashColor])
2510  )
2511);
2512
2513// ──────────────────────────────────────────────────────────────────────────
2514// v112 — Shop skin mini-preview dispatcher (Phase 2 of architectural cleanup).
2515// Extracted from the ~800-line if/else chain inside _animShopPreviews in
2516// index.html. Same visual output, same timing — this is a pure code-move.
2517// Called from _animShopPreviews for any canvas with previewType starting with
2518// "skin_". Helpers _drawMiniHex / _strokeMiniHex / _miniHexPath are already in
2519// this file (shop-data.js); the "lighten" helper lives in the main inline
2520// script (classic-script scope makes it visible at call time).
2521// ──────────────────────────────────────────────────────────────────────────
2522function _drawMiniSkinPreview(type, ctx2, cx, cy, sz, baseColor, now, w, h) {
2523  if (type === 'skin_gold') {
2524    // Mini gold hex with sparkles
2525    _drawMiniHex(ctx2, cx, cy, sz, '#ffd700', '#b8860b');
2526    const shimmer = 0.3 + Math.sin(now * 0.003) * 0.2;
2527    ctx2.globalAlpha = shimmer;
2528    _drawMiniHex(ctx2, cx, cy - sz * 0.15, sz * 0.4, '#fff8dc', null);
2529    ctx2.globalAlpha = 1;
2530    for (let s = 0; s < 3; s++) {
2531      const sa = now * 0.002 + s * 2.1;
2532      const sx = cx + Math.cos(sa) * sz * 0.7;
2533      const sy = cy + Math.sin(sa) * sz * 0.7;
2534      ctx2.fillStyle = '#ffd700'; ctx2.globalAlpha = 0.5 + Math.sin(now * 0.005 + s) * 0.3;
2535      ctx2.beginPath(); ctx2.arc(sx, sy, 1.5, 0, Math.PI * 2); ctx2.fill();
2536    }
2537  } else if (type === 'skin_neon_glow') {
2538    const pulse = Math.sin(now * 0.004) * 0.3 + 0.7;
2539    ctx2.shadowColor = '#0ff'; ctx2.shadowBlur = 10 * pulse;
2540    _drawMiniHex(ctx2, cx, cy, sz, '#1a1a3e', null);
2541    ctx2.shadowBlur = 0;
2542    ctx2.strokeStyle = `hsla(${(now * 0.05) % 360},100%,60%,${0.6 + pulse * 0.3})`;
2543    ctx2.lineWidth = 2; _strokeMiniHex(ctx2, cx, cy, sz);
2544  } else if (type === 'skin_crystal') {
2545    const hueShift = Math.sin(now * 0.001) * 15;
2546    ctx2.filter = `hue-rotate(${hueShift}deg)`;
2547    _drawMiniHex(ctx2, cx, cy, sz, '#c4b5fd', '#a78bfa');
2548    ctx2.filter = 'none';
2549    ctx2.globalAlpha = 0.4;
2550    for (let s = 0; s < 3; s++) {
2551      const sa = now * 0.001 + s * 2.1;
2552      const sx = cx + Math.cos(sa) * sz * 0.3;
2553      const sy = cy + Math.sin(sa) * sz * 0.3;
2554      ctx2.fillStyle = '#fff'; ctx2.beginPath(); ctx2.arc(sx, sy, 1, 0, Math.PI * 2); ctx2.fill();
2555    }
2556  } else if (type === 'skin_pixel') {
2557    _drawMiniHex(ctx2, cx, cy, sz, '#2a2a4e', null);
2558    ctx2.globalAlpha = 0.2;
2559    const pxSz = sz * 0.3;
2560    for (let gx = cx - sz; gx < cx + sz; gx += pxSz) {
2561      for (let gy = cy - sz; gy < cy + sz; gy += pxSz) {
2562        ctx2.strokeStyle = '#000'; ctx2.lineWidth = 0.5; ctx2.strokeRect(gx, gy, pxSz, pxSz);
2563      }
2564    }
2565  } else if (type === 'skin_gradient') {
2566    const hue = (now * 0.04) % 360;
2567    const grad = ctx2.createLinearGradient(cx - sz, cy - sz, cx + sz, cy + sz);
2568    grad.addColorStop(0, `hsl(${hue},80%,60%)`);
2569    grad.addColorStop(0.5, `hsl(${(hue+120)%360},80%,60%)`);
2570    grad.addColorStop(1, `hsl(${(hue+240)%360},80%,60%)`);
2571    _drawMiniHex(ctx2, cx, cy, sz, null, null, grad);
2572    ctx2.strokeStyle = `hsl(${hue},90%,55%)`; ctx2.lineWidth = 2; _strokeMiniHex(ctx2, cx, cy, sz);
2573  } else if (type === 'skin_obsidian') {
2574    // Volcanic black stone with molten gold veins
2575    // Body: radial gradient from near-black to deep purple-black
2576    _miniHexPath(ctx2, cx, cy, sz);
2577    const obsGrad = ctx2.createRadialGradient(cx - sz * 0.3, cy - sz * 0.3, 0, cx, cy, sz);
2578    obsGrad.addColorStop(0, '#4a3d5c');
2579    obsGrad.addColorStop(0.45, '#1c1628');
2580    obsGrad.addColorStop(1, '#06040c');
2581    ctx2.fillStyle = obsGrad;
2582    ctx2.fill();
2583    // Gold veins clipped to hex
2584    ctx2.save();
2585    _miniHexPath(ctx2, cx, cy, sz);
2586    ctx2.clip();
2587    const veinGrad = ctx2.createLinearGradient(cx - sz, cy - sz, cx + sz, cy + sz);
2588    veinGrad.addColorStop(0, '#ff8a4c');
2589    veinGrad.addColorStop(0.5, '#ffc857');
2590    veinGrad.addColorStop(1, '#ff4c2a');
2591    ctx2.strokeStyle = veinGrad;
2592    ctx2.lineWidth = sz * 0.07;
2593    ctx2.lineCap = 'round';
2594    ctx2.shadowColor = 'rgba(255,138,76,0.8)';
2595    ctx2.shadowBlur = 4;
2596    ctx2.beginPath();
2597    ctx2.moveTo(cx - sz * 0.9, cy - sz * 0.3);
2598    ctx2.lineTo(cx - sz * 0.2, cy - sz * 0.1);
2599    ctx2.lineTo(cx + sz * 0.1, cy + sz * 0.3);
2600    ctx2.lineTo(cx + sz * 0.8, cy + sz * 0.1);
2601    ctx2.moveTo(cx - sz * 0.2, cy - sz * 0.1);
2602    ctx2.lineTo(cx - sz * 0.1, cy + sz * 0.7);
2603    ctx2.moveTo(cx + sz * 0.1, cy + sz * 0.3);
2604    ctx2.lineTo(cx + sz * 0.5, cy - sz * 0.6);
2605    ctx2.stroke();
2606    ctx2.shadowBlur = 0;
2607    // Pulsing embers at vein junctions
2608    const emberPulse = 0.7 + Math.sin(now * 0.004) * 0.3;
2609    ctx2.globalAlpha = emberPulse;
2610    ctx2.fillStyle = '#ffc857';
2611    ctx2.beginPath(); ctx2.arc(cx - sz * 0.2, cy - sz * 0.1, sz * 0.08, 0, Math.PI * 2); ctx2.fill();
2612    ctx2.fillStyle = '#ff8a4c';
2613    ctx2.beginPath(); ctx2.arc(cx + sz * 0.1, cy + sz * 0.3, sz * 0.06, 0, Math.PI * 2); ctx2.fill();
2614    ctx2.globalAlpha = 1;
2615    ctx2.restore();
2616    // Bevel rim
2617    ctx2.strokeStyle = 'rgba(255,255,255,0.15)';
2618    ctx2.lineWidth = 1;
2619    _strokeMiniHex(ctx2, cx, cy, sz);
2620  } else if (type === 'skin_plasma') {
2621    // Glass shell with swirling plasma core
2622    // Outer glass
2623    _miniHexPath(ctx2, cx, cy, sz);
2624    const shellGrad = ctx2.createRadialGradient(cx, cy, 0, cx, cy, sz);
2625    shellGrad.addColorStop(0, 'rgba(0,0,0,0.1)');
2626    shellGrad.addColorStop(0.7, 'rgba(10,5,32,0.5)');
2627    shellGrad.addColorStop(1, 'rgba(0,0,0,0.85)');
2628    ctx2.fillStyle = shellGrad;
2629    ctx2.fill();
2630    // Plasma core (rotating)
2631    ctx2.save();
2632    _miniHexPath(ctx2, cx, cy, sz);
2633    ctx2.clip();
2634    ctx2.translate(cx, cy);
2635    ctx2.rotate((now * 0.001) % (Math.PI * 2));
2636    const coreGrad = ctx2.createRadialGradient(-sz * 0.1, -sz * 0.1, 0, 0, 0, sz * 0.75);
2637    coreGrad.addColorStop(0, '#ffffff');
2638    coreGrad.addColorStop(0.25, '#ff7cc4');
2639    coreGrad.addColorStop(0.65, '#b565ff');
2640    coreGrad.addColorStop(1, 'rgba(109,94,245,0.9)');
2641    ctx2.fillStyle = coreGrad;
2642    ctx2.beginPath();
2643    ctx2.ellipse(0, 0, sz * 0.75, sz * 0.5, 0, 0, Math.PI * 2);
2644    ctx2.fill();
2645    // Inner highlight counter-rotates
2646    ctx2.rotate(-(now * 0.0025) % (Math.PI * 2));
2647    ctx2.globalAlpha = 0.4;
2648    ctx2.fillStyle = '#ffffff';
2649    ctx2.beginPath();
2650    ctx2.ellipse(sz * 0.15, -sz * 0.1, sz * 0.35, sz * 0.22, 0, 0, Math.PI * 2);
2651    ctx2.fill();
2652    ctx2.globalAlpha = 1;
2653    ctx2.restore();
2654    // Flickering arc lightning
2655    const flicker = 0.4 + Math.abs(Math.sin(now * 0.005)) * 0.6;
2656    ctx2.save();
2657    ctx2.globalAlpha = 0.7 * flicker;
2658    ctx2.strokeStyle = '#ffffff';
2659    ctx2.lineWidth = sz * 0.04;
2660    ctx2.beginPath();
2661    ctx2.moveTo(cx - sz * 0.6, cy);
2662    ctx2.quadraticCurveTo(cx, cy - sz * 0.2, cx + sz * 0.6, cy);
2663    ctx2.stroke();
2664    ctx2.restore();
2665    // Glass rim highlight + top-left shine
2666    ctx2.strokeStyle = 'rgba(255,255,255,0.4)';
2667    ctx2.lineWidth = 1.2;
2668    _strokeMiniHex(ctx2, cx, cy, sz);
2669    ctx2.strokeStyle = 'rgba(255,255,255,0.7)';
2670    ctx2.lineWidth = 1.5;
2671    ctx2.lineCap = 'round';
2672    ctx2.beginPath();
2673    ctx2.moveTo(cx - sz * 0.6, cy - sz * 0.5);
2674    ctx2.lineTo(cx - sz * 0.15, cy - sz * 0.8);
2675    ctx2.stroke();
2676  } else if (type === 'skin_origami') {
2677    // 6 triangular facets alternating light/mid/dark violet tones
2678    const pLt = '#d4c5ff', pMd = '#a594ff', pDk = '#6d5ef5', pEdge = '#362a9e';
2679    const facetFills = [pLt, pMd, pDk, pLt, pMd, pDk];
2680    // Base
2681    _miniHexPath(ctx2, cx, cy, sz);
2682    ctx2.fillStyle = pMd;
2683    ctx2.fill();
2684    // Draw 6 triangles fanning from center — matches _miniHexPath angle
2685    // basis (i*π/3 - π/6) so corners line up
2686    for (let i = 0; i < 6; i++) {
2687      const a1 = (Math.PI / 3) * i - Math.PI / 6;
2688      const a2 = (Math.PI / 3) * (i + 1) - Math.PI / 6;
2689      const x1 = cx + sz * Math.cos(a1), y1 = cy + sz * Math.sin(a1);
2690      const x2 = cx + sz * Math.cos(a2), y2 = cy + sz * Math.sin(a2);
2691      ctx2.beginPath();
2692      ctx2.moveTo(cx, cy);
2693      ctx2.lineTo(x1, y1);
2694      ctx2.lineTo(x2, y2);
2695      ctx2.closePath();
2696      ctx2.fillStyle = facetFills[i];
2697      ctx2.fill();
2698    }
2699    // Soft fold lines from center to corners
2700    ctx2.globalAlpha = 0.35;
2701    ctx2.strokeStyle = pEdge;
2702    ctx2.lineWidth = 0.6;
2703    for (let i = 0; i < 6; i++) {
2704      const a = (Math.PI / 3) * i - Math.PI / 6;
2705      const x = cx + sz * Math.cos(a), y = cy + sz * Math.sin(a);
2706      ctx2.beginPath();
2707      ctx2.moveTo(cx, cy);
2708      ctx2.lineTo(x, y);
2709      ctx2.stroke();
2710    }
2711    ctx2.globalAlpha = 1;
2712    // Matte paper edge
2713    ctx2.globalAlpha = 0.5;
2714    ctx2.strokeStyle = pEdge;
2715    ctx2.lineWidth = 1;
2716    _strokeMiniHex(ctx2, cx, cy, sz);
2717    ctx2.globalAlpha = 1;
2718    // Subtle breathing center highlight (ties into animation loop)
2719    const breath = 0.3 + Math.sin(now * 0.002) * 0.15;
2720    ctx2.fillStyle = `rgba(255,255,255,${breath})`;
2721    ctx2.beginPath();
2722    ctx2.arc(cx, cy, sz * 0.06, 0, Math.PI * 2);
2723    ctx2.fill();
2724  } else if (type === 'skin_magma') {
2725    // v104 — Magma preview: cracked obsidian with pulsing molten veins +
2726    // drifting embers. Same logic as in-game render, sized for the card.
2727    const beat = (Math.sin(now * 0.003) + 1) * 0.5;
2728    const emberP = (Math.sin(now * 0.007) + 1) * 0.5;
2729    _miniHexPath(ctx2, cx, cy, sz);
2730    ctx2.save();
2731    _miniHexPath(ctx2, cx, cy, sz);
2732    ctx2.clip();
2733    // Base — black with red underglow
2734    const mgBase = ctx2.createRadialGradient(cx, cy + sz * 0.4, 0, cx, cy, sz * 1.1);
2735    mgBase.addColorStop(0,    '#5a1005');
2736    mgBase.addColorStop(0.4,  '#240604');
2737    mgBase.addColorStop(1,    '#0a0201');
2738    ctx2.fillStyle = mgBase; ctx2.fill();
2739    // Molten core
2740    const coreR = sz * (0.55 + beat * 0.2);
2741    const mgCore = ctx2.createRadialGradient(cx, cy + sz * 0.1, 0, cx, cy, coreR);
2742    mgCore.addColorStop(0,    `rgba(255,230,140,${0.55 + beat * 0.25})`);
2743    mgCore.addColorStop(0.35, `rgba(255,140,40,${0.35 + beat * 0.2})`);
2744    mgCore.addColorStop(1,    'rgba(255,60,10,0)');
2745    ctx2.fillStyle = mgCore;
2746    ctx2.beginPath(); ctx2.arc(cx, cy, coreR, 0, Math.PI * 2); ctx2.fill();
2747    // Cracks
2748    ctx2.lineCap = 'round';
2749    ctx2.strokeStyle = `rgba(255,${Math.round(120 + beat * 90)},30,${0.82 + beat * 0.15})`;
2750    ctx2.lineWidth = sz * 0.09;
2751    ctx2.beginPath();
2752    ctx2.moveTo(cx - sz * 0.85, cy - sz * 0.1);
2753    ctx2.lineTo(cx - sz * 0.35, cy + sz * 0.05);
2754    ctx2.lineTo(cx - sz * 0.1,  cy + sz * 0.35);
2755    ctx2.lineTo(cx + sz * 0.3,  cy + sz * 0.5);
2756    ctx2.lineTo(cx + sz * 0.85, cy + sz * 0.2);
2757    ctx2.moveTo(cx - sz * 0.35, cy + sz * 0.05);
2758    ctx2.lineTo(cx - sz * 0.45, cy - sz * 0.75);
2759    ctx2.moveTo(cx - sz * 0.1,  cy + sz * 0.35);
2760    ctx2.lineTo(cx + sz * 0.15, cy + sz * 0.85);
2761    ctx2.moveTo(cx + sz * 0.3,  cy + sz * 0.5);
2762    ctx2.lineTo(cx + sz * 0.55, cy - sz * 0.35);
2763    ctx2.stroke();
2764    // White-hot inner line
2765    ctx2.strokeStyle = `rgba(255,245,210,${0.6 + beat * 0.3})`;
2766    ctx2.lineWidth = sz * 0.03;
2767    ctx2.stroke();
2768    // Drifting embers
2769    for (let e = 0; e < 3; e++) {
2770      const ephase = (now * 0.0012 + e * 0.33) % 1;
2771      const ex = cx + Math.sin(ephase * Math.PI * 2 + e) * sz * 0.25;
2772      const ey = cy + sz * 0.6 - ephase * sz * 1.4;
2773      const esz = sz * 0.06 * (1 - ephase * 0.5);
2774      ctx2.globalAlpha = (1 - ephase) * (0.75 + emberP * 0.2);
2775      ctx2.fillStyle = e % 2 === 0 ? '#ffe080' : '#ff7020';
2776      ctx2.beginPath(); ctx2.arc(ex, ey, esz, 0, Math.PI * 2); ctx2.fill();
2777    }
2778    ctx2.globalAlpha = 1;
2779    // Obsidian specular highlight
2780    ctx2.globalAlpha = 0.18;
2781    ctx2.fillStyle = '#ffffff';
2782    ctx2.beginPath();
2783    ctx2.ellipse(cx - sz * 0.15, cy - sz * 0.45, sz * 0.35, sz * 0.08, 0, 0, Math.PI * 2);
2784    ctx2.fill();
2785    ctx2.globalAlpha = 1;
2786    ctx2.restore();
2787    // Rim
2788    ctx2.strokeStyle = `rgba(255,${Math.round(140 + beat * 50)},40,${0.55 + beat * 0.25})`;
2789    ctx2.lineWidth = 1.5;
2790    _strokeMiniHex(ctx2, cx, cy, sz);
2791  } else if (type === 'skin_circuit_board') {
2792    // v104 — Circuit Board preview: PCB substrate, copper traces to each
2793    // vertex, LED vias that cycle, rotating microchip at center.
2794    const led = Math.floor(now * 0.003) % 6;
2795    const chipSpin = (now * 0.001) % (Math.PI * 2);
2796    ctx2.save();
2797    _miniHexPath(ctx2, cx, cy, sz);
2798    ctx2.clip();
2799    // Substrate
2800    const pcb = ctx2.createRadialGradient(cx, cy, 0, cx, cy, sz * 1.1);
2801    pcb.addColorStop(0,    '#1a5a30');
2802    pcb.addColorStop(0.6,  '#0e3a1c');
2803    pcb.addColorStop(1,    '#061e0c');
2804    ctx2.fillStyle = pcb;
2805    ctx2.fill();
2806    // Silkscreen dots
2807    ctx2.fillStyle = 'rgba(210,230,200,0.18)';
2808    for (let d = 0; d < 5; d++) {
2809      const dx = cx + Math.cos(d * 1.7) * sz * 0.45;
2810      const dy = cy + Math.sin(d * 2.1) * sz * 0.45;
2811      ctx2.fillRect(dx, dy, 1.5, 1.5);
2812    }
2813    // Copper traces with right-angle kink
2814    ctx2.strokeStyle = '#c08040';
2815    ctx2.lineWidth = sz * 0.08;
2816    ctx2.lineCap = 'square';
2817    ctx2.beginPath();
2818    for (let v = 0; v < 6; v++) {
2819      const a = (Math.PI / 3) * v - Math.PI / 6;
2820      const tx = cx + Math.cos(a) * sz * 0.78;
2821      const ty = cy + Math.sin(a) * sz * 0.78;
2822      const mx = cx + Math.cos(a) * sz * 0.35;
2823      const my = cy + Math.sin(a) * sz * 0.35;
2824      ctx2.moveTo(cx, cy); ctx2.lineTo(mx, my); ctx2.lineTo(tx, ty);
2825    }
2826    ctx2.stroke();
2827    ctx2.strokeStyle = '#ffcc88';
2828    ctx2.lineWidth = sz * 0.03;
2829    ctx2.stroke();
2830    // LEDs
2831    for (let v = 0; v < 6; v++) {
2832      const a = (Math.PI / 3) * v - Math.PI / 6;
2833      const lx = cx + Math.cos(a) * sz * 0.78;
2834      const ly = cy + Math.sin(a) * sz * 0.78;
2835      const isLit = v === led;
2836      ctx2.fillStyle = '#8a5a2a';
2837      ctx2.beginPath(); ctx2.arc(lx, ly, sz * 0.1, 0, Math.PI * 2); ctx2.fill();
2838      if (isLit) {
2839        ctx2.globalAlpha = 0.55;
2840        ctx2.fillStyle = '#60ff80';
2841        ctx2.beginPath(); ctx2.arc(lx, ly, sz * 0.2, 0, Math.PI * 2); ctx2.fill();
2842        ctx2.globalAlpha = 1;
2843        ctx2.fillStyle = '#d0ffd8';
2844        ctx2.beginPath(); ctx2.arc(lx, ly, sz * 0.055, 0, Math.PI * 2); ctx2.fill();
2845      } else {
2846        ctx2.fillStyle = '#1a3a20';
2847        ctx2.beginPath(); ctx2.arc(lx, ly, sz * 0.055, 0, Math.PI * 2); ctx2.fill();
2848      }
2849    }
2850    // Central chip
2851    ctx2.save();
2852    ctx2.translate(cx, cy);
2853    ctx2.rotate(chipSpin);
2854    const chipR = sz * 0.22;
2855    ctx2.fillStyle = '#0a1410';
2856    ctx2.fillRect(-chipR, -chipR, chipR * 2, chipR * 2);
2857    ctx2.strokeStyle = '#304040';
2858    ctx2.lineWidth = 1;
2859    ctx2.strokeRect(-chipR, -chipR, chipR * 2, chipR * 2);
2860    ctx2.fillStyle = '#c0c0c0';
2861    ctx2.beginPath(); ctx2.arc(-chipR * 0.7, -chipR * 0.7, chipR * 0.08, 0, Math.PI * 2); ctx2.fill();
2862    const pulse = (Math.sin(now * 0.006) + 1) * 0.5;
2863    ctx2.globalAlpha = 0.65 + pulse * 0.35;
2864    ctx2.fillStyle = '#80e0ff';
2865    ctx2.beginPath(); ctx2.arc(0, 0, chipR * 0.35, 0, Math.PI * 2); ctx2.fill();
2866    ctx2.globalAlpha = 1;
2867    ctx2.restore();
2868    ctx2.restore();
2869    // Rim
2870    ctx2.strokeStyle = 'rgba(15,65,30,0.9)';
2871    ctx2.lineWidth = 1.5;
2872    _strokeMiniHex(ctx2, cx, cy, sz);
2873    ctx2.strokeStyle = 'rgba(220,170,80,0.45)';
2874    ctx2.lineWidth = 0.6;
2875    _strokeMiniHex(ctx2, cx, cy, sz);
2876  } else if (type === 'skin_galaxy') {
2877    // v105 — Galaxy preview: rotating spiral galaxy + starfield + nebula
2878    const rot = now * 0.0006;
2879    ctx2.save();
2880    _miniHexPath(ctx2, cx, cy, sz);
2881    ctx2.clip();
2882    // Deep space
2883    const space = ctx2.createRadialGradient(cx, cy, 0, cx, cy, sz * 1.1);
2884    space.addColorStop(0,    '#1a0a3a');
2885    space.addColorStop(0.55, '#08041a');
2886    space.addColorStop(1,    '#020108');
2887    ctx2.fillStyle = space; ctx2.fill();
2888    // Nebula
2889    ctx2.globalAlpha = 0.3;
2890    const neb = ctx2.createRadialGradient(cx + sz * 0.25, cy - sz * 0.2, 0, cx, cy, sz * 0.9);
2891    neb.addColorStop(0, 'rgba(180,120,255,0.9)');
2892    neb.addColorStop(1, 'rgba(180,120,255,0)');
2893    ctx2.fillStyle = neb;
2894    ctx2.fill();
2895    ctx2.globalAlpha = 1;
2896    // Stars
2897    for (let s = 0; s < 10; s++) {
2898      const sa = s * 1.37;
2899      const sr = (0.25 + ((s * 7) % 10) / 15) * sz;
2900      const sx = cx + Math.cos(sa) * sr;
2901      const sy = cy + Math.sin(sa) * sr;
2902      const tw = 0.4 + Math.sin(now * 0.004 + s * 2.3) * 0.6;
2903      ctx2.globalAlpha = Math.max(0, tw);
2904      ctx2.fillStyle = s % 3 === 0 ? '#fff' : '#c8d8ff';
2905      ctx2.fillRect(sx, sy, 1.2, 1.2);
2906    }
2907    ctx2.globalAlpha = 1;
2908    // Spiral
2909    ctx2.save();
2910    ctx2.translate(cx, cy);
2911    ctx2.rotate(rot);
2912    for (let arm = 0; arm < 2; arm++) {
2913      ctx2.beginPath();
2914      const armOff = arm * Math.PI;
2915      let first = true;
2916      for (let t2 = 0; t2 <= 1; t2 += 0.06) {
2917        const theta = armOff + t2 * Math.PI * 2.2;
2918        const rad = t2 * sz * 0.42;
2919        const ax = Math.cos(theta) * rad;
2920        const ay = Math.sin(theta) * rad;
2921        if (first) { ctx2.moveTo(ax, ay); first = false; }
2922        else ctx2.lineTo(ax, ay);
2923      }
2924      ctx2.strokeStyle = arm === 0 ? 'rgba(200,180,255,0.75)' : 'rgba(160,220,255,0.6)';
2925      ctx2.lineWidth = sz * 0.07;
2926      ctx2.lineCap = 'round';
2927      ctx2.stroke();
2928    }
2929    // Core
2930    const core = ctx2.createRadialGradient(0, 0, 0, 0, 0, sz * 0.22);
2931    core.addColorStop(0,   '#fff');
2932    core.addColorStop(0.3, '#ffe8b0');
2933    core.addColorStop(1,   'rgba(255,180,80,0)');
2934    ctx2.fillStyle = core;
2935    ctx2.beginPath(); ctx2.arc(0, 0, sz * 0.22, 0, Math.PI * 2); ctx2.fill();
2936    ctx2.restore();
2937    ctx2.restore();
2938    // Rim
2939    ctx2.strokeStyle = 'rgba(160,120,255,0.9)';
2940    ctx2.lineWidth = 1.5;
2941    _strokeMiniHex(ctx2, cx, cy, sz);
2942  } else if (type === 'skin_stained_glass') {
2943    // v106 — Stained glass preview: 6 jewel panels + lead caming + rose boss
2944    ctx2.save();
2945    _miniHexPath(ctx2, cx, cy, sz);
2946    ctx2.clip();
2947    // Dark substrate
2948    ctx2.fillStyle = '#1a0810';
2949    ctx2.fill();
2950    // Hex vertices
2951    const glass = [];
2952    for (let i = 0; i < 6; i++) {
2953      const a = Math.PI / 3 * i - Math.PI / 6;
2954      glass.push({ x: cx + Math.cos(a) * sz, y: cy + Math.sin(a) * sz });
2955    }
2956    const jewels = [
2957      'rgba(220,40,70,0.92)',   // ruby
2958      'rgba(60,100,220,0.92)',  // sapphire
2959      'rgba(30,180,110,0.92)',  // emerald
2960      'rgba(160,70,220,0.92)',  // amethyst
2961      'rgba(240,180,40,0.92)',  // topaz
2962      'rgba(240,110,40,0.92)',  // citrine
2963    ];
2964    for (let i = 0; i < 6; i++) {
2965      const p1 = glass[i];
2966      const p2 = glass[(i + 1) % 6];
2967      ctx2.beginPath();
2968      ctx2.moveTo(cx, cy);
2969      ctx2.lineTo(p1.x, p1.y);
2970      ctx2.lineTo(p2.x, p2.y);
2971      ctx2.closePath();
2972      ctx2.fillStyle = jewels[i];
2973      ctx2.fill();
2974    }
2975    // Sweeping light
2976    const rayRot = now * 0.0008;
2977    ctx2.save();
2978    ctx2.translate(cx, cy);
2979    ctx2.rotate(rayRot);
2980    ctx2.globalCompositeOperation = 'lighter';
2981    const rg = ctx2.createLinearGradient(-sz, 0, sz, 0);
2982    rg.addColorStop(0,   'rgba(255,240,180,0)');
2983    rg.addColorStop(0.5, 'rgba(255,240,180,0.3)');
2984    rg.addColorStop(1,   'rgba(255,240,180,0)');
2985    ctx2.fillStyle = rg;
2986    ctx2.fillRect(-sz, -sz * 0.3, sz * 2, sz * 0.6);
2987    ctx2.restore();
2988    // Lead caming
2989    ctx2.strokeStyle = '#0a0405';
2990    ctx2.lineWidth = Math.max(1.4, sz * 0.1);
2991    ctx2.lineCap = 'round';
2992    for (let i = 0; i < 6; i++) {
2993      ctx2.beginPath();
2994      ctx2.moveTo(cx, cy);
2995      ctx2.lineTo(glass[i].x, glass[i].y);
2996      ctx2.stroke();
2997    }
2998    // Rose boss
2999    const bossR = sz * 0.22;
3000    ctx2.beginPath();
3001    ctx2.arc(cx, cy, bossR, 0, Math.PI * 2);
3002    ctx2.fillStyle = '#3a2008';
3003    ctx2.fill();
3004    ctx2.strokeStyle = '#ffc048';
3005    ctx2.lineWidth = 1.4;
3006    ctx2.stroke();
3007    // Spokes
3008    ctx2.lineWidth = 0.8;
3009    for (let s = 0; s < 6; s++) {
3010      const sa = s * Math.PI / 3;
3011      ctx2.beginPath();
3012      ctx2.moveTo(cx + Math.cos(sa) * bossR * 0.3, cy + Math.sin(sa) * bossR * 0.3);
3013      ctx2.lineTo(cx + Math.cos(sa) * bossR * 0.9, cy + Math.sin(sa) * bossR * 0.9);
3014      ctx2.stroke();
3015    }
3016    // Pulsing central gem
3017    const bp = 0.7 + Math.sin(now * 0.004) * 0.3;
3018    ctx2.beginPath();
3019    ctx2.arc(cx, cy, bossR * 0.4, 0, Math.PI * 2);
3020    ctx2.fillStyle = `rgba(255,200,120,${0.9 * bp})`;
3021    ctx2.fill();
3022    ctx2.restore();
3023    // Outer lead frame
3024    ctx2.strokeStyle = '#0a0405';
3025    ctx2.lineWidth = Math.max(2, sz * 0.14);
3026    _strokeMiniHex(ctx2, cx, cy, sz);
3027    ctx2.strokeStyle = 'rgba(255,192,72,0.6)';
3028    ctx2.lineWidth = 1.2;
3029    _strokeMiniHex(ctx2, cx, cy, sz);
3030  } else if (type === 'skin_storm_core') {
3031    // v107 — Storm Core preview: clipped storm-cloud interior with
3032    // rotating wisps, pulsing electric eye, flickering arc, cyan rim.
3033    ctx2.save();
3034    _miniHexPath(ctx2, cx, cy, sz);
3035    ctx2.clip();
3036    // Base storm gradient
3037    const sBg = ctx2.createRadialGradient(cx, cy, 0, cx, cy, sz);
3038    sBg.addColorStop(0,    '#2a4470');
3039    sBg.addColorStop(0.55, '#14203a');
3040    sBg.addColorStop(1,    '#050a16');
3041    ctx2.fillStyle = sBg; ctx2.fill();
3042    // Rotating wisps
3043    const spin = now * 0.0011;
3044    for (let wi = 0; wi < 3; wi++) {
3045      const wa = spin + wi * (Math.PI * 2 / 3);
3046      const wr = sz * 0.5;
3047      const wx = cx + Math.cos(wa) * wr;
3048      const wy = cy + Math.sin(wa) * wr;
3049      ctx2.save();
3050      ctx2.translate(wx, wy);
3051      ctx2.rotate(wa + Math.PI * 0.5);
3052      ctx2.globalAlpha = 0.55;
3053      const wg = ctx2.createRadialGradient(0, 0, 0, 0, 0, sz * 0.55);
3054      wg.addColorStop(0,   'rgba(170,210,255,0.55)');
3055      wg.addColorStop(0.55,'rgba(50,90,150,0.3)');
3056      wg.addColorStop(1,   'rgba(8,14,28,0)');
3057      ctx2.fillStyle = wg;
3058      ctx2.beginPath();
3059      ctx2.ellipse(0, 0, sz * 0.55, sz * 0.23, 0, 0, Math.PI * 2);
3060      ctx2.fill();
3061      ctx2.restore();
3062    }
3063    // Flickering arc (time-based)
3064    const arcPh = (now % 900) / 900;
3065    if (arcPh < 0.14) {
3066      const arcI = Math.max(0, Math.sin((arcPh / 0.14) * Math.PI));
3067      ctx2.save();
3068      ctx2.globalAlpha = arcI;
3069      ctx2.lineCap = 'round';
3070      ctx2.lineJoin = 'round';
3071      const a1 = (now * 0.001) % (Math.PI * 2);
3072      const a2 = a1 + Math.PI + 0.4;
3073      const d = sz * 0.55;
3074      const x1 = cx + Math.cos(a1) * d, y1 = cy + Math.sin(a1) * d;
3075      const x2 = cx + Math.cos(a2) * d, y2 = cy + Math.sin(a2) * d;
3076      const mx = (x1 + x2) * 0.5 + (Math.sin(now * 0.01) * sz * 0.2);
3077      const my = (y1 + y2) * 0.5 + (Math.cos(now * 0.011) * sz * 0.2);
3078      ctx2.strokeStyle = 'rgba(130,210,255,0.7)';
3079      ctx2.lineWidth = 3.4;
3080      ctx2.beginPath();
3081      ctx2.moveTo(x1, y1); ctx2.lineTo(mx, my); ctx2.lineTo(x2, y2);
3082      ctx2.stroke();
3083      ctx2.strokeStyle = '#fff';
3084      ctx2.lineWidth = 1.3;
3085      ctx2.beginPath();
3086      ctx2.moveTo(x1, y1); ctx2.lineTo(mx, my); ctx2.lineTo(x2, y2);
3087      ctx2.stroke();
3088      ctx2.restore();
3089    }
3090    // Eye of the storm
3091    const eyeR = sz * 0.3;
3092    const eyePulse = 0.65 + Math.sin(now * 0.004) * 0.3;
3093    const eg2 = ctx2.createRadialGradient(cx, cy, 0, cx, cy, eyeR);
3094    eg2.addColorStop(0,   `rgba(255,255,255,${0.95 * eyePulse})`);
3095    eg2.addColorStop(0.3, `rgba(200,235,255,${0.9 * eyePulse})`);
3096    eg2.addColorStop(0.6, `rgba(100,190,255,${0.7 * eyePulse})`);
3097    eg2.addColorStop(1,   'rgba(40,80,160,0)');
3098    ctx2.fillStyle = eg2;
3099    ctx2.beginPath();
3100    ctx2.arc(cx, cy, eyeR, 0, Math.PI * 2);
3101    ctx2.fill();
3102    ctx2.restore();
3103    // Rim — dark base + cyan crackle
3104    ctx2.strokeStyle = '#050a16';
3105    ctx2.lineWidth = Math.max(2, sz * 0.12);
3106    _strokeMiniHex(ctx2, cx, cy, sz);
3107    const rimC = 0.6 + Math.sin(now * 0.008) * 0.4;
3108    ctx2.strokeStyle = `rgba(140,220,255,${0.75 * rimC})`;
3109    ctx2.lineWidth = 1.3;
3110    _strokeMiniHex(ctx2, cx, cy, sz);
3111  } else if (type === 'skin_brass_cog') {
3112    // Drop 6 — Brass Cog preview: clipped brass plate, rotating 10-tooth
3113    // cog with spokes + hub + flathead screw, single steam puff, copper rim.
3114    ctx2.save();
3115    _miniHexPath(ctx2, cx, cy, sz);
3116    ctx2.clip();
3117    // Brass plate base
3118    const bcBg = ctx2.createRadialGradient(cx - sz * 0.3, cy - sz * 0.3, sz * 0.05, cx, cy, sz);
3119    bcBg.addColorStop(0,    '#d5a848');
3120    bcBg.addColorStop(0.55, '#8a6322');
3121    bcBg.addColorStop(1,    '#3a2708');
3122    ctx2.fillStyle = bcBg; ctx2.fill();
3123    // Brushed metal streaks
3124    ctx2.globalAlpha = 0.22;
3125    ctx2.strokeStyle = '#2a1a08';
3126    ctx2.lineWidth = 0.5;
3127    for (let si = 0; si < 5; si++) {
3128      const sy = cy + (si - 2) * sz * 0.25;
3129      ctx2.beginPath();
3130      ctx2.moveTo(cx - sz * 0.8, sy);
3131      ctx2.lineTo(cx + sz * 0.8, sy);
3132      ctx2.stroke();
3133    }
3134    ctx2.globalAlpha = 1;
3135    // Rotating cog
3136    const cogSpin = now * 0.0014;
3137    const cogR = sz * 0.55;
3138    const teeth = 10;
3139    ctx2.save();
3140    ctx2.translate(cx, cy);
3141    ctx2.rotate(cogSpin);
3142    ctx2.beginPath();
3143    for (let ti = 0; ti < teeth * 2; ti++) {
3144      const ta = (ti / (teeth * 2)) * Math.PI * 2;
3145      const tr = (ti % 2 === 0) ? cogR : cogR * 0.78;
3146      const tx = Math.cos(ta) * tr;
3147      const ty = Math.sin(ta) * tr;
3148      if (ti === 0) ctx2.moveTo(tx, ty); else ctx2.lineTo(tx, ty);
3149    }
3150    ctx2.closePath();
3151    const cogG = ctx2.createRadialGradient(0, 0, 0, 0, 0, cogR);
3152    cogG.addColorStop(0,    '#f2cf6a');
3153    cogG.addColorStop(0.7,  '#a87a26');
3154    cogG.addColorStop(1,    '#5a3c0e');
3155    ctx2.fillStyle = cogG;
3156    ctx2.fill();
3157    ctx2.strokeStyle = '#2a1a08';
3158    ctx2.lineWidth = 1.0;
3159    ctx2.stroke();
3160    // Spokes
3161    ctx2.strokeStyle = 'rgba(42,26,8,0.75)';
3162    ctx2.lineWidth = 2.0;
3163    for (let sp = 0; sp < 6; sp++) {
3164      const spa = sp * (Math.PI / 3);
3165      ctx2.beginPath();
3166      ctx2.moveTo(Math.cos(spa) * cogR * 0.22, Math.sin(spa) * cogR * 0.22);
3167      ctx2.lineTo(Math.cos(spa) * cogR * 0.65, Math.sin(spa) * cogR * 0.65);
3168      ctx2.stroke();
3169    }
3170    // Hub + screw
3171    ctx2.beginPath();
3172    ctx2.arc(0, 0, cogR * 0.22, 0, Math.PI * 2);
3173    const hubG = ctx2.createRadialGradient(0, 0, 0, 0, 0, cogR * 0.22);
3174    hubG.addColorStop(0, '#f7e29a');
3175    hubG.addColorStop(1, '#6a4a14');
3176    ctx2.fillStyle = hubG;
3177    ctx2.fill();
3178    ctx2.strokeStyle = '#2a1a08';
3179    ctx2.lineWidth = 0.8;
3180    ctx2.stroke();
3181    // Flathead screw slot
3182    ctx2.strokeStyle = '#1a1004';
3183    ctx2.lineWidth = 1.4;
3184    ctx2.beginPath();
3185    ctx2.moveTo(-cogR * 0.15, 0);
3186    ctx2.lineTo( cogR * 0.15, 0);
3187    ctx2.stroke();
3188    ctx2.restore();
3189    // Steam puff (cycled)
3190    const puffPh = (now % 2600) / 2600;
3191    if (puffPh < 0.35) {
3192      const pu = puffPh / 0.35;
3193      const px = cx - sz * 0.55 + pu * sz * 0.35;
3194      const py = cy - sz * 0.5 - pu * sz * 0.45;
3195      const pa = Math.max(0, 1 - pu) * 0.6;
3196      const pr = sz * 0.15 + pu * sz * 0.2;
3197      ctx2.save();
3198      ctx2.globalAlpha = pa;
3199      const pg = ctx2.createRadialGradient(px, py, 0, px, py, pr);
3200      pg.addColorStop(0,   'rgba(255,255,255,0.9)');
3201      pg.addColorStop(0.6, 'rgba(220,220,230,0.4)');
3202      pg.addColorStop(1,   'rgba(180,180,200,0)');
3203      ctx2.fillStyle = pg;
3204      ctx2.beginPath();
3205      ctx2.arc(px, py, pr, 0, Math.PI * 2);
3206      ctx2.fill();
3207      ctx2.restore();
3208    }
3209    // 6 corner rivets
3210    for (let rv = 0; rv < 6; rv++) {
3211      const rva = rv * (Math.PI / 3) + Math.PI / 6;
3212      const rvx = cx + Math.cos(rva) * sz * 0.78;
3213      const rvy = cy + Math.sin(rva) * sz * 0.78;
3214      const rvR = sz * 0.08;
3215      // Shadow
3216      ctx2.beginPath();
3217      ctx2.arc(rvx + 0.6, rvy + 0.6, rvR, 0, Math.PI * 2);
3218      ctx2.fillStyle = 'rgba(0,0,0,0.5)';
3219      ctx2.fill();
3220      // Rivet
3221      const rvG = ctx2.createRadialGradient(rvx - rvR * 0.3, rvy - rvR * 0.3, 0, rvx, rvy, rvR);
3222      rvG.addColorStop(0, '#ffe29a');
3223      rvG.addColorStop(0.6, '#a87a26');
3224      rvG.addColorStop(1, '#3a2708');
3225      ctx2.fillStyle = rvG;
3226      ctx2.beginPath();
3227      ctx2.arc(rvx, rvy, rvR, 0, Math.PI * 2);
3228      ctx2.fill();
3229    }
3230    ctx2.restore();
3231    // Outer dark rim + copper inner rim
3232    ctx2.strokeStyle = '#1a0f03';
3233    ctx2.lineWidth = Math.max(2, sz * 0.13);
3234    _strokeMiniHex(ctx2, cx, cy, sz);
3235    ctx2.strokeStyle = 'rgba(210,150,70,0.85)';
3236    ctx2.lineWidth = 1.4;
3237    _strokeMiniHex(ctx2, cx, cy, sz);
3238  } else if (type === 'skin_gravity_well') {
3239    // Drop 7 — Gravity Well preview: dark void base, two rotating
3240    // accretion disc ellipses, event-horizon lensing ring, bright
3241    // white singularity point, two inward-spiraling streams.
3242    ctx2.save();
3243    _miniHexPath(ctx2, cx, cy, sz);
3244    ctx2.clip();
3245    // Void base
3246    const gwBg = ctx2.createRadialGradient(cx, cy, 0, cx, cy, sz);
3247    gwBg.addColorStop(0,    '#04020e');
3248    gwBg.addColorStop(0.55, '#0e061e');
3249    gwBg.addColorStop(1,    '#1e0c37');
3250    ctx2.fillStyle = gwBg; ctx2.fill();
3251    // Accretion discs (two)
3252    const gwSpin = now * 0.0028;
3253    for (let d = 0; d < 2; d++) {
3254      const rot = gwSpin + d * 1.0;
3255      const rx = sz * (0.8 - d * 0.12);
3256      const ry = sz * (0.18 + d * 0.09);
3257      ctx2.save();
3258      ctx2.translate(cx, cy);
3259      ctx2.rotate(rot);
3260      const dg = ctx2.createLinearGradient(-rx, 0, rx, 0);
3261      dg.addColorStop(0,    'rgba(80,40,180,0.02)');
3262      dg.addColorStop(0.3,  'rgba(180,120,255,0.7)');
3263      dg.addColorStop(0.5,  'rgba(230,200,255,0.98)');
3264      dg.addColorStop(0.7,  'rgba(180,120,255,0.7)');
3265      dg.addColorStop(1,    'rgba(80,40,180,0.02)');
3266      ctx2.fillStyle = dg;
3267      ctx2.beginPath();
3268      ctx2.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
3269      ctx2.fill();
3270      ctx2.restore();
3271    }
3272    // Event horizon ring + black void + white singularity
3273    const lensR = sz * 0.32;
3274    const lg = ctx2.createRadialGradient(cx, cy, lensR * 0.5, cx, cy, lensR * 1.25);
3275    lg.addColorStop(0,    'rgba(0,0,0,0)');
3276    lg.addColorStop(0.72, 'rgba(40,20,80,0.4)');
3277    lg.addColorStop(0.88, 'rgba(200,160,255,0.95)');
3278    lg.addColorStop(1,    'rgba(0,0,0,0)');
3279    ctx2.fillStyle = lg;
3280    ctx2.beginPath(); ctx2.arc(cx, cy, lensR * 1.25, 0, Math.PI * 2); ctx2.fill();
3281    ctx2.fillStyle = '#000';
3282    ctx2.beginPath(); ctx2.arc(cx, cy, lensR * 0.7, 0, Math.PI * 2); ctx2.fill();
3283    const pulse = 0.7 + Math.sin(now * 0.008) * 0.3;
3284    ctx2.fillStyle = `rgba(255,255,255,${0.95 * pulse})`;
3285    ctx2.beginPath(); ctx2.arc(cx, cy, lensR * 0.22, 0, Math.PI * 2); ctx2.fill();
3286    // Inward-spiral streams
3287    ctx2.strokeStyle = 'rgba(200,160,255,0.6)';
3288    ctx2.lineWidth = 0.8;
3289    for (let s = 0; s < 2; s++) {
3290      const a0 = gwSpin * 1.4 + s * Math.PI;
3291      ctx2.beginPath();
3292      for (let k = 0; k < 14; k++) {
3293        const u = k / 13;
3294        const r = lensR * 0.7 + (1 - u) * sz * 0.55;
3295        const a = a0 + u * Math.PI * 2.2;
3296        const px = cx + Math.cos(a) * r;
3297        const py = cy + Math.sin(a) * r;
3298        if (k === 0) ctx2.moveTo(px, py); else ctx2.lineTo(px, py);
3299      }
3300      ctx2.stroke();
3301    }
3302    ctx2.restore();
3303    // Outer event-horizon rim
3304    ctx2.strokeStyle = 'rgba(200,160,255,0.9)';
3305    ctx2.lineWidth = Math.max(1.6, sz * 0.1);
3306    _strokeMiniHex(ctx2, cx, cy, sz);
3307    ctx2.strokeStyle = '#000';
3308    ctx2.lineWidth = 0.8;
3309    _strokeMiniHex(ctx2, cx, cy, sz);
3310  } else if (type === 'skin_aurora_ribbon') {
3311    // Drop 8 — Aurora Ribbon preview: deep-navy clipped base, 3 drifting
3312    // aurora curtain bands (green/teal/violet) with gentle undulation, 4
3313    // pinpoint stars, soft ice-blue rim. Matches the in-game skin 1:1.
3314    ctx2.save();
3315    _miniHexPath(ctx2, cx, cy, sz);
3316    ctx2.clip();
3317    // Deep navy sky
3318    const sg = ctx2.createLinearGradient(cx, cy - sz, cx, cy + sz);
3319    sg.addColorStop(0,   '#0a1438');
3320    sg.addColorStop(0.6, '#050820');
3321    sg.addColorStop(1,   '#02030e');
3322    ctx2.fillStyle = sg;
3323    ctx2.fill();
3324    // Aurora bands — additive for the shimmer
3325    ctx2.save();
3326    ctx2.globalCompositeOperation = 'lighter';
3327    const pBands = [
3328      { col:'80,255,170',  yOff:-sz * 0.35, amp:sz * 0.08, speed:0.0014, phase:0.0 },
3329      { col:'110,220,255', yOff: 0,         amp:sz * 0.12, speed:0.0010, phase:1.3 },
3330      { col:'190,120,255', yOff: sz * 0.30, amp:sz * 0.09, speed:0.0018, phase:2.6 },
3331    ];
3332    for (let b = 0; b < pBands.length; b++) {
3333      const bb = pBands[b];
3334      ctx2.beginPath();
3335      const seg = 8;
3336      const halfW = sz * 1.05;
3337      for (let i = 0; i <= seg; i++) {
3338        const u = i / seg;
3339        const x = cx - halfW + u * halfW * 2;
3340        const y = cy + bb.yOff
3341                     + Math.sin(u * Math.PI * 2.5 + bb.phase + now * bb.speed) * bb.amp
3342                     + Math.sin(u * Math.PI * 5.0 + bb.phase * 1.7) * bb.amp * 0.35;
3343        if (i === 0) ctx2.moveTo(x, y); else ctx2.lineTo(x, y);
3344      }
3345      const bY0 = cy + bb.yOff - sz * 0.25;
3346      const bY1 = cy + bb.yOff + sz * 0.35;
3347      const bg = ctx2.createLinearGradient(0, bY0, 0, bY1);
3348      bg.addColorStop(0,   `rgba(${bb.col},0)`);
3349      bg.addColorStop(0.45,`rgba(${bb.col},0.65)`);
3350      bg.addColorStop(0.7, `rgba(${bb.col},0.4)`);
3351      bg.addColorStop(1,   `rgba(${bb.col},0)`);
3352      ctx2.strokeStyle = bg;
3353      ctx2.lineWidth = sz * 0.45;
3354      ctx2.lineCap = 'round';
3355      ctx2.stroke();
3356    }
3357    ctx2.restore();
3358    // 4 twinkling pin-stars — deterministic placement
3359    const pStars = [
3360      { x: cx - sz * 0.55, y: cy - sz * 0.5, p: 0.0 },
3361      { x: cx + sz * 0.4,  y: cy - sz * 0.15, p: 1.9 },
3362      { x: cx - sz * 0.3,  y: cy + sz * 0.6, p: 3.4 },
3363      { x: cx + sz * 0.6,  y: cy + sz * 0.25, p: 5.2 },
3364    ];
3365    for (let s = 0; s < pStars.length; s++) {
3366      const st = pStars[s];
3367      const tw = 0.45 + Math.sin(now * 0.004 + st.p) * 0.35;
3368      ctx2.globalAlpha = Math.max(0.15, tw);
3369      ctx2.fillStyle = s % 3 === 0 ? '#e0eaff' : '#ffffff';
3370      ctx2.fillRect(st.x, st.y, 1.3, 1.3);
3371    }
3372    ctx2.globalAlpha = 1;
3373    ctx2.restore();
3374    // Ice-blue rim + dark outer line
3375    ctx2.strokeStyle = 'rgba(138,182,255,0.8)';
3376    ctx2.lineWidth = Math.max(1.4, sz * 0.06);
3377    _strokeMiniHex(ctx2, cx, cy, sz);
3378    ctx2.strokeStyle = 'rgba(4,8,20,0.8)';
3379    ctx2.lineWidth = 0.7;
3380    _strokeMiniHex(ctx2, cx, cy, sz);
3381  } else if (type === 'skin_laurel_crown') {
3382    // Drop 9 — Laurel Crown mini: polished gold hex with a beveled mirror
3383    // center and a simplified carved laurel around the rim. Native gold
3384    // palette; matches the full in-game skin.
3385    // 1) Gold plate fill
3386    ctx2.save();
3387    _miniHexPath(ctx2, cx, cy, sz); ctx2.clip();
3388    const gp = ctx2.createRadialGradient(cx - sz * 0.25, cy - sz * 0.3, 0, cx, cy, sz * 1.1);
3389    gp.addColorStop(0, '#f3d07a');
3390    gp.addColorStop(0.55, '#c48f2c');
3391    gp.addColorStop(1, '#6b4a12');
3392    ctx2.fillStyle = gp;
3393    ctx2.fillRect(cx - sz, cy - sz, sz * 2, sz * 2);
3394    // 2) Beveled mirror center
3395    const innerR = sz * 0.46;
3396    const mg = ctx2.createRadialGradient(cx, cy - innerR * 0.2, 0, cx, cy, innerR);
3397    mg.addColorStop(0, '#8a6420');
3398    mg.addColorStop(0.8, '#4a3410');
3399    mg.addColorStop(1, '#2a1e08');
3400    ctx2.fillStyle = mg;
3401    ctx2.beginPath(); ctx2.arc(cx, cy, innerR, 0, Math.PI * 2); ctx2.fill();
3402    // Top highlight crescent
3403    ctx2.beginPath();
3404    ctx2.arc(cx, cy - innerR * 0.1, innerR * 0.92, Math.PI * 1.15, Math.PI * 1.85);
3405    ctx2.strokeStyle = 'rgba(250,230,180,0.35)';
3406    ctx2.lineWidth = 1.2;
3407    ctx2.stroke();
3408    // Mirror rim
3409    ctx2.beginPath(); ctx2.arc(cx, cy, innerR, 0, Math.PI * 2);
3410    ctx2.strokeStyle = 'rgba(20,12,4,0.7)';
3411    ctx2.lineWidth = 1;
3412    ctx2.stroke();
3413    // 3) Laurel leaves around the rim (8 positions — fewer than full skin
3414    // since previews are tiny and detail gets lost)
3415    const laurelR = sz * 0.7;
3416    const n = 8;
3417    for (let i = 0; i < n; i++) {
3418      const a = (i / n) * Math.PI * 2 - Math.PI / 2;
3419      const lx = cx + Math.cos(a) * laurelR;
3420      const ly = cy + Math.sin(a) * laurelR;
3421      const tiltA = a + Math.PI / 2;
3422      ctx2.save();
3423      ctx2.translate(lx, ly); ctx2.rotate(tiltA + 0.3);
3424      ctx2.fillStyle = '#e8c870';
3425      ctx2.beginPath(); ctx2.ellipse(0, -1.6, 2.4, 1.0, 0, 0, Math.PI * 2); ctx2.fill();
3426      ctx2.restore();
3427      ctx2.save();
3428      ctx2.translate(lx, ly); ctx2.rotate(tiltA - 0.3);
3429      ctx2.fillStyle = '#d4a84b';
3430      ctx2.beginPath(); ctx2.ellipse(0, 1.6, 2.4, 1.0, 0, 0, Math.PI * 2); ctx2.fill();
3431      ctx2.restore();
3432    }
3433    // Clasp gem
3434    ctx2.beginPath(); ctx2.arc(cx, cy - laurelR, 1.8, 0, Math.PI * 2);
3435    ctx2.fillStyle = '#fff4c2'; ctx2.fill();
3436    ctx2.strokeStyle = 'rgba(60,40,10,0.7)'; ctx2.lineWidth = 0.5; ctx2.stroke();
3437    ctx2.restore();
3438    // 4) Polished gold rim
3439    const rg = ctx2.createLinearGradient(cx, cy - sz, cx, cy + sz);
3440    rg.addColorStop(0, '#ffe88a');
3441    rg.addColorStop(0.5, '#d4a84b');
3442    rg.addColorStop(1, '#7a5a1c');
3443    ctx2.strokeStyle = rg;
3444    ctx2.lineWidth = Math.max(1.4, sz * 0.07);
3445    _strokeMiniHex(ctx2, cx, cy, sz);
3446    ctx2.strokeStyle = 'rgba(28,16,4,0.75)';
3447    ctx2.lineWidth = 0.6;
3448    _strokeMiniHex(ctx2, cx, cy, sz);
3449  } else if (type === 'skin_default') {
3450    _drawMiniHex(ctx2, cx, cy, sz, '#4a5568', '#2d3748');
3451  } else if (type === 'skin_panda_face') {
3452    // Drop 10 — Panda Face mini preview. Same parts as the in-game skin
3453    // (white plate, ear nubs, eye patches, nose) but downscaled to fit
3454    // the 72×72 tile cleanly.
3455    // Ear nubs first (so they sit behind the face plate)
3456    ctx2.fillStyle = '#1a1410';
3457    const earR = sz * 0.30;
3458    ctx2.beginPath(); ctx2.arc(cx - sz * 0.55, cy - sz * 0.55, earR, 0, Math.PI * 2); ctx2.fill();
3459    ctx2.beginPath(); ctx2.arc(cx + sz * 0.55, cy - sz * 0.55, earR, 0, Math.PI * 2); ctx2.fill();
3460    ctx2.fillStyle = '#7a3a44';
3461    ctx2.beginPath(); ctx2.arc(cx - sz * 0.55, cy - sz * 0.55 + earR * 0.15, earR * 0.42, 0, Math.PI * 2); ctx2.fill();
3462    ctx2.beginPath(); ctx2.arc(cx + sz * 0.55, cy - sz * 0.55 + earR * 0.15, earR * 0.42, 0, Math.PI * 2); ctx2.fill();
3463    // White face plate (clipped to hex)
3464    ctx2.save();
3465    _miniHexPath(ctx2, cx, cy, sz);
3466    ctx2.clip();
3467    const fg = ctx2.createRadialGradient(cx - sz * 0.2, cy - sz * 0.25, 0, cx, cy, sz * 1.05);
3468    fg.addColorStop(0, '#ffffff');
3469    fg.addColorStop(0.7, '#f4ede4');
3470    fg.addColorStop(1, '#d8cdb8');
3471    ctx2.fillStyle = fg;
3472    ctx2.fillRect(cx - sz, cy - sz, sz * 2, sz * 2);
3473    // Eye patches
3474    ctx2.fillStyle = '#0e0a08';
3475    ctx2.save(); ctx2.translate(cx - sz * 0.30, cy + sz * 0.05); ctx2.rotate(-0.42);
3476    ctx2.beginPath(); ctx2.ellipse(0, 0, sz * 0.26, sz * 0.34, 0, 0, Math.PI * 2); ctx2.fill();
3477    ctx2.restore();
3478    ctx2.save(); ctx2.translate(cx + sz * 0.30, cy + sz * 0.05); ctx2.rotate(0.42);
3479    ctx2.beginPath(); ctx2.ellipse(0, 0, sz * 0.26, sz * 0.34, 0, 0, Math.PI * 2); ctx2.fill();
3480    ctx2.restore();
3481    // Pupils
3482    ctx2.fillStyle = '#f6f6f6';
3483    ctx2.beginPath(); ctx2.arc(cx - sz * 0.27, cy - sz * 0.02, sz * 0.07, 0, Math.PI * 2); ctx2.fill();
3484    ctx2.beginPath(); ctx2.arc(cx + sz * 0.27, cy - sz * 0.02, sz * 0.07, 0, Math.PI * 2); ctx2.fill();
3485    // Nose
3486    const noseY = cy + sz * 0.30;
3487    const noseSz = sz * 0.10;
3488    ctx2.fillStyle = '#3a1a18';
3489    ctx2.beginPath();
3490    ctx2.moveTo(cx - noseSz, noseY - noseSz * 0.6);
3491    ctx2.lineTo(cx + noseSz, noseY - noseSz * 0.6);
3492    ctx2.lineTo(cx, noseY + noseSz * 0.7);
3493    ctx2.closePath(); ctx2.fill();
3494    // Cheeks
3495    ctx2.fillStyle = 'rgba(240,140,150,0.32)';
3496    ctx2.beginPath(); ctx2.arc(cx - sz * 0.55, cy + sz * 0.20, sz * 0.13, 0, Math.PI * 2); ctx2.fill();
3497    ctx2.beginPath(); ctx2.arc(cx + sz * 0.55, cy + sz * 0.20, sz * 0.13, 0, Math.PI * 2); ctx2.fill();
3498    ctx2.restore();
3499    // Outer hex border
3500    ctx2.strokeStyle = 'rgba(60,40,30,0.55)';
3501    ctx2.lineWidth = 1;
3502    _strokeMiniHex(ctx2, cx, cy, sz);
3503  } else if (type === 'skin_pastel_glaze') {
3504    // v153.147 — Pastel Glaze mini preview was missing from the dispatcher,
3505    // so the shop tile rendered blank. Mirrors the in-game renderer in
3506    // skins.js: pearly body radial, candy gloss sheen at top-left, soft
3507    // rim glow, white pearlescent border, and the signature wet-dot
3508    // pinpoint. Uses a soft pastel pink as the base color so the tile
3509    // reads instantly as the Pastel skin even before the player picks
3510    // a body color.
3511    const baseHex = '#d4c4ff'; // soft lilac that reads pastel on the dark tile
3512    const lite = (typeof lighten === 'function') ? lighten : ((h) => h);
3513    // 1) Pastel body — radial gradient: pearly highlight → soft mid → rim
3514    ctx2.save();
3515    _miniHexPath(ctx2, cx, cy, sz);
3516    ctx2.clip();
3517    const body = ctx2.createRadialGradient(
3518      cx - sz * 0.32, cy - sz * 0.40, 0,
3519      cx, cy, sz * 1.05
3520    );
3521    body.addColorStop(0,    '#ffffff');         // pearly highlight
3522    body.addColorStop(0.45, lite(baseHex, 25)); // soft pastel mid
3523    body.addColorStop(1,    baseHex);           // saturated rim
3524    ctx2.fillStyle = body;
3525    ctx2.fill();
3526    // 2) Candy gloss sheen
3527    const sheen = ctx2.createRadialGradient(
3528      cx - sz * 0.30, cy - sz * 0.45, 0,
3529      cx - sz * 0.30, cy - sz * 0.45, sz * 0.85
3530    );
3531    sheen.addColorStop(0, 'rgba(255,255,255,0.65)');
3532    sheen.addColorStop(0.5, 'rgba(255,255,255,0.18)');
3533    sheen.addColorStop(1, 'rgba(255,255,255,0)');
3534    ctx2.fillStyle = sheen;
3535    ctx2.fill();
3536    // 3) Inner rim glow
3537    const rim = ctx2.createRadialGradient(cx, cy, sz * 0.65, cx, cy, sz);
3538    rim.addColorStop(0, 'rgba(255,255,255,0)');
3539    rim.addColorStop(1, 'rgba(255,255,255,0.18)');
3540    ctx2.fillStyle = rim;
3541    ctx2.fill();
3542    ctx2.restore();
3543    // 4) Pearlescent border
3544    _miniHexPath(ctx2, cx, cy, sz);
3545    ctx2.strokeStyle = 'rgba(255,255,255,0.65)';
3546    ctx2.lineWidth = 1.5;
3547    ctx2.stroke();
3548    // 5) Wet-dot pinpoint
3549    ctx2.save();
3550    ctx2.fillStyle = 'rgba(255,255,255,0.90)';
3551    ctx2.beginPath();
3552    ctx2.ellipse(cx - sz * 0.36, cy - sz * 0.42, sz * 0.10, sz * 0.07, -0.5, 0, Math.PI * 2);
3553    ctx2.fill();
3554    ctx2.restore();
3555  } else if (type === 'theme_bamboo_grove') {
3556    // Drop 10 — Bamboo Grove mini preview: jade gradient, 2 vertical
3557    // bamboo silhouettes, 3 drifting leaves over a soft amber-jade sky.
3558    const sky = ctx2.createLinearGradient(cx, cy - sz, cx, cy + sz);
3559    sky.addColorStop(0, '#5a4a3a');
3560    sky.addColorStop(0.5, '#2d4a3a');
3561    sky.addColorStop(1, '#0a1d12');
3562    ctx2.fillStyle = sky;
3563    ctx2.fillRect(cx - sz, cy - sz, sz * 2, sz * 2);
3564    // Bamboo stalks
3565    ctx2.fillStyle = '#26452c';
3566    ctx2.fillRect(cx - sz * 0.7, cy - sz, sz * 0.18, sz * 2);
3567    ctx2.fillRect(cx + sz * 0.5, cy - sz, sz * 0.14, sz * 2);
3568    ctx2.fillStyle = 'rgba(20,40,28,0.6)';
3569    for (let i = -2; i <= 2; i++) {
3570      ctx2.fillRect(cx - sz * 0.71, cy + i * sz * 0.4, sz * 0.20, 1.5);
3571      ctx2.fillRect(cx + sz * 0.49, cy + i * sz * 0.4 + sz * 0.1, sz * 0.16, 1.5);
3572    }
3573    // Leaves
3574    ctx2.fillStyle = '#7bbf6a';
3575    for (let i = 0; i < 3; i++) {
3576      ctx2.save();
3577      ctx2.translate(cx - sz * 0.3 + i * sz * 0.4, cy - sz * 0.5 + i * sz * 0.3);
3578      ctx2.rotate((i - 1) * 0.5);
3579      ctx2.beginPath(); ctx2.ellipse(0, 0, sz * 0.22, sz * 0.06, 0, 0, Math.PI * 2); ctx2.fill();
3580      ctx2.restore();
3581    }
3582    // Empty hex tinted preview
3583    _drawMiniHex(ctx2, cx, cy, sz * 0.55, 'rgba(60,100,68,0.5)', 'rgba(123,191,106,0.6)');
3584  }
3585}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.