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.