diff options
| -rw-r--r-- | .gitignore | 147 | ||||
| -rw-r--r-- | bun.lock | 130 | ||||
| -rw-r--r-- | index.html | 12 | ||||
| -rw-r--r-- | package.json | 16 | ||||
| -rwxr-xr-x | server/cyberjump | bin | 0 -> 9442714 bytes | |||
| -rw-r--r-- | server/go.mod | 7 | ||||
| -rw-r--r-- | server/go.sum | 4 | ||||
| -rw-r--r-- | server/main.go | 263 | ||||
| -rw-r--r-- | src/game/AudioEngine.ts | 100 | ||||
| -rw-r--r-- | src/game/CyberJumpApp.ts | 888 | ||||
| -rw-r--r-- | src/game/content.ts | 123 | ||||
| -rw-r--r-- | src/main.ts | 11 | ||||
| -rw-r--r-- | src/network/NetworkClient.ts | 180 | ||||
| -rw-r--r-- | src/rendering/shaders/world.frag | 57 | ||||
| -rw-r--r-- | src/rendering/shaders/world.vert | 8 | ||||
| -rw-r--r-- | src/styles.css | 200 | ||||
| -rw-r--r-- | src/vite-env.d.ts | 1 | ||||
| -rw-r--r-- | tsconfig.json | 20 | ||||
| -rw-r--r-- | vite.config.ts | 10 |
19 files changed, 2177 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08748cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,147 @@ +# Created by https://gitignore.org +# Node.gitignore + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp directory +.temp + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ + diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..c42f922 --- /dev/null +++ b/bun.lock @@ -0,0 +1,130 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "cyberjump", + "devDependencies": { + "typescript": "^5.4.0", + "vite": "^5.2.0", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], + + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..cdb8d8e --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>CyberJump</title> +</head> +<body> + <div id="app"></div> + <script type="module" src="/src/main.ts"></script> +</body> +</html> diff --git a/package.json b/package.json new file mode 100644 index 0000000..ed888de --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "cyberjump", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "server": "cd server && go run ." + }, + "dependencies": {}, + "devDependencies": { + "typescript": "^5.4.0", + "vite": "^5.2.0" + } +} diff --git a/server/cyberjump b/server/cyberjump Binary files differnew file mode 100755 index 0000000..00d4ec1 --- /dev/null +++ b/server/cyberjump diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..5ef24cc --- /dev/null +++ b/server/go.mod @@ -0,0 +1,7 @@ +module cyberjump + +go 1.21 + +require github.com/gorilla/websocket v1.5.1 + +require golang.org/x/net v0.17.0 // indirect diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..272772f --- /dev/null +++ b/server/go.sum @@ -0,0 +1,4 @@ +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..cd31356 --- /dev/null +++ b/server/main.go @@ -0,0 +1,263 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +type playerState struct { + ID string `json:"id"` + X float64 `json:"x"` + Y float64 `json:"y"` + VX float64 `json:"vx"` + VY float64 `json:"vy"` + Height float64 `json:"height"` + Layer string `json:"layer"` + Cycle int `json:"cycle"` + UpdatedAt int64 `json:"updatedAt"` +} + +type stateMessage struct { + Type string `json:"type"` + X float64 `json:"x"` + Y float64 `json:"y"` + VX float64 `json:"vx"` + VY float64 `json:"vy"` + Height float64 `json:"height"` + Layer string `json:"layer"` + Cycle int `json:"cycle"` +} + +type welcomeMessage struct { + Type string `json:"type"` + ID string `json:"id"` +} + +type snapshotMessage struct { + Type string `json:"type"` + Players []playerState `json:"players"` +} + +type client struct { + id string + conn *websocket.Conn + hub *hub + mu sync.Mutex +} + +type hub struct { + mu sync.RWMutex + clients map[string]*client + states map[string]playerState + ids atomic.Uint64 +} + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +func newHub() *hub { + return &hub{ + clients: make(map[string]*client), + states: make(map[string]playerState), + } +} + +func (h *hub) addClient(conn *websocket.Conn) *client { + id := fmt.Sprintf("ghost-%04d", h.ids.Add(1)) + client := &client{id: id, conn: conn, hub: h} + + h.mu.Lock() + h.clients[id] = client + h.mu.Unlock() + + return client +} + +func (h *hub) removeClient(id string) { + h.mu.Lock() + delete(h.clients, id) + delete(h.states, id) + h.mu.Unlock() + h.broadcastSnapshot() +} + +func (h *hub) updateState(id string, message stateMessage) { + h.mu.Lock() + h.states[id] = playerState{ + ID: id, + X: message.X, + Y: message.Y, + VX: message.VX, + VY: message.VY, + Height: message.Height, + Layer: message.Layer, + Cycle: message.Cycle, + UpdatedAt: time.Now().UnixMilli(), + } + h.mu.Unlock() + h.broadcastSnapshot() +} + +func (h *hub) snapshot() []playerState { + h.mu.RLock() + defer h.mu.RUnlock() + + players := make([]playerState, 0, len(h.states)) + for _, state := range h.states { + players = append(players, state) + } + return players +} + +func (h *hub) clientCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} + +func (h *hub) broadcastSnapshot() { + payload, err := json.Marshal(snapshotMessage{Type: "snapshot", Players: h.snapshot()}) + if err != nil { + log.Printf("snapshot marshal error: %v", err) + return + } + + h.mu.RLock() + clients := make([]*client, 0, len(h.clients)) + for _, connected := range h.clients { + clients = append(clients, connected) + } + h.mu.RUnlock() + + for _, connected := range clients { + connected.writeJSON(payload) + } +} + +func (c *client) writeJSON(payload []byte) { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil { + log.Printf("write error for %s: %v", c.id, err) + } +} + +func (c *client) sendWelcome() error { + payload, err := json.Marshal(welcomeMessage{Type: "welcome", ID: c.id}) + if err != nil { + return err + } + c.writeJSON(payload) + return nil +} + +func (c *client) readLoop() { + defer func() { + c.hub.removeClient(c.id) + _ = c.conn.Close() + }() + + if err := c.sendWelcome(); err != nil { + log.Printf("welcome error for %s: %v", c.id, err) + return + } + + for { + _, payload, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("read error for %s: %v", c.id, err) + } + return + } + + var message stateMessage + if err := json.Unmarshal(payload, &message); err != nil { + continue + } + + if message.Type != "state" { + continue + } + + c.hub.updateState(c.id, message) + } +} + +func main() { + addr := flag.String("addr", ":8080", "HTTP service address") + flag.Parse() + + h := newHub() + mux := http.NewServeMux() + + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("websocket upgrade error: %v", err) + return + } + + client := h.addClient(conn) + go client.readLoop() + }) + + mux.HandleFunc("/ping", func(w http.ResponseWriter, _ *http.Request) { + withCORS(w) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("pong")) + }) + + mux.HandleFunc("/status", func(w http.ResponseWriter, _ *http.Request) { + withCORS(w) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "online", + "players": h.clientCount(), + }) + }) + + mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) { + withCORS(w) + w.Header().Set("Content-Type", "application/json") + + host := r.Host + if host == "" { + host = "localhost:8080" + } + + scheme := "ws" + if r.TLS != nil { + scheme = "wss" + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "servers": []map[string]string{{ + "url": fmt.Sprintf("%s://%s/ws", scheme, host), + "region": "local", + }}, + }) + }) + + log.Printf("CyberJump relay listening on %s", *addr) + if err := http.ListenAndServe(*addr, mux); err != nil { + log.Fatal(err) + } +} + +func withCORS(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") +} diff --git a/src/game/AudioEngine.ts b/src/game/AudioEngine.ts new file mode 100644 index 0000000..1a6f907 --- /dev/null +++ b/src/game/AudioEngine.ts @@ -0,0 +1,100 @@ +type AudioContextCtor = typeof AudioContext; + +export class AudioEngine { + private context?: AudioContext; + private master?: GainNode; + private drone?: OscillatorNode; + private shimmer?: OscillatorNode; + private active = false; + + activate(): void { + if (this.active) { + void this.context?.resume(); + return; + } + + const ctor = window.AudioContext ?? (window as Window & { webkitAudioContext?: AudioContextCtor }).webkitAudioContext; + if (!ctor) { + return; + } + + this.context = new ctor(); + this.master = this.context.createGain(); + this.master.gain.value = 0.07; + this.master.connect(this.context.destination); + + this.drone = this.context.createOscillator(); + this.drone.type = 'sawtooth'; + this.drone.frequency.value = 82; + const droneGain = this.context.createGain(); + droneGain.gain.value = 0.18; + this.drone.connect(droneGain); + droneGain.connect(this.master); + this.drone.start(); + + this.shimmer = this.context.createOscillator(); + this.shimmer.type = 'triangle'; + this.shimmer.frequency.value = 164; + const shimmerGain = this.context.createGain(); + shimmerGain.gain.value = 0.08; + this.shimmer.connect(shimmerGain); + shimmerGain.connect(this.master); + this.shimmer.start(); + + this.active = true; + } + + setMood(fidelity: number, cycle: number, layerIndex: number): void { + if (!this.context || !this.master || !this.drone || !this.shimmer) { + return; + } + + const now = this.context.currentTime; + const base = 82 + cycle * 8 + layerIndex * 3; + this.drone.frequency.linearRampToValueAtTime(base + fidelity * 18, now + 0.18); + this.shimmer.frequency.linearRampToValueAtTime(base * 2 + (1 - fidelity) * 70, now + 0.22); + this.master.gain.linearRampToValueAtTime(0.045 + fidelity * 0.04, now + 0.25); + } + + pulseJump(boost = false): void { + if (!this.context || !this.master) { + return; + } + + const osc = this.context.createOscillator(); + const gain = this.context.createGain(); + osc.type = boost ? 'square' : 'sine'; + osc.frequency.value = boost ? 420 : 260; + gain.gain.value = 0.0001; + osc.connect(gain); + gain.connect(this.master); + + const now = this.context.currentTime; + gain.gain.exponentialRampToValueAtTime(0.06, now + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.24); + osc.frequency.exponentialRampToValueAtTime(boost ? 170 : 120, now + 0.24); + osc.start(now); + osc.stop(now + 0.26); + } + + pulseSnap(): void { + if (!this.context || !this.master) { + return; + } + + const osc = this.context.createOscillator(); + const gain = this.context.createGain(); + osc.type = 'triangle'; + osc.frequency.value = 680; + gain.gain.value = 0.0001; + osc.connect(gain); + gain.connect(this.master); + + const now = this.context.currentTime; + gain.gain.exponentialRampToValueAtTime(0.08, now + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.45); + osc.frequency.exponentialRampToValueAtTime(240, now + 0.45); + osc.start(now); + osc.stop(now + 0.5); + } +} diff --git a/src/game/CyberJumpApp.ts b/src/game/CyberJumpApp.ts new file mode 100644 index 0000000..db69b08 --- /dev/null +++ b/src/game/CyberJumpApp.ts @@ -0,0 +1,888 @@ +import { AudioEngine } from './AudioEngine'; +import { CYCLE_HEIGHT, LAYERS, PALETTES, THOUGHT_LINES, WORLD_WIDTH, type LayerDescriptor, type Palette } from './content'; +import { NetworkClient, type GhostState, type NetworkStatus } from '../network/NetworkClient'; + +type PlatformKind = 'stable' | 'drift' | 'boost' | 'fragile'; + +interface Platform { + id: number; + kind: PlatformKind; + x: number; + y: number; + width: number; + height: number; + baseX: number; + drift: number; + phase: number; + broken: boolean; +} + +interface Player { + x: number; + y: number; + vx: number; + vy: number; + width: number; + height: number; +} + +interface HudRefs { + height: HTMLSpanElement; + layer: HTMLSpanElement; + fidelity: HTMLSpanElement; + ghosts: HTMLSpanElement; + palette: HTMLSpanElement; + thought: HTMLParagraphElement; + subtle: HTMLParagraphElement; + status: HTMLDivElement; + gameOver: HTMLDivElement; +} + +const GRAVITY = -37; +const MOVE_SPEED = 11; +const AIR_ACCEL = 26; +const BASE_JUMP = 15.8; +const BOOST_JUMP = 22.5; +const PLATFORM_HEIGHT = 0.36; +const MIN_PLATFORM_STEP = 1.7; +const PLATFORM_STEP_PADDING = 0.38; +const HORIZONTAL_REACH_FACTOR = 0.68; +const HORIZONTAL_REACH_BUFFER = 0.72; +const VIEW_BUFFER = 30; +const PLAYER_TRAIL = 8; + +export class CyberJumpApp { + private readonly root: HTMLDivElement; + private readonly canvas: HTMLCanvasElement; + private readonly context: CanvasRenderingContext2D; + private readonly hud: HudRefs; + private readonly audio = new AudioEngine(); + private readonly network: NetworkClient; + private readonly input = { left: false, right: false }; + + private player: Player = { x: 0, y: 0, vx: 0, vy: 0, width: 0.9, height: 1.2 }; + private platforms: Platform[] = []; + private ghosts: GhostState[] = []; + private networkStatus: NetworkStatus = { label: 'network', detail: 'offline', peers: 0 }; + private trail: Array<{ x: number; y: number }> = []; + + private lastTime = 0; + private devicePixelRatio = Math.max(1, window.devicePixelRatio || 1); + private cameraBottom = -4; + private highestY = 0; + private nextPlatformY = 0; + private nextPlatformId = 1; + private cycle = 0; + private thoughtTimer = 0; + private thoughtIndex = 0; + private gameOver = false; + + constructor(root: HTMLDivElement) { + this.root = root; + this.root.innerHTML = this.buildMarkup(); + + this.canvas = this.query<HTMLCanvasElement>('[data-role="canvas"]'); + const context = this.canvas.getContext('2d'); + if (!context) { + throw new Error('Canvas 2D context is unavailable.'); + } + this.context = context; + + this.hud = { + height: this.query<HTMLSpanElement>('[data-role="height"]'), + layer: this.query<HTMLSpanElement>('[data-role="layer"]'), + fidelity: this.query<HTMLSpanElement>('[data-role="fidelity"]'), + ghosts: this.query<HTMLSpanElement>('[data-role="ghosts"]'), + palette: this.query<HTMLSpanElement>('[data-role="palette"]'), + thought: this.query<HTMLParagraphElement>('[data-role="thought"]'), + subtle: this.query<HTMLParagraphElement>('[data-role="subtle"]'), + status: this.query<HTMLDivElement>('[data-role="status"]'), + gameOver: this.query<HTMLDivElement>('[data-role="game-over"]') + }; + + this.network = new NetworkClient( + (players) => { + this.ghosts = players; + }, + (status) => { + this.networkStatus = status; + } + ); + + this.attachEvents(); + this.resize(); + this.reset(); + this.network.start(); + } + + start(): void { + window.requestAnimationFrame(this.loop); + } + + private readonly loop = (timestamp: number): void => { + if (!this.lastTime) { + this.lastTime = timestamp; + } + + const deltaSeconds = Math.min(0.033, (timestamp - this.lastTime) / 1000); + this.lastTime = timestamp; + + this.update(deltaSeconds, timestamp / 1000); + this.render(timestamp / 1000); + + window.requestAnimationFrame(this.loop); + }; + + private attachEvents(): void { + window.addEventListener('resize', this.resize); + window.addEventListener('pointerdown', this.activateAudio, { passive: true }); + window.addEventListener('keydown', this.handleKeyDown); + window.addEventListener('keyup', this.handleKeyUp); + } + + private readonly resize = (): void => { + this.devicePixelRatio = Math.max(1, window.devicePixelRatio || 1); + const width = Math.floor(window.innerWidth * this.devicePixelRatio); + const height = Math.floor(window.innerHeight * this.devicePixelRatio); + this.canvas.width = width; + this.canvas.height = height; + }; + + private readonly activateAudio = (): void => { + this.audio.activate(); + }; + + private readonly handleKeyDown = (event: KeyboardEvent): void => { + if (event.code === 'ArrowLeft' || event.code === 'KeyA') { + this.input.left = true; + this.activateAudio(); + } + + if (event.code === 'ArrowRight' || event.code === 'KeyD') { + this.input.right = true; + this.activateAudio(); + } + + if ((event.code === 'Space' || event.code === 'ArrowUp' || event.code === 'KeyW') && this.gameOver) { + this.reset(); + this.activateAudio(); + } + }; + + private readonly handleKeyUp = (event: KeyboardEvent): void => { + if (event.code === 'ArrowLeft' || event.code === 'KeyA') { + this.input.left = false; + } + + if (event.code === 'ArrowRight' || event.code === 'KeyD') { + this.input.right = false; + } + }; + + private reset(): void { + this.player = { x: 0, y: 1.2, vx: 0, vy: 0, width: 0.9, height: 1.2 }; + this.platforms = []; + this.ghosts = []; + this.trail = []; + this.highestY = 1.2; + this.cameraBottom = -4; + this.nextPlatformY = 0; + this.nextPlatformId = 1; + this.cycle = 0; + this.thoughtTimer = 1; + this.thoughtIndex = 0; + this.gameOver = false; + + this.platforms.push({ + id: this.nextPlatformId++, + kind: 'stable', + x: 0, + y: -0.1, + width: 4.4, + height: PLATFORM_HEIGHT, + baseX: 0, + drift: 0, + phase: 0, + broken: false + }); + + this.nextPlatformY = 2.8; + this.generatePlatforms(this.cameraBottom + VIEW_BUFFER); + this.setThought('Signal reacquired. Ascend until the world sheds another layer.'); + this.hud.gameOver.classList.remove('is-visible'); + } + + private update(deltaSeconds: number, elapsedSeconds: number): void { + const height = Math.max(0, this.highestY); + const layer = this.getLayer(height); + const fidelity = this.getFidelity(height); + const cycle = Math.floor(height / CYCLE_HEIGHT); + + this.audio.setMood(fidelity, cycle, layer.index); + this.updateThoughts(deltaSeconds, cycle, layer); + + if (this.gameOver) { + this.syncHud(height, layer, fidelity); + return; + } + + const moveAxis = (this.input.right ? 1 : 0) - (this.input.left ? 1 : 0); + const targetVelocity = moveAxis * MOVE_SPEED; + this.player.vx = lerp(this.player.vx, targetVelocity, Math.min(1, AIR_ACCEL * deltaSeconds)); + this.player.vy += GRAVITY * deltaSeconds; + + const previousY = this.player.y; + this.player.x += this.player.vx * deltaSeconds; + this.player.y += this.player.vy * deltaSeconds; + + const wrapLimit = WORLD_WIDTH * 0.5 + 1.2; + if (this.player.x < -wrapLimit) { + this.player.x = wrapLimit; + } else if (this.player.x > wrapLimit) { + this.player.x = -wrapLimit; + } + + this.updatePlatforms(elapsedSeconds); + this.resolveCollisions(previousY); + + this.highestY = Math.max(this.highestY, this.player.y); + this.cameraBottom = Math.max(this.cameraBottom, this.player.y - 6.2); + this.generatePlatforms(this.cameraBottom + VIEW_BUFFER); + this.platforms = this.platforms.filter((platform) => platform.y > this.cameraBottom - 8 || !platform.broken); + + if (cycle > this.cycle) { + this.cycle = cycle; + this.audio.pulseSnap(); + this.setThought(`Cycle ${cycle + 1} initialized. ${layer.descriptor.caption}`); + } + + if (this.player.y < this.cameraBottom - 5.5) { + this.gameOver = true; + this.hud.gameOver.classList.add('is-visible'); + this.setThought('You dropped out of the visible stack. Space rebinds your trajectory.'); + } + + this.pushTrail(); + this.network.updateState({ + x: this.player.x, + y: this.player.y, + vx: this.player.vx, + vy: this.player.vy, + height: this.highestY, + layer: layer.descriptor.name, + cycle + }); + + this.syncHud(height, layer, fidelity); + } + + private updatePlatforms(elapsedSeconds: number): void { + for (const platform of this.platforms) { + if (platform.kind !== 'drift' || platform.broken) { + continue; + } + + platform.x = platform.baseX + Math.sin(elapsedSeconds * 1.15 + platform.phase) * platform.drift; + } + } + + private resolveCollisions(previousY: number): void { + if (this.player.vy >= 0) { + return; + } + + const previousBottom = previousY - this.player.height * 0.5; + const currentBottom = this.player.y - this.player.height * 0.5; + + for (const platform of this.platforms) { + if (platform.broken) { + continue; + } + + const platformTop = platform.y + platform.height * 0.5; + const overlapX = Math.abs(this.player.x - platform.x) <= platform.width * 0.5 + this.player.width * 0.38; + const crossedTop = previousBottom >= platformTop && currentBottom <= platformTop; + + if (!overlapX || !crossedTop) { + continue; + } + + this.player.y = platformTop + this.player.height * 0.5; + this.player.vy = platform.kind === 'boost' ? BOOST_JUMP : BASE_JUMP; + if (platform.kind === 'fragile') { + platform.broken = true; + } + this.audio.pulseJump(platform.kind === 'boost'); + return; + } + } + + private generatePlatforms(targetY: number): void { + while (this.nextPlatformY < targetY) { + const previousPlatform = this.platforms[this.platforms.length - 1]; + const difficulty = clamp(this.nextPlatformY / 3600, 0, 1); + const step = this.choosePlatformStep(previousPlatform, difficulty); + const kind = this.choosePlatformKind(difficulty); + const width = this.getPlatformWidth(kind, difficulty); + const y = previousPlatform.y + step; + const baseX = this.choosePlatformX(previousPlatform, width, step, difficulty); + + this.platforms.push({ + id: this.nextPlatformId++, + kind, + x: baseX, + y, + width, + height: PLATFORM_HEIGHT, + baseX, + drift: kind === 'drift' ? 0.9 + difficulty * 1.2 + Math.random() * 0.9 : 0, + phase: Math.random() * Math.PI * 2, + broken: false + }); + + this.nextPlatformY = y; + } + } + + private choosePlatformKind(difficulty: number): PlatformKind { + const roll = Math.random(); + const stableWeight = 0.73 - difficulty * 0.25; + const driftWeight = 0.15 + difficulty * 0.12; + const fragileWeight = 0.07 + difficulty * 0.1; + + if (roll < stableWeight) { + return 'stable'; + } + + if (roll < stableWeight + driftWeight) { + return 'drift'; + } + + if (roll < stableWeight + driftWeight + fragileWeight) { + return 'fragile'; + } + + return 'boost'; + } + + private getPlatformWidth(kind: PlatformKind, difficulty: number): number { + switch (kind) { + case 'boost': + return clamp(1.95 - difficulty * 0.3, 1.5, 1.95); + case 'drift': + return clamp(2.3 - difficulty * 0.75, 1.35, 2.3); + case 'fragile': + return clamp(2.05 - difficulty * 0.8, 1.2, 2.05); + case 'stable': + default: + return clamp(2.6 - difficulty * 1.0, 1.35, 2.6); + } + } + + private choosePlatformStep(previousPlatform: Platform, difficulty: number): number { + const maxStep = this.getReachableStepCap(previousPlatform); + const minStep = clamp(MIN_PLATFORM_STEP + difficulty * 0.35, 1.45, maxStep - 0.32); + const pressure = clamp(0.34 + difficulty * 0.34 + Math.random() * 0.24, 0.2, 0.96); + + return lerp(minStep, maxStep, pressure); + } + + private choosePlatformX(previousPlatform: Platform, width: number, step: number, difficulty: number): number { + const span = WORLD_WIDTH * 0.5 - width * 0.6; + const offsetCap = Math.min(this.getReachableOffsetCap(previousPlatform, step, width), span * 2); + const lowChallenge = Math.min(offsetCap * (0.12 + difficulty * 0.3), Math.max(0.24, offsetCap - 0.18)); + + let targetX = previousPlatform.x; + if (Math.random() < 0.18) { + const relaxedOffset = (Math.random() * 2 - 1) * Math.min(0.9, offsetCap); + targetX += relaxedOffset; + } else { + const direction = Math.random() < 0.5 ? -1 : 1; + const offset = lerp(lowChallenge, offsetCap, 0.3 + difficulty * 0.28 + Math.random() * 0.42); + targetX += direction * offset; + } + + if (targetX < -span || targetX > span) { + targetX = previousPlatform.x - (targetX - previousPlatform.x) * 0.72; + } + + return clamp(targetX, -span, span); + } + + private getReachableStepCap(platform: Platform): number { + const jumpVelocity = platform.kind === 'boost' ? BOOST_JUMP : BASE_JUMP; + const apexRise = (jumpVelocity * jumpVelocity) / (2 * -GRAVITY); + const safeStep = apexRise - PLATFORM_HEIGHT - PLATFORM_STEP_PADDING; + + return clamp(safeStep, 1.9, platform.kind === 'boost' ? 5.6 : 2.6); + } + + private getReachableOffsetCap(platform: Platform, step: number, nextWidth: number): number { + const jumpVelocity = platform.kind === 'boost' ? BOOST_JUMP : BASE_JUMP; + const gravity = -GRAVITY; + const discriminant = Math.max(0, jumpVelocity * jumpVelocity - 2 * gravity * step); + const descentTime = (jumpVelocity + Math.sqrt(discriminant)) / gravity; + const travelDistance = MOVE_SPEED * Math.max(0.24, descentTime - 0.08) * HORIZONTAL_REACH_FACTOR; + const landingSlack = nextWidth * 0.5 + this.player.width * 0.34; + const safeOffset = travelDistance + landingSlack - HORIZONTAL_REACH_BUFFER; + + return clamp(safeOffset, 1.7, platform.kind === 'boost' ? 6.2 : 3.6); + } + + private updateThoughts(deltaSeconds: number, cycle: number, layer: LayerState): void { + this.thoughtTimer -= deltaSeconds; + if (this.thoughtTimer > 0) { + return; + } + + this.thoughtIndex = (this.thoughtIndex + 1 + cycle) % THOUGHT_LINES.length; + this.setThought(THOUGHT_LINES[this.thoughtIndex]); + this.hud.subtle.textContent = layer.descriptor.caption; + this.thoughtTimer = 8 + Math.random() * 4; + } + + private syncHud(height: number, layer: LayerState, fidelity: number): void { + const palette = this.getPalette(Math.floor(height / CYCLE_HEIGHT)); + this.hud.height.textContent = `${Math.floor(height)} m`; + this.hud.layer.textContent = layer.descriptor.name; + this.hud.fidelity.textContent = `${Math.round(fidelity * 100)}% · ${palette.descriptor}`; + this.hud.ghosts.textContent = `${this.networkStatus.peers}`; + this.hud.palette.textContent = palette.name; + this.hud.status.textContent = `${this.networkStatus.label}: ${this.networkStatus.detail}`; + } + + private render(elapsedSeconds: number): void { + const ctx = this.context; + const { width, height } = this.canvas; + const palette = this.getPalette(this.cycle); + const layer = this.getLayer(Math.max(0, this.highestY)); + const fidelity = this.getFidelity(Math.max(0, this.highestY)); + const visibleHeight = 22; + const scale = Math.min(width / (WORLD_WIDTH * 1.15), height / visibleHeight); + const viewTop = this.cameraBottom + height / scale; + + ctx.clearRect(0, 0, width, height); + + const background = ctx.createLinearGradient(0, 0, 0, height); + background.addColorStop(0, palette.top); + background.addColorStop(1, palette.bottom); + ctx.fillStyle = background; + ctx.fillRect(0, 0, width, height); + + this.drawAtmosphere(ctx, width, height, palette, fidelity, elapsedSeconds); + this.drawMotif(ctx, width, height, scale, layer.descriptor, palette, fidelity, elapsedSeconds); + this.drawGrid(ctx, width, height, scale, palette, fidelity); + + for (const platform of this.platforms) { + if (platform.y < this.cameraBottom - 1 || platform.y > viewTop + 2) { + continue; + } + this.drawPlatform(ctx, platform, scale, palette, fidelity); + } + + for (const ghost of this.ghosts) { + if (ghost.y < this.cameraBottom - 2 || ghost.y > viewTop + 2) { + continue; + } + this.drawGhost(ctx, ghost, scale, palette); + } + + this.drawTrail(ctx, scale, palette); + this.drawPlayer(ctx, scale, palette, fidelity); + this.drawCompass(ctx, width, height, palette, fidelity); + } + + private drawAtmosphere( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + palette: Palette, + fidelity: number, + elapsedSeconds: number + ): void { + const sparkleCount = Math.floor(14 + fidelity * 34 + this.cycle * 4); + ctx.save(); + for (let index = 0; index < sparkleCount; index += 1) { + const seed = hash(index + this.cycle * 131); + const x = seed * width; + const y = (hash(index * 19 + this.cycle * 17) * height + elapsedSeconds * (8 + hash(index * 23) * 16)) % height; + const radius = (0.6 + hash(index * 29) * 2.3) * this.devicePixelRatio; + ctx.globalAlpha = 0.18 + hash(index * 31) * 0.34; + ctx.fillStyle = index % 5 === 0 ? palette.signal : palette.primary; + ctx.beginPath(); + ctx.arc(x, height - y, radius, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + private drawGrid( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + scale: number, + palette: Palette, + fidelity: number + ): void { + ctx.save(); + ctx.strokeStyle = palette.grid; + ctx.globalAlpha = 0.12 + fidelity * 0.1; + ctx.lineWidth = this.devicePixelRatio; + + const verticalLines = Math.max(4, Math.floor(6 + fidelity * 7)); + for (let index = 0; index <= verticalLines; index += 1) { + const x = (index / verticalLines) * width; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + + const horizontalStep = 2 * scale; + const offset = (this.cameraBottom * scale) % horizontalStep; + for (let y = -offset; y < height + horizontalStep; y += horizontalStep) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + + ctx.restore(); + } + + private drawMotif( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + scale: number, + layer: LayerDescriptor, + palette: Palette, + fidelity: number, + elapsedSeconds: number + ): void { + ctx.save(); + ctx.globalAlpha = 0.16 + fidelity * 0.22; + ctx.strokeStyle = palette.accent; + ctx.fillStyle = palette.signal; + ctx.lineWidth = Math.max(1, this.devicePixelRatio * 1.4); + + switch (layer.motif) { + case 'core': { + for (let index = 0; index < 7; index += 1) { + const radius = (4 + index * 1.3) * scale; + ctx.beginPath(); + ctx.arc(width * 0.5, height + scale * 4, radius, Math.PI, Math.PI * 2); + ctx.stroke(); + } + break; + } + case 'roots': { + const strands = Math.floor(7 + fidelity * 12); + for (let index = 0; index < strands; index += 1) { + const x = (index / Math.max(1, strands - 1)) * width; + ctx.beginPath(); + ctx.moveTo(x, height); + ctx.bezierCurveTo( + x + Math.sin(index + elapsedSeconds) * 60, + height * 0.75, + x + Math.cos(index * 1.3 + elapsedSeconds * 0.7) * 90, + height * 0.35, + x + Math.sin(index * 2.2 + elapsedSeconds * 0.5) * 40, + 0 + ); + ctx.stroke(); + } + break; + } + case 'surface': { + const blocks = Math.floor(9 + fidelity * 12); + for (let index = 0; index < blocks; index += 1) { + const seed = hash(index * 13 + this.cycle * 17); + const x = seed * width; + const blockWidth = (18 + seed * 70) * this.devicePixelRatio; + const blockHeight = (80 + hash(index * 23) * 180) * this.devicePixelRatio; + ctx.fillRect(x, height - blockHeight, blockWidth, blockHeight); + } + break; + } + case 'aqueduct': { + const lanes = 6; + for (let index = 0; index < lanes; index += 1) { + const y = height * (0.2 + index * 0.13) + Math.sin(elapsedSeconds + index) * 10; + ctx.lineWidth = 6 * this.devicePixelRatio; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + ctx.lineWidth = Math.max(1, this.devicePixelRatio * 1.2); + } + break; + } + case 'towers': { + const towers = Math.floor(12 + fidelity * 16); + for (let index = 0; index < towers; index += 1) { + const seed = hash(index * 7 + this.cycle * 41); + const x = seed * width; + const towerWidth = (8 + hash(index * 11) * 24) * this.devicePixelRatio; + const towerHeight = (120 + hash(index * 17) * 260) * this.devicePixelRatio; + ctx.fillRect(x, height - towerHeight, towerWidth, towerHeight); + } + break; + } + case 'clouds': { + const clouds = Math.floor(5 + fidelity * 8); + for (let index = 0; index < clouds; index += 1) { + const seed = hash(index * 9 + this.cycle * 53); + const x = (seed * width + elapsedSeconds * 20 * (index % 2 === 0 ? 1 : -1)) % width; + const y = height * (0.18 + hash(index * 27) * 0.58); + ctx.beginPath(); + ctx.ellipse(x, y, 36 * scale * 0.1, 16 * scale * 0.1, 0, 0, Math.PI * 2); + ctx.fill(); + } + break; + } + case 'orbital': { + const bands = Math.floor(6 + fidelity * 10); + for (let index = 0; index < bands; index += 1) { + const y = height * 0.12 + index * 42 * this.devicePixelRatio; + ctx.beginPath(); + ctx.moveTo(width * 0.1, y + Math.sin(elapsedSeconds + index) * 14); + ctx.lineTo(width * 0.9, y + Math.cos(elapsedSeconds * 0.8 + index) * 14); + ctx.stroke(); + } + break; + } + } + + ctx.restore(); + } + + private drawPlatform(ctx: CanvasRenderingContext2D, platform: Platform, scale: number, palette: Palette, fidelity: number): void { + const center = this.worldToScreen(platform.x, platform.y, scale); + const width = platform.width * scale; + const height = platform.height * scale; + const radius = Math.max(4, height * 0.6); + + ctx.save(); + ctx.globalAlpha = platform.broken ? 0.2 : 0.95; + ctx.fillStyle = platform.kind === 'boost' ? palette.warm : platform.kind === 'fragile' ? palette.signal : palette.primary; + ctx.shadowBlur = (platform.kind === 'boost' ? 22 : 12) * fidelity * this.devicePixelRatio; + ctx.shadowColor = ctx.fillStyle; + roundRect(ctx, center.x - width * 0.5, center.y - height * 0.5, width, height, radius); + ctx.fill(); + + if (platform.kind === 'drift') { + ctx.strokeStyle = palette.accent; + ctx.lineWidth = Math.max(1, this.devicePixelRatio * 1.2); + ctx.stroke(); + } + ctx.restore(); + } + + private drawGhost(ctx: CanvasRenderingContext2D, ghost: GhostState, scale: number, palette: Palette): void { + const ageSeconds = Math.max(0, Date.now() - ghost.updatedAt) / 1000; + const alpha = clamp(0.48 - ageSeconds * 0.14, 0.1, 0.48); + const center = this.worldToScreen(ghost.x, ghost.y, scale); + const width = 0.74 * scale; + const height = 1.02 * scale; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = palette.ghost; + ctx.strokeStyle = palette.signal; + ctx.lineWidth = Math.max(1, this.devicePixelRatio); + roundRect(ctx, center.x - width * 0.5, center.y - height * 0.5, width, height, width * 0.16); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + } + + private drawTrail(ctx: CanvasRenderingContext2D, scale: number, palette: Palette): void { + ctx.save(); + for (let index = 0; index < this.trail.length; index += 1) { + const point = this.trail[index]; + const center = this.worldToScreen(point.x, point.y, scale); + const alpha = (index + 1) / this.trail.length; + ctx.globalAlpha = alpha * 0.14; + ctx.fillStyle = palette.signal; + ctx.beginPath(); + ctx.arc(center.x, center.y, Math.max(2, scale * 0.08 * alpha), 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + private drawPlayer(ctx: CanvasRenderingContext2D, scale: number, palette: Palette, fidelity: number): void { + const center = this.worldToScreen(this.player.x, this.player.y, scale); + const width = this.player.width * scale; + const height = this.player.height * scale; + const simplify = fidelity < 0.22; + + ctx.save(); + ctx.shadowBlur = 24 * this.devicePixelRatio; + ctx.shadowColor = palette.accent; + ctx.fillStyle = simplify ? palette.primary : palette.accent; + roundRect(ctx, center.x - width * 0.5, center.y - height * 0.5, width, height, simplify ? 2 : width * 0.24); + ctx.fill(); + + if (!simplify) { + ctx.fillStyle = palette.primary; + ctx.fillRect(center.x - width * 0.18, center.y - height * 0.1, width * 0.36, height * 0.16); + ctx.fillStyle = palette.warm; + ctx.fillRect(center.x - width * 0.08, center.y + height * 0.1, width * 0.16, height * 0.24); + } + ctx.restore(); + } + + private drawCompass( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + palette: Palette, + fidelity: number + ): void { + const barWidth = Math.min(width * 0.38, 320 * this.devicePixelRatio); + const x = width - barWidth - 24 * this.devicePixelRatio; + const y = height - 28 * this.devicePixelRatio; + + ctx.save(); + ctx.globalAlpha = 0.85; + ctx.fillStyle = 'rgba(15, 23, 42, 0.55)'; + roundRect(ctx, x, y, barWidth, 10 * this.devicePixelRatio, 999); + ctx.fill(); + + ctx.fillStyle = palette.primary; + roundRect(ctx, x, y, barWidth * fidelity, 10 * this.devicePixelRatio, 999); + ctx.fill(); + ctx.restore(); + } + + private pushTrail(): void { + this.trail.push({ x: this.player.x, y: this.player.y }); + if (this.trail.length > PLAYER_TRAIL) { + this.trail.shift(); + } + } + + private setThought(text: string): void { + this.hud.thought.textContent = text; + this.thoughtTimer = 9 + Math.random() * 3; + } + + private getLayer(height: number): LayerState { + const index = Math.min(LAYERS.length - 1, Math.floor(height / 700)); + return { index, descriptor: LAYERS[index] }; + } + + private getPalette(cycle: number): Palette { + return PALETTES[cycle % PALETTES.length]; + } + + private getFidelity(height: number): number { + const progress = (height % CYCLE_HEIGHT) / CYCLE_HEIGHT; + return clamp(1 - progress, 0.1, 1); + } + + private worldToScreen(x: number, y: number, scale: number): { x: number; y: number } { + const screenX = this.canvas.width * 0.5 + x * scale; + const screenY = this.canvas.height - (y - this.cameraBottom) * scale; + return { x: screenX, y: screenY }; + } + + private buildMarkup(): string { + return ` + <div class="cyberjump-shell"> + <canvas class="cyberjump-canvas" data-role="canvas"></canvas> + <div class="cyberjump-overlay"> + <section class="cyberjump-panel cyberjump-stats"> + <p class="cyberjump-label">Run Metrics</p> + <div class="cyberjump-grid"> + <div class="cyberjump-stat"> + <span class="cyberjump-stat-name">Height</span> + <span class="cyberjump-stat-value" data-role="height">0 m</span> + </div> + <div class="cyberjump-stat"> + <span class="cyberjump-stat-name">Realm</span> + <span class="cyberjump-stat-value" data-role="layer">Planetary Core</span> + </div> + <div class="cyberjump-stat"> + <span class="cyberjump-stat-name">Fidelity</span> + <span class="cyberjump-stat-value" data-role="fidelity">100%</span> + </div> + <div class="cyberjump-stat"> + <span class="cyberjump-stat-name">Ghosts</span> + <span class="cyberjump-stat-value" data-role="ghosts">0</span> + </div> + <div class="cyberjump-stat"> + <span class="cyberjump-stat-name">Palette</span> + <span class="cyberjump-stat-value" data-role="palette">Neon Furnace</span> + </div> + </div> + </section> + + <section class="cyberjump-panel cyberjump-thoughts"> + <p class="cyberjump-label">Transmission</p> + <p class="cyberjump-thought" data-role="thought"></p> + <p class="cyberjump-subtle" data-role="subtle"></p> + </section> + + <section class="cyberjump-panel cyberjump-status" data-role="status"></section> + + <section class="cyberjump-panel cyberjump-gameover" data-role="game-over"> + <p class="cyberjump-label">Run Interrupted</p> + <h1 class="cyberjump-title">Signal lost</h1> + <p class="cyberjump-copy">Press <span class="cyberjump-accent">Space</span> to restart the ascent.</p> + </section> + </div> + </div> + `; + } + + private query<T extends Element>(selector: string): T { + const element = this.root.querySelector<T>(selector); + if (!element) { + throw new Error(`Missing required element: ${selector}`); + } + return element; + } +} + +interface LayerState { + index: number; + descriptor: LayerDescriptor; +} + +function lerp(start: number, end: number, amount: number): number { + return start + (end - start) * amount; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function hash(seed: number): number { + const value = Math.sin(seed * 127.1 + 311.7) * 43758.5453123; + return value - Math.floor(value); +} + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number +): void { + const bounded = Math.min(radius, width * 0.5, height * 0.5); + ctx.beginPath(); + ctx.moveTo(x + bounded, y); + ctx.lineTo(x + width - bounded, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + bounded); + ctx.lineTo(x + width, y + height - bounded); + ctx.quadraticCurveTo(x + width, y + height, x + width - bounded, y + height); + ctx.lineTo(x + bounded, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - bounded); + ctx.lineTo(x, y + bounded); + ctx.quadraticCurveTo(x, y, x + bounded, y); + ctx.closePath(); +} diff --git a/src/game/content.ts b/src/game/content.ts new file mode 100644 index 0000000..db94df7 --- /dev/null +++ b/src/game/content.ts @@ -0,0 +1,123 @@ +export interface Palette { + name: string; + descriptor: string; + top: string; + bottom: string; + grid: string; + primary: string; + accent: string; + warm: string; + ghost: string; + signal: string; +} + +export interface LayerDescriptor { + name: string; + caption: string; + motif: 'core' | 'roots' | 'surface' | 'aqueduct' | 'towers' | 'clouds' | 'orbital'; +} + +export const CYCLE_HEIGHT = 900; +export const WORLD_WIDTH = 18; + +export const LAYERS: LayerDescriptor[] = [ + { + name: 'Planetary Core', + caption: 'Magnetic furnaces hum below the city. Infrastructure begins as pressure.', + motif: 'core' + }, + { + name: 'Mycelial Switchyard', + caption: 'Roots and fungal circuits share memory through living cable bundles.', + motif: 'roots' + }, + { + name: 'Surface Arteries', + caption: 'Aqueduct rails, markets, and dense habitation recycle heat into movement.', + motif: 'surface' + }, + { + name: 'Analog Canals', + caption: 'Fluid logic, wheels, and pressure gates store power in visible machinery.', + motif: 'aqueduct' + }, + { + name: 'Tower Choir', + caption: 'Needle towers process weather, finance, and rumor in stacked districts.', + motif: 'towers' + }, + { + name: 'Cloud Mesh', + caption: 'Platforms thin into luminous rafts and relay gardens above the weather.', + motif: 'clouds' + }, + { + name: 'Orbital Threshold', + caption: 'Geometry strips itself down to signal, velocity, and intention.', + motif: 'orbital' + } +]; + +export const PALETTES: Palette[] = [ + { + name: 'Neon Furnace', + descriptor: 'dense, humid, electric', + top: '#140b26', + bottom: '#04070f', + grid: '#1f4d5f', + primary: '#71faff', + accent: '#fd4fd0', + warm: '#ff9f43', + ghost: '#d8f8ff', + signal: '#a78bfa' + }, + { + name: 'Biolume Relay', + descriptor: 'organic, damp, networked', + top: '#0f1d17', + bottom: '#04070d', + grid: '#205a4f', + primary: '#70ffbf', + accent: '#8de95f', + warm: '#f4d35e', + ghost: '#d4ffe8', + signal: '#64d2ff' + }, + { + name: 'Ceramic Dawn', + descriptor: 'clean, bright, infrastructural', + top: '#1c2448', + bottom: '#080b13', + grid: '#315f8e', + primary: '#98c8ff', + accent: '#ff6aa2', + warm: '#ffd166', + ghost: '#f4faff', + signal: '#8cf1ff' + }, + { + name: 'Voltage Bloom', + descriptor: 'vibrant, ceremonial, airborne', + top: '#221140', + bottom: '#05070c', + grid: '#483f9b', + primary: '#b8a7ff', + accent: '#ff7b72', + warm: '#ffe066', + ghost: '#ece9ff', + signal: '#6ef3ff' + } +]; + +export const THOUGHT_LINES = [ + 'If the world gets simpler as you rise, what exactly are you learning to ignore?', + 'The city stores memory in pipes, fungus, glass, and habits. Which medium trusts you most?', + 'Ghosts are not dead players. They are adjacent decisions still visible from here.', + 'Progress can mean refinement, abstraction, or amputation. Which one are you performing?', + 'A platform is a temporary agreement between gravity and intention.', + 'Every layer calls itself the real city. Each one merely found a different compression.', + 'Information wants a body. Bodies want a future. Cities negotiate between the two.', + 'Ascending is easy to narrate and hard to define. Are you escaping or integrating?', + 'The brighter the signal, the easier it is to mistake compression for truth.', + 'Shared ghosts make failure public and persistence communal.' +] as const; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..36d156f --- /dev/null +++ b/src/main.ts @@ -0,0 +1,11 @@ +import './styles.css'; +import { CyberJumpApp } from './game/CyberJumpApp'; + +const root = document.querySelector<HTMLDivElement>('#app'); + +if (!root) { + throw new Error('Missing #app root element.'); +} + +const app = new CyberJumpApp(root); +app.start(); diff --git a/src/network/NetworkClient.ts b/src/network/NetworkClient.ts new file mode 100644 index 0000000..a2d3ef4 --- /dev/null +++ b/src/network/NetworkClient.ts @@ -0,0 +1,180 @@ +export interface GhostState { + id: string; + x: number; + y: number; + vx: number; + vy: number; + height: number; + layer: string; + cycle: number; + updatedAt: number; +} + +interface WelcomeMessage { + type: 'welcome'; + id: string; +} + +interface SnapshotMessage { + type: 'snapshot'; + players: GhostState[]; +} + +interface StateMessage { + type: 'state'; + x: number; + y: number; + vx: number; + vy: number; + height: number; + layer: string; + cycle: number; +} + +interface DiscoveryResponse { + servers?: Array<{ + url: string; + region?: string; + }>; +} + +export interface NetworkStatus { + label: string; + detail: string; + peers: number; +} + +const DEFAULT_DISCOVERY = 'http://localhost:8080/servers'; +const DEFAULT_SOCKET = 'ws://localhost:8080/ws'; + +export class NetworkClient { + private socket?: WebSocket; + private discoveryUrl: string; + private fallbackUrl: string; + private reconnectHandle?: number; + private stateBuffer?: StateMessage; + private flushHandle?: number; + private selfId?: string; + private snapshotHandler: (players: GhostState[]) => void; + private statusHandler: (status: NetworkStatus) => void; + + constructor( + snapshotHandler: (players: GhostState[]) => void, + statusHandler: (status: NetworkStatus) => void + ) { + this.snapshotHandler = snapshotHandler; + this.statusHandler = statusHandler; + this.discoveryUrl = import.meta.env.VITE_DISCOVERY_URL ?? DEFAULT_DISCOVERY; + this.fallbackUrl = import.meta.env.VITE_WS_URL ?? DEFAULT_SOCKET; + } + + start(): void { + void this.connect(); + } + + stop(): void { + if (this.reconnectHandle) { + window.clearTimeout(this.reconnectHandle); + this.reconnectHandle = undefined; + } + if (this.flushHandle) { + window.clearInterval(this.flushHandle); + this.flushHandle = undefined; + } + this.socket?.close(); + this.socket = undefined; + } + + updateState(state: Omit<StateMessage, 'type'>): void { + this.stateBuffer = { type: 'state', ...state }; + } + + private async connect(): Promise<void> { + this.statusHandler({ label: 'network', detail: 'discovering ghost relay…', peers: 0 }); + + const target = await this.resolveSocketUrl(); + this.socket = new WebSocket(target); + + this.socket.addEventListener('open', () => { + this.statusHandler({ label: 'network', detail: `ghost relay online · ${target}`, peers: 0 }); + this.startFlusher(); + }); + + this.socket.addEventListener('message', (event) => { + this.handleMessage(event.data); + }); + + this.socket.addEventListener('close', () => { + this.statusHandler({ label: 'network', detail: 'ghost relay offline · retrying…', peers: 0 }); + this.snapshotHandler([]); + this.socket = undefined; + this.selfId = undefined; + this.stopFlusher(); + this.reconnectHandle = window.setTimeout(() => { + void this.connect(); + }, 2000); + }); + + this.socket.addEventListener('error', () => { + this.statusHandler({ label: 'network', detail: 'ghost relay unreachable', peers: 0 }); + }); + } + + private async resolveSocketUrl(): Promise<string> { + try { + const response = await fetch(this.discoveryUrl, { headers: { Accept: 'application/json' } }); + if (!response.ok) { + return this.fallbackUrl; + } + + const payload = (await response.json()) as DiscoveryResponse; + const first = payload.servers?.[0]?.url; + return first ?? this.fallbackUrl; + } catch { + return this.fallbackUrl; + } + } + + private startFlusher(): void { + this.stopFlusher(); + this.flushHandle = window.setInterval(() => { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN || !this.stateBuffer) { + return; + } + this.socket.send(JSON.stringify(this.stateBuffer)); + }, 80); + } + + private stopFlusher(): void { + if (!this.flushHandle) { + return; + } + window.clearInterval(this.flushHandle); + this.flushHandle = undefined; + } + + private handleMessage(raw: string): void { + let parsed: WelcomeMessage | SnapshotMessage | undefined; + + try { + parsed = JSON.parse(raw) as WelcomeMessage | SnapshotMessage; + } catch { + return; + } + + if (parsed.type === 'welcome') { + this.selfId = parsed.id; + return; + } + + if (parsed.type === 'snapshot') { + const others = parsed.players.filter((player) => player.id !== this.selfId); + this.snapshotHandler(others); + this.statusHandler({ + label: 'network', + detail: this.socket?.url ? `ghost relay online · ${this.socket.url}` : 'ghost relay online', + peers: others.length + }); + } + } +} diff --git a/src/rendering/shaders/world.frag b/src/rendering/shaders/world.frag new file mode 100644 index 0000000..06ed16f --- /dev/null +++ b/src/rendering/shaders/world.frag @@ -0,0 +1,57 @@ +uniform float complexity; // 0.0 to 1.0 +uniform float time; +uniform vec3 color1; +uniform vec3 color2; +uniform vec3 color3; +uniform float layerIndex; + +varying vec2 vUv; +varying vec3 vPosition; + +// Reduce color palette based on complexity +vec3 quantizeColor(vec3 color, float levels) { + return floor(color * levels) / levels; +} + +// Simple noise function +float noise(vec2 p) { + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +} + +void main() { + vec2 uv = vUv; + + // Base gradient + vec3 color = mix(color1, color2, uv.y); + color = mix(color, color3, sin(uv.x * 3.14159) * 0.3); + + // At high complexity, add details + if (complexity > 0.3) { + // Circuit patterns + float circuit = step(0.98, fract(uv.x * 20.0 + time * 0.05)); + circuit += step(0.98, fract(uv.y * 20.0 + time * 0.03)); + color += vec3(0.0, 0.5, 1.0) * circuit * complexity * 0.2; + + // Scanlines + float scanline = sin(uv.y * 200.0) * 0.5 + 0.5; + color *= 0.95 + scanline * 0.05 * complexity; + } + + // At medium complexity, add some glow + if (complexity > 0.5) { + float glow = sin(time * 2.0 + uv.y * 10.0) * 0.5 + 0.5; + color += color2 * glow * 0.1 * complexity; + } + + // Reduce color palette as complexity decreases + float levels = mix(4.0, 256.0, complexity); + color = quantizeColor(color, levels); + + // Fade edges at low complexity + float edge = 1.0; + if (complexity < 0.3) { + edge = smoothstep(0.0, 0.1, uv.x) * smoothstep(1.0, 0.9, uv.x); + } + + gl_FragColor = vec4(color, edge); +} diff --git a/src/rendering/shaders/world.vert b/src/rendering/shaders/world.vert new file mode 100644 index 0000000..f4410b6 --- /dev/null +++ b/src/rendering/shaders/world.vert @@ -0,0 +1,8 @@ +varying vec2 vUv; +varying vec3 vPosition; + +void main() { + vUv = uv; + vPosition = position; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..6ed92e5 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,200 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #050816; + color: #eff6ff; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + margin: 0; + min-height: 100%; + width: 100%; +} + +body { + overflow: hidden; + background: + radial-gradient(circle at top, rgba(99, 102, 241, 0.16), transparent 32%), + linear-gradient(180deg, #060816 0%, #04050d 100%); +} + +button, +canvas, +input, +textarea, +select { + font: inherit; +} + +.cyberjump-shell { + position: relative; + width: 100vw; + height: 100vh; + overflow: hidden; +} + +.cyberjump-canvas { + display: block; + width: 100%; + height: 100%; +} + +.cyberjump-overlay { + position: absolute; + inset: 0; + pointer-events: none; +} + +.cyberjump-panel { + position: absolute; + backdrop-filter: blur(18px); + background: rgba(5, 10, 24, 0.46); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + box-shadow: 0 18px 64px rgba(2, 8, 23, 0.35); +} + +.cyberjump-stats { + top: 20px; + left: 20px; + min-width: 270px; + padding: 16px 18px; +} + +.cyberjump-thoughts { + top: 20px; + right: 20px; + width: min(360px, calc(100vw - 40px)); + padding: 16px 18px; +} + +.cyberjump-status { + position: absolute; + left: 20px; + bottom: 20px; + padding: 12px 16px; + max-width: min(420px, calc(100vw - 40px)); +} + +.cyberjump-gameover { + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + width: min(520px, calc(100vw - 32px)); + padding: 28px; + text-align: center; + opacity: 0; + transition: opacity 180ms ease; +} + +.cyberjump-gameover.is-visible { + opacity: 1; +} + +.cyberjump-label { + margin: 0 0 8px; + color: rgba(125, 211, 252, 0.9); + font-size: 0.78rem; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.cyberjump-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 18px; +} + +.cyberjump-stat { + display: flex; + flex-direction: column; + gap: 3px; +} + +.cyberjump-stat-name { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: rgba(148, 163, 184, 0.85); +} + +.cyberjump-stat-value { + font-size: 1.02rem; + font-weight: 600; +} + +.cyberjump-thought { + margin: 0; + color: rgba(224, 231, 255, 0.96); + font-size: 1rem; + line-height: 1.55; +} + +.cyberjump-subtle { + margin: 8px 0 0; + color: rgba(148, 163, 184, 0.95); + font-size: 0.88rem; + line-height: 1.45; +} + +.cyberjump-title { + margin: 0; + font-size: clamp(1.8rem, 3vw, 2.8rem); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.cyberjump-copy { + margin: 12px 0 0; + line-height: 1.6; + color: rgba(226, 232, 240, 0.92); +} + +.cyberjump-accent { + color: rgba(125, 211, 252, 1); +} + +@media (max-width: 900px) { + .cyberjump-thoughts { + top: auto; + right: 20px; + bottom: 108px; + } + + .cyberjump-stats { + min-width: 0; + width: min(320px, calc(100vw - 40px)); + } +} + +@media (max-width: 640px) { + .cyberjump-stats, + .cyberjump-thoughts, + .cyberjump-status { + left: 12px; + right: 12px; + width: auto; + } + + .cyberjump-stats { + top: 12px; + } + + .cyberjump-thoughts { + bottom: 106px; + top: auto; + } + + .cyberjump-status { + bottom: 12px; + } + + .cyberjump-grid { + grid-template-columns: 1fr 1fr; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..eead15c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..a9e29f6 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 3000 + }, + build: { + target: 'esnext' + } +}); |
