bubbles / commit
got the basics working
index.html | 127 ++++++++++++++++++++++ package.json | 10 +- pnpm-lock.yaml | 232 +++++++++++++++++++++++++++++++++++++++++ src/App.ts | 25 +++++ src/icons/registry.ts | 7 +- src/input/TiltInput.ts | 17 +-- src/input/TouchInput.ts | 16 +-- src/main.ts | 63 ++++++++++- src/physics/RigidBody.ts | 8 ++ src/physics/Water.ts | 61 ++++++++--- src/physics/World.ts | 64 ++++++++---- src/rendering/IconRenderer.ts | 15 +-- src/rendering/Renderer.ts | 35 ++++++- src/rendering/WaterRenderer.ts | 3 + vite.config.ts | 2 + 15 files changed, 611 insertions(+), 74 deletions(-) diff --git a/index.html b/index.html index 5d05ecc..b1294de 100644 --- a/index.html +++ b/index.html @@ -37,6 +37,13 @@ touch-action: none; } + /* CSS fallback: if orientation lock API didn't work, rotate everything back to portrait */ + @media screen and (orientation: landscape) { + #rotate-overlay { + display: flex !important; + } + } + #app { display: flex; flex-direction: column; @@ -80,12 +87,132 @@ text-decoration: underline; text-underline-offset: 2px; } + + /* ── Start screen ───────────────────────────────────────────────── */ + #start-screen { + position: fixed; + inset: 0; + z-index: 100; + background: #0f172a; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 32px; + padding: 40px; + transition: opacity 0.4s, visibility 0.4s; + } + + #start-screen.hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; + } + + #start-screen .logo { + width: 90px; + height: 90px; + opacity: 0.85; + } + + #start-screen h1 { + font-size: 28px; + font-weight: 700; + letter-spacing: 0.04em; + color: #e2e8f0; + margin: 0; + } + + #start-screen p { + font-size: 15px; + color: rgba(226, 232, 240, 0.5); + text-align: center; + max-width: 260px; + line-height: 1.6; + margin: 0; + } + + #start-btn { + margin-top: 8px; + padding: 16px 48px; + font-size: 17px; + font-weight: 600; + font-family: inherit; + color: #0f172a; + background: #38bdf8; + border: none; + border-radius: 999px; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + transition: background 0.15s, transform 0.1s; + } + + #start-btn:active { + background: #7dd3fc; + transform: scale(0.97); + } + + #reset-btn { + position: absolute; + top: 12px; + right: 12px; + width: 40px; + height: 40px; + border: none; + border-radius: 10px; + background: rgba(255, 255, 255, 0.09); + color: rgba(226, 232, 240, 0.6); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + } + + #reset-btn:hover, + #reset-btn:active { + background: rgba(255, 255, 255, 0.18); + color: rgba(226, 232, 240, 0.95); + } + + #reset-btn svg { + width: 20px; + height: 20px; + fill: currentColor; + } </style> </head> <body> <div id="app"> + <!-- Landscape blocker --> + <div id="rotate-overlay" + style="display:none;position:fixed;inset:0;z-index:9999;background:#0f172a;flex-direction:column;align-items:center;justify-content:center;gap:16px;font-size:15px;color:rgba(226,232,240,0.7);text-align:center;padding:24px"> + <svg width="48" height="48" viewBox="0 0 24 24" fill="rgba(226,232,240,0.5)"> + <path + d="M16.48 2.52c3.27 1.55 5.61 4.72 5.97 8.48h1.5C23.44 4.84 18.29 0 12 0l-.66.03 3.81 3.81 1.33-1.32zm-6.25-.77c-.59-.59-1.54-.59-2.12 0L1.75 8.11c-.59.59-.59 1.54 0 2.12l12.02 12.02c.59.59 1.54.59 2.12 0l6.36-6.36c.59-.59.59-1.54 0-2.12L10.23 1.75zm4.6 19.44L2.81 9.17l6.36-6.36 12.02 12.02-6.36 6.36zm-7.57.29C4.01 19.92 1.67 16.75 1.31 13H-.19C.56 19.16 5.71 24 12 24l.66-.03-3.81-3.81-1.33 1.32z" /> + </svg> + <span>Please rotate back to portrait</span> + </div> + + <!-- Start screen --> + <div id="start-screen"> + <img class="logo" src="favicon.ico" alt="Bubbles logo" width="90" height="90" /> + <h1>Bubbles</h1> + <p>Your phone is a glass of water.<br>Tilt to slosh. Tap to drop things in.</p> + <button id="start-btn">Start</button> + </div> + <canvas id="canvas"></canvas> + <button id="reset-btn" title="Reset" aria-label="Reset simulation"> + <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <path + d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" /> + </svg> + </button> <p id="hint">tap to drop · tilt to slosh</p> <footer> <a href="https://gitlab.com/PavelSafronov/bubbles" target="_blank" rel="noopener">gitlab</a> diff --git a/package.json b/package.json index c53eae4..03cb05b 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,21 @@ "private": true, "description": "Silly water glass web app — tilt your phone to slosh water, tap to drop things in", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "tsc --noEmit && vite build", "preview": "vite preview", "gen-assets": "tsx scripts/generate-assets.ts" }, - "dependencies": {}, "devDependencies": { "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", "sharp": "^0.33.5", "tsx": "^4.19.2", "typescript": "^5.7.3", - "vite": "^6.2.5" + "vite": "^6.2.5", + "vite-plugin-qrcode": "^0.4.1" + }, + "dependencies": { + "qrcode": "^1.5.4" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94a0232..a171847 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,17 @@ settings: importers: .: + dependencies: + qrcode: + specifier: ^1.5.4 + version: 1.5.4 devDependencies: '@types/node': specifier: ^22.0.0 version: 22.19.17 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 sharp: specifier: ^0.33.5 version: 0.33.5 @@ -23,6 +30,9 @@ importers: vite: specifier: ^6.2.5 version: 6.4.2(@types/node@22.19.17)(tsx@4.21.0) + vite-plugin-qrcode: + specifier: ^0.4.1 + version: 0.4.1(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0)) packages: @@ -602,6 +612,24 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -616,10 +644,20 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -639,22 +677,54 @@ packages: picomatch: optional: true + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} is-arrayish@0.3.4: resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -662,10 +732,26 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.9: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -679,6 +765,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + sharp@0.33.5: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -690,6 +779,14 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -710,6 +807,14 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + vite-plugin-qrcode@0.4.1: + resolution: {integrity: sha512-t3gdCbn/a2HHLK3M8bUrQ+4c+I0YQGJ/STxAL9eI6TBnfdeM/CJr3HctvOb7UYhEtWxHs5xZWpz7kYnRd5w+rA==} + peerDependencies: + vite: '>=3' + vite@6.4.2: resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -750,6 +855,24 @@ packages: yaml: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + snapshots: '@emnapi/runtime@1.9.2': @@ -1069,6 +1192,24 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.17 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + camelcase@5.3.1: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1085,8 +1226,14 @@ snapshots: color-convert: 2.0.1 color-string: 1.9.1 + decamelize@1.2.0: {} + detect-libc@2.1.2: {} + dijkstrajs@1.0.3: {} + + emoji-regex@8.0.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -1149,27 +1296,64 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 is-arrayish@0.3.4: {} + is-fullwidth-code-point@3.0.0: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + nanoid@3.3.11: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + path-exists@4.0.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} + pngjs@5.0.0: {} + postcss@8.5.9: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + resolve-pkg-maps@1.0.0: {} rollup@4.60.1: @@ -1205,6 +1389,8 @@ snapshots: semver@7.7.4: {} + set-blocking@2.0.0: {} + sharp@0.33.5: dependencies: color: 4.2.3 @@ -1237,6 +1423,16 @@ snapshots: source-map-js@1.2.1: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -1256,6 +1452,13 @@ snapshots: undici-types@6.21.0: {} + uqr@0.1.3: {} + + vite-plugin-qrcode@0.4.1(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0)): + dependencies: + uqr: 0.1.3 + vite: 6.4.2(@types/node@22.19.17)(tsx@4.21.0) + vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0): dependencies: esbuild: 0.25.12 @@ -1268,3 +1471,32 @@ snapshots: '@types/node': 22.19.17 fsevents: 2.3.3 tsx: 4.21.0 + + which-module@2.0.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@4.0.3: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 diff --git a/src/App.ts b/src/App.ts index 3b9ca34..4f1a293 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,5 +1,7 @@ import { World } from './physics/World.js'; import { Water } from './physics/Water.js'; +import { RigidBody } from './physics/RigidBody.js'; +import { Vector2 } from './physics/Vector2.js'; import { Renderer } from './rendering/Renderer.js'; import { TiltInput } from './input/TiltInput.js'; import { TouchInput } from './input/TouchInput.js'; @@ -11,6 +13,7 @@ export class App { private readonly renderer: Renderer; private readonly tilt: TiltInput; private readonly touch: TouchInput; + private qrBody: import('./physics/RigidBody.js').RigidBody | undefined; private rafId = 0; private lastTime = 0; @@ -37,11 +40,33 @@ export class App { this.touch = new TouchInput(canvas, this.world, () => this.requestPermissionOnce()); } + /** Call after the QR canvas has been generated to spawn the QR body. */ + spawnQRBody(qrTexture: HTMLCanvasElement): void { + const r = 40; + const x = this.world.glassX + this.world.glassW / 2; + const y = this.world.glassY + r + 4; + this.qrBody = new RigidBody({ + position: new Vector2(x, y), + radius: r, + density: 0.5, + label: '', + texture: qrTexture, + permanent: true, + }); + this.world.addBody(this.qrBody); + } + start(): void { this.lastTime = performance.now(); this.rafId = requestAnimationFrame(this.loop); } + reset(): void { + // Keep only the permanent QR body + this.world.bodies = this.world.bodies.filter(b => b.permanent); + this.world.water.resetFill(0.55); + } + stop(): void { cancelAnimationFrame(this.rafId); this.renderer.destroy(); diff --git a/src/icons/registry.ts b/src/icons/registry.ts index a953619..0061e47 100644 --- a/src/icons/registry.ts +++ b/src/icons/registry.ts @@ -7,14 +7,13 @@ export interface IconDef { } export const ICONS: IconDef[] = [ - { label: '🏓', density: 0.10, radius: 22 }, // ping pong — bobs wildly - { label: '🦆', density: 0.50, radius: 28 }, // duck — floats high + { label: '⚽', density: 0.10, radius: 22 }, // soccer ball — bobs wildly + { label: '🪵', density: 0.50, radius: 28 }, // wood log — floats high { label: '🍎', density: 0.80, radius: 26 }, // apple — floats { label: '🍋', density: 0.85, radius: 24 }, // lemon — floats { label: '🧊', density: 0.92, radius: 26 }, // ice — barely floats - { label: '🐟', density: 1.05, radius: 26 }, // fish — barely sinks { label: '🪨', density: 2.60, radius: 24 }, // rock — sinks - { label: '💎', density: 3.50, radius: 22 }, // gem — sinks + { label: '🪙', density: 3.50, radius: 22 }, // coin — sinks { label: '⚓', density: 7.50, radius: 26 }, // anchor — sinks fast { label: '🔩', density: 7.80, radius: 20 }, // bolt — sinks fast ]; diff --git a/src/input/TiltInput.ts b/src/input/TiltInput.ts index 6d50c26..fcd509a 100644 --- a/src/input/TiltInput.ts +++ b/src/input/TiltInput.ts @@ -58,16 +58,19 @@ export class TiltInput { } private readonly handleOrientation = (e: DeviceOrientationEvent): void => { - if (e.gamma === null || e.beta === null) return; - // gamma: left/right tilt (-90…+90), beta: front/back (-180…+180) - // We use gamma as lateral tilt (maps to x gravity) and clamp to ±45° - const gammaRad = (Math.min(45, Math.max(-45, e.gamma)) * Math.PI) / 180; - const betaRad = (Math.min(90, Math.max(-90, e.beta)) * Math.PI) / 180; + if (e.gamma === null) return; + // gamma: left/right tilt (-90…+90) when phone is held upright in portrait. + // We derive BOTH gravity components from gamma so that: + // gamma=0 → gravity=(0, 980) — phone upright, water flat + // gamma=30 → gravity=(490, 849) — tilted right, water shifts right + // Using beta for gravity.y was wrong: when upright beta≈90°, cos(90°)≈0 + // which zeroed out vertical gravity entirely. + const gammaDeg = Math.min(88, Math.max(-88, e.gamma)); + const gammaRad = (gammaDeg * Math.PI) / 180; - // Project onto x/y gravity components this._gravity = new Vector2( Math.sin(gammaRad) * 980, - Math.cos(betaRad) * 980, + Math.cos(gammaRad) * 980, ); }; diff --git a/src/input/TouchInput.ts b/src/input/TouchInput.ts index 46c9288..8da71d8 100644 --- a/src/input/TouchInput.ts +++ b/src/input/TouchInput.ts @@ -25,8 +25,10 @@ export class TouchInput { private spawnAt(clientX: number, clientY: number): void { const rect = this.canvas.getBoundingClientRect(); - const canvasX = (clientX - rect.left) * (this.canvas.width / rect.width); - const canvasY = (clientY - rect.top) * (this.canvas.height / rect.height); + // World coordinates are CSS pixels (the renderer scales by DPR via ctx.scale), + // so we just subtract the canvas offset — no DPR multiplication needed. + const canvasX = clientX - rect.left; + const canvasY = clientY - rect.top; // Clamp to glass interior const gx = this.world.glassX; @@ -36,10 +38,9 @@ export class TouchInput { const icon = ICONS[Math.floor(Math.random() * ICONS.length)]!; const r = icon.radius; + // Clamp spawn position to glass interior const spawnX = Math.max(gx + r, Math.min(gx + gw - r, canvasX)); - // Spawn near top of glass (just below the surface or at touch Y if that's lower) - const topOfGlass = gy + r; - const spawnY = Math.min(canvasY, topOfGlass + 20); + const spawnY = Math.max(gy + r, Math.min(gy + this.world.glassH - r, canvasY)); const body = new RigidBody({ position: new Vector2(spawnX, spawnY), @@ -48,9 +49,8 @@ export class TouchInput { label: icon.label, }); - // Give a small random horizontal nudge for fun - body.velocity.x = (Math.random() - 0.5) * 120; - body.velocity.y = 60; + body.velocity.x = (Math.random() - 0.5) * 80; + body.velocity.y = 0; body.angularVelocity = (Math.random() - 0.5) * 4; this.world.addBody(body); diff --git a/src/main.ts b/src/main.ts index 2f834e3..c289bbb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,68 @@ import { App } from './App.js'; +import QRCode from 'qrcode'; const canvas = document.getElementById('canvas') as HTMLCanvasElement | null; +const startScreen = document.getElementById('start-screen') as HTMLDivElement; +const startBtn = document.getElementById('start-btn') as HTMLButtonElement; if (!canvas) throw new Error('No canvas element found'); +const isIOS = /iP(hone|od|ad)/.test(navigator.userAgent); +const supportsFullscreen = document.fullscreenEnabled + // eslint-disable-next-line @typescript-eslint/no-explicit-any + || (document as any).webkitFullscreenEnabled; + +let orientationLocked = false; + +async function tryLockOrientation(): Promise<void> { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (screen.orientation as any).lock('portrait'); + orientationLocked = true; + } catch { /* not supported — CSS landscape overlay handles it */ } +} + +async function enterFullscreen(): Promise<void> { + if (!isIOS && supportsFullscreen) { + try { + await document.documentElement.requestFullscreen({ navigationUI: 'hide' }); + } catch { /* ignore */ } + } + await tryLockOrientation(); +} + +// Re-apply lock if fullscreen change drops it +document.addEventListener('fullscreenchange', () => { + if (document.fullscreenElement && !orientationLocked) { + void tryLockOrientation(); + } +}); + +// Re-apply lock if OS overrides it mid-session +screen.orientation.addEventListener('change', () => { + if (orientationLocked) void tryLockOrientation(); +}); + +// ── Start button ────────────────────────────────────────────────────────── + const app = new App(canvas); -app.start(); +const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement; + +resetBtn.addEventListener('click', () => app.reset()); + +// ── QR body — generate once and spawn into the glass ────────────────────── +void (async () => { + const qrCanvas = document.createElement('canvas'); + await QRCode.toCanvas(qrCanvas, window.location.href, { + width: 200, + margin: 1, + color: { dark: '#000000', light: '#ffffff' }, + }); + app.spawnQRBody(qrCanvas); +})(); + +startBtn.addEventListener('click', async () => { + await enterFullscreen(); + startScreen.classList.add('hidden'); + app.start(); +}, { once: true }); + diff --git a/src/physics/RigidBody.ts b/src/physics/RigidBody.ts index bd12b4b..3f65edd 100644 --- a/src/physics/RigidBody.ts +++ b/src/physics/RigidBody.ts @@ -7,6 +7,10 @@ export interface RigidBodyOptions { density: number; /** Display label — emoji or short string */ label: string; + /** Optional pre-rendered texture drawn instead of label */ + texture?: HTMLCanvasElement; + /** Permanent bodies are never evicted by the 12-body cap */ + permanent?: boolean; } export class RigidBody { @@ -15,6 +19,8 @@ export class RigidBody { readonly radius: number; readonly density: number; readonly label: string; + readonly texture: HTMLCanvasElement | undefined; + readonly permanent: boolean; /** Rotation angle in radians, for visual wobble */ angle: number = 0; angularVelocity: number = 0; @@ -29,6 +35,8 @@ export class RigidBody { this.radius = opts.radius; this.density = opts.density; this.label = opts.label; + this.texture = opts.texture; + this.permanent = opts.permanent ?? false; this.id = RigidBody._nextId++; } diff --git a/src/physics/Water.ts b/src/physics/Water.ts index e78bc26..2d43269 100644 --- a/src/physics/Water.ts +++ b/src/physics/Water.ts @@ -45,27 +45,33 @@ export class Water { */ step(dt: number, lateralG: number, verticalG: number): void { const n = this.columns; - const k = 280; // spring stiffness between columns - const damping = 0.985; // velocity damping per step - const lateralK = 0.08; // how strongly lateral gravity pushes water + const k = 280; // spring stiffness — drives wave propagation + const kTilt = 45; // restoring strength toward equilibrium tilt (must be strong enough to actually reach it) + const damping = 0.978; // velocity damping per step - // Compute target water level shift due to lateral gravity - // A positive lateralG means gravity pulls right → right columns get more water - const shift = lateralG * lateralK * dt; + // Equilibrium surface: when tilted, the surface should slope at angle + // slope = lateralG / verticalG (hydrostatic equilibrium). + // When the phone approaches landscape (verticalG → 0), slope → ∞ capped below. + const hAvg = this.averageHeight; + const slope = verticalG > 5 ? lateralG / verticalG : (lateralG > 0 ? 10 : -10); + // Cap slope — at slope=8 with a typical glassW the high side already overflows + const slopeCapped = Math.max(-8, Math.min(8, slope)); - // Apply spring forces + damping for (let i = 0; i < n; i++) { const left = i > 0 ? this.heights[i - 1]! : this.heights[i]!; const right = i < n - 1 ? this.heights[i + 1]! : this.heights[i]!; const cur = this.heights[i]!; - this.velocities[i] = (this.velocities[i]! + k * (left + right - 2 * cur) * dt) * damping; - } - // Apply lateral gravity as a linear tilt on velocities - for (let i = 0; i < n; i++) { - // Columns on the right get a nudge upward, left get downward — net mass shift - const normalised = (i / (n - 1)) - 0.5; // -0.5 … +0.5 - this.velocities[i] = (this.velocities[i]! + normalised * shift * verticalG); + // Wave spring (Laplacian — drives oscillation, tends toward flat) + const springAcc = k * (left + right - 2 * cur) * dt; + + // Tilt restoring force: drives toward the equilibrium sloped surface. + // hEq for column i: average height ± slope * distance from centre. + const colCentre = (i / (n - 1)) - 0.5; // -0.5 … +0.5 + const hEq = hAvg + colCentre * this.glassW * slopeCapped; + const tiltAcc = kTilt * (hEq - cur) * dt; + + this.velocities[i] = (this.velocities[i]! + springAcc + tiltAcc) * damping; } // Integrate heights @@ -109,6 +115,33 @@ export class Water { return sum / this.columns; } + /** + * Apply a velocity disturbance to columns near localX (px from glass left). + * Positive velocityY = pushing down (body entering water), negative = body leaving. + * influence controls the horizontal spread in columns. + */ + disturb(localX: number, radius: number, velocityY: number): void { + const colW = this.columnWidth; + const centerCol = localX / colW; + const spreadCols = (radius / colW) * 1.5; + const strength = velocityY * 0.018; // tuned so fast splashes are dramatic but not wild + + for (let i = 0; i < this.columns; i++) { + const dist = Math.abs(i - centerCol); + if (dist > spreadCols * 2) continue; + const falloff = Math.exp(-(dist * dist) / (spreadCols * spreadCols)); + this.velocities[i] = (this.velocities[i]! - strength * falloff); + } + } + + /** Reset to a flat surface at the given fill fraction (default 0.55). */ + resetFill(fillFrac: number = 0.55): void { + this.heights.fill(this.glassH * fillFrac); + const vel = this.velocities; + vel.fill(0); + this.volume = this.computeVolume(); + } + /** Resize glass dimensions (e.g. on canvas resize), scaling heights proportionally. */ resize(newGlassW: number, newGlassH: number): void { const scaleH = newGlassH / this.glassH; diff --git a/src/physics/World.ts b/src/physics/World.ts index b5a08fa..47ac49b 100644 --- a/src/physics/World.ts +++ b/src/physics/World.ts @@ -28,7 +28,9 @@ export class World { addBody(body: RigidBody): void { this.bodies.push(body); if (this.bodies.length > MAX_BODIES) { - this.bodies.shift(); // remove oldest + // Evict the oldest non-permanent body + const idx = this.bodies.findIndex(b => !b.permanent); + if (idx !== -1) this.bodies.splice(idx, 1); } } @@ -62,6 +64,9 @@ export class World { // Integrate position body.position.addMut(body.velocity.scale(dt)); + // --- Disturb water surface based on body movement --- + this.disturbWater(body); + // Angular body.angularVelocity *= ANGULAR_DAMPING; body.angle += body.angularVelocity * dt; @@ -76,6 +81,11 @@ export class World { this.resolveBodyCollision(this.bodies[i]!, this.bodies[j]!); } } + + // Remove non-permanent bodies that have fully escaped above the glass rim + this.bodies = this.bodies.filter(b => + b.permanent || (b.position.y + b.radius) > this.glassY + ); } private submergedFraction(body: RigidBody): number { @@ -103,23 +113,25 @@ export class World { private constrainToGlass(body: RigidBody): void { const r = body.radius; - // Convert to local glass coords let lx = body.position.x - this.glassX; let ly = body.position.y - this.glassY; - // Left wall - if (lx - r < 0) { - lx = r; - body.velocity.x = Math.abs(body.velocity.x) * RESTITUTION; - body.velocity.y *= FRICTION; - body.angularVelocity += body.velocity.y * 0.01; - } - // Right wall - if (lx + r > this.glassW) { - lx = this.glassW - r; - body.velocity.x = -Math.abs(body.velocity.x) * RESTITUTION; - body.velocity.y *= FRICTION; - body.angularVelocity -= body.velocity.y * 0.01; + // Only enforce walls below the rim (ly >= 0); above the rim bodies can fly free + if (ly + r > 0) { + // Left wall + if (lx - r < 0) { + lx = r; + body.velocity.x = Math.abs(body.velocity.x) * RESTITUTION; + body.velocity.y *= FRICTION; + body.angularVelocity += body.velocity.y * 0.01; + } + // Right wall + if (lx + r > this.glassW) { + lx = this.glassW - r; + body.velocity.x = -Math.abs(body.velocity.x) * RESTITUTION; + body.velocity.y *= FRICTION; + body.angularVelocity -= body.velocity.y * 0.01; + } } // Floor if (ly + r > this.glassH) { @@ -127,16 +139,28 @@ export class World { body.velocity.y = -Math.abs(body.velocity.y) * RESTITUTION; body.velocity.x *= FRICTION; } - // Ceiling (shouldn't normally happen, but keep objects inside) - if (ly - r < 0) { - ly = r; - body.velocity.y = Math.abs(body.velocity.y) * RESTITUTION; - } body.position.x = lx + this.glassX; body.position.y = ly + this.glassY; } + private disturbWater(body: RigidBody): void { + const localX = body.position.x - this.glassX; + const localY = body.position.y - this.glassY; + const surfaceY = this.water.getSurfaceY(Math.max(0, Math.min(this.glassW, localX))); + + // Only disturb when the body center is within one radius of the surface + const distToSurface = Math.abs(localY - surfaceY); + if (distToSurface > body.radius * 1.5) return; + + // Scale impulse by how fast the body is crossing the surface (Y component) + // and how much of it is in the surface zone + const velY = body.velocity.y; + if (Math.abs(velY) < 10) return; // ignore very slow drift + + this.water.disturb(localX, body.radius, velY); + } + private resolveBodyCollision(a: RigidBody, b: RigidBody): void { const delta = b.position.sub(a.position); const dist = delta.magnitude(); diff --git a/src/rendering/IconRenderer.ts b/src/rendering/IconRenderer.ts index ba8d967..d9a4888 100644 --- a/src/rendering/IconRenderer.ts +++ b/src/rendering/IconRenderer.ts @@ -20,7 +20,7 @@ export class IconRenderer { const bx = body.position.x; const by = body.position.y; - // Determine if center is below water surface + // Determine if center is below water surface (for refraction offset) const localX = bx - this.glassX; const surfaceY = this.glassY + this.water.getSurfaceY(Math.max(0, localX)); const submerged = by > surfaceY; @@ -29,23 +29,10 @@ export class IconRenderer { ctx.translate(bx + (submerged ? getRefractionDx(bx) : 0), by); ctx.rotate(body.angle); - // Draw shadow / glow ring - ctx.beginPath(); - ctx.arc(0, 0, r, 0, Math.PI * 2); - if (submerged) { - ctx.fillStyle = 'rgba(0, 80, 120, 0.25)'; - } else { - ctx.fillStyle = 'rgba(0, 0, 0, 0.15)'; - } - ctx.fill(); - // Draw emoji ctx.font = `${size}px serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - if (submerged) { - ctx.globalAlpha = 0.82; - } ctx.fillText(body.label, 0, 0); ctx.restore(); diff --git a/src/rendering/Renderer.ts b/src/rendering/Renderer.ts index dcffbcc..82aba31 100644 --- a/src/rendering/Renderer.ts +++ b/src/rendering/Renderer.ts @@ -78,6 +78,12 @@ export class Renderer { this.waterRend.update(dt); } + /** Register a pre-rendered QR canvas to be drawn on top of water. */ + setQRTexture(tex: HTMLCanvasElement): void { + this._qrTexture = tex; + } + private _qrTexture: HTMLCanvasElement | undefined; + draw(): void { const ctx = this.ctx; const W = this.canvas.width / (window.devicePixelRatio || 1); @@ -90,12 +96,35 @@ export class Renderer { ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, W, H); - // Clip to glass interior → draw water + // Clip to glass interior this.glass.beginClip(); - this.waterRend.draw(); - // Draw icons (refraction handled inside) + // Icons first (behind water) this.iconRend.draw(this.world.bodies, (x) => this.waterRend.getRefractionDx(x)); + + // Water on top of icons + this.waterRend.draw(); + + // Textured bodies (QR card) drawn on top of water, inside glass clip + for (const body of this.world.bodies) { + if (!body.texture) continue; + const ctx = this.ctx; + const s = body.radius * 2; + ctx.save(); + ctx.translate(body.position.x, body.position.y); + ctx.rotate(body.angle); + // White card background + ctx.fillStyle = '#ffffff'; + const r = 4; + ctx.beginPath(); + ctx.roundRect(-s / 2, -s / 2, s, s, r); + ctx.fill(); + // QR image centred with small padding + const pad = s * 0.06; + ctx.drawImage(body.texture, -s / 2 + pad, -s / 2 + pad, s - pad * 2, s - pad * 2); + ctx.restore(); + } + this.glass.endClip(); // Glass outline on top diff --git a/src/rendering/WaterRenderer.ts b/src/rendering/WaterRenderer.ts index 8862cd7..e6845f1 100644 --- a/src/rendering/WaterRenderer.ts +++ b/src/rendering/WaterRenderer.ts @@ -49,6 +49,7 @@ export class WaterRenderer { } // Close to bottom-right then bottom-left + ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1)); ctx.lineTo(gx + w.glassW, gy + gh); ctx.closePath(); @@ -84,6 +85,7 @@ export class WaterRenderer { ctx.bezierCurveTo(cpX, prevSY, cpX, sy, x, sy); } } + ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1)); ctx.lineTo(gx + w.glassW, gy + gh); ctx.closePath(); ctx.clip(); @@ -107,6 +109,7 @@ export class WaterRenderer { ctx.bezierCurveTo(cpX, prevSY, cpX, sy, x, sy); } } + ctx.lineTo(gx + w.glassW, gy + w.getSurfaceY(w.glassW - 1)); ctx.strokeStyle = 'rgba(186, 230, 253, 0.7)'; ctx.lineWidth = 2; ctx.stroke(); diff --git a/vite.config.ts b/vite.config.ts index 0d60b09..5a966dc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from 'vite'; +import { qrcode } from 'vite-plugin-qrcode'; export default defineConfig({ + plugins: [qrcode()], publicDir: 'static', build: { outDir: 'dist',