diff --git a/index.html b/index.html index 7f6b79f..dd84abd 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,32 @@ + + + - - + + + + + Pick-Px diff --git a/package-lock.json b/package-lock.json index 436ff83..a705aaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,27 +19,40 @@ "react-toastify": "^11.0.5", "socket.io-client": "^4.8.1", "tailwindcss": "^4.1.11", + "use-sound": "^5.0.0", "zustand": "^5.0.6" }, "devDependencies": { "@eslint/js": "^9.29.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@typescript-eslint/eslint-plugin": "^8.35.0", "@typescript-eslint/parser": "^8.35.0", "@vitejs/plugin-react": "^4.5.2", + "@vitest/ui": "^3.2.4", "eslint": "^9.29.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.2.0", + "jsdom": "^26.1.0", "prettier": "^3.6.0", "prettier-plugin-tailwindcss": "^0.6.13", "typescript": "~5.8.3", "typescript-eslint": "^8.34.1", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vitest": "^3.2.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", + "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -53,6 +66,27 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -277,6 +311,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -335,6 +379,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", @@ -1066,6 +1225,13 @@ "node": ">= 8" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.19", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", @@ -1601,6 +1767,105 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1646,6 +1911,23 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "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.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1978,6 +2260,143 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", + "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.14", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.2.4" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2001,6 +2420,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2018,6 +2447,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2041,6 +2481,26 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2122,10 +2582,20 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2166,6 +2636,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz", + "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2183,6 +2670,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2277,12 +2774,47 @@ "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", "license": "BSD" }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2301,6 +2833,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2317,6 +2866,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -2326,6 +2885,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2399,6 +2966,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2417,6 +2997,13 @@ "node": ">= 0.4" } }, + "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/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2691,6 +3278,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", @@ -2701,6 +3298,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "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", @@ -2762,6 +3369,13 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3032,12 +3646,72 @@ "node": ">= 0.4" } }, + "node_modules/howler": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", + "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3075,6 +3749,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3108,6 +3792,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3143,6 +3834,68 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3467,6 +4220,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3486,6 +4246,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz", + "integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3496,6 +4263,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -3568,6 +4346,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3617,6 +4405,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3655,6 +4453,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3727,6 +4532,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3747,6 +4565,23 @@ "node": ">=8" } }, + "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/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3899,6 +4734,44 @@ } } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4053,6 +4926,20 @@ "react-dom": "^18 || ^19" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4113,6 +5000,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4137,6 +5031,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -4188,6 +5102,28 @@ "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/sirv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/socket.io-client": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", @@ -4259,6 +5195,33 @@ "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.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4272,6 +5235,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4285,6 +5268,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", @@ -4326,6 +5316,20 @@ "node": ">=18" } }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -4368,6 +5372,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4381,6 +5435,42 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -4485,6 +5575,18 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sound": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/use-sound/-/use-sound-5.0.0.tgz", + "integrity": "sha512-MNHT3FFC5HxNCrgZtrnpIMJI2cw/0D2xismcrtyht8BTuF5FhFhb57xO/jlQr2xJaFrc/0btzRQvGyHQwB7PVA==", + "license": "MIT", + "dependencies": { + "howler": "^2.2.4" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, "node_modules/vite": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", @@ -4559,6 +5661,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.4.6", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", @@ -4585,6 +5710,152 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4601,6 +5872,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", @@ -4632,6 +5920,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/package.json b/package.json index 5a78f9d..e28f605 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest" }, "dependencies": { "@tailwindcss/vite": "^4.1.11", @@ -21,24 +22,30 @@ "react-toastify": "^11.0.5", "socket.io-client": "^4.8.1", "tailwindcss": "^4.1.11", + "use-sound": "^5.0.0", "zustand": "^5.0.6" }, "devDependencies": { "@eslint/js": "^9.29.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@typescript-eslint/eslint-plugin": "^8.35.0", "@typescript-eslint/parser": "^8.35.0", "@vitejs/plugin-react": "^4.5.2", + "@vitest/ui": "^3.2.4", "eslint": "^9.29.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.2.0", + "jsdom": "^26.1.0", "prettier": "^3.6.0", "prettier-plugin-tailwindcss": "^0.6.13", "typescript": "~5.8.3", "typescript-eslint": "^8.34.1", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vitest": "^3.2.4" } } diff --git a/public/adventure.mp3 b/public/adventure.mp3 new file mode 100644 index 0000000..bd7f249 Binary files /dev/null and b/public/adventure.mp3 differ diff --git a/public/click.mp3 b/public/click.mp3 new file mode 100644 index 0000000..960443d Binary files /dev/null and b/public/click.mp3 differ diff --git a/public/count_down.mp3 b/public/count_down.mp3 new file mode 100644 index 0000000..7695c5f Binary files /dev/null and b/public/count_down.mp3 differ diff --git a/public/empty_box.png b/public/empty_box.png new file mode 100644 index 0000000..b053f3a Binary files /dev/null and b/public/empty_box.png differ diff --git a/public/event_bgm.mp3 b/public/event_bgm.mp3 new file mode 100644 index 0000000..7bed6f0 Binary files /dev/null and b/public/event_bgm.mp3 differ diff --git a/public/explosion.mp3 b/public/explosion.mp3 new file mode 100644 index 0000000..32a1b7d Binary files /dev/null and b/public/explosion.mp3 differ diff --git a/public/game.mp3 b/public/game.mp3 new file mode 100644 index 0000000..09c35e0 Binary files /dev/null and b/public/game.mp3 differ diff --git a/public/main_bgm.mp3 b/public/main_bgm.mp3 new file mode 100644 index 0000000..bdbbaff Binary files /dev/null and b/public/main_bgm.mp3 differ diff --git a/public/main_logo.svg b/public/main_logo.svg new file mode 100644 index 0000000..bfc0604 --- /dev/null +++ b/public/main_logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx index 762f51e..60e0508 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,8 @@ // App.tsx import PixelCanvas from './components/canvas/PixelCanvas'; +import GameCanvas from './components/game/GameCanvas'; +import { isGameCanvas, isGameCanvasById } from './utils/canvasTypeUtils'; import React, { useRef, useEffect, useCallback, useState } from 'react'; // UI 상태 관리를 위해 import import { useLocation } from 'react-router-dom'; @@ -17,6 +19,11 @@ import GroupModalContent from './components/modal/GroupModalContent'; import CanvasModalContent from './components/modal/CanvasModalContent'; import { toast } from 'react-toastify'; import { jwtDecode } from 'jwt-decode'; +import AlbumModalContent from './components/modal/AlbumModalContent'; +import HelpModalContent from './components/modal/HelpModalContent'; +import CanvasEndedModal from './components/modal/CanvasEndedModal'; // CanvasEndedModal import 추가 +import NotificationToast from './components/toast/NotificationToast'; // NotificationToast import 추가 +import { useToastStore } from './store/toastStore'; // useToastStore import 추가 type DecodedToken = { sub: { @@ -30,9 +37,14 @@ type DecodedToken = { function App() { // URL에서 ?canvas_id= 값을 읽어온다 - const { search } = useLocation(); + const { search, state } = useLocation(); // state도 함께 가져옴 const canvas_id = new URLSearchParams(search).get('canvas_id') || ''; + // state에서 isGame 정보를 가져오거나, 기존 isGameCanvasById로 판단 + const isGameFromState = state?.isGame || false; + // const isGame = isGameFromState || isGameCanvasById(canvas_id); // state 정보 우선 사용 + const isGame = isGameFromState; + const { isLoginModalOpen, closeLoginModal, @@ -42,16 +54,33 @@ function App() { closeGroupModal, isCanvasModalOpen, closeCanvasModal, + isAlbumModalOpen, + closeAlbumModal, + isHelpModalOpen, // 스토어의 isHelpModalOpen을 직접 사용 + closeHelpModal, + isCanvasEndedModalOpen, // isCanvasEndedModalOpen 상태 가져오기 + openHelpModal, // openHelpModal 액션 가져오기 } = useModalStore(); - // if (!canvas_id) { - // return
canvas_id 쿼리가 필요합니다.
; - // } + useEffect(() => { + const hasSeenHelpModal = localStorage.getItem('hasSeenHelpModal'); + if (hasSeenHelpModal === null || hasSeenHelpModal === 'false') { + openHelpModal(); // 첫 접속 시 또는 명시적으로 false인 경우 모달 열기 + localStorage.setItem('hasSeenHelpModal', 'true'); + } + }, [openHelpModal]); + + const handleCloseHelpModal = useCallback(() => { + closeHelpModal(); // 스토어의 상태만 업데이트 + }, [closeHelpModal]); const { isLoggedIn, setAuth, clearAuth } = useAuthStore(); const [isLoading, setIsLoading] = useState(true); const [canvasLoading, setCanvasLoading] = useState(true); + // useToastStore 훅 사용 + const showToast = useToastStore((state) => state.showToast); + useEffect(() => { //=======canvas_id 파싱 const { search } = window.location; @@ -91,13 +120,29 @@ function App() { } }, [setAuth, clearAuth]); + if (isLoading) { + return ( +
+
+
+ ); + } + return ( -
- +
+ {isGame ? ( + + ) : ( + + )} @@ -110,9 +155,19 @@ function App() { + + + + + + + {isCanvasEndedModalOpen && } + {/* NotificationToast 컴포넌트 추가 */} + {/* 로딩 완료 후 채팅 컴포넌트 표시 */} {!isLoading && !canvasLoading && + !isGame && (() => { try { return ; diff --git a/src/api/CanvasAPI.ts b/src/api/CanvasAPI.ts index b75dabe..ce7061d 100644 --- a/src/api/CanvasAPI.ts +++ b/src/api/CanvasAPI.ts @@ -1,3 +1,4 @@ +import { CanvasType } from '../components/canvas/canvasConstants'; import apiClient from '../services/apiClient'; export interface Canvas { @@ -6,8 +7,9 @@ export interface Canvas { created_at: string; size_x: number; size_y: number; - type: string; + type: CanvasType; ended_at: string; + started_at?: string; // Add started_at field status?: 'active' | 'inactive' | 'archived'; // 향후 이미지 관련 필드 추가 예정 // thumbnail?: string; // 썸네일 이미지 URL diff --git a/src/api/GameAPI.ts b/src/api/GameAPI.ts new file mode 100644 index 0000000..11a64e8 --- /dev/null +++ b/src/api/GameAPI.ts @@ -0,0 +1,71 @@ +import apiClient from '../services/apiClient'; + +export interface GameCanvasInfo { + canvas_id: string; // Changed to canvas_id to match API response + title: string; + type: string; + startedAt: string; + endedAt: string; + canvasSize: { width: number; height: number }; + color: string; // Added color to GameCanvasInfo +} + +export interface GameQuestion { + id: string; + question: string; + options: string[]; + answer: number; +} + +export interface WaitingRoomData extends GameCanvasInfo { + questions: GameQuestion[]; +} + +export interface WaitingRoomResponse { + success: boolean; + data: WaitingRoomData; +} + +// 목업 데이터 제거 + +export const GameAPI = { + /** + * 게임 대기실 정보를 가져옵니다. + * @param canvasId 캔버스 ID + * @returns 게임 대기실 정보 + */ + getWaitingRoomInfo: async (canvasId: string): Promise => { + try { + const response = await apiClient.get( + `/game/waitingroom`, + { + params: { canvasId }, + } + ); + return response.data.data; + } catch (error) { + console.error('Error fetching waiting room info:', error); + throw error; + } + }, + + /** + * 게임 캔버스 데이터를 가져옵니다. + * @param canvasId 캔버스 ID + * @returns 게임 캔버스 정보 + */ + fetchGameCanvasData: async (canvasId: string): Promise => { + try { + const response = await apiClient.get( + `/game/waitingroom`, + { + params: { canvasId }, + } + ); + return response.data.data; + } catch (error) { + console.error('Error fetching game canvas data:', error); + throw error; + } + } +}; diff --git a/src/api/canvasFetch.ts b/src/api/canvasFetch.ts index db3d6ad..9d4ec81 100644 --- a/src/api/canvasFetch.ts +++ b/src/api/canvasFetch.ts @@ -1,4 +1,5 @@ import React from 'react'; +import type { CanvasType } from '../components/canvas/canvasConstants'; interface FetchCanvasDataParams { id: string | null; @@ -8,10 +9,12 @@ interface FetchCanvasDataParams { setCanvasSize: React.Dispatch< React.SetStateAction<{ width: number; height: number }> >; - sourceCanvasRef: React.MutableRefObject; + sourceCanvasRef: React.RefObject; onLoadingChange?: (loading: boolean) => void; setShowCanvas: (show: boolean) => void; INITIAL_BACKGROUND_COLOR: string; + setCanvasType: (type: CanvasType) => void; + setEndedAt: (endedAt: string | null) => void; } export const fetchCanvasData = async ({ @@ -24,6 +27,8 @@ export const fetchCanvasData = async ({ onLoadingChange, setShowCanvas, INITIAL_BACKGROUND_COLOR, + setCanvasType, + setEndedAt, }: FetchCanvasDataParams) => { setIsLoading(true); setHasError(false); @@ -40,16 +45,52 @@ export const fetchCanvasData = async ({ }, }); - if (!res.ok) throw new Error('잘못된 응답'); + if (!res.ok) { + if (res.status === 404) { + console.error('Canvas not found (404). The API returned a 404 error.'); + } + // 다른 종류의 HTTP 에러도 여기서 잡힙니다. + throw new Error(`API responded with status: ${res.status}`); + } + const json = await res.json(); - if (!json.success) throw new Error('실패 응답'); + // API가 200 OK를 반환했지만, 응답 내용에 에러가 있는 경우 (e.g. { success: false, message: '...' }) + if (!json.success) { + throw new Error(json.message || 'API request was not successful'); + } + + console.log(json.data); const { canvas_id: fetchedId, pixels, + type: fetchedType, canvasSize: fetchedCanvasSize, + endedAt: fetchedEndedAt, } = json.data; + setCanvasType(fetchedType); + setEndedAt(fetchedEndedAt); + + if (fetchedEndedAt) { + const endedAt = new Date(fetchedEndedAt); + const now = new Date(); + const timeLeft = endedAt.getTime() - now.getTime(); // Time left in milliseconds + + if (timeLeft > 0) { + const seconds = Math.floor((timeLeft / 1000) % 60); + const minutes = Math.floor((timeLeft / (1000 * 60)) % 60); + const hours = Math.floor((timeLeft / (1000 * 60 * 60)) % 24); + const days = Math.floor(timeLeft / (1000 * 60 * 60 * 24)); + + console.log( + `Time left for canvas ${fetchedId}: ${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds` + ); + } else { + console.log(`Canvas ${fetchedId} has ended.`); + } + } + // console.log('Fetched pixels:', pixels); setCanvasId(fetchedId); diff --git a/src/components/SocketIntegration.tsx b/src/components/SocketIntegration.tsx index 40f5dda..e2ef513 100644 --- a/src/components/SocketIntegration.tsx +++ b/src/components/SocketIntegration.tsx @@ -6,7 +6,10 @@ interface SocketIntegrationProps { sourceCanvasRef: React.RefObject; draw: () => void; canvas_id: string; - onCooldownReceived?: (cooldown: { cooldown: boolean; remaining: number }) => void; + onCooldownReceived?: (cooldown: { + cooldown: boolean; + remaining: number; + }) => void; } interface ChatSocketProps { @@ -16,6 +19,7 @@ interface ChatSocketProps { message: string; created_at: string; }) => void; + onImageReceived?: (imageData: any) => void; group_id: string; user_id: string; } @@ -38,13 +42,18 @@ export const usePixelSocket = ({ [sourceCanvasRef, draw] ); - const { sendPixel } = useSocket(handlePixelReceived, canvas_id, onCooldownReceived); + const { sendPixel } = useSocket( + handlePixelReceived, + canvas_id, + onCooldownReceived + ); return { sendPixel }; }; export const useChatSocket = ({ onMessageReceived, + onImageReceived, group_id, user_id, }: ChatSocketProps) => { @@ -52,12 +61,13 @@ export const useChatSocket = ({ console.error('채팅 에러:', error); }, []); - const { sendMessage, leaveChat } = useChatSocketHook( + const { sendMessage, sendImageMessage, leaveChat } = useChatSocketHook( onMessageReceived, handleChatError, group_id, - user_id + user_id, + onImageReceived ); - return { sendMessage, leaveChat }; + return { sendMessage, sendImageMessage, leaveChat }; }; diff --git a/src/components/album/albumAPI.ts b/src/components/album/albumAPI.ts new file mode 100644 index 0000000..46ff26e --- /dev/null +++ b/src/components/album/albumAPI.ts @@ -0,0 +1,14 @@ +import apiClient from '../../services/apiClient'; + +export const albumServices = { + async getAlbumList() { + try { + const response = await apiClient.get(`/gallery`); + console.log('get', response); + return response.data; + } catch (error) { + console.error(`Failed to fetch albumList `); + throw error; + } + }, +}; diff --git a/src/components/album/albumTypes.ts b/src/components/album/albumTypes.ts new file mode 100644 index 0000000..45f2eca --- /dev/null +++ b/src/components/album/albumTypes.ts @@ -0,0 +1,15 @@ +export interface AlbumItemData { + image_url: string; + title: string; + type: string; + created_at: string; + ended_at: string; + size_x: number; + size_y: number; + participant_count: number; + total_try_count: number; + top_try_user_name: string; + top_try_user_count: number; + top_own_user_name: string; + top_own_user_count: number; +} diff --git a/src/components/canvas/CanvasUI.tsx b/src/components/canvas/CanvasUI.tsx index e994c03..97ce22f 100644 --- a/src/components/canvas/CanvasUI.tsx +++ b/src/components/canvas/CanvasUI.tsx @@ -1,7 +1,10 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useMediaQuery } from 'react-responsive'; +import useSound from 'use-sound'; import CanvasUIPC from './CanvasUIPC'; import CanvasUIMobile from './CanvasUIMobile'; +import { useBgmStore } from '../../store/bgmStore'; +import { CanvasType } from './canvasConstants'; type CanvasUIProps = { onConfirm: () => void; @@ -12,14 +15,47 @@ type CanvasUIProps = { colors: string[]; onZoomIn: () => void; onZoomOut: () => void; + canvasType: CanvasType; }; export default function CanvasUI(props: CanvasUIProps) { + const { canvasType } = props; const isDesktopOrLaptop = useMediaQuery({ query: '(min-width: 480px)' }); + const { isPlaying, setIsPlaying } = useBgmStore(); + const bgmFile = + canvasType === CanvasType.EVENT_COMMON || CanvasType.EVENT_COLORLIMIT + ? '/event_bgm.mp3' + : '/main_bgm.mp3'; + const [play, { stop }] = useSound(bgmFile, { loop: true, volume: 0.1 }); + + useEffect(() => { + if (isPlaying) { + play(); + } else { + stop(); + } + return () => { + stop(); + }; + }, [isPlaying, play, stop]); + + const toggleBgm = () => { + setIsPlaying(!isPlaying); + }; return isDesktopOrLaptop ? ( - + ) : ( - + ); } diff --git a/src/components/canvas/CanvasUIMobile.tsx b/src/components/canvas/CanvasUIMobile.tsx index 6f4c81f..20c34d8 100644 --- a/src/components/canvas/CanvasUIMobile.tsx +++ b/src/components/canvas/CanvasUIMobile.tsx @@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStrore'; import { useModalStore } from '../../store/modalStore'; import { showInstructionsToast } from '../toast/InstructionsToast'; import { useCanvasUiStore } from '../../store/canvasUiStore'; +import { CanvasType } from './canvasConstants'; // type HoverPos = { x: number; y: number } | null; @@ -19,6 +20,9 @@ type CanvasUIProps = { colors: string[]; onZoomIn: () => void; onZoomOut: () => void; + isBgmPlaying: boolean; + toggleBgm: () => void; + canvasType: CanvasType; }; export default function CanvasUIMobile({ @@ -30,6 +34,9 @@ export default function CanvasUIMobile({ colors, onZoomIn, onZoomOut, + isBgmPlaying, + toggleBgm, + canvasType, }: CanvasUIProps) { const [isPressed, setIsPressed] = useState(false); const [showConfirmEffect, setShowConfirmEffect] = useState(false); @@ -58,15 +65,16 @@ export default function CanvasUIMobile({ openAlbumModal, openMyPageModal, openGroupModal, + openHelpModal, } = useModalStore(); // 드롭다움 열림, 닫힘 상태 const [isMenuOpen, setIsMenuOpen] = useState(false); const menuRef = useRef(null); - useEffect(() => { - showInstructionsToast(); - }, []); + // useEffect(() => { + // showInstructionsToast(); + // }, []); useEffect(() => { if (!isMenuOpen) return; @@ -88,108 +96,192 @@ export default function CanvasUIMobile({ <> {/* 컬러 피커 */} -
- { - const newColor = e.target.value; - setColor(newColor); - onSelectColor(newColor); - }} - className='h-[40px] w-[40px] cursor-pointer rounded-[4px] border-2 border-solid border-white p-0' - title='색상 선택' - /> - {/* {onImageAttach && ( -
-
-
+
{/* 항상 보이는 메뉴 토글 버튼 (햄버거 아이콘) */} - + {isLoggedIn ? '마이페이지' : '로그인'}
@@ -264,7 +356,7 @@ export default function CanvasUIMobile({ /> - + 캔버스
@@ -289,7 +381,7 @@ export default function CanvasUIMobile({ /> - + 앨범 @@ -315,167 +407,86 @@ export default function CanvasUIMobile({ /> - + 그룹 + + {/* BGM 버튼 */} +
+ + + {isBgmPlaying ? 'BGM 끄기' : 'BGM 켜기'} + +
+ {/* 도움말 버튼 */} +
+ + + 도움말 + +
)} {/* 좌표 표시창 */} -
+
{hoverPos ? `(${hoverPos.x}, ${hoverPos.y})` : 'OutSide'}
- {/* 확대/축소 버튼 */} -
- - -
- - {/* 팔레트 */} -
-
- {colors.slice(0, 10).map((c, index) => ( -
- -
- {/*확정 버튼 */} - - -
-
- {/* 쿨타임 창 : 쿨타임 중에만 표시*/} {cooldown && (
diff --git a/src/components/canvas/CanvasUIPC.tsx b/src/components/canvas/CanvasUIPC.tsx index 8c987f0..3abcf74 100644 --- a/src/components/canvas/CanvasUIPC.tsx +++ b/src/components/canvas/CanvasUIPC.tsx @@ -7,6 +7,8 @@ import { useAuthStore } from '../../store/authStrore'; import { useModalStore } from '../../store/modalStore'; import { showInstructionsToast } from '../toast/InstructionsToast'; import { useCanvasUiStore } from '../../store/canvasUiStore'; +import UserCount from './UserCount'; +import { CanvasType } from './canvasConstants'; // type HoverPos = { x: number; y: number } | null; @@ -17,6 +19,9 @@ type CanvasUIProps = { onImageDelete: () => void; hasImage: boolean; colors: string[]; + isBgmPlaying: boolean; + toggleBgm: () => void; + canvasType: CanvasType; }; export default function CanvasUIPC({ @@ -26,6 +31,9 @@ export default function CanvasUIPC({ onImageDelete, hasImage, colors, + isBgmPlaying, + toggleBgm, + canvasType, }: CanvasUIProps) { const [isPressed, setIsPressed] = useState(false); const [showConfirmEffect, setShowConfirmEffect] = useState(false); @@ -54,15 +62,16 @@ export default function CanvasUIPC({ openAlbumModal, openMyPageModal, openGroupModal, + openHelpModal, } = useModalStore(); // 드롭다움 열림, 닫힘 상태 const [isMenuOpen, setIsMenuOpen] = useState(false); const menuRef = useRef(null); - useEffect(() => { - showInstructionsToast(); - }, []); + // useEffect(() => { + // showInstructionsToast(); + // }, []); useEffect(() => { if (!isMenuOpen) return; @@ -80,27 +89,18 @@ export default function CanvasUIPC({ }; }, [isMenuOpen]); + console.log(canvasType); + return ( <> - {/* 컬러 피커 */} -
- { - const newColor = e.target.value; - setColor(newColor); - onSelectColor(newColor); - }} - className='h-[40px] w-[40px] cursor-pointer rounded-[4px] border-2 border-solid border-white p-0' - title='색상 선택' - /> + {/* 이미지 업로드 */} +
{onImageAttach && (
+
{/* 항상 보이는 메뉴 토글 버튼 (햄버거 아이콘) */} - + {isLoggedIn ? '마이페이지' : '로그인'}
+ {/* 그룹 버튼 */} +
+ + + 그룹 + +
{/* 캔버스 버튼 */}
- + 캔버스
- {/* 앨범 버튼 */} + {/* 갤러리 버튼 */}
- - 앨범 + + 갤러리
- - {/* 그룹 버튼 */} + {/* BGM 버튼 */}
+ + {isBgmPlaying ? 'BGM 끄기' : 'BGM 켜기'} + +
+ {/* 도움말 버튼 */} +
+ - - 그룹 + + 도움말
)}
- {/* 좌표 표시창 */} -
- {hoverPos ? `(${hoverPos.x}, ${hoverPos.y})` : 'OutSide'} -
- {/* 팔레트 */}
+ {/* 좌표 표시창 */} +
+ {hoverPos ? `(${hoverPos.x},${hoverPos.y})` : 'OutSide'} +
+ {canvasType !== CanvasType.EVENT_COLORLIMIT && ( + { + const newColor = e.target.value; + setColor(newColor); + onSelectColor(newColor); + }} + id='color-picker' + className='mb-3 h-[40px] w-20 cursor-pointer rounded-[4px] border-2 border-solid border-white p-0' + title='색상 선택' + /> + )}
+ {/* 접속자수 표시 */} + {/* 쿨타임 창 : 쿨타임 중에만 표시*/} {cooldown && ( diff --git a/src/components/canvas/PixelCanvas.tsx b/src/components/canvas/PixelCanvas.tsx index 7037ed5..9771b44 100644 --- a/src/components/canvas/PixelCanvas.tsx +++ b/src/components/canvas/PixelCanvas.tsx @@ -1,12 +1,16 @@ import React, { useRef, useEffect, useCallback, useState } from 'react'; +import StarfieldCanvas from './StarfieldCanvas'; import { useCanvasUiStore } from '../../store/canvasUiStore'; -import { shallow } from 'zustand/shallow'; import { usePixelSocket } from '../SocketIntegration'; import CanvasUI from './CanvasUI'; import Preloader from '../Preloader'; import { useCanvasStore } from '../../store/canvasStore'; import { toast } from 'react-toastify'; import { fetchCanvasData as fetchCanvasDataUtil } from '../../api/canvasFetch'; +import NotFoundPage from '../../pages/NotFoundPage'; +import { useCanvasInteraction } from '../../hooks/useCanvasInteraction'; +import useSound from 'use-sound'; +import { useModalStore } from '../../store/modalStore'; // useModalStore import 추가 import { INITIAL_POSITION, @@ -15,6 +19,7 @@ import { INITIAL_BACKGROUND_COLOR, VIEWPORT_BACKGROUND_COLOR, COLORS, + CanvasType, } from './canvasConstants'; type PixelCanvasProps = { @@ -28,28 +33,24 @@ function PixelCanvas({ }: PixelCanvasProps) { const { canvas_id, setCanvasId } = useCanvasStore(); - useEffect(() => { - if (initialCanvasId && initialCanvasId !== canvas_id) { - setCanvasId(initialCanvasId); - console.log('Canvas ID changed:', initialCanvasId); + const generateGrayscalePalette = (numColors: number) => { + const palette = []; + for (let i = 0; i < numColors; i++) { + const value = Math.floor((i / (numColors - 1)) * 255); + const hex = value.toString(16).padStart(2, '0'); + palette.push(`#${hex}${hex}${hex}`); } - }, [initialCanvasId, canvas_id, setCanvasId]); + return palette; + }; const rootRef = useRef(null); const previewCanvasRef = useRef(null); const renderCanvasRef = useRef(null); const interactionCanvasRef = useRef(null); const sourceCanvasRef = useRef(null!); - const scaleRef = useRef(1); const viewPosRef = useRef<{ x: number; y: number }>(INITIAL_POSITION); - const startPosRef = useRef<{ x: number; y: number }>(INITIAL_POSITION); - const isPanningRef = useRef(false); - - const pinchDistanceRef = useRef(0); - const dragStartInfoRef = useRef<{ x: number; y: number } | null>(null); const DRAG_THRESHOLD = 5; // 5px 이상 움직이면 드래그로 간주 - const fixedPosRef = useRef<{ x: number; y: number; color: string } | null>( null ); @@ -58,19 +59,29 @@ function PixelCanvas({ y: number; color: string; } | null>(null); + const flashingPixelRef = useRef<{ x: number; y: number } | null>(null); + + const imageTransparencyRef = useRef(0.5); - // state를 각각 가져오도록 하여 불필요한 리렌더링을 방지합니다. + // state를 각각 가져오도록 하여 불필요한 리렌더링을 방지합니다。 const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); + const [hasError, setHasError] = useState(false); + const [canvasType, setCanvasType] = useState(null); + const [endedAt, setEndedAt] = useState(null); + const [timeLeft, setTimeLeft] = useState(null); + const [playCountDown, { stop: stopCountDown }] = useSound('/count_down.mp3', { + volume: 0.3, + }); + const [playClick] = useSound('/click.mp3', { volume: 0.7 }); + + const filteredColors = + canvasType === CanvasType.EVENT_COLORLIMIT + ? generateGrayscalePalette(20) + : COLORS; const color = useCanvasUiStore((state) => state.color); - const setColor = useCanvasUiStore((state) => state.setColor); - const hoverPos = useCanvasUiStore((state) => state.hoverPos); const setHoverPos = useCanvasUiStore((state) => state.setHoverPos); const cooldown = useCanvasUiStore((state) => state.cooldown); - const setCooldown = useCanvasUiStore((state) => state.setCooldown); - const timeLeft = useCanvasUiStore((state) => state.timeLeft); - const setTimeLeft = useCanvasUiStore((state) => state.setTimeLeft); - const showPalette = useCanvasUiStore((state) => state.showPalette); const setShowPalette = useCanvasUiStore((state) => state.setShowPalette); const showImageControls = useCanvasUiStore( (state) => state.showImageControls @@ -90,8 +101,6 @@ function PixelCanvas({ ); const isLoading = useCanvasUiStore((state) => state.isLoading); const setIsLoading = useCanvasUiStore((state) => state.setIsLoading); - const hasError = useCanvasUiStore((state) => state.hasError); - const setHasError = useCanvasUiStore((state) => state.setHasError); const showCanvas = useCanvasUiStore((state) => state.showCanvas); const setShowCanvas = useCanvasUiStore((state) => state.setShowCanvas); const targetPixel = useCanvasUiStore((state) => state.targetPixel); @@ -99,8 +108,7 @@ function PixelCanvas({ const startCooldown = useCanvasUiStore((state) => state.startCooldown); - const imageTransparencyRef = useRef(0.5); - const lastTouchPosRef = useRef<{ x: number; y: number } | null>(null); + const { openCanvasEndedModal } = useModalStore(); // openCanvasEndedModal 가져오기 // 이미지 관련 상태 (Zustand로 이동하지 않는 부분) const imageCanvasRef = useRef(null); @@ -159,7 +167,7 @@ function PixelCanvas({ } return null; }, - [imagePosition, imageSize, isImageFixed] + [imagePosition, imageSize, isImageFixed, scaleRef] ); const draw = useCallback(() => { @@ -182,11 +190,20 @@ function PixelCanvas({ canvasSize.width, canvasSize.height ); - gradient.addColorStop(0, 'rgba(34, 197, 94, 0.8)'); - gradient.addColorStop(0.25, 'rgba(59, 130, 246, 0.8)'); - gradient.addColorStop(0.5, 'rgba(168, 85, 247, 0.8)'); - gradient.addColorStop(0.75, 'rgba(236, 72, 153, 0.8)'); - gradient.addColorStop(1, 'rgba(34, 197, 94, 0.8)'); + + if (canvasType === CanvasType.EVENT_COLORLIMIT) { + gradient.addColorStop(0, 'rgba(0, 0, 0, 0.8)'); + gradient.addColorStop(0.25, 'rgba(50, 50, 50, 0.8)'); + gradient.addColorStop(0.5, 'rgba(100, 100, 100, 0.8)'); + gradient.addColorStop(0.75, 'rgba(150, 150, 150, 0.8)'); + gradient.addColorStop(1, 'rgba(200, 200, 200, 0.8)'); + } else { + gradient.addColorStop(0, 'rgba(34, 197, 94, 0.8)'); + gradient.addColorStop(0.25, 'rgba(59, 130, 246, 0.8)'); + gradient.addColorStop(0.5, 'rgba(168, 85, 247, 0.8)'); + gradient.addColorStop(0.75, 'rgba(236, 72, 153, 0.8)'); + gradient.addColorStop(1, 'rgba(34, 197, 94, 0.8)'); + } ctx.strokeStyle = gradient; ctx.lineWidth = 3 / scaleRef.current; @@ -198,29 +215,59 @@ function PixelCanvas({ ctx.imageSmoothingEnabled = false; ctx.drawImage(src, 0, 0); + // 이미지 편집 모드일 때만 격자 그리기 (방장 이미지는 제외) + if ( + !isImageFixed && + imageCanvasRef.current && + !(imageCanvasRef.current as any)._isGroupImage + ) { + ctx.strokeStyle = 'rgba(255,255,255, 0.12)'; + ctx.lineWidth = 1 / scaleRef.current; + ctx.beginPath(); + for (let x = 0; x <= canvasSize.width; x++) { + ctx.moveTo(x, 0); + ctx.lineTo(x, canvasSize.height); + } + for (let y = 0; y <= canvasSize.height; y++) { + ctx.moveTo(0, y); + ctx.lineTo(canvasSize.width, y); + } + ctx.stroke(); + } + // 이미지 렌더링 if (imageCanvasRef.current) { - ctx.globalAlpha = imageTransparencyRef.current; - ctx.imageSmoothingEnabled = false; - if (!isImageFixed) { - // 이미지 경계선 - ctx.strokeStyle = 'rgba(0, 255, 255, 0.8)'; - ctx.lineWidth = 2 / scaleRef.current; - ctx.strokeRect( - imagePosition.x - 1, - imagePosition.y - 1, - imageSize.width + 2, - imageSize.height + 2 + try { + // 투명도 설정 + ctx.globalAlpha = imageTransparencyRef.current; + ctx.imageSmoothingEnabled = false; + + // 편집 모드일 때 경계선 표시 (방장 이미지는 제외) + if (!isImageFixed && !(imageCanvasRef.current as any)._isGroupImage) { + ctx.strokeStyle = 'rgba(0, 255, 255, 0.8)'; + ctx.lineWidth = 2 / scaleRef.current; + ctx.strokeRect( + imagePosition.x - 1, + imagePosition.y - 1, + imageSize.width + 2, + imageSize.height + 2 + ); + } + + // 이미지 그리기 + ctx.drawImage( + imageCanvasRef.current, + imagePosition.x, + imagePosition.y, + imageSize.width, + imageSize.height ); + + // 투명도 초기화 + ctx.globalAlpha = 1.0; + } catch (error) { + console.error('이미지 그리기 실패:', error); } - ctx.drawImage( - imageCanvasRef.current, - imagePosition.x, - imagePosition.y, - imageSize.width, - imageSize.height - ); - ctx.globalAlpha = 1.0; if (!isImageFixed) { // 리사이즈 핸들 (네모) - 이미지 위에 그리기 @@ -296,11 +343,31 @@ function PixelCanvas({ pctx.restore(); } - }, [canvasSize, imagePosition, imageSize, isImageFixed]); + + // Flashing pixel effect + if (flashingPixelRef.current) { + const { x, y } = flashingPixelRef.current; + const currentTime = Date.now(); + const isVisible = Math.floor(currentTime / 500) % 2 === 0; // Blink every 500ms + + if (isVisible) { + const flashCtx = previewCanvasRef.current?.getContext('2d'); + if (flashCtx) { + flashCtx.save(); + flashCtx.translate(viewPosRef.current.x, viewPosRef.current.y); + flashCtx.scale(scaleRef.current, scaleRef.current); + flashCtx.strokeStyle = 'rgba(255, 0, 0, 0.9)'; // Red border + flashCtx.lineWidth = 4 / scaleRef.current; + flashCtx.strokeRect(x, y, 1, 1); + flashCtx.restore(); + } + } + } + }, [canvasSize, imagePosition, imageSize, isImageFixed, imageMode]); // 이미지 첨부 핸들러 const handleImageAttach = useCallback( - (file: File) => { + (file: File, options?: any) => { // 팔레트 닫기 setShowPalette(false); @@ -370,7 +437,7 @@ function PixelCanvas({ img.src = URL.createObjectURL(file); }, - [canvasSize, draw] + [canvasSize, draw, setIsImageFixed, setShowImageControls, setShowPalette] ); // 이미지 확대축소 @@ -404,7 +471,19 @@ function PixelCanvas({ setIsImageFixed(true); setShowImageControls(false); toast.success('이미지가 고정되었습니다!'); - }, []); + + // 그룹 이미지 업로드 처리를 위한 이벤트 발생 + document.dispatchEvent( + new CustomEvent('group-image-confirmed', { + detail: { + x: imagePosition.x, + y: imagePosition.y, + width: imageSize.width, + height: imageSize.height, + }, + }) + ); + }, [setIsImageFixed, setShowImageControls, imagePosition, imageSize]); // 이미지 취소 const cancelImage = useCallback(() => { @@ -413,7 +492,7 @@ function PixelCanvas({ setIsImageFixed(false); toast.info('이미지가 제거되었습니다.'); draw(); - }, [draw]); + }, [draw, setIsImageFixed, setShowImageControls]); const { sendPixel } = usePixelSocket({ sourceCanvasRef, @@ -461,7 +540,7 @@ function PixelCanvas({ setHoverPos(null); } }, - [canvasSize] + [canvasSize, viewPosRef, scaleRef, setHoverPos, interactionCanvasRef] ); const clearOverlay = useCallback(() => { @@ -470,24 +549,24 @@ function PixelCanvas({ if (!overlayCanvas) return; const overlayCtx = overlayCanvas.getContext('2d'); overlayCtx?.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); - }, []); + }, [setHoverPos, interactionCanvasRef]); const resetAndCenter = useCallback(() => { const canvas = renderCanvasRef.current; if (!canvas || canvas.clientWidth === 0 || canvasSize.width === 0) return; - + if (!isImageFixed && imageCanvasRef.current) { + draw(); + return; + } // 화면 크기에 맞게 스케일 계산 const viewportWidth = canvas.clientWidth; const viewportHeight = canvas.clientHeight; - const isMobile = window.innerWidth < 768; const scaleFactor = 0.7; const scaleX = (viewportWidth / canvasSize.width) * scaleFactor; const scaleY = (viewportHeight / canvasSize.height) * scaleFactor; scaleRef.current = Math.max(Math.min(scaleX, scaleY), MIN_SCALE); scaleRef.current = Math.min(scaleRef.current, MAX_SCALE); - scaleRef.current = Math.min(scaleX, scaleY); - scaleRef.current = Math.min(scaleRef.current, MAX_SCALE); // 캔버스를 화면 중앙에 배치 viewPosRef.current.x = @@ -497,7 +576,7 @@ function PixelCanvas({ draw(); clearOverlay(); - }, [draw, clearOverlay, canvasSize]); + }, [draw, clearOverlay, canvasSize, scaleRef, viewPosRef, renderCanvasRef]); const centerOnPixel = useCallback( (screenX: number, screenY: number) => { @@ -549,7 +628,7 @@ function PixelCanvas({ }; requestAnimationFrame(animate); }, - [draw, updateOverlay, canvasSize] + [draw, updateOverlay, canvasSize, viewPosRef, scaleRef, renderCanvasRef] ); const zoomCanvas = useCallback( @@ -575,7 +654,7 @@ function PixelCanvas({ draw(); updateOverlay(centerX, centerY); }, - [draw, updateOverlay] + [draw, updateOverlay, viewPosRef, scaleRef, renderCanvasRef] ); const handleZoomIn = useCallback(() => { @@ -648,7 +727,7 @@ function PixelCanvas({ }; requestAnimationFrame(animate); }, - [draw, canvasSize, updateOverlay] + [draw, canvasSize, updateOverlay, viewPosRef, scaleRef, renderCanvasRef] ); const handleCooltime = useCallback(() => { @@ -661,14 +740,21 @@ function PixelCanvas({ handleCooltime(); previewPixelRef.current = { x: pos.x, y: pos.y, color }; + flashingPixelRef.current = { x: pos.x, y: pos.y }; // Set flashing pixel draw(); sendPixel({ x: pos.x, y: pos.y, color }); + + // 10초 카운트 다운 소리 + // playCountDown(); + + // The flashingPixelRef will now be cleared when cooldown ends, not after 1 second. setTimeout(() => { previewPixelRef.current = null; pos.color = 'transparent'; + stopCountDown(); draw(); }, 1000); - }, [color, draw, sendPixel, handleCooltime]); + }, [color, draw, sendPixel, handleCooltime, playCountDown, stopCountDown]); const handleSelectColor = useCallback( (newColor: string) => { @@ -679,203 +765,47 @@ function PixelCanvas({ [draw] ); - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - const sx = e.nativeEvent.offsetX; - const sy = e.nativeEvent.offsetY; - const wx = (sx - viewPosRef.current.x) / scaleRef.current; - const wy = (sy - viewPosRef.current.y) / scaleRef.current; - - // 이미지 모드에서 리사이즈 핸들 또는 이미지 영역 클릭 감지 - if ( - imageMode && - !isImageFixed && - imageCanvasRef.current && - e.button === 0 - ) { - const handle = getResizeHandle(wx, wy); - - if (handle) { - // 리사이즈 핸들 클릭 - setIsResizing(true); - setResizeHandle(handle); - setResizeStart({ - x: wx, - y: wy, - width: imageSize.width, - height: imageSize.height, - }); - return; - } else if ( - wx >= imagePosition.x && - wx <= imagePosition.x + imageSize.width && - wy >= imagePosition.y && - wy <= imagePosition.y + imageSize.height - ) { - // 이미지 드래그 - setIsDraggingImage(true); - setDragStart({ x: wx - imagePosition.x, y: wy - imagePosition.y }); - return; - } - } - - if (e.button === 0) { - dragStartInfoRef.current = { x: sx, y: sy }; - } - }, - [ - imageMode, - isImageFixed, - imagePosition, - imageSize, - getResizeHandle, - setIsResizing, - setResizeHandle, - setResizeStart, - setIsDraggingImage, - setDragStart, - ] - ); - - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - const { offsetX, offsetY } = e.nativeEvent; - - if (dragStartInfoRef.current && !isPanningRef.current) { - const dx = offsetX - dragStartInfoRef.current.x; - const dy = offsetY - dragStartInfoRef.current.y; - if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { - isPanningRef.current = true; - startPosRef.current = { - // 패닝 시작 위치 설정 - x: offsetX - viewPosRef.current.x, - y: offsetY - viewPosRef.current.y, - }; - dragStartInfoRef.current = null; // 대기 상태 해제 - } - } - - // 이미지 리사이즈 중 - if (isResizing && resizeHandle) { - const wx = (offsetX - viewPosRef.current.x) / scaleRef.current; - const wy = (offsetY - viewPosRef.current.y) / scaleRef.current; - - let newWidth = imageSize.width; - let newHeight = imageSize.height; - - if (resizeHandle === 'se') { - // 대각선 리사이즈 - newWidth = resizeStart.width + (wx - resizeStart.x); - newHeight = resizeStart.height + (wy - resizeStart.y); - } else if (resizeHandle === 'e') { - // 가로만 리사이즈 - newWidth = resizeStart.width + (wx - resizeStart.x); - } else if (resizeHandle === 's') { - // 세로만 리사이즈 - newHeight = resizeStart.height + (wy - resizeStart.y); - } - - if ( - newWidth > 10 && - newHeight > 10 && - newWidth < canvasSize.width * 2 && - newHeight < canvasSize.height * 2 - ) { - setImageSize({ width: newWidth, height: newHeight }); - draw(); - } - return; - } - - // 이미지 드래그 중 - if (isDraggingImage && !isImageFixed) { - const wx = (offsetX - viewPosRef.current.x) / scaleRef.current; - const wy = (offsetY - viewPosRef.current.y) / scaleRef.current; - setImagePosition({ - x: wx - dragStart.x, - y: wy - dragStart.y, - }); - draw(); - return; - } - - // 캔버스 팬닝 중 - if (isPanningRef.current) { - viewPosRef.current = { - x: offsetX - startPosRef.current.x, - y: offsetY - startPosRef.current.y, - }; - draw(); - } - updateOverlay(offsetX, offsetY); - }, - [ - draw, - updateOverlay, - isDraggingImage, - isImageFixed, - dragStart, - isResizing, - resizeHandle, - resizeStart, - imageSize, - canvasSize, - setImagePosition, - setImageSize, - ] - ); - - const handleMouseUp = useCallback( - (e: React.MouseEvent) => { - // If it was a click (not a drag/pan) - if (dragStartInfoRef.current) { - const dx = e.nativeEvent.offsetX - dragStartInfoRef.current.x; - const dy = e.nativeEvent.offsetY - dragStartInfoRef.current.y; - if (Math.sqrt(dx * dx + dy * dy) <= DRAG_THRESHOLD) { - // This was a click, not a drag - const sx = e.nativeEvent.offsetX; - const sy = e.nativeEvent.offsetY; - const wx = (sx - viewPosRef.current.x) / scaleRef.current; - const wy = (sy - viewPosRef.current.y) / scaleRef.current; - - const pixelX = Math.floor(wx); - const pixelY = Math.floor(wy); - - if ( - pixelX >= 0 && - pixelX < canvasSize.width && - pixelY >= 0 && - pixelY < canvasSize.height && - (!imageCanvasRef.current || isImageFixed) // Only allow pixel selection if no image or image is fixed - ) { - fixedPosRef.current = { - x: pixelX, - y: pixelY, - color: 'transparent', - }; - setShowPalette(true); - centerOnPixel(sx, sy); // Call centerOnPixel here - } - } - } - - isPanningRef.current = false; - setIsDraggingImage(false); - setIsResizing(false); - setResizeHandle(null); - dragStartInfoRef.current = null; // Reset drag start info - }, - [canvasSize, isImageFixed, setShowPalette, centerOnPixel] - ); - - const handleMouseLeave = useCallback( - (e: React.MouseEvent) => { - handleMouseUp(e); - clearOverlay(); - dragStartInfoRef.current = null; // Reset drag start info on mouse leave - }, - [handleMouseUp, clearOverlay] - ); + const { + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleMouseLeave, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + } = useCanvasInteraction({ + viewPosRef, + scaleRef, + imageCanvasRef, + interactionCanvasRef, + fixedPosRef, + canvasSize, + imageMode, + isImageFixed, + isDraggingImage, + setIsDraggingImage, + dragStart, + setDragStart, + isResizing, + setIsResizing, + resizeHandle, + setResizeHandle, + resizeStart, + setResizeStart, + imagePosition, + setImagePosition, + imageSize, + setImageSize, + draw, + updateOverlay, + clearOverlay, + centerOnPixel, + getResizeHandle, + handleImageScale, + setShowPalette, + DRAG_THRESHOLD, + handleConfirm, + }); // fetchCanvasData 분리 useEffect(() => { @@ -889,148 +819,73 @@ function PixelCanvas({ onLoadingChange, setShowCanvas, INITIAL_BACKGROUND_COLOR, + setCanvasType, + setEndedAt, }); }, [ initialCanvasId, setCanvasId, setCanvasSize, setIsLoading, - setHasError, onLoadingChange, setShowCanvas, + setHasError, + setCanvasType, + setEndedAt, ]); - // 투명도 상태가 변경될 때 ref 값만 업데이트하고 draw 함수 직접 호출 - const handleTransparencyChange = useCallback( - (value: number) => { - imageTransparencyRef.current = value; - setImageTransparency(value); - // 투명도가 변경되면 즉시 화면에 반영 (draw 함수 직접 호출) - if (imageCanvasRef.current) { - draw(); - } - }, - [draw, setImageTransparency] - ); - - // PixelCanvas.tsx 내부에 아래 함수들을 추가합니다. - - // --- 터치 이벤트 핸들러 --- - const handleTouchStart = useCallback( - (e: React.TouchEvent) => { - e.preventDefault(); - const touches = e.touches; - - // 두 손가락 터치: 핀치 줌 시작 - if (touches.length === 2) { - const dx = touches[0].clientX - touches[1].clientX; - const dy = touches[0].clientY - touches[1].clientY; - pinchDistanceRef.current = Math.sqrt(dx * dx + dy * dy); - isPanningRef.current = false; // 줌 할때는 패닝 방지 - dragStartInfoRef.current = null; // 두 손가락 터치 시 드래그 시작 정보 초기화 - return; - } - - // 한 손가락 터치: 이동 또는 픽셀 선택 시작 - if (touches.length === 1) { - const touch = touches[0]; - const rect = interactionCanvasRef.current!.getBoundingClientRect(); - const sx = touch.clientX - rect.left; - const sy = touch.clientY - rect.top; + useEffect(() => { + if (initialCanvasId && initialCanvasId !== canvas_id) { + setCanvasId(initialCanvasId); + console.log('Canvas ID changed:', initialCanvasId); + } + }, [initialCanvasId, canvas_id, setCanvasId]); - dragStartInfoRef.current = { x: sx, y: sy }; - lastTouchPosRef.current = { x: sx, y: sy }; - } - }, - [] - ); + // 그룹 이미지 업로드를 위한 이벤트 리스너 + useEffect(() => { + const handleCanvasImageAttach = (event: Event) => { + const customEvent = event as CustomEvent; + const { file, groupUpload, onConfirm } = customEvent.detail; + + if (groupUpload && file) { + // 그룹 이미지 업로드인 경우 확정 이벤트 리스너 추가 + const handleGroupImageConfirmed = (confirmEvent: Event) => { + const confirmCustomEvent = confirmEvent as CustomEvent; + const imageData = confirmCustomEvent.detail; + + // 그룹 이미지 확정 콜백 호출 + if (onConfirm) { + onConfirm(imageData); + } - const handleTouchMove = useCallback( - (e: React.TouchEvent) => { - e.preventDefault(); - const touches = e.touches; - const rect = interactionCanvasRef.current!.getBoundingClientRect(); - - // 두 손가락 터치: 핀치 줌 로직 - if (touches.length === 2) { - const dx = touches[0].clientX - touches[1].clientX; - const dy = touches[0].clientY - touches[1].clientY; - const newDistance = Math.sqrt(dx * dx + dy * dy); - const oldDistance = pinchDistanceRef.current; - - if (oldDistance > 0) { - const scaleFactor = newDistance / oldDistance; - const newScale = Math.max( - MIN_SCALE, - Math.min(MAX_SCALE, scaleRef.current * scaleFactor) + // 이벤트 리스너 제거 + document.removeEventListener( + 'group-image-confirmed', + handleGroupImageConfirmed ); - const centerX = - (touches[0].clientX + touches[1].clientX) / 2 - rect.left; - const centerY = - (touches[0].clientY + touches[1].clientY) / 2 - rect.top; - - const xs = (centerX - viewPosRef.current.x) / scaleRef.current; - const ys = (centerY - viewPosRef.current.y) / scaleRef.current; - - viewPosRef.current.x = centerX - xs * newScale; - viewPosRef.current.y = centerY - ys * newScale; - scaleRef.current = newScale; + }; - draw(); - updateOverlay(centerX, centerY); - } - pinchDistanceRef.current = newDistance; - return; + // 이미지 확정 이벤트 리스너 추가 + document.addEventListener( + 'group-image-confirmed', + handleGroupImageConfirmed + ); } - // 한 손가락 터치: 이동 로직 - if (touches.length === 1) { - const touch = touches[0]; - const sx = touch.clientX - rect.left; - const sy = touch.clientY - rect.top; - - if (dragStartInfoRef.current && !isPanningRef.current) { - const dx = sx - dragStartInfoRef.current.x; - const dy = sy - dragStartInfoRef.current.y; - if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { - isPanningRef.current = true; - startPosRef.current = { - x: sx - viewPosRef.current.x, - y: sy - viewPosRef.current.y, - }; - dragStartInfoRef.current = null; - } - } + // 파일 처리 + handleImageAttach(file, customEvent.detail); + }; - if (isPanningRef.current) { - viewPosRef.current = { - x: sx - startPosRef.current.x, - y: sy - startPosRef.current.y, - }; - draw(); - } - updateOverlay(sx, sy); - lastTouchPosRef.current = { x: sx, y: sy }; - } - }, - [draw, updateOverlay] - ); + document.addEventListener('canvas-image-attach', handleCanvasImageAttach); + + return () => { + document.removeEventListener( + 'canvas-image-attach', + handleCanvasImageAttach + ); + }; + }, [handleImageAttach]); - const handleTouchEnd = useCallback( - (e: React.TouchEvent) => { - // 모든 제스처 상태 초기화 - pinchDistanceRef.current = 0; - // handleMouseUp에 마지막 터치 위치를 전달하여 클릭/드래그 판단에 사용 - handleMouseUp({ - nativeEvent: { - offsetX: lastTouchPosRef.current?.x || 0, - offsetY: lastTouchPosRef.current?.y || 0, - }, - } as React.MouseEvent); - lastTouchPosRef.current = null; // 터치 종료 시 초기화 - }, - [handleMouseUp] - ); // 투명도 상태가 변경될 때 ref 값 업데이트 및 draw 함수 호출 useEffect(() => { imageTransparencyRef.current = imageTransparency; @@ -1048,6 +903,140 @@ function PixelCanvas({ } }, [targetPixel, centerOnWorldPixel, setTargetPixel]); + // 그룹 이미지 수신 이벤트 리스너 - 편집 기능 없이 바로 그리기 + useEffect(() => { + const handleGroupImageReceived = (event: Event) => { + const customEvent = event as CustomEvent; + const { url, x, y, width, height } = customEvent.detail; + + console.log('방장 이미지 수신:', { url, x, y, width, height }); + + // 이미지 로드 + const img = new Image(); + img.crossOrigin = 'anonymous'; + + img.onload = () => { + // 먼저 이미지 고정 상태 설정 + setIsImageFixed(true); + setShowImageControls(false); + + // 이미지 캠버스 생성 + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + + if (ctx) { + // 이미지를 캠버스에 그리기 + ctx.drawImage(img, 0, 0); + + // 방장 이미지임을 표시 + const groupCanvas = canvas as any; + groupCanvas._isGroupImage = true; + + // 캠버스 설정 + imageCanvasRef.current = groupCanvas; + + // 이미지 크기와 위치 설정 + const numX = Number(x); + const numY = Number(y); + const numWidth = Number(width); + const numHeight = Number(height); + + setImageSize({ width: numWidth, height: numHeight }); + setImagePosition({ x: numX, y: numY }); + + // 이미지가 있는 위치로 화면 이동 + centerOnWorldPixel(numX + numWidth / 2, numY + numHeight / 2); + + // 화면 그리기 + draw(); + } + }; + + img.onerror = () => { + toast.error('이미지를 불러오는데 실패했습니다.'); + }; + + img.src = url; + }; + + document.addEventListener('group-image-received', handleGroupImageReceived); + + return () => { + document.removeEventListener( + 'group-image-received', + handleGroupImageReceived + ); + }; + }, [ + centerOnWorldPixel, + draw, + setImagePosition, + setImageSize, + setIsImageFixed, + setShowImageControls, + ]); + + // Animation loop for flashing pixel + useEffect(() => { + let animationFrameId: number; + + const animate = () => { + draw(); + animationFrameId = requestAnimationFrame(animate); + }; + + // Start animation loop if there's a cooldown or a pixel is flashing + if (cooldown || flashingPixelRef.current) { + animationFrameId = requestAnimationFrame(animate); + } + + return () => { + cancelAnimationFrame(animationFrameId); + }; + }, [cooldown, draw]); + + // Countdown timer for event canvases + useEffect(() => { + let timerInterval: number; + + const calculateTimeLeft = () => { + if ( + canvasType === CanvasType.EVENT_COMMON || + (canvasType === CanvasType.EVENT_COLORLIMIT && endedAt) + ) { + const endDate = new Date(endedAt!); + const now = new Date(); + const difference = endDate.getTime() - now.getTime(); + + if (difference > 0) { + const days = Math.floor(difference / (1000 * 60 * 60 * 24)); + const hours = Math.floor((difference / (1000 * 60 * 60)) % 24); + const minutes = Math.floor((difference / (1000 * 60)) % 60); + const seconds = Math.floor((difference / 1000) % 60); + + setTimeLeft( + `D-${days} ${String(hours).padStart(2, '0')}:${String( + minutes + ).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` + ); + } else { + setTimeLeft('캔버스 종료'); + openCanvasEndedModal(); // 캔버스 종료 시 모달 열기 + clearInterval(timerInterval); + } + } else { + setTimeLeft(null); + } + }; + + calculateTimeLeft(); // Initial calculation + timerInterval = setInterval(calculateTimeLeft, 1000); // Update every second + + return () => clearInterval(timerInterval); + }, [canvasType, endedAt, openCanvasEndedModal]); // 의존성 배열에 openCanvasEndedModal 추가 + useEffect(() => { const rootElement = rootRef.current; if (!rootElement) return; @@ -1080,59 +1069,33 @@ function PixelCanvas({ return () => observer.disconnect(); }, [resetAndCenter]); - useEffect(() => { - const interactionCanvas = interactionCanvasRef.current; - if (!interactionCanvas) return; - - const handleWheel = (e: WheelEvent) => { - e.preventDefault(); - const { offsetX, offsetY } = e; - - // 이미지 모드에서 이미지만 확대축소 - if (imageMode && !isImageFixed && imageCanvasRef.current) { - const delta = -e.deltaY; - const scaleFactor = delta > 0 ? 1.1 : 0.9; - handleImageScale(scaleFactor); - return; - } - - // 캔버스 모드 또는 이미지 확정 후 전체 확대축소 - const xs = (offsetX - viewPosRef.current.x) / scaleRef.current; - const ys = (offsetY - viewPosRef.current.y) / scaleRef.current; - const delta = -e.deltaY; - const newScale = - delta > 0 ? scaleRef.current * 1.2 : scaleRef.current / 1.2; - - if (newScale >= MIN_SCALE && newScale <= MAX_SCALE) { - scaleRef.current = newScale; - viewPosRef.current.x = offsetX - xs * scaleRef.current; - viewPosRef.current.y = offsetY - ys * scaleRef.current; - draw(); - updateOverlay(offsetX, offsetY); - } - }; - - interactionCanvas.addEventListener('wheel', handleWheel, { - passive: false, - }); - return () => interactionCanvas.removeEventListener('wheel', handleWheel); - }, [draw, updateOverlay, handleImageScale, imageMode, isImageFixed]); + if (hasError) { + return ; + } return (
+ + {timeLeft && ( +
+ {canvasType === CanvasType.EVENT_COLORLIMIT && ( +

BLACK&WHITE

+ )} + {timeLeft} +
+ )} {cooldown && ( <>
@@ -1158,7 +1121,10 @@ function PixelCanvas({ { + playClick(); + handleMouseDown(e); + }} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} @@ -1174,7 +1140,7 @@ function PixelCanvas({ ) : ( )} {showImageControls && !isImageFixed && ( @@ -1240,7 +1207,7 @@ function PixelCanvas({ 🎨 캔버스 모드
-
• 우클릭 드래그: 캔버스 이동
+
• 좌클릭 드래그: 캔버스 이동
• 마우스 휠: 캔버스 확대/축소
• 이미지는 고정된 상태
diff --git a/src/components/canvas/StarfieldCanvas.css b/src/components/canvas/StarfieldCanvas.css new file mode 100644 index 0000000..164ae12 --- /dev/null +++ b/src/components/canvas/StarfieldCanvas.css @@ -0,0 +1,9 @@ +#starfield-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; /* 다른 모든 요소 뒤에 위치하도록 명시 */ + background-color: black; +} diff --git a/src/components/canvas/StarfieldCanvas.tsx b/src/components/canvas/StarfieldCanvas.tsx new file mode 100644 index 0000000..ec2751d --- /dev/null +++ b/src/components/canvas/StarfieldCanvas.tsx @@ -0,0 +1,161 @@ +import React, { useRef, useEffect } from 'react'; +import './StarfieldCanvas.css'; + +type StarfieldCanvasProps = { + viewPosRef: React.RefObject<{ x: number; y: number }>; +}; + +const StarfieldCanvas = ({ viewPosRef }: StarfieldCanvasProps) => { + const canvasRef = useRef(null); + const animationFrameIdRef = useRef(0); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let w = (canvas.width = window.innerWidth); + let h = (canvas.height = window.innerHeight); + + const hue = 217; + const stars: any[] = []; + let count = 0; + const maxStars = 400; // Reduced for a sparser effect + + const canvas2 = document.createElement('canvas'); + const ctx2 = canvas2.getContext('2d'); + canvas2.width = 100; + canvas2.height = 100; + const half = canvas2.width / 2; + const gradient2 = ctx2!.createRadialGradient( + half, + half, + 0, + half, + half, + half + ); + gradient2.addColorStop(0.025, '#fff'); + gradient2.addColorStop(0.1, `hsl(${hue}, 61%, 33%)`); + gradient2.addColorStop(0.25, `hsl(${hue}, 64%, 6%)`); + gradient2.addColorStop(1, 'transparent'); + + ctx2!.fillStyle = gradient2; + ctx2!.beginPath(); + ctx2!.arc(half, half, half, 0, Math.PI * 2); + ctx2!.fill(); + + function random(min: number, max?: number) { + if (max === undefined) { + max = min; + min = 0; + } + if (min > max) { + [min, max] = [max, min]; + } + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + function maxOrbit(x: number, y: number) { + const max = Math.max(x, y); + const diameter = Math.round(Math.sqrt(max * max + max * max)); + return diameter / 2; + } + + class Star { + orbitRadius: number; + radius: number; + orbitX: number; + orbitY: number; + timePassed: number; + speed: number; + alpha: number; + parallaxFactor: number; // New: for parallax effect + + constructor() { + this.orbitRadius = random(maxOrbit(w, h)); + this.radius = random(60, this.orbitRadius) / 12; + this.orbitX = w / 2; + this.orbitY = h / 2; + this.timePassed = random(0, maxStars); + this.speed = random(this.orbitRadius) / 400000; + this.alpha = random(2, 10) / 10; + this.parallaxFactor = random(2, 10) / 10; // Assign a random parallax factor + count++; + stars[count] = this; + } + + draw() { + const canvasX = + Math.sin(this.timePassed) * this.orbitRadius + this.orbitX; + const canvasY = + Math.cos(this.timePassed) * this.orbitRadius + this.orbitY; + const twinkle = random(10); + + if (twinkle === 1 && this.alpha > 0) { + this.alpha -= 0.05; + } else if (twinkle === 2 && this.alpha < 1) { + this.alpha += 0.05; + } + + // Calculate parallax offset + const parallaxX = viewPosRef.current + ? viewPosRef.current.x * this.parallaxFactor * 0.1 // Adjust multiplier for desired effect + : 0; + const parallaxY = viewPosRef.current + ? viewPosRef.current.y * this.parallaxFactor * 0.1 // Adjust multiplier for desired effect + : 0; + + ctx!.globalAlpha = this.alpha; + ctx!.drawImage( + canvas2, + canvasX - this.radius / 2 + parallaxX, + canvasY - this.radius / 2 + parallaxY, + this.radius, + this.radius + ); + this.timePassed += this.speed; + } + } + + for (let i = 0; i < maxStars; i++) { + new Star(); + } + + const animation = () => { + ctx!.globalCompositeOperation = 'source-over'; + ctx!.globalAlpha = 0.8; + ctx!.fillStyle = 'black'; // Solid black background + ctx!.fillRect(0, 0, w, h); + + ctx!.globalCompositeOperation = 'lighter'; + for (let i = 1, l = stars.length; i < l; i++) { + stars[i].draw(); + } + + animationFrameIdRef.current = window.requestAnimationFrame(animation); + }; + + animation(); + + const handleResize = () => { + w = canvas.width = window.innerWidth; + h = canvas.height = window.innerHeight; + }; + + window.addEventListener('resize', handleResize); + + return () => { + if (animationFrameIdRef.current) { + window.cancelAnimationFrame(animationFrameIdRef.current); + } + window.removeEventListener('resize', handleResize); + }; + }, [viewPosRef]); // Add viewPosRef to dependency array + + return ; +}; + +export default StarfieldCanvas; diff --git a/src/components/canvas/UserCount.tsx b/src/components/canvas/UserCount.tsx new file mode 100644 index 0000000..a773b65 --- /dev/null +++ b/src/components/canvas/UserCount.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState, useCallback } from 'react'; +import socketService from '../../services/socketService'; +import { useCanvasStore } from '../../store/canvasStore'; + +interface ActiveUserCountData { + count: number; // 전체 접속자 수 (소켓 연결 수) + canvasCounts: { + // 캔버스별 접속자 수 + [canvasId: string]: number; + }; + timestamp: number; // 이벤트 발생 시간 (Unix timestamp) +} + +export default function UserCount() { + const [userCount, setUserCount] = useState(null); + const [canvasCount, setCanvasCount] = useState(null); + const { canvas_id } = useCanvasStore(); + + // useCallback으로 함수를 메모이제이션하여 불필요한 리렌더링 방지 + const handleUserCountChange = (data: ActiveUserCountData | null) => { + if (data) { + setUserCount(data.count); + const currentCanvasCount = data.canvasCounts[canvas_id]; + setCanvasCount(currentCanvasCount || 0); // undefined일 경우 0으로 설정 + } + }; + + useEffect(() => { + // 리스너 등록 + socketService.onUserCountChange(handleUserCountChange); + + // // cleanup 함수: 컴포넌트 언마운트 또는 의존성 변경 시 리스너 제거 + return () => { + socketService.offUserCountChange(handleUserCountChange); + }; + }, [canvas_id, handleUserCountChange]); // handleUserCountChange가 변경될 때만 재실행 + + return ( +
+
+
+ + + + + + + {userCount === null ? ( + + ) : ( + userCount + )} + +
+ +
+ + + + + {canvasCount === null ? ( + + ) : ( + canvasCount + )} + +
+
+
+ ); +} diff --git a/src/components/canvas/canvasConstants.ts b/src/components/canvas/canvasConstants.ts index b2e0a19..8aaa1ea 100644 --- a/src/components/canvas/canvasConstants.ts +++ b/src/components/canvas/canvasConstants.ts @@ -26,3 +26,10 @@ export const COLORS = [ '#ffd700', '#87cefa', ]; + +export enum CanvasType { + PUBLIC = 'public', + EVENT_COMMON = 'event_common', + EVENT_COLORLIMIT = 'event_colorlimit', + GAME_CALCULATION = 'game_calculation', +} diff --git a/src/components/chat/Chat.tsx b/src/components/chat/Chat.tsx index 6c1ed72..0865594 100644 --- a/src/components/chat/Chat.tsx +++ b/src/components/chat/Chat.tsx @@ -7,12 +7,17 @@ import { useCanvasStore } from '../../store/canvasStore'; import { useChatSocket } from '../SocketIntegration'; import { useAuthStore } from '../../store/authStrore'; import { useModalStore } from '../../store/modalStore'; +import { useCanvasUiStore } from '../../store/canvasUiStore'; +import { useChatStore } from '../../store/chatStore'; +import { toast } from 'react-toastify'; +import socketService from '../../services/socketService'; // 임시로 사용할 가짜 메시지 데이터 export type Group = { group_id: string; group_title: string; + made_by: string; }; function Chat() { @@ -24,15 +29,23 @@ function Chat() { const [groups, setGroups] = useState([]); const [currentGroupId, setCurrentGroupId] = useState(null); const [isLoading, setIsLoading] = useState(false); // 로딩 상태 추가 - const canvas_id = useCanvasStore((state) => state.canvas_id); + const { leader, setLeader, isSyncEnabled, setIsSyncEnabled } = useChatStore(); const { user, isLoggedIn } = useAuthStore(); - const { openLoginModal, isGroupModalOpen } = useModalStore(); + const { openLoginModal, isGroupModalOpen, openChat, closeChat } = + useModalStore(); // 채팅 소켓 연결 - 유효한 group_id가 있을 때만 - const { sendMessage: sendSocketMessage, leaveChat } = useChatSocket({ + const { + sendMessage: sendChatMessage, + sendImageMessage, + leaveChat, + } = useChatSocket({ + // 일반 채팅 메시지 수신 onMessageReceived: (message) => { - console.log('메시지 수신:', message); + console.log('채팅 메시지 수신:', message); + + // 일반 메시지 처리 const newMessage: Message = { messageId: message.id.toString(), user: { @@ -45,12 +58,43 @@ function Chat() { setMessages((prev) => [...prev, newMessage]); }, + // 이미지 업로드 알림 수신 + onImageReceived: (message) => { + console.log('이미지 업로드 알림 수신:', message); + // 이미지 정보 추출 + const { url, x, y, width, height } = message; + const syncState = useChatStore.getState().isSyncEnabled; + console.log('현재 동기화 상태:', syncState); + + // 새로운 메시지 추가 - 방장이 이미지를 업로드했음을 알리는 메시지 + const newMessage: Message = { + messageId: Date.now().toString(), + user: { + userId: '', + name: '공지', + }, + content: syncState + ? ` 📣 방장이 새로운 이미지를 업로드했습니다. 화면에 표시됩니다.` + : ` 📣 방장이 새로운 이미지를 업로드했습니다. 동기화 버튼을 클릭하여 화면에 표시하세요.`, + timestamp: new Date().toISOString(), + }; + setMessages((prev) => [...prev, newMessage]); + + // 동기화 상태일 때만 이미지 반영 + if (syncState) { + // 이미지 반영 + document.dispatchEvent( + new CustomEvent('group-image-received', { + detail: { url, x, y, width, height }, + }) + ); + } + }, + group_id: currentGroupId || '0', // 유효하지 않은 group_id 사용 user_id: user?.userId || '', }); - // const {getChatMessages} = chatService(); - // 그룹 변경 핸들러 함수 const handleGroupChange = async (groupId: string) => { if (groupId === currentGroupId) return; @@ -58,7 +102,11 @@ function Chat() { try { setCurrentGroupId(groupId); setIsLoading(true); // 로딩 시작 - const newMessages = await chatService.getChatMessages(groupId); + // 그룹 변경 시 동기화 상태 비활성화 + setIsSyncEnabled(false); + const { newMessages, madeBy } = + await chatService.getChatMessages(groupId); + setLeader(madeBy); setMessages(newMessages); // 메시지 상태 업데이트 } catch (error) { console.error( @@ -85,7 +133,7 @@ function Chat() { userId: user?.userId, }); if (currentGroupId && user?.userId) { - sendSocketMessage(text); + sendChatMessage(text); } }; @@ -97,8 +145,17 @@ function Chat() { useEffect(() => { if (isOpen && (isGroupModalOpen || !isLoggedIn)) { setIsOpen(false); + closeChat(); + } + }, [isGroupModalOpen, isLoggedIn, isOpen, closeChat]); + + // 컴포넌트 최상단 + useEffect(() => { + if (isSyncEnabled && currentGroupId) { + console.log('동기화 시작됨: ', isSyncEnabled); + socketService.joinImg({ group_id: currentGroupId }); } - }, [isGroupModalOpen, isLoggedIn, isOpen]); + }, [isSyncEnabled, currentGroupId]); // isOpen True 시, canvasId 변경시 useEffect(() => { @@ -107,13 +164,16 @@ function Chat() { const fetchInitialData = async () => { console.log(`start fetch, ${canvas_id}`); setIsLoading(true); // 로딩 시작 + // 채팅창 열 때 동기화 상태 초기화 + setIsSyncEnabled(false); + setLeader(''); try { const { defaultGroupId, groups: fetchedGroups, messages: initialMessages, } = await chatService.getChatInitMessages(canvas_id); - + // defaultGroupId 저장 setGroups(fetchedGroups); setCurrentGroupId(defaultGroupId); setMessages(initialMessages); @@ -129,17 +189,162 @@ function Chat() { }, [isOpen, canvas_id]); return ( -
+
{/* 채팅창 UI */}
{/* 헤더: 동적 제목 표시 */} -
+

{chatTitle}

+ +
+ {leader === user?.userId && ( + + )} + +
{/* 그룹 목록 탭 */} @@ -154,9 +359,13 @@ function Chat() { : 'bg-white/10 text-gray-200 hover:bg-white/20' }`} > - {group.group_title.length > 10 - ? `${group.group_title.substring(0, 10)}...` - : group.group_title} + {group.made_by === user?.userId + ? group.group_title.length > 5 + ? `👑 ${group.group_title.substring(0, 5)}...` + : `👑 ${group.group_title}` + : group.group_title.length > 5 + ? `${group.group_title.substring(0, 5)}...` + : group.group_title} ))}
@@ -188,11 +397,14 @@ function Chat() { } if (isOpen) { - leaveChat(); + setIsOpen(false); + closeChat(); // Synchronize with modal store + } else { + setIsOpen(true); + openChat(); // Synchronize with modal store } - setIsOpen(!isOpen); }} - className='flex h-10 w-10 items-center justify-center rounded-full bg-blue-500 text-white shadow-xl transition-transform hover:bg-blue-600 active:scale-90 pointer-events-auto' + className='pointer-events-auto flex h-10 w-10 items-center justify-center rounded-full bg-blue-500 text-white shadow-xl transition-transform hover:bg-blue-600 active:scale-90' > {isOpen ? ( // 닫기 아이콘 (X) diff --git a/src/components/chat/ChatAPI.tsx b/src/components/chat/ChatAPI.tsx index 4e2a880..2faa1cc 100644 --- a/src/components/chat/ChatAPI.tsx +++ b/src/components/chat/ChatAPI.tsx @@ -1,4 +1,5 @@ import apiClient from '../../services/apiClient'; +import { useAuthStore } from '../../store/authStrore'; export const chatService = { /** @@ -23,7 +24,15 @@ export const chatService = { new Date(a.timestamp || a.created_at).getTime() - new Date(b.timestamp || b.created_at).getTime() ); - return { defaultGroupId, groups, messages: sortedMessages }; + const sortedGroups = groups.sort( + (a: { group_id: string }, b: { group_id: string }) => + Number(a.group_id) - Number(b.group_id) + ); + return { + defaultGroupId, + groups: sortedGroups, + messages: sortedMessages, + }; } catch (error) { console.error(`Failed to fetch message for chat ${canvasId}:`, error); throw error; @@ -41,9 +50,8 @@ export const chatService = { params: { group_id: groupId, limit }, }); // 실제 API에서는 data.messages 형태로 올 수 있습니다. - const messages = response.data.data.messages; - // 메시지를 시간순으로 정렬 - return messages.sort( + console.log(response.data.data); + const newMessages = response.data.data.messages.sort( ( a: { timestamp: any; created_at: any }, b: { timestamp: any; created_at: any } @@ -51,9 +59,44 @@ export const chatService = { new Date(a.timestamp || a.created_at).getTime() - new Date(b.timestamp || b.created_at).getTime() ); + const madeBy = response.data.data.group.made_by; + + // 메시지를 시간순으로 정렬 + return { newMessages, madeBy }; } catch (error) { console.error(`Failed to fetch messages for group ${groupId}:`, error); throw error; } }, + + /** + * 그룹 이미지 업로드를 위한 URL 요청 + * @param groupId - 그룹 ID + * @param contentType - 이미지 타입 (예: image/png) + */ + async getGroupImageUploadUrl(groupId: string, contentType: string) { + try { + // authStore에서 토큰 가져오기 + const { accessToken } = useAuthStore.getState(); + const response = await apiClient.post( + '/group/upload', + { + group_id: groupId, + contentType: contentType, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + return response.data.url; + } catch (error) { + console.error( + `Failed to get image upload URL for group ${groupId}:`, + error + ); + throw error; + } + }, }; diff --git a/src/components/chat/MessageItem.tsx b/src/components/chat/MessageItem.tsx index 3e9b413..6f7f9bc 100644 --- a/src/components/chat/MessageItem.tsx +++ b/src/components/chat/MessageItem.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useAuthStore } from '../../store/authStrore'; import { useCanvasUiStore } from '../../store/canvasUiStore'; +import { useChatStore } from '../../store/chatStore'; export type Message = { messageId: string; @@ -16,6 +17,7 @@ const MessageItem = React.memo(({ message }: { message: Message }) => { const currentUser = useAuthStore((state) => state.user); const setTargetPixel = useCanvasUiStore((state) => state.setTargetPixel); const isMyMessage = message.user.userId === currentUser?.userId; + const { leader } = useChatStore(); const handleCoordinateClick = (x: number, y: number) => { setTargetPixel({ x, y }); @@ -41,7 +43,7 @@ const MessageItem = React.memo(({ message }: { message: Message }) => { parts.push( handleCoordinateClick(x, y)} > {fullMatch} @@ -71,7 +73,9 @@ const MessageItem = React.memo(({ message }: { message: Message }) => {
{!isMyMessage && (
- {message.user.name} + {leader === message.user.userId + ? `👑 ${message.user.name}` + : message.user.name}
)}
diff --git a/src/components/game/GameCanvas.tsx b/src/components/game/GameCanvas.tsx new file mode 100644 index 0000000..57b1489 --- /dev/null +++ b/src/components/game/GameCanvas.tsx @@ -0,0 +1,1242 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; + +import GameStarfieldCanvas from './GameStarfieldCanvas'; +import { useCanvasUiStore } from '../../store/canvasUiStore'; +import Preloader from '../Preloader'; +import { useCanvasStore } from '../../store/canvasStore'; +import { useAuthStore } from '../../store/authStrore'; +import { toast } from 'react-toastify'; +import { GameAPI } from '../../api/GameAPI'; +import type { WaitingRoomData } from '../../api/GameAPI'; +import { useTimeSyncStore } from '../../store/timeSyncStore'; +import NotFoundPage from '../../pages/NotFoundPage'; +import { useCanvasInteraction } from '../../hooks/useCanvasInteraction'; +import useSound from 'use-sound'; +import { useGameSocketIntegration } from '../gameSocketIntegration'; +import { useNavigate } from 'react-router-dom'; +import GameTimer from './GameTimer'; // GameTimer import 추가 +import GameResultModal from '../modal/GameResultModal'; // 게임 결과 모달 import +import DeathModal from '../modal/DeathModal'; // 사망 모달 import +import QuestionModal from '../modal/QuestionModal'; // 문제 모달 import +import ExitModal from '../modal/ExitModal'; // 나가기 모달 import + +import { + INITIAL_POSITION, + MIN_SCALE, + MAX_SCALE, + INITIAL_BACKGROUND_COLOR, + VIEWPORT_BACKGROUND_COLOR, +} from '../canvas/canvasConstants'; +import GameReadyModal from './GameReadyModal'; + +// 게임 문제 타입 정의 +interface GameQuestion { + id: string; + question: string; + options: string[]; + answer: number; +} + +type GameCanvasProps = { + canvas_id: string; + onLoadingChange?: (loading: boolean) => void; +}; + +function GameCanvas({ + canvas_id: initialCanvasId, + onLoadingChange, +}: GameCanvasProps) { + const [waitingData, setWaitingData] = useState(null); // API에서 가져온 게임 데이터 + const [isGameStarted, setIsGameStarted] = useState(false); // 게임 시작 상태 + const [isReadyModalOpen, setIsReadyModalOpen] = useState(true); // 모달 표시 상태 + const [assignedColor, setAssignedColor] = useState( + undefined + ); + const [readyTime, setReadyTime] = useState(undefined); // 대기 모달 카운트다운 + const [gameTime, setGameTime] = useState(90); // 실제 게임 시간 (초) + const [totalGameDuration, setTotalGameDuration] = useState(90); // 전체 게임 시간 (초) + const [lives, setLives] = useState(2); // 사용자 생명 (2개) + + const navigate = useNavigate(); + const { canvas_id, setCanvasId } = useCanvasStore(); + const { user } = useAuthStore(); // 현재 사용자 정보 가져오기 + const [showDeathModal, setShowDeathModal] = useState(false); + const [showExitModal, setShowExitModal] = useState(false); // 나가기 모달 상태 + const [isGameEnded, setIsGameEnded] = useState(false); // 게임 종료 상태 + const [isWaitingForResults, setIsWaitingForResults] = useState(false); // 결과 대기 상태 + const [gameResults, setGameResults] = useState | null>(null); // 게임 결과 + const [userColor, setUserColor] = useState('#FF5733'); // 사용자 색상 (서버에서 받아올 예정) + const [playExplosion] = useSound('/explosion.mp3', { + volume: 0.2, + }); + + // 게임 대기 모달창 배경음악 + const [playAdventureMusic, { stop: stopAdventureMusic }] = useSound( + '/adventure.mp3', + { + volume: 0.15, + loop: true, + } + ); + + // 게임 시작 배경음악 + const [playGameMusic, { stop: stopGameMusic }] = useSound('/game.mp3', { + volume: 0.25, + loop: true, + }); + const [playClick] = useSound('/click.mp3', { volume: 0.7 }); + + const rootRef = useRef(null); + const previewCanvasRef = useRef(null); + const renderCanvasRef = useRef(null); + const interactionCanvasRef = useRef(null); + const sourceCanvasRef = useRef(null!); + const scaleRef = useRef(1); + const viewPosRef = useRef<{ x: number; y: number }>(INITIAL_POSITION); + const DRAG_THRESHOLD = 5; // 5px 이상 움직이면 드래그로 간주 + const fixedPosRef = useRef<{ x: number; y: number; color: string } | null>( + null + ); + const previewPixelRef = useRef<{ + x: number; + y: number; + color: string; + } | null>(null); + const flashingPixelRef = useRef<{ x: number; y: number } | null>(null); + + // 상태 관리 + const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); + const [hasError, setHasError] = useState(false); + const [canvasType, setCanvasType] = useState(null); + const [showQuestionModal, setShowQuestionModal] = useState(false); + const [currentQuestion, setCurrentQuestion] = useState( + null + ); + const [selectedAnswer, setSelectedAnswer] = useState(null); + const [questionTimeLeft, setQuestionTimeLeft] = useState(10); // 문제 타이머 (10초) + const [questionTimeDisplay, setQuestionTimeDisplay] = useState(10); // 문제 타이머 표시용 + const [showResult, setShowResult] = useState(false); // 문제 결과 표시 상태 + const [isCorrect, setIsCorrect] = useState(false); // 정답 여부 + const [currentPixel, setCurrentPixel] = useState<{ + x: number; + y: number; + color: string; + } | null>(null); + + const cooldown = useCanvasUiStore((state) => state.cooldown); + const timeLeft = useCanvasUiStore((state) => state.timeLeft); + const setHoverPos = useCanvasUiStore((state) => state.setHoverPos); + const startCooldown = useCanvasUiStore((state) => state.startCooldown); + const isLoading = useCanvasUiStore((state) => state.isLoading); + const setIsLoading = useCanvasUiStore((state) => state.setIsLoading); + const showCanvas = useCanvasUiStore((state) => state.showCanvas); + const setShowCanvas = useCanvasUiStore((state) => state.setShowCanvas); + const draw = useCallback(() => { + const src = sourceCanvasRef.current; + if (!src) return; + + const canvas = renderCanvasRef.current; + const ctx = canvas?.getContext('2d'); + if (ctx && canvas) { + ctx.save(); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.translate(viewPosRef.current.x, viewPosRef.current.y); + ctx.scale(scaleRef.current, scaleRef.current); + ctx.fillStyle = INITIAL_BACKGROUND_COLOR; + ctx.fillRect(0, 0, canvasSize.width, canvasSize.height); + + // 테두리 그리기 - PixelCanvas 스타일 + const gradient = ctx.createLinearGradient( + 0, + 0, + canvasSize.width, + canvasSize.height + ); + gradient.addColorStop(0, 'rgba(34, 197, 94, 0.8)'); + gradient.addColorStop(0.25, 'rgba(59, 130, 246, 0.8)'); + gradient.addColorStop(0.5, 'rgba(168, 85, 247, 0.8)'); + gradient.addColorStop(0.75, 'rgba(236, 72, 153, 0.8)'); + gradient.addColorStop(1, 'rgba(34, 197, 94, 0.8)'); + + ctx.strokeStyle = gradient; + ctx.lineWidth = 3 / scaleRef.current; + ctx.strokeRect(-1, -1, canvasSize.width + 2, canvasSize.height + 2); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1 / scaleRef.current; + ctx.strokeRect(0, 0, canvasSize.width, canvasSize.height); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(src, 0, 0); + + // 격자 그리기 + ctx.strokeStyle = 'rgba(255,255,255, 0.3)'; + ctx.lineWidth = 1 / scaleRef.current; + ctx.beginPath(); + for (let x = 0; x <= canvasSize.width; x++) { + ctx.moveTo(x, 0); + ctx.lineTo(x, canvasSize.height); + } + for (let y = 0; y <= canvasSize.height; y++) { + ctx.moveTo(0, y); + ctx.lineTo(canvasSize.width, y); + } + ctx.stroke(); + + ctx.restore(); + } + + const preview = previewCanvasRef.current; + const pctx = preview?.getContext('2d'); + if (pctx && preview) { + pctx.save(); + pctx.clearRect(0, 0, preview.width, preview.height); + pctx.translate(viewPosRef.current.x, viewPosRef.current.y); + pctx.scale(scaleRef.current, scaleRef.current); + + if (fixedPosRef.current && fixedPosRef.current.color !== 'transparent') { + const { x, y, color: fx } = fixedPosRef.current; + pctx.fillStyle = fx; + pctx.fillRect(x, y, 1, 1); + } + + if (fixedPosRef.current) { + const { x, y } = fixedPosRef.current; + pctx.strokeStyle = 'rgba(255,255,0,0.9)'; + pctx.lineWidth = 3 / scaleRef.current; + pctx.strokeRect(x, y, 1, 1); + } + if (previewPixelRef.current) { + const { x, y, color: px } = previewPixelRef.current; + pctx.fillStyle = px; + pctx.fillRect(x, y, 1, 1); + } + + pctx.restore(); + } + + // Flashing pixel effect + if (flashingPixelRef.current) { + const { x, y } = flashingPixelRef.current; + const currentTime = Date.now(); + const isVisible = Math.floor(currentTime / 500) % 2 === 0; // Blink every 500ms + + if (isVisible) { + const flashCtx = previewCanvasRef.current?.getContext('2d'); + if (flashCtx) { + flashCtx.save(); + flashCtx.translate(viewPosRef.current.x, viewPosRef.current.y); + flashCtx.scale(scaleRef.current, scaleRef.current); + flashCtx.strokeStyle = 'rgba(255, 0, 0, 0.9)'; // Red border + flashCtx.lineWidth = 4 / scaleRef.current; + flashCtx.strokeRect(x, y, 1, 1); + flashCtx.restore(); + } + } + } + }, [canvasSize, isGameStarted]); + + // 게임 소켓 연결 + // 다른 유저의 사망 처리 (dead_user 이벤트) + const onDeadPixels = useCallback( + (data: any) => { + playExplosion(); + const { pixels, username } = data; + + // 소스 캔버스에 죽은 픽셀 표시 + const sourceCtx = sourceCanvasRef.current?.getContext('2d'); + if (sourceCtx && pixels && pixels.length > 0) { + // 각 픽셀에 대해 폭발 효과 생성 + pixels.forEach((pixel: { x: number; y: number; color: string }) => { + // 소스 캔버스에 픽셀 그리기 + sourceCtx.fillStyle = pixel.color; + sourceCtx.fillRect(pixel.x, pixel.y, 1, 1); + + // 폭발 효과 생성 + createExplosionEffect(pixel.x, pixel.y); + }); + + // 캔버스 다시 그리기 + draw(); + } + + // 다른 플레이어 사망시 작은 알림 표시 + const deathMessage = document.createElement('div'); + deathMessage.className = + 'fixed z-50 top-4 right-4 bg-gradient-to-b from-red-900/90 to-black/90 text-white px-4 py-2 rounded-lg shadow-lg backdrop-blur-sm border border-red-500 text-sm max-w-[200px]'; + + // 애니메이션 적용 + deathMessage.animate( + [ + { opacity: 0, transform: 'translateY(-20px)' }, + { opacity: 1, transform: 'translateY(0)' }, + ], + { + duration: 300, + easing: 'ease-out', + fill: 'forwards', + } + ); + + deathMessage.innerHTML = ` +
+
☠️
+
+
${username} 전사!
+
상대의 색이 사라졌습니다!
+
지금이 기회! 빈 공간을 차지하세요!
+
+
+ `; + document.body.appendChild(deathMessage); + + // 화면 진동 효과 + const shakeScreen = () => { + const root = rootRef.current; + if (!root) return; + + root.animate( + [ + { transform: 'translate(0, 0)' }, + { transform: 'translate(-5px, 5px)' }, + { transform: 'translate(5px, -5px)' }, + { transform: 'translate(-5px, -5px)' }, + { transform: 'translate(5px, 5px)' }, + { transform: 'translate(0, 0)' }, + ], + { duration: 500, easing: 'ease-in-out' } + ); + }; + + shakeScreen(); + + // 3초 후 메시지 제거 (페이드 아웃 효과 추가) + setTimeout(() => { + deathMessage.animate( + [ + { opacity: 1, transform: 'translateY(0)' }, + { opacity: 0, transform: 'translateY(-20px)' }, + ], + { duration: 300, easing: 'ease-in', fill: 'forwards' } + ); + + setTimeout(() => { + if (document.body.contains(deathMessage)) { + document.body.removeChild(deathMessage); + } + }, 300); + }, 3000); + }, + [draw] + ); + + const onDeadNotice = useCallback( + (data: { message: string }) => { + playExplosion(); + stopGameMusic(); + // React로 모달 열기 + setShowDeathModal(true); + }, + [playExplosion, stopGameMusic] + ); + + // 폭발 효과 생성 함수 + const createExplosionEffect = useCallback((x: number, y: number) => { + // 폭발 효과를 더 화려하게 개선 + // 1. 큰 폭발 원 생성 + const explosion = document.createElement('div'); + explosion.className = 'absolute rounded-full z-40'; + + // 위치 계산 (캔버스 좌표계에서 화면 좌표계로 변환) + const screenX = x * scaleRef.current + viewPosRef.current.x; + const screenY = y * scaleRef.current + viewPosRef.current.y; + + explosion.style.left = `${screenX}px`; + explosion.style.top = `${screenY}px`; + explosion.style.transform = 'translate(-50%, -50%)'; + explosion.style.boxShadow = '0 0 10px 2px rgba(255, 100, 50, 0.8)'; + + // 폭발 애니메이션 + explosion.animate( + [ + { + width: '0px', + height: '0px', + backgroundColor: 'rgba(255, 255, 200, 1)', + opacity: 1, + }, + { + width: '80px', + height: '80px', + backgroundColor: 'rgba(255, 100, 50, 0.8)', + opacity: 0.8, + }, + { + width: '120px', + height: '120px', + backgroundColor: 'rgba(255, 50, 0, 0)', + opacity: 0, + }, + ], + { duration: 600, easing: 'ease-out', fill: 'forwards' } + ); + + document.body.appendChild(explosion); + + // 2. 파티클 수 증가 + const particleCount = 20; + + // 3. 파티클 생성 + for (let i = 0; i < particleCount; i++) { + const particle = document.createElement('div'); + + const isCircle = Math.random() > 0.3; + particle.className = `absolute z-40 ${isCircle ? 'rounded-full' : ''}`; + + // 랜덤 크기 (3px ~ 10px) + const size = Math.floor(Math.random() * 8) + 3; + particle.style.width = `${size}px`; + particle.style.height = `${size}px`; + + // 더 다양한 색상 + const colors = [ + '#ff4444', + '#ff7700', + '#ffaa00', + '#ff0000', + '#ffff00', + '#ffcc00', + '#ff5500', + '#ff2200', + '#ffddaa', + '#ffffff', + ]; + particle.style.backgroundColor = + colors[Math.floor(Math.random() * colors.length)]; + + // 반짝임 효과 추가 + if (Math.random() > 0.7) { + particle.style.boxShadow = + '0 0 3px 1px ' + particle.style.backgroundColor; + } + + particle.style.left = `${screenX}px`; + particle.style.top = `${screenY}px`; + particle.style.transform = 'translate(-50%, -50%)'; + + // 애니메이션 설정 + const angle = Math.random() * Math.PI * 2; // 랜덤 방향 + const speed = Math.random() * 100 + 50; // 더 빠른 속도 + const duration = Math.random() * 1500 + 800; // 더 긴 지속시간 + + // 회전 추가 + const rotation = Math.random() * 720 - 360; // -360도 ~ 360도 + + // 애니메이션 적용 + particle.animate( + [ + { + opacity: 1, + transform: 'translate(-50%, -50%) scale(1) rotate(0deg)', + }, + { + opacity: 0.8, + transform: `translate(calc(-50% + ${Math.cos(angle) * speed * 0.5}px), calc(-50% + ${Math.sin(angle) * speed * 0.5}px)) scale(1.5) rotate(${rotation * 0.5}deg)`, + offset: 0.4, + }, + { + opacity: 0, + transform: `translate(calc(-50% + ${Math.cos(angle) * speed}px), calc(-50% + ${Math.sin(angle) * speed}px)) scale(0) rotate(${rotation}deg)`, + }, + ], + { duration, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'forwards' } + ); + + // DOM에 추가 + document.body.appendChild(particle); + + // 애니메이션 종료 후 제거 + setTimeout(() => { + if (document.body.contains(particle)) { + document.body.removeChild(particle); + } + }, duration); + } + + // 폭발 원 제거 + setTimeout(() => { + if (document.body.contains(explosion)) { + document.body.removeChild(explosion); + } + }, 600); + }, []); + + // 게임 결과 처리 + const onGameResult = useCallback( + (data: { + results: Array<{ + username: string; + rank: number; + own_count: number; + try_count: number; + dead: boolean; + }>; + }) => { + // 배경음악 중지 + stopGameMusic(); + + // 결과 저장 및 결과 모달 표시 + setGameResults(data.results); + setIsWaitingForResults(false); + setIsGameEnded(true); + }, + [stopGameMusic] + ); + + const { sendGameResult } = useGameSocketIntegration({ + sourceCanvasRef, + draw, + canvas_id, + onDeadPixels, + onDeadNotice, + onGameResult, + onCanvasCloseAlarm: useCallback( + (data: { + canvas_id: number; + title: string; + ended_at: string; + server_time: string; + remain_time: number; + }) => { + setGameTime(data.remain_time); + }, + [] + ), + }); + + const updateOverlay = useCallback( + (screenX: number, screenY: number) => { + const worldX = Math.floor( + (screenX - viewPosRef.current.x) / scaleRef.current + ); + const worldY = Math.floor( + (screenY - viewPosRef.current.y) / scaleRef.current + ); + + const overlayCanvas = interactionCanvasRef.current; + if (!overlayCanvas) return; + const overlayCtx = overlayCanvas.getContext('2d'); + if (!overlayCtx) return; + + overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); + + const isInBounds = + worldX >= 0 && + worldX < canvasSize.width && + worldY >= 0 && + worldY < canvasSize.height; + + if (isInBounds) { + setHoverPos({ x: worldX, y: worldY }); + overlayCtx.save(); + overlayCtx.translate(viewPosRef.current.x, viewPosRef.current.y); + overlayCtx.scale(scaleRef.current, scaleRef.current); + overlayCtx.strokeStyle = 'rgba(0, 255, 0, 0.9)'; + overlayCtx.lineWidth = 2 / scaleRef.current; + overlayCtx.strokeRect(worldX, worldY, 1, 1); + overlayCtx.restore(); + } else { + setHoverPos(null); + } + }, + [canvasSize, setHoverPos] + ); + + const clearOverlay = useCallback(() => { + setHoverPos(null); + const overlayCanvas = interactionCanvasRef.current; + if (!overlayCanvas) return; + const overlayCtx = overlayCanvas.getContext('2d'); + overlayCtx?.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); + }, [setHoverPos]); + + const resetAndCenter = useCallback(() => { + const canvas = renderCanvasRef.current; + if (!canvas || canvas.clientWidth === 0 || canvasSize.width === 0) return; + + const viewportWidth = canvas.clientWidth; + const viewportHeight = canvas.clientHeight; + + // 모바일 (480px 이하)과 데스크탑에 따라 다른 스케일 팩터 적용 + const isMobile = viewportWidth <= 480; + const scaleFactor = isMobile ? 1.0 : 2.0; // 모바일에서는 1.0, 데스크탑에서는 2.0 + const maxScaleLimit = isMobile ? MAX_SCALE : MAX_SCALE * 2; // 모바일에서는 MAX_SCALE, 데스크탑에서는 MAX_SCALE * 2 + + const scaleX = (viewportWidth / canvasSize.width) * scaleFactor; + const scaleY = (viewportHeight / canvasSize.height) * scaleFactor; + scaleRef.current = Math.max(Math.min(scaleX, scaleY), MIN_SCALE); + scaleRef.current = Math.min(scaleRef.current, maxScaleLimit); + + // 캔버스를 화면 중앙에 배치 + viewPosRef.current.x = + (viewportWidth - canvasSize.width * scaleRef.current) / 2; + viewPosRef.current.y = + (viewportHeight - canvasSize.height * scaleRef.current) / 2; + + draw(); + clearOverlay(); + }, [draw, clearOverlay, canvasSize]); + + // 픽셀 확정 처리 + const handleConfirm = useCallback(() => { + const pos = fixedPosRef.current; + if (!pos) return; + + // 현재 픽셀 색상 확인 + const sourceCtx = sourceCanvasRef.current?.getContext('2d'); + if (!sourceCtx) return; + + const pixelData = sourceCtx.getImageData(pos.x, pos.y, 1, 1).data; + const isBlack = + pixelData[0] === 0 && pixelData[1] === 0 && pixelData[2] === 0; + + if (isBlack) { + // 검은색 픽셀이면 바로 그리기 (기존 로직과 동일) + startCooldown(1); // 쿨다운 유지 + + previewPixelRef.current = { x: pos.x, y: pos.y, color: userColor }; + flashingPixelRef.current = { x: pos.x, y: pos.y }; + draw(); + // 소켓으로 전송 + sendGameResult({ x: pos.x, y: pos.y, color: userColor, result: true }); + setTimeout(() => { + previewPixelRef.current = null; + pos.color = 'transparent'; + draw(); + }, 1000); + } else { + // 검은색이 아니면 문제 모달 표시 + setCurrentPixel({ x: pos.x, y: pos.y, color: userColor }); + + // API에서 가져온 문제 사용 + if ( + waitingData && + waitingData.questions && + waitingData.questions.length > 0 + ) { + // 랜덤하게 문제 선택 + const randomIndex = Math.floor( + Math.random() * waitingData.questions.length + ); + const question = waitingData.questions[randomIndex]; + setCurrentQuestion(question); + } else { + // 문제가 없는 경우 기본 문제 사용 + const defaultQuestion = { + id: '1', + question: '기본 문제', + options: ['옵션 1', '옵션 2', '옵션 3', '옵션 4'], + answer: 0, + }; + setCurrentQuestion(defaultQuestion); + } + + setSelectedAnswer(null); + setQuestionTimeLeft(10); // 문제 타이머 10초로 초기화 + setShowQuestionModal(true); + } + }, [ + userColor, + draw, + sendGameResult, + startCooldown, + setQuestionTimeLeft, + waitingData, + ]); + + // 문제 답변 제출 + const submitAnswer = useCallback(() => { + if (!currentQuestion || selectedAnswer === null || !currentPixel) return; + + const answerCorrect = selectedAnswer === currentQuestion.answer; + setIsCorrect(answerCorrect); + setShowResult(true); + + // 1초 후에 결과 화면 닫기 + setTimeout(() => { + setShowQuestionModal(false); + setShowResult(false); + setQuestionTimeLeft(10); // Reset question timer + + if (answerCorrect) { + startCooldown(1); // 쿨다운 유지 + + previewPixelRef.current = { + x: currentPixel.x, + y: currentPixel.y, + color: currentPixel.color, + }; + flashingPixelRef.current = { x: currentPixel.x, y: currentPixel.y }; + draw(); + + // 결과 전송 - 정답일 경우 result: true + sendGameResult({ + x: currentPixel.x, + y: currentPixel.y, + color: currentPixel.color, + result: true, + }); + + setTimeout(() => { + previewPixelRef.current = null; + draw(); + }, 1000); + } else { + // 오답일 경우 생명 감소 + setLives((prev) => Math.max(0, prev - 1)); + startCooldown(1); + + sendGameResult({ + x: currentPixel.x, + y: currentPixel.y, + color: currentPixel.color, + result: false, + }); + } + + setCurrentPixel(null); + }, 1000); + }, [ + currentQuestion, + selectedAnswer, + currentPixel, + draw, + sendGameResult, + startCooldown, + setQuestionTimeLeft, + lives, + setLives, + ]); + + // 문제 타이머 효과 + useEffect(() => { + let timerId: number; + + // Reset timer when modal closes + if (!showQuestionModal) { + setQuestionTimeLeft(10); + } + + if (showQuestionModal && questionTimeLeft > 0) { + timerId = window.setInterval(() => { + setQuestionTimeLeft((prev) => { + console.log('Question timer:', prev - 1); + return prev - 1; + }); + }, 1000); + } else if (questionTimeLeft === 0 && showQuestionModal) { + // 시간 초과 시 자동 제출 + submitAnswer(); + setShowQuestionModal(false); + } + + return () => { + clearInterval(timerId); + }; + }, [showQuestionModal, questionTimeLeft, submitAnswer]); + + // 게임 데이터 및 캔버스 초기화 + const { getSynchronizedServerTime } = useTimeSyncStore(); + + useEffect(() => { + // 게임 대기 모달창이 표시될 때 대기 음악 재생 + playAdventureMusic(); + + // 게임 데이터 가져오기 + const fetchGameData = async () => { + try { + setIsLoading(true); + const gameData = await GameAPI.fetchGameCanvasData(initialCanvasId); + + if (gameData) { + // 게임 데이터 저장 + setWaitingData(gameData); + + // 색상 설정 + setAssignedColor(gameData.color); + setUserColor(gameData.color); + + // 캔버스 크기 설정 + setCanvasSize(gameData.canvasSize); + + // 소스 캔버스 초기화 (모든 픽셀을 검은색으로 설정) + initializeSourceCanvas( + gameData.canvasSize.width, + gameData.canvasSize.height + ); + + // 시작 시간에서 현재 시간을 빼서 대기 시간 계산 (useTimeSyncStore 사용) + const startTime = new Date(gameData.startedAt).getTime(); + const now = getSynchronizedServerTime(); + const timeUntilStart = Math.max( + 0, + Math.floor((startTime - now) / 1000) + ); + setReadyTime(timeUntilStart); + + // 게임 총 시간 계산 및 설정 + const endTime = new Date(gameData.endedAt).getTime(); + const calculatedTotalGameDuration = Math.floor((endTime - startTime) / 1000); + setTotalGameDuration(calculatedTotalGameDuration); + setGameTime(calculatedTotalGameDuration); + + // 캔버스 표시 + setShowCanvas(true); + } + } catch (error) { + console.error('게임 데이터 가져오기 실패:', error); + toast.error('게임 데이터를 불러오는데 실패했습니다.'); + setHasError(true); + } finally { + setIsLoading(false); + } + }; + + fetchGameData(); + + // 컴포넌트 언마운트 시 음악 중지 + return () => { + stopAdventureMusic(); + stopGameMusic(); + }; + }, [ + initialCanvasId, + playAdventureMusic, + stopAdventureMusic, + stopGameMusic, + setIsLoading, + setShowCanvas, + getSynchronizedServerTime, + ]); + + // 소스 캔버스 초기화 함수 (모든 픽셀을 검은색으로 설정) + const initializeSourceCanvas = useCallback( + (width: number, height: number) => { + if (!sourceCanvasRef.current) { + sourceCanvasRef.current = document.createElement('canvas'); + } + + const canvas = sourceCanvasRef.current; + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (ctx) { + // 검은색 배경으로 초기화 + ctx.fillStyle = INITIAL_BACKGROUND_COLOR; + ctx.fillRect(0, 0, width, height); + } + + // 캔버스 크기 설정 후 중앙 정렬 + setTimeout(() => { + resetAndCenter(); + }, 100); + }, + [resetAndCenter] + ); + + // 시작시간 받아오기 여기서 처리 + useEffect(() => { + if (readyTime === undefined) return; + + if (readyTime > 0) { + const timer = setInterval(() => { + setReadyTime((prev) => (prev ? prev - 1 : 0)); + }, 1000); + return () => clearInterval(timer); + } else if (readyTime <= 1) { + // 0이하로 변경하여 어떤 경우라도 게임 종료 처리 + // 게임 시작 시 대기 음악 중지하고 게임 음악 재생 + stopAdventureMusic(); + playGameMusic(); + + setIsGameStarted(true); + setIsReadyModalOpen(false); + } + }, [readyTime]); + + // 게임 타이머 처리 + useEffect(() => { + if (!isGameStarted) return; + + if (gameTime > 0) { + const timer = setInterval(() => { + setGameTime((prev) => (prev ? prev - 1 : 0)); + }, 1000); + return () => clearInterval(timer); + } else if (gameTime <= 1) { + // 게임 시간이 종료되면 결과 화면 표시 + stopGameMusic(); + playAdventureMusic(); + setIsWaitingForResults(true); + + // 사망 모달이 표시되어 있다면 닫기 + setShowDeathModal(false); + } + }, [isGameStarted, gameTime, stopGameMusic, user?.nickname]); + + useEffect(() => { + if (initialCanvasId && initialCanvasId !== canvas_id) { + setCanvasId(initialCanvasId); + } + }, [initialCanvasId, canvas_id, setCanvasId]); + + // 캔버스 크기 조정 + useEffect(() => { + const rootElement = rootRef.current; + if (!rootElement) return; + + const observer = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + if (width === 0 || height === 0) return; + + [ + renderCanvasRef.current, + previewCanvasRef.current, + interactionCanvasRef.current, + ].forEach((canvas) => { + if (canvas) { + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + const ctx = canvas.getContext('2d'); + ctx?.scale(dpr, dpr); + } + }); + + resetAndCenter(); + }); + + observer.observe(rootElement); + return () => observer.disconnect(); + }, [resetAndCenter]); + + // 캔버스 상호작용 훅 + const { + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleMouseLeave, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + } = useCanvasInteraction({ + viewPosRef, + scaleRef, + imageCanvasRef: { current: null }, // 이미지 캔버스 없음 + interactionCanvasRef, + fixedPosRef, + canvasSize, + imageMode: false, + isImageFixed: true, + isDraggingImage: false, + setIsDraggingImage: () => {}, + dragStart: { x: 0, y: 0 }, + setDragStart: () => {}, + isResizing: false, + setIsResizing: () => {}, + resizeHandle: null, + setResizeHandle: () => {}, + resizeStart: { x: 0, y: 0, width: 0, height: 0 }, + setResizeStart: () => {}, + imagePosition: { x: 0, y: 0 }, + setImagePosition: () => {}, + imageSize: { width: 0, height: 0 }, + setImageSize: () => {}, + draw, + updateOverlay, + clearOverlay, + centerOnPixel: () => {}, // 픽셀 중앙 이동 기능 비활성화 + getResizeHandle: () => null, + handleImageScale: () => {}, + setShowPalette: () => {}, + DRAG_THRESHOLD, + handleConfirm, + isGameMode: true, // 게임 모드 활성화 + }); + + if (hasError) { + return ; + } + + // 게임 나가기 핸들러 + const handleExit = useCallback(() => { + setShowExitModal(true); + }, []); + + // 게임 나가기 확인 핸들러 + const confirmExit = useCallback(() => { + // 모든 음악 중지 + stopAdventureMusic(); + stopGameMusic(); + + setShowExitModal(false); + navigate('/canvas/pixels'); // 홈페이지로 이동 + }, [navigate, stopAdventureMusic, stopGameMusic]); + + // 게임 나가기 취소 핸들러 + const cancelExit = useCallback(() => { + setShowExitModal(false); + }, []); + + return ( +
+ setIsReadyModalOpen(false)} + color={assignedColor} + remainingTime={readyTime} + /> + {isGameStarted && ( + <> + {/* 나가기 버튼 및 생명 표시 */} +
+ +
+ {[...Array(2)].map((_, i) => ( +
+ {i < lives ? ( + + + + ) : ( + + + + )} +
+ ))} +
+
+ + + + {cooldown && ( + <> +
+
+
+
+
+ {/* 외부 링 */} +
+ {/* 중간 링 */} +
+ {/* 내부 원 */} +
+ + {timeLeft} + +
+ {/* 글로우 효과 */} +
+
+
+
+ + )} +
+ + + { + playClick(); + handleMouseDown(e); + }} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseLeave={handleMouseLeave} + onContextMenu={(e) => e.preventDefault()} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onTouchCancel={handleTouchEnd} + /> +
+ + {isLoading ? ( + + ) : ( +
+ {/* 확정 버튼 - 항상 표시 */} + +
+ )} + + {/* 문제 모달 */} + + + {/* 나가기 확인 모달 */} + + + {/* 사망 모달 */} + + + {/* 게임 결과 모달 */} + + + )} +
+ ); +} + +export default GameCanvas; diff --git a/src/components/game/GameReadyModal.tsx b/src/components/game/GameReadyModal.tsx new file mode 100644 index 0000000..b4fa9c4 --- /dev/null +++ b/src/components/game/GameReadyModal.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { WaitingRoomData } from '../../api/GameAPI'; +import { useTimeSyncStore } from '../../store/timeSyncStore'; +import { useToastStore } from '../../store/toastStore'; + + +interface GameReadyModalProps { + isOpen: boolean; + onClose: (data?: WaitingRoomData) => void; + canvasId: string; + color?: string; + remainingTime?: number; +} + +const GameReadyModal = ({ isOpen, onClose, canvasId, color, remainingTime }: GameReadyModalProps) => { + const navigate = useNavigate(); + const { showToast } = useToastStore(); + const { getSynchronizedServerTime } = useTimeSyncStore(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [timeUntilStart, setTimeUntilStart] = useState(null); + + useEffect(() => { + if (!isOpen) { + return; + } + + // 부모 컴포넌트에서 전달받은 remainingTime 사용 + if (remainingTime !== undefined) { + setTimeUntilStart(remainingTime); + setLoading(false); + + // 시간이 0이하인 경우 모달 닫기 + if (remainingTime <= 0) { + setTimeout(() => onClose(), 1000); // 1초 후 모달 닫기 + } + } else { + setLoading(false); + } + }, [isOpen, onClose, remainingTime]); + + useEffect(() => { + if (timeUntilStart === null || timeUntilStart <= 0) { + return; + } + + const timer = setInterval(() => { + // useTimeSyncStore를 사용하여 더 정확한 시간 계산 + if (remainingTime === undefined) { + setTimeUntilStart(prev => { + const newValue = prev !== null ? prev - 1 : 0; + if (newValue <= 0) { + clearInterval(timer); + onClose(); + } + return newValue; + }); + } + }, 1000); + + return () => clearInterval(timer); + }, [timeUntilStart, onClose, remainingTime]); + + const handleExit = () => { + onClose(); + navigate('/'); + }; + + if (!isOpen) { + return null; + } + + return ( +
+
+
+

+ 게임 준비 +

+ +
+ +
+ {loading && ( +
+
+
+
+

+ 게임 정보를 불러오는 중... +

+
+
+
+ )} + + {error && ( +
+
+

{error}

+
+
+ )} + + {!loading && !error && ( + <> +
+
+

+ 서버와 동기화 중... 곧 게임이 시작됩니다. +

+
+
+ +
+
+

+ 할당된 색상: {color || '로딩 중...'} +

+
+
+ +
+
+

색상 할당 완료!

+
+
+
+
+
+ + {remainingTime !== undefined && remainingTime > 0 ? `${remainingTime}` : + timeUntilStart !== null && timeUntilStart > 0 ? `${timeUntilStart}` : + '시작!'} + +
+
+
+
+
+
+ + )} +
+ +
+
+ ); +}; + +export default GameReadyModal; diff --git a/src/components/game/GameStarfieldCanvas.tsx b/src/components/game/GameStarfieldCanvas.tsx new file mode 100644 index 0000000..a8ca52a --- /dev/null +++ b/src/components/game/GameStarfieldCanvas.tsx @@ -0,0 +1,161 @@ +import React, { useRef, useEffect } from 'react'; +import '../canvas/StarfieldCanvas.css'; + +type StarfieldCanvasProps = { + viewPosRef: React.RefObject<{ x: number; y: number }>; +}; + +const StarfieldCanvas = ({ viewPosRef }: StarfieldCanvasProps) => { + const canvasRef = useRef(null); + const animationFrameIdRef = useRef(0); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let w = (canvas.width = window.innerWidth); + let h = (canvas.height = window.innerHeight); + + const hue = 217; + const stars: any[] = []; + let count = 0; + const maxStars = 400; // Reduced for a sparser effect + + const canvas2 = document.createElement('canvas'); + const ctx2 = canvas2.getContext('2d'); + canvas2.width = 100; + canvas2.height = 100; + const half = canvas2.width / 2; + const gradient2 = ctx2!.createRadialGradient( + half, + half, + 0, + half, + half, + half + ); + gradient2.addColorStop(0.025, '#fff'); + gradient2.addColorStop(0.1, `hsl(${hue}, 61%, 33%)`); + gradient2.addColorStop(0.25, `hsl(${hue}, 64%, 6%)`); + gradient2.addColorStop(1, 'transparent'); + + ctx2!.fillStyle = gradient2; + ctx2!.beginPath(); + ctx2!.arc(half, half, half, 0, Math.PI * 2); + ctx2!.fill(); + + function random(min: number, max?: number) { + if (max === undefined) { + max = min; + min = 0; + } + if (min > max) { + [min, max] = [max, min]; + } + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + function maxOrbit(x: number, y: number) { + const max = Math.max(x, y); + const diameter = Math.round(Math.sqrt(max * max + max * max)); + return diameter / 2; + } + + class Star { + orbitRadius: number; + radius: number; + orbitX: number; + orbitY: number; + timePassed: number; + speed: number; + alpha: number; + parallaxFactor: number; // New: for parallax effect + + constructor() { + this.orbitRadius = random(maxOrbit(w, h)); + this.radius = random(60, this.orbitRadius) / 12; + this.orbitX = w / 2; + this.orbitY = h / 2; + this.timePassed = random(0, maxStars); + this.speed = random(this.orbitRadius) / 50000; + this.alpha = random(2, 10) / 10; + this.parallaxFactor = random(2, 10) / 10; // Assign a random parallax factor + count++; + stars[count] = this; + } + + draw() { + const canvasX = + Math.sin(this.timePassed) * this.orbitRadius + this.orbitX; + const canvasY = + Math.cos(this.timePassed) * this.orbitRadius + this.orbitY; + const twinkle = random(10); + + if (twinkle === 1 && this.alpha > 0) { + this.alpha -= 0.05; + } else if (twinkle === 2 && this.alpha < 1) { + this.alpha += 0.05; + } + + // Calculate parallax offset + const parallaxX = viewPosRef.current + ? viewPosRef.current.x * this.parallaxFactor * 0.1 // Adjust multiplier for desired effect + : 0; + const parallaxY = viewPosRef.current + ? viewPosRef.current.y * this.parallaxFactor * 0.1 // Adjust multiplier for desired effect + : 0; + + ctx!.globalAlpha = this.alpha; + ctx!.drawImage( + canvas2, + canvasX - this.radius / 2 + parallaxX, + canvasY - this.radius / 2 + parallaxY, + this.radius, + this.radius + ); + this.timePassed += this.speed; + } + } + + for (let i = 0; i < maxStars; i++) { + new Star(); + } + + const animation = () => { + ctx!.globalCompositeOperation = 'source-over'; + ctx!.globalAlpha = 0.8; + ctx!.fillStyle = 'black'; // Solid black background + ctx!.fillRect(0, 0, w, h); + + ctx!.globalCompositeOperation = 'lighter'; + for (let i = 1, l = stars.length; i < l; i++) { + stars[i].draw(); + } + + animationFrameIdRef.current = window.requestAnimationFrame(animation); + }; + + animation(); + + const handleResize = () => { + w = canvas.width = window.innerWidth; + h = canvas.height = window.innerHeight; + }; + + window.addEventListener('resize', handleResize); + + return () => { + if (animationFrameIdRef.current) { + window.cancelAnimationFrame(animationFrameIdRef.current); + } + window.removeEventListener('resize', handleResize); + }; + }, [viewPosRef]); // Add viewPosRef to dependency array + + return ; +}; + +export default StarfieldCanvas; diff --git a/src/components/game/GameTimer.tsx b/src/components/game/GameTimer.tsx new file mode 100644 index 0000000..a5be824 --- /dev/null +++ b/src/components/game/GameTimer.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +interface GameTimerProps { + currentTime: number; + totalTime: number; +} + +const GameTimer: React.FC = ({ currentTime, totalTime }) => { + const progress = totalTime > 0 ? (currentTime / totalTime) * 100 : 0; + + return ( +
+
+
+
+
+ {currentTime} +
+
+
+
+ ); +}; + +export default GameTimer; diff --git a/src/components/gameSocketIntegration.tsx b/src/components/gameSocketIntegration.tsx new file mode 100644 index 0000000..539a732 --- /dev/null +++ b/src/components/gameSocketIntegration.tsx @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useGameSocket } from '../hooks/useGameSocket'; + +interface GameSocketProps { + sourceCanvasRef: React.RefObject; + draw: () => void; + canvas_id: string; + onDeadPixels?: (data: { + pixels: Array<{ x: number; y: number; color: string }>; + username: string; + }) => void; + onDeadNotice?: (data: { message: string }) => void; + onGameResult?: (data: { + results: Array<{ + username: string; + rank: number; + own_count: number; + try_count: number; + dead: boolean; + }>; + }) => void; + onCanvasCloseAlarm: (data: { + canvas_id: number; + title: string; + ended_at: string; + server_time: string; + remain_time: number; + }) => void; +} + +export const useGameSocketIntegration = ({ + sourceCanvasRef, + draw, + canvas_id, + onDeadPixels, + onDeadNotice, + onGameResult, + onCanvasCloseAlarm, +}: GameSocketProps) => { + const handlePixelReceived = useCallback( + (pixel: { x: number; y: number; color: string }) => { + const sourceCtx = sourceCanvasRef.current?.getContext('2d'); + if (sourceCtx) { + sourceCtx.fillStyle = pixel.color; + sourceCtx.fillRect(pixel.x, pixel.y, 1, 1); + draw(); + } + }, + [sourceCanvasRef, draw] + ); + + const { sendGameResult } = useGameSocket( + handlePixelReceived, + canvas_id, + onDeadPixels, + onDeadNotice, + onGameResult, + onCanvasCloseAlarm + ); + + return { sendGameResult }; +}; diff --git a/src/components/group/GroupCreateTab.tsx b/src/components/group/GroupCreateTab.tsx index 710cde0..a05b057 100644 --- a/src/components/group/GroupCreateTab.tsx +++ b/src/components/group/GroupCreateTab.tsx @@ -18,23 +18,24 @@ export default function GroupCreateTab({ return (
-
-