diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d0d4b6f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/index.html b/index.html new file mode 100644 index 0000000..96a93c6 --- /dev/null +++ b/index.html @@ -0,0 +1,27 @@ + + + + + + Quantum Runner + + + + + +
+ +
+ Turn: 0 + Future: 0/0 +
+
+
+ + +
+ +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f808e12 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1119 @@ +{ + "name": "quantum-runner", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "quantum-runner", + "version": "1.0.0", + "devDependencies": { + "typescript": "~5.6.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..81d7501 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "quantum-runner", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.6.0", + "vite": "^6.0.0" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b5e1b74 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,35 @@ +export const CONFIG = { + // Grid + GRID_COLS: 12, + GRID_ROWS: 10, + CELL_SIZE: 52, + + // Futures + NUM_FUTURES: 3, + MIN_FUTURES: 2, + MAX_FUTURES: 5, + + // Enemy AI + ENEMY_CHASE_BIAS: 0.6, + ENEMY_RANDOM_MOVE_CHANCE: 0.25, + ENEMY_STAY_CHANCE: 0.15, + + // Player movement + PLAYER_GOAL_BIAS: 0.3, + PLAYER_STAY_CHANCE: 0.1, + + // Colors + COLOR_BG: '#0a0a0f', + COLOR_GRID: '#1a3a2a', + COLOR_PLAYER: '#00ffcc', + COLOR_ENEMY: '#ff6b35', + COLOR_GOAL: '#39ff14', + COLOR_WALL: '#2a2a3a', + COLOR_FLOOR: '#12121f', + COLOR_DANGER: '#ff1744', + COLOR_UI_TEXT: '#b0ffb0', + COLOR_ACCENT: '#ffeb3b', + COLOR_FUTURE_GOOD: 'rgba(0, 255, 204, 0.35)', + COLOR_FUTURE_BAD: 'rgba(255, 23, 68, 0.35)', + COLOR_FUTURE_NEUTRAL: 'rgba(255, 235, 59, 0.35)', +} as const; diff --git a/src/engine/EnemyAI.ts b/src/engine/EnemyAI.ts new file mode 100644 index 0000000..fdb9bee --- /dev/null +++ b/src/engine/EnemyAI.ts @@ -0,0 +1,19 @@ +import { Entity, CellType } from '../types'; +import { CONFIG } from '../config'; +import { sampleEnemyMove } from './movement'; + +export function moveEnemy( + enemy: Entity, + playerPos: { x: number; y: number }, + grid: CellType[][], +): Entity { + const newPos = sampleEnemyMove( + grid, + enemy, + playerPos, + CONFIG.ENEMY_CHASE_BIAS, + CONFIG.ENEMY_RANDOM_MOVE_CHANCE, + CONFIG.ENEMY_STAY_CHANCE, + ); + return { ...enemy, x: newPos.x, y: newPos.y }; +} diff --git a/src/engine/FuturesEngine.ts b/src/engine/FuturesEngine.ts new file mode 100644 index 0000000..6e8fbb5 --- /dev/null +++ b/src/engine/FuturesEngine.ts @@ -0,0 +1,111 @@ +import { GameState, Future } from '../types'; +import { CONFIG } from '../config'; +import { cloneState } from '../state/GameState'; +import { samplePlayerMove } from './movement'; +import { moveEnemy } from './EnemyAI'; +import { manhattanDistance } from '../state/Grid'; + +function generateOneFuture(state: GameState): Future { + const next = cloneState(state); + next.turn = state.turn + 1; + + // Move player + const playerMove = samplePlayerMove( + next.grid, + state.player, + state.goalPos, + CONFIG.PLAYER_GOAL_BIAS, + CONFIG.PLAYER_STAY_CHANCE, + ); + next.player.x = playerMove.x; + next.player.y = playerMove.y; + + // Move enemies + next.enemies = state.enemies.map(e => moveEnemy(e, playerMove, next.grid)); + + // Check win/lose + if (next.player.x === next.goalPos.x && next.player.y === next.goalPos.y) { + next.status = 'won'; + } else { + for (const enemy of next.enemies) { + if (enemy.x === next.player.x && enemy.y === next.player.y) { + next.status = 'lost'; + break; + } + } + } + + // Compute quality: based on distance to goal and proximity to enemies + const distToGoal = manhattanDistance(next.player, next.goalPos); + const prevDistToGoal = manhattanDistance(state.player, state.goalPos); + const goalImprovement = prevDistToGoal - distToGoal; + + const minEnemyDist = Math.min(...next.enemies.map(e => manhattanDistance(next.player, e))); + const prevMinEnemyDist = Math.min(...state.enemies.map(e => manhattanDistance(state.player, e))); + const enemyChange = minEnemyDist - prevMinEnemyDist; // positive = enemies farther + + let quality = 0; + if (next.status === 'won') quality = 1; + else if (next.status === 'lost') quality = -1; + else { + quality = (goalImprovement * 0.4 + enemyChange * 0.3) / 2; + quality = Math.max(-1, Math.min(1, quality)); + } + + // Description + let desc = ''; + if (next.status === 'won') { + desc = 'You reach the exit! Victory!'; + } else if (next.status === 'lost') { + desc = 'An enemy catches you! Captured!'; + } else { + const dirX = playerMove.x - state.player.x; + const dirY = playerMove.y - state.player.y; + const moveDir = + dirX === 0 && dirY === 0 ? 'stays in place' : + dirX > 0 ? 'moves right' : + dirX < 0 ? 'moves left' : + dirY < 0 ? 'moves up' : 'moves down'; + const dangerLevel = minEnemyDist <= 2 ? ' (danger nearby!)' : minEnemyDist <= 4 ? '' : ' (safe zone)'; + desc = `Observer ${moveDir}${dangerLevel}`; + } + + return { state: next, description: desc, quality }; +} + +export function generateFutures(state: GameState, count: number = CONFIG.NUM_FUTURES): Future[] { + const n = Math.max(CONFIG.MIN_FUTURES, Math.min(CONFIG.MAX_FUTURES, count)); + const futures: Future[] = []; + + // Generate candidates — oversample to ensure quality spread + const candidates: Future[] = []; + const attempts = n * 4; + for (let i = 0; i < attempts; i++) { + candidates.push(generateOneFuture(state)); + } + + // Sort by quality + candidates.sort((a, b) => b.quality - a.quality); + + // Pick with spread: best, worst, and fill from middle + if (candidates.length >= n) { + futures.push(candidates[0]!); // best + futures.push(candidates[candidates.length - 1]!); // worst + // Fill remaining from evenly spaced positions + const remaining = n - 2; + for (let i = 0; i < remaining; i++) { + const idx = Math.floor((i + 1) * (candidates.length / (remaining + 1))); + futures.push(candidates[idx]!); + } + } else { + futures.push(...candidates); + } + + // Shuffle so the player doesn't know best is always first + for (let i = futures.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [futures[i], futures[j]] = [futures[j]!, futures[i]!]; + } + + return futures; +} diff --git a/src/engine/movement.ts b/src/engine/movement.ts new file mode 100644 index 0000000..cb40680 --- /dev/null +++ b/src/engine/movement.ts @@ -0,0 +1,74 @@ +import { Position, CellType } from '../types'; +import { getWalkableNeighbors, manhattanDistance } from '../state/Grid'; + +/** + * Weighted random pick from an array of (item, weight) tuples. + */ +export function weightedPick(items: { item: T; weight: number }[]): T { + const total = items.reduce((s, i) => s + i.weight, 0); + let r = Math.random() * total; + for (const { item, weight } of items) { + r -= weight; + if (r <= 0) return item; + } + return items[items.length - 1]!.item; +} + +/** + * Sample a player move biased toward a target (goal). + * Also includes a chance to stay in place. + */ +export function samplePlayerMove( + grid: CellType[][], + current: Position, + target: Position, + goalBias: number, + stayChance: number, +): Position { + if (Math.random() < stayChance) return { ...current }; + + const neighbors = getWalkableNeighbors(grid, current); + if (neighbors.length === 0) return { ...current }; + + const currentDist = manhattanDistance(current, target); + const weighted = neighbors.map(n => { + const dist = manhattanDistance(n, target); + const improvement = currentDist - dist; // positive = closer to goal + const weight = improvement > 0 ? 1 + goalBias : improvement < 0 ? Math.max(0.1, 1 - goalBias) : 0.5; + return { item: n, weight }; + }); + + return weightedPick(weighted); +} + +/** + * Sample an enemy move biased toward chasing the player. + */ +export function sampleEnemyMove( + grid: CellType[][], + current: Position, + playerPos: Position, + chaseBias: number, + randomChance: number, + stayChance: number, +): Position { + if (Math.random() < stayChance) return { ...current }; + + const neighbors = getWalkableNeighbors(grid, current); + if (neighbors.length === 0) return { ...current }; + + // Occasionally move randomly + if (Math.random() < randomChance) { + return neighbors[Math.floor(Math.random() * neighbors.length)]!; + } + + const currentDist = manhattanDistance(current, playerPos); + const weighted = neighbors.map(n => { + const dist = manhattanDistance(n, playerPos); + const closer = currentDist - dist; + const weight = closer > 0 ? 1 + chaseBias : closer < 0 ? Math.max(0.1, 1 - chaseBias) : 0.5; + return { item: n, weight }; + }); + + return weightedPick(weighted); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..2609fdf --- /dev/null +++ b/src/main.ts @@ -0,0 +1,5 @@ +import './style.css'; +import { GameController } from './ui/GameController'; + +const canvas = document.getElementById('game-canvas') as HTMLCanvasElement; +new GameController(canvas); diff --git a/src/render/EntityRenderer.ts b/src/render/EntityRenderer.ts new file mode 100644 index 0000000..c021447 --- /dev/null +++ b/src/render/EntityRenderer.ts @@ -0,0 +1,80 @@ +import { Entity, Position } from '../types'; +import { CONFIG } from '../config'; + +export function renderPlayer(ctx: CanvasRenderingContext2D, player: Entity): void { + const { CELL_SIZE, COLOR_PLAYER } = CONFIG; + const cx = player.x * CELL_SIZE + CELL_SIZE / 2; + const cy = player.y * CELL_SIZE + CELL_SIZE / 2; + const r = CELL_SIZE * 0.35; + + // Glow + ctx.shadowColor = COLOR_PLAYER; + ctx.shadowBlur = 12; + + // Diamond shape for player (the "observer") + ctx.fillStyle = COLOR_PLAYER; + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.closePath(); + ctx.fill(); + + // Eye symbol in center + ctx.shadowBlur = 0; + ctx.fillStyle = CONFIG.COLOR_BG; + ctx.font = `bold ${CELL_SIZE * 0.3}px "JetBrains Mono", monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('◉', cx, cy); + + ctx.shadowBlur = 0; +} + +export function renderEnemy(ctx: CanvasRenderingContext2D, enemy: Entity): void { + const { CELL_SIZE, COLOR_ENEMY } = CONFIG; + const cx = enemy.x * CELL_SIZE + CELL_SIZE / 2; + const cy = enemy.y * CELL_SIZE + CELL_SIZE / 2; + const r = CELL_SIZE * 0.32; + + // Glow + ctx.shadowColor = COLOR_ENEMY; + ctx.shadowBlur = 8; + + // Triangle shape for enemies + ctx.fillStyle = COLOR_ENEMY; + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy + r * 0.8); + ctx.lineTo(cx - r, cy + r * 0.8); + ctx.closePath(); + ctx.fill(); + + // X symbol + ctx.shadowBlur = 0; + ctx.fillStyle = CONFIG.COLOR_BG; + ctx.font = `bold ${CELL_SIZE * 0.25}px "JetBrains Mono", monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('✕', cx, cy + 2); + + ctx.shadowBlur = 0; +} + +export function renderGoalMarker(ctx: CanvasRenderingContext2D, goal: Position): void { + const { CELL_SIZE, COLOR_GOAL } = CONFIG; + const cx = goal.x * CELL_SIZE + CELL_SIZE / 2; + const cy = goal.y * CELL_SIZE + CELL_SIZE / 2; + + // Pulsing ring + const time = Date.now() / 1000; + const pulse = 0.4 + 0.2 * Math.sin(time * 3); + ctx.globalAlpha = pulse; + ctx.strokeStyle = COLOR_GOAL; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, cy, CELL_SIZE * 0.4, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; +} diff --git a/src/render/FutureOverlay.ts b/src/render/FutureOverlay.ts new file mode 100644 index 0000000..e67df51 --- /dev/null +++ b/src/render/FutureOverlay.ts @@ -0,0 +1,133 @@ +import { GameState, Future } from '../types'; +import { CONFIG } from '../config'; + +export function renderFutureOverlay( + ctx: CanvasRenderingContext2D, + current: GameState, + future: Future, +): void { + const { CELL_SIZE } = CONFIG; + const next = future.state; + + // Determine overlay color based on quality + let overlayColor: string; + if (future.quality > 0.15) { + overlayColor = CONFIG.COLOR_FUTURE_GOOD; + } else if (future.quality < -0.15) { + overlayColor = CONFIG.COLOR_FUTURE_BAD; + } else { + overlayColor = CONFIG.COLOR_FUTURE_NEUTRAL; + } + + // Draw ghost player at future position + const fpx = next.player.x * CELL_SIZE + CELL_SIZE / 2; + const fpy = next.player.y * CELL_SIZE + CELL_SIZE / 2; + const cpx = current.player.x * CELL_SIZE + CELL_SIZE / 2; + const cpy = current.player.y * CELL_SIZE + CELL_SIZE / 2; + + // Arrow from current to future player position + if (next.player.x !== current.player.x || next.player.y !== current.player.y) { + ctx.strokeStyle = CONFIG.COLOR_PLAYER; + ctx.globalAlpha = 0.6; + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.beginPath(); + ctx.moveTo(cpx, cpy); + ctx.lineTo(fpx, fpy); + ctx.stroke(); + ctx.setLineDash([]); + + // Arrowhead + const angle = Math.atan2(fpy - cpy, fpx - cpx); + const headLen = 10; + ctx.beginPath(); + ctx.moveTo(fpx, fpy); + ctx.lineTo(fpx - headLen * Math.cos(angle - 0.4), fpy - headLen * Math.sin(angle - 0.4)); + ctx.lineTo(fpx - headLen * Math.cos(angle + 0.4), fpy - headLen * Math.sin(angle + 0.4)); + ctx.closePath(); + ctx.fillStyle = CONFIG.COLOR_PLAYER; + ctx.fill(); + ctx.globalAlpha = 1; + } + + // Ghost player diamond + ctx.globalAlpha = 0.45; + ctx.fillStyle = CONFIG.COLOR_PLAYER; + const pr = CELL_SIZE * 0.3; + ctx.beginPath(); + ctx.moveTo(fpx, fpy - pr); + ctx.lineTo(fpx + pr, fpy); + ctx.lineTo(fpx, fpy + pr); + ctx.lineTo(fpx - pr, fpy); + ctx.closePath(); + ctx.fill(); + + // Dashed outline + ctx.strokeStyle = CONFIG.COLOR_PLAYER; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(fpx, fpy - pr); + ctx.lineTo(fpx + pr, fpy); + ctx.lineTo(fpx, fpy + pr); + ctx.lineTo(fpx - pr, fpy); + ctx.closePath(); + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; + + // Ghost enemies + for (let i = 0; i < next.enemies.length; i++) { + const fe = next.enemies[i]!; + const ce = current.enemies[i]; + const fex = fe.x * CELL_SIZE + CELL_SIZE / 2; + const fey = fe.y * CELL_SIZE + CELL_SIZE / 2; + + // Arrow from current to future enemy + if (ce && (fe.x !== ce.x || fe.y !== ce.y)) { + const cex = ce.x * CELL_SIZE + CELL_SIZE / 2; + const cey = ce.y * CELL_SIZE + CELL_SIZE / 2; + ctx.strokeStyle = CONFIG.COLOR_ENEMY; + ctx.globalAlpha = 0.4; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(cex, cey); + ctx.lineTo(fex, fey); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Ghost enemy triangle + ctx.globalAlpha = 0.35; + ctx.fillStyle = CONFIG.COLOR_ENEMY; + const er = CELL_SIZE * 0.28; + ctx.beginPath(); + ctx.moveTo(fex, fey - er); + ctx.lineTo(fex + er, fey + er * 0.8); + ctx.lineTo(fex - er, fey + er * 0.8); + ctx.closePath(); + ctx.fill(); + + ctx.strokeStyle = CONFIG.COLOR_ENEMY; + ctx.lineWidth = 2; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(fex, fey - er); + ctx.lineTo(fex + er, fey + er * 0.8); + ctx.lineTo(fex - er, fey + er * 0.8); + ctx.closePath(); + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; + } + + // Highlight the future player cell with quality-based overlay + ctx.fillStyle = overlayColor; + ctx.fillRect( + next.player.x * CELL_SIZE + 2, + next.player.y * CELL_SIZE + 2, + CELL_SIZE - 4, + CELL_SIZE - 4, + ); +} diff --git a/src/render/GridRenderer.ts b/src/render/GridRenderer.ts new file mode 100644 index 0000000..874007f --- /dev/null +++ b/src/render/GridRenderer.ts @@ -0,0 +1,48 @@ +import { CellType } from '../types'; +import { CONFIG } from '../config'; + +export function renderGrid(ctx: CanvasRenderingContext2D, grid: CellType[][]): void { + const { CELL_SIZE, COLOR_FLOOR, COLOR_WALL, COLOR_GOAL, COLOR_GRID } = CONFIG; + + for (let y = 0; y < grid.length; y++) { + const row = grid[y]!; + for (let x = 0; x < row.length; x++) { + const cell = row[x]!; + const px = x * CELL_SIZE; + const py = y * CELL_SIZE; + + // Fill cell + if (cell === 'wall') { + ctx.fillStyle = COLOR_WALL; + } else if (cell === 'goal') { + ctx.fillStyle = COLOR_GOAL; + ctx.globalAlpha = 0.25; + } else { + ctx.fillStyle = COLOR_FLOOR; + } + ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE); + ctx.globalAlpha = 1; + + // Grid lines + ctx.strokeStyle = COLOR_GRID; + ctx.lineWidth = 1; + ctx.strokeRect(px, py, CELL_SIZE, CELL_SIZE); + + // Goal marker + if (cell === 'goal') { + ctx.fillStyle = CONFIG.COLOR_GOAL; + ctx.globalAlpha = 0.6; + const margin = CELL_SIZE * 0.2; + ctx.fillRect(px + margin, py + margin, CELL_SIZE - margin * 2, CELL_SIZE - margin * 2); + ctx.globalAlpha = 1; + + // Draw "EXIT" text + ctx.fillStyle = CONFIG.COLOR_GOAL; + ctx.font = `bold ${CELL_SIZE * 0.25}px "JetBrains Mono", monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('EXIT', px + CELL_SIZE / 2, py + CELL_SIZE / 2); + } + } + } +} diff --git a/src/render/Renderer.ts b/src/render/Renderer.ts new file mode 100644 index 0000000..5c6c939 --- /dev/null +++ b/src/render/Renderer.ts @@ -0,0 +1,74 @@ +import { GameState, Future } from '../types'; +import { CONFIG } from '../config'; +import { renderGrid } from './GridRenderer'; +import { renderPlayer, renderEnemy, renderGoalMarker } from './EntityRenderer'; +import { renderFutureOverlay } from './FutureOverlay'; + +export class Renderer { + private ctx: CanvasRenderingContext2D; + private canvas: HTMLCanvasElement; + private animFrameId: number | null = null; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d')!; + this.resize(); + } + + resize(): void { + this.canvas.width = CONFIG.GRID_COLS * CONFIG.CELL_SIZE; + this.canvas.height = CONFIG.GRID_ROWS * CONFIG.CELL_SIZE; + } + + renderState(state: GameState, activeFuture?: Future): void { + const ctx = this.ctx; + + // Clear + ctx.fillStyle = CONFIG.COLOR_BG; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + // Grid + renderGrid(ctx, state.grid); + + // Goal pulsing marker + renderGoalMarker(ctx, state.goalPos); + + // Entities + for (const enemy of state.enemies) { + renderEnemy(ctx, enemy); + } + renderPlayer(ctx, state.player); + + // Future overlay + if (activeFuture) { + renderFutureOverlay(ctx, state, activeFuture); + } + + // Scanline effect + this.renderScanlines(); + } + + private renderScanlines(): void { + const ctx = this.ctx; + ctx.fillStyle = 'rgba(0, 0, 0, 0.04)'; + for (let y = 0; y < this.canvas.height; y += 3) { + ctx.fillRect(0, y, this.canvas.width, 1); + } + } + + startPulseAnimation(state: GameState, activeFuture?: Future): void { + this.stopAnimation(); + const animate = () => { + this.renderState(state, activeFuture); + this.animFrameId = requestAnimationFrame(animate); + }; + animate(); + } + + stopAnimation(): void { + if (this.animFrameId !== null) { + cancelAnimationFrame(this.animFrameId); + this.animFrameId = null; + } + } +} diff --git a/src/state/GameState.ts b/src/state/GameState.ts new file mode 100644 index 0000000..60cbc59 --- /dev/null +++ b/src/state/GameState.ts @@ -0,0 +1,29 @@ +import { GameState, Entity } from '../types'; +import { createGrid, getGoalPosition } from './Grid'; +import { createPlayer, createEnemies } from './entities'; + +export function createInitialState(): GameState { + return { + grid: createGrid(), + player: createPlayer(), + enemies: createEnemies(), + goalPos: getGoalPosition(), + turn: 0, + status: 'playing', + }; +} + +export function cloneState(state: GameState): GameState { + return { + grid: state.grid.map(row => [...row]), + player: { ...state.player }, + enemies: state.enemies.map(e => ({ ...e })), + goalPos: { ...state.goalPos }, + turn: state.turn, + status: state.status, + }; +} + +export function cloneEntity(entity: Entity): Entity { + return { ...entity }; +} diff --git a/src/state/Grid.ts b/src/state/Grid.ts new file mode 100644 index 0000000..21eecb0 --- /dev/null +++ b/src/state/Grid.ts @@ -0,0 +1,55 @@ +import { CellType, Position } from '../types'; +import { CONFIG } from '../config'; + +const W: CellType = 'wall'; +const F: CellType = 'floor'; +const G: CellType = 'goal'; + +// 12x10 grid — corridors with dead ends for interesting choices +// Row 0 = top, Row 9 = bottom +const LEVEL: CellType[][] = [ + [W, W, W, W, W, W, W, W, W, W, W, W], + [W, F, F, F, W, F, F, F, F, F, G, W], + [W, F, W, F, W, F, W, W, W, F, F, W], + [W, F, W, F, F, F, F, F, W, F, W, W], + [W, F, W, W, W, F, W, F, F, F, F, W], + [W, F, F, F, F, F, W, F, W, W, F, W], + [W, W, W, F, W, W, W, F, F, F, F, W], + [W, F, F, F, F, F, F, F, W, F, W, W], + [W, F, W, F, W, F, W, F, F, F, F, W], + [W, W, W, W, W, W, W, W, W, W, W, W], +]; + +export function createGrid(): CellType[][] { + return LEVEL.map(row => [...row]); +} + +export function getGoalPosition(): Position { + for (let y = 0; y < LEVEL.length; y++) { + const row = LEVEL[y]!; + for (let x = 0; x < row.length; x++) { + if (row[x] === 'goal') return { x, y }; + } + } + return { x: 10, y: 1 }; // fallback +} + +export function isWalkable(grid: CellType[][], x: number, y: number): boolean { + if (x < 0 || y < 0 || y >= CONFIG.GRID_ROWS || x >= CONFIG.GRID_COLS) return false; + const cell = grid[y]?.[x]; + return cell === 'floor' || cell === 'goal'; +} + +export function getWalkableNeighbors(grid: CellType[][], pos: Position): Position[] { + const dirs = [ + { x: pos.x, y: pos.y - 1 }, + { x: pos.x, y: pos.y + 1 }, + { x: pos.x - 1, y: pos.y }, + { x: pos.x + 1, y: pos.y }, + ]; + return dirs.filter(p => isWalkable(grid, p.x, p.y)); +} + +export function manhattanDistance(a: Position, b: Position): number { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} diff --git a/src/state/entities.ts b/src/state/entities.ts new file mode 100644 index 0000000..80fbe6e --- /dev/null +++ b/src/state/entities.ts @@ -0,0 +1,12 @@ +import { Entity } from '../types'; + +export function createPlayer(): Entity { + return { type: 'player', x: 1, y: 8, id: 'player' }; +} + +export function createEnemies(): Entity[] { + return [ + { type: 'enemy', x: 5, y: 5, id: 'enemy-1' }, + { type: 'enemy', x: 9, y: 2, id: 'enemy-2' }, + ]; +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..bfdfd75 --- /dev/null +++ b/src/style.css @@ -0,0 +1,108 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: #0a0a0f; + color: #b0ffb0; + font-family: 'JetBrains Mono', monospace; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + overflow: hidden; +} + +#game-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +#game-canvas { + border: 2px solid #1a3a2a; + border-radius: 4px; + image-rendering: pixelated; +} + +#hud { + display: flex; + gap: 24px; + font-size: 14px; + color: #b0ffb0; +} + +#future-description { + font-size: 15px; + color: #ffeb3b; + min-height: 22px; + text-align: center; +} + +#controls { + display: flex; + gap: 16px; +} + +#controls.hidden { + visibility: hidden; +} + +.game-btn { + font-family: 'JetBrains Mono', monospace; + font-size: 15px; + font-weight: 700; + padding: 10px 28px; + border: 2px solid #1a3a2a; + border-radius: 4px; + background: #12121f; + color: #b0ffb0; + cursor: pointer; + transition: all 0.15s ease; +} + +.game-btn:hover { + background: #1a3a2a; + border-color: #00ffcc; + color: #00ffcc; +} + +.game-btn:active { + transform: scale(0.96); +} + +#btn-accept { + border-color: #39ff14; + color: #39ff14; +} + +#btn-accept:hover { + background: rgba(57, 255, 20, 0.15); +} + +#btn-pass { + border-color: #ff6b35; + color: #ff6b35; +} + +#btn-pass:hover { + background: rgba(255, 107, 53, 0.15); +} + +#btn-pass.forced { + border-color: #ff1744; + color: #ff1744; + animation: pulse-danger 0.8s ease-in-out infinite; +} + +@keyframes pulse-danger { + 0%, 100% { border-color: #ff1744; } + 50% { border-color: #ff6b35; } +} + +#screen-overlay.hidden { + display: none; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..1cd141b --- /dev/null +++ b/src/types.ts @@ -0,0 +1,32 @@ +export type CellType = 'floor' | 'wall' | 'goal'; + +export interface Position { + x: number; + y: number; +} + +export interface Entity { + type: 'player' | 'enemy'; + x: number; + y: number; + id: string; +} + +export interface GameState { + grid: CellType[][]; + player: Entity; + enemies: Entity[]; + goalPos: Position; + turn: number; + status: 'playing' | 'won' | 'lost'; +} + +export interface Future { + state: GameState; + description: string; + quality: number; // -1 to 1 +} + +export type GamePhase = 'title' | 'playing' | 'won' | 'lost'; + +export type Direction = 'up' | 'down' | 'left' | 'right' | 'stay'; diff --git a/src/ui/GameController.ts b/src/ui/GameController.ts new file mode 100644 index 0000000..319823f --- /dev/null +++ b/src/ui/GameController.ts @@ -0,0 +1,173 @@ +import { GameState, GamePhase, Future } from '../types'; +import { createInitialState } from '../state/GameState'; +import { Renderer } from '../render/Renderer'; +import { TurnManager } from './TurnManager'; +import { HUD } from './HUD'; +import { renderTitleScreen, renderWinScreen, renderLoseScreen } from './screens'; + +export class GameController { + private phase: GamePhase = 'title'; + private state: GameState; + private renderer: Renderer; + private turnManager: TurnManager; + private hud: HUD; + private canvas: HTMLCanvasElement; + private controls: HTMLElement; + private btnAccept: HTMLElement; + private btnPass: HTMLElement; + private animFrameId: number | null = null; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.renderer = new Renderer(canvas); + this.turnManager = new TurnManager(); + this.hud = new HUD(); + this.state = createInitialState(); + this.controls = document.getElementById('controls')!; + this.btnAccept = document.getElementById('btn-accept')!; + this.btnPass = document.getElementById('btn-pass')!; + + this.turnManager.setCallbacks( + (future, index, total) => this.onFuturePresented(future, index, total), + (newState) => this.onTurnResolved(newState), + ); + + this.setupInput(); + this.showTitle(); + } + + private setupInput(): void { + this.btnAccept.addEventListener('click', () => this.handleAccept()); + this.btnPass.addEventListener('click', () => this.handlePass()); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (this.phase === 'title' || this.phase === 'won' || this.phase === 'lost') { + this.startGame(); + } else if (this.phase === 'playing') { + this.handleAccept(); + } + } else if (e.key === ' ') { + e.preventDefault(); + if (this.phase === 'playing') { + this.handlePass(); + } + } + }); + + // Click on canvas for title/end screens + this.canvas.addEventListener('click', () => { + if (this.phase === 'title' || this.phase === 'won' || this.phase === 'lost') { + this.startGame(); + } + }); + } + + private showTitle(): void { + this.phase = 'title'; + this.controls.classList.add('hidden'); + this.hud.hide(); + this.startScreenAnimation(() => { + const ctx = this.canvas.getContext('2d')!; + renderTitleScreen(ctx, this.canvas.width, this.canvas.height); + }); + } + + private startGame(): void { + this.stopAnimation(); + this.phase = 'playing'; + this.state = createInitialState(); + this.controls.classList.remove('hidden'); + this.hud.show(); + this.hud.updateTurn(0); + this.renderer.renderState(this.state); + this.startTurn(); + } + + private startTurn(): void { + this.turnManager.startTurn(this.state); + } + + private onFuturePresented(future: Future, index: number, total: number): void { + this.hud.updateFutures(index, total); + this.hud.updateDescription(future.description); + + // Update pass button text + if (this.turnManager.isLastFuture()) { + this.btnPass.textContent = 'Forced [Space]'; + this.btnPass.classList.add('forced'); + } else { + this.btnPass.textContent = 'Pass [Space]'; + this.btnPass.classList.remove('forced'); + } + + // Animate the current state with future overlay + this.renderer.startPulseAnimation(this.state, future); + } + + private onTurnResolved(newState: GameState): void { + this.renderer.stopAnimation(); + this.state = newState; + this.hud.updateTurn(this.state.turn); + this.hud.clearDescription(); + this.renderer.renderState(this.state); + + if (this.state.status === 'won') { + this.showWin(); + } else if (this.state.status === 'lost') { + this.showLose(); + } else { + // Small delay before next turn for readability + setTimeout(() => this.startTurn(), 300); + } + } + + private handleAccept(): void { + if (this.phase !== 'playing') return; + this.turnManager.accept(); + } + + private handlePass(): void { + if (this.phase !== 'playing') return; + this.turnManager.pass(); + } + + private showWin(): void { + this.phase = 'won'; + this.controls.classList.add('hidden'); + this.startScreenAnimation(() => { + // First render the final game state + this.renderer.renderState(this.state); + const ctx = this.canvas.getContext('2d')!; + renderWinScreen(ctx, this.canvas.width, this.canvas.height, this.state.turn); + }); + } + + private showLose(): void { + this.phase = 'lost'; + this.controls.classList.add('hidden'); + this.startScreenAnimation(() => { + this.renderer.renderState(this.state); + const ctx = this.canvas.getContext('2d')!; + renderLoseScreen(ctx, this.canvas.width, this.canvas.height, this.state.turn); + }); + } + + private startScreenAnimation(renderFn: () => void): void { + this.stopAnimation(); + const animate = () => { + renderFn(); + this.animFrameId = requestAnimationFrame(animate); + }; + animate(); + } + + private stopAnimation(): void { + if (this.animFrameId !== null) { + cancelAnimationFrame(this.animFrameId); + this.animFrameId = null; + } + this.renderer.stopAnimation(); + } +} diff --git a/src/ui/HUD.ts b/src/ui/HUD.ts new file mode 100644 index 0000000..418cc07 --- /dev/null +++ b/src/ui/HUD.ts @@ -0,0 +1,38 @@ +export class HUD { + private turnEl: HTMLElement; + private futuresEl: HTMLElement; + private descEl: HTMLElement; + + constructor() { + this.turnEl = document.getElementById('turn-counter')!; + this.futuresEl = document.getElementById('futures-counter')!; + this.descEl = document.getElementById('future-description')!; + } + + updateTurn(turn: number): void { + this.turnEl.textContent = `Turn: ${turn}`; + } + + updateFutures(current: number, total: number): void { + this.futuresEl.textContent = `Future: ${current + 1}/${total}`; + } + + updateDescription(text: string): void { + this.descEl.textContent = text; + } + + clearDescription(): void { + this.descEl.textContent = ''; + } + + show(): void { + this.turnEl.style.display = ''; + this.futuresEl.style.display = ''; + } + + hide(): void { + this.turnEl.style.display = 'none'; + this.futuresEl.style.display = 'none'; + this.descEl.textContent = ''; + } +} diff --git a/src/ui/TurnManager.ts b/src/ui/TurnManager.ts new file mode 100644 index 0000000..59b90f1 --- /dev/null +++ b/src/ui/TurnManager.ts @@ -0,0 +1,56 @@ +import { GameState, Future } from '../types'; +import { generateFutures } from '../engine/FuturesEngine'; +import { CONFIG } from '../config'; + +export class TurnManager { + private futures: Future[] = []; + private currentIndex = 0; + private onFuturePresented: ((future: Future, index: number, total: number) => void) | null = null; + private onTurnResolved: ((state: GameState) => void) | null = null; + + setCallbacks( + onFuturePresented: (future: Future, index: number, total: number) => void, + onTurnResolved: (state: GameState) => void, + ): void { + this.onFuturePresented = onFuturePresented; + this.onTurnResolved = onTurnResolved; + } + + startTurn(state: GameState): void { + this.futures = generateFutures(state, CONFIG.NUM_FUTURES); + this.currentIndex = 0; + this.presentCurrent(); + } + + private presentCurrent(): void { + const future = this.futures[this.currentIndex]; + if (future && this.onFuturePresented) { + this.onFuturePresented(future, this.currentIndex, this.futures.length); + } + } + + accept(): void { + const future = this.futures[this.currentIndex]; + if (future && this.onTurnResolved) { + this.onTurnResolved(future.state); + } + } + + pass(): void { + if (this.currentIndex < this.futures.length - 1) { + this.currentIndex++; + this.presentCurrent(); + } else { + // Last future — auto-accept + this.accept(); + } + } + + getCurrentFuture(): Future | null { + return this.futures[this.currentIndex] ?? null; + } + + isLastFuture(): boolean { + return this.currentIndex === this.futures.length - 1; + } +} diff --git a/src/ui/screens.ts b/src/ui/screens.ts new file mode 100644 index 0000000..9437bb3 --- /dev/null +++ b/src/ui/screens.ts @@ -0,0 +1,104 @@ +import { CONFIG } from '../config'; + +export function renderTitleScreen(ctx: CanvasRenderingContext2D, width: number, height: number): void { + ctx.fillStyle = CONFIG.COLOR_BG; + ctx.fillRect(0, 0, width, height); + + // Scanlines + ctx.fillStyle = 'rgba(0, 0, 0, 0.04)'; + for (let y = 0; y < height; y += 3) { + ctx.fillRect(0, y, width, 1); + } + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Title + ctx.shadowColor = CONFIG.COLOR_PLAYER; + ctx.shadowBlur = 20; + ctx.fillStyle = CONFIG.COLOR_PLAYER; + ctx.font = 'bold 36px "JetBrains Mono", monospace'; + ctx.fillText('QUANTUM RUNNER', width / 2, height / 3); + ctx.shadowBlur = 0; + + // Subtitle + ctx.fillStyle = CONFIG.COLOR_UI_TEXT; + ctx.font = '16px "JetBrains Mono", monospace'; + ctx.fillText('Collapse the future. Reach the exit.', width / 2, height / 3 + 50); + + // Instructions + ctx.fillStyle = CONFIG.COLOR_ACCENT; + ctx.font = '14px "JetBrains Mono", monospace'; + const instructions = [ + 'You are the Observer ◉ — you don\'t move directly.', + 'Each turn, possible futures are revealed one by one.', + 'Accept a future or pass to see the next.', + 'If you pass all futures, the last one is forced.', + 'Reach the EXIT. Avoid the enemies ✕.', + ]; + instructions.forEach((line, i) => { + ctx.fillText(line, width / 2, height / 2 + 20 + i * 26); + }); + + // Start prompt + const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 500); + ctx.globalAlpha = pulse; + ctx.fillStyle = CONFIG.COLOR_GOAL; + ctx.font = 'bold 20px "JetBrains Mono", monospace'; + ctx.fillText('[ Press ENTER or Click to Start ]', width / 2, height - 60); + ctx.globalAlpha = 1; +} + +export function renderWinScreen(ctx: CanvasRenderingContext2D, width: number, height: number, turns: number): void { + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.fillRect(0, 0, width, height); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + ctx.shadowColor = CONFIG.COLOR_GOAL; + ctx.shadowBlur = 20; + ctx.fillStyle = CONFIG.COLOR_GOAL; + ctx.font = 'bold 40px "JetBrains Mono", monospace'; + ctx.fillText('COLLAPSED!', width / 2, height / 3); + ctx.shadowBlur = 0; + + ctx.fillStyle = CONFIG.COLOR_UI_TEXT; + ctx.font = '18px "JetBrains Mono", monospace'; + ctx.fillText('The observer reached the exit.', width / 2, height / 3 + 50); + ctx.fillText(`Turns taken: ${turns}`, width / 2, height / 3 + 80); + + const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 500); + ctx.globalAlpha = pulse; + ctx.fillStyle = CONFIG.COLOR_ACCENT; + ctx.font = 'bold 18px "JetBrains Mono", monospace'; + ctx.fillText('[ Press ENTER or Click to Restart ]', width / 2, height - 60); + ctx.globalAlpha = 1; +} + +export function renderLoseScreen(ctx: CanvasRenderingContext2D, width: number, height: number, turns: number): void { + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.fillRect(0, 0, width, height); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + ctx.shadowColor = CONFIG.COLOR_DANGER; + ctx.shadowBlur = 20; + ctx.fillStyle = CONFIG.COLOR_DANGER; + ctx.font = 'bold 40px "JetBrains Mono", monospace'; + ctx.fillText('DECOHERENCE', width / 2, height / 3); + ctx.shadowBlur = 0; + + ctx.fillStyle = CONFIG.COLOR_UI_TEXT; + ctx.font = '18px "JetBrains Mono", monospace'; + ctx.fillText('The observer was caught.', width / 2, height / 3 + 50); + ctx.fillText(`Survived ${turns} turns.`, width / 2, height / 3 + 80); + + const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 500); + ctx.globalAlpha = pulse; + ctx.fillStyle = CONFIG.COLOR_ACCENT; + ctx.font = 'bold 18px "JetBrains Mono", monospace'; + ctx.fillText('[ Press ENTER or Click to Restart ]', width / 2, height - 60); + ctx.globalAlpha = 1; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c63d14e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..e59758d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + target: 'ES2022', + }, +});