1// Which way up a page wants the phone. 2// 3// Every cabinet on this site is portrait or close enough to square, and play 4// mode was built for a thumb on each side of a tall screen. CRYPTO SMASH is 5// the exception: its world is 480x320 and its canvas 960x640, so on a 390px 6// portrait phone the fight gets 390x260 â thirty-one per cent of the screen 7// height â while its own controls sit on top of that strip. Turned sideways 8// the same fight gets the full height and 2.3x the area, and the control 9// layout stops being cramped: #touch pins the pad hard left and the strikes 10// hard right, which is 390px apart in portrait and 844px apart in landscape. 11// The second number is where thumbs actually are when you hold a phone to 12// play something. 13// 14// The ask comes at the door rather than at the fight. Being told to rotate 15// while a match is counting down is a worse moment than being told before you 16// have picked a fighter, so the gate goes up on arrival and everything past 17// it â menu, character select, the fight â is already the right way round. 18// 19// Two mechanisms, because neither is enough alone: 20// 21// screen.orientation.lock() actually turns the app. It works in an installed 22// PWA or the Seeker TWA, needs fullscreen first in an ordinary browser tab, 23// and is simply absent on iOS Safari. So it is attempted and never relied on. 24// 25// The gate is the fallback and the honest one: ask, show which way, and get 26// out of the way the moment the phone is round. It is also what a player 27// sees if they decline or revoke the lock. 28// 29// Phantom's in-app browser can do neither. Its WebView is portrait-locked at 30// the native level, and no web API overrides a host app's orientation policy â 31// lock() rejects, and the gate asks for something the player physically cannot 32// give. A gate that cannot be satisfied is the worst screen on the site. 33// 34// So there the CONTENT turns instead of the phone: <main> is rotated ninety 35// degrees with its width and height swapped, and the player turns the handset 36// to meet it. The OS never rotates; the game is simply already sideways when 37// they get there. 38// 39// This costs nothing in brawl.js, which is luck rather than design. Its 40// controls are DOM buttons whose handlers only set a bitmask â no clientX, no
41// coordinate maths â so the browser hit-tests them through the transform on its 42// own. And fitCabinet measures #cab-screen's own clientWidth/clientHeight 43// rather than the window's, so it reads the swapped box and scales correctly 44// with no changes. A canvas that read raw pointer coordinates would have needed 45// every one of them mapped back through the rotation. 46(() => { 47 const WANT = { // everything else stays portrait 48 '/brawl': 'landscape', 49 // The cabinet is 16:9 and its UI is authored against a 960x540 reference 50 // that matches on HEIGHT. On a 390px portrait phone that is a 219px-tall 51 // strip with the rod, the reel button and the joystick sharing it; turned, 52 // the same game gets the whole 390 and 2.2x the area. 53 '/harbormaster': 'landscape', 54 // BREAK's table is 2:1 across the bed and its canvas 1040x640. On a 390px 55 // portrait phone that is a 326x201 strip in the top corner of an 844-tall 56 // page â a fifth of the screen, with balls four pixels across, which is 57 // not a table anybody can aim on. Turned, it gets the full 844 and four 58 // times the area. 59 '/break': 'landscape', 60 '/break/live': 'landscape', 61 }; 62 // Why each one asks, in its own words. The gate used to name CRYPTO SMASH 63 // whatever page it was on, which is the sort of copy that reads as a bug on 64 // every page but the one it was written for. 65 const WHY = { 66 '/brawl': 'CRYPTO SMASH is a sideways fight \u2014 landscape gives it ' 67 + 'the whole screen instead of a third of it.', 68 '/harbormaster': 'The bay is wider than it is tall \u2014 landscape gives the ' 69 + 'water the whole screen, and puts the reel under your thumb.', 70 '/break': 'A pool table is twice as wide as it is deep \u2014 landscape gives ' 71 + 'it the whole screen instead of a fifth of it.', 72 '/break/live': 'A pool table is twice as wide as it is deep \u2014 landscape ' 73 + 'gives it the whole screen instead of a fifth of it.', 74 }; 75 // Pages whose gate WAITS. The ask normally comes at the door, because being 76 // told to rotate mid-countdown is worse than being told before you have 77 // chosen anything. /break broke that rule the other way: its door is now a 78 // menu, and a menu reads perfectly well upright â so the first thing a phone 79 // met was a demand to turn it, made before it had been shown a single thing 80 // worth turning for. Here the gate is armed by the page instead, at the 81 // moment a mode is chosen and a table is actually about to appear. 82 // 83 // Deferring is only ever safe where the pre-armed view is genuinely usable 84 // in portrait. Adding a path here without checking that is how you ship a 85 // game that runs in a strip. 86 const DEFER = { 87 '/break': true, 88 }; 89 90 const path = location.pathname.replace(/\/$/, '') || '/'; 91 const want = WANT[path] || 'portrait'; 92 93 // Desktop has no orientation to speak of, and a mouse does not care. 94 const touch = matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window; 95 if (!touch) return; 96 97 // THE QUESTION IS ABOUT THE BOX, NOT THE HANDSET. 98 // 99 // This used to prefer screen.orientation.type, and that is a statement about 100 // the DEVICE. It is the same statement whether the page owns the whole screen 101 // or a panel in the middle of somebody else's app â and inside a Telegram 102 // Mini App it is routinely 'portrait-primary' while the sheet the game has 103 // been handed is 844x390. So a player holding the phone sideways was told to 104 // rotate it, and the gate â a full-screen overlay â sat on top of the fight 105 // swallowing every tap. They could see the match and could not touch it. 106 // 107 // The media query is the same engine that lays the page out answering about 108 // the same viewport, so it cannot disagree with what the player sees. It is 109 // also what the change listener below already watches, which until now 110 // decided WHEN to re-check while something else decided WHAT the answer was. 111 // innerWidth/innerHeight is the fallback for anything without matchMedia. 112 // 113 // screen.orientation keeps its one honest job: tryLock() asks the platform to 114 // turn the device, which is a question about the device. 115 const isLandscape = () => { 116 try { 117 const mq = matchMedia('(orientation: landscape)'); 118 if (mq && typeof mq.matches === 'boolean') return mq.matches; 119 } catch (_) { /* fall through to measuring it */ } 120 return window.innerWidth > window.innerHeight; 121 }; 122 const correct = () => (want === 'landscape' ? isLandscape() : !isLandscape()); 123 const MUST_LANDSCAPE = want === 'landscape'; 124 125 // ------------------------------------------------ the portrait-locked case 126 127 // Phantom's in-app browser. The provider object alone is not proof â the 128 // desktop extension injects the identical thing â but this module has already 129 // returned unless the pointer is coarse, so a Phantom provider here means the 130 // handset app's own browser. 131 //
132 // Named explicitly, and that is a real cost: Solflare, Coinbase and whatever 133 // wallet ships next are locked the same way and are not covered by this. A 134 // check on the failure rather than the name would catch all of them. 135 function inPhantom() { 136 try { 137 if (/Phantom/i.test(navigator.userAgent || '')) return true; 138 const p = window.phantom && window.phantom.solana; 139 return !!(p && p.isPhantom); 140 } catch (_) { return false; } 141 } 142 143 const ROTATE = MUST_LANDSCAPE && inPhantom(); 144 145 // Armed on arrival everywhere except a deferred path, where the page says 146 // when. Everything the sideways treatment does â the gate, the content 147 // rotation and the landscape layout class â waits on this one flag, because 148 // turning the menu ninety degrees is exactly as wrong as demanding the 149 // player turn the handset for it. 150 let armed = !DEFER[path]; 151 152 // Ask the platform to turn. Failure is normal â a browser tab that is not 153 // fullscreen refuses, and iOS has no such API at all â so it is fired and 154 // forgotten, with the gate below carrying the case either way. 155 function tryLock() { 156 try { 157 const o = window.screen && screen.orientation; 158 if (o && typeof o.lock === 'function') o.lock(want).catch(() => {}); 159 } catch (_) { /* not supported here; the gate does the asking */ } 160 } 161 162 // ------------------------------------------------------------- the gate 163 164 // Only a page that genuinely cannot be played the other way blocks. Nothing 165 // on this site needs to insist on portrait â a portrait page in landscape is 166 // merely wide â so the gate belongs to the landscape one alone. 167 // The gate belongs to the landscape page alone, and never where the content 168 // is being turned instead â asking a player to rotate a screen that is 169 // already sideways is asking them to undo it. 170 const MUST = MUST_LANDSCAPE && !ROTATE; 171 let gate = null; 172 173 function build() { 174 if (gate) return gate; 175 gate = document.createElement('div'); 176 gate.id = 'rotate-gate'; 177 gate.innerHTML = ` 178 <div class="rg-box"> 179 <div class="rg-phone" aria-hidden="true"> 180 <svg viewBox="0 0 64 64"> 181 <rect x="20" y="6" width="24" height="42" rx="4" stroke-width="3" fill="none"/> 182 <line x1="27" y1="43" x2="37" y2="43" stroke-width="3" stroke-linecap="round"/> 183 </svg> 184 </div> 185 <p class="rg-title">TURN YOUR PHONE</p> 186 <p class="rg-sub"></p> 187 <button type="button" class="rg-anyway">Play in portrait anyway</button> 188 </div>`; 189 gate.querySelector('.rg-sub').textContent = WHY[path]
190 || 'This one plays better sideways.'; 191 // Dismissible on purpose. A gate with no way past it is a wall, and a 192 // player who wants to try it upright is entitled to a worse view. 193 gate.querySelector('.rg-anyway').addEventListener('click', () => { 194 dismissed = true; 195 apply(); 196 }); 197 document.body.appendChild(gate); 198 return gate; 199 } 200 201 let dismissed = false; 202 203 function apply() { 204 const ok = correct(); 205 const block = MUST && armed && !ok && !dismissed; 206 document.body.classList.toggle('needs-rotate', block); 207 if (block) build(); 208 // Rotating after dismissing puts the player where they were headed, and 209 // there is nothing left to ask, so the dismissal is spent. 210 if (ok) dismissed = false; 211 } 212 213 function applyRotation() { 214 // Only while the handset is upright. Turned already, the OS has done the 215 // job and rotating on top of it would stand the game on its head. 216 const on = ROTATE && armed && !isLandscape(); 217 document.body.classList.toggle('phantom-landscape', on); 218 if (on) addExit(); 219 } 220 221 // Sideways, however it got there. 222 // 223 // The landscape layout â pad hard left, strikes hard right, the select laid 224 // along the long axis, the artwork sized off the short one â was written for 225 // the rotated case and keyed to it, so it only ever ran inside Phantom. A 226 // phone that simply turned got the portrait stylesheet instead, which sizes 227 // everything off the long axis: the menu landed below the fold and behind 228 // the tab bar, and the fight ran taller than the screen with its controls 229 // off the bottom of it. This is the class that layout actually belongs to; 230 // the rotation keeps its own for the transform alone. 231 function applySideways() { 232 const rotated = document.body.classList.contains('phantom-landscape'); 233 document.body.classList.toggle('sideways', MUST_LANDSCAPE && armed && (rotated || isLandscape())); 234 } 235 236 // A way out. 237 // 238 // Turning the content means hiding the header, the footer and the tab bar â 239 // they are outside <main> and would sit sideways across a turned page. Every 240 // one of them was also the only way off this page, so the rotated view had no 241 // exit at all: the cabinet's own MENU goes back to its title screen, and the 242 // title screen offers three game modes and nothing else. The only way back to 243 // the arcade was the browser's back button, which an in-app wallet browser 244 // does not always give you. 245 // 246 // Built here rather than in the page because this belongs to the rotation, 247 // not to any one cabinet â whatever else ends up in WANT gets it too. Inside 248 // <main> so it turns with everything else. 249 function addExit() { 250 if (document.getElementById('rot-exit')) return; 251 const host = document.querySelector('main'); 252 if (!host) return; 253 const a = document.createElement('a'); 254 a.id = 'rot-exit'; 255 a.href = '/arcade'; 256 a.textContent = '\u2190 ARCADE'; 257 host.appendChild(a); 258 } 259 260 function start() { 261 if (ROTATE) applyRotation(); 262 applySideways(); 263 tryLock(); 264 apply(); 265 // orientationchange is the one that fires on the phones that matter; the 266 // media query catches the rest, including a desktop window being dragged 267 // narrow with touch emulation on. 268 const onTurn = () => { applyRotation(); applySideways(); apply(); }; 269 window.addEventListener('orientationchange', () => setTimeout(onTurn, 80)); 270 matchMedia('(orientation: landscape)').addEventListener('change', onTurn); 271 window.addEventListener('resize', onTurn); 272 // A lock is dropped when the app is backgrounded on some platforms; ask 273 // again on the way back rather than leaving the player sideways-locked to 274 // the wrong thing. 275 document.addEventListener('visibilitychange', () => { 276 if (!document.hidden) { tryLock(); applySideways(); apply(); } 277 }); 278 } 279 280 // What a deferred page calls when it is finally about to show a table. 281 // Idempotent: a player who backs out to the menu and picks again must not 282 // get a second gate, and there is nothing to disarm â once a table has been 283 // on screen, the page is a landscape page for the rest of the visit. 284 window.ARCADE_ORIENT = { 285 arm() { 286 if (armed) return; 287 armed = true; 288 if (ROTATE) applyRotation(); 289 applySideways();
290 tryLock(); 291 apply(); 292 }, 293 get armed() { return armed; }, 294 get deferred() { return !!DEFER[path]; }, 295 }; 296 297 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); 298 else start(); 299})();
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.