1import { 2 createWorld, 3 updateWorld, 4 swing, 5 continueRound, 6 setAim, 7 nudgeAim, 8 viewSize, 9} from './world.js'; 10import { readBest, submitBest } from './score.js'; 11import { attachInput, createDisplay, startLoop } from './runtime.js'; 12import { createArt } from './art.js'; 13import { 14 isMuted, 15 playCatch, 16 playBonus, 17 playRound, 18 playSplat, 19 playSwing, 20 resume as resumeAudio, 21 startMusic, 22 stopMusic, 23 toggleMuted, 24} from './audio.js'; 25import { 26 assertGameMountOptions, 27 createGameShell, 28 createScoreFlow, 29} from '../../shared/game-integration.js?v=20260911-debug'; 30 31const GAME = Object.freeze({ 32 gameId: 4, 33 slug: 'recycle-tycoon', 34 title: 'RECYCLE TYCOON', 35 hint: 'click to catch · M mute · R restart', 36}); 37const KEY_AXIS = { 38 ArrowUp: [0, -1], 39 ArrowDown: [0, 1], 40 ArrowLeft: [-1, 0], 41 ArrowRight: [1, 0], 42 w: [0, -1], 43 s: [0, 1], 44 a: [-1, 0], 45 d: [1, 0], 46 W: [0, -1], 47 S: [0, 1], 48 A: [-1, 0], 49 D: [1, 0], 50}; 51 52export function mountRecycleTycoon(options) { 53 assertGameMountOptions(options, GAME); 54 55 const { container, auth, onExit } = options; 56 const elements = createGameShell(container, GAME); 57 const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false; 58 const { width, height } = viewSize(); 59 const display = createDisplay(elements.stage, { 60 width, 61 height, 62 background: '#080a10', 63 backgroundGradient: ['#263b5b', '#080a10', '#4a2b38'], 64 }); 65 const input = attachInput(elements.stage); 66 const art = createArt(); 67 const ctx = display.ctx; 68 const held = new Set(); 69 70 let world = createWorld({ seed: (Date.now() / 1000) | 0 }); 71 let cover = true; 72 let time = 0; 73 let shake = 0; 74 let swingPulse = 0; 75 let displayed = 0; 76 let newBest = false; 77 let best = readBest(); 78 let musicStarted = false; 79 let runStartedAt = Date.now(); 80 let destroyed = false; 81 let scoreFlow; 82 83 function syncMuteButton() { 84 const muted = isMuted(); 85 elements.soundButton.setAttribute('aria-pressed', String(!muted)); 86 const label = elements.soundButton.querySelector('.toggle-text'); 87 if (label) label.textContent = muted ? 'Sound off' : 'Sound on'; 88 } 89 90 function startMusicOnce() { 91 if (musicStarted) return; 92 musicStarted = true; 93 startMusic(); 94 } 95 96 function chase(current, target, dt) { 97 if (current === target) return target; 98 const diff = target - current; 99 const abs = Math.abs(diff); 100 const step = Math.max(abs * 9 * dt, 40 * dt * Math.max(1, abs / 400)); 101 return abs <= step ? target : current + Math.sign(diff) * step; 102 } 103 104 function resetToCover() { 105 if (destroyed) return; 106 world = createWorld({ seed: (Date.now() / 1000) | 0 }); 107 art.clearParticles(); 108 held.clear(); 109 cover = true; 110 shake = 0; 111 swingPulse = 0; 112 displayed = 0; 113 newBest = false; 114 runStartedAt = Date.now(); 115 elements.stage.focus(); 116 } 117 118 function restartPlaying() { 119 if (destroyed) return; 120 world = createWorld({ seed: (Date.now() / 1000) | 0 }); 121 art.clearParticles(); 122 held.clear(); 123 cover = false; 124 shake = 0; 125 swingPulse = 0; 126 displayed = 0; 127 newBest = false; 128 runStartedAt = Date.now(); 129 elements.stage.focus(); 130 } 131 132 function noteScore() { 133 if (submitBest(undefined, world.saved)) { 134 newBest = true; 135 best = world.saved; 136 } 137 } 138 139 scoreFlow = createScoreFlow({ 140 gameId: GAME.gameId, 141 auth, 142 elements, 143 onExit, 144 onPlayAgain: restartPlaying, 145 }); 146 147 function handleEvents() { 148 for (const event of world.events) { 149 if (event.type === 'hit') { 150 playCatch(event.itemType); 151 const power = event.value >= 100 ? 2 : event.value >= 20 ? 1.5 : 1; 152 art.burst(event.x + 8, event.y + 6, '#f5d97a', power); 153 art.spawnFloat(event.x, event.y, `+${event.value}`); 154 if (!reducedMotion) shake = Math.max(shake, 1.4 * power); 155 noteScore(); 156 } else if (event.type === 'bonus') { 157 playBonus(); 158 art.burst(event.x + 7, event.y, '#ffe680', 2.4); 159 art.spawnFloat(event.x, event.y, `+${event.value}`); 160 if (!reducedMotion) shake = Math.max(shake, 3.2); 161 noteScore(); 162 } else if (event.type === 'escaped' && event.splat && world.impact) { 163 playSplat(); 164 art.splat(world.impact.x, world.impact.y, '#4aa564'); 165 if (!reducedMotion) shake = Math.max(shake, 1.1); 166 } else if (event.type === 'roundOver') { 167 playRound(); 168 noteScore(); 169 } else if (event.type === 'gameOver') { 170 noteScore(); 171 scoreFlow.completeRun(world.saved, runStartedAt); 172 } 173 } 174 world.events.length = 0; 175 for (const gone of world.bonusExpired) { 176 art.burst(gone.x, gone.y, '#f5d97a', 1.1); 177 } 178 world.bonusExpired.length = 0; 179 world.impact = null; 180 world.bonusHit = null; 181 } 182 183 function tapAt(x, y) { 184 if (destroyed || scoreFlow.isBlocking()) return; 185 resumeAudio(); 186 startMusicOnce(); 187 setAim(world, x, y); 188 if (cover) { 189 cover = false;
190 runStartedAt = Date.now(); 191 return; 192 } 193 if (world.phase === 'roundOver') { 194 continueRound(world); 195 return; 196 } 197 if (world.phase === 'gameOver') { 198 restartPlaying(); 199 return; 200 } 201 const result = swing(world, x, y); 202 if (result.result !== 'ignored') { 203 playSwing(); 204 swingPulse = 1; 205 } 206 } 207 208 function onKey(event) { 209 const key = event.key; 210 if (key === 'm' || key === 'M') { 211 toggleMuted(); 212 syncMuteButton(); 213 resumeAudio(); 214 startMusicOnce(); 215 return; 216 } 217 if (key === 'r' || key === 'R') { 218 if (!scoreFlow.isBlocking()) resetToCover(); 219 return; 220 } 221 if (scoreFlow.isBlocking()) return; 222 const axis = KEY_AXIS[key]; 223 if (axis) { 224 event.preventDefault(); 225 held.add(key); 226 resumeAudio(); 227 return; 228 } 229 if (key === ' ' || key === 'Enter') { 230 event.preventDefault(); 231 if (event.repeat) return; 232 resumeAudio(); 233 startMusicOnce(); 234 if (cover) { 235 cover = false; 236 runStartedAt = Date.now(); 237 return; 238 } 239 if (world.phase === 'roundOver') continueRound(world); 240 } 241 } 242 243 function onKeyUp(event) { 244 held.delete(event.key); 245 } 246 247 function onBlur() { 248 held.clear(); 249 } 250 251 function onSoundClick() { 252 resumeAudio(); 253 startMusicOnce(); 254 toggleMuted(); 255 syncMuteButton(); 256 } 257 258 window.addEventListener('keyup', onKeyUp); 259 window.addEventListener('blur', onBlur); 260 document.addEventListener('keydown', onKey); 261 elements.soundButton.addEventListener('click', onSoundClick); 262 syncMuteButton(); 263 264 const loop = startLoop({ 265 update(dt) { 266 time += dt; 267 if (scoreFlow.isBlocking()) { 268 held.clear(); 269 input.takeClicks(); 270 return; 271 } 272 const ptr = input.getPointer(); 273 if (ptr) {
274 const pos = display.toGame(ptr.clientX, ptr.clientY); 275 setAim(world, pos.x, pos.y); 276 } 277 let ax = 0; 278 let ay = 0; 279 for (const key of held) { 280 const axis = KEY_AXIS[key]; 281 if (axis) { 282 ax += axis[0]; 283 ay += axis[1]; 284 } 285 } 286 if (ax || ay) nudgeAim(world, dt, Math.sign(ax), Math.sign(ay)); 287 for (const click of input.takeClicks()) { 288 const pos = display.toGame(click.clientX, click.clientY); 289 tapAt(pos.x, pos.y); 290 } 291 if (!cover) updateWorld(world, dt); 292 handleEvents(); 293 art.updateParticles(dt); 294 displayed = chase(displayed, world.saved, dt); 295 swingPulse = Math.max(0, swingPulse - dt * 6); 296 if (!reducedMotion) { 297 shake *= Math.max(0, 1 - 9 * dt); 298 if (shake < 0.05) shake = 0; 299 } else { 300 shake = 0; 301 } 302 }, 303 render() { 304 art.draw(ctx, { 305 world, 306 time, 307 best, 308 displayed, 309 cover, 310 newBest, 311 reducedMotion, 312 swingPulse, 313 }); 314 const ox = shake > 0 ? (Math.random() - 0.5) * 2 * shake : 0; 315 const oy = shake > 0 ? (Math.random() - 0.5) * 2 * shake : 0; 316 display.present(ox, oy); 317 }, 318 }); 319 320 return { 321 pause() { 322 if (!destroyed) loop.pause(); 323 }, 324 resume() { 325 if (!destroyed) loop.resume(); 326 }, 327 destroy() { 328 if (destroyed) return; 329 destroyed = true; 330 loop.stop(); 331 scoreFlow.destroy(); 332 input.dispose(); 333 display.destroy(); 334 held.clear(); 335 stopMusic(); 336 window.removeEventListener('keyup', onKeyUp); 337 window.removeEventListener('blur', onBlur); 338 document.removeEventListener('keydown', onKey); 339 elements.soundButton.removeEventListener('click', onSoundClick); 340 container.replaceChildren(); 341 }, 342 }; 343}
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.