diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..041226b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,34 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install:*)", + "Bash(npm run dev:*)", + "Bash(timeout 3:*)", + "Bash(npm run typecheck:*)", + "Bash(npm run build:*)", + "Bash(find:*)", + "Bash(xargs -I {} bash -c 'echo \"\"=== {} ===\"\" && grep -c \"\"React.memo\\\\|useMemo\\\\|useCallback\"\" {} || echo \"\"0\"\"')", + "Bash(gh repo create:*)", + "Bash(where.exe:*)", + "Bash(dir \"C:\\\\Program Files\\\\GitHub CLI\"\" 2>&1 || dir \"%LOCALAPPDATA%ProgramsGitHub CLI\"\")", + "Bash(cmd.exe /c \"where gh\")", + "Bash(powershell.exe -Command \"& {$env:Path = [System.Environment]::GetEnvironmentVariable\\(''Path'',''Machine''\\) + '';'' + [System.Environment]::GetEnvironmentVariable\\(''Path'',''User''\\); gh repo create PizzaDAO/pizza-chef --public --description ''Pizza Chef game''}\")", + "Bash(git remote add:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)", + "Bash(tasklist:*)", + "Bash(findstr:*)", + "Bash(wc:*)", + "Bash(npx tsc:*)", + "Bash(npm test:*)", + "Bash(python:*)", + "Bash(magick:*)", + "Bash(pip install:*)", + "Bash(grep:*)", + "Bash(npx vitest run src/logic/customerSystem.test.ts)", + "Bash(npx vitest:*)", + "Bash(timeout 5 npm run dev:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index a534bbc..a18e944 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ dist-ssr *.sln *.sw? .env +.vercel diff --git a/index.html b/index.html index 222db39..9ce13ac 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,8 @@ - + + diff --git a/package-lock.json b/package-lock.json index 0ca0d99..9ad7d7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "tailwindcss": "^3.4.1", "typescript": "^5.5.3", "typescript-eslint": "^8.3.0", - "vite": "^5.4.2" + "vite": "^5.4.2", + "vitest": "^4.0.16" } }, "node_modules/@alloc/quick-lru": { @@ -618,6 +619,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -634,6 +652,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -650,6 +685,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -936,10 +988,11 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -997,213 +1050,362 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", - "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", - "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", - "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", - "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", - "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", - "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", - "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", - "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", - "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", - "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", - "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", - "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", - "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", - "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", - "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", - "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.71.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", @@ -1312,11 +1514,30 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true + "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/@types/json-schema": { "version": "7.0.15", @@ -1619,6 +1840,90 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -1711,6 +2016,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", @@ -1858,6 +2173,16 @@ } ] }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -2029,6 +2354,13 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -2326,6 +2658,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2335,6 +2677,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2883,6 +3235,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2944,9 +3306,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -2954,6 +3316,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3009,6 +3372,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3120,6 +3494,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3157,9 +3538,9 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -3175,9 +3556,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -3427,12 +3809,13 @@ } }, "node_modules/rollup": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", - "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -3442,22 +3825,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.24.0", - "@rollup/rollup-android-arm64": "4.24.0", - "@rollup/rollup-darwin-arm64": "4.24.0", - "@rollup/rollup-darwin-x64": "4.24.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", - "@rollup/rollup-linux-arm-musleabihf": "4.24.0", - "@rollup/rollup-linux-arm64-gnu": "4.24.0", - "@rollup/rollup-linux-arm64-musl": "4.24.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", - "@rollup/rollup-linux-riscv64-gnu": "4.24.0", - "@rollup/rollup-linux-s390x-gnu": "4.24.0", - "@rollup/rollup-linux-x64-gnu": "4.24.0", - "@rollup/rollup-linux-x64-musl": "4.24.0", - "@rollup/rollup-win32-arm64-msvc": "4.24.0", - "@rollup/rollup-win32-ia32-msvc": "4.24.0", - "@rollup/rollup-win32-x64-msvc": "4.24.0", + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" } }, @@ -3522,6 +3914,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3543,6 +3942,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -3761,46 +4174,121 @@ "node": ">=0.8" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "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": { - "is-number": "^7.0.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=8.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "node_modules/tinyglobby/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": ">=16" + "node": ">=12.0.0" }, "peerDependencies": { - "typescript": ">=4.2.0" + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", + "node_modules/tinyglobby/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/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true @@ -3962,6 +4450,650 @@ } } }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/vitest/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/vitest/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/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "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 + } + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -3991,6 +5123,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index da3a0e9..d25d47f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "build": "vite build", "lint": "eslint .", "preview": "vite preview", - "typecheck": "tsc --noEmit -p tsconfig.app.json" + "typecheck": "tsc --noEmit -p tsconfig.app.json", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@supabase/supabase-js": "^2.57.4", @@ -30,6 +32,7 @@ "tailwindcss": "^3.4.1", "typescript": "^5.5.3", "typescript-eslint": "^8.3.0", - "vite": "^5.4.2" + "vite": "^5.4.2", + "vitest": "^4.0.16" } } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..ff25b85 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..3d74f54 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/sprites/pizza-mafia.png b/public/sprites/pizza-mafia.png new file mode 100644 index 0000000..299dffb Binary files /dev/null and b/public/sprites/pizza-mafia.png differ diff --git a/refactorplan.md b/refactorplan.md index 895b98c..c698744 100644 --- a/refactorplan.md +++ b/refactorplan.md @@ -1,534 +1,446 @@ -# Pizza Chef Refactoring Plan - -## Current State Analysis - -### Primary Issues - -1. **`useGameLogic.ts` is 1062 lines** - Violates single responsibility, hard to maintain -2. **Game loop contains 9 major sections** - Difficult to understand and test -3. **Scoring logic duplicated** - Appears in 5+ places with similar patterns -4. **Power-up system partially extracted** - `powerUpSystem.ts` exists but logic reimplemented in hook -5. **Collision detection embedded** - Mixed with game logic, not reusable -6. **Boss battle logic embedded** - ~100 lines in main game loop -7. **Nyan cat sweep embedded** - ~95 lines in main game loop -8. **Event handling scattered** - Sound calls mixed with state updates - -### Code Metrics - -- **useGameLogic.ts**: 1062 lines -- **Game loop sections**: 9 major blocks -- **Scoring calculations**: ~200 lines duplicated -- **Power-up handling**: ~150 lines (partially duplicated) -- **Boss battle logic**: ~100 lines -- **Nyan sweep logic**: ~95 lines +# Pizza Chef Refactoring Plan v2 + +## Current State (January 2025) + +### Completed Systems +- `scoringSystem.ts` - Score calculations, life gains, streaks +- `collisionSystem.ts` - Collision detection helpers +- `powerUpSystem.ts` - Power-up collection and expiration +- `nyanSystem.ts` - Nyan sweep movement and collisions +- `ovenSystem.ts` - Oven tick, interaction, pause logic, display status +- `storeSystem.ts` - Store purchases, upgrades +- `customerSystem.ts` - Customer movement and state +- `bossSystem.ts` - Boss battle logic, minions, waves ✅ NEW +- `spawnSystem.ts` - Customer and power-up spawning ✅ NEW +- `plateSystem.ts` - Plate catching and movement ✅ NEW + +### Current Metrics +- **useGameLogic.ts**: 883 lines (was 1045, target: ~300) +- **updateGame function**: ~484 lines (was ~500, ideally ~50) +- **Logic files**: 10 systems extracted (was 7) --- -## Refactoring Strategy +## Phase 1: useGameLogic Decomposition (HIGH PRIORITY) -### Phase 1: Extract Scoring System ⭐ (HIGH PRIORITY) +### 1.1 Extract Boss Battle System +**New File**: `src/logic/bossSystem.ts` -**Goal**: Centralize all scoring calculations and life management +**Functions to extract**: +```typescript +// Initialize boss battle +initializeBossBattle(level: number, now: number): BossBattle -**New File**: `src/logic/scoringSystem.ts` +// Process boss battle each tick +processBossTick(state: GameState, now: number): { + nextState: GameState; + events: BossEvent[]; +} -**Functions to Create**: -```typescript -// Calculate scores for different actions -calculateCustomerScore( - customer: Customer, - dogeMultiplier: number, - streakMultiplier: number -): { points: number; bank: number } - -calculatePlateScore( - dogeMultiplier: number, - streakMultiplier: number -): number - -// Process life gains -processLifeGain( - state: GameState, - happyCustomers: number, - dogeMultiplier: number -): { newLives: number; shouldPlaySound: boolean } +// Handle slice-boss collisions +processBossCollisions( + slices: PizzaSlice[], + boss: BossBattle, + minions: BossMinion[] +): { + hitBoss: boolean; + hitMinionIds: string[]; + consumedSliceIds: string[]; + scores: Array<{ points: number; lane: number; position: number }>; +} -// Apply scoring to state -applyCustomerScoring( - state: GameState, - customer: Customer, - event: CustomerHitEvent, - dogeMultiplier: number -): GameState +// Spawn wave of minions +spawnBossWave(waveNumber: number, now: number): BossMinion[] ``` -**Benefits**: -- Eliminates ~200 lines of duplicated scoring code -- Makes scoring rules easy to adjust -- Enables unit testing of scoring logic -- Single source of truth for scoring calculations - -**Lines Removed**: ~200 -**Estimated Effort**: 4-6 hours +**Lines to move**: ~100 lines from useGameLogic.ts (lines 700-800) --- -### Phase 2: Extract Collision System ⭐ (HIGH PRIORITY) +### 1.2 Extract Spawn System +**New File**: `src/logic/spawnSystem.ts` -**Goal**: Separate collision detection from game logic - -**New File**: `src/logic/collisionSystem.ts` - -**Functions to Create**: +**Functions to extract**: ```typescript -// Collision detection helpers -checkSliceCustomerCollision( - slice: PizzaSlice, - customer: Customer, - threshold?: number -): boolean - -checkSlicePowerUpCollision( - slice: PizzaSlice, - powerUp: PowerUp, - threshold?: number +// Determine if customer should spawn +shouldSpawnCustomer( + lastSpawn: number, + now: number, + level: number, + customerCount: number ): boolean -checkChefPowerUpCollision( - chefLane: number, - chefX: number, - powerUp: PowerUp -): boolean +// Create new customer +createCustomer(level: number, now: number): Customer -checkChefPlateCollision( - chefLane: number, - chefX: number, - plate: EmptyPlate, - threshold?: number -): boolean - -checkNyanSweepCollision( - nyanSweep: NyanSweep, - entity: { lane: number; position: number } +// Determine if power-up should spawn +shouldSpawnPowerUp( + lastSpawn: number, + now: number, + level: number ): boolean -``` - -**Benefits**: -- Makes collision detection testable -- Allows easy adjustment of collision thresholds -- Separates physics from game rules -- Reusable across different systems - -**Lines Removed**: ~50 (but enables other refactors) -**Estimated Effort**: 3-4 hours - ---- - -### Phase 3: Integrate Power-Up System ⭐ (HIGH PRIORITY) - -**Goal**: Use existing `powerUpSystem.ts` instead of reimplementing - -**Changes**: -1. Replace power-up collection logic (lines 492-583) with `processPowerUpCollection()` -2. Replace power-up expiration logic (lines 444-451) with `processPowerUpExpirations()` -3. Extract star power auto-feed to separate function -4. Extract nyan sweep to `nyanSystem.ts` - -**New File**: `src/logic/nyanSystem.ts` -```typescript -processNyanSweep( - state: GameState, - now: number -): GameState -checkNyanCollisions( - nyanSweep: NyanSweep, - customers: Customer[], - minions?: BossMinion[] -): { - hitCustomers: Customer[]; - hitMinions: BossMinion[]; - scores: Array<{ points: number; lane: number; position: number }>; -} +// Create random power-up +createPowerUp(level: number, now: number): PowerUp ``` -**Benefits**: -- Removes ~150 lines of duplicated power-up logic -- Makes power-up effects consistent -- Enables easier addition of new power-ups -- Uses existing tested code - -**Lines Removed**: ~150 -**Estimated Effort**: 4-5 hours +**Lines to move**: ~80 lines from useGameLogic tick callback --- -### Phase 4: Extract Boss Battle System (MEDIUM PRIORITY) - -**Goal**: Move all boss battle logic to separate module - -**New File**: `src/logic/bossSystem.ts` +### 1.3 Extract Plate Catching System +**New File**: `src/logic/plateSystem.ts` -**Functions to Create**: +**Functions to extract**: ```typescript -// Boss battle management -checkBossLevelTrigger( - currentLevel: number, - defeatedLevels: number[] -): number | null - -initializeBossBattle( - level: number, - now: number -): BossBattle - -processBossBattleTick( - state: GameState, - now: number -): GameState - -processMinionMovement( - minions: BossMinion[], - speed: number -): BossMinion[] - -processBossCollisions( - state: GameState, - slices: PizzaSlice[] +// Process chef catching plates +processPlateCatching( + chefLane: number, + chefX: number, + plates: EmptyPlate[], + stats: GameStats, + dogeMultiplier: number ): { - updatedState: GameState; - consumedSliceIds: Set; + caughtPlateIds: string[]; + newStats: GameStats; scores: Array<{ points: number; lane: number; position: number }>; } -checkWaveCompletion( - minions: BossMinion[], - currentWave: number, - maxWaves: number -): { nextWave?: number; bossVulnerable?: boolean } +// Update plate positions +updatePlatePositions(plates: EmptyPlate[]): EmptyPlate[] -spawnBossWave( - waveNumber: number, - now: number -): BossMinion[] +// Clean up off-screen plates +cleanupPlates(plates: EmptyPlate[]): EmptyPlate[] ``` -**Benefits**: -- Removes ~100 lines from main game loop -- Makes boss battles easier to extend -- Enables testing boss logic independently -- Clear separation of concerns - -**Lines Removed**: ~100 -**Estimated Effort**: 5-6 hours +**Lines to move**: ~40 lines --- -### Phase 5: Extract Level & Progression System (MEDIUM PRIORITY) - -**Goal**: Centralize level progression and store triggers - -**New File**: `src/logic/progressionSystem.ts` +### 1.4 Consolidate updateGame Function +After extractions, `updateGame` should become: -**Functions to Create**: ```typescript -// Level and progression -calculateLevel(score: number): number - -checkLevelUp( - oldLevel: number, - newLevel: number -): { leveledUp: boolean; newLevel: number } - -checkStoreTrigger( - level: number, - lastStoreLevel: number, - storeInterval: number -): boolean - -checkBossTrigger( - level: number, - defeatedLevels: number[], - triggerLevels: number[] -): number | null +const updateGame = useCallback(() => { + setGameState(prev => { + if (prev.gameOver || prev.paused) return prev; + + let state = { ...prev }; + const now = Date.now(); + + // 1. Process ovens (already extracted) + const ovenResult = processOvenTick(...); + state = applyOvenResult(state, ovenResult); + + // 2. Update entity positions + state = updateCustomerPositions(state, now); + state = updateSlicePositions(state); + state = updatePlatePositions(state); + state = updatePowerUpPositions(state); + + // 3. Process collisions + state = processSliceCollisions(state, now); + state = processPlateCatching(state); + state = processPowerUpCollection(state, now); + + // 4. Process special systems + if (state.nyanSweep?.active) { + state = processNyanSweep(state, now); + } + if (state.bossBattle?.active) { + state = processBossTick(state, now); + } + + // 5. Cleanup and spawning + state = cleanupEntities(state, now); + state = processSpawning(state, now); + + // 6. Check level/game state + state = checkLevelProgression(state); + + return state; + }); +}, []); ``` -**Benefits**: -- Simplifies main game loop -- Makes progression rules configurable -- Easier to add new progression features -- Single place to adjust level thresholds - -**Lines Removed**: ~30 -**Estimated Effort**: 2-3 hours +**Target**: Reduce updateGame from ~500 lines to ~50 lines --- -### Phase 6: Extract Entity Management System (LOW PRIORITY) - -**Goal**: Centralize entity spawning and cleanup +## Phase 2: Power-Up Consolidation (MEDIUM PRIORITY) -**New File**: `src/logic/entitySystem.ts` +### Problem +Power-up effects are implemented in 3 places: +- `powerUpSystem.ts` (production) +- `useGameLogic.ts debugActivatePowerUp` (debug) +- `customerSystem.ts` (effect application) -**Functions to Create**: -```typescript -// Entity spawning -spawnCustomer( - level: number, - now: number, - lastSpawn: number -): Customer | null +### Solution +Create single source of truth: -spawnPowerUp( - now: number, - lastSpawn: number -): PowerUp | null +**Update**: `src/logic/powerUpSystem.ts` -// Entity cleanup -cleanupExpiredEntities( +```typescript +// Single function to apply any power-up effect +applyPowerUpEffect( state: GameState, + powerUpType: PowerUpType, now: number ): GameState -// Entity movement -updateEntityPositions( - state: GameState -): GameState +// Remove duplicate implementations from: +// - debugActivatePowerUp in useGameLogic.ts +// - Inline effect logic scattered throughout ``` -**Benefits**: -- Centralizes spawn logic -- Makes spawn rates easier to tune -- Cleaner main game loop -- Consistent entity management +### Also +- Delete unused `checkStarPowerAutoFeed()` function +- Consolidate ice-cream/honey conflict resolution logic -**Lines Removed**: ~80 -**Estimated Effort**: 3-4 hours +**Lines removed**: ~50 duplicate lines --- -### Phase 7: Refactor useGameLogic Hook (FINAL PHASE) +## Phase 3: Customer Type Refactor (MEDIUM PRIORITY) + +### Problem +Customer interface has 26 properties with overlapping boolean flags: +- `woozy`, `woozyState`, `frozen`, `unfrozenThisPeriod` +- `hotHoneyAffected`, `shouldBeFrozen`, `woozySpeedModifier` +- `served`, `leaving`, `disappointed`, `vomit` -**Goal**: Transform hook into orchestrator +### Solution +Introduce state machine pattern: -**New Structure**: ```typescript -export const useGameLogic = (gameStarted: boolean) => { - // State management (keep) - const [gameState, setGameState] = useState(...); - const [ovenSoundStates, setOvenSoundStates] = useState(...); - - // Helper functions (keep minimal) - const triggerGameOver = useCallback(...); - const addFloatingScore = useCallback(...); - - // Main game loop - now much simpler - const updateGame = useCallback(() => { - setGameState(prev => { - if (prev.gameOver) return handleGameOverState(prev); - if (prev.paused) return prev; - - let state = { ...prev }; - const now = Date.now(); - - // Orchestrate systems - state = processOvenTick(state, ovenSoundStates, now); - state = updateCustomerPositions(state, now); - state = processCollisions(state, now); - state = processPowerUps(state, now); - state = processBossBattle(state, now); - state = processLevelProgression(state); - state = cleanupEntities(state, now); - state = spawnEntities(state, now); - - return state; - }); - }, [dependencies]); - - // Action handlers (keep) - const servePizza = useCallback(...); - const moveChef = useCallback(...); - const useOven = useCallback(...); - // etc. - - return { gameState, actions... }; +// New types +type CustomerState = + | 'approaching' + | 'served' + | 'disappointed' + | 'leaving' + | 'vomit'; + +type CustomerEffect = { + type: 'frozen' | 'woozy' | 'honey'; + startTime: number; + endTime: number; }; -``` - -**Target Size**: ~250-300 lines (down from 1062) -**Benefits**: -- Much easier to understand -- Each system can be tested independently -- Easier to add new features -- Better performance (smaller re-renders) -- Clear separation of concerns - -**Lines Removed**: ~600-700 (after all extractions) -**Estimated Effort**: 4-6 hours - ---- - -## Implementation Order - -### Recommended Sequence: +// Simplified Customer interface +interface Customer { + id: string; + lane: number; + position: number; + speed: number; + + state: CustomerState; + effects: CustomerEffect[]; + + // Special types + variant: 'normal' | 'critic' | 'badLuckBrian'; + + // UI state (separate concern) + ui: { + textMessage?: string; + textMessageTime?: number; + emoji?: string; + }; +} +``` -1. **Phase 1: Scoring System** ⭐ (Start here - biggest impact) -2. **Phase 2: Collision System** ⭐ (Enables other refactors) -3. **Phase 3: Power-Up System** ⭐ (Uses collision system) -4. **Phase 4: Boss Battle System** (Uses collision system) -5. **Phase 5: Progression System** (Quick win) -6. **Phase 6: Entity Management** (Cleanup) -7. **Phase 7: Refactor Hook** (Final integration) +### Migration +1. Create new types alongside existing +2. Add adapter functions +3. Gradually migrate components +4. Remove old properties --- -## Testing Strategy +## Phase 4: App.tsx Modal State (LOW PRIORITY) -### For Each System: +### Problem +6 separate useState hooks for screen visibility: +```typescript +const [showSplash, setShowSplash] = useState(true); +const [showInstructions, setShowInstructions] = useState(false); +const [showHighScores, setShowHighScores] = useState(false); +const [showGameOver, setShowGameOver] = useState(false); +// etc. +``` -1. **Unit Tests**: Test pure functions with various inputs -2. **Edge Cases**: Empty arrays, null values, boundary conditions -3. **Integration Tests**: Test system interactions -4. **Game State Tests**: Test with mock game states +### Solution +Single state enum: -### Example Test Structure: ```typescript -// logic/scoringSystem.test.ts -describe('scoringSystem', () => { - describe('calculateCustomerScore', () => { - it('calculates normal customer score correctly', () => { - const result = calculateCustomerScore( - normalCustomer, - 1, // no doge - 1 // no streak - ); - expect(result.points).toBe(150); - expect(result.bank).toBe(1); - }); - - it('applies doge multiplier', () => { - const result = calculateCustomerScore(normalCustomer, 2, 1); - expect(result.points).toBe(300); - }); - - it('applies streak multiplier', () => { - const result = calculateCustomerScore(normalCustomer, 1, 1.5); - expect(result.points).toBe(225); - }); - }); -}); +type ScreenState = + | 'splash' + | 'game' + | 'paused' + | 'instructions' + | 'highScores' + | 'store' + | 'gameOver'; + +const [screen, setScreen] = useState('splash'); + +// Helper for transitions +const navigateTo = (next: ScreenState) => { + // Handle any cleanup/side effects + setScreen(next); +}; ``` ---- +**Benefits**: +- Impossible to have conflicting states +- Clearer state transitions +- Easier to add new screens -## Migration Strategy +--- -### Incremental Approach: +## Phase 5: Constants Cleanup (LOW PRIORITY) -1. ✅ Create new system file -2. ✅ Write tests for new system -3. ✅ Extract logic from `useGameLogic.ts` -4. ✅ Update `useGameLogic.ts` to use new system -5. ✅ Test game still works -6. ✅ Remove old code -7. ✅ Repeat for next system +### Issues Found +- Magic numbers scattered (lane tolerances, position buffers) +- Some constants defined but not imported where needed -### Safety Measures: +### New Constants to Add +```typescript +// src/lib/constants.ts + +export const COLLISION_CONFIG = { + NYAN_LANE_TOLERANCE: 0.8, + NYAN_POSITION_BUFFER: 10, + SLICE_CUSTOMER_THRESHOLD: 8, + CHEF_POWERUP_THRESHOLD: 5, + PLATE_CATCH_THRESHOLD: 10, +}; -- Keep old code until new system is proven -- Use feature flags if needed -- Test thoroughly after each phase -- Git commits after each working phase -- Test on both desktop and mobile +export const NYAN_CONFIG = { + MAX_X: 90, + DURATION: 2600, + SPEED: 35, +}; +``` --- -## Expected Outcomes - -### Code Metrics: -- **useGameLogic.ts**: 1062 lines → ~250 lines (76% reduction) -- **New logic files**: ~800 lines total (well-organized) -- **Test coverage**: 0% → 60%+ (for logic systems) -- **Duplication**: ~200 lines → 0 lines - -### Benefits: -- ✅ Easier to understand and maintain -- ✅ Easier to test individual systems -- ✅ Easier to add new features -- ✅ Better performance (smaller components) -- ✅ Better code reusability -- ✅ Easier onboarding for new developers -- ✅ Single source of truth for game rules - -### Risks: -- ⚠️ Initial time investment (30-40 hours total) -- ⚠️ Potential bugs during migration -- ⚠️ Need to update tests -- ⚠️ Temporary code duplication during migration +## Implementation Order + +| Phase | Priority | Effort | Impact | +|-------|----------|--------|--------| +| 1.1 Boss System | High | 4-5 hrs | -100 lines | +| 1.2 Spawn System | High | 3-4 hrs | -80 lines | +| 1.3 Plate System | High | 2-3 hrs | -40 lines | +| 1.4 Consolidate updateGame | High | 3-4 hrs | Major clarity | +| 2 Power-Up Consolidation | Medium | 2-3 hrs | -50 lines, fewer bugs | +| 3 Customer Type | Medium | 6-8 hrs | Major clarity | +| 4 Modal State | Low | 1-2 hrs | Minor clarity | +| 5 Constants | Low | 1 hr | Minor clarity | --- -## Timeline Estimate +## Success Criteria -### Conservative (with testing): -- Phase 1: 1 week -- Phase 2: 3-4 days -- Phase 3: 1 week -- Phase 4: 1 week -- Phase 5: 2-3 days -- Phase 6: 3-4 days -- Phase 7: 1 week +- [ ] `useGameLogic.ts` under 400 lines (currently 883) +- [ ] `updateGame` function under 100 lines (currently ~484) +- [x] No duplicate power-up effect logic +- [ ] All magic numbers in constants +- [x] Boss system fully extracted and tested +- [x] Spawn system fully extracted and tested +- [x] Plate system fully extracted and tested +- [x] Integrated test suite covering core systems (32 tests passing) +- [x] Customer type helpers implemented (Phase 3) -**Total**: ~6-7 weeks (part-time) or 2-3 weeks (full-time) +--- -### Aggressive (minimal testing): -- All phases: 2-3 weeks (full-time) +## Completed in This Session ---- +- [x] Fixed cook time bug in MobileGameControls +- [x] Created shared `getOvenDisplayStatus()` utility +- [x] Both GameBoard and MobileGameControls now use consistent oven status logic +- [x] Star power now auto-refills pizza slices +- [x] Star power allows pulling pizza from oven with no room +- [x] Cumulative upgrade pricing ($10, $20, $30...) +- [x] Local storage fallback for high scores +- [x] Pizza confetti for top 10 scores +- [x] Disabled game board tap controls (kept mobile buttons) +- [x] Doge alert 1/3 size on mobile -## Alternative: Quick Wins +## Phase 1 Refactoring Complete -If full refactor is too much, prioritize: +- [x] **1.1 Boss System** - Extracted to `bossSystem.ts` (~280 lines) +- [x] **1.2 Spawn System** - Extracted to `spawnSystem.ts` (~140 lines) +- [x] **1.3 Plate System** - Extracted to `plateSystem.ts` (~75 lines) +- [x] **1.4 Consolidation** - Reduced useGameLogic from 1045 to 923 lines (-122 lines) -1. **Extract Scoring System** (Phase 1) - Biggest impact, removes most duplication -2. **Integrate Power-Up System** (Phase 3) - Uses existing code -3. **Extract Boss System** (Phase 4) - Large chunk of code +### Notes on Further Reduction +The remaining large section in updateGame is the slice-customer collision loop (~130 lines). This is tightly coupled to: +- Sound effects (multiple soundManager calls) +- Customer state transitions (woozy, frozen, served) +- Scoring and life gain calculations +- Stats tracking -These three alone would reduce `useGameLogic.ts` by ~400-500 lines. +Extracting this would require a complex result object and careful handling of side effects. Consider for a future refactoring phase. ---- +## Phase 2 Refactoring Complete -## Code Quality Improvements +- [x] Deleted unused `checkStarPowerAutoFeed()` function (-26 lines from powerUpSystem.ts) +- [x] Consolidated `debugActivatePowerUp` to use `processPowerUpCollection` (-40 lines from useGameLogic.ts) +- [x] Total reduction: **-66 lines** -### After Refactoring: +## Integrated Test Suite Created -- **Single Responsibility**: Each system has one clear purpose -- **DRY Principle**: No duplicated scoring/collision logic -- **Testability**: Pure functions easy to test -- **Maintainability**: Changes isolated to specific systems -- **Readability**: Clear system boundaries -- **Extensibility**: Easy to add new features +Added vitest test suite covering core game logic systems: ---- +### Test Files +- `src/logic/customerSystem.test.ts` - 23 tests +- `src/logic/powerUpSystem.test.ts` - 5 tests +- `src/logic/nyanSystem.test.ts` - 4 tests -## Notes +### Coverage +- **Customer Movement**: approaching, leaving, off-screen removal +- **Customer Disappointment**: reaching chef, life loss events +- **Frozen Effect (Ice Cream)**: freeze activation, Brian immunity, no movement when frozen +- **Hot Honey Effect**: speed reduction, critic immunity ("Just plain, thanks."), Brian immunity ("I can't do spicy.") +- **Woozy Movement**: bidirectional swaying +- **processCustomerHit**: normal serve, critic serve, Brian drops, frozen unfreeze, woozy 2-step process +- **Bad Luck Brian**: special movement, complaint on reaching chef (no life loss) +- **Nyan Cat Effect**: customer push (brianNyaned) +- **Power-Up Expiration**: removal and star power detection +- **Power-Up Collection**: timed activation, star power effects, beer+woozy=vomit +- **Nyan Sweep**: movement and collision detection -- Keep `useGameLogic.ts` as the orchestrator - don't over-engineer -- Systems should be pure functions where possible -- Use TypeScript strictly - catch errors early -- Document each system's responsibilities -- Consider using a state machine library if complexity grows -- Maintain backward compatibility during migration +### Configuration +- vitest.config.ts with globals and node environment +- Run with: `npx vitest run` ---- +## Phase 3 Customer Type Refactor Complete -## Success Criteria +Added new customer state machine types and helper functions for cleaner code: -- ✅ `useGameLogic.ts` under 300 lines -- ✅ No duplicated scoring logic -- ✅ All systems have unit tests -- ✅ Game functionality unchanged -- ✅ Performance maintained or improved -- ✅ Code is easier to understand +### New Types Added (game.ts) +```typescript +export type CustomerState = 'approaching' | 'served' | 'disappointed' | 'leaving' | 'vomit'; +export type CustomerVariant = 'normal' | 'critic' | 'badLuckBrian'; +export type WoozyState = 'normal' | 'drooling' | 'satisfied'; +``` +### Helper Functions Added (game.ts) +- `isCustomerLeaving(c)` - Check if customer is in any departure state +- `isCustomerApproaching(c)` - Check if customer is still approaching +- `getCustomerVariant(c)` - Get customer type: normal, critic, or badLuckBrian +- `isCustomerAffectedByPowerUps(c)` - Check if customer can receive power-up effects + +### Files Updated +- **customerSystem.ts**: Uses `isCustomerLeaving`, `isCustomerAffectedByPowerUps`, `getCustomerVariant` +- **spawnSystem.ts**: Uses `CustomerVariant` type for cleaner customer creation +- **useGameLogic.ts**: Uses `isCustomerLeaving`, `getCustomerVariant` for collision handling +- **Customer.tsx**: Uses `getCustomerVariant` for cleaner display logic + +### Benefits +- Single source of truth for state checks +- Clearer code intent (e.g., `isCustomerLeaving(c)` vs `c.served || c.disappointed || ...`) +- Type safety for customer variants +- Foundation for future state machine migration +- All 32 tests still passing diff --git a/src/App.tsx b/src/App.tsx index 38f7b4f..c5cec5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,7 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import GameBoard from './components/GameBoard'; import ScoreBoard from './components/ScoreBoard'; -import LandscapeGameBoard from './components/LandscapeGameBoard'; -import LandscapeScoreBoard from './components/LandscapeScoreBoard'; -import LandscapeControls from './components/LandscapeControls'; import MobileGameControls from './components/MobileGameControls'; -import InstructionsModal from './components/InstructionsModal'; import SplashScreen from './components/SplashScreen'; import GameOverScreen from './components/GameOverScreen'; import HighScores from './components/HighScores'; @@ -14,24 +10,39 @@ import PowerUpAlert from './components/PowerUpAlert'; import StreakDisplay from './components/StreakDisplay'; import DebugPanel from './components/DebugPanel'; import ControlsOverlay from './components/ControlsOverlay'; +import PauseMenu from './components/PauseMenu'; import { useGameLogic } from './hooks/useGameLogic'; +import { useAssetPreloader } from './hooks/useAssetPreloader'; import { bg } from './lib/assets'; +import { soundManager } from './utils/sounds'; const counterImg = bg('counter.png'); function App() { const [showGameOver, setShowGameOver] = useState(false); const [showHighScores, setShowHighScores] = useState(false); - const [showInstructions, setShowInstructions] = useState(false); const [showSplash, setShowSplash] = useState(true); const [showControlsOverlay, setShowControlsOverlay] = useState(false); + const [controlsOpenedFromPause, setControlsOpenedFromPause] = useState(false); + const [showPauseMenu, setShowPauseMenu] = useState(false); const [gameStarted, setGameStarted] = useState(false); const [isLandscape, setIsLandscape] = useState(false); const [isMobile, setIsMobile] = useState(false); + const [isMuted, setIsMuted] = useState(soundManager.checkMuted()); const [marbleTop, setMarbleTop] = useState(0); const gameBoardRef = useRef(null); const SHOW_DEBUG = false; + // Preload game assets + const { progress: assetProgress, isComplete: assetsReady, failedAssets } = useAssetPreloader(); + + // Log failed assets in development + useEffect(() => { + if (failedAssets.length > 0) { + console.warn('Some assets failed to load:', failedAssets); + } + }, [failedAssets]); + const { gameState, servePizza, @@ -48,6 +59,25 @@ function App() { debugActivatePowerUp, } = useGameLogic(gameStarted); + // Custom pause handler - shows pause menu overlay + const handlePauseToggle = () => { + if (showPauseMenu) { + // Closing pause menu + setShowPauseMenu(false); + // Only toggle game pause if store isn't open (store handles its own pause) + if (!gameState.showStore && gameState.paused) { + togglePause(); + } + } else { + // Opening pause menu + setShowPauseMenu(true); + // Only toggle game pause if not already paused (store might have paused it) + if (!gameState.paused) { + togglePause(); + } + } + }; + // ---- Refs to avoid stale closures + re-binding keyboard handler every tick ---- const gameStateRef = useRef(gameState); useEffect(() => { @@ -59,20 +89,28 @@ function App() { moveChef, useOven, cleanOven, - togglePause, + handlePauseToggle, resetGame, }); useEffect(() => { - actionsRef.current = { servePizza, moveChef, useOven, cleanOven, togglePause, resetGame }; - }, [servePizza, moveChef, useOven, cleanOven, togglePause, resetGame]); + actionsRef.current = { servePizza, moveChef, useOven, cleanOven, handlePauseToggle, resetGame }; + }, [servePizza, moveChef, useOven, cleanOven, handlePauseToggle, resetGame]); useEffect(() => { if (gameState.gameOver && !showGameOver && !showHighScores) { setShowGameOver(true); + setShowPauseMenu(false); } }, [gameState.gameOver, showGameOver, showHighScores]); + // Close pause menu when game is unpaused externally + useEffect(() => { + if (!gameState.paused && !gameState.showStore && showPauseMenu) { + setShowPauseMenu(false); + } + }, [gameState.paused, gameState.showStore, showPauseMenu]); + const handleStartGame = () => { setShowSplash(false); setGameStarted(true); @@ -88,12 +126,53 @@ function App() { const handleCloseControlsOverlay = () => { setShowControlsOverlay(false); - // Unpause the game - if (gameState.paused && !gameState.gameOver) { + // Only unpause if controls weren't opened from the pause menu + if (!controlsOpenedFromPause && gameState.paused && !gameState.gameOver) { togglePause(); } + setControlsOpenedFromPause(false); }; + // Pause menu action handlers + const handlePauseResume = useCallback(() => { + handlePauseToggle(); + }, [handlePauseToggle]); + + const handlePauseReset = useCallback(() => { + resetGame(); + setShowPauseMenu(false); + }, [resetGame]); + + const handlePauseToggleMute = useCallback(() => { + setIsMuted(soundManager.toggleMute()); + }, []); + + const handlePauseShowScores = useCallback(() => { + setShowPauseMenu(false); + setShowHighScores(true); + }, []); + + const handlePauseShowHelp = useCallback(() => { + setControlsOpenedFromPause(true); + setShowControlsOverlay(true); + }, []); + + // Handle Enter/Escape to close high scores view + useEffect(() => { + if (!showHighScores || gameState.gameOver) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === 'Escape') { + e.preventDefault(); + setShowHighScores(false); + setShowPauseMenu(true); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [showHighScores, gameState.gameOver]); + useEffect(() => { const checkOrientation = () => { const mobile = window.innerWidth < 1000; @@ -141,11 +220,37 @@ function App() { // NOTE: you had [isMobile, gameState]; keeping it to preserve behavior, but it's heavier than needed. }, [isMobile, gameState]); + // Track menu state for space bar blocking + const menuStateRef = useRef({ showSplash, showGameOver, showHighScores, showControlsOverlay, showPauseMenu, showStore: gameState.showStore }); useEffect(() => { - if (showInstructions && !gameState.paused && gameStarted && !gameState.gameOver) { - togglePause(); - } - }, [showInstructions, gameStarted, gameState.paused, gameState.gameOver, togglePause]); + menuStateRef.current = { showSplash, showGameOver, showHighScores, showControlsOverlay, showPauseMenu, showStore: gameState.showStore }; + }, [showSplash, showGameOver, showHighScores, showControlsOverlay, showPauseMenu, gameState.showStore]); + + // Prevent space bar from triggering button clicks when menus are showing + useEffect(() => { + const preventSpaceInMenus = (event: KeyboardEvent) => { + if (event.key !== ' ') return; + + // Skip if user is typing in an input + const target = event.target as HTMLElement; + if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') { + return; + } + + // Block space bar if any menu/overlay is showing + const { showSplash, showGameOver, showHighScores, showControlsOverlay, showPauseMenu, showStore } = menuStateRef.current; + if (showSplash || showGameOver || showHighScores || showControlsOverlay || showPauseMenu || showStore) { + event.preventDefault(); + event.stopPropagation(); + } + }; + document.addEventListener('keydown', preventSpaceInMenus, { capture: true }); + document.addEventListener('keyup', preventSpaceInMenus, { capture: true }); + return () => { + document.removeEventListener('keydown', preventSpaceInMenus, { capture: true }); + document.removeEventListener('keyup', preventSpaceInMenus, { capture: true }); + }; + }, []); // ✅ Stable keyboard listener (no re-bind every tick) useEffect(() => { @@ -153,7 +258,7 @@ function App() { const gs = gameStateRef.current; const a = actionsRef.current; - if (!gameStarted || showInstructions) return; + if (!gameStarted) return; // Optional: block input when overlays/modals are up if (showControlsOverlay || showHighScores || showGameOver || gs.showStore) return; @@ -186,7 +291,7 @@ function App() { event.preventDefault(); a.servePizza(); } else if (event.key === 'p' || event.key === 'P') { - a.togglePause(); + a.handlePauseToggle(); } else if (event.key === 'r' || event.key === 'R') { a.resetGame(); } @@ -194,73 +299,110 @@ function App() { window.addEventListener('keydown', handleKeyDown, { passive: false }); return () => window.removeEventListener('keydown', handleKeyDown as any); - }, [gameStarted, showInstructions, showControlsOverlay, showHighScores, showGameOver]); - - const handleGameBoardClick = (event: React.MouseEvent) => { - if (!gameStarted || gameState.gameOver || gameState.paused || gameState.showStore) return; - - const rect = event.currentTarget.getBoundingClientRect(); - const x = event.clientX - rect.left; - const y = event.clientY - rect.top; - const relativeX = x / rect.width; - const relativeY = y / rect.height; - - const laneHeight = 0.25; - const chefY = gameState.chefLane * laneHeight + 0.06; - const counterX = 0.42; - - if (relativeX > counterX) { - servePizza(); - } else if (relativeX < counterX) { - if (relativeY >= chefY && relativeY <= chefY + laneHeight) { - const currentOven = gameState.ovens[gameState.chefLane]; - if (currentOven.burned) { - cleanOven(); - } else { - useOven(); - } - } else if (relativeY < chefY && gameState.chefLane > 0) { - moveChef('up'); - } else if (relativeY > chefY + laneHeight && gameState.chefLane < 3) { - moveChef('down'); - } - } - }; + }, [gameStarted, showControlsOverlay, showHighScores, showGameOver]); + + // Game board click controls disabled - keyboard only + // const handleGameBoardClick = (event: React.MouseEvent) => { + // if (!gameStarted || gameState.gameOver || gameState.paused || gameState.showStore) return; + // ... + // }; if (showSplash) { - return ; + return ( + + ); } if (isLandscape) { return (
-
- + {/* Landscape layout: controls on sides, game board centered */} +
+ {/* Center area for ScoreBoard and GameBoard */} +
+ {/* ScoreBoard at top - compact mode for landscape */} +
+ +
- {gameState.powerUpAlert && ( - - )} + {/* GameBoard - maintains 5:3 aspect ratio, scales to fit */} +
+ + + {gameState.powerUpAlert && !gameState.paused && ( + + )} + + {gameState.cleanKitchenBonusAlert && !gameState.paused && ( +
+
+
+ ✨ Clean Kitchen Bonus! ✨ +
+
+
+ )} - {!gameState.gameOver && !gameState.paused && !gameState.showStore && } + {!gameState.gameOver && !gameState.paused && !gameState.showStore && } - setShowInstructions(true)} /> - moveChef('up')} - onMoveDown={() => moveChef('down')} - onServePizza={servePizza} - onUseOven={useOven} - onCleanOven={cleanOven} - currentLane={gameState.chefLane} - availableSlices={gameState.availableSlices} - ovens={gameState.ovens} - ovenSpeedUpgrades={gameState.ovenSpeedUpgrades} - /> + {showControlsOverlay && } + + {!gameState.gameOver && !showControlsOverlay && ( + + )} + + {gameState.showStore && ( +
+ +
+ )} +
+
+ + {/* Mobile controls on sides */} + {!gameState.gameOver && !showHighScores && !gameState.showStore && ( + moveChef('up')} + onMoveDown={() => moveChef('down')} + onServePizza={servePizza} + onUseOven={useOven} + onCleanOven={cleanOven} + currentLane={gameState.chefLane} + availableSlices={gameState.availableSlices} + ovens={gameState.ovens} + ovenSpeedUpgrades={gameState.ovenSpeedUpgrades} + isLandscape={true} + /> + )} {gameState.gameOver && showGameOver && ( -
+
)} - {showControlsOverlay && } - - {gameState.paused && !gameState.gameOver && !gameState.showStore && !showControlsOverlay && ( -
-
-

Paused

-

Tap to continue

- -
-
- )} - - {gameState.showStore && ( -
- -
- )} - - {showInstructions && ( - setShowInstructions(false)} - onReset={() => { - resetGame(); - setShowHighScores(false); - setShowGameOver(false); - }} - onShowHighScores={() => { - setShowHighScores(true); - setShowInstructions(false); - }} - onResume={() => { - if (gameState.paused && !gameState.gameOver) { - togglePause(); - } - }} - /> - )} - {showHighScores && !gameState.gameOver && (
@@ -358,37 +445,43 @@ function App() { } ${isMobile ? 'relative' : ''}`} >
- setShowInstructions(true)} /> +
- {gameState.powerUpAlert && ( + {gameState.powerUpAlert && !gameState.paused && ( )} + {gameState.cleanKitchenBonusAlert && !gameState.paused && ( +
+
+
+ ✨ Clean Kitchen Bonus! ✨ +
+
+
+ )} + {!gameState.gameOver && !gameState.paused && !gameState.showStore && } {showControlsOverlay && } - {gameState.paused && !gameState.gameOver && !gameState.showStore && !showControlsOverlay && ( -
-
-

Paused

-

Press Space or tap to continue

- -
-
+ {!gameState.gameOver && !showControlsOverlay && ( + )} {gameState.showStore && ( @@ -442,46 +535,21 @@ function App() {
)} - {showInstructions && ( - setShowInstructions(false)} - onReset={() => { - resetGame(); - setShowHighScores(false); - setShowGameOver(false); - }} - onShowHighScores={() => { - setShowHighScores(true); - setShowInstructions(false); - }} - onResume={() => { - if (gameState.paused && !gameState.gameOver) { - togglePause(); - } - }} - /> - )} - {showHighScores && !gameState.gameOver && (
)} - {isMobile && !gameState.gameOver && !showInstructions && !showHighScores && !gameState.showStore && ( + {isMobile && !gameState.gameOver && !showHighScores && !gameState.showStore && ( )} + + {/* GitHub + Google Sheets links - desktop only, light brown */} + {!isMobile && ( + + )}
); diff --git a/src/components/Boss.tsx b/src/components/Boss.tsx index 4261e52..6adc7e4 100644 --- a/src/components/Boss.tsx +++ b/src/components/Boss.tsx @@ -1,8 +1,31 @@ import React from 'react'; import { BossBattle } from '../types/game'; import { sprite } from '../lib/assets'; +import { PAPA_JOHN_CONFIG, DOMINOS_CONFIG } from '../lib/constants'; -const bossImg = sprite("dominos-boss.png"); +const dominosBossImg = sprite("dominos-boss.png"); +const papaJohnSprites = [ + sprite("papa-john.png"), // Encounter 1 (level 10) + sprite("papa-john-2.png"), // Encounter 2 (level 20) + sprite("papa-john-3.png"), // Encounter 3 (level 40) + sprite("papa-john-4.png"), // Encounter 4 (level 50) + sprite("papa-john-5.png"), // Encounter 5 (level 60) + sprite("papa-john-6.png"), // Encounter 6 (level 70) +]; + +const getBossSprite = (bossBattle: BossBattle): string => { + if (bossBattle.bossType === 'dominos') { + return dominosBossImg; + } + // Papa John - select based on hits received (changes every 8 hits) + const hits = bossBattle.hitsReceived || 0; + const spriteIndex = Math.min(Math.floor(hits / 8), papaJohnSprites.length - 1); + return papaJohnSprites[spriteIndex]; +}; + +const getBossConfig = (bossBattle: BossBattle) => { + return bossBattle.bossType === 'papaJohn' ? PAPA_JOHN_CONFIG : DOMINOS_CONFIG; +}; interface BossProps { bossBattle: BossBattle; @@ -11,21 +34,24 @@ interface BossProps { const Boss: React.FC = ({ bossBattle }) => { if (!bossBattle.active && !bossBattle.bossDefeated) return null; + const bossSprite = getBossSprite(bossBattle); + const config = getBossConfig(bossBattle); + return ( <> {!bossBattle.bossDefeated && (
boss = ({ bossBattle }) => {
- HP: {bossBattle.bossHealth}/8 + HP: {bossBattle.bossHealth}/{config.HEALTH}
)} {!bossBattle.bossVulnerable && (
- Wave {bossBattle.currentWave}/3 + Wave {bossBattle.currentWave}/{config.WAVES}
)}
)} {bossBattle.minions.map(minion => { - if (minion.defeated) return null; - return ( -
- minion -
- ); -})} + if (minion.defeated) return null; + return ( +
+ minion +
+ ); + })} ); }; diff --git a/src/components/ControlsOverlay.tsx b/src/components/ControlsOverlay.tsx index 610f221..5889525 100644 --- a/src/components/ControlsOverlay.tsx +++ b/src/components/ControlsOverlay.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { ui } from '../lib/assets'; interface ControlsOverlayProps { @@ -8,6 +8,19 @@ interface ControlsOverlayProps { const ControlsOverlay: React.FC = ({ onClose }) => { const controls = ui("controls.png"); + // Close on Escape key or Enter key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' || e.key === 'Enter') { + e.preventDefault(); + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + const handleImageClick = (event: React.MouseEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const x = event.clientX - rect.left; @@ -23,8 +36,8 @@ const ControlsOverlay: React.FC = ({ onClose }) => { return (
-
= ({ onClose }) => { alt="Game Controls" className="w-full h-auto rounded-lg shadow-2xl" /> - {/* Visual indicator for close area (optional, can be removed) */} - {/* -
- */}
); diff --git a/src/components/Customer.tsx b/src/components/Customer.tsx index fb29a0a..222c837 100644 --- a/src/components/Customer.tsx +++ b/src/components/Customer.tsx @@ -1,6 +1,6 @@ // src/components/Customer.tsx import React from 'react'; -import { Customer as CustomerType } from '../types/game'; +import { Customer as CustomerType, getCustomerVariant } from '../types/game'; import { sprite } from '../lib/assets'; // Sprites (all hosted on Cloudflare) @@ -13,6 +13,8 @@ const criticImg = sprite("critic.png"); const badLuckBrianImg = sprite("bad-luck-brian.png"); const badLuckBrianPukeImg = sprite("bad-luck-brian-puke.png"); const rainbowBrian = sprite("rainbow-brian.png"); +const scumbagSteveImg = sprite("scumbag-steve.png"); +const pizzaMafiaImg = sprite("pizza-mafia.png"); interface CustomerProps { customer: CustomerType; @@ -40,13 +42,28 @@ const Customer: React.FC = ({ customer, boardWidth, boardHeight } const textYPx = ((customer.lane * 25 + textYOffset) / 100) * boardHeight; const getDisplay = () => { - // 🌈 Rainbow Brian (nyan hit) — override everything else + const variant = getCustomerVariant(customer); + const isSpecialCustomer = variant === 'badLuckBrian' || variant === 'scumbagSteve' || variant === 'pizzaMafia'; + + // 🌈 Rainbow Brian (nyan hit) — special behavior override if (customer.brianNyaned) { return { type: 'image', value: rainbowBrian, alt: 'rainbow-brian' }; } + // Brian puke — special behavior override + if (customer.vomit && variant === 'badLuckBrian') { + return { type: 'image', value: badLuckBrianPukeImg, alt: 'brian-puke' }; + } + + // Special customers keep their base image (no powerup/status effects) + if (isSpecialCustomer) { + if (variant === 'badLuckBrian') return { type: 'image', value: badLuckBrianImg, alt: 'badluckbrian' }; + if (variant === 'scumbagSteve') return { type: 'image', value: scumbagSteveImg, alt: 'scumbagsteve' }; + if (variant === 'pizzaMafia') return { type: 'image', value: pizzaMafiaImg, alt: 'pizzamafia' }; + } + + // Status effects for normal customers and critics if (customer.frozen) return { type: 'image', value: frozenfaceImg, alt: 'frozen' }; - if (customer.vomit && customer.badLuckBrian) return { type: 'image', value: badLuckBrianPukeImg, alt: 'brian-puke' }; if (customer.vomit) return { type: 'emoji', value: '🤮' }; if (customer.woozy) { if (customer.woozyState === 'drooling') return { type: 'image', value: droolfaceImg, alt: 'drooling' }; @@ -55,8 +72,9 @@ const Customer: React.FC = ({ customer, boardWidth, boardHeight } if (customer.served) return { type: 'image', value: yumfaceImg, alt: 'yum' }; if (customer.disappointed) return { type: 'emoji', value: customer.disappointedEmoji || '😢' }; if (customer.hotHoneyAffected) return { type: 'image', value: spicyfaceImg, alt: 'spicy' }; - if (customer.badLuckBrian) return { type: 'image', value: badLuckBrianImg, alt: 'badluckbrian' }; - if (customer.critic) return { type: 'image', value: criticImg, alt: 'critic' }; + + // Base appearance by variant + if (variant === 'critic') return { type: 'image', value: criticImg, alt: 'critic' }; return { type: 'image', value: droolfaceImg, alt: 'drool' }; }; diff --git a/src/components/DroppedPlate.tsx b/src/components/DroppedPlate.tsx index 4066fda..ae39bce 100644 --- a/src/components/DroppedPlate.tsx +++ b/src/components/DroppedPlate.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useState } from 'react'; import { DroppedPlate as DroppedPlateType } from '../types/game'; -const slice1PlateImg = "https://i.imgur.com/XFdXriH.png"; +import { sprite } from '../lib/assets'; + +const slicePlateImg = sprite("slice-plate.png"); interface DroppedPlateProps { droppedPlate: DroppedPlateType; @@ -41,7 +43,7 @@ const DroppedPlate: React.FC = ({ droppedPlate }) => { opacity: visible ? 1 : 0, }} > - dropped plate + dropped plate
); }; diff --git a/src/components/EmptyPlate.tsx b/src/components/EmptyPlate.tsx index 32cc639..84d165e 100644 --- a/src/components/EmptyPlate.tsx +++ b/src/components/EmptyPlate.tsx @@ -1,44 +1,29 @@ import React from 'react'; import { EmptyPlate as EmptyPlateType } from '../types/game'; +import { sprite } from '../lib/assets'; + +const paperPlateImg = sprite("paperplate.png"); interface EmptyPlateProps { plate: EmptyPlateType; } -const LANDSCAPE_LANE_POSITIONS = [20, 40, 60, 80]; // match LandscapeCustomer & PizzaSlice +const OVEN_POSITION = 10; // Target X position (near the ovens) const EmptyPlate: React.FC = ({ plate }) => { - // Safe helpers (SSR-friendly) - const getIsLandscape = () => - typeof window !== 'undefined' ? window.innerWidth > window.innerHeight : true; - - const getIsMobile = () => - typeof navigator !== 'undefined' - ? /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || - (navigator as any).maxTouchPoints > 1 - : false; - - const [isLandscape, setIsLandscape] = React.useState(getIsLandscape); - const [isMobile, setIsMobile] = React.useState(getIsMobile); - - React.useEffect(() => { - const handleResize = () => { - setIsLandscape(getIsLandscape()); - setIsMobile(getIsMobile()); - }; - window.addEventListener('resize', handleResize); - window.addEventListener('orientationchange', handleResize); - return () => { - window.removeEventListener('resize', handleResize); - window.removeEventListener('orientationchange', handleResize); - }; - }, []); - - // Match PizzaSlice logic exactly - const topPercent = - isMobile && isLandscape - ? LANDSCAPE_LANE_POSITIONS[plate.lane] - : plate.lane * 25 + 6; + // Calculate visual lane for angled throws + let visualLane = plate.lane; + + if (plate.targetLane !== undefined && plate.startLane !== undefined && plate.startPosition !== undefined) { + // Interpolate lane based on horizontal progress + const totalDistance = plate.startPosition - OVEN_POSITION; + const traveled = plate.startPosition - plate.position; + const progress = Math.min(1, Math.max(0, traveled / totalDistance)); + + visualLane = plate.startLane + (plate.targetLane - plate.startLane) * progress; + } + + const topPercent = visualLane * 25 + 6; return (
= ({ plate }) => { > {/* Empty plate image */} empty plate void; +} + +export default function FloatingStar({ id, isGain, count = 1, lane, position, onComplete }: FloatingStarProps) { + const [yOffset, setYOffset] = useState(0); + const [opacity, setOpacity] = useState(1); + + useEffect(() => { + const startTime = Date.now(); + const duration = 2000; + + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + + setYOffset(progress * -30); + setOpacity(1 - progress); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + onComplete(id); + } + }; + + requestAnimationFrame(animate); + }, [id, onComplete]); + + const lanePosition = 15 + (lane * 22); + + return ( +
+ + {isGain ? '+' : '-'} + + {Array.from({ length: count }, (_, i) => ( + + ))} +
+ ); +} diff --git a/src/components/GameBoard.tsx b/src/components/GameBoard.tsx index 8172ed7..7ee582e 100644 --- a/src/components/GameBoard.tsx +++ b/src/components/GameBoard.tsx @@ -1,17 +1,24 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; import Customer from './Customer'; import PizzaSlice from './PizzaSlice'; +import MafiaSlice from './MafiaSlice'; import EmptyPlate from './EmptyPlate'; import DroppedPlate from './DroppedPlate'; import PowerUp from './PowerUp'; import PizzaSliceStack from './PizzaSliceStack'; import FloatingScore from './FloatingScore'; +import FloatingStar from './FloatingStar'; import Boss from './Boss'; +import PepeHelpers from './PepeHelpers'; import { GameState } from '../types/game'; import pizzaShopBg from '/pizza shop background v2.png'; import { sprite } from '../lib/assets'; +import { getOvenDisplayStatus } from '../logic/ovenSystem'; +import { OVEN_CONFIG, TIMINGS } from '../lib/constants'; const chefImg = sprite("chef.png"); +const sadChefImg = sprite("sad-chef.png"); +const nyanChefImg = sprite("nyan-chef.png"); interface GameBoardProps { gameState: GameState; @@ -19,8 +26,8 @@ interface GameBoardProps { const GameBoard: React.FC = ({ gameState }) => { const lanes = [0, 1, 2, 3]; - const [, forceUpdate] = React.useReducer(x => x + 1, 0); const [completedScores, setCompletedScores] = useState>(new Set()); + const [completedStars, setCompletedStars] = useState>(new Set()); // ✅ Measure board size (for px-based translate3d positioning) const boardRef = useRef(null); @@ -47,53 +54,31 @@ const GameBoard: React.FC = ({ gameState }) => { setCompletedScores(prev => new Set(prev).add(id)); }, []); - React.useEffect(() => { - const interval = setInterval(forceUpdate, 100); - return () => clearInterval(interval); + const handleStarComplete = useCallback((id: string) => { + setCompletedStars(prev => new Set(prev).add(id)); }, []); const getOvenStatus = (lane: number) => { const oven = gameState.ovens[lane]; - - if (oven.burned) { - if (oven.cleaningStartTime > 0) { - const cleaningElapsed = Date.now() - oven.cleaningStartTime; - const halfCleaning = 1500; // 1.5 seconds (half of 3 second cleaning time) - if (cleaningElapsed < halfCleaning) { - return 'extinguishing'; - } - return 'sweeping'; - } - return 'burned'; - } - - if (!oven.cooking) return 'empty'; - - // Use pausedElapsed if game is paused, otherwise calculate from startTime - const elapsed = oven.pausedElapsed !== undefined ? oven.pausedElapsed : Date.now() - oven.startTime; - - // Calculate cook time based on speed upgrades const speedUpgrade = gameState.ovenSpeedUpgrades[lane] || 0; - const cookingTime = - speedUpgrade === 0 ? 3000 : - speedUpgrade === 1 ? 2500 : - speedUpgrade === 2 ? 2000 : 1500; - - const warningTime = 7000; // 7 seconds (start blinking) - const burnTime = 8000; // 8 seconds total - const blinkInterval = 250; // 0.25 seconds + const baseStatus = getOvenDisplayStatus(oven, speedUpgrade); - if (elapsed >= burnTime) return 'burning'; + // Add visual enhancements for GameBoard display + if (baseStatus === 'cleaning') { + const cleaningElapsed = Date.now() - oven.cleaningStartTime; + const halfCleaning = OVEN_CONFIG.CLEANING_TIME / 2; + return cleaningElapsed < halfCleaning ? 'extinguishing' : 'sweeping'; + } - // Blinking phase (between 7-8 seconds) - if (elapsed >= warningTime) { - const warningElapsed = elapsed - warningTime; - const blinkCycle = Math.floor(warningElapsed / blinkInterval); + if (baseStatus === 'warning') { + // Blinking effect for warning state + const elapsed = oven.pausedElapsed !== undefined ? oven.pausedElapsed : Date.now() - oven.startTime; + const warningElapsed = elapsed - OVEN_CONFIG.WARNING_TIME; + const blinkCycle = Math.floor(warningElapsed / TIMINGS.WARNING_BLINK_INTERVAL); return blinkCycle % 2 === 0 ? 'warning-fire' : 'warning-pizza'; } - if (elapsed >= cookingTime) return 'ready'; - return 'cooking'; + return baseStatus; }; return ( @@ -154,7 +139,8 @@ const GameBoard: React.FC = ({ gameState }) => { })} {/* ✅ Chef (no scale(15), positioned directly on board) */} - {!gameState.nyanSweep?.active && ( + {/* Hide chef when paused (but show game over chef) */} + {!gameState.nyanSweep?.active && (!gameState.paused || gameState.gameOver) && (
= ({ gameState }) => { }} > {gameState.gameOver = ({ gameState }) => {
)} + {/* Pepe Helpers - Franco-Pepe and Frank-Pepe */} + + {/* Nyan Cat Chef - positioned directly on game board during sweep */} {gameState.nyanSweep?.active && (
= ({ gameState }) => { }} > nyan chef = ({ gameState }) => { ))} + {gameState.mafiaSlices.map((slice) => ( + + ))} + {gameState.emptyPlates.map((plate) => ( ))} @@ -271,6 +264,19 @@ const GameBoard: React.FC = ({ gameState }) => { /> ))} + {/* Floating star indicators */} + {gameState.floatingStars.filter(fs => !completedStars.has(fs.id)).map((floatingStar) => ( + + ))} + {/* Falling pizza when game over */} {gameState.fallingPizza && (
(null); const imagesRef = useRef({ splashLogo: null, @@ -117,6 +122,53 @@ export default function GameOverScreen({ stats, score, level, lastStarLostReason const skillRating = calculateSkillRating(stats, score, level); const gameId = useMemo(() => crypto.randomUUID(), []); const timestamp = new Date(); + + // Keyboard navigation for main scorecard view + // 0: Submit Score, 1: Leaderboard, 2: Play Again + const mainMenuActions = useMemo(() => [ + () => { /* form submit handled by form */ }, + () => setShowLeaderboard(true), + onPlayAgain, + ], [onPlayAgain]); + + const handleMainMenuSelect = useCallback((index: number) => { + if (index === 0) { + // Trigger form submit programmatically + const form = document.querySelector('form'); + if (form) form.requestSubmit(); + } else { + mainMenuActions[index]?.(); + } + }, [mainMenuActions]); + + const { selectedIndex: mainSelectedIndex, getItemProps: getMainItemProps } = useMenuKeyboardNav({ + itemCount: 3, + columns: 2, + onSelect: handleMainMenuSelect, + isActive: !showLeaderboard && !scoreSubmitted, + initialIndex: 0, + }); + + // Keyboard navigation for leaderboard view (after submission) + // 0: Back, 1: Play Again + const leaderboardMenuActions = useMemo(() => [ + () => setShowLeaderboard(false), + onPlayAgain, + ], [onPlayAgain]); + + const handleLeaderboardMenuSelect = useCallback((index: number) => { + leaderboardMenuActions[index]?.(); + }, [leaderboardMenuActions]); + + const { selectedIndex: leaderboardSelectedIndex, getItemProps: getLeaderboardItemProps } = useMenuKeyboardNav({ + itemCount: 2, + columns: 2, + onSelect: handleLeaderboardMenuSelect, + isActive: showLeaderboard && scoreSubmitted, + initialIndex: 1, // Start on Play Again + }); + + const selectedRing = "ring-2 ring-white ring-opacity-80"; const formattedDate = timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const formattedTime = timestamp.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); @@ -469,6 +521,16 @@ export default function GameOverScreen({ stats, score, level, lastStarLostReason setScoreSubmitted(true); setShowLeaderboard(true); setSubmitting(false); + + // Check if score made it to top 10 and show confetti + const isTopScore = await checkIfTopScore(score); + if (isTopScore) { + const isNumOne = await checkIfNumberOneScore(score); + setIsNumberOne(isNumOne); + setShowConfetti(true); + setLeaderboardRefreshKey(prev => prev + 1); + } + onSubmitted(session, nameToSubmit); } else if (scoreSuccess) { const fallbackSession: GameSession = { @@ -489,6 +551,16 @@ export default function GameOverScreen({ stats, score, level, lastStarLostReason setScoreSubmitted(true); setShowLeaderboard(true); setSubmitting(false); + + // Check if score made it to top 10 and show confetti + const isTopScore = await checkIfTopScore(score); + if (isTopScore) { + const isNumOne = await checkIfNumberOneScore(score); + setIsNumberOne(isNumOne); + setShowConfetti(true); + setLeaderboardRefreshKey(prev => prev + 1); + } + onSubmitted(fallbackSession, nameToSubmit); } else { setError('Failed to submit score. Please try again.'); @@ -585,24 +657,27 @@ export default function GameOverScreen({ stats, score, level, lastStarLostReason return (
- + + {scoreSubmitted ? ( -
- - +
+
+ + +
) : (
@@ -724,8 +799,9 @@ export default function GameOverScreen({ stats, score, level, lastStarLostReason
+

Use arrow keys + Enter to navigate

+ {/* SHARE SCORE CARD BUTTON REMOVED */}
diff --git a/src/components/HighScores.tsx b/src/components/HighScores.tsx index 5365ba3..e1dcd9d 100644 --- a/src/components/HighScores.tsx +++ b/src/components/HighScores.tsx @@ -5,9 +5,10 @@ import ScorecardImageView from './ScorecardImageView'; interface HighScoresProps { userScore?: { name: string; score: number }; + refreshKey?: number; // Increment to trigger refresh } -const HighScores: React.FC = ({ userScore }) => { +const HighScores: React.FC = ({ userScore, refreshKey = 0 }) => { const [scores, setScores] = useState([]); const [loading, setLoading] = useState(true); const [selectedImageUrl, setSelectedImageUrl] = useState(null); @@ -16,7 +17,7 @@ const HighScores: React.FC = ({ userScore }) => { useEffect(() => { loadScores(); - }, []); + }, [refreshKey]); const loadScores = async () => { setLoading(true); diff --git a/src/components/ItemStore.tsx b/src/components/ItemStore.tsx index cadb454..0fa7ff5 100644 --- a/src/components/ItemStore.tsx +++ b/src/components/ItemStore.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useCallback, useMemo, useState, useEffect, useRef } from 'react'; import { GameState } from '../types/game'; import { Store, DollarSign, X } from 'lucide-react'; import PizzaSliceStack from './PizzaSliceStack'; import { sprite } from '../lib/assets'; +import { getUpgradeCost, getSpeedUpgradeCost } from '../logic/storeSystem'; // Power-up images (served from Cloudflare) const beerImg = sprite("beer.png"); @@ -26,8 +27,6 @@ const ItemStore: React.FC = ({ onBuyPowerUp, onClose, }) => { - const upgradeCost = 10; - const speedUpgradeCost = 10; const maxUpgradeLevel = 7; const maxSpeedUpgradeLevel = 3; const bribeCost = 25; @@ -35,8 +34,10 @@ const ItemStore: React.FC = ({ const getOvenUpgradeLevel = (lane: number) => gameState.ovenUpgrades[lane] || 0; const getOvenSpeedUpgradeLevel = (lane: number) => gameState.ovenSpeedUpgrades[lane] || 0; - const canAffordUpgrade = gameState.bank >= upgradeCost; - const canAffordSpeedUpgrade = gameState.bank >= speedUpgradeCost; + const getLaneUpgradeCost = (lane: number) => getUpgradeCost(getOvenUpgradeLevel(lane)); + const getLaneSpeedUpgradeCost = (lane: number) => getSpeedUpgradeCost(getOvenSpeedUpgradeLevel(lane)); + const canAffordUpgrade = (lane: number) => gameState.bank >= getLaneUpgradeCost(lane); + const canAffordSpeedUpgrade = (lane: number) => gameState.bank >= getLaneSpeedUpgradeCost(lane); const getSpeedUpgradeText = (level: number) => { if (level === 0) return 'Base: 3s'; @@ -45,6 +46,263 @@ const ItemStore: React.FC = ({ return '1.5s'; }; + // Custom keyboard navigation for complex grid layout: + // Left side (ovens): 4 rows x 2 cols = indices 0-7 + // Row 0: [Speed0=0] [Level0=1] + // Row 1: [Speed1=2] [Level1=3] + // Row 2: [Speed2=4] [Level2=5] + // Row 3: [Speed3=6] [Level3=7] + // Right side: + // Bribe = 8 (accessible from oven rows 0-1) + // Power-ups = 9, 10, 11 (accessible from oven rows 2-3) + // Bottom: Continue = 12 + + const [selectedIndex, setSelectedIndex] = useState(12); // Start on Continue + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + + const menuActions = useMemo(() => [ + () => onUpgradeOvenSpeed(0), + () => onUpgradeOven(0), + () => onUpgradeOvenSpeed(1), + () => onUpgradeOven(1), + () => onUpgradeOvenSpeed(2), + () => onUpgradeOven(2), + () => onUpgradeOvenSpeed(3), + () => onUpgradeOven(3), + onBribeReviewer, + () => onBuyPowerUp('beer'), + () => onBuyPowerUp('ice-cream'), + () => onBuyPowerUp('honey'), + onClose, + ], [onUpgradeOvenSpeed, onUpgradeOven, onBribeReviewer, onBuyPowerUp, onClose]); + + // Focus selected element + useEffect(() => { + itemRefs.current[selectedIndex]?.focus(); + }, [selectedIndex]); + + // Store refs for stable access in event handler + const selectedIndexRef = useRef(selectedIndex); + const menuActionsRef = useRef(menuActions); + const onCloseRef = useRef(onClose); + const gameStateRef = useRef(gameState); + + useEffect(() => { + selectedIndexRef.current = selectedIndex; + }, [selectedIndex]); + + useEffect(() => { + menuActionsRef.current = menuActions; + }, [menuActions]); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + gameStateRef.current = gameState; + }, [gameState]); + + // Helper to check if a button at given index is disabled (defined before ref) + const isDisabledAt = (index: number, gs: GameState): boolean => { + // Oven speed buttons (0, 2, 4, 6) + if (index % 2 === 0 && index <= 6) { + const lane = index / 2; + const speedLevel = gs.ovenSpeedUpgrades[lane] || 0; + const isMaxSpeed = speedLevel >= maxSpeedUpgradeLevel; + const cost = getSpeedUpgradeCost(speedLevel); + return isMaxSpeed || gs.bank < cost; + } + // Oven level buttons (1, 3, 5, 7) + if (index % 2 === 1 && index <= 7) { + const lane = (index - 1) / 2; + const level = gs.ovenUpgrades[lane] || 0; + const isMaxLevel = level >= maxUpgradeLevel; + const cost = getUpgradeCost(level); + return isMaxLevel || gs.bank < cost; + } + // Bribe (8) + if (index === 8) { + return gs.bank < bribeCost || gs.lives >= 5; + } + // Power-ups (9, 10, 11) + if (index >= 9 && index <= 11) { + return gs.bank < powerUpCost; + } + // Continue (12) - never disabled + return false; + }; + + // Custom navigation logic - stable handler + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const key = e.key; + if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', ' ', 'Escape'].includes(key)) return; + + e.preventDefault(); + e.stopPropagation(); + + if (key === 'Escape') { + onCloseRef.current(); + return; + } + + if (key === 'Enter') { + menuActionsRef.current[selectedIndexRef.current]?.(); + return; + } + + setSelectedIndex(current => { + const gs = gameStateRef.current; + const disabled = (idx: number) => isDisabledAt(idx, gs); + + // Helper to find first non-disabled in a list, or return fallback + const firstEnabled = (indices: number[], fallback: number) => { + for (const idx of indices) { + if (!disabled(idx)) return idx; + } + return fallback; + }; + + // In oven grid (0-7) + if (current >= 0 && current <= 7) { + const row = Math.floor(current / 2); + const col = current % 2; + + if (key === 'ArrowUp') { + // Try rows above, find first enabled in same column first + for (let r = row - 1; r >= 0; r--) { + const target = r * 2 + col; + if (!disabled(target)) return target; + } + // If none in same column, try the other column in rows above + const otherCol = col === 0 ? 1 : 0; + for (let r = row - 1; r >= 0; r--) { + const target = r * 2 + otherCol; + if (!disabled(target)) return target; + } + return current; // Stay if none found + } + if (key === 'ArrowDown') { + // Try rows below, find first enabled in same column first + for (let r = row + 1; r <= 3; r++) { + const target = r * 2 + col; + if (!disabled(target)) return target; + } + // If none in same column, try the other column in rows below + const otherCol = col === 0 ? 1 : 0; + for (let r = row + 1; r <= 3; r++) { + const target = r * 2 + otherCol; + if (!disabled(target)) return target; + } + return 12; // Go to Continue + } + if (key === 'ArrowLeft') { + if (col > 0) { + const target = current - 1; + if (!disabled(target)) return target; + } + return current; // Stay at left edge or if disabled + } + if (key === 'ArrowRight') { + if (col === 0) { + const target = current + 1; + if (!disabled(target)) return target; + // If level button disabled, try going to right side + } + // From level column (or if level disabled), go to right side + if (row <= 1) { + // Try Bribe first, then power-ups + if (!disabled(8)) return 8; + return firstEnabled([9, 10, 11], current); + } + // From bottom rows, go to power-ups + return firstEnabled([9, 10, 11], current); + } + } + + // At Bribe (8) + if (current === 8) { + if (key === 'ArrowLeft') { + // Go to first enabled in oven rows 0-1 + return firstEnabled([1, 0, 3, 2], current); + } + if (key === 'ArrowDown') { + // Go to first enabled power-up + return firstEnabled([9, 10, 11, 12], current); + } + if (key === 'ArrowUp') { + // Go to first enabled in oven row 0-1 + return firstEnabled([1, 0, 3, 2], current); + } + if (key === 'ArrowRight') return current; + } + + // At Power-ups (9-11) + if (current >= 9 && current <= 11) { + const powerUpCol = current - 9; + if (key === 'ArrowLeft') { + // Try power-ups to the left first + for (let i = powerUpCol - 1; i >= 0; i--) { + if (!disabled(9 + i)) return 9 + i; + } + // Then try oven section + return firstEnabled([5, 4, 7, 6], current); + } + if (key === 'ArrowRight') { + // Try power-ups to the right + for (let i = powerUpCol + 1; i <= 2; i++) { + if (!disabled(9 + i)) return 9 + i; + } + return current; // Stay at right edge + } + if (key === 'ArrowUp') { + // Try Bribe if enabled + if (!disabled(8)) return 8; + return current; + } + if (key === 'ArrowDown') return 12; // Go to Continue + } + + // At Continue (12) + if (current === 12) { + if (key === 'ArrowUp') { + // Try bottommost rightmost oven upgrade first (oven 4 level, then oven 4 speed) + if (!disabled(7)) return 7; // Oven 4 level (rightmost) + if (!disabled(6)) return 6; // Oven 4 speed + // No oven 4 upgrades - try power-ups if enabled + const powerUpEnabled = firstEnabled([9, 10, 11], -1); + if (powerUpEnabled !== -1) return powerUpEnabled; + // Fall back to other ovens (bottommost rightmost first) + return firstEnabled([5, 4, 3, 2, 1, 0], current); + } + if (key === 'ArrowLeft') return current; + if (key === 'ArrowRight') return current; + if (key === 'ArrowDown') return current; + } + + return current; + }); + }; + + // Use capture phase to ensure we get events before other handlers + window.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); + }, []); // Empty deps - handler is stable via refs + + const registerRef = useCallback((index: number) => (el: HTMLButtonElement | null) => { + itemRefs.current[index] = el; + }, []); + + const getItemProps = useCallback((index: number) => ({ + ref: registerRef(index), + tabIndex: selectedIndex === index ? 0 : -1, + onMouseEnter: () => setSelectedIndex(index), + onClick: () => menuActions[index]?.(), + }), [selectedIndex, registerRef, menuActions]); + + const selectedRing = "ring-2 ring-white ring-opacity-80"; + return ( // ADDED z-[100] here to ensure the Store Card sits above text prompts (which are z-50)
@@ -117,16 +375,16 @@ const ItemStore: React.FC = ({
) : ( )} @@ -137,16 +395,16 @@ const ItemStore: React.FC = ({
) : ( )}
@@ -164,13 +422,13 @@ const ItemStore: React.FC = ({

⭐ Bribe Reviewer

Gain an extra star

@@ -182,19 +440,19 @@ const ItemStore: React.FC = ({

Power-Ups

{[ - { type: 'beer', img: beerImg, color: 'amber' }, - { type: 'ice-cream', img: sundaeImg, color: 'blue' }, - { type: 'honey', img: honeyImg, color: 'orange' }, - ].map(({ type, img, color }) => ( + { type: 'beer', img: beerImg, color: 'amber', index: 9 }, + { type: 'ice-cream', img: sundaeImg, color: 'blue', index: 10 }, + { type: 'honey', img: honeyImg, color: 'orange', index: 11 }, + ].map(({ type, img, color, index }) => (
diff --git a/src/components/LandscapeControls.tsx b/src/components/LandscapeControls.tsx deleted file mode 100644 index 68b1294..0000000 --- a/src/components/LandscapeControls.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import React from 'react'; -import { sprite } from '../lib/assets'; - -const pizzaPanImg = sprite("pizzapan.png"); - -interface LandscapeControlsProps { - gameOver: boolean; - paused: boolean; - nyanSweepActive: boolean; - onMoveUp: () => void; - onMoveDown: () => void; - onServePizza: () => void; - onUseOven: () => void; - onCleanOven: () => void; - currentLane: number; - availableSlices: number; - ovens: { - [key: number]: { - cooking: boolean; - startTime: number; - burned: boolean; - cleaningStartTime: number; - pausedElapsed?: number; - sliceCount: number; - }; - }; - ovenSpeedUpgrades: { [key: number]: number }; -} - -const LandscapeControls: React.FC = ({ - gameOver, - paused, - nyanSweepActive, - onMoveUp, - onMoveDown, - onServePizza, - onUseOven, - onCleanOven, - currentLane, - availableSlices, - ovens, - ovenSpeedUpgrades, -}) => { - const safeLane = Math.round(currentLane); - const isDisabled = gameOver || paused || nyanSweepActive; - - const getOvenStatus = () => { - const oven = ovens[safeLane]; - if (!oven) return 'empty'; - if (oven.burned) return 'burned'; - if (!oven.cooking) return 'empty'; - - const elapsed = oven.pausedElapsed !== undefined ? oven.pausedElapsed : Date.now() - oven.startTime; - - const speedUpgrade = ovenSpeedUpgrades[safeLane] || 0; - const cookingTime = speedUpgrade === 0 ? 3000 : - speedUpgrade === 1 ? 2000 : - speedUpgrade === 2 ? 1000 : 500; - - const warningTime = 7000; - const burnTime = 8000; - - if (elapsed >= burnTime) return 'burning'; - if (elapsed >= warningTime) return 'warning'; - if (elapsed >= cookingTime) return 'ready'; - return 'cooking'; - }; - - const handleOvenAction = () => { - const oven = ovens[safeLane]; - if (!oven) return; - if (oven.burned) { - onCleanOven(); - } else { - onUseOven(); - } - }; - - const ovenStatus = getOvenStatus(); - const currentOven = ovens[safeLane]; - - return ( - <> - {/* Left side - Chef Movement Controls */} -
- - -
- chef - {availableSlices > 0 && ( -
- {availableSlices} -
- )} -
- - - -
- Lane {safeLane + 1} -
-
- - {/* Right side - Oven and Serve Controls */} -
- {/* Serve Pizza Button */} -
- -
- Serve -
-
- - {/* Oven Control */} -
- -
- {ovenStatus === 'burned' ? 'Clean!' : - ovenStatus === 'burning' ? 'Burning!' : - ovenStatus === 'warning' ? 'Warning!' : - ovenStatus === 'ready' ? 'Take Out!' : - ovenStatus === 'cooking' ? 'Cooking...' : - 'Put Pizza'} -
-
-
- - ); -}; - -export default LandscapeControls; \ No newline at end of file diff --git a/src/components/LandscapeCustomer.tsx b/src/components/LandscapeCustomer.tsx deleted file mode 100644 index f7e33c5..0000000 --- a/src/components/LandscapeCustomer.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react'; -import { Customer as CustomerType } from '../types/game'; -import droolfaceImg from '/sprites/droolface.png'; -import yumfaceImg from '/sprites/yumface.png'; -import frozenfaceImg from '/sprites/frozenface.png'; -const spicyfaceImg = "https://i.imgur.com/MDS5EVg.png"; -import woozyfaceImg from '/sprites/woozyface.png'; -const criticImg = "https://i.imgur.com/ZygBTOI.png"; -const badLuckBrianImg = "https://i.imgur.com/cs0LDgJ.png"; -const badLuckBrianPukeImg = "https://i.imgur.com/yRXQDIT.png"; - -interface LandscapeCustomerProps { - customer: CustomerType; -} - -const LANDSCAPE_LANE_POSITIONS = [20, 40, 60, 80]; - -const LandscapeCustomer: React.FC = ({ customer }) => { - const leftPosition = customer.position; - - const getDisplay = () => { - if (customer.frozen) return { type: 'image', value: frozenfaceImg, alt: 'frozen' }; - if (customer.vomit && customer.badLuckBrian) return { type: 'image', value: badLuckBrianPukeImg, alt: 'brian-puke' }; - if (customer.vomit) return { type: 'emoji', value: '🤮' }; - if (customer.woozy) { - if (customer.woozyState === 'drooling') return { type: 'image', value: droolfaceImg, alt: 'drooling' }; - return { type: 'image', value: woozyfaceImg, alt: 'woozy' }; - } - if (customer.served) return { type: 'image', value: yumfaceImg, alt: 'yum' }; - if (customer.disappointed) return { type: 'emoji', value: customer.disappointedEmoji || '😢' }; - if (customer.hotHoneyAffected) return { type: 'image', value: spicyfaceImg, alt: 'spicy' }; - if (customer.badLuckBrian) return { type: 'image', value: badLuckBrianImg, alt: 'badluckbrian' }; - if (customer.critic) return { type: 'image', value: criticImg, alt: 'critic' }; - return { type: 'image', value: droolfaceImg, alt: 'drool' }; - }; - - const display = getDisplay(); - - return ( - <> -
- {display.type === 'image' ? ( - {display.alt} - ) : ( -
- {display.value} -
- )} -
- {customer.textMessage && ( -
- {customer.textMessage} -
- )} - - ); -}; - -export default LandscapeCustomer; diff --git a/src/components/LandscapeDroppedPlate.tsx b/src/components/LandscapeDroppedPlate.tsx deleted file mode 100644 index e1254c2..0000000 --- a/src/components/LandscapeDroppedPlate.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { DroppedPlate as DroppedPlateType } from '../types/game'; -import slice1PlateImg from '/sprites/1slicepizzapan.png'; - -interface LandscapeDroppedPlateProps { - droppedPlate: DroppedPlateType; -} - -const LANDSCAPE_LANE_POSITIONS = [20, 40, 60, 80]; -const BLINK_DURATION = 250; -const TOTAL_DURATION = 1000; - -const LandscapeDroppedPlate: React.FC = ({ droppedPlate }) => { - const [visible, setVisible] = useState(true); - const elapsed = Date.now() - droppedPlate.startTime; - - useEffect(() => { - if (elapsed >= TOTAL_DURATION) { - setVisible(false); - return; - } - - const blinkInterval = setInterval(() => { - const currentElapsed = Date.now() - droppedPlate.startTime; - const blinkCycle = Math.floor(currentElapsed / BLINK_DURATION); - setVisible(blinkCycle % 2 === 0); - }, BLINK_DURATION); - - return () => clearInterval(blinkInterval); - }, [droppedPlate.startTime, elapsed]); - - if (!visible || elapsed >= TOTAL_DURATION) { - return null; - } - - return ( -
- dropped plate -
- ); -}; - -export default LandscapeDroppedPlate; diff --git a/src/components/LandscapeGameBoard.tsx b/src/components/LandscapeGameBoard.tsx deleted file mode 100644 index 0bdb6ec..0000000 --- a/src/components/LandscapeGameBoard.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import LandscapeCustomer from './LandscapeCustomer'; -import PizzaSlice from './PizzaSlice'; -import EmptyPlate from './EmptyPlate'; -import LandscapeDroppedPlate from './LandscapeDroppedPlate'; -import PowerUp from './PowerUp'; -import PizzaSliceStack from './PizzaSliceStack'; -import FloatingScore from './FloatingScore'; -import Boss from './Boss'; -import { GameState } from '../types/game'; -import landscapeBg from '../assets/landscape version pizza chef.png'; -import { sprite } from '../lib/assets'; - -const chefImg = sprite("chef.png"); - -interface LandscapeGameBoardProps { - gameState: GameState; -} - -const LandscapeGameBoard: React.FC = ({ gameState }) => { - const lanes = [0, 1, 2, 3]; - const [, forceUpdate] = React.useReducer(x => x + 1, 0); - const [completedScores, setCompletedScores] = useState>(new Set()); - - const handleScoreComplete = useCallback((id: string) => { - setCompletedScores(prev => new Set(prev).add(id)); - }, []); - - React.useEffect(() => { - const interval = setInterval(forceUpdate, 100); - return () => clearInterval(interval); - }, []); - - const getOvenStatus = (lane: number) => { - const oven = gameState.ovens[lane]; - - if (oven.burned) { - if (oven.cleaningStartTime > 0) { - const cleaningElapsed = Date.now() - oven.cleaningStartTime; - const halfCleaning = 1500; - if (cleaningElapsed < halfCleaning) { - return 'extinguishing'; - } - return 'sweeping'; - } - return 'burned'; - } - - if (!oven.cooking) return 'empty'; - - const elapsed = oven.pausedElapsed !== undefined ? oven.pausedElapsed : Date.now() - oven.startTime; - - // Calculate cook time based on speed upgrades - const speedUpgrade = gameState.ovenSpeedUpgrades[lane] || 0; - const cookingTime = speedUpgrade === 0 ? 3000 : - speedUpgrade === 1 ? 2000 : - speedUpgrade === 2 ? 1000 : 500; - - const warningTime = 7000; - const burnTime = 8000; - const blinkInterval = 250; - - if (elapsed >= burnTime) return 'burning'; - - if (elapsed >= warningTime) { - const warningElapsed = elapsed - warningTime; - const blinkCycle = Math.floor(warningElapsed / blinkInterval); - return blinkCycle % 2 === 0 ? 'warning-fire' : 'warning-pizza'; - } - - if (elapsed >= cookingTime) return 'ready'; - return 'cooking'; - }; - - return ( -
- {/* Pizza Ovens - positioned on the left side */} - {lanes.map((lane) => { - const ovenStatus = getOvenStatus(lane); - const oven = gameState.ovens[lane]; - const showSlices = oven.cooking && !oven.burned; - - return ( -
- {showSlices && ( -
- -
- )} -
- {ovenStatus === 'burned' ? '💀' : - ovenStatus === 'extinguishing' ? '🧯' : - ovenStatus === 'sweeping' ? '🧹' : - ovenStatus === 'burning' ? '💀' : - ovenStatus === 'warning-fire' ? '🔥' : - ovenStatus === 'warning-pizza' ? '⚠️' : - ovenStatus === 'ready' ? '♨️' : - ovenStatus === 'cooking' ? '🌡️' : - ''} -
-
- ); - })} - - {/* Chef positioned at current lane - only shown when NOT in nyan sweep */} - {!gameState.nyanSweep?.active && ( -
- {gameState.gameOver ? ( - game over - ) : ( - chef - )} -
- -
-
- )} - - {/* Nyan Cat Chef - positioned directly on game board during sweep */} - {gameState.nyanSweep?.active && ( -
- nyan cat -
- )} - - {/* Game Elements */} - {gameState.customers.map((customer) => ( - - ))} - - {gameState.pizzaSlices.map((slice) => ( - - ))} - - {gameState.emptyPlates.map((plate) => ( - - ))} - - {gameState.droppedPlates.map((droppedPlate) => ( - - ))} - - {gameState.powerUps.map((powerUp) => ( - - ))} - - {/* Boss Battle */} - {gameState.bossBattle && ( - - )} - - {/* Floating score indicators */} - {gameState.floatingScores.filter(fs => !completedScores.has(fs.id)).map((floatingScore) => ( - - ))} - - {/* Falling pizza when game over */} - {gameState.fallingPizza && ( -
- 🍕 -
- )} -
- ); -}; - -export default LandscapeGameBoard; diff --git a/src/components/LandscapeScoreBoard.tsx b/src/components/LandscapeScoreBoard.tsx deleted file mode 100644 index 86fcf7d..0000000 --- a/src/components/LandscapeScoreBoard.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import { GameState } from '../types/game'; -import { Star, Trophy, DollarSign, Pause, HelpCircle, Layers } from 'lucide-react'; - -interface LandscapeScoreBoardProps { - gameState: GameState; - onShowInstructions: () => void; -} - -const LandscapeScoreBoard: React.FC = ({ gameState, onShowInstructions }) => { - return ( -
-
-
- - {gameState.score.toLocaleString()} -
- -
-
- {Array.from({ length: 5 }, (_, i) => ( - - ))} -
-
- -
- - {gameState.bank} -
- -
- - {gameState.level} -
- - -
-
- ); -}; - -export default LandscapeScoreBoard; diff --git a/src/components/MafiaSlice.tsx b/src/components/MafiaSlice.tsx new file mode 100644 index 0000000..ebee6b1 --- /dev/null +++ b/src/components/MafiaSlice.tsx @@ -0,0 +1,34 @@ +// src/components/MafiaSlice.tsx +import React from 'react'; +import { MafiaSlice as MafiaSliceType } from '../types/game'; + +interface MafiaSliceProps { + slice: MafiaSliceType; +} + +const MafiaSlice: React.FC = ({ slice }) => { + // Calculate rotation based on velocity direction + const rotation = Math.atan2(slice.speedY, slice.speedX) * (180 / Math.PI); + + // Position based on lane (fractional) and position (percentage) + const xPct = slice.position; + const yPct = slice.lane * 25 + 6; + + return ( +
+ 🍕 +
+ ); +}; + +export default MafiaSlice; diff --git a/src/components/MobileGameControls.tsx b/src/components/MobileGameControls.tsx index 16a4911..9d4f9a1 100644 --- a/src/components/MobileGameControls.tsx +++ b/src/components/MobileGameControls.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { sprite } from '../lib/assets'; +import { getOvenDisplayStatus } from '../logic/ovenSystem'; const pizzaPanImg = sprite("pizzapan.png"); @@ -25,6 +26,7 @@ interface MobileGameControlsProps { }; }; ovenSpeedUpgrades: { [key: number]: number }; + isLandscape?: boolean; } const MobileGameControls: React.FC = ({ @@ -40,6 +42,7 @@ const MobileGameControls: React.FC = ({ availableSlices, ovens, ovenSpeedUpgrades, + isLandscape = false, }) => { const safeLane = Math.round(currentLane); const isDisabled = gameOver || paused || nyanSweepActive; @@ -47,23 +50,8 @@ const MobileGameControls: React.FC = ({ const getOvenStatus = () => { const oven = ovens[safeLane]; if (!oven) return 'empty'; - if (oven.burned) return 'burned'; - if (!oven.cooking) return 'empty'; - - const elapsed = oven.pausedElapsed !== undefined ? oven.pausedElapsed : Date.now() - oven.startTime; - const speedUpgrade = ovenSpeedUpgrades[safeLane] || 0; - const cookingTime = speedUpgrade === 0 ? 3000 : - speedUpgrade === 1 ? 2000 : - speedUpgrade === 2 ? 1000 : 500; - - const warningTime = 7000; - const burnTime = 8000; - - if (elapsed >= burnTime) return 'burning'; - if (elapsed >= warningTime) return 'warning'; - if (elapsed >= cookingTime) return 'ready'; - return 'cooking'; + return getOvenDisplayStatus(oven, speedUpgrade); }; const handleOvenAction = () => { @@ -79,6 +67,85 @@ const MobileGameControls: React.FC = ({ const ovenStatus = getOvenStatus(); const currentOven = ovens[safeLane]; + // Landscape layout - controls on left and right edges + if (isLandscape) { + return ( + <> + {/* Left side - Movement Controls */} +
+ + +
+ + {/* Right side - Oven and Serve Controls */} +
+ {/* Serve Pizza Control */} + + + {/* Oven Control */} + +
+ + ); + } + + // Portrait layout - controls at bottom return (
diff --git a/src/components/PauseMenu.tsx b/src/components/PauseMenu.tsx new file mode 100644 index 0000000..b890e59 --- /dev/null +++ b/src/components/PauseMenu.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useCallback } from 'react'; +import { Play, RotateCcw, Volume2, VolumeX, Trophy, HelpCircle } from 'lucide-react'; +import { useMenuKeyboardNav } from '../hooks/useMenuKeyboardNav'; +import { sprite } from '../lib/assets'; + +const smokingChefImg = sprite('chef-smoking.png'); + +interface PauseMenuProps { + isVisible: boolean; + isMuted: boolean; + onResume: () => void; + onReset: () => void; + onToggleMute: () => void; + onShowScores: () => void; + onShowHelp: () => void; +} + +const PauseMenu: React.FC = ({ + isVisible, + isMuted, + onResume, + onReset, + onToggleMute, + onShowScores, + onShowHelp, +}) => { + const menuActions = [onResume, onReset, onToggleMute, onShowScores, onShowHelp]; + + const handleSelect = useCallback((index: number) => { + menuActions[index]?.(); + }, [menuActions]); + + const { selectedIndex, getItemProps } = useMenuKeyboardNav({ + itemCount: 5, // 4 main buttons + help + columns: 2, + onSelect: handleSelect, + onEscape: onResume, + isActive: isVisible, + initialIndex: 0, + }); + + if (!isVisible) return null; + + const buttonBaseClass = "flex items-center justify-center gap-2 px-4 py-3 rounded-lg transition-colors font-bold shadow-lg"; + const selectedRing = "ring-4 ring-white ring-opacity-80"; + + return ( +
+
+ {/* Help button */} + + + Chef taking a break + + {/* Button grid */} +
+ + + + +
+
+
+ ); +}; + +export default PauseMenu; diff --git a/src/components/PepeHelpers.tsx b/src/components/PepeHelpers.tsx new file mode 100644 index 0000000..3ebb4ab --- /dev/null +++ b/src/components/PepeHelpers.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { sprite } from '../lib/assets'; +import { PepeHelpers as PepeHelpersType } from '../types/game'; +import PizzaSliceStack from './PizzaSliceStack'; + +const francoPepeImg = sprite("franco-pepe.png"); +const frankPepeImg = sprite("frank-pepe.png"); + +interface PepeHelpersProps { + helpers: PepeHelpersType | undefined; +} + +const PepeHelpers: React.FC = ({ helpers }) => { + if (!helpers?.active) return null; + + return ( + <> + {/* Franco-Pepe */} +
+ Franco-Pepe helper + {/* Franco's slice stack */} + {helpers.franco.availableSlices > 0 && ( +
+ +
+ )} +
+ + {/* Frank-Pepe */} +
+ Frank-Pepe helper + {/* Frank's slice stack */} + {helpers.frank.availableSlices > 0 && ( +
+ +
+ )} +
+ + ); +}; + +export default PepeHelpers; diff --git a/src/components/PizzaConfetti.tsx b/src/components/PizzaConfetti.tsx new file mode 100644 index 0000000..f974f02 --- /dev/null +++ b/src/components/PizzaConfetti.tsx @@ -0,0 +1,99 @@ +import React, { useEffect, useState } from 'react'; +import { sprite } from '../lib/assets'; + +interface ConfettiPiece { + id: number; + left: number; + delay: number; + duration: number; + rotation: number; + size: number; +} + +interface PizzaConfettiProps { + active: boolean; + duration?: number; // How long to show confetti in ms + isNumberOne?: boolean; // Use Molto Benny instead of pizza +} + +const PizzaConfetti: React.FC = ({ active, duration = 5000, isNumberOne = false }) => { + const [pieces, setPieces] = useState([]); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (active) { + // Generate confetti pieces + const newPieces: ConfettiPiece[] = []; + for (let i = 0; i < 30; i++) { + newPieces.push({ + id: i, + left: Math.random() * 100, + delay: Math.random() * 2, + duration: 2 + Math.random() * 2, + rotation: Math.random() * 720 - 360, + size: 24 + Math.random() * 24, + }); + } + setPieces(newPieces); + setVisible(true); + + // Hide after duration + const timer = setTimeout(() => { + setVisible(false); + }, duration); + + return () => clearTimeout(timer); + } + }, [active, duration]); + + if (!visible || pieces.length === 0) return null; + + return ( +
+ {pieces.map((piece) => ( +
+ {isNumberOne ? ( + Molto Benny + ) : ( + '🍕' + )} +
+ ))} + + +
+ ); +}; + +export default PizzaConfetti; diff --git a/src/components/PizzaSlice.tsx b/src/components/PizzaSlice.tsx index 8fc23d0..d47303c 100644 --- a/src/components/PizzaSlice.tsx +++ b/src/components/PizzaSlice.tsx @@ -1,42 +1,15 @@ import React from 'react'; import { PizzaSlice as PizzaSliceType } from '../types/game'; +import { sprite } from '../lib/assets'; + +const slicePlateImg = sprite("slice-plate.png"); interface PizzaSliceProps { slice: PizzaSliceType; } -const LANDSCAPE_LANE_POSITIONS = [20, 40, 60, 80]; // match LandscapeCustomer - const PizzaSlice: React.FC = ({ slice }) => { - const getIsLandscape = () => - typeof window !== 'undefined' ? window.innerWidth > window.innerHeight : true; - - const getIsMobile = () => - typeof navigator !== 'undefined' - ? /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || - (navigator as any).maxTouchPoints > 1 - : false; - - const [isLandscape, setIsLandscape] = React.useState(getIsLandscape); - const [isMobile, setIsMobile] = React.useState(getIsMobile); - - React.useEffect(() => { - const handleResize = () => { - setIsLandscape(getIsLandscape()); - setIsMobile(getIsMobile()); - }; - window.addEventListener('resize', handleResize); - window.addEventListener('orientationchange', handleResize); - return () => { - window.removeEventListener('resize', handleResize); - window.removeEventListener('orientationchange', handleResize); - }; - }, []); - - const topPercent = - isMobile && isLandscape - ? LANDSCAPE_LANE_POSITIONS[slice.lane] - : slice.lane * 25 + 6; + const topPercent = slice.lane * 25 + 6; return (
= ({ slice }) => { top: `${topPercent}%`, }} > - {/* White plate image underneath */} + {/* Pizza slice on plate */} slice1plate diff --git a/src/components/PowerUp.tsx b/src/components/PowerUp.tsx index b297e5e..db6d6dc 100644 --- a/src/components/PowerUp.tsx +++ b/src/components/PowerUp.tsx @@ -6,6 +6,11 @@ import { sprite } from '../lib/assets'; const beerImg = sprite("beer.png"); const honeyImg = sprite("hot-honey.png"); const sundaeImg = sprite("sundae.png"); +const dogeImg = sprite("doge.png"); +const nyanImg = sprite("nyan-cat.png"); +const moltobennyImg = sprite("molto-benny.png"); +const starImg = sprite("star.png"); +const pepeImg = sprite("pepe.png"); interface PowerUpProps { powerUp: PowerUpType; @@ -31,13 +36,15 @@ const PowerUp: React.FC = ({ powerUp, boardWidth, boardHeight }) = case 'beer': return beerImg; case 'doge': - return 'https://i.imgur.com/TqnVUzO.png'; + return dogeImg; case 'nyan': - return 'https://i.imgur.com/OLD9UC8.png'; + return nyanImg; case 'moltobenny': - return 'https://i.imgur.com/5goVcAS.png'; + return moltobennyImg; case 'star': - return 'https://i.imgur.com/hw0jkrq.png'; + return starImg; + case 'pepe': + return pepeImg; default: return null; } diff --git a/src/components/PowerUpAlert.tsx b/src/components/PowerUpAlert.tsx index 84f16a3..39e758b 100644 --- a/src/components/PowerUpAlert.tsx +++ b/src/components/PowerUpAlert.tsx @@ -1,5 +1,8 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { PowerUpType } from '../types/game'; +import { sprite } from '../lib/assets'; + +const dogeAlertImg = sprite("doge-power-up-alert.png"); interface PowerUpAlertProps { powerUpType: PowerUpType; @@ -7,12 +10,22 @@ interface PowerUpAlertProps { } const PowerUpAlert: React.FC = ({ powerUpType }) => { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 1000); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + const getAlertContent = () => { switch (powerUpType) { case 'doge': return { - image: 'https://i.imgur.com/n0FtlUg.png', + image: dogeAlertImg, scale: 6, + mobileScale: 2, // 1/3 size on mobile }; default: return null; @@ -22,7 +35,7 @@ const PowerUpAlert: React.FC = ({ powerUpType }) => { const content = getAlertContent(); if (!content) return null; - const scale = content.scale || 1; + const scale = isMobile ? (content.mobileScale || content.scale / 3) : (content.scale || 1); return (
void; + onPauseClick: () => void; + compact?: boolean; } -const ScoreBoard: React.FC = ({ gameState, onShowInstructions }) => { +const ScoreBoard: React.FC = ({ gameState, onPauseClick, compact = false }) => { return ( -
+
@@ -45,13 +46,12 @@ const ScoreBoard: React.FC = ({ gameState, onShowInstructions }
diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index a2ae015..6a5ad63 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -1,19 +1,37 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { sprite } from '../lib/assets'; const chefImg = sprite("chef.png"); interface SplashScreenProps { onStart: () => void; + isLoading?: boolean; + loadingProgress?: number; } -const SplashScreen: React.FC = ({ onStart }) => { +const SplashScreen: React.FC = ({ + onStart, + isLoading = false, + loadingProgress = 100 +}) => { + // Allow Enter key to start the game (only when not loading) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !isLoading) { + e.preventDefault(); + onStart(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onStart, isLoading]); + return (
PizzaDAO Logo @@ -27,11 +45,31 @@ const SplashScreen: React.FC = ({ onStart }) => { className="w-48 h-auto mx-auto" /> + {/* Loading progress bar */} + {isLoading && ( +
+
+
+
+

+ Loading... {loadingProgress}% +

+
+ )} +
diff --git a/src/hooks/useAssetPreloader.ts b/src/hooks/useAssetPreloader.ts new file mode 100644 index 0000000..e515a5b --- /dev/null +++ b/src/hooks/useAssetPreloader.ts @@ -0,0 +1,66 @@ +import { useState, useEffect } from 'react'; +import { sprite, ui } from '../lib/assets'; +import { PRELOAD_SPRITES, PRELOAD_UI } from '../lib/spriteManifest'; + +interface PreloadResult { + progress: number; // 0-100 + isComplete: boolean; + failedAssets: string[]; +} + +function preloadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(); + img.onerror = () => reject(new Error(`Failed to load: ${src}`)); + img.src = src; + }); +} + +export function useAssetPreloader(): PreloadResult { + const [loaded, setLoaded] = useState(0); + const [failedAssets, setFailedAssets] = useState([]); + + // Build full URL list + const allUrls = [ + ...PRELOAD_SPRITES.map(name => sprite(name)), + ...PRELOAD_UI.map(name => ui(name)), + ]; + + const total = allUrls.length; + + useEffect(() => { + let isMounted = true; + + const loadAssets = async () => { + // Load images in parallel with individual tracking + const promises = allUrls.map(async (url) => { + try { + await preloadImage(url); + if (isMounted) { + setLoaded(prev => prev + 1); + } + } catch { + console.warn(`Asset preload failed: ${url}`); + if (isMounted) { + setLoaded(prev => prev + 1); // Still count as "processed" + setFailedAssets(prev => [...prev, url]); + } + } + }); + + await Promise.all(promises); + }; + + loadAssets(); + + return () => { + isMounted = false; + }; + }, []); // Run once on mount + + const progress = total > 0 ? Math.round((loaded / total) * 100) : 0; + const isComplete = loaded >= total; + + return { progress, isComplete, failedAssets }; +} diff --git a/src/hooks/useGameLogic.ts b/src/hooks/useGameLogic.ts index fd5a281..8819e5e 100644 --- a/src/hooks/useGameLogic.ts +++ b/src/hooks/useGameLogic.ts @@ -2,15 +2,14 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { GameState, - Customer, PizzaSlice, - EmptyPlate, - PowerUp, + MafiaSlice, + GameStats, PowerUpType, - FloatingScore, - DroppedPlate, StarLostReason, - BossMinion + EmptyPlate, + isCustomerLeaving, + getCustomerVariant } from '../types/game'; import { soundManager } from '../utils/sounds'; import { getStreakMultiplier } from '../components/StreakDisplay'; @@ -20,13 +19,10 @@ import { SPAWN_RATES, PROBABILITIES, SCORING, - COSTS, - BOSS_CONFIG, - POWERUPS, - TIMINGS, POSITIONS, INITIAL_GAME_STATE, - OVEN_CONFIG + POWERUPS, + TIMINGS } from '../lib/constants'; // --- Logic Imports --- @@ -44,24 +40,55 @@ import { import { calculateCustomerScore, - calculatePlateScore, calculateMinionScore, - calculatePowerUpScore, checkLifeGain, - updateStatsForStreak + updateStatsForStreak, + applyCustomerScoring } from '../logic/scoringSystem'; import { - checkSliceCustomerCollision, checkSlicePowerUpCollision, - checkChefPowerUpCollision, - checkChefPlateCollision, - checkNyanSweepCollision, - checkStarPowerRange, - checkMinionReachedChef, - checkSliceMinionCollision + checkSliceCustomerCollision } from '../logic/collisionSystem'; +import { + processChefPowerUpCollisions, + processPowerUpCollection, + processPowerUpExpirations +} from '../logic/powerUpSystem'; + +import { + processNyanSweepMovement, + checkNyanSweepCollisions +} from '../logic/nyanSystem'; + +import { + checkBossTrigger, + initializeBossBattle, + processBossTick +} from '../logic/bossSystem'; + +import { initializeBossMasks } from '../logic/bossCollisionMasks'; + +import { + processSpawning +} from '../logic/spawnSystem'; + +import { + processPlates +} from '../logic/plateSystem'; + +import { + processPepeHelperTick, + checkPepeHelpersExpired +} from '../logic/pepeHelperSystem'; + +import { + spawnMafiaSlices, + updateMafiaSlices, + checkMafiaSliceCollision +} from '../logic/mafiaSliceSystem'; + // --- Store System (actions only) --- import { upgradeOven as upgradeOvenStore, @@ -94,6 +121,11 @@ export const useGameLogic = (gameStarted: boolean = true) => { const prevShowStoreRef = useRef(false); + // Initialize boss collision masks (fire and forget) + useEffect(() => { + initializeBossMasks(); + }, []); + // --- 1. THE STABLE TICK REF --- const latestTickRef = useRef<() => void>(() => { }); @@ -104,12 +136,23 @@ export const useGameLogic = (gameStarted: boolean = true) => { return { ...state, floatingScores: [...state.floatingScores, { - id: `score-${now}-${Math.random()}`, + id: `score - ${now} -${Math.random()} `, points, lane, position, startTime: now, }], }; }, []); + const addFloatingStar = useCallback((isGain: boolean, lane: number, position: number, state: GameState, count: number = 1): GameState => { + const now = Date.now(); + return { + ...state, + floatingStars: [...state.floatingStars, { + id: `star-${now}-${Math.random()}`, + isGain, count, lane, position, startTime: now, + }], + }; + }, []); + /** * Consolidated "game over" cleanup: * - triggers game over sound once @@ -126,6 +169,8 @@ export const useGameLogic = (gameStarted: boolean = true) => { // Stop oven loop + freeze oven timers const pausedOvens = calculateOvenPauseState(state.ovens, true, now); + // Stop Nyan cat song + soundManager.stopNyan(); soundManager.gameOver(); const shouldDropPizza = state.availableSlices > 0; @@ -148,7 +193,7 @@ export const useGameLogic = (gameStarted: boolean = true) => { setGameState(prev => ({ ...prev, pizzaSlices: [...prev.pizzaSlices, { - id: `pizza-${Date.now()}-${gameState.chefLane}`, + id: `pizza - ${Date.now()} -${gameState.chefLane} `, lane: gameState.chefLane, position: GAME_CONFIG.CHEF_X_POSITION, speed: ENTITY_SPEEDS.PIZZA, @@ -171,7 +216,8 @@ export const useGameLogic = (gameStarted: boolean = true) => { if (gameState.gameOver || gameState.paused) return; setGameState(prev => { - const result = tryInteractWithOven(prev, prev.chefLane, Date.now()); + const starPowerActive = prev.activePowerUps.some(p => p.type === 'star'); + const result = tryInteractWithOven(prev, prev.chefLane, Date.now(), starPowerActive); if (result.action === 'STARTED') { soundManager.ovenStart(); @@ -227,6 +273,11 @@ export const useGameLogic = (gameStarted: boolean = true) => { const hasStar = newState.activePowerUps.some(p => p.type === 'star'); const dogeMultiplier = hasDoge ? 2 : 1; + // Initialize clean kitchen timer if not set + if (newState.cleanKitchenStartTime === undefined) { + newState.cleanKitchenStartTime = now; + } + // 1. PROCESS OVENS (Logic from ovenSystem) const ovenTickResult = processOvenTick( newState.ovens, @@ -250,6 +301,10 @@ export const useGameLogic = (gameStarted: boolean = true) => { soundManager.lifeLost(); newState.lives = Math.max(0, newState.lives - 1); newState.lastStarLostReason = 'burned_pizza'; + // Use the oven's lane for the floating star + newState = addFloatingStar(false, event.lane, 5, newState); + // Reset clean kitchen timer + newState.cleanKitchenStartTime = now; if (newState.lives === 0) { newState = triggerGameOver(newState, now); } @@ -266,19 +321,22 @@ export const useGameLogic = (gameStarted: boolean = true) => { } customerUpdate.events.forEach(event => { - if (event === 'LIFE_LOST') { + if (event.type === 'LIFE_LOST') { soundManager.customerDisappointed(); soundManager.lifeLost(); } - if (event === 'STAR_LOST_CRITIC') { + if (event.type === 'STAR_LOST_CRITIC') { newState.lives = Math.max(0, newState.lives - 2); newState.lastStarLostReason = 'disappointed_critic'; + // Critic loses 2 stars - show one indicator with 2 stars + newState = addFloatingStar(false, event.lane, event.position, newState, 2); } - if (event === 'STAR_LOST_NORMAL') { + if (event.type === 'STAR_LOST_NORMAL') { newState.lives = Math.max(0, newState.lives - 1); newState.lastStarLostReason = 'disappointed_customer'; + newState = addFloatingStar(false, event.lane, event.position, newState); } - if (event === 'GAME_OVER' && newState.lives === 0) { + if (event.type === 'GAME_OVER' && newState.lives === 0) { newState = triggerGameOver(newState, now); } }); @@ -290,20 +348,21 @@ export const useGameLogic = (gameStarted: boolean = true) => { const destroyedPowerUpIds = new Set(); const platesFromSlices = new Set(); const customerScores: Array<{ points: number; lane: number; position: number }> = []; + const starGainsToAdd: Array<{ lane: number; position: number }> = []; let sliceWentOffScreen = false; newState.pizzaSlices.forEach(slice => { let consumed = false; newState.customers = newState.customers.map(customer => { - if (consumed || customer.served || customer.disappointed || customer.vomit || customer.leaving) return customer; + if (consumed || isCustomerLeaving(customer)) return customer; const isHit = checkSliceCustomerCollision(slice, customer); if (isHit) { consumed = true; - const hitResult = processCustomerHit(customer, now); + const hitResult = processCustomerHit(customer, now, hasDoge); if (hitResult.newEntities.droppedPlate) newState.droppedPlates = [...newState.droppedPlates, hitResult.newEntities.droppedPlate]; if (hitResult.newEntities.emptyPlate) newState.emptyPlates = [...newState.emptyPlates, hitResult.newEntities.emptyPlate]; @@ -313,71 +372,116 @@ export const useGameLogic = (gameStarted: boolean = true) => { soundManager.plateDropped(); newState.stats.currentCustomerStreak = 0; newState.stats.currentPlateStreak = 0; + // Reset clean kitchen timer + newState.cleanKitchenStartTime = now; + // Brian still pays $1 even when he drops the slice + newState.bank += SCORING.BASE_BANK_REWARD; + newState.stats.totalEarned += SCORING.BASE_BANK_REWARD; } else if (event === 'UNFROZEN_AND_SERVED') { soundManager.customerUnfreeze(); - const { points: pointsEarned, bank: bankEarned } = calculateCustomerScore( - customer, - dogeMultiplier, - getStreakMultiplier(newState.stats.currentCustomerStreak) - ); + const result = applyCustomerScoring(customer, newState, dogeMultiplier, + getStreakMultiplier(newState.stats.currentCustomerStreak), + { includeBank: true, countsAsServed: true, isFirstSlice: false, checkLifeGain: true }); + + newState.score += result.scoreToAdd; + newState.bank += result.bankToAdd; + newState.stats.totalEarned += result.bankToAdd; + newState.happyCustomers = result.newHappyCustomers; + newState.stats = result.newStats; + customerScores.push(result.floatingScore); + + if (result.livesToAdd > 0) { + newState.lives += result.livesToAdd; + if (result.shouldPlayLifeSound) soundManager.lifeGained(); + if (result.starGain) starGainsToAdd.push(result.starGain); + } - newState.score += pointsEarned; - newState.bank += bankEarned; - customerScores.push({ points: pointsEarned, lane: customer.lane, position: customer.position }); + } else if (event === 'WOOZY_STEP_1') { + soundManager.woozyServed(); - newState.happyCustomers += 1; - newState.stats.customersServed += 1; - newState.stats = updateStatsForStreak(newState.stats, 'customer'); + const result = applyCustomerScoring(customer, newState, dogeMultiplier, + getStreakMultiplier(newState.stats.currentCustomerStreak), + { includeBank: true, countsAsServed: false, isFirstSlice: true, checkLifeGain: false }); - const lifeResult = checkLifeGain(newState.lives, newState.happyCustomers, dogeMultiplier); - if (lifeResult.livesToAdd > 0) { - newState.lives += lifeResult.livesToAdd; - if (lifeResult.shouldPlaySound) soundManager.lifeGained(); - } + newState.score += result.scoreToAdd; + newState.bank += result.bankToAdd; + newState.stats.totalEarned += result.bankToAdd; + customerScores.push(result.floatingScore); - } else if (event === 'WOOZY_STEP_1') { + } else if (event === 'STEVE_FIRST_SLICE') { + // Steve got his first slice but wants more - NO PAYMENT soundManager.woozyServed(); - const { points: pointsEarned, bank: bankEarned } = calculateCustomerScore( - customer, - dogeMultiplier, + const result = applyCustomerScoring(customer, newState, dogeMultiplier, getStreakMultiplier(newState.stats.currentCustomerStreak), - true // isFirstSlice - ); + { includeBank: false, countsAsServed: false, isFirstSlice: true, checkLifeGain: false }); - newState.score += pointsEarned; - newState.bank += bankEarned; - customerScores.push({ points: pointsEarned, lane: customer.lane, position: customer.position }); + newState.score += result.scoreToAdd; + customerScores.push(result.floatingScore); - } else if (event === 'WOOZY_STEP_2' || event === 'SERVED_NORMAL' || event === 'SERVED_CRITIC') { + } else if (event === 'STEVE_SERVED') { + // Steve is satisfied - NO PAYMENT but counts as served soundManager.customerServed(); - const { points: pointsEarned, bank: bankEarned } = calculateCustomerScore( - customer, - dogeMultiplier, - getStreakMultiplier(newState.stats.currentCustomerStreak) - ); - - newState.score += pointsEarned; - newState.bank += bankEarned; - customerScores.push({ points: pointsEarned, lane: customer.lane, position: customer.position }); - - newState.happyCustomers += 1; - newState.stats.customersServed += 1; - newState.stats = updateStatsForStreak(newState.stats, 'customer'); - - const lifeResult = checkLifeGain( - newState.lives, - newState.happyCustomers, - dogeMultiplier, - customer.critic, - customer.position - ); - - if (lifeResult.livesToAdd > 0) { - newState.lives += lifeResult.livesToAdd; - if (lifeResult.shouldPlaySound) soundManager.lifeGained(); + const result = applyCustomerScoring(customer, newState, dogeMultiplier, + getStreakMultiplier(newState.stats.currentCustomerStreak), + { includeBank: false, countsAsServed: true, isFirstSlice: false, checkLifeGain: true }); + + newState.score += result.scoreToAdd; + newState.happyCustomers = result.newHappyCustomers; + newState.stats = result.newStats; + customerScores.push(result.floatingScore); + + if (result.livesToAdd > 0) { + newState.lives += result.livesToAdd; + if (result.shouldPlayLifeSound) soundManager.lifeGained(); + if (result.starGain) starGainsToAdd.push(result.starGain); + } + + } else if (event === 'MAFIA_SERVED') { + // Pizza Mafia served - spawn 8 slices flying in all directions + soundManager.customerServed(); + + const result = applyCustomerScoring(customer, newState, dogeMultiplier, + getStreakMultiplier(newState.stats.currentCustomerStreak), + { includeBank: true, countsAsServed: true, isFirstSlice: false, checkLifeGain: true }); + + newState.score += result.scoreToAdd; + newState.bank += result.bankToAdd; + newState.stats.totalEarned += result.bankToAdd; + newState.happyCustomers = result.newHappyCustomers; + newState.stats = result.newStats; + customerScores.push(result.floatingScore); + + if (result.livesToAdd > 0) { + newState.lives += result.livesToAdd; + if (result.shouldPlayLifeSound) soundManager.lifeGained(); + if (result.starGain) starGainsToAdd.push(result.starGain); + } + + // Spawn 8 mafia slices radiating outward + const mafiaSlices = spawnMafiaSlices(customer.lane, customer.position, now); + newState.mafiaSlices = [...newState.mafiaSlices, ...mafiaSlices]; + + } else if (event === 'WOOZY_STEP_2' || event === 'SERVED_NORMAL' || event === 'SERVED_CRITIC' || event === 'SERVED_BRIAN_DOGE') { + soundManager.customerServed(); + + const result = applyCustomerScoring(customer, newState, dogeMultiplier, + getStreakMultiplier(newState.stats.currentCustomerStreak), + { includeBank: true, countsAsServed: true, isFirstSlice: false, checkLifeGain: true }); + + newState.score += result.scoreToAdd; + newState.bank += result.bankToAdd; + newState.stats.totalEarned += result.bankToAdd; + newState.happyCustomers = result.newHappyCustomers; + newState.stats = result.newStats; + customerScores.push(result.floatingScore); + + if (result.livesToAdd > 0) { + newState.lives += result.livesToAdd; + if (result.shouldPlayLifeSound) soundManager.lifeGained(); + if (result.starGain) starGainsToAdd.push(result.starGain); } } }); @@ -414,11 +518,16 @@ export const useGameLogic = (gameStarted: boolean = true) => { newState.pizzaSlices = finalSlices; newState.powerUps = newState.powerUps.filter(p => !destroyedPowerUpIds.has(p.id)); - if (sliceWentOffScreen) newState.stats.currentPlateStreak = 0; + if (sliceWentOffScreen) { + newState.stats.currentPlateStreak = 0; + newState.cleanKitchenStartTime = now; + } customerScores.forEach(({ points, lane, position }) => { newState = addFloatingScore(points, lane, position, newState); }); + starGainsToAdd.forEach(({ lane, position }) => { newState = addFloatingStar(true, lane, position, newState); }); // --- 4. CLEANUP EXPIRATIONS --- newState.floatingScores = newState.floatingScores.filter(fs => now - fs.startTime < TIMINGS.FLOATING_SCORE_LIFETIME); + newState.floatingStars = newState.floatingStars.filter(fs => now - fs.startTime < TIMINGS.FLOATING_SCORE_LIFETIME); newState.droppedPlates = newState.droppedPlates.filter(dp => now - dp.startTime < TIMINGS.DROPPED_PLATE_LIFETIME); newState.customers = newState.customers.map(customer => { if (customer.textMessage && customer.textMessageTime && now - customer.textMessageTime >= TIMINGS.TEXT_MESSAGE_LIFETIME) { @@ -427,280 +536,275 @@ export const useGameLogic = (gameStarted: boolean = true) => { return customer; }); - const expiredStarPower = newState.activePowerUps.some(p => p.type === 'star' && now >= p.endTime); - const expiredHoney = newState.activePowerUps.some(p => p.type === 'honey' && now >= p.endTime); - newState.activePowerUps = newState.activePowerUps.filter(powerUp => now < powerUp.endTime); - if (expiredStarPower) newState.starPowerActive = false; - if (expiredHoney) newState.customers = newState.customers.map(c => ({ ...c, hotHoneyAffected: false })); - if (newState.powerUpAlert && now >= newState.powerUpAlert.endTime) { - if (newState.powerUpAlert.type !== 'doge' || !hasDoge) newState.powerUpAlert = undefined; - } + // --- 4a. MAFIA SLICES PROCESSING --- + if (newState.mafiaSlices.length > 0) { + // Update positions + newState.mafiaSlices = updateMafiaSlices(newState.mafiaSlices, now); - // --- 5. STAR POWER AUTO-FEED --- - const starPowerScores: Array<{ points: number; lane: number; position: number }> = []; - if (hasStar && newState.availableSlices > 0) { - newState.customers = newState.customers.map(customer => { - if (checkStarPowerRange(newState.chefLane, GAME_CONFIG.CHEF_X_POSITION, customer)) { - newState.availableSlices = Math.max(0, newState.availableSlices - 1); - if (customer.badLuckBrian) { - soundManager.plateDropped(); - newState.stats.currentCustomerStreak = 0; - newState.stats.currentPlateStreak = 0; - const droppedPlate = { id: `dropped-${Date.now()}-${customer.id}`, lane: customer.lane, position: customer.position, startTime: Date.now(), hasSlice: true }; - newState.droppedPlates = [...newState.droppedPlates, droppedPlate]; - return { ...customer, flipped: false, leaving: true, movingRight: true, textMessage: "Ugh! I dropped my slice!", textMessageTime: Date.now() }; - } - soundManager.customerServed(); + // Check collisions with customers + const mafiaSlicesToRemove = new Set(); + const mafiaScores: Array<{ points: number; lane: number; position: number }> = []; + const mafiaStarGains: Array<{ lane: number; position: number }> = []; - const { points: pointsEarned, bank: bankEarned } = calculateCustomerScore( - customer, - dogeMultiplier, - getStreakMultiplier(newState.stats.currentCustomerStreak) - ); - - newState.score += pointsEarned; - newState.bank += bankEarned; - newState.happyCustomers += 1; - starPowerScores.push({ points: pointsEarned, lane: customer.lane, position: customer.position }); - - newState.stats.customersServed += 1; - newState.stats = updateStatsForStreak(newState.stats, 'customer'); - - if (!customer.critic) { - const lifeResult = checkLifeGain(newState.lives, newState.happyCustomers, dogeMultiplier); - if (lifeResult.livesToAdd > 0) { - newState.lives += lifeResult.livesToAdd; - if (lifeResult.shouldPlaySound) soundManager.lifeGained(); + newState.mafiaSlices.forEach(slice => { + if (mafiaSlicesToRemove.has(slice.id)) return; + + newState.customers = newState.customers.map(customer => { + if (mafiaSlicesToRemove.has(slice.id) || isCustomerLeaving(customer)) return customer; + + if (checkMafiaSliceCollision(slice, customer)) { + mafiaSlicesToRemove.add(slice.id); + soundManager.customerServed(); + + const result = applyCustomerScoring(customer, newState, dogeMultiplier, + getStreakMultiplier(newState.stats.currentCustomerStreak), + { includeBank: true, countsAsServed: true, isFirstSlice: false, checkLifeGain: true }); + + newState.score += result.scoreToAdd; + newState.bank += result.bankToAdd; + newState.stats.totalEarned += result.bankToAdd; + newState.happyCustomers = result.newHappyCustomers; + newState.stats = result.newStats; + mafiaScores.push(result.floatingScore); + + if (result.livesToAdd > 0) { + newState.lives += result.livesToAdd; + if (result.shouldPlayLifeSound) soundManager.lifeGained(); + if (result.starGain) mafiaStarGains.push(result.starGain); } + + // Return empty plate and mark as served + newState.emptyPlates = [...newState.emptyPlates, { + id: `plate-${now}-${customer.id}-mafia`, + lane: customer.lane, + position: customer.position, + speed: ENTITY_SPEEDS.PLATE + }]; + + return { ...customer, served: true, hasPlate: false }; } + return customer; + }); + }); - const newPlate: EmptyPlate = { id: `plate-star-${Date.now()}-${customer.id}`, lane: customer.lane, position: customer.position, speed: ENTITY_SPEEDS.PLATE }; - newState.emptyPlates = [...newState.emptyPlates, newPlate]; - return { ...customer, served: true, hasPlate: false }; - } - return customer; + // Remove consumed slices + newState.mafiaSlices = newState.mafiaSlices.filter(s => !mafiaSlicesToRemove.has(s.id)); + + // Add floating scores and stars + mafiaScores.forEach(({ points, lane, position }) => { + newState = addFloatingScore(points, lane, position, newState); + }); + mafiaStarGains.forEach(({ lane, position }) => { + newState = addFloatingStar(true, lane, position, newState); }); } - starPowerScores.forEach(({ points, lane, position }) => { newState = addFloatingScore(points, lane, position, newState); }); - // --- 6. CHEF POWERUP COLLISIONS --- - const caughtPowerUpIds = new Set(); - const powerUpScores: Array<{ points: number; lane: number; position: number }> = []; - newState.powerUps.forEach(powerUp => { - if (checkChefPowerUpCollision(newState.chefLane, GAME_CONFIG.CHEF_X_POSITION, powerUp) && !newState.nyanSweep?.active) { - soundManager.powerUpCollected(powerUp.type); - - if (powerUp.type !== 'moltobenny') { - const pointsEarned = calculatePowerUpScore(dogeMultiplier); - newState.score += pointsEarned; - powerUpScores.push({ points: pointsEarned, lane: powerUp.lane, position: powerUp.position }); - } + // --- 4. POWER-UP EXPIRATIONS --- + const expResult = processPowerUpExpirations(newState.activePowerUps, now); + newState.activePowerUps = expResult.activePowerUps; + newState.starPowerActive = expResult.starPowerActive; - caughtPowerUpIds.add(powerUp.id); - newState.stats.powerUpsUsed[powerUp.type] += 1; - - if (powerUp.type === 'beer') { - let livesLost = 0; - let lastReason: StarLostReason | undefined; - newState.customers = newState.customers.map(customer => { - if (customer.critic) { - if (customer.woozy) return { ...customer, woozy: false, woozyState: undefined, frozen: false, hotHoneyAffected: false, textMessage: "I prefer wine", textMessageTime: Date.now() }; - if (!customer.served && !customer.vomit && !customer.disappointed && !customer.leaving) return { ...customer, textMessage: "I prefer wine", textMessageTime: Date.now() }; - return customer; - } - if (customer.woozy) { - livesLost += 1; - lastReason = 'beer_vomit'; - return { ...customer, woozy: false, vomit: true, disappointed: true, movingRight: true }; - } - if (!customer.served && !customer.vomit && !customer.disappointed && !customer.leaving) { - if (customer.badLuckBrian) { - livesLost += 1; - lastReason = 'brian_hurled'; - return { ...customer, vomit: true, disappointed: true, movingRight: true, flipped: false, textMessage: "Oh man I hurled", textMessageTime: Date.now(), hotHoneyAffected: false, frozen: false }; - } - return { ...customer, woozy: true, woozyState: 'normal', movingRight: true, hotHoneyAffected: false, frozen: false }; - } - return customer; - }); - newState.lives = Math.max(0, newState.lives - livesLost); - if (livesLost > 0) { - soundManager.lifeLost(); - newState.stats.currentCustomerStreak = 0; - if (lastReason) newState.lastStarLostReason = lastReason; - } - if (newState.lives === 0) { - newState = triggerGameOver(newState, now); - } - } else if (powerUp.type === 'star') { - newState.availableSlices = GAME_CONFIG.MAX_SLICES; - newState.starPowerActive = true; - newState.activePowerUps = [...newState.activePowerUps.filter(p => p.type !== 'star'), { type: 'star', endTime: now + POWERUPS.DURATION }]; - } else if (powerUp.type === 'doge') { - newState.activePowerUps = [...newState.activePowerUps.filter(p => p.type !== 'doge'), { type: 'doge', endTime: now + POWERUPS.DURATION }]; - newState.powerUpAlert = { type: 'doge', endTime: now + POWERUPS.ALERT_DURATION_DOGE, chefLane: newState.chefLane }; - } else if (powerUp.type === 'nyan') { - if (!newState.nyanSweep?.active) { - newState.nyanSweep = { active: true, xPosition: GAME_CONFIG.CHEF_X_POSITION, laneDirection: 1, startTime: now, lastUpdateTime: now, startingLane: newState.chefLane }; - soundManager.nyanCatPowerUp(); - if (!hasDoge || newState.powerUpAlert?.type !== 'doge') { - newState.powerUpAlert = { type: 'nyan', endTime: now + POWERUPS.ALERT_DURATION_NYAN, chefLane: newState.chefLane }; - } - } - } else if (powerUp.type === 'moltobenny') { - const moltoScore = SCORING.MOLTOBENNY_POINTS * dogeMultiplier; - const moltoMoney = SCORING.MOLTOBENNY_CASH * dogeMultiplier; - newState.score += moltoScore; - newState.bank += moltoMoney; - powerUpScores.push({ points: moltoScore, lane: newState.chefLane, position: GAME_CONFIG.CHEF_X_POSITION }); - } else { - newState.activePowerUps = [...newState.activePowerUps.filter(p => p.type !== powerUp.type), { type: powerUp.type, endTime: now + POWERUPS.DURATION }]; - if (powerUp.type === 'honey') { - newState.customers = newState.customers.map(c => { - if (c.served || c.disappointed || c.vomit || c.leaving) return c; - if (c.badLuckBrian) return { ...c, shouldBeHotHoneyAffected: false, hotHoneyAffected: false, frozen: false, woozy: false, woozyState: undefined, textMessage: "I can't do spicy.", textMessageTime: Date.now() }; - return { ...c, shouldBeHotHoneyAffected: true, hotHoneyAffected: true, frozen: false, woozy: false, woozyState: undefined }; - }); - } - if (powerUp.type === 'ice-cream') { - newState.customers = newState.customers.map(c => { - if (!c.served && !c.disappointed && !c.vomit) { - if (c.badLuckBrian) return { ...c, textMessage: "I'm lactose intolerant", textMessageTime: Date.now() }; - return { ...c, shouldBeFrozenByIceCream: true, frozen: true, hotHoneyAffected: false, woozy: false, woozyState: undefined }; - } - return c; - }); + // Handle specific expiration effects + if (expResult.expiredTypes.includes('honey')) { + newState.customers = newState.customers.map(c => ({ ...c, hotHoneyAffected: false })); + } + + if (newState.powerUpAlert && now >= newState.powerUpAlert.endTime) { + if (newState.powerUpAlert.type !== 'doge' || !hasDoge) newState.powerUpAlert = undefined; + } + + // --- 4b. PEPE HELPERS PROCESSING --- + if (newState.pepeHelpers?.active) { + // Check expiration first + if (checkPepeHelpersExpired(newState.pepeHelpers, now)) { + newState.pepeHelpers = undefined; + } else { + // Process helper actions + const pepeResult = processPepeHelperTick(newState, now); + + // Apply state updates + if (pepeResult.updatedState.ovens) newState.ovens = pepeResult.updatedState.ovens; + if (pepeResult.updatedState.pizzaSlices) newState.pizzaSlices = pepeResult.updatedState.pizzaSlices; + if (pepeResult.updatedState.emptyPlates) newState.emptyPlates = pepeResult.updatedState.emptyPlates; + if (pepeResult.updatedState.pepeHelpers) newState.pepeHelpers = pepeResult.updatedState.pepeHelpers; + if (pepeResult.updatedState.stats) newState.stats = pepeResult.updatedState.stats; + if (pepeResult.updatedState.score !== undefined) newState.score = pepeResult.updatedState.score; + + // Handle events (sounds) + pepeResult.events.forEach(event => { + if (event.type === 'OVEN_STARTED') soundManager.ovenStart(); + if (event.type === 'PIZZA_PULLED') soundManager.servePizza(); + if (event.type === 'CUSTOMER_SERVED') soundManager.servePizza(); + if (event.type === 'PLATE_CAUGHT') soundManager.plateCaught(); + }); + + // Add floating scores for plates caught by helpers + pepeResult.events.forEach(event => { + if (event.type === 'PLATE_CAUGHT') { + newState = addFloatingScore(50, event.lane, GAME_CONFIG.CHEF_X_POSITION, newState); } - } + }); } + } + + // --- 5. STAR POWER AUTO-REFILL SLICES --- + if (hasStar) { + // Keep chef's pizza slices maxed out + newState.availableSlices = GAME_CONFIG.MAX_SLICES; + } + + // --- 6. CHEF POWERUP COLLISIONS --- + const powerUpResult = processChefPowerUpCollisions( + newState, + newState.chefLane, + GAME_CONFIG.CHEF_X_POSITION, + dogeMultiplier, + now + ); + newState = powerUpResult.newState; + + // Play sounds for caught power-ups + powerUpResult.caughtPowerUpIds.forEach(id => { + const powerUp = newState.powerUps.find(p => p.id === id); + if (powerUp) soundManager.powerUpCollected(powerUp.type); }); + + // Handle life loss sounds + if (powerUpResult.livesLost > 0) { + soundManager.lifeLost(); + if (powerUpResult.shouldTriggerGameOver) { + newState = triggerGameOver(newState, now); + } + } + + // Handle Nyan sweep sound + if (powerUpResult.nyanSweepStarted) { + soundManager.nyanCatPowerUp(); + } + + // Update power-ups: remove caught, move remaining, remove off-screen newState.powerUps = newState.powerUps - .filter(powerUp => !caughtPowerUpIds.has(powerUp.id)) + .filter(powerUp => !powerUpResult.caughtPowerUpIds.has(powerUp.id)) .map(powerUp => ({ ...powerUp, position: powerUp.position - powerUp.speed })) .filter(powerUp => powerUp.position > 0); - powerUpScores.forEach(({ points, lane, position }) => { newState = addFloatingScore(points, lane, position, newState); }); + + // Add floating scores + powerUpResult.scores.forEach(({ points, lane, position }) => { + newState = addFloatingScore(points, lane, position, newState); + }); // --- 7. PLATE CATCHING LOGIC --- - const platesToAddScores: Array<{ points: number; lane: number; position: number }> = []; - newState.emptyPlates = newState.emptyPlates - .map(plate => ({ ...plate, position: plate.position - plate.speed })) - .filter(plate => { - if (checkChefPlateCollision(newState.chefLane, plate) && !newState.nyanSweep?.active) { - soundManager.plateCaught(); - - const pointsEarned = calculatePlateScore( - dogeMultiplier, - getStreakMultiplier(newState.stats.currentPlateStreak) - ); - - newState.score += pointsEarned; - platesToAddScores.push({ points: pointsEarned, lane: plate.lane, position: plate.position }); - - newState.stats.platesCaught += 1; - newState.stats = updateStatsForStreak(newState.stats, 'plate'); - return false; - } else if (plate.position <= 0) { - soundManager.plateDropped(); - newState.stats.currentPlateStreak = 0; - return false; - } - return true; - }); - platesToAddScores.forEach(({ points, lane, position }) => { newState = addFloatingScore(points, lane, position, newState); }); + const plateResult = processPlates( + newState.emptyPlates, + newState.chefLane, + newState.stats, + dogeMultiplier, + getStreakMultiplier(newState.stats.currentPlateStreak), + newState.nyanSweep?.active ?? false + ); + + newState.emptyPlates = plateResult.remainingPlates; + newState.stats = plateResult.updatedStats; + newState.score += plateResult.totalScore; + + plateResult.events.forEach(event => { + if (event === 'CAUGHT') soundManager.plateCaught(); + else if (event === 'DROPPED') { + soundManager.plateDropped(); + newState.cleanKitchenStartTime = now; + } + }); + + plateResult.scores.forEach(({ points, lane, position }) => { + newState = addFloatingScore(points, lane, position, newState); + }); // --- 8. NYAN CAT SWEEP LOGIC --- if (newState.nyanSweep?.active) { - const MAX_X = 90; - const dt = Math.min(now - newState.nyanSweep.lastUpdateTime, 100); - const INITIAL_X = GAME_CONFIG.CHEF_X_POSITION; - const totalDistance = MAX_X - INITIAL_X; - const duration = 2600; - const moveIncrement = (totalDistance / duration) * dt; - const oldX = newState.nyanSweep.xPosition; - const newXPosition = oldX + moveIncrement; - const laneChangeSpeed = 0.01; - let newLane = newState.chefLane + (newState.nyanSweep.laneDirection * laneChangeSpeed * dt); - let newLaneDirection = newState.nyanSweep.laneDirection; - - if (newLane > GAME_CONFIG.LANE_BOTTOM) { - newLane = GAME_CONFIG.LANE_BOTTOM; - newLaneDirection = -1; - } else if (newLane < GAME_CONFIG.LANE_TOP) { - newLane = GAME_CONFIG.LANE_TOP; - newLaneDirection = 1; - } + // 1. Move Sweep + const sweepResult = processNyanSweepMovement(newState.nyanSweep, newState.chefLane, now); + + const newLane = sweepResult.nextChefLane; + // 2. Check Collisions const nyanScores: Array<{ points: number; lane: number; position: number }> = []; - newState.customers = newState.customers.map(customer => { - if (customer.served || customer.disappointed || customer.vomit) return customer; - if (checkNyanSweepCollision(newLane, oldX, newXPosition, customer)) { - if (customer.badLuckBrian) { + + const collisionResult = checkNyanSweepCollisions( + newState.nyanSweep, + sweepResult.newXPosition, + newLane, + newState.customers, + newState.bossBattle?.active && !newState.bossBattle.bossDefeated ? newState.bossBattle.minions : undefined + ); + + // 3. Process Customer Hits + const hitCustomerSet = new Set(collisionResult.hitCustomerIds); + + if (hitCustomerSet.size > 0) { + newState.customers = newState.customers.map(customer => { + if (hitCustomerSet.has(customer.id)) { + if (getCustomerVariant(customer) === 'badLuckBrian') { + soundManager.customerServed(); + return { ...customer, brianNyaned: true, leaving: true, hasPlate: false, flipped: false, movingRight: true, woozy: false, frozen: false, unfrozenThisPeriod: undefined }; + } + soundManager.customerServed(); - return { ...customer, brianNyaned: true, leaving: true, hasPlate: false, flipped: false, movingRight: true, woozy: false, frozen: false, unfrozenThisPeriod: undefined }; - } - soundManager.customerServed(); - const { points: pointsEarned, bank: bankEarned } = calculateCustomerScore( - customer, - dogeMultiplier, - getStreakMultiplier(newState.stats.currentCustomerStreak) - ); - - newState.score += pointsEarned; - newState.bank += bankEarned; - nyanScores.push({ points: pointsEarned, lane: customer.lane, position: customer.position }); - - newState.happyCustomers += 1; - newState.stats.customersServed += 1; - newState.stats = updateStatsForStreak(newState.stats, 'customer'); - - const lifeResult = checkLifeGain( - newState.lives, - newState.happyCustomers, - dogeMultiplier, - customer.critic, - customer.position - ); - if (lifeResult.livesToAdd > 0) { - newState.lives += lifeResult.livesToAdd; - if (lifeResult.shouldPlaySound) soundManager.lifeGained(); + const result = applyCustomerScoring(customer, newState, dogeMultiplier, + getStreakMultiplier(newState.stats.currentCustomerStreak), + { includeBank: true, countsAsServed: true, isFirstSlice: false, checkLifeGain: true }); + + newState.score += result.scoreToAdd; + newState.bank += result.bankToAdd; + newState.stats.totalEarned += result.bankToAdd; + newState.happyCustomers = result.newHappyCustomers; + newState.stats = result.newStats; + nyanScores.push(result.floatingScore); + + if (result.livesToAdd > 0) { + newState.lives += result.livesToAdd; + if (result.shouldPlayLifeSound) soundManager.lifeGained(); + newState = addFloatingStar(true, customer.lane, customer.position, newState); + } + + return { ...customer, served: true, hasPlate: false, woozy: false, frozen: false, unfrozenThisPeriod: undefined }; } - return { ...customer, served: true, hasPlate: false, woozy: false, frozen: false, unfrozenThisPeriod: undefined }; - } - return customer; - }); + return customer; + }); + } - if (newState.bossBattle?.active && !newState.bossBattle.bossDefeated) { + // 4. Process Minion Hits + const hitMinionSet = new Set(collisionResult.hitMinionIds); + if (hitMinionSet.size > 0 && newState.bossBattle) { newState.bossBattle.minions = newState.bossBattle.minions.map(minion => { - if (minion.defeated) return minion; - const isLaneHit = Math.abs(minion.lane - newLane) < 0.8; - const sweepStart = oldX - 10; - const sweepEnd = newXPosition + 10; - const isPositionHit = minion.position >= sweepStart && minion.position <= sweepEnd; - - if (isLaneHit && isPositionHit) { + if (hitMinionSet.has(minion.id)) { soundManager.customerServed(); - const pointsEarned = calculateMinionScore(); + const pointsEarned = calculateMinionScore(); // Assumes this is available in scope newState.score += pointsEarned; - newState = addFloatingScore(pointsEarned, minion.lane, minion.position, newState); + // addFloatingScore helper handles the state update for score list, but we can't call it easily inside map. + // We'll add to nyanScores list and process it after. + nyanScores.push({ points: pointsEarned, lane: minion.lane, position: minion.position }); return { ...minion, defeated: true }; } return minion; }); } + + // Add floaters nyanScores.forEach(({ points, lane, position }) => { newState = addFloatingScore(points, lane, position, newState); }); + // 5. Update State newState.chefLane = newLane; - newState.nyanSweep = { ...newState.nyanSweep, xPosition: newXPosition, laneDirection: newLaneDirection, lastUpdateTime: now }; - if (newState.nyanSweep.xPosition >= MAX_X) { - newState.chefLane = Math.round(newState.chefLane); - newState.chefLane = Math.max(GAME_CONFIG.LANE_TOP, Math.min(GAME_CONFIG.LANE_BOTTOM, newState.chefLane)); + if (sweepResult.sweepComplete) { + // Snap lane done in helper but helper returned finalLane as nextChefLane newState.nyanSweep = undefined; if (newState.pendingStoreShow) { newState.showStore = true; newState.pendingStoreShow = false; } + } else if (sweepResult.nextSweep) { + newState.nyanSweep = sweepResult.nextSweep; } } @@ -718,133 +822,85 @@ export const useGameLogic = (gameStarted: boolean = true) => { else newState.showStore = true; } - const crossedBossLevel = BOSS_CONFIG.TRIGGER_LEVELS.find(triggerLvl => - oldLevel < triggerLvl && targetLevel >= triggerLvl + // Check if boss battle should trigger + const bossTrigger = checkBossTrigger( + oldLevel, + targetLevel, + newState.defeatedBossLevels, + newState.bossBattle ); - - if (crossedBossLevel !== undefined && - !newState.defeatedBossLevels.includes(crossedBossLevel) && - !newState.bossBattle?.active) { - - const initialMinions: BossMinion[] = []; - for (let i = 0; i < BOSS_CONFIG.MINIONS_PER_WAVE; i++) { - initialMinions.push({ - id: `minion-${now}-1-${i}`, - lane: i % 4, - position: POSITIONS.SPAWN_X + (Math.floor(i / 4) * 15), - speed: ENTITY_SPEEDS.MINION, - defeated: false, - }); - } - - newState.bossBattle = { - active: true, - bossHealth: BOSS_CONFIG.HEALTH, - currentWave: 1, - minions: initialMinions, - bossVulnerable: true, - bossDefeated: false, - bossPosition: BOSS_CONFIG.BOSS_POSITION, - }; + if (bossTrigger !== null) { + newState.bossBattle = initializeBossBattle(now, bossTrigger.type); } } + // --- BOSS BATTLE PROCESSING --- if (newState.bossBattle?.active && !newState.bossBattle.bossDefeated) { - const bossScores: Array<{ points: number; lane: number; position: number }> = []; - newState.bossBattle.minions = newState.bossBattle.minions.map(minion => { - if (minion.defeated) return minion; - return { ...minion, position: minion.position - minion.speed }; - }); + const bossResult = processBossTick( + newState.bossBattle, + newState.pizzaSlices, + newState.level, + newState.defeatedBossLevels, + now + ); - newState.bossBattle.minions = newState.bossBattle.minions.map(minion => { - if (minion.defeated) return minion; - if (checkMinionReachedChef(minion)) { + newState.bossBattle = bossResult.nextBossBattle; + newState.pizzaSlices = newState.pizzaSlices.filter(s => !bossResult.consumedSliceIds.has(s.id)); + newState.score += bossResult.scoreGained; + + // Handle lives lost + if (bossResult.livesLost > 0) { + for (let i = 0; i < bossResult.livesLost; i++) { soundManager.lifeLost(); - newState.lives = Math.max(0, newState.lives - 1); - if (newState.lives === 0) { - newState = triggerGameOver(newState, now); - } - return { ...minion, defeated: true }; + newState = addFloatingStar(false, i % 4, GAME_CONFIG.CHEF_X_POSITION, newState); } - return minion; - }); - - const consumedSliceIds = new Set(); - newState.pizzaSlices.forEach(slice => { - if (consumedSliceIds.has(slice.id)) return; - newState.bossBattle!.minions = newState.bossBattle!.minions.map(minion => { - if (minion.defeated || consumedSliceIds.has(slice.id)) return minion; - if (checkSliceMinionCollision(slice, minion, 8)) { - consumedSliceIds.add(slice.id); - soundManager.customerServed(); - const pointsEarned = SCORING.MINION_DEFEAT; - newState.score += pointsEarned; - bossScores.push({ points: pointsEarned, lane: minion.lane, position: minion.position }); - return { ...minion, defeated: true }; - } - return minion; - }); - }); + newState.lives = Math.max(0, newState.lives - bossResult.livesLost); + if (newState.lives === 0) { + newState = triggerGameOver(newState, now); + } + } - if (newState.bossBattle.bossVulnerable) { - newState.pizzaSlices.forEach(slice => { - if (consumedSliceIds.has(slice.id)) return; - if (Math.abs(newState.bossBattle!.bossPosition - slice.position) < 10) { - consumedSliceIds.add(slice.id); - soundManager.customerServed(); - newState.bossBattle!.bossHealth -= 1; - const pointsEarned = SCORING.BOSS_HIT; - newState.score += pointsEarned; - bossScores.push({ points: pointsEarned, lane: slice.lane, position: slice.position }); - - if (newState.bossBattle!.bossHealth <= 0) { - newState.bossBattle!.bossDefeated = true; - newState.bossBattle!.active = false; - newState.bossBattle!.minions = []; - newState.score += SCORING.BOSS_DEFEAT; - bossScores.push({ points: SCORING.BOSS_DEFEAT, lane: 1, position: newState.bossBattle!.bossPosition }); - - const currentBossLevel = BOSS_CONFIG.TRIGGER_LEVELS - .slice() - .reverse() - .find(lvl => newState.level >= lvl); - - if (currentBossLevel && !newState.defeatedBossLevels.includes(currentBossLevel)) { - newState.defeatedBossLevels = [...newState.defeatedBossLevels, currentBossLevel]; - } - } - } - }); + // Handle defeated boss level + if (bossResult.defeatedBossLevel !== undefined) { + newState.defeatedBossLevels = [...newState.defeatedBossLevels, bossResult.defeatedBossLevel]; } - newState.pizzaSlices = newState.pizzaSlices.filter(slice => !consumedSliceIds.has(slice.id)); - bossScores.forEach(({ points, lane, position }) => { newState = addFloatingScore(points, lane, position, newState); }); - - const activeMinions = newState.bossBattle.minions.filter(m => !m.defeated); - if (activeMinions.length === 0) { - if (newState.bossBattle.currentWave < BOSS_CONFIG.WAVES) { - const nextWave = newState.bossBattle.currentWave + 1; - const newMinions: BossMinion[] = []; - for (let i = 0; i < BOSS_CONFIG.MINIONS_PER_WAVE; i++) { - newMinions.push({ - id: `minion-${now}-${nextWave}-${i}`, - lane: i % 4, - position: POSITIONS.SPAWN_X + (Math.floor(i / 4) * 15), - speed: ENTITY_SPEEDS.MINION, - defeated: false, - }); - } - newState.bossBattle.currentWave = nextWave; - newState.bossBattle.minions = newMinions; - } else if (!newState.bossBattle.bossVulnerable) { - newState.bossBattle.bossVulnerable = true; - newState.bossBattle.minions = []; + + // Play sounds and add floating scores for events + bossResult.events.forEach(event => { + if (event.type === 'MINION_DEFEATED' || event.type === 'BOSS_HIT' || event.type === 'BOSS_DEFEATED') { + soundManager.customerServed(); + newState = addFloatingScore(event.points, event.lane, event.position, newState); } + }); + } + + // --- CLEAN KITCHEN BONUS CHECK --- + if (newState.cleanKitchenStartTime !== undefined) { + const cleanDuration = now - newState.cleanKitchenStartTime; + const timeSinceLastBonus = newState.lastCleanKitchenBonusTime + ? now - newState.lastCleanKitchenBonusTime + : Infinity; + + // Award bonus if 30 seconds of clean kitchen and at least 30 seconds since last bonus + if (cleanDuration >= SCORING.CLEAN_KITCHEN_TIME && timeSinceLastBonus >= SCORING.CLEAN_KITCHEN_TIME) { + const bonusPoints = SCORING.CLEAN_KITCHEN_BONUS * dogeMultiplier; + newState.score += bonusPoints; + newState = addFloatingScore(bonusPoints, newState.chefLane, GAME_CONFIG.CHEF_X_POSITION, newState); + newState.cleanKitchenStartTime = now; // Reset timer for next bonus + newState.lastCleanKitchenBonusTime = now; + newState.cleanKitchenBonusAlert = { endTime: now + 3000 }; // Show for 3 seconds + soundManager.lifeGained(); // Use a celebratory sound } } + // Clear expired clean kitchen bonus alert + if (newState.cleanKitchenBonusAlert && now >= newState.cleanKitchenBonusAlert.endTime) { + newState.cleanKitchenBonusAlert = undefined; + } + return newState; }); - }, [addFloatingScore, triggerGameOver]); // ✅ removed gameState.* and ovenSoundStates deps + }, [addFloatingScore, addFloatingStar, triggerGameOver]); // ✅ removed gameState.* and ovenSoundStates deps // --- Store / Upgrades / Debug (now via storeSystem.ts) --- @@ -878,87 +934,39 @@ export const useGameLogic = (gameStarted: boolean = true) => { setGameState(prev => { if (prev.gameOver) return prev; const now = Date.now(); - let newState = { - ...prev, - stats: { - ...prev.stats, - powerUpsUsed: { ...prev.stats.powerUpsUsed, [type]: prev.stats.powerUpsUsed[type] + 1 } - } + + // Create synthetic power-up for the collection system + const syntheticPowerUp = { + id: `debug-${now}`, + lane: prev.chefLane, + position: GAME_CONFIG.CHEF_X_POSITION, + speed: 0, + type }; - const dogeMultiplier = prev.activePowerUps.some(p => p.type === 'doge') ? 2 : 1; + // Use the unified power-up collection system + const result = processPowerUpCollection(prev, syntheticPowerUp, 1, now); + let newState = result.newState; - if (type === 'beer') { - let livesLost = 0; - let lastReason: StarLostReason | undefined; - newState.customers = newState.customers.map(customer => { - if (customer.critic) { - if (customer.woozy) return { ...customer, woozy: false, woozyState: undefined, frozen: false, hotHoneyAffected: false, textMessage: "I prefer wine", textMessageTime: Date.now() }; - if (!customer.served && !customer.vomit && !customer.disappointed && !customer.leaving) return { ...customer, textMessage: "I prefer wine", textMessageTime: Date.now() }; - return customer; - } - if (customer.woozy) { - livesLost += 1; - lastReason = 'beer_vomit'; - return { ...customer, woozy: false, vomit: true, disappointed: true, movingRight: true }; - } - if (!customer.served && !customer.vomit && !customer.leaving) { - if (customer.badLuckBrian) { - livesLost += 1; - lastReason = 'brian_hurled'; - return { ...customer, vomit: true, disappointed: true, movingRight: true, flipped: false, textMessage: "Oh man I hurled", textMessageTime: Date.now(), hotHoneyAffected: false, frozen: false }; - } - return { ...customer, woozy: true, woozyState: 'normal', movingRight: true, hotHoneyAffected: false, frozen: false }; - } - return customer; - }); - newState.lives = Math.max(0, newState.lives - livesLost); - if (livesLost > 0) { - newState.stats.currentCustomerStreak = 0; - if (lastReason) newState.lastStarLostReason = lastReason; - } - if (newState.lives === 0) { - newState = triggerGameOver(newState as GameState, now); - } - } else if (type === 'star') { - newState.availableSlices = GAME_CONFIG.MAX_SLICES; - newState.starPowerActive = true; - newState.activePowerUps = [...newState.activePowerUps.filter(p => p.type !== 'star'), { type: 'star', endTime: now + POWERUPS.DURATION }]; - } else if (type === 'doge') { - newState.activePowerUps = [...newState.activePowerUps.filter(p => p.type !== 'doge'), { type: 'doge', endTime: now + POWERUPS.DURATION }]; - newState.powerUpAlert = { type: 'doge', endTime: now + POWERUPS.ALERT_DURATION_DOGE, chefLane: newState.chefLane }; - } else if (type === 'nyan') { - if (!newState.nyanSweep?.active) { - newState.nyanSweep = { active: true, xPosition: GAME_CONFIG.CHEF_X_POSITION, laneDirection: 1, startTime: now, lastUpdateTime: now, startingLane: newState.chefLane }; - soundManager.nyanCatPowerUp(); - if (!newState.activePowerUps.some(p => p.type === 'doge') || newState.powerUpAlert?.type !== 'doge') { - newState.powerUpAlert = { type: 'nyan', endTime: now + POWERUPS.ALERT_DURATION_NYAN, chefLane: newState.chefLane }; - } - } - } else { - newState.activePowerUps = [...newState.activePowerUps.filter(p => p.type !== type), { type: type, endTime: now + POWERUPS.DURATION }]; - if (type === 'honey') { - newState.customers = newState.customers.map(c => { - if (c.served || c.disappointed || c.vomit || c.leaving) return c; - if (c.badLuckBrian) return { ...c, shouldBeHotHoneyAffected: false, hotHoneyAffected: false, frozen: false, woozy: false, woozyState: undefined, textMessage: "I can't do spicy.", textMessageTime: Date.now() }; - return { ...c, shouldBeHotHoneyAffected: true, hotHoneyAffected: true, frozen: false, woozy: false, woozyState: undefined }; - }); - } - if (type === 'ice-cream') { - newState.customers = newState.customers.map(c => { - if (!c.served && !c.disappointed && !c.vomit) { - if (c.badLuckBrian) return { ...c, textMessage: "I'm lactose intolerant", textMessageTime: Date.now() }; - return { ...c, shouldBeFrozenByIceCream: true, frozen: true, hotHoneyAffected: false, woozy: false, woozyState: undefined }; - } - return c; - }); + // Handle side effects + if (result.livesLost > 0) { + soundManager.lifeLost(); + if (result.shouldTriggerGameOver) { + newState = triggerGameOver(newState, now); } } - return newState as GameState; + + // Play Nyan sweep sound if started + if (result.nyanSweepStarted) { + soundManager.nyanCatPowerUp(); + } + + return newState; }); }, [triggerGameOver]); const resetGame = useCallback(() => { + soundManager.stopNyan(); setGameState({ ...INITIAL_GAME_STATE }); lastCustomerSpawnRef.current = 0; lastPowerUpSpawnRef.current = 0; @@ -968,9 +976,32 @@ export const useGameLogic = (gameStarted: boolean = true) => { const togglePause = useCallback(() => { setGameState(prev => { + const now = Date.now(); const newPaused = !prev.paused; - const updatedOvens = calculateOvenPauseState(prev.ovens, newPaused, Date.now()); - return { ...prev, paused: newPaused, ovens: updatedOvens }; + const updatedOvens = calculateOvenPauseState(prev.ovens, newPaused, now); + + // Pause/resume Nyan cat song + if (newPaused) { + soundManager.pauseNyan(); + } else { + soundManager.resumeNyan(); + } + + // Handle clean kitchen timer pause/resume + let cleanKitchenStartTime = prev.cleanKitchenStartTime; + let lastPauseTime = prev.lastPauseTime; + + if (newPaused) { + // Starting pause - record when we paused + lastPauseTime = now; + } else if (prev.lastPauseTime && cleanKitchenStartTime) { + // Resuming - adjust clean kitchen start time to exclude pause duration + const pauseDuration = now - prev.lastPauseTime; + cleanKitchenStartTime = cleanKitchenStartTime + pauseDuration; + lastPauseTime = undefined; + } + + return { ...prev, paused: newPaused, ovens: updatedOvens, cleanKitchenStartTime, lastPauseTime }; }); }, []); @@ -982,18 +1013,31 @@ export const useGameLogic = (gameStarted: boolean = true) => { const now = Date.now(); if (!prevShowStore && currentShowStore) { + // Store opening - pause game setGameState(prev => ({ ...prev, paused: true, - ovens: calculateOvenPauseState(prev.ovens, true, now) + ovens: calculateOvenPauseState(prev.ovens, true, now), + lastPauseTime: now, // Track pause time for clean kitchen timer })); } if (prevShowStore && !currentShowStore) { - setGameState(prev => ({ - ...prev, - paused: false, - ovens: calculateOvenPauseState(prev.ovens, false, now) - })); + // Store closing - unpause game + setGameState(prev => { + // Adjust clean kitchen start time to exclude pause duration + let cleanKitchenStartTime = prev.cleanKitchenStartTime; + if (prev.lastPauseTime && cleanKitchenStartTime) { + const pauseDuration = now - prev.lastPauseTime; + cleanKitchenStartTime = cleanKitchenStartTime + pauseDuration; + } + return { + ...prev, + paused: false, + ovens: calculateOvenPauseState(prev.ovens, false, now), + cleanKitchenStartTime, + lastPauseTime: undefined, + }; + }); } prevShowStoreRef.current = currentShowStore; }, [gameState.showStore]); @@ -1009,76 +1053,25 @@ export const useGameLogic = (gameStarted: boolean = true) => { const now = Date.now(); - // Customer spawn (gate by min interval) - const spawnDelay = - SPAWN_RATES.CUSTOMER_MIN_INTERVAL_BASE - - (current.level * SPAWN_RATES.CUSTOMER_MIN_INTERVAL_DECREMENT); - - const levelSpawnRate = - SPAWN_RATES.CUSTOMER_BASE_RATE + - (current.level - 1) * SPAWN_RATES.CUSTOMER_LEVEL_INCREMENT; - - const effectiveSpawnRate = current.bossBattle?.active - ? levelSpawnRate * 0.5 - : levelSpawnRate; + // Use spawn system for customer and power-up spawning + const spawnResult = processSpawning( + lastCustomerSpawnRef.current, + lastPowerUpSpawnRef.current, + now, + current.level, + current.bossBattle?.active ?? false + ); let next = current; - if (now - lastCustomerSpawnRef.current >= spawnDelay && Math.random() < effectiveSpawnRate * 0.01) { - const lane = Math.floor(Math.random() * GAME_CONFIG.LANE_COUNT); - const disappointedEmojis = ['😢', '😭', '😠', '🤬']; - const isCritic = Math.random() < PROBABILITIES.CRITIC_CHANCE; - const isBadLuckBrian = !isCritic && Math.random() < PROBABILITIES.BAD_LUCK_BRIAN_CHANCE; - + if (spawnResult.newCustomer) { lastCustomerSpawnRef.current = now; - - next = { - ...next, - customers: [ - ...next.customers, - { - id: `customer-${now}-${lane}`, - lane, - position: POSITIONS.SPAWN_X, - speed: ENTITY_SPEEDS.CUSTOMER_BASE, - served: false, - hasPlate: false, - leaving: false, - disappointed: false, - disappointedEmoji: disappointedEmojis[Math.floor(Math.random() * disappointedEmojis.length)], - movingRight: false, - critic: isCritic, - badLuckBrian: isBadLuckBrian, - flipped: isBadLuckBrian, - } - ] - }; + next = { ...next, customers: [...next.customers, spawnResult.newCustomer] }; } - // PowerUp spawn (gate by min interval) - if (now - lastPowerUpSpawnRef.current >= SPAWN_RATES.POWERUP_MIN_INTERVAL && Math.random() < SPAWN_RATES.POWERUP_CHANCE) { - const lane = Math.floor(Math.random() * GAME_CONFIG.LANE_COUNT); - const rand = Math.random(); - const randomType = - rand < PROBABILITIES.POWERUP_STAR_CHANCE - ? 'star' - : POWERUPS.TYPES[Math.floor(Math.random() * POWERUPS.TYPES.length)]; - + if (spawnResult.newPowerUp) { lastPowerUpSpawnRef.current = now; - - next = { - ...next, - powerUps: [ - ...next.powerUps, - { - id: `powerup-${now}-${lane}`, - lane, - position: POSITIONS.POWERUP_SPAWN_X, - speed: ENTITY_SPEEDS.POWERUP, - type: randomType, - } - ] - }; + next = { ...next, powerUps: [...next.powerUps, spawnResult.newPowerUp] }; } return next; diff --git a/src/hooks/useMenuKeyboardNav.ts b/src/hooks/useMenuKeyboardNav.ts new file mode 100644 index 0000000..daa3372 --- /dev/null +++ b/src/hooks/useMenuKeyboardNav.ts @@ -0,0 +1,161 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +interface UseMenuKeyboardNavOptions { + itemCount: number; + columns?: number; // For grid layouts (default 1 = vertical list) + onSelect: (index: number) => void; + onEscape?: () => void; + isActive?: boolean; // Whether this menu is currently active/visible + initialIndex?: number; + loop?: boolean; // Whether to wrap around at edges (default true) +} + +/** + * Hook for keyboard navigation in menus + * Supports arrow keys, Enter to select, Escape to close + */ +export const useMenuKeyboardNav = ({ + itemCount, + columns = 1, + onSelect, + onEscape, + isActive = true, + initialIndex = 0, + loop = true, +}: UseMenuKeyboardNavOptions) => { + const [selectedIndex, setSelectedIndex] = useState(initialIndex); + const itemRefs = useRef<(HTMLButtonElement | HTMLElement | null)[]>([]); + + // Reset selection when menu becomes active + useEffect(() => { + if (isActive) { + setSelectedIndex(initialIndex); + } + }, [isActive, initialIndex]); + + // Focus the selected element when it changes + useEffect(() => { + if (isActive && itemRefs.current[selectedIndex]) { + itemRefs.current[selectedIndex]?.focus(); + } + }, [selectedIndex, isActive]); + + const navigate = useCallback((direction: 'up' | 'down' | 'left' | 'right') => { + if (itemCount === 0) return; + + setSelectedIndex(current => { + let next = current; + const rows = Math.ceil(itemCount / columns); + + switch (direction) { + case 'up': + next = current - columns; + if (next < 0) { + next = loop ? itemCount - 1 : current; + } + break; + case 'down': + next = current + columns; + if (next >= itemCount) { + next = loop ? 0 : current; + } + break; + case 'left': + if (columns > 1) { + // In grid, move left within row + if (current % columns === 0) { + next = loop ? current + columns - 1 : current; + if (next >= itemCount) next = itemCount - 1; + } else { + next = current - 1; + } + } else { + // In single column, treat as up + next = current - 1; + if (next < 0) next = loop ? itemCount - 1 : 0; + } + break; + case 'right': + if (columns > 1) { + // In grid, move right within row + if ((current + 1) % columns === 0 || current === itemCount - 1) { + next = loop ? current - (current % columns) : current; + } else { + next = current + 1; + } + } else { + // In single column, treat as down + next = current + 1; + if (next >= itemCount) next = loop ? 0 : itemCount - 1; + } + break; + } + + // Ensure next is within bounds + return Math.max(0, Math.min(itemCount - 1, next)); + }); + }, [itemCount, columns, loop]); + + useEffect(() => { + if (!isActive) return; + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + navigate('up'); + break; + case 'ArrowDown': + e.preventDefault(); + navigate('down'); + break; + case 'ArrowLeft': + e.preventDefault(); + navigate('left'); + break; + case 'ArrowRight': + e.preventDefault(); + navigate('right'); + break; + case 'Enter': + e.preventDefault(); + onSelect(selectedIndex); + break; + case 'Escape': + e.preventDefault(); + onEscape?.(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isActive, navigate, onSelect, onEscape, selectedIndex]); + + // Helper to register refs for focusable items + const registerItem = useCallback((index: number) => (el: HTMLButtonElement | HTMLElement | null) => { + itemRefs.current[index] = el; + }, []); + + // Helper to get props for each menu item + const getItemProps = useCallback((index: number) => ({ + ref: registerItem(index), + tabIndex: selectedIndex === index ? 0 : -1, + 'data-selected': selectedIndex === index, + onMouseEnter: () => setSelectedIndex(index), + onClick: () => onSelect(index), + // Prevent space bar from triggering button click (browser default) + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault(); + } + }, + }), [selectedIndex, registerItem, onSelect]); + + return { + selectedIndex, + setSelectedIndex, + getItemProps, + registerItem, + }; +}; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 38e5d2f..6ae40e0 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -4,14 +4,14 @@ export const GAME_CONFIG = { STARTING_LIVES: 3, LEVEL_THRESHOLD: 500, // Score needed to level up GAME_LOOP_INTERVAL: 50, // ms - + // Store Settings STORE_LEVEL_INTERVAL: 10, - + // Chef & Player MAX_SLICES: 8, CHEF_X_POSITION: 15, // The "catch/serve" zone (approx 15%) - + // Lanes LANE_COUNT: 4, LANE_TOP: 0, @@ -23,7 +23,7 @@ export const OVEN_CONFIG = { WARNING_TIME: 7000, // Pizza starts warning BURN_TIME: 8000, // Pizza burns (total time) CLEANING_TIME: 3000, - + // Upgrade Timings (based on speedUpgrade level 0-3) COOK_TIMES: [3000, 2500, 2000, 1500], MAX_UPGRADE_LEVEL: 7, @@ -51,30 +51,50 @@ export const SPAWN_RATES = { export const PROBABILITIES = { CRITIC_CHANCE: 0.15, BAD_LUCK_BRIAN_CHANCE: 0.1, // If not critic + SCUMBAG_STEVE_CHANCE: 0.08, // If not critic or brian + PIZZA_MAFIA_CHANCE: 0.05, // If not critic, brian, or steve POWERUP_STAR_CHANCE: 0.1, }; +export const SCUMBAG_STEVE = { + SPEED_MULTIPLIER: 1.4, // 40% faster than normal + SLICES_REQUIRED: 2, + LANE_CHANGE_INTERVAL: 1500, // ms between possible lane changes + LANE_CHANGE_CHANCE: 0.3, // 30% chance to change lane each interval +}; + +export const MAFIA_SLICE_CONFIG = { + SLICE_COUNT: 8, + SPEED: 2.5, + LIFETIME: 2000, // ms + LANE_SPEED: 0.02, // Vertical movement speed +}; + export const SCORING = { // Customer Service CUSTOMER_NORMAL: 150, CUSTOMER_CRITIC: 300, CUSTOMER_FIRST_SLICE: 50, // "Drooling" state - + // Actions PLATE_CAUGHT: 50, POWERUP_COLLECTED: 100, - + // Boss MINION_DEFEAT: 100, BOSS_HIT: 100, BOSS_DEFEAT: 5000, - + // Special MOLTOBENNY_POINTS: 10000, MOLTOBENNY_CASH: 69, - + // Bank BASE_BANK_REWARD: 1, + + // Clean Kitchen Bonus + CLEAN_KITCHEN_BONUS: 1000, + CLEAN_KITCHEN_TIME: 30000, // 30 seconds }; export const COSTS = { @@ -85,24 +105,50 @@ export const COSTS = { }; export const BOSS_CONFIG = { - TRIGGER_LEVELS: [30], + DOMINOS_LEVEL: 30, + PAPA_JOHN_LEVEL: 10, // Single appearance at level 10 + BOSS_POSITION: 85, +}; + +export const PAPA_JOHN_CONFIG = { + HEALTH: 40, // 40 slices to defeat, changes image every 8 hits + WAVES: 3, + MINIONS_PER_WAVE: 4, + HITS_PER_IMAGE: 8, // Change sprite every 8 hits +}; + +export const DOMINOS_CONFIG = { HEALTH: 24, WAVES: 3, MINIONS_PER_WAVE: 4, - BOSS_POSITION: 85, }; export const POWERUPS = { DURATION: 5000, // ms - ALERT_DURATION_DOGE: 5000, + DOGE_DURATION: 8750, // 75% longer than base duration + PEPE_DURATION: 8000, // 8 seconds + ALERT_DURATION_DOGE: 8750, ALERT_DURATION_NYAN: 3000, - TYPES: ['honey', 'ice-cream', 'beer', 'doge', 'nyan', 'moltobenny'] as const, + TYPES: ['honey', 'ice-cream', 'beer', 'doge', 'nyan', 'moltobenny', 'pepe'] as const, +}; + +export const NYAN_CONFIG = { + MAX_X: 90, // End position of sweep + DURATION: 2600, // Total sweep duration in ms + LANE_CHANGE_SPEED: 0.01, // Vertical movement speed + DT_MAX: 100, // Max delta time per frame +}; + +export const PEPE_CONFIG = { + ACTION_INTERVAL: 100, // ms between helper actions (famous chefs are fast!) + STARTING_SLICES: 4, // Famous chefs come prepared }; export const TIMINGS = { FLOATING_SCORE_LIFETIME: 1000, DROPPED_PLATE_LIFETIME: 1000, TEXT_MESSAGE_LIFETIME: 3000, + WARNING_BLINK_INTERVAL: 250, // ms between warning blinks }; export const POSITIONS = { @@ -113,13 +159,21 @@ export const POSITIONS = { TURN_AROUND_POINT: 90, // For woozy customers }; +// Lane positioning (percentage-based layout) +export const LAYOUT = { + LANE_HEIGHT_PERCENT: 25, // Each lane is 25% of board height + LANE_Y_OFFSET: 6, // Vertical offset within lane (%) +}; + export const INITIAL_GAME_STATE = { customers: [], pizzaSlices: [], + mafiaSlices: [], emptyPlates: [], powerUps: [], activePowerUps: [], floatingScores: [], + floatingStars: [], droppedPlates: [], chefLane: 0, score: 0, @@ -162,9 +216,17 @@ export const INITIAL_GAME_STATE = { doge: 0, nyan: 0, moltobenny: 0, + pepe: 0, + speed: 0, + slow: 0, }, ovenUpgradesMade: 0, + totalEarned: 0, + totalSpent: 0, }, bossBattle: undefined, - defeatedBossLevels:[], + defeatedBossLevels: [], + cleanKitchenStartTime: undefined, + lastCleanKitchenBonusTime: undefined, + cleanKitchenBonusAlert: undefined, }; \ No newline at end of file diff --git a/src/lib/spriteManifest.ts b/src/lib/spriteManifest.ts new file mode 100644 index 0000000..141a022 --- /dev/null +++ b/src/lib/spriteManifest.ts @@ -0,0 +1,57 @@ +// All sprites to preload before game starts +export const PRELOAD_SPRITES = [ + // Characters + 'chef.png', + 'sad-chef.png', + 'nyan-chef.png', + + // Customer faces + 'drool-face.png', + 'yum-face.png', + 'frozen-face.png', + 'woozy-face.png', + 'spicy-face.png', + 'critic.png', + 'bad-luck-brian.png', + 'bad-luck-brian-puke.png', + 'scumbag-steve.png', + 'rainbow-brian.png', + + // Food + 'slice-plate.png', + 'paperplate.png', + '1slicepizzapan.png', + '2slicepizzapan.png', + '3slicepizzapan.png', + '4slicepizzapan.png', + '5slicepizzapan.png', + '6slicepizzapan.png', + '7slicepizzapan.png', + '8slicepizzapan.png', + + // Power-ups + 'beer.png', + 'hot-honey.png', + 'sundae.png', + 'doge.png', + 'nyan-cat.png', + 'pepe.png', + 'molto-benny.png', + 'star.png', + + // Boss/special + 'dominos-boss.png', + 'papa-john.png', + 'papa-john-2.png', + 'papa-john-3.png', + 'papa-john-4.png', + 'papa-john-5.png', + 'papa-john-6.png', + 'franco-pepe.png', + 'frank-pepe.png', +] as const; + +// UI assets to preload (separate array for different CDN path) +export const PRELOAD_UI = [ + 'controls.png', +] as const; diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index cb15a97..67c897d 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,10 +1,9 @@ -import { createClient } from '@supabase/supabase-js'; +import { createClient, SupabaseClient } from '@supabase/supabase-js'; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; -if (!supabaseUrl || !supabaseAnonKey) { - throw new Error('Missing Supabase environment variables'); -} - -export const supabase = createClient(supabaseUrl, supabaseAnonKey); +export const supabase: SupabaseClient | null = + supabaseUrl && supabaseAnonKey + ? createClient(supabaseUrl, supabaseAnonKey) + : null; diff --git a/src/logic/bossCollisionMasks.ts b/src/logic/bossCollisionMasks.ts new file mode 100644 index 0000000..3533265 --- /dev/null +++ b/src/logic/bossCollisionMasks.ts @@ -0,0 +1,93 @@ +/** + * Boss Collision Masks + * + * Loads pre-generated collision masks for pixel-perfect collision detection. + * Masks are 32x32 boolean grids where true = solid pixel, false = transparent. + */ + +import { BOSS_CONFIG } from '../lib/constants'; + +const ASSET_BASE = "https://pizza-chef-assets.pages.dev"; + +export interface CollisionMask { + width: number; + height: number; + data: boolean[][]; +} + +// Cached masks for Papa John sprites (6 variants) +const papaJohnMasks: (CollisionMask | null)[] = [null, null, null, null, null, null]; +let masksInitialized = false; + +const PAPA_JOHN_MASK_FILES = [ + 'papa-john.json', + 'papa-john-2.json', + 'papa-john-3.json', + 'papa-john-4.json', + 'papa-john-5.json', + 'papa-john-6.json', +]; + +/** + * Fetch and cache all Papa John collision masks. + * Call once at game start. Fire-and-forget - game works without masks. + */ +export const initializeBossMasks = async (): Promise => { + if (masksInitialized) return; + + const loadPromises = PAPA_JOHN_MASK_FILES.map(async (filename, index) => { + try { + const url = `${ASSET_BASE}/sprites/masks/${filename}`; + const response = await fetch(url); + if (response.ok) { + const mask = await response.json() as CollisionMask; + papaJohnMasks[index] = mask; + } + } catch { + // Silently fail - game works without pixel-perfect collision + } + }); + + await Promise.all(loadPromises); + masksInitialized = true; +}; + +/** + * Get the collision mask for the current Papa John sprite. + * Returns null if masks haven't loaded yet. + * + * @param hitsReceived - Number of hits Papa John has taken + */ +export const getPapaJohnMask = (hitsReceived: number): CollisionMask | null => { + const spriteIndex = Math.min( + Math.floor(hitsReceived / BOSS_CONFIG.HITS_PER_IMAGE), + papaJohnMasks.length - 1 + ); + return papaJohnMasks[spriteIndex]; +}; + +/** + * Check if a point collides with a solid pixel in the mask. + * + * @param mask - The collision mask + * @param normalizedX - X position within sprite bounds (0-1) + * @param normalizedY - Y position within sprite bounds (0-1) + * @returns true if the point hits a solid pixel + */ +export const checkMaskCollision = ( + mask: CollisionMask, + normalizedX: number, + normalizedY: number +): boolean => { + // Clamp to valid range + if (normalizedX < 0 || normalizedX >= 1 || normalizedY < 0 || normalizedY >= 1) { + return false; + } + + // Map to mask grid coordinates + const gridX = Math.floor(normalizedX * mask.width); + const gridY = Math.floor(normalizedY * mask.height); + + // Return whether this cell is solid + return mask.data[gridY]?.[gridX] ?? false; +}; diff --git a/src/logic/bossSystem.ts b/src/logic/bossSystem.ts new file mode 100644 index 0000000..9f6c82f --- /dev/null +++ b/src/logic/bossSystem.ts @@ -0,0 +1,445 @@ +import { GameState, BossBattle, BossMinion, PizzaSlice, BossType } from '../types/game'; +import { BOSS_CONFIG, PAPA_JOHN_CONFIG, DOMINOS_CONFIG, POSITIONS, ENTITY_SPEEDS, SCORING } from '../lib/constants'; +import { checkSliceMinionCollision, checkMinionReachedChef } from './collisionSystem'; +import { getPapaJohnMask, checkMaskCollision } from './bossCollisionMasks'; + +export type BossEvent = + | { type: 'MINION_DEFEATED'; lane: number; position: number; points: number } + | { type: 'BOSS_HIT'; lane: number; position: number; points: number } + | { type: 'BOSS_DEFEATED'; lane: number; position: number; points: number } + | { type: 'MINION_REACHED_CHEF' } + | { type: 'WAVE_COMPLETE'; nextWave: number } + | { type: 'BOSS_VULNERABLE' }; + +export interface BossTickResult { + nextBossBattle: BossBattle; + consumedSliceIds: Set; + livesLost: number; + scoreGained: number; + events: BossEvent[]; + defeatedBossLevel?: number; +} + +export interface BossTriggerResult { + type: BossType; + level: number; +} + +/** + * Check if a boss battle should trigger based on level progression + */ +export const checkBossTrigger = ( + oldLevel: number, + newLevel: number, + defeatedBossLevels: number[], + currentBossBattle?: BossBattle +): BossTriggerResult | null => { + if (currentBossBattle?.active) return null; + + // Check Papa John (level 10) + if (oldLevel < BOSS_CONFIG.PAPA_JOHN_LEVEL && newLevel >= BOSS_CONFIG.PAPA_JOHN_LEVEL && + !defeatedBossLevels.includes(BOSS_CONFIG.PAPA_JOHN_LEVEL)) { + return { type: 'papaJohn', level: BOSS_CONFIG.PAPA_JOHN_LEVEL }; + } + + // Check Dominos (level 30) + if (oldLevel < BOSS_CONFIG.DOMINOS_LEVEL && newLevel >= BOSS_CONFIG.DOMINOS_LEVEL && + !defeatedBossLevels.includes(BOSS_CONFIG.DOMINOS_LEVEL)) { + return { type: 'dominos', level: BOSS_CONFIG.DOMINOS_LEVEL }; + } + + return null; +}; + +/** + * Create initial minions for a wave + */ +export const createWaveMinions = (waveNumber: number, now: number, minionsPerWave: number): BossMinion[] => { + const minions: BossMinion[] = []; + for (let i = 0; i < minionsPerWave; i++) { + minions.push({ + id: `minion-${now}-${waveNumber}-${i}`, + lane: i % 4, + position: POSITIONS.SPAWN_X + (Math.floor(i / 4) * 15), + speed: ENTITY_SPEEDS.MINION, + defeated: false, + }); + } + return minions; +}; + +/** + * Get boss config based on boss type + */ +const getBossConfig = (bossType: BossType) => { + return bossType === 'papaJohn' ? PAPA_JOHN_CONFIG : DOMINOS_CONFIG; +}; + +/** + * Initialize a new boss battle + */ +export const initializeBossBattle = ( + now: number, + bossType: BossType +): BossBattle => { + const config = getBossConfig(bossType); + // Papa John has no minions - immediately vulnerable + const isPapaJohn = bossType === 'papaJohn'; + return { + active: true, + bossType, + bossHealth: config.HEALTH, + currentWave: isPapaJohn ? config.WAVES : 1, // Skip waves for Papa John + minions: isPapaJohn ? [] : createWaveMinions(1, now, config.MINIONS_PER_WAVE), + bossVulnerable: isPapaJohn, // Papa John is immediately vulnerable + bossDefeated: false, + bossPosition: isPapaJohn ? 50 : BOSS_CONFIG.BOSS_POSITION, // Papa John starts in the middle + bossLane: 1.5, // Start in the middle (between lanes 1 and 2) + bossLaneDirection: 1, // Start moving down + bossXDirection: -1, // Start moving left + hitsReceived: 0, // Track hits for Papa John sprite changes + }; +}; + +/** + * Update boss position (moves around the board) + * Papa John runs all over, Dominos stays on the right + */ +export const updateBossLane = (bossBattle: BossBattle): BossBattle => { + if (!bossBattle.active || bossBattle.bossDefeated) return bossBattle; + + const isPapaJohn = bossBattle.bossType === 'papaJohn'; + + // Vertical movement (both bosses) + const BOSS_LANE_SPEED = isPapaJohn ? 0.04 : 0.02; // Papa John moves faster + const MIN_LANE = 0.5; + const MAX_LANE = 2.5; + + let newLane = bossBattle.bossLane + (BOSS_LANE_SPEED * bossBattle.bossLaneDirection); + let newLaneDirection = bossBattle.bossLaneDirection; + + // Bounce off top and bottom + if (newLane >= MAX_LANE) { + newLane = MAX_LANE; + newLaneDirection = -1; + } else if (newLane <= MIN_LANE) { + newLane = MIN_LANE; + newLaneDirection = 1; + } + + // Horizontal movement (Papa John only - runs all over!) + let newPosition = bossBattle.bossPosition; + let newXDirection = bossBattle.bossXDirection; + + if (isPapaJohn) { + const BOSS_X_SPEED = 0.3; // How fast Papa John runs horizontally + const MIN_X = 20; // Don't go too close to chef + const MAX_X = 85; // Right edge + + newPosition = bossBattle.bossPosition + (BOSS_X_SPEED * bossBattle.bossXDirection); + + // Bounce off left and right + if (newPosition >= MAX_X) { + newPosition = MAX_X; + newXDirection = -1; + } else if (newPosition <= MIN_X) { + newPosition = MIN_X; + newXDirection = 1; + } + } + + return { + ...bossBattle, + bossLane: newLane, + bossLaneDirection: newLaneDirection, + bossPosition: newPosition, + bossXDirection: newXDirection, + }; +}; + +/** + * Update minion positions (move left) + */ +export const updateMinionPositions = (minions: BossMinion[]): BossMinion[] => { + return minions.map(minion => { + if (minion.defeated) return minion; + return { ...minion, position: minion.position - minion.speed }; + }); +}; + +/** + * Check for minions reaching the chef (causes life loss) + */ +export const checkMinionsReachedChef = ( + minions: BossMinion[] +): { updatedMinions: BossMinion[]; livesLost: number } => { + let livesLost = 0; + const updatedMinions = minions.map(minion => { + if (minion.defeated) return minion; + if (checkMinionReachedChef(minion)) { + livesLost++; + return { ...minion, defeated: true }; + } + return minion; + }); + return { updatedMinions, livesLost }; +}; + +/** + * Process slice-minion collisions + */ +export const processSliceMinionCollisions = ( + slices: PizzaSlice[], + minions: BossMinion[] +): { + updatedMinions: BossMinion[]; + consumedSliceIds: Set; + events: BossEvent[]; + scoreGained: number; +} => { + const consumedSliceIds = new Set(); + const events: BossEvent[] = []; + let scoreGained = 0; + + let updatedMinions = [...minions]; + + slices.forEach(slice => { + if (consumedSliceIds.has(slice.id)) return; + + updatedMinions = updatedMinions.map(minion => { + if (minion.defeated || consumedSliceIds.has(slice.id)) return minion; + + if (checkSliceMinionCollision(slice, minion, 8)) { + consumedSliceIds.add(slice.id); + const points = SCORING.MINION_DEFEAT; + scoreGained += points; + events.push({ + type: 'MINION_DEFEATED', + lane: minion.lane, + position: minion.position, + points, + }); + return { ...minion, defeated: true }; + } + return minion; + }); + }); + + return { updatedMinions, consumedSliceIds, events, scoreGained }; +}; + +/** + * Process slice-boss collisions (when boss is vulnerable) + */ +export const processSliceBossCollisions = ( + slices: PizzaSlice[], + bossBattle: BossBattle, + alreadyConsumedIds: Set, + currentLevel: number, + defeatedBossLevels: number[] +): { + updatedBossBattle: BossBattle; + consumedSliceIds: Set; + events: BossEvent[]; + scoreGained: number; + defeatedBossLevel?: number; +} => { + if (!bossBattle.bossVulnerable) { + return { + updatedBossBattle: bossBattle, + consumedSliceIds: new Set(), + events: [], + scoreGained: 0, + }; + } + + const consumedSliceIds = new Set(); + const events: BossEvent[] = []; + let scoreGained = 0; + let updatedBossBattle = { ...bossBattle }; + let defeatedBossLevel: number | undefined; + + slices.forEach(slice => { + if (alreadyConsumedIds.has(slice.id) || consumedSliceIds.has(slice.id)) return; + + // Check both horizontal position AND vertical lane proximity + // bossPosition is left edge, boss is 24% wide, so center is at bossPosition + 12 + const bossCenterX = updatedBossBattle.bossPosition + 12; + const horizontalHit = Math.abs(bossCenterX - slice.position) < 14; // 14 = half width (12) + some margin + const verticalHit = Math.abs(updatedBossBattle.bossLane - slice.lane) < 1.2; // Boss is roughly 1 lane tall + + if (horizontalHit && verticalHit) { + // For Papa John, do pixel-perfect collision check + if (updatedBossBattle.bossType === 'papaJohn') { + const mask = getPapaJohnMask(updatedBossBattle.hitsReceived || 0); + if (mask) { + // Map game coords to sprite coords (0-1 range) + // Boss left edge is at bossPosition, width is 24% + const normalizedX = (slice.position - updatedBossBattle.bossPosition) / 24; + // Boss top edge is at bossLane (in lane units), height is 1 lane + // Slice is at center of its lane, so add 0.5 to align + const normalizedY = (slice.lane - updatedBossBattle.bossLane) + 0.5; + + if (!checkMaskCollision(mask, normalizedX, normalizedY)) { + return; // Hit transparent area - skip this slice + } + } + } + + consumedSliceIds.add(slice.id); + updatedBossBattle.bossHealth -= 1; + updatedBossBattle.hitsReceived = (updatedBossBattle.hitsReceived || 0) + 1; + + const points = SCORING.BOSS_HIT; + scoreGained += points; + events.push({ + type: 'BOSS_HIT', + lane: slice.lane, + position: slice.position, + points, + }); + + if (updatedBossBattle.bossHealth <= 0) { + updatedBossBattle.bossDefeated = true; + updatedBossBattle.active = false; + updatedBossBattle.minions = []; + + scoreGained += SCORING.BOSS_DEFEAT; + events.push({ + type: 'BOSS_DEFEATED', + lane: 1, + position: updatedBossBattle.bossPosition, + points: SCORING.BOSS_DEFEAT, + }); + + // Find current boss level to mark as defeated + let currentBossLevel: number | undefined; + if (updatedBossBattle.bossType === 'papaJohn') { + currentBossLevel = currentLevel >= BOSS_CONFIG.PAPA_JOHN_LEVEL ? BOSS_CONFIG.PAPA_JOHN_LEVEL : undefined; + } else { + currentBossLevel = currentLevel >= BOSS_CONFIG.DOMINOS_LEVEL ? BOSS_CONFIG.DOMINOS_LEVEL : undefined; + } + + if (currentBossLevel && !defeatedBossLevels.includes(currentBossLevel)) { + defeatedBossLevel = currentBossLevel; + } + } + } + }); + + return { updatedBossBattle, consumedSliceIds, events, scoreGained, defeatedBossLevel }; +}; + +/** + * Check wave completion and spawn next wave or make boss vulnerable + */ +export const checkWaveCompletion = ( + bossBattle: BossBattle, + now: number +): { updatedBossBattle: BossBattle; events: BossEvent[] } => { + const activeMinions = bossBattle.minions.filter(m => !m.defeated); + const events: BossEvent[] = []; + + if (activeMinions.length > 0) { + return { updatedBossBattle: bossBattle, events }; + } + + let updatedBossBattle = { ...bossBattle }; + const config = getBossConfig(bossBattle.bossType); + + if (bossBattle.currentWave < config.WAVES) { + const nextWave = bossBattle.currentWave + 1; + updatedBossBattle.currentWave = nextWave; + updatedBossBattle.minions = createWaveMinions(nextWave, now, config.MINIONS_PER_WAVE); + events.push({ type: 'WAVE_COMPLETE', nextWave }); + } else if (!bossBattle.bossVulnerable) { + updatedBossBattle.bossVulnerable = true; + updatedBossBattle.minions = []; + events.push({ type: 'BOSS_VULNERABLE' }); + } + + return { updatedBossBattle, events }; +}; + +/** + * Process a full boss battle tick + */ +export const processBossTick = ( + bossBattle: BossBattle, + slices: PizzaSlice[], + currentLevel: number, + defeatedBossLevels: number[], + now: number +): BossTickResult => { + if (!bossBattle.active || bossBattle.bossDefeated) { + return { + nextBossBattle: bossBattle, + consumedSliceIds: new Set(), + livesLost: 0, + scoreGained: 0, + events: [], + }; + } + + const allEvents: BossEvent[] = []; + let totalScore = 0; + let totalLivesLost = 0; + const allConsumedSliceIds = new Set(); + + // 1. Move minions + let currentMinions = updateMinionPositions(bossBattle.minions); + + // 2. Check minions reaching chef + const reachResult = checkMinionsReachedChef(currentMinions); + currentMinions = reachResult.updatedMinions; + totalLivesLost = reachResult.livesLost; + if (reachResult.livesLost > 0) { + for (let i = 0; i < reachResult.livesLost; i++) { + allEvents.push({ type: 'MINION_REACHED_CHEF' }); + } + } + + // 3. Process slice-minion collisions + const minionCollisionResult = processSliceMinionCollisions(slices, currentMinions); + currentMinions = minionCollisionResult.updatedMinions; + minionCollisionResult.consumedSliceIds.forEach(id => allConsumedSliceIds.add(id)); + totalScore += minionCollisionResult.scoreGained; + allEvents.push(...minionCollisionResult.events); + + let currentBossBattle: BossBattle = { + ...bossBattle, + minions: currentMinions, + }; + + // 3.5. Update boss vertical movement + currentBossBattle = updateBossLane(currentBossBattle); + + // 4. Process slice-boss collisions (if vulnerable) + const bossCollisionResult = processSliceBossCollisions( + slices, + currentBossBattle, + allConsumedSliceIds, + currentLevel, + defeatedBossLevels + ); + currentBossBattle = bossCollisionResult.updatedBossBattle; + bossCollisionResult.consumedSliceIds.forEach(id => allConsumedSliceIds.add(id)); + totalScore += bossCollisionResult.scoreGained; + allEvents.push(...bossCollisionResult.events); + + // 5. Check wave completion + if (!currentBossBattle.bossDefeated) { + const waveResult = checkWaveCompletion(currentBossBattle, now); + currentBossBattle = waveResult.updatedBossBattle; + allEvents.push(...waveResult.events); + } + + return { + nextBossBattle: currentBossBattle, + consumedSliceIds: allConsumedSliceIds, + livesLost: totalLivesLost, + scoreGained: totalScore, + events: allEvents, + defeatedBossLevel: bossCollisionResult.defeatedBossLevel, + }; +}; diff --git a/src/logic/collisionSystem.ts b/src/logic/collisionSystem.ts index c510a1b..a591e64 100644 --- a/src/logic/collisionSystem.ts +++ b/src/logic/collisionSystem.ts @@ -48,6 +48,20 @@ export const checkChefPowerUpCollision = ( return powerUp.lane === chefLane && powerUp.position <= chefX; }; +/** + * Calculates the visual lane for a plate (handles angled throws) + */ +const getPlateVisualLane = (plate: EmptyPlate): number => { + if (plate.targetLane !== undefined && plate.startLane !== undefined && plate.startPosition !== undefined) { + const OVEN_POSITION = 10; + const totalDistance = plate.startPosition - OVEN_POSITION; + const traveled = plate.startPosition - plate.position; + const progress = Math.min(1, Math.max(0, traveled / totalDistance)); + return plate.startLane + (plate.targetLane - plate.startLane) * progress; + } + return plate.lane; +}; + /** * Checks if the chef has caught an empty plate. */ @@ -56,7 +70,10 @@ export const checkChefPlateCollision = ( plate: EmptyPlate, threshold: number = 10 ): boolean => { - return plate.lane === chefLane && plate.position <= threshold; + const visualLane = getPlateVisualLane(plate); + // For angled plates, check if within 0.5 lane distance for more forgiving collision + const laneTolerance = plate.targetLane !== undefined ? 0.5 : 0; + return Math.abs(visualLane - chefLane) <= laneTolerance && plate.position <= threshold; }; /** diff --git a/src/logic/customerSystem.test.ts b/src/logic/customerSystem.test.ts new file mode 100644 index 0000000..978a124 --- /dev/null +++ b/src/logic/customerSystem.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect } from 'vitest'; +import { updateCustomerPositions, processCustomerHit, CustomerUpdateResult, CustomerHitResult } from './customerSystem'; +import { Customer, ActivePowerUp } from '../types/game'; + +// Helper to create a basic customer +const createCustomer = (overrides: Partial = {}): Customer => ({ + id: 'test-customer-1', + lane: 0, + position: 80, + speed: 0.5, + served: false, + hasPlate: false, + leaving: false, + disappointed: false, + disappointedEmoji: '😢', + movingRight: false, + critic: false, + badLuckBrian: false, + flipped: false, + ...overrides, +}); + +describe('Customer System - Integrated Tests', () => { + const now = Date.now(); + + describe('Customer Movement (updateCustomerPositions)', () => { + it('should move customer left when approaching', () => { + const customer = createCustomer({ position: 80, speed: 0.5 }); + const result = updateCustomerPositions([customer], [], now); + + expect(result.nextCustomers[0].position).toBeLessThan(80); + }); + + it('should move customer right when served', () => { + const customer = createCustomer({ position: 50, served: true }); + const result = updateCustomerPositions([customer], [], now); + + expect(result.nextCustomers[0].position).toBeGreaterThan(50); + }); + + it('should remove customer when off screen left (position <= -10)', () => { + const customer = createCustomer({ position: -10 }); + const result = updateCustomerPositions([customer], [], now); + + // Customer should not be in nextCustomers (removed) + expect(result.nextCustomers.length).toBe(0); + }); + + it('should remove customer when off screen right', () => { + const customer = createCustomer({ position: 101, served: true }); + const result = updateCustomerPositions([customer], [], now); + + expect(result.nextCustomers.length).toBe(0); + }); + }); + + describe('Customer Disappointment', () => { + it('should mark customer disappointed when reaching chef (position <= 15)', () => { + const customer = createCustomer({ position: 16, speed: 2 }); // Will move to 14 + const result = updateCustomerPositions([customer], [], now); + + expect(result.nextCustomers[0].disappointed).toBe(true); + expect(result.nextCustomers[0].movingRight).toBe(true); + expect(result.events.some(e => e.type === 'LIFE_LOST')).toBe(true); + }); + + it('should not mark served customer as disappointed', () => { + const customer = createCustomer({ position: 14, served: true }); + const result = updateCustomerPositions([customer], [], now); + + expect(result.nextCustomers[0].disappointed).toBe(false); + }); + }); + + describe('Frozen Effect (Ice Cream)', () => { + it('should freeze normal customer when ice cream active and shouldBeFrozenByIceCream', () => { + const customer = createCustomer({ shouldBeFrozenByIceCream: true }); + const iceCreamPowerUp: ActivePowerUp = { type: 'ice-cream', endTime: now + 5000 }; + + const result = updateCustomerPositions([customer], [iceCreamPowerUp], now); + + expect(result.nextCustomers[0].frozen).toBe(true); + }); + + it('should not freeze Bad Luck Brian', () => { + const customer = createCustomer({ + badLuckBrian: true, + shouldBeFrozenByIceCream: true + }); + const iceCreamPowerUp: ActivePowerUp = { type: 'ice-cream', endTime: now + 5000 }; + + const result = updateCustomerPositions([customer], [iceCreamPowerUp], now); + + // Brian is immune - shouldBeFrozenByIceCream check is bypassed for badLuckBrian + expect(result.nextCustomers[0].frozen).toBeFalsy(); + }); + + it('should not move frozen customer', () => { + const customer = createCustomer({ + position: 50, + frozen: true, + shouldBeFrozenByIceCream: true + }); + const iceCreamPowerUp: ActivePowerUp = { type: 'ice-cream', endTime: now + 5000 }; + + const result = updateCustomerPositions([customer], [iceCreamPowerUp], now); + + // Position should remain the same (frozen) + expect(result.nextCustomers[0].position).toBe(50); + }); + }); + + describe('Hot Honey Effect', () => { + it('should slow down normal customer when honey active (half speed)', () => { + const customer = createCustomer({ + position: 80, + speed: 1, + shouldBeHotHoneyAffected: true + }); + const honeyPowerUp: ActivePowerUp = { type: 'honey', endTime: now + 5000 }; + + const result = updateCustomerPositions([customer], [honeyPowerUp], now); + + // Hot honey slows customers (speed * 0.5), so movement should be 0.5 + const actualMovement = 80 - result.nextCustomers[0].position; + expect(actualMovement).toBeCloseTo(0.5, 1); + expect(result.nextCustomers[0].hotHoneyAffected).toBe(true); + }); + + it('should not affect critic with hot honey', () => { + const customer = createCustomer({ + critic: true, + shouldBeHotHoneyAffected: true + }); + const honeyPowerUp: ActivePowerUp = { type: 'honey', endTime: now + 5000 }; + + const result = updateCustomerPositions([customer], [honeyPowerUp], now); + + expect(result.nextCustomers[0].hotHoneyAffected).toBe(false); + expect(result.nextCustomers[0].textMessage).toBe('Just plain, thanks.'); + }); + + it('should not affect Bad Luck Brian with hot honey', () => { + const customer = createCustomer({ + badLuckBrian: true, + shouldBeHotHoneyAffected: true + }); + const honeyPowerUp: ActivePowerUp = { type: 'honey', endTime: now + 5000 }; + + const result = updateCustomerPositions([customer], [honeyPowerUp], now); + + expect(result.nextCustomers[0].hotHoneyAffected).toBe(false); + expect(result.nextCustomers[0].textMessage).toBe("I can't do spicy."); + }); + }); + + describe('Woozy Movement', () => { + it('should move woozy customer right when movingRight is true', () => { + const customer = createCustomer({ + position: 50, + woozy: true, + woozyState: 'normal', + movingRight: true + }); + + const result = updateCustomerPositions([customer], [], now); + + expect(result.nextCustomers[0].position).toBeGreaterThan(50); + }); + + it('should move woozy customer left when movingRight is false', () => { + const customer = createCustomer({ + position: 50, + woozy: true, + woozyState: 'normal', + movingRight: false + }); + + const result = updateCustomerPositions([customer], [], now); + + expect(result.nextCustomers[0].position).toBeLessThan(50); + }); + }); + + describe('processCustomerHit', () => { + it('should serve normal customer and create empty plate', () => { + const customer = createCustomer(); + const result = processCustomerHit(customer, now); + + expect(result.updatedCustomer.served).toBe(true); + expect(result.events).toContain('SERVED_NORMAL'); + expect(result.newEntities.emptyPlate).toBeDefined(); + }); + + it('should serve critic and emit SERVED_CRITIC event', () => { + const customer = createCustomer({ critic: true }); + const result = processCustomerHit(customer, now); + + expect(result.updatedCustomer.served).toBe(true); + expect(result.events).toContain('SERVED_CRITIC'); + }); + + it('should handle Bad Luck Brian drop and create dropped plate', () => { + const customer = createCustomer({ badLuckBrian: true }); + const result = processCustomerHit(customer, now); + + expect(result.updatedCustomer.leaving).toBe(true); + expect(result.events).toContain('BRIAN_DROPPED_PLATE'); + expect(result.newEntities.droppedPlate).toBeDefined(); + expect(result.updatedCustomer.textMessage).toBe("Ugh! I dropped my slice!"); + }); + + it('should serve Bad Luck Brian when doge power-up is active', () => { + const customer = createCustomer({ badLuckBrian: true }); + const result = processCustomerHit(customer, now, true); // dogeActive = true + + expect(result.updatedCustomer.served).toBe(true); + expect(result.updatedCustomer.leaving).toBeFalsy(); + expect(result.events).toContain('SERVED_BRIAN_DOGE'); + expect(result.newEntities.emptyPlate).toBeDefined(); + expect(result.updatedCustomer.textMessage).toBe("Such yum!"); + }); + + it('should unfreeze and serve frozen customer', () => { + const customer = createCustomer({ frozen: true }); + const result = processCustomerHit(customer, now); + + expect(result.updatedCustomer.frozen).toBe(false); + expect(result.updatedCustomer.unfrozenThisPeriod).toBe(true); + expect(result.updatedCustomer.served).toBe(true); + expect(result.events).toContain('UNFROZEN_AND_SERVED'); + }); + + it('should handle woozy customer first hit (step 1)', () => { + const customer = createCustomer({ woozy: true, woozyState: 'normal' }); + const result = processCustomerHit(customer, now); + + expect(result.updatedCustomer.woozyState).toBe('drooling'); + expect(result.updatedCustomer.woozy).toBe(false); + expect(result.events).toContain('WOOZY_STEP_1'); + }); + + it('should handle woozy customer second hit (step 2)', () => { + const customer = createCustomer({ woozy: true, woozyState: 'drooling' }); + const result = processCustomerHit(customer, now); + + expect(result.updatedCustomer.woozyState).toBe('satisfied'); + expect(result.updatedCustomer.served).toBe(true); + expect(result.events).toContain('WOOZY_STEP_2'); + }); + }); + + describe('Bad Luck Brian Behavior', () => { + it('should move Brian right when movingRight is true', () => { + const customer = createCustomer({ + badLuckBrian: true, + movingRight: true, + position: 50 + }); + + const result = updateCustomerPositions([customer], [], now); + + expect(result.nextCustomers[0].position).toBeGreaterThan(50); + }); + + it('should make Brian leave with complaint when reaching chef', () => { + const customer = createCustomer({ + badLuckBrian: true, + position: 16, + speed: 2 + }); + + const result = updateCustomerPositions([customer], [], now); + + expect(result.nextCustomers[0].leaving).toBe(true); + expect(result.nextCustomers[0].textMessage).toBe("You don't have gluten free?"); + // Brian doesn't cause LIFE_LOST + expect(result.events.some(e => e.type === 'LIFE_LOST')).toBe(false); + }); + }); + + describe('Nyan Cat Effect', () => { + it('should push brianNyaned customer right and up', () => { + const customer = createCustomer({ + brianNyaned: true, + position: 50, + lane: 2 + }); + + const result = updateCustomerPositions([customer], [], now); + + expect(result.nextCustomers[0].position).toBeGreaterThan(50); + expect(result.nextCustomers[0].lane).toBeLessThan(2); + }); + }); +}); diff --git a/src/logic/customerSystem.ts b/src/logic/customerSystem.ts index 16d8e4f..5488d90 100644 --- a/src/logic/customerSystem.ts +++ b/src/logic/customerSystem.ts @@ -1,12 +1,19 @@ -import { Customer, DroppedPlate, EmptyPlate, GameState } from '../types/game'; -import { ENTITY_SPEEDS, GAME_CONFIG, POSITIONS } from '../lib/constants'; +import { + Customer, + DroppedPlate, + EmptyPlate, + isCustomerLeaving, + isCustomerAffectedByPowerUps, + getCustomerVariant +} from '../types/game'; +import { ENTITY_SPEEDS, GAME_CONFIG, POSITIONS, SCUMBAG_STEVE } from '../lib/constants'; // --- Types for the Update Result --- -export type CustomerUpdateEvent = - | 'GAME_OVER' - | 'LIFE_LOST' - | 'STAR_LOST_CRITIC' - | 'STAR_LOST_NORMAL'; +export type CustomerUpdateEvent = + | { type: 'GAME_OVER' } + | { type: 'LIFE_LOST'; lane: number; position: number } + | { type: 'STAR_LOST_CRITIC'; lane: number; position: number } + | { type: 'STAR_LOST_NORMAL'; lane: number; position: number }; export interface CustomerUpdateResult { nextCustomers: Customer[]; @@ -17,13 +24,17 @@ export interface CustomerUpdateResult { } // --- Types for the Hit Result --- -export type CustomerHitEvent = +export type CustomerHitEvent = | 'SERVED_NORMAL' | 'SERVED_CRITIC' + | 'SERVED_BRIAN_DOGE' | 'WOOZY_STEP_1' | 'WOOZY_STEP_2' | 'UNFROZEN_AND_SERVED' - | 'BRIAN_DROPPED_PLATE'; + | 'BRIAN_DROPPED_PLATE' + | 'STEVE_FIRST_SLICE' + | 'STEVE_SERVED' + | 'MAFIA_SERVED'; export interface CustomerHitResult { updatedCustomer: Customer; @@ -71,7 +82,15 @@ export const updateCustomerPositions = ( processedCustomer.textMessage = "I can't do spicy."; processedCustomer.textMessageTime = now; } - } else if (!processedCustomer.woozy && !processedCustomer.served && !processedCustomer.leaving && !processedCustomer.disappointed) { + // Critics are immune to hot honey + } else if (processedCustomer.critic) { + if (processedCustomer.hotHoneyAffected || processedCustomer.shouldBeHotHoneyAffected) { + processedCustomer.hotHoneyAffected = false; + processedCustomer.shouldBeHotHoneyAffected = false; + processedCustomer.textMessage = "Just plain, thanks."; + processedCustomer.textMessageTime = now; + } + } else if (isCustomerAffectedByPowerUps(processedCustomer)) { // Normal customers get effects if (hasHoney && hasIceCream) { if (honeyEnd > iceCreamEnd) { @@ -98,15 +117,25 @@ export const updateCustomerPositions = ( // Clear effects if powerups are gone if (!hasIceCream && (processedCustomer.frozen || processedCustomer.shouldBeFrozenByIceCream)) { + // If woozy customer was frozen, cure them when ice cream ends + if (processedCustomer.woozy && processedCustomer.frozen) { + processedCustomer.woozy = false; + processedCustomer.woozyState = 'drooling'; + } processedCustomer.frozen = false; processedCustomer.shouldBeFrozenByIceCream = false; } if (!hasHoney && processedCustomer.hotHoneyAffected) { + // If woozy customer had hot honey, cure them when honey ends + if (processedCustomer.woozy) { + processedCustomer.woozy = false; + processedCustomer.woozyState = 'drooling'; + } processedCustomer.hotHoneyAffected = false; } // C. Movement Calculations - const isDeparting = processedCustomer.served || processedCustomer.disappointed || processedCustomer.vomit || processedCustomer.leaving; + const isDeparting = isCustomerLeaving(processedCustomer); // 1. Nyan Cat pushed (Zoom!) if (processedCustomer.brianNyaned) { @@ -146,10 +175,12 @@ export const updateCustomerPositions = ( const newPos = processedCustomer.position - (processedCustomer.speed * 0.75); if (newPos <= GAME_CONFIG.CHEF_X_POSITION) { // Game Over Condition for Woozy - events.push('LIFE_LOST'); - events.push(processedCustomer.critic ? 'STAR_LOST_CRITIC' : 'STAR_LOST_NORMAL'); - events.push('GAME_OVER'); // Technically game over logic checks lives later, but this signals a fail state - + events.push({ type: 'LIFE_LOST', lane: processedCustomer.lane, position: newPos }); + events.push(getCustomerVariant(processedCustomer) === 'critic' + ? { type: 'STAR_LOST_CRITIC', lane: processedCustomer.lane, position: newPos } + : { type: 'STAR_LOST_NORMAL', lane: processedCustomer.lane, position: newPos }); + events.push({ type: 'GAME_OVER' }); // Technically game over logic checks lives later, but this signals a fail state + processedCustomer.disappointed = true; processedCustomer.movingRight = true; processedCustomer.woozy = false; @@ -180,7 +211,7 @@ export const updateCustomerPositions = ( // Moving Left (Approaching) const speedMod = processedCustomer.hotHoneyAffected ? 0.5 : 1; const newPos = processedCustomer.position - (processedCustomer.speed * speedMod); - + if (newPos <= GAME_CONFIG.CHEF_X_POSITION) { // Brian Reaches Chef -> Complains and Leaves (No Game Over) processedCustomer.position = newPos; @@ -197,16 +228,66 @@ export const updateCustomerPositions = ( return; } + // 6.5. Scumbag Steve Special Movement (Lane Changing) + if (processedCustomer.scumbagSteve && !isDeparting) { + // Check for lane change + const lastChange = processedCustomer.lastLaneChangeTime || 0; + if (now - lastChange >= SCUMBAG_STEVE.LANE_CHANGE_INTERVAL) { + if (Math.random() < SCUMBAG_STEVE.LANE_CHANGE_CHANCE) { + // Change to a random adjacent lane + const currentLane = processedCustomer.lane; + let newLane: number; + if (currentLane === 0) { + newLane = 1; + } else if (currentLane === GAME_CONFIG.LANE_COUNT - 1) { + newLane = GAME_CONFIG.LANE_COUNT - 2; + } else { + newLane = Math.random() < 0.5 ? currentLane - 1 : currentLane + 1; + } + processedCustomer.lane = newLane; + } + processedCustomer.lastLaneChangeTime = now; + } + + // Steve moves (faster than normal, set in spawn) + if (processedCustomer.movingRight) { + processedCustomer.position += processedCustomer.speed; + nextCustomers.push(processedCustomer); + return; + } + + const newPos = processedCustomer.position - processedCustomer.speed; + if (newPos <= GAME_CONFIG.CHEF_X_POSITION) { + // Steve reaches chef without enough pizza -> Disappointed + events.push({ type: 'LIFE_LOST', lane: processedCustomer.lane, position: newPos }); + events.push({ type: 'STAR_LOST_NORMAL', lane: processedCustomer.lane, position: newPos }); + events.push({ type: 'GAME_OVER' }); + + processedCustomer.disappointed = true; + processedCustomer.movingRight = true; + processedCustomer.position = newPos; + processedCustomer.textMessage = "I wanted more!"; + processedCustomer.textMessageTime = now; + customerStreakReset = true; + } else { + processedCustomer.position = newPos; + } + nextCustomers.push(processedCustomer); + return; + } + // 7. Standard Customer Movement (Approaching) const speedMod = processedCustomer.hotHoneyAffected ? 0.5 : 1; const newPos = processedCustomer.position - (processedCustomer.speed * speedMod); if (newPos <= GAME_CONFIG.CHEF_X_POSITION) { // Reached Chef -> Angry -> Life Lost - events.push('LIFE_LOST'); - events.push(processedCustomer.critic ? 'STAR_LOST_CRITIC' : 'STAR_LOST_NORMAL'); - events.push('GAME_OVER'); - + events.push({ type: 'LIFE_LOST', lane: processedCustomer.lane, position: newPos }); + events.push(getCustomerVariant(processedCustomer) === 'critic' + ? { type: 'STAR_LOST_CRITIC', lane: processedCustomer.lane, position: newPos } + : { type: 'STAR_LOST_NORMAL', lane: processedCustomer.lane, position: newPos }); + events.push({ type: 'GAME_OVER' }); + processedCustomer.disappointed = true; processedCustomer.movingRight = true; processedCustomer.hotHoneyAffected = false; @@ -215,7 +296,7 @@ export const updateCustomerPositions = ( } else { processedCustomer.position = newPos; } - + nextCustomers.push(processedCustomer); }); @@ -229,13 +310,40 @@ export const updateCustomerPositions = ( */ export const processCustomerHit = ( customer: Customer, - now: number + now: number, + dogeActive: boolean = false ): CustomerHitResult => { const events: CustomerHitEvent[] = []; const newEntities: { droppedPlate?: DroppedPlate; emptyPlate?: EmptyPlate } = {}; - - // 1. Bad Luck Brian (Fail State) + + // 1. Bad Luck Brian if (customer.badLuckBrian) { + // Doge power-up lets Brian be served successfully! + if (dogeActive) { + events.push('SERVED_BRIAN_DOGE'); + newEntities.emptyPlate = { + id: `plate-${now}-${customer.id}`, + lane: customer.lane, + position: customer.position, + speed: ENTITY_SPEEDS.PLATE + }; + return { + updatedCustomer: { + ...customer, + served: true, + hasPlate: false, + flipped: false, + textMessage: "Such yum!", + textMessageTime: now, + frozen: false, + woozy: false + }, + events, + newEntities + }; + } + + // Normal Brian behavior - drops the plate events.push('BRIAN_DROPPED_PLATE'); const droppedPlate: DroppedPlate = { id: `dropped-${now}-${customer.id}`, @@ -260,6 +368,30 @@ export const processCustomerHit = ( }; } + // 1.5. Pizza Mafia - spawns 8 slices flying in all directions + if (customer.pizzaMafia) { + events.push('MAFIA_SERVED'); + newEntities.emptyPlate = { + id: `plate-${now}-${customer.id}`, + lane: customer.lane, + position: customer.position, + speed: ENTITY_SPEEDS.PLATE + }; + return { + updatedCustomer: { + ...customer, + served: true, + hasPlate: false, + textMessage: "Bada bing!", + textMessageTime: now, + frozen: false, + woozy: false + }, + events, + newEntities + }; + } + // 2. Frozen Customers (Instant Serve + Unfreeze) if (customer.frozen) { events.push('UNFROZEN_AND_SERVED'); @@ -315,8 +447,75 @@ export const processCustomerHit = ( } } - // 4. Normal / Hot Honey Customers (Standard Serve) - events.push(customer.critic ? 'SERVED_CRITIC' : 'SERVED_NORMAL'); + // 4. Scumbag Steve (Two-Slice Requirement, Angled Plate, No Payment) + if (customer.scumbagSteve) { + const slicesReceived = (customer.slicesReceived || 0) + 1; + + // Calculate target lane for angled throw (toward adjacent oven) + let targetLane: number; + if (customer.lane === 0) { + targetLane = 1; // Top lane throws to lane below + } else if (customer.lane === GAME_CONFIG.LANE_COUNT - 1) { + targetLane = GAME_CONFIG.LANE_COUNT - 2; // Bottom lane throws to lane above + } else { + // Middle lanes randomly throw up or down + targetLane = Math.random() < 0.5 ? customer.lane - 1 : customer.lane + 1; + } + + if (slicesReceived < SCUMBAG_STEVE.SLICES_REQUIRED) { + // First slice - not satisfied yet + events.push('STEVE_FIRST_SLICE'); + newEntities.emptyPlate = { + id: `plate-${now}-${customer.id}-first`, + lane: customer.lane, // Start at Steve's lane + position: customer.position, + speed: ENTITY_SPEEDS.PLATE, + // Angled throw properties + startLane: customer.lane, + startPosition: customer.position, + targetLane: targetLane + }; + return { + updatedCustomer: { + ...customer, + slicesReceived, + textMessage: "I'm still hungry!", + textMessageTime: now + }, + events, + newEntities + }; + } else { + // Second slice - Steve is satisfied but doesn't pay + events.push('STEVE_SERVED'); + newEntities.emptyPlate = { + id: `plate-${now}-${customer.id}`, + lane: customer.lane, // Start at Steve's lane + position: customer.position, + speed: ENTITY_SPEEDS.PLATE, + // Angled throw properties + startLane: customer.lane, + startPosition: customer.position, + targetLane: targetLane + }; + return { + updatedCustomer: { + ...customer, + served: true, + hasPlate: false, + slicesReceived, + flipped: true, // Flip when leaving + textMessage: "Thanks sucker!", + textMessageTime: now + }, + events, + newEntities + }; + } + } + + // 5. Normal / Hot Honey Customers (Standard Serve) + events.push(getCustomerVariant(customer) === 'critic' ? 'SERVED_CRITIC' : 'SERVED_NORMAL'); newEntities.emptyPlate = { id: `plate-${now}-${customer.id}`, lane: customer.lane, diff --git a/src/logic/mafiaSliceSystem.ts b/src/logic/mafiaSliceSystem.ts new file mode 100644 index 0000000..8bd7c41 --- /dev/null +++ b/src/logic/mafiaSliceSystem.ts @@ -0,0 +1,78 @@ +// src/logic/mafiaSliceSystem.ts +import { MafiaSlice, Customer, isCustomerLeaving } from '../types/game'; +import { MAFIA_SLICE_CONFIG, GAME_CONFIG } from '../lib/constants'; + +/** + * Spawn 8 mafia slices radiating outward from the served customer's position + */ +export const spawnMafiaSlices = ( + lane: number, + position: number, + now: number +): MafiaSlice[] => { + const slices: MafiaSlice[] = []; + const { SLICE_COUNT, SPEED } = MAFIA_SLICE_CONFIG; + + for (let i = 0; i < SLICE_COUNT; i++) { + // Distribute slices evenly in a circle (360 / 8 = 45 degrees apart) + const angle = (i / SLICE_COUNT) * 2 * Math.PI; + + slices.push({ + id: `mafia-slice-${now}-${i}`, + lane, + position, + speedX: Math.cos(angle) * SPEED, + speedY: Math.sin(angle) * MAFIA_SLICE_CONFIG.LANE_SPEED * 100, // Scale for lane units + startTime: now, + }); + } + + return slices; +}; + +/** + * Update mafia slice positions and remove expired ones + */ +export const updateMafiaSlices = ( + slices: MafiaSlice[], + now: number +): MafiaSlice[] => { + return slices + .filter(slice => { + // Remove expired slices + const elapsed = now - slice.startTime; + if (elapsed > MAFIA_SLICE_CONFIG.LIFETIME) return false; + + // Remove slices that went off screen + if (slice.position < -10 || slice.position > 110) return false; + if (slice.lane < -1 || slice.lane > GAME_CONFIG.LANE_COUNT) return false; + + return true; + }) + .map(slice => ({ + ...slice, + position: slice.position + slice.speedX, + lane: slice.lane + slice.speedY, + })); +}; + +/** + * Check if a mafia slice collides with a customer + */ +export const checkMafiaSliceCollision = ( + slice: MafiaSlice, + customer: Customer +): boolean => { + // Don't hit customers that are already leaving + if (isCustomerLeaving(customer)) return false; + + // Check if in same lane (with tolerance for fractional lanes) + const laneDiff = Math.abs(slice.lane - customer.lane); + if (laneDiff > 0.5) return false; + + // Check horizontal collision + const posDiff = Math.abs(slice.position - customer.position); + if (posDiff > 5) return false; + + return true; +}; diff --git a/src/logic/nyanSystem.test.ts b/src/logic/nyanSystem.test.ts new file mode 100644 index 0000000..203cad6 --- /dev/null +++ b/src/logic/nyanSystem.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { processNyanSweepMovement, checkNyanSweepCollisions } from './nyanSystem'; +import { Customer, BossMinion, NyanSweep } from '../types/game'; +import { GAME_CONFIG } from '../lib/constants'; + +describe('nyanSystem', () => { + describe('processNyanSweepMovement', () => { + it('moves the nyan cat forward', () => { + const initialSweep: NyanSweep = { + active: true, + xPosition: 10, + laneDirection: 1, + startTime: 1000, + lastUpdateTime: 1000, + startingLane: 1 + }; + + // Advance time by 100ms + const result = processNyanSweepMovement(initialSweep, 1, 1100); + + expect(result.nextSweep?.xPosition).toBeGreaterThan(10); + expect(result.newXPosition).toBeGreaterThan(10); + expect(result.sweepComplete).toBe(false); + }); + + it('completes the sweep when reaching MAX_X', () => { + const initialSweep: NyanSweep = { + active: true, + xPosition: 89, // Near end (90) + laneDirection: 1, + startTime: 1000, + lastUpdateTime: 1000, + startingLane: 1 + }; + + // Advance time significantly to ensure completion + const result = processNyanSweepMovement(initialSweep, 1, 5000); + + expect(result.sweepComplete).toBe(true); + expect(result.nextSweep).toBeUndefined(); + }); + }); + + describe('checkNyanSweepCollisions', () => { + it('detects collisions with customers', () => { + const sweep: NyanSweep = { + active: true, + xPosition: 50, + laneDirection: 1, + startTime: 1000, + lastUpdateTime: 1000, + startingLane: 0 + }; + + const customers: Customer[] = [ + // Hit + { id: 'c1', lane: 0, position: 50, speed: 0, served: false, hasPlate: false, leaving: false, disappointed: false, woozy: false, vomit: false, movingRight: false, critic: false, badLuckBrian: false, flipped: false }, + // Miss (wrong position) + { id: 'c2', lane: 0, position: 20, speed: 0, served: false, hasPlate: false, leaving: false, disappointed: false, woozy: false, vomit: false, movingRight: false, critic: false, badLuckBrian: false, flipped: false } + ]; + + // Assuming newLane calculation placed it on lane 0 + const result = checkNyanSweepCollisions(sweep, 52, 0, customers); + + expect(result.hitCustomerIds).toContain('c1'); + expect(result.hitCustomerIds).not.toContain('c2'); + }); + + it('detects collisions with minions', () => { + const sweep: NyanSweep = { + active: true, + xPosition: 50, + laneDirection: 1, + startTime: 1000, + lastUpdateTime: 1000, + startingLane: 0 + }; + + const minions: BossMinion[] = [ + { id: 'm1', lane: 0, position: 50, speed: 0, defeated: false } + ]; + + const result = checkNyanSweepCollisions(sweep, 52, 0, [], minions); + + expect(result.hitMinionIds).toContain('m1'); + }); + }); +}); diff --git a/src/logic/nyanSystem.ts b/src/logic/nyanSystem.ts new file mode 100644 index 0000000..119f680 --- /dev/null +++ b/src/logic/nyanSystem.ts @@ -0,0 +1,113 @@ +import { GameState, Customer, BossMinion, NyanSweep } from '../types/game'; +import { GAME_CONFIG, NYAN_CONFIG } from '../lib/constants'; +import { checkNyanSweepCollision } from './collisionSystem'; + +export interface NyanSweepResult { + nextSweep?: NyanSweep; + nextChefLane: number; + sweepComplete: boolean; + newXPosition: number; +} + +export interface NyanCollisionResult { + hitCustomerIds: string[]; + hitMinionIds: string[]; +} + +/** + * Processes the movement of the Nyan Cat sweep + */ +export const processNyanSweepMovement = ( + currentSweep: NyanSweep, + currentChefLane: number, + now: number +): NyanSweepResult => { + const dt = Math.min(now - currentSweep.lastUpdateTime, NYAN_CONFIG.DT_MAX); + const INITIAL_X = GAME_CONFIG.CHEF_X_POSITION; + const totalDistance = NYAN_CONFIG.MAX_X - INITIAL_X; + + const moveIncrement = (totalDistance / NYAN_CONFIG.DURATION) * dt; + const newXPosition = currentSweep.xPosition + moveIncrement; + + let newLane = currentChefLane + (currentSweep.laneDirection * NYAN_CONFIG.LANE_CHANGE_SPEED * dt); + let newLaneDirection = currentSweep.laneDirection; + + // Bounce logic + if (newLane > GAME_CONFIG.LANE_BOTTOM) { + newLane = GAME_CONFIG.LANE_BOTTOM; + newLaneDirection = -1; + } else if (newLane < GAME_CONFIG.LANE_TOP) { + newLane = GAME_CONFIG.LANE_TOP; + newLaneDirection = 1; + } + + const sweepComplete = newXPosition >= NYAN_CONFIG.MAX_X; + + if (sweepComplete) { + // Snap to nearest lane when done + const finalLane = Math.max( + GAME_CONFIG.LANE_TOP, + Math.min(GAME_CONFIG.LANE_BOTTOM, Math.round(newLane)) + ); + return { + nextSweep: undefined, + nextChefLane: finalLane, + sweepComplete: true, + newXPosition + }; + } + + return { + nextSweep: { + ...currentSweep, + xPosition: newXPosition, + laneDirection: newLaneDirection, + lastUpdateTime: now + }, + nextChefLane: newLane, + sweepComplete: false, + newXPosition + }; +}; + +/** + * Checks for collisions during Nyan Sweep + */ +export const checkNyanSweepCollisions = ( + sweep: NyanSweep, + newXPosition: number, + newLane: number, + customers: Customer[], + minions?: BossMinion[] +): NyanCollisionResult => { + const hitCustomerIds: string[] = []; + const hitMinionIds: string[] = []; + + const oldX = sweep.xPosition; + + // Check Customers + customers.forEach(customer => { + if (customer.served || customer.disappointed || customer.vomit) return; + + // Using the extracted collision logic + if (checkNyanSweepCollision(newLane, oldX, newXPosition, customer)) { + hitCustomerIds.push(customer.id); + } + }); + + // Check Boss Minions + if (minions) { + minions.forEach(minion => { + if (minion.defeated) return; + + // Inline check or reuse logic. Since checkNyanSweepCollision takes {lane, position}, it works for minions too. + // Replicating the specific tolerance used in the original code for minions if different + // Original code used 0.8 tolerance for minions, same as default in checkNyanSweepCollision + if (checkNyanSweepCollision(newLane, oldX, newXPosition, minion)) { + hitMinionIds.push(minion.id); + } + }); + } + + return { hitCustomerIds, hitMinionIds }; +}; diff --git a/src/logic/ovenSystem.ts b/src/logic/ovenSystem.ts index 8d1b739..be4652a 100644 --- a/src/logic/ovenSystem.ts +++ b/src/logic/ovenSystem.ts @@ -25,6 +25,37 @@ export interface OvenInteractionResult { newState?: Partial; // Only the parts that changed } +export type OvenDisplayStatus = 'empty' | 'cooking' | 'ready' | 'warning' | 'burning' | 'burned' | 'cleaning'; + +/** + * Get the display status of an oven for UI rendering + * Single source of truth for oven status calculation + */ +export const getOvenDisplayStatus = ( + oven: OvenState, + speedUpgrade: number, + now: number = Date.now() +): OvenDisplayStatus => { + if (oven.burned) { + if (oven.cleaningStartTime > 0) { + return 'cleaning'; + } + return 'burned'; + } + + if (!oven.cooking) { + return 'empty'; + } + + const elapsed = oven.pausedElapsed !== undefined ? oven.pausedElapsed : now - oven.startTime; + const cookTime = OVEN_CONFIG.COOK_TIMES[speedUpgrade] || OVEN_CONFIG.COOK_TIMES[0]; + + if (elapsed >= OVEN_CONFIG.BURN_TIME) return 'burning'; + if (elapsed >= OVEN_CONFIG.WARNING_TIME) return 'warning'; + if (elapsed >= cookTime) return 'ready'; + return 'cooking'; +}; + /** * Calculates the status of all ovens for a single game tick */ @@ -110,10 +141,11 @@ export const processOvenTick = ( export const tryInteractWithOven = ( gameState: GameState, lane: number, - now: number + now: number, + starPowerActive: boolean = false ): OvenInteractionResult => { const currentOven = gameState.ovens[lane]; - + if (currentOven.burned) return { action: 'NONE' }; // A. Start Cooking @@ -124,40 +156,62 @@ export const tryInteractWithOven = ( newState: { ovens: { ...gameState.ovens, - [lane]: { - cooking: true, - startTime: now, - burned: false, - cleaningStartTime: 0, - sliceCount: slicesProduced + [lane]: { + cooking: true, + startTime: now, + burned: false, + cleaningStartTime: 0, + sliceCount: slicesProduced } } } }; - } - + } + // B. Serve Pizza const speedUpgrade = gameState.ovenSpeedUpgrades[lane] || 0; const cookTime = OVEN_CONFIG.COOK_TIMES[speedUpgrade]; - + // Check if cooked enough but not burned if (now - currentOven.startTime >= cookTime && now - currentOven.startTime < OVEN_CONFIG.BURN_TIME) { const slicesProduced = currentOven.sliceCount; const newTotal = gameState.availableSlices + slicesProduced; if (newTotal <= GAME_CONFIG.MAX_SLICES) { + // Has room - serve normally return { action: 'SERVED', newState: { availableSlices: newTotal, ovens: { ...gameState.ovens, - [lane]: { - cooking: false, - startTime: 0, - burned: false, - cleaningStartTime: 0, - sliceCount: 0 + [lane]: { + cooking: false, + startTime: 0, + burned: false, + cleaningStartTime: 0, + sliceCount: 0 + } + }, + stats: { + ...gameState.stats, + slicesBaked: gameState.stats.slicesBaked + slicesProduced, + } + } + }; + } else if (starPowerActive) { + // No room but star power active - just empty the oven (don't add slices) + return { + action: 'SERVED', + newState: { + ovens: { + ...gameState.ovens, + [lane]: { + cooking: false, + startTime: 0, + burned: false, + cleaningStartTime: 0, + sliceCount: 0 } }, stats: { @@ -167,6 +221,7 @@ export const tryInteractWithOven = ( } }; } + // No room and no star power - do nothing } return { action: 'NONE' }; diff --git a/src/logic/pepeHelperSystem.ts b/src/logic/pepeHelperSystem.ts new file mode 100644 index 0000000..6ab4c48 --- /dev/null +++ b/src/logic/pepeHelperSystem.ts @@ -0,0 +1,292 @@ +import { GameState, PepeHelpers, PepeHelper, PizzaSlice, Customer } from '../types/game'; +import { POWERUPS, PEPE_CONFIG, GAME_CONFIG, ENTITY_SPEEDS, OVEN_CONFIG } from '../lib/constants'; +import { getOvenDisplayStatus } from './ovenSystem'; + +/** + * Initialize pepe helpers when power-up is collected + * Famous chefs come prepared with pizza and spread out to cover all lanes + */ +export const initializePepeHelpers = (now: number, chefLane: number): PepeHelpers => ({ + active: true, + startTime: now, + endTime: now + POWERUPS.PEPE_DURATION, + franco: { + id: 'franco', + lane: chefLane <= 1 ? 2 : 0, // Spread out from chef + availableSlices: PEPE_CONFIG.STARTING_SLICES, // Famous chefs come prepared! + lastActionTime: 0, + }, + frank: { + id: 'frank', + lane: chefLane >= 2 ? 1 : 3, // Spread out from chef + availableSlices: PEPE_CONFIG.STARTING_SLICES, // Famous chefs come prepared! + lastActionTime: 0, + }, +}); + +/** + * Check if pepe helpers have expired + */ +export const checkPepeHelpersExpired = (helpers: PepeHelpers, now: number): boolean => { + return now >= helpers.endTime; +}; + +export interface PepeHelperTickResult { + updatedState: Partial; + events: PepeHelperEvent[]; +} + +export type PepeHelperEvent = + | { type: 'OVEN_STARTED'; lane: number; helper: 'franco' | 'frank' } + | { type: 'PIZZA_PULLED'; lane: number; slices: number; helper: 'franco' | 'frank' } + | { type: 'CUSTOMER_SERVED'; lane: number; helper: 'franco' | 'frank' } + | { type: 'PLATE_CAUGHT'; lane: number; helper: 'franco' | 'frank' } + | { type: 'HELPER_MOVED'; lane: number; helper: 'franco' | 'frank' }; + +/** + * Evaluate what action a helper should take + */ +const evaluateLanePriority = ( + lane: number, + gameState: GameState, + helper: PepeHelper, + otherHelperLane: number, + chefLane: number +): number => { + let priority = 0; + const oven = gameState.ovens[lane]; + const speedUpgrade = gameState.ovenSpeedUpgrades[lane] || 0; + const status = getOvenDisplayStatus(oven, speedUpgrade); + + // High priority: Ready oven that needs pulling (and helper can carry more) + if (status === 'ready' && helper.availableSlices < GAME_CONFIG.MAX_SLICES) { + priority += 100; + } + + // Medium priority: Idle oven that can be started + if (status === 'idle') { + priority += 50; + } + + // High priority: Approaching customers in this lane (if we have slices) + const approachingInLane = gameState.customers.filter( + c => c.lane === lane && !c.served && !c.disappointed && !c.vomit && !c.leaving && c.position < 80 + ); + if (approachingInLane.length > 0 && helper.availableSlices > 0) { + // Closer customers = higher priority + const closestCustomer = approachingInLane.reduce((a, b) => a.position < b.position ? a : b); + priority += 80 + (100 - closestCustomer.position); + } + + // Medium priority: Plates returning in this lane + const platesInLane = gameState.emptyPlates.filter(p => p.lane === lane && p.position < 30); + if (platesInLane.length > 0) { + priority += 60; + } + + // Avoid clustering - reduce priority if chef or other helper is here + if (lane === chefLane) priority -= 20; + if (lane === otherHelperLane) priority -= 30; + + return priority; +}; + +/** + * Process a single helper's actions + */ +const processHelperAction = ( + helper: PepeHelper, + gameState: GameState, + otherHelperLane: number, + chefLane: number, + now: number +): { + updatedHelper: PepeHelper; + updatedOvens: typeof gameState.ovens; + newSlices: PizzaSlice[]; + caughtPlateIds: string[]; + events: PepeHelperEvent[]; + statsUpdates: Partial; + scoreGained: number; +} => { + const events: PepeHelperEvent[] = []; + let updatedHelper = { ...helper }; + let updatedOvens = { ...gameState.ovens }; + const newSlices: PizzaSlice[] = []; + const caughtPlateIds: string[] = []; + let statsUpdates: Partial = {}; + let scoreGained = 0; + + // Rate limit actions + if (now - helper.lastActionTime < PEPE_CONFIG.ACTION_INTERVAL) { + return { updatedHelper, updatedOvens, newSlices, caughtPlateIds, events, statsUpdates, scoreGained }; + } + + // Evaluate best lane + const lanePriorities = [0, 1, 2, 3].map(lane => ({ + lane, + priority: evaluateLanePriority(lane, gameState, helper, otherHelperLane, chefLane), + })); + lanePriorities.sort((a, b) => b.priority - a.priority); + + const bestLane = lanePriorities[0].lane; + + // Move one lane at a time toward the best lane + if (helper.lane !== bestLane) { + const direction = bestLane > helper.lane ? 1 : -1; + updatedHelper.lane = helper.lane + direction; + events.push({ type: 'HELPER_MOVED', lane: updatedHelper.lane, helper: helper.id }); + // Famous chefs can move AND act in the same tick! + } + + // We're in the best lane - take action (use updatedHelper.lane since we might have moved) + const currentLane = updatedHelper.lane; + const oven = updatedOvens[currentLane]; + const speedUpgrade = gameState.ovenSpeedUpgrades[currentLane] || 0; + const status = getOvenDisplayStatus(oven, speedUpgrade); + + // Priority 1: Catch plates + const platesInLane = gameState.emptyPlates.filter( + p => p.lane === currentLane && p.position < 20 + ); + if (platesInLane.length > 0) { + const plate = platesInLane[0]; + caughtPlateIds.push(plate.id); + scoreGained += 50; + statsUpdates = { + platesCaught: (gameState.stats.platesCaught || 0) + 1, + currentPlateStreak: (gameState.stats.currentPlateStreak || 0) + 1, + largestPlateStreak: Math.max( + gameState.stats.largestPlateStreak || 0, + (gameState.stats.currentPlateStreak || 0) + 1 + ), + }; + updatedHelper.lastActionTime = now; + events.push({ type: 'PLATE_CAUGHT', lane: currentLane, helper: helper.id }); + return { updatedHelper, updatedOvens, newSlices, caughtPlateIds, events, statsUpdates, scoreGained }; + } + + // Priority 2: Pull ready pizza + if (status === 'ready' && updatedHelper.availableSlices < GAME_CONFIG.MAX_SLICES) { + const slicesToAdd = Math.min(oven.sliceCount, GAME_CONFIG.MAX_SLICES - updatedHelper.availableSlices); + updatedHelper.availableSlices += slicesToAdd; + updatedOvens[currentLane] = { + ...oven, + cooking: false, + startTime: 0, + sliceCount: 0, + }; + statsUpdates = { + slicesBaked: (gameState.stats.slicesBaked || 0) + slicesToAdd, + }; + updatedHelper.lastActionTime = now; + events.push({ type: 'PIZZA_PULLED', lane: currentLane, slices: slicesToAdd, helper: helper.id }); + return { updatedHelper, updatedOvens, newSlices, caughtPlateIds, events, statsUpdates, scoreGained }; + } + + // Priority 3: Serve customers (only if needed) + const approachingCustomers = gameState.customers.filter( + c => c.lane === currentLane && !c.served && !c.disappointed && !c.vomit && !c.leaving && c.position < 85 + ); + // Count slices already heading to this lane + const slicesInLane = gameState.pizzaSlices.filter(s => s.lane === currentLane).length + newSlices.filter(s => s.lane === currentLane).length; + // Only throw if there are more customers than slices already in flight + if (approachingCustomers.length > slicesInLane && updatedHelper.availableSlices > 0) { + const newSlice: PizzaSlice = { + id: `${helper.id}-pizza-${now}-${currentLane}`, + lane: currentLane, + position: GAME_CONFIG.CHEF_X_POSITION, + speed: ENTITY_SPEEDS.PIZZA, + }; + newSlices.push(newSlice); + updatedHelper.availableSlices -= 1; + updatedHelper.lastActionTime = now; + events.push({ type: 'CUSTOMER_SERVED', lane: currentLane, helper: helper.id }); + return { updatedHelper, updatedOvens, newSlices, caughtPlateIds, events, statsUpdates, scoreGained }; + } + + // Priority 4: Start cooking + if (status === 'idle') { + const upgradeLevel = gameState.ovenUpgrades[currentLane] || 0; + const sliceCount = upgradeLevel + 1; + updatedOvens[currentLane] = { + ...oven, + cooking: true, + startTime: now, + burned: false, + sliceCount, + }; + updatedHelper.lastActionTime = now; + events.push({ type: 'OVEN_STARTED', lane: currentLane, helper: helper.id }); + return { updatedHelper, updatedOvens, newSlices, caughtPlateIds, events, statsUpdates, scoreGained }; + } + + return { updatedHelper, updatedOvens, newSlices, caughtPlateIds, events, statsUpdates, scoreGained }; +}; + +/** + * Process pepe helper actions each tick + * Helpers operate independently like additional chefs + */ +export const processPepeHelperTick = ( + gameState: GameState, + now: number +): PepeHelperTickResult => { + const helpers = gameState.pepeHelpers; + if (!helpers || !helpers.active) { + return { updatedState: {}, events: [] }; + } + + const allEvents: PepeHelperEvent[] = []; + let currentOvens = { ...gameState.ovens }; + let currentPlates = [...gameState.emptyPlates]; + let allNewSlices: PizzaSlice[] = []; + let totalScore = 0; + let statsUpdates: Partial = {}; + + // Process Franco + const francoResult = processHelperAction( + helpers.franco, + { ...gameState, ovens: currentOvens, emptyPlates: currentPlates }, + helpers.frank.lane, + gameState.chefLane, + now + ); + currentOvens = francoResult.updatedOvens; + currentPlates = currentPlates.filter(p => !francoResult.caughtPlateIds.includes(p.id)); + allNewSlices = [...allNewSlices, ...francoResult.newSlices]; + allEvents.push(...francoResult.events); + totalScore += francoResult.scoreGained; + statsUpdates = { ...statsUpdates, ...francoResult.statsUpdates }; + + // Process Frank + const frankResult = processHelperAction( + helpers.frank, + { ...gameState, ovens: currentOvens, emptyPlates: currentPlates }, + francoResult.updatedHelper.lane, + gameState.chefLane, + now + ); + currentOvens = frankResult.updatedOvens; + currentPlates = currentPlates.filter(p => !frankResult.caughtPlateIds.includes(p.id)); + allNewSlices = [...allNewSlices, ...frankResult.newSlices]; + allEvents.push(...frankResult.events); + totalScore += frankResult.scoreGained; + statsUpdates = { ...statsUpdates, ...frankResult.statsUpdates }; + + return { + updatedState: { + ovens: currentOvens, + pizzaSlices: [...gameState.pizzaSlices, ...allNewSlices], + emptyPlates: currentPlates, + pepeHelpers: { + ...helpers, + franco: francoResult.updatedHelper, + frank: frankResult.updatedHelper, + }, + stats: { ...gameState.stats, ...statsUpdates }, + score: gameState.score + totalScore, + }, + events: allEvents, + }; +}; diff --git a/src/logic/plateSystem.ts b/src/logic/plateSystem.ts new file mode 100644 index 0000000..9357b91 --- /dev/null +++ b/src/logic/plateSystem.ts @@ -0,0 +1,80 @@ +import { EmptyPlate, GameStats } from '../types/game'; +import { checkChefPlateCollision } from './collisionSystem'; +import { calculatePlateScore, updateStatsForStreak } from './scoringSystem'; + +export type PlateEvent = 'CAUGHT' | 'DROPPED'; + +export interface PlateTickResult { + remainingPlates: EmptyPlate[]; + scores: Array<{ points: number; lane: number; position: number }>; + events: PlateEvent[]; + updatedStats: GameStats; + totalScore: number; +} + +/** + * Update plate positions (move left) + */ +export const updatePlatePositions = (plates: EmptyPlate[]): EmptyPlate[] => { + return plates.map(plate => ({ + ...plate, + position: plate.position - plate.speed + })); +}; + +/** + * Process plate catching and cleanup + */ +export const processPlates = ( + plates: EmptyPlate[], + chefLane: number, + stats: GameStats, + dogeMultiplier: number, + streakMultiplier: number, + nyanSweepActive: boolean +): PlateTickResult => { + const remainingPlates: EmptyPlate[] = []; + const scores: Array<{ points: number; lane: number; position: number }> = []; + const events: PlateEvent[] = []; + let totalScore = 0; + let updatedStats = { ...stats }; + + // First update positions + const movedPlates = updatePlatePositions(plates); + + movedPlates.forEach(plate => { + // Check chef collision (only if not in nyan sweep) + if (checkChefPlateCollision(chefLane, plate) && !nyanSweepActive) { + const pointsEarned = calculatePlateScore(dogeMultiplier, streakMultiplier); + + totalScore += pointsEarned; + scores.push({ points: pointsEarned, lane: plate.lane, position: plate.position }); + + updatedStats.platesCaught += 1; + updatedStats = updateStatsForStreak(updatedStats, 'plate'); + events.push('CAUGHT'); + + // Don't add to remaining plates (caught) + return; + } + + // Check if plate went off screen + if (plate.position <= 0) { + updatedStats.currentPlateStreak = 0; + events.push('DROPPED'); + // Don't add to remaining plates (dropped) + return; + } + + // Keep the plate + remainingPlates.push(plate); + }); + + return { + remainingPlates, + scores, + events, + updatedStats, + totalScore + }; +}; diff --git a/src/logic/powerUpSystem.test.ts b/src/logic/powerUpSystem.test.ts new file mode 100644 index 0000000..4b873d4 --- /dev/null +++ b/src/logic/powerUpSystem.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect } from 'vitest'; +import { processPowerUpCollection, processPowerUpExpirations, processChefPowerUpCollisions } from './powerUpSystem'; +import { GameState, Customer, PowerUp } from '../types/game'; +import { INITIAL_GAME_STATE, GAME_CONFIG } from '../lib/constants'; + +const createMockGameState = (overrides: Partial = {}): GameState => ({ + ...INITIAL_GAME_STATE, + ...overrides +} as GameState); + +describe('powerUpSystem', () => { + describe('processPowerUpExpirations', () => { + it('removes expired power-ups', () => { + const now = 1000; + const result = processPowerUpExpirations([ + { type: 'speed', endTime: 500 }, // Expired + { type: 'slow', endTime: 1500 } // Active + ], now); + + expect(result.activePowerUps).toHaveLength(1); + expect(result.activePowerUps[0].type).toBe('slow'); + expect(result.expiredTypes).toContain('speed'); + }); + + it('detects active star power', () => { + const now = 1000; + const result = processPowerUpExpirations([ + { type: 'star', endTime: 1500 } + ], now); + + expect(result.starPowerActive).toBe(true); + }); + }); + + describe('processPowerUpCollection', () => { + it('activates timed power-ups', () => { + const state = createMockGameState({ chefLane: 0 }); + const now = 1000; + const result = processPowerUpCollection( + state, + { id: '1', type: 'speed', lane: 0, position: 0, speed: 0 }, + 1, + now + ); + + expect(result.newState.activePowerUps).toHaveLength(1); + expect(result.newState.activePowerUps[0].type).toBe('speed'); + }); + + it('triggers star power effects', () => { + const state = createMockGameState({ availableSlices: 0 }); + const now = 1000; + const result = processPowerUpCollection( + state, + { id: '1', type: 'star', lane: 0, position: 0, speed: 0 }, + 1, + now + ); + + expect(result.newState.starPowerActive).toBe(true); + expect(result.newState.availableSlices).toBe(8); // MAX_SLICES + expect(result.newState.activePowerUps).toHaveLength(1); + }); + + it('handles beer power-up lives lost', () => { + const woozyCustomer: Customer = { + id: 'c1', lane: 0, position: 50, speed: 0, served: false, + hasPlate: false, leaving: false, disappointed: false, + woozy: true, vomit: false, movingRight: false, critic: false, badLuckBrian: false, flipped: false + }; + + const state = createMockGameState({ + lives: 3, + customers: [woozyCustomer] + }); + + const result = processPowerUpCollection( + state, + { id: '1', type: 'beer', lane: 0, position: 0, speed: 0 }, + 1, + 1000 + ); + + // Woozy + Beer = Vomit and Life Lost + expect(result.livesLost).toBe(1); + expect(result.newState.lives).toBe(2); + expect(result.newState.customers[0].vomit).toBe(true); + }); + + it('initializes nyan sweep and returns nyanSweepStarted flag', () => { + const state = createMockGameState({ chefLane: 1 }); + const now = 1000; + const result = processPowerUpCollection( + state, + { id: '1', type: 'nyan', lane: 0, position: 0, speed: 0 }, + 1, + now + ); + + expect(result.nyanSweepStarted).toBe(true); + expect(result.newState.nyanSweep).toBeDefined(); + expect(result.newState.nyanSweep?.active).toBe(true); + expect(result.newState.nyanSweep?.startingLane).toBe(1); + }); + + it('does not start nyan sweep if already active', () => { + const state = createMockGameState({ + chefLane: 1, + nyanSweep: { + active: true, + xPosition: 50, + laneDirection: 1, + startTime: 500, + lastUpdateTime: 500, + startingLane: 0 + } + }); + const now = 1000; + const result = processPowerUpCollection( + state, + { id: '1', type: 'nyan', lane: 0, position: 0, speed: 0 }, + 1, + now + ); + + expect(result.nyanSweepStarted).toBe(false); + // Original sweep should remain unchanged + expect(result.newState.nyanSweep?.startingLane).toBe(0); + }); + }); + + describe('processChefPowerUpCollisions', () => { + const createPowerUp = (id: string, type: PowerUp['type'], lane: number, position: number): PowerUp => ({ + id, type, lane, position, speed: 1 + }); + + it('detects collision when chef is on same lane and position', () => { + const powerUp = createPowerUp('p1', 'honey', 0, GAME_CONFIG.CHEF_X_POSITION); + const state = createMockGameState({ powerUps: [powerUp] }); + + const result = processChefPowerUpCollisions( + state, + 0, // chefLane + GAME_CONFIG.CHEF_X_POSITION, + 1, // dogeMultiplier + 1000 + ); + + expect(result.caughtPowerUpIds.has('p1')).toBe(true); + expect(result.scores.length).toBeGreaterThan(0); + }); + + it('does not detect collision when chef is on different lane', () => { + const powerUp = createPowerUp('p1', 'honey', 2, GAME_CONFIG.CHEF_X_POSITION); + const state = createMockGameState({ powerUps: [powerUp] }); + + const result = processChefPowerUpCollisions( + state, + 0, // chefLane - different from powerUp lane + GAME_CONFIG.CHEF_X_POSITION, + 1, + 1000 + ); + + expect(result.caughtPowerUpIds.size).toBe(0); + }); + + it('skips collision detection during active nyan sweep', () => { + const powerUp = createPowerUp('p1', 'honey', 0, GAME_CONFIG.CHEF_X_POSITION); + const state = createMockGameState({ + powerUps: [powerUp], + nyanSweep: { + active: true, + xPosition: 50, + laneDirection: 1, + startTime: 500, + lastUpdateTime: 500, + startingLane: 0 + } + }); + + const result = processChefPowerUpCollisions( + state, + 0, + GAME_CONFIG.CHEF_X_POSITION, + 1, + 1000 + ); + + expect(result.caughtPowerUpIds.size).toBe(0); + }); + + it('collects multiple power-ups in single pass', () => { + const powerUps = [ + createPowerUp('p1', 'honey', 0, GAME_CONFIG.CHEF_X_POSITION), + createPowerUp('p2', 'star', 0, GAME_CONFIG.CHEF_X_POSITION) // Same position + ]; + const state = createMockGameState({ powerUps }); + + const result = processChefPowerUpCollisions( + state, + 0, + GAME_CONFIG.CHEF_X_POSITION, + 1, + 1000 + ); + + expect(result.caughtPowerUpIds.size).toBe(2); + }); + + it('returns nyanSweepStarted when nyan power-up collected', () => { + const powerUp = createPowerUp('p1', 'nyan', 0, GAME_CONFIG.CHEF_X_POSITION); + const state = createMockGameState({ powerUps: [powerUp] }); + + const result = processChefPowerUpCollisions( + state, + 0, + GAME_CONFIG.CHEF_X_POSITION, + 1, + 1000 + ); + + expect(result.nyanSweepStarted).toBe(true); + expect(result.newState.nyanSweep?.active).toBe(true); + }); + + it('aggregates lives lost from beer power-up', () => { + const woozyCustomer: Customer = { + id: 'c1', lane: 0, position: 50, speed: 0, served: false, + hasPlate: false, leaving: false, disappointed: false, + woozy: true, vomit: false, movingRight: false, critic: false, badLuckBrian: false, flipped: false + }; + const powerUp = createPowerUp('p1', 'beer', 0, GAME_CONFIG.CHEF_X_POSITION); + const state = createMockGameState({ + powerUps: [powerUp], + customers: [woozyCustomer], + lives: 3 + }); + + const result = processChefPowerUpCollisions( + state, + 0, + GAME_CONFIG.CHEF_X_POSITION, + 1, + 1000 + ); + + expect(result.livesLost).toBe(1); + expect(result.newState.lives).toBe(2); + }); + }); + +}); diff --git a/src/logic/powerUpSystem.ts b/src/logic/powerUpSystem.ts new file mode 100644 index 0000000..ec75998 --- /dev/null +++ b/src/logic/powerUpSystem.ts @@ -0,0 +1,232 @@ +import { GameState, PowerUp, StarLostReason, PowerUpType, ActivePowerUp, NyanSweep } from '../types/game'; +import { GAME_CONFIG, POWERUPS, SCORING } from '../lib/constants'; +import { checkChefPowerUpCollision } from './collisionSystem'; +import { calculatePowerUpScore } from './scoringSystem'; +import { initializePepeHelpers } from './pepeHelperSystem'; + +// Result of collecting a power-up +export interface PowerUpCollectionResult { + newState: GameState; // Modified state + scoresToAdd: Array<{ points: number; lane: number; position: number }>; // Floating scores to spawn + livesLost: number; // For sound effects + shouldTriggerGameOver: boolean; + powerUpAlert?: { type: PowerUpType; endTime: number; chefLane: number }; + nyanSweepStarted: boolean; // Whether a new Nyan sweep was started +} + +// Result of processing all chef power-up collisions +export interface ChefPowerUpCollisionResult { + newState: GameState; + caughtPowerUpIds: Set; + scores: Array<{ points: number; lane: number; position: number }>; + livesLost: number; + shouldTriggerGameOver: boolean; + nyanSweepStarted: boolean; +} + +// Result of processing expirations +export interface PowerUpExpirationResult { + activePowerUps: ActivePowerUp[]; + expiredTypes: PowerUpType[]; + starPowerActive: boolean; +} + +/** + * Processes the collection of a power-up by the chef + */ +export const processPowerUpCollection = ( + currentState: GameState, + powerUp: PowerUp, + dogeMultiplier: number, + now: number +): PowerUpCollectionResult => { + let newState = { ...currentState }; + const scoresToAdd: Array<{ points: number; lane: number; position: number }> = []; + let livesLost = 0; + let shouldTriggerGameOver = false; + + // Track power-up usage + newState.stats = { + ...newState.stats, + powerUpsUsed: { + ...newState.stats.powerUpsUsed, + [powerUp.type]: (newState.stats.powerUpsUsed[powerUp.type] || 0) + 1 + } + }; + + if (powerUp.type === 'beer') { + let lastReason: StarLostReason | undefined; + + newState.customers = newState.customers.map(customer => { + // Impact on Critic + if (customer.critic) { + if (customer.woozy) return { ...customer, woozy: false, woozyState: undefined, frozen: false, hotHoneyAffected: false, textMessage: "I prefer wine", textMessageTime: now }; + if (!customer.served && !customer.vomit && !customer.disappointed && !customer.leaving) return { ...customer, textMessage: "I prefer wine", textMessageTime: now }; + return customer; + } + + // Impact on Woozy customers (Double Beer = Vomit) + if (customer.woozy) { + livesLost += 1; + lastReason = 'beer_vomit'; + return { ...customer, woozy: false, vomit: true, disappointed: true, movingRight: true }; + } + + // Impact on Normal customers + if (!customer.served && !customer.vomit && !customer.disappointed && !customer.leaving) { + if (customer.badLuckBrian) { + livesLost += 1; + lastReason = 'brian_hurled'; + return { ...customer, vomit: true, disappointed: true, movingRight: true, flipped: false, textMessage: "Oh man I hurled", textMessageTime: now, hotHoneyAffected: false, frozen: false }; + } + return { ...customer, woozy: true, woozyState: 'normal', movingRight: true, hotHoneyAffected: false, frozen: false }; + } + return customer; + }); + + if (livesLost > 0) { + newState.lives = Math.max(0, newState.lives - livesLost); + newState.stats = { ...newState.stats, currentCustomerStreak: 0 }; + if (lastReason) newState.lastStarLostReason = lastReason; + } + + if (newState.lives === 0) { + shouldTriggerGameOver = true; + } + + } else if (powerUp.type === 'star') { + newState.availableSlices = GAME_CONFIG.MAX_SLICES; + newState.starPowerActive = true; + newState.activePowerUps = [...newState.activePowerUps.filter(p => p.type !== 'star'), { type: 'star', endTime: now + POWERUPS.DURATION }]; + } else if (powerUp.type === 'doge') { + newState.activePowerUps = [...newState.activePowerUps.filter(p => p.type !== 'doge'), { type: 'doge', endTime: now + POWERUPS.DOGE_DURATION }]; + newState.powerUpAlert = { type: 'doge', endTime: now + POWERUPS.ALERT_DURATION_DOGE, chefLane: newState.chefLane }; + } else if (powerUp.type === 'nyan') { + newState.powerUpAlert = { type: 'nyan', endTime: now + POWERUPS.ALERT_DURATION_NYAN, chefLane: newState.chefLane }; + // Initialize Nyan sweep if not already active + if (!newState.nyanSweep?.active) { + newState.nyanSweep = { + active: true, + xPosition: GAME_CONFIG.CHEF_X_POSITION, + laneDirection: 1, + startTime: now, + lastUpdateTime: now, + startingLane: newState.chefLane + }; + return { newState, scoresToAdd, livesLost, shouldTriggerGameOver, powerUpAlert: newState.powerUpAlert, nyanSweepStarted: true }; + } + } else if (powerUp.type === 'moltobenny') { + const moltoScore = SCORING.MOLTOBENNY_POINTS * dogeMultiplier; + const moltoMoney = SCORING.MOLTOBENNY_CASH * dogeMultiplier; + newState.score += moltoScore; + newState.bank += moltoMoney; + scoresToAdd.push({ points: moltoScore, lane: newState.chefLane, position: GAME_CONFIG.CHEF_X_POSITION }); + } else if (powerUp.type === 'pepe') { + // Initialize Pepe helpers - Franco-Pepe and Frank-Pepe assist the chef + newState.pepeHelpers = initializePepeHelpers(now, newState.chefLane); + newState.activePowerUps = [...newState.activePowerUps.filter(p => p.type !== 'pepe'), { type: 'pepe', endTime: now + POWERUPS.PEPE_DURATION }]; + } else { + // Generic timed power-up addition + newState.activePowerUps = [...newState.activePowerUps.filter(p => p.type !== powerUp.type), { type: powerUp.type, endTime: now + POWERUPS.DURATION }]; + + // Immediate effects for Honey + if (powerUp.type === 'honey') { + newState.customers = newState.customers.map(c => { + if (c.served || c.disappointed || c.vomit || c.leaving) return c; + if (c.critic) return { ...c, shouldBeHotHoneyAffected: false, hotHoneyAffected: false, textMessage: "Just plain, thanks.", textMessageTime: now }; + if (c.badLuckBrian) return { ...c, shouldBeHotHoneyAffected: false, hotHoneyAffected: false, frozen: false, woozy: false, woozyState: undefined, textMessage: "I can't do spicy.", textMessageTime: now }; + return { ...c, shouldBeHotHoneyAffected: true, hotHoneyAffected: true, frozen: false, woozy: false, woozyState: undefined }; + }); + } + + // Immediate effects for Ice Cream + if (powerUp.type === 'ice-cream') { + newState.customers = newState.customers.map(c => { + if (!c.served && !c.disappointed && !c.vomit) { + if (c.badLuckBrian) return { ...c, textMessage: "I'm lactose intolerant", textMessageTime: now }; + return { ...c, shouldBeFrozenByIceCream: true, frozen: true, hotHoneyAffected: false, woozy: false, woozyState: undefined }; + } + return c; + }); + } + } + + return { newState, scoresToAdd, livesLost, shouldTriggerGameOver, powerUpAlert: newState.powerUpAlert, nyanSweepStarted: false }; +}; + +/** + * Handles expiration of active power-ups + */ +export const processPowerUpExpirations = ( + activePowerUps: ActivePowerUp[], + now: number +): PowerUpExpirationResult => { + const nextPowerUps = activePowerUps.filter(p => p.endTime > now); + const expiredTypes = activePowerUps + .filter(p => p.endTime <= now) + .map(p => p.type); + + const starPowerActive = nextPowerUps.some(p => p.type === 'star'); + + return { + activePowerUps: nextPowerUps, + expiredTypes, + starPowerActive + }; +}; + +/** + * Processes all chef power-up collisions in a single pass + * Returns updated state, caught IDs, scores, and events for sound handling + */ +export const processChefPowerUpCollisions = ( + state: GameState, + chefLane: number, + chefXPosition: number, + dogeMultiplier: number, + now: number +): ChefPowerUpCollisionResult => { + const caughtPowerUpIds = new Set(); + const scores: Array<{ points: number; lane: number; position: number }> = []; + let newState = state; + let totalLivesLost = 0; + let shouldTriggerGameOver = false; + let nyanSweepStarted = false; + + // Skip collision detection during active Nyan sweep + if (state.nyanSweep?.active) { + return { newState, caughtPowerUpIds, scores, livesLost: 0, shouldTriggerGameOver: false, nyanSweepStarted: false }; + } + + state.powerUps.forEach(powerUp => { + if (checkChefPowerUpCollision(chefLane, chefXPosition, powerUp)) { + caughtPowerUpIds.add(powerUp.id); + + // Add base score for non-moltobenny power-ups + if (powerUp.type !== 'moltobenny') { + const pointsEarned = calculatePowerUpScore(dogeMultiplier); + newState = { ...newState, score: newState.score + pointsEarned }; + scores.push({ points: pointsEarned, lane: powerUp.lane, position: powerUp.position }); + } + + // Process the collection effects + const collectionResult = processPowerUpCollection(newState, powerUp, dogeMultiplier, now); + newState = collectionResult.newState; + + // Aggregate results + totalLivesLost += collectionResult.livesLost; + if (collectionResult.shouldTriggerGameOver) { + shouldTriggerGameOver = true; + } + if (collectionResult.nyanSweepStarted) { + nyanSweepStarted = true; + } + if (collectionResult.scoresToAdd.length > 0) { + scores.push(...collectionResult.scoresToAdd); + } + } + }); + + return { newState, caughtPowerUpIds, scores, livesLost: totalLivesLost, shouldTriggerGameOver, nyanSweepStarted }; +}; + diff --git a/src/logic/scoringSystem.test.ts b/src/logic/scoringSystem.test.ts new file mode 100644 index 0000000..f3e0a92 --- /dev/null +++ b/src/logic/scoringSystem.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; +import { applyCustomerScoring, calculateCustomerScore, checkLifeGain } from './scoringSystem'; +import { GameState, Customer } from '../types/game'; +import { INITIAL_GAME_STATE, SCORING, GAME_CONFIG } from '../lib/constants'; + +const createMockGameState = (overrides: Partial = {}): GameState => ({ + ...INITIAL_GAME_STATE, + ...overrides +} as GameState); + +const createMockCustomer = (overrides: Partial = {}): Customer => ({ + id: 'c1', + lane: 0, + position: 50, + speed: 1, + served: false, + hasPlate: true, + leaving: false, + disappointed: false, + woozy: false, + vomit: false, + movingRight: false, + critic: false, + badLuckBrian: false, + flipped: false, + ...overrides +}); + +describe('scoringSystem', () => { + describe('calculateCustomerScore', () => { + it('calculates normal customer score', () => { + const customer = createMockCustomer(); + const result = calculateCustomerScore(customer, 1, 1); + + expect(result.points).toBe(SCORING.CUSTOMER_NORMAL); + expect(result.bank).toBe(SCORING.BASE_BANK_REWARD); + }); + + it('calculates critic score (double points)', () => { + const customer = createMockCustomer({ critic: true }); + const result = calculateCustomerScore(customer, 1, 1); + + expect(result.points).toBe(SCORING.CUSTOMER_CRITIC); + }); + + it('calculates first slice score', () => { + const customer = createMockCustomer(); + const result = calculateCustomerScore(customer, 1, 1, true); + + expect(result.points).toBe(SCORING.CUSTOMER_FIRST_SLICE); + }); + + it('applies doge multiplier to points and bank', () => { + const customer = createMockCustomer(); + const dogeMultiplier = 2; + const result = calculateCustomerScore(customer, dogeMultiplier, 1); + + expect(result.points).toBe(SCORING.CUSTOMER_NORMAL * dogeMultiplier); + expect(result.bank).toBe(SCORING.BASE_BANK_REWARD * dogeMultiplier); + }); + + it('applies streak multiplier to points', () => { + const customer = createMockCustomer(); + const streakMultiplier = 1.5; + const result = calculateCustomerScore(customer, 1, streakMultiplier); + + expect(result.points).toBe(Math.floor(SCORING.CUSTOMER_NORMAL * streakMultiplier)); + }); + }); + + describe('checkLifeGain', () => { + it('grants life at every 8 happy customers', () => { + const result = checkLifeGain(3, 8, 1); // 8th customer + + expect(result.livesToAdd).toBe(1); + expect(result.shouldPlaySound).toBe(true); + }); + + it('does not grant life at non-multiples of 8', () => { + const result = checkLifeGain(3, 7, 1); + + expect(result.livesToAdd).toBe(0); + }); + + it('does not exceed max lives', () => { + const result = checkLifeGain(GAME_CONFIG.MAX_LIVES, 8, 1); + + expect(result.livesToAdd).toBe(0); + }); + + it('grants bonus life for critic served efficiently', () => { + const result = checkLifeGain(3, 5, 1, true, 60); // critic at position 60+ + + expect(result.livesToAdd).toBe(1); + }); + + it('does not grant critic bonus if served too late', () => { + const result = checkLifeGain(3, 5, 1, true, 40); // position < 50 + + expect(result.livesToAdd).toBe(0); + }); + }); + + describe('applyCustomerScoring', () => { + it('applies full scoring with bank and stats for served customer', () => { + const customer = createMockCustomer({ lane: 1, position: 60 }); + const state = createMockGameState({ happyCustomers: 0, lives: 3 }); + + const result = applyCustomerScoring(customer, state, 1, 1, { + includeBank: true, + countsAsServed: true, + isFirstSlice: false, + checkLifeGain: true + }); + + expect(result.scoreToAdd).toBe(SCORING.CUSTOMER_NORMAL); + expect(result.bankToAdd).toBe(SCORING.BASE_BANK_REWARD); + expect(result.newHappyCustomers).toBe(1); + expect(result.newStats.customersServed).toBe(1); + expect(result.floatingScore.points).toBe(SCORING.CUSTOMER_NORMAL); + }); + + it('excludes bank when includeBank is false (Scumbag Steve)', () => { + const customer = createMockCustomer(); + const state = createMockGameState(); + + const result = applyCustomerScoring(customer, state, 1, 1, { + includeBank: false, + countsAsServed: true, + isFirstSlice: false, + checkLifeGain: true + }); + + expect(result.bankToAdd).toBe(0); + expect(result.scoreToAdd).toBe(SCORING.CUSTOMER_NORMAL); + }); + + it('does not increment happy customers when countsAsServed is false', () => { + const customer = createMockCustomer(); + const state = createMockGameState({ happyCustomers: 5 }); + + const result = applyCustomerScoring(customer, state, 1, 1, { + includeBank: true, + countsAsServed: false, + isFirstSlice: true, + checkLifeGain: false + }); + + expect(result.newHappyCustomers).toBe(5); // unchanged + expect(result.scoreToAdd).toBe(SCORING.CUSTOMER_FIRST_SLICE); + }); + + it('checks life gain when checkLifeGain is true', () => { + const customer = createMockCustomer({ lane: 2, position: 55 }); + const state = createMockGameState({ happyCustomers: 7, lives: 3 }); // Will become 8 + + const result = applyCustomerScoring(customer, state, 1, 1, { + includeBank: true, + countsAsServed: true, + isFirstSlice: false, + checkLifeGain: true + }); + + expect(result.livesToAdd).toBe(1); + expect(result.shouldPlayLifeSound).toBe(true); + expect(result.starGain).toBeDefined(); + }); + + it('returns correct floatingScore position', () => { + const customer = createMockCustomer({ lane: 2, position: 75 }); + const state = createMockGameState(); + + const result = applyCustomerScoring(customer, state, 1, 1, { + includeBank: true, + countsAsServed: true, + isFirstSlice: false, + checkLifeGain: false + }); + + expect(result.floatingScore.lane).toBe(2); + expect(result.floatingScore.position).toBe(75); + }); + }); +}); diff --git a/src/logic/scoringSystem.ts b/src/logic/scoringSystem.ts index eacff03..106f98c 100644 --- a/src/logic/scoringSystem.ts +++ b/src/logic/scoringSystem.ts @@ -1,8 +1,94 @@ import { Customer, - GameStats + GameStats, + GameState } from '../types/game'; import { SCORING, GAME_CONFIG } from '../lib/constants'; +import { getCustomerVariant } from '../types/game'; + +/** + * Options for applying customer scoring to game state + */ +export interface CustomerScoringOptions { + includeBank: boolean; // Whether to add bank reward + countsAsServed: boolean; // Whether to increment happyCustomers and stats + isFirstSlice: boolean; // Whether this is a first slice (drooling/partial) + checkLifeGain: boolean; // Whether to check for life gain bonus +} + +/** + * Result of applying customer scoring + */ +export interface CustomerScoringResult { + scoreToAdd: number; + bankToAdd: number; + newHappyCustomers: number; + newStats: GameStats; + livesToAdd: number; + shouldPlayLifeSound: boolean; + floatingScore: { points: number; lane: number; position: number }; + starGain?: { lane: number; position: number }; +} + +/** + * Applies customer scoring to game state - consolidates repeated scoring logic + */ +export const applyCustomerScoring = ( + customer: Customer, + state: GameState, + dogeMultiplier: number, + streakMultiplier: number, + options: CustomerScoringOptions +): CustomerScoringResult => { + const { points, bank } = calculateCustomerScore( + customer, + dogeMultiplier, + streakMultiplier, + options.isFirstSlice + ); + + let newHappyCustomers = state.happyCustomers; + let newStats = state.stats; + let livesToAdd = 0; + let shouldPlayLifeSound = false; + let starGain: { lane: number; position: number } | undefined; + + if (options.countsAsServed) { + newHappyCustomers += 1; + newStats = { + ...newStats, + customersServed: newStats.customersServed + 1, + }; + newStats = updateStatsForStreak(newStats, 'customer'); + + if (options.checkLifeGain) { + const lifeResult = checkLifeGain( + state.lives, + newHappyCustomers, + dogeMultiplier, + getCustomerVariant(customer) === 'critic', + customer.position + ); + + if (lifeResult.livesToAdd > 0) { + livesToAdd = lifeResult.livesToAdd; + shouldPlayLifeSound = lifeResult.shouldPlaySound; + starGain = { lane: customer.lane, position: customer.position }; + } + } + } + + return { + scoreToAdd: points, + bankToAdd: options.includeBank ? bank : 0, + newHappyCustomers, + newStats, + livesToAdd, + shouldPlayLifeSound, + floatingScore: { points, lane: customer.lane, position: customer.position }, + starGain, + }; +}; /** * Calculates the score and bank reward for serving a customer. @@ -82,7 +168,7 @@ export const checkLifeGain = ( // HOWEVER, looking at legacy code: // "if (newState.happyCustomers % 8 === 0 ...)" // This implies we check the *accumulated* value. - if (!isCritic && happyCustomers > 0 && happyCustomers % 8 === 0) { + if (happyCustomers > 0 && happyCustomers % 8 === 0) { const stars = Math.min(dogeMultiplier, GAME_CONFIG.MAX_LIVES - currentLives); livesToAdd += stars; } diff --git a/src/logic/spawnSystem.ts b/src/logic/spawnSystem.ts new file mode 100644 index 0000000..e97279a --- /dev/null +++ b/src/logic/spawnSystem.ts @@ -0,0 +1,160 @@ +import { Customer, PowerUp, CustomerVariant } from '../types/game'; +import { + SPAWN_RATES, + GAME_CONFIG, + PROBABILITIES, + POSITIONS, + ENTITY_SPEEDS, + POWERUPS, + SCUMBAG_STEVE +} from '../lib/constants'; + +export interface SpawnResult { + shouldSpawn: boolean; + entity?: T; +} + +/** + * Calculate the spawn delay based on level + */ +export const getCustomerSpawnDelay = (level: number): number => { + return SPAWN_RATES.CUSTOMER_MIN_INTERVAL_BASE - + (level * SPAWN_RATES.CUSTOMER_MIN_INTERVAL_DECREMENT); +}; + +/** + * Calculate effective spawn rate based on level and boss status + */ +export const getEffectiveSpawnRate = (level: number, bossActive: boolean): number => { + const levelSpawnRate = + SPAWN_RATES.CUSTOMER_BASE_RATE + + (level - 1) * SPAWN_RATES.CUSTOMER_LEVEL_INCREMENT; + + return bossActive ? levelSpawnRate * 0.5 : levelSpawnRate; +}; + +/** + * Check if a customer should spawn and create one if so + */ +export const trySpawnCustomer = ( + lastSpawnTime: number, + now: number, + level: number, + bossActive: boolean +): SpawnResult => { + const spawnDelay = getCustomerSpawnDelay(level); + const effectiveSpawnRate = getEffectiveSpawnRate(level, bossActive); + + // Check time gate and random chance + if (now - lastSpawnTime < spawnDelay) { + return { shouldSpawn: false }; + } + + if (Math.random() >= effectiveSpawnRate * 0.01) { + return { shouldSpawn: false }; + } + + // Create the customer + const lane = Math.floor(Math.random() * GAME_CONFIG.LANE_COUNT); + const disappointedEmojis = ['😢', '😭', '😠', '🤬']; + + // Determine customer variant (mutually exclusive) + const variant: CustomerVariant = + Math.random() < PROBABILITIES.CRITIC_CHANCE ? 'critic' : + Math.random() < PROBABILITIES.BAD_LUCK_BRIAN_CHANCE ? 'badLuckBrian' : + Math.random() < PROBABILITIES.SCUMBAG_STEVE_CHANCE ? 'scumbagSteve' : + Math.random() < PROBABILITIES.PIZZA_MAFIA_CHANCE ? 'pizzaMafia' : + 'normal'; + + // Calculate speed (Steve is faster) + const speed = variant === 'scumbagSteve' + ? ENTITY_SPEEDS.CUSTOMER_BASE * SCUMBAG_STEVE.SPEED_MULTIPLIER + : ENTITY_SPEEDS.CUSTOMER_BASE; + + // Create customer in 'approaching' state + const customer: Customer = { + id: `customer-${now}-${lane}`, + lane, + position: POSITIONS.SPAWN_X, + speed, + // Initial state: approaching (not served, leaving, or disappointed) + served: false, + hasPlate: false, + leaving: false, + disappointed: false, + disappointedEmoji: disappointedEmojis[Math.floor(Math.random() * disappointedEmojis.length)], + movingRight: false, + // Customer variant + critic: variant === 'critic', + badLuckBrian: variant === 'badLuckBrian', + scumbagSteve: variant === 'scumbagSteve', + pizzaMafia: variant === 'pizzaMafia', + slicesReceived: variant === 'scumbagSteve' ? 0 : undefined, + lastLaneChangeTime: variant === 'scumbagSteve' ? now : undefined, + flipped: variant === 'badLuckBrian', // Brian spawns flipped, Steve spawns normal + }; + + return { shouldSpawn: true, entity: customer }; +}; + +/** + * Check if a power-up should spawn and create one if so + */ +export const trySpawnPowerUp = ( + lastSpawnTime: number, + now: number +): SpawnResult => { + // Check time gate + if (now - lastSpawnTime < SPAWN_RATES.POWERUP_MIN_INTERVAL) { + return { shouldSpawn: false }; + } + + // Check random chance + if (Math.random() >= SPAWN_RATES.POWERUP_CHANCE) { + return { shouldSpawn: false }; + } + + // Create the power-up + const lane = Math.floor(Math.random() * GAME_CONFIG.LANE_COUNT); + const rand = Math.random(); + const randomType = rand < PROBABILITIES.POWERUP_STAR_CHANCE + ? 'star' + : POWERUPS.TYPES[Math.floor(Math.random() * POWERUPS.TYPES.length)]; + + const powerUp: PowerUp = { + id: `powerup-${now}-${lane}`, + lane, + position: POSITIONS.POWERUP_SPAWN_X, + speed: ENTITY_SPEEDS.POWERUP, + type: randomType, + }; + + return { shouldSpawn: true, entity: powerUp }; +}; + +/** + * Process all spawning for a tick + * Returns new entities to add and whether spawn timers should be updated + */ +export const processSpawning = ( + lastCustomerSpawn: number, + lastPowerUpSpawn: number, + now: number, + level: number, + bossActive: boolean +): { + newCustomer?: Customer; + newPowerUp?: PowerUp; + updateCustomerSpawnTime: boolean; + updatePowerUpSpawnTime: boolean; +} => { + const customerResult = trySpawnCustomer(lastCustomerSpawn, now, level, bossActive); + const powerUpResult = trySpawnPowerUp(lastPowerUpSpawn, now); + + return { + newCustomer: customerResult.entity, + newPowerUp: powerUpResult.entity, + updateCustomerSpawnTime: customerResult.shouldSpawn, + updatePowerUpSpawnTime: powerUpResult.shouldSpawn, + }; +}; diff --git a/src/logic/storeSystem.ts b/src/logic/storeSystem.ts index 50c6e92..2522deb 100644 --- a/src/logic/storeSystem.ts +++ b/src/logic/storeSystem.ts @@ -9,9 +9,18 @@ export type StoreResult = { events: StoreEvent[]; }; +// Calculate cumulative upgrade cost: $10 for 1st, $20 for 2nd, $30 for 3rd, etc. +export const getUpgradeCost = (currentLevel: number): number => { + return COSTS.OVEN_UPGRADE * (currentLevel + 1); +}; + +export const getSpeedUpgradeCost = (currentLevel: number): number => { + return COSTS.OVEN_SPEED_UPGRADE * (currentLevel + 1); +}; + export const upgradeOven = (prev: GameState, lane: number): GameState => { - const upgradeCost = COSTS.OVEN_UPGRADE; const currentUpgrade = prev.ovenUpgrades[lane] || 0; + const upgradeCost = getUpgradeCost(currentUpgrade); if (prev.bank >= upgradeCost && currentUpgrade < OVEN_CONFIG.MAX_UPGRADE_LEVEL) { return { @@ -25,8 +34,8 @@ export const upgradeOven = (prev: GameState, lane: number): GameState => { }; export const upgradeOvenSpeed = (prev: GameState, lane: number): GameState => { - const speedUpgradeCost = COSTS.OVEN_SPEED_UPGRADE; const currentSpeedUpgrade = prev.ovenSpeedUpgrades[lane] || 0; + const speedUpgradeCost = getSpeedUpgradeCost(currentSpeedUpgrade); if (prev.bank >= speedUpgradeCost && currentSpeedUpgrade < OVEN_CONFIG.MAX_SPEED_LEVEL) { return { diff --git a/src/services/highScores.ts b/src/services/highScores.ts index 6bb96e2..c8a47fd 100644 --- a/src/services/highScores.ts +++ b/src/services/highScores.ts @@ -1,6 +1,9 @@ import { supabase } from '../lib/supabase'; import { GameStats } from '../types/game'; +const LOCAL_SCORES_KEY = 'pizza_chef_high_scores'; +const LOCAL_SESSIONS_KEY = 'pizza_chef_game_sessions'; + export interface HighScore { id: string; player_name: string; @@ -9,6 +12,41 @@ export interface HighScore { game_session_id?: string; } +// Local storage helpers +function getLocalScores(): HighScore[] { + try { + const data = localStorage.getItem(LOCAL_SCORES_KEY); + return data ? JSON.parse(data) : []; + } catch { + return []; + } +} + +function saveLocalScores(scores: HighScore[]): void { + try { + localStorage.setItem(LOCAL_SCORES_KEY, JSON.stringify(scores)); + } catch { + console.warn('Failed to save scores to local storage'); + } +} + +function getLocalSessions(): GameSession[] { + try { + const data = localStorage.getItem(LOCAL_SESSIONS_KEY); + return data ? JSON.parse(data) : []; + } catch { + return []; + } +} + +function saveLocalSessions(sessions: GameSession[]): void { + try { + localStorage.setItem(LOCAL_SESSIONS_KEY, JSON.stringify(sessions)); + } catch { + console.warn('Failed to save sessions to local storage'); + } +} + export interface GameSession { id: string; player_name: string; @@ -26,31 +64,73 @@ export interface GameSession { } export async function getTopScores(limit: number = 10): Promise { - const { data, error } = await supabase - .from('high_scores') - .select('*') - .order('score', { ascending: false }) - .order('created_at', { ascending: true }) - .limit(limit); + // Try Supabase first + if (supabase) { + const { data, error } = await supabase + .from('high_scores') + .select('*') + .order('score', { ascending: false }) + .order('created_at', { ascending: true }) + .limit(limit); + + if (!error && data) { + return data; + } + console.warn('Supabase fetch failed, falling back to local storage:', error); + } - if (error) { - console.error('Error fetching high scores:', error); - return []; + // Fall back to local storage + const localScores = getLocalScores(); + return localScores + .sort((a, b) => b.score - a.score || new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) + .slice(0, limit); +} + +export async function checkIfTopScore(score: number, limit: number = 10): Promise { + const topScores = await getTopScores(limit); + + if (topScores.length < limit) { + return true; // Less than 10 scores, any score qualifies } - return data || []; + const lowestTopScore = topScores[topScores.length - 1]?.score ?? 0; + return score > lowestTopScore; } -export async function submitScore(playerName: string, score: number, gameSessionId?: string): Promise { - const { error } = await supabase - .from('high_scores') - .insert([{ player_name: playerName.toLowerCase(), score, game_session_id: gameSessionId }]); +export async function checkIfNumberOneScore(score: number): Promise { + const topScores = await getTopScores(1); - if (error) { - console.error('Error submitting score:', error); - return false; + if (topScores.length === 0) { + return true; // No scores yet, this is #1 } + return score >= topScores[0].score; +} + +export async function submitScore(playerName: string, score: number, gameSessionId?: string): Promise { + // Try Supabase first + if (supabase) { + const { error } = await supabase + .from('high_scores') + .insert([{ player_name: playerName.toLowerCase(), score, game_session_id: gameSessionId }]); + + if (!error) { + return true; + } + console.warn('Supabase submit failed, falling back to local storage:', error); + } + + // Fall back to local storage + const localScores = getLocalScores(); + const newScore: HighScore = { + id: crypto.randomUUID(), + player_name: playerName.toLowerCase(), + score, + created_at: new Date().toISOString(), + game_session_id: gameSessionId + }; + localScores.push(newScore); + saveLocalScores(localScores); return true; } @@ -60,47 +140,78 @@ export async function createGameSession( level: number, stats: GameStats ): Promise { - const { data, error } = await supabase - .from('game_sessions') - .insert([{ - player_name: playerName.toLowerCase(), - score, - level, - slices_baked: stats.slicesBaked, - customers_served: stats.customersServed, - longest_streak: stats.longestCustomerStreak, - plates_caught: stats.platesCaught, - largest_plate_streak: stats.largestPlateStreak, - oven_upgrades: stats.ovenUpgradesMade, - power_ups_used: stats.powerUpsUsed, - }]) - .select() - .single(); - - if (error) { - console.error('Error creating game session:', error); - return null; + // Try Supabase first + if (supabase) { + const { data, error } = await supabase + .from('game_sessions') + .insert([{ + player_name: playerName.toLowerCase(), + score, + level, + slices_baked: stats.slicesBaked, + customers_served: stats.customersServed, + longest_streak: stats.longestCustomerStreak, + plates_caught: stats.platesCaught, + largest_plate_streak: stats.largestPlateStreak, + oven_upgrades: stats.ovenUpgradesMade, + power_ups_used: stats.powerUpsUsed, + }]) + .select() + .single(); + + if (!error && data) { + return data; + } + console.warn('Supabase session create failed, falling back to local storage:', error); } - return data; + // Fall back to local storage + const localSessions = getLocalSessions(); + const newSession: GameSession = { + id: crypto.randomUUID(), + player_name: playerName.toLowerCase(), + score, + level, + slices_baked: stats.slicesBaked, + customers_served: stats.customersServed, + longest_streak: stats.longestCustomerStreak, + plates_caught: stats.platesCaught, + largest_plate_streak: stats.largestPlateStreak, + oven_upgrades: stats.ovenUpgradesMade, + power_ups_used: stats.powerUpsUsed, + created_at: new Date().toISOString() + }; + localSessions.push(newSession); + saveLocalSessions(localSessions); + return newSession; } export async function getGameSession(id: string): Promise { - const { data, error } = await supabase - .from('game_sessions') - .select('*') - .eq('id', id) - .maybeSingle(); - - if (error) { - console.error('Error fetching game session:', error); - return null; + // Try Supabase first + if (supabase) { + const { data, error } = await supabase + .from('game_sessions') + .select('*') + .eq('id', id) + .maybeSingle(); + + if (!error && data) { + return data; + } + console.warn('Supabase session fetch failed, falling back to local storage:', error); } - return data; + // Fall back to local storage + const localSessions = getLocalSessions(); + return localSessions.find(s => s.id === id) || null; } export async function uploadScorecardImage(gameSessionId: string, blob: Blob): Promise { + if (!supabase) { + console.warn('Supabase not configured - cannot upload scorecard'); + return null; + } + const fileName = `${gameSessionId}.png`; const { error } = await supabase.storage .from('scorecards') @@ -122,6 +233,11 @@ export async function uploadScorecardImage(gameSessionId: string, blob: Blob): P } export async function updateGameSessionImage(gameSessionId: string, imageUrl: string): Promise { + if (!supabase) { + console.warn('Supabase not configured - cannot update game session'); + return false; + } + const { error } = await supabase .from('game_sessions') .update({ scorecard_image_url: imageUrl }) diff --git a/src/types/game.ts b/src/types/game.ts index de2bb66..0b431b0 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -1,3 +1,33 @@ +// Customer state machine types +export type CustomerState = + | 'approaching' // Moving toward chef + | 'served' // Got pizza, leaving happy + | 'disappointed' // Reached chef without pizza, leaving sad + | 'leaving' // Generic leaving (Brian complaining, etc.) + | 'vomit'; // Beer+woozy = sick + +export type CustomerVariant = 'normal' | 'critic' | 'badLuckBrian' | 'scumbagSteve' | 'pizzaMafia'; + +export type WoozyState = 'normal' | 'drooling' | 'satisfied'; + +// Helper functions for state checks +export const isCustomerLeaving = (c: Customer): boolean => + c.served || c.disappointed || c.leaving || c.vomit || false; + +export const isCustomerApproaching = (c: Customer): boolean => + !isCustomerLeaving(c); + +export const getCustomerVariant = (c: Customer): CustomerVariant => { + if (c.pizzaMafia) return 'pizzaMafia'; + if (c.scumbagSteve) return 'scumbagSteve'; + if (c.badLuckBrian) return 'badLuckBrian'; + if (c.critic) return 'critic'; + return 'normal'; +}; + +export const isCustomerAffectedByPowerUps = (c: Customer): boolean => + !c.badLuckBrian && !c.critic && !c.scumbagSteve && !c.pizzaMafia && !c.served && !c.leaving && !c.disappointed; + export interface Customer { id: string; lane: number; @@ -8,7 +38,7 @@ export interface Customer { disappointed?: boolean; disappointedEmoji?: string; woozy?: boolean; - woozyState?: 'normal' | 'drooling' | 'satisfied'; + woozyState?: WoozyState; movingRight?: boolean; vomit?: boolean; frozen?: boolean; @@ -18,7 +48,11 @@ export interface Customer { shouldBeHotHoneyAffected?: boolean; critic?: boolean; badLuckBrian?: boolean; + scumbagSteve?: boolean; + slicesReceived?: number; // For Steve who needs 2 slices + lastLaneChangeTime?: number; // For Steve's random lane changes leaving?: boolean; + pizzaMafia?: boolean; brianNyaned?: boolean; // Brian got hit by Nyan + is flying away flipped?: boolean; textMessage?: string; @@ -34,14 +68,51 @@ export interface PizzaSlice { fallY?: number; } +export interface MafiaSlice { + id: string; + lane: number; + position: number; + speedX: number; + speedY: number; + startTime: number; +} + export interface EmptyPlate { id: string; lane: number; position: number; speed: number; + // For angled throws (Steve) + startLane?: number; + startPosition?: number; + targetLane?: number; +} + +export interface NyanSweep { + active: boolean; + xPosition: number; + laneDirection: number; + startTime: number; + lastUpdateTime: number; + startingLane: number; +} + +export interface PepeHelper { + id: 'franco' | 'frank'; + lane: number; + availableSlices: number; + lastActionTime: number; } -export type PowerUpType = 'honey' | 'ice-cream' | 'beer' | 'star' | 'doge' | 'nyan' | 'moltobenny'; +export interface PepeHelpers { + active: boolean; + startTime: number; + endTime: number; + franco: PepeHelper; + frank: PepeHelper; +} + +export type PowerUpType = 'honey' | 'ice-cream' | 'beer' | 'star' | 'doge' | 'nyan' | 'moltobenny' | 'pepe' | 'speed' | 'slow'; export interface PowerUp { id: string; @@ -64,6 +135,15 @@ export interface FloatingScore { startTime: number; } +export interface FloatingStar { + id: string; + isGain: boolean; // true = gained star (green +), false = lost star (red -) + count: number; // number of stars (e.g., 2 for critic) + lane: number; + position: number; + startTime: number; +} + export interface DroppedPlate { id: string; lane: number; @@ -72,6 +152,15 @@ export interface DroppedPlate { hasSlice?: boolean; } +export interface OvenState { + cooking: boolean; + startTime: number; + burned: boolean; + cleaningStartTime: number; + pausedElapsed?: number; + sliceCount: number; +} + export interface BossMinion { id: string; lane: number; @@ -80,14 +169,21 @@ export interface BossMinion { defeated: boolean; } +export type BossType = 'dominos' | 'papaJohn'; + export interface BossBattle { active: boolean; + bossType: BossType; bossHealth: number; currentWave: number; minions: BossMinion[]; bossVulnerable: boolean; bossDefeated: boolean; bossPosition: number; + bossLane: number; + bossLaneDirection: number; // 1 = moving down, -1 = moving up + bossXDirection: number; // 1 = moving right, -1 = moving left + hitsReceived?: number; // Track hits for Papa John sprite changes } export interface GameStats { @@ -106,8 +202,13 @@ export interface GameStats { doge: number; nyan: number; moltobenny: number; + pepe: number; + speed: number; + slow: number; }; ovenUpgradesMade: number; + totalEarned: number; + totalSpent: number; } export type StarLostReason = @@ -123,10 +224,12 @@ export type StarLostReason = export interface GameState { customers: Customer[]; pizzaSlices: PizzaSlice[]; + mafiaSlices: MafiaSlice[]; emptyPlates: EmptyPlate[]; powerUps: PowerUp[]; activePowerUps: ActivePowerUp[]; floatingScores: FloatingScore[]; + floatingStars: FloatingStar[]; droppedPlates: DroppedPlate[]; chefLane: number; score: number; @@ -136,7 +239,7 @@ export interface GameState { lastStarLostReason?: StarLostReason; paused: boolean; availableSlices: number; - ovens: { [key: number]: { cooking: boolean; startTime: number; burned: boolean; cleaningStartTime: number; pausedElapsed?: number; sliceCount: number } }; + ovens: { [key: number]: OvenState }; ovenUpgrades: { [key: number]: number }; ovenSpeedUpgrades: { [key: number]: number }; happyCustomers: number; @@ -147,8 +250,13 @@ export interface GameState { fallingPizza?: { lane: number; y: number }; starPowerActive?: boolean; powerUpAlert?: { type: PowerUpType; endTime: number; chefLane: number }; - nyanSweep?: { active: boolean; xPosition: number; laneDirection: 1 | -1; startTime: number; lastUpdateTime: number; startingLane: number }; + nyanSweep?: NyanSweep; + pepeHelpers?: PepeHelpers; stats: GameStats; bossBattle?: BossBattle; defeatedBossLevels: number[]; + cleanKitchenStartTime?: number; + lastCleanKitchenBonusTime?: number; + cleanKitchenBonusAlert?: { endTime: number }; + lastPauseTime?: number; // Track when game was paused for timer adjustments } \ No newline at end of file diff --git a/src/utils/sounds.ts b/src/utils/sounds.ts index d73e89b..03041e8 100644 --- a/src/utils/sounds.ts +++ b/src/utils/sounds.ts @@ -1,6 +1,10 @@ class SoundManager { private audioContext: AudioContext | null = null; private isMuted: boolean = false; + private nyanTimeouts: number[] = []; + private nyanPausedAt: number = 0; + private nyanRemainingNotes: Array<{ frequency: number; delay: number; duration: number; type?: OscillatorType; volume?: number }> = []; + private nyanStartTime: number = 0; private getAudioContext(): AudioContext { if (!this.audioContext) { @@ -196,35 +200,80 @@ ovenReady() { ]); } + private nyanNotes: Array<{ frequency: number; delay: number; duration: number; type: OscillatorType; volume: number }> = [ + { frequency: 1046.5, delay: 0, duration: 0.188, type: 'square', volume: 0.22 }, + { frequency: 1174.7, delay: 188, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 784.0, delay: 377, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 880.0, delay: 472, duration: 0.188, type: 'square', volume: 0.22 }, + { frequency: 698.5, delay: 660, duration: 0.047, type: 'square', volume: 0.22 }, + { frequency: 830.6, delay: 755, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 784.0, delay: 848, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 698.5, delay: 943, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 698.5, delay: 1132, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 784.0, delay: 1320, duration: 0.188, type: 'square', volume: 0.22 }, + { frequency: 830.6, delay: 1508, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 830.6, delay: 1697, duration: 0.047, type: 'square', volume: 0.22 }, + { frequency: 784.0, delay: 1792, duration: 0.047, type: 'square', volume: 0.22 }, + { frequency: 698.5, delay: 1885, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 784.0, delay: 1980, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 880.0, delay: 2075, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 1046.5, delay: 2168, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 1174.7, delay: 2263, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 880.0, delay: 2357, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 1046.5, delay: 2452, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 784.0, delay: 2545, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 830.6, delay: 2640, duration: 0.095, type: 'square', volume: 0.22 }, + { frequency: 698.5, delay: 2735, duration: 0.095, type: 'square', volume: 0.22 }, + ]; + nyanCatPowerUp() { - this.playMultiTone([ - { frequency: 1046.5, delay: 0, duration: 0.188, type: 'square', volume: 0.22 }, - { frequency: 1174.7, delay: 188, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 784.0, delay: 377, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 880.0, delay: 472, duration: 0.188, type: 'square', volume: 0.22 }, - { frequency: 698.5, delay: 660, duration: 0.047, type: 'square', volume: 0.22 }, - - { frequency: 830.6, delay: 755, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 784.0, delay: 848, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 698.5, delay: 943, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 698.5, delay: 1132, duration: 0.095, type: 'square', volume: 0.22 }, - - { frequency: 784.0, delay: 1320, duration: 0.188, type: 'square', volume: 0.22 }, - { frequency: 830.6, delay: 1508, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 830.6, delay: 1697, duration: 0.047, type: 'square', volume: 0.22 }, - { frequency: 784.0, delay: 1792, duration: 0.047, type: 'square', volume: 0.22 }, - - { frequency: 698.5, delay: 1885, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 784.0, delay: 1980, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 880.0, delay: 2075, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 1046.5, delay: 2168, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 1174.7, delay: 2263, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 880.0, delay: 2357, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 1046.5, delay: 2452, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 784.0, delay: 2545, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 830.6, delay: 2640, duration: 0.095, type: 'square', volume: 0.22 }, - { frequency: 698.5, delay: 2735, duration: 0.095, type: 'square', volume: 0.22 }, -]); + this.stopNyan(); // Clear any existing nyan playback + this.nyanStartTime = Date.now(); + this.nyanRemainingNotes = [...this.nyanNotes]; + this.playNyanNotes(this.nyanNotes); + } + + private playNyanNotes(notes: Array<{ frequency: number; delay: number; duration: number; type?: OscillatorType; volume?: number }>) { + notes.forEach(note => { + const timeoutId = window.setTimeout(() => { + this.playTone(note.frequency, note.duration, note.type || 'sine', note.volume || 0.3); + }, note.delay); + this.nyanTimeouts.push(timeoutId); + }); + } + + pauseNyan() { + if (this.nyanTimeouts.length === 0) return; + + // Clear all pending timeouts + this.nyanTimeouts.forEach(id => window.clearTimeout(id)); + this.nyanTimeouts = []; + + // Calculate how much time has elapsed + this.nyanPausedAt = Date.now() - this.nyanStartTime; + + // Store remaining notes (notes that haven't played yet) + this.nyanRemainingNotes = this.nyanNotes.filter(note => note.delay > this.nyanPausedAt); + } + + resumeNyan() { + if (this.nyanRemainingNotes.length === 0) return; + + // Adjust delays based on elapsed time + const adjustedNotes = this.nyanRemainingNotes.map(note => ({ + ...note, + delay: note.delay - this.nyanPausedAt + })); + + this.nyanStartTime = Date.now() - this.nyanPausedAt; + this.playNyanNotes(adjustedNotes); + } + + stopNyan() { + this.nyanTimeouts.forEach(id => window.clearTimeout(id)); + this.nyanTimeouts = []; + this.nyanRemainingNotes = []; + this.nyanPausedAt = 0; } setMuted(muted: boolean) { @@ -235,8 +284,9 @@ ovenReady() { return this.isMuted; } - toggleMute(): void { + toggleMute(): boolean { this.isMuted = !this.isMuted; + return this.isMuted; } checkMuted(): boolean { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8e730d5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +});