From 14f4e1a756fabe85319243ae00e60aa7332fb943 Mon Sep 17 00:00:00 2001 From: Zuhanit Date: Sun, 29 Mar 2026 17:37:19 +0900 Subject: [PATCH 1/5] feat: add pixi.js libraries --- frontend/package.json | 4 + frontend/pnpm-lock.yaml | 200 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/frontend/package.json b/frontend/package.json index 6a3926f..b8a7a4c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,8 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", + "@pixi/react": "^8.0.5", + "@pixi/tilemap": "^5.0.2", "@tanstack/react-query": "^5.70.0", "axios": "^1.8.4", "class-variance-authority": "^0.7.0", @@ -27,6 +29,8 @@ "immer": "^10.1.1", "lucide-react": "^0.456.0", "next": "15.5.12", + "pixi-viewport": "^6.0.3", + "pixi.js": "^8.17.1", "re-resizable": "^6.10.1", "react": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f31b780..42e65ab 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.0.0-rc-66855b96-20241106(react@19.0.0-rc-66855b96-20241106))(react@19.0.0-rc-66855b96-20241106) + '@pixi/react': + specifier: ^8.0.5 + version: 8.0.5(@types/react@18.3.12)(pixi.js@8.17.1)(react@19.0.0-rc-66855b96-20241106) + '@pixi/tilemap': + specifier: ^5.0.2 + version: 5.0.2(pixi.js@8.17.1) '@tanstack/react-query': specifier: ^5.70.0 version: 5.70.0(react@19.0.0-rc-66855b96-20241106) @@ -53,6 +59,12 @@ importers: next: specifier: 15.5.12 version: 15.5.12(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-66855b96-20241106(react@19.0.0-rc-66855b96-20241106))(react@19.0.0-rc-66855b96-20241106) + pixi-viewport: + specifier: ^6.0.3 + version: 6.0.3(pixi.js@8.17.1) + pixi.js: + specifier: ^8.17.1 + version: 8.17.1 re-resizable: specifier: ^6.10.1 version: 6.10.1(react-dom@19.0.0-rc-66855b96-20241106(react@19.0.0-rc-66855b96-20241106))(react@19.0.0-rc-66855b96-20241106) @@ -739,155 +751,183 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -1007,24 +1047,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.5.12': resolution: {integrity: sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.5.12': resolution: {integrity: sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.5.12': resolution: {integrity: sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.5.12': resolution: {integrity: sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==} @@ -1063,6 +1107,20 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@pixi/colord@2.9.6': + resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + + '@pixi/react@8.0.5': + resolution: {integrity: sha512-Z1VRdnv9Gh+lTLzNKjpS7GaTNDjUxRVJNtIdtK2i0sCpigvjx5mvJ72EPLhBFgepB6j3ZCkL0YBb6BqgTGbhGA==} + peerDependencies: + pixi.js: ^8.2.6 + react: '>=19.0.0' + + '@pixi/tilemap@5.0.2': + resolution: {integrity: sha512-J+K1eU7uB58WPVD33d7usXrhKA03IGq3IfPvXBFr1027Bb9ScxrGhH0NomR9VpVVUPiHkK7FcTGuOtJMVGAv6A==} + peerDependencies: + pixi.js: '>=8.5.0' + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1143,56 +1201,67 @@ packages: resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.39.0': resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.39.0': resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.39.0': resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.39.0': resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.39.0': resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.39.0': resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.39.0': resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.39.0': resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.39.0': resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.39.0': resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.39.0': resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==} @@ -1471,6 +1540,9 @@ packages: '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} @@ -1528,6 +1600,11 @@ packages: '@types/react-dom@18.3.1': resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==} + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + '@types/react@18.3.12': resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} @@ -1629,10 +1706,17 @@ packages: '@vitest/utils@3.1.1': resolution: {integrity: sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==} + '@webgpu/types@0.1.69': + resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + '@xmldom/xmldom@0.8.10': resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} engines: {node: '>=10.0.0'} + '@xmldom/xmldom@0.8.11': + resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + engines: {node: '>=10.0.0'} + abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -2166,6 +2250,9 @@ packages: duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -2278,6 +2365,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.2.1: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} @@ -2457,6 +2547,9 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + gifuct-js@2.1.2: + resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2726,6 +2819,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + ismobilejs@1.1.1: + resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2742,6 +2838,11 @@ packages: resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} engines: {node: '>=8'} + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -2757,6 +2858,9 @@ packages: jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + js-binary-schema-parser@2.0.3: + resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3209,6 +3313,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-svg-path@0.1.2: + resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3261,6 +3368,14 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} + pixi-viewport@6.0.3: + resolution: {integrity: sha512-2+qPJ0/n+8hQYhWvY+795+x9y3MiUrCOWacK0DY53whowWaGdx9iDocy7z1pBwjkZhC52YvrJQuZKK0sdVLtBw==} + peerDependencies: + pixi.js: '>=8' + + pixi.js@8.17.1: + resolution: {integrity: sha512-OB4TpZHrP5RYy+7FqmFrAc0IHRhfOoNIfF4sVeinvK3aG1r2pYrSMneJAKi9+WvGKC70Dj7GEpZ2OZGB6o/xdg==} + playwright-core@1.51.1: resolution: {integrity: sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==} engines: {node: '>=18'} @@ -3482,6 +3597,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-reconciler@0.31.0: + resolution: {integrity: sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.0.0 + react@19.0.0-rc-66855b96-20241106: resolution: {integrity: sha512-klH7xkT71SxRCx4hb1hly5FJB21Hz0ACyxbXYAECEqssUjtJeFUAaI2U1DgJAzkGEnvEm3DkxuBchMC/9K4ipg==} engines: {node: '>=0.10.0'} @@ -3599,6 +3720,9 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + scheduler@0.25.0: + resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} + scheduler@0.25.0-rc-66855b96-20241106: resolution: {integrity: sha512-HQXp/Mnp/MMRSXMQF7urNFla+gmtXW/Gr1KliuR0iboTit4KvZRY8KYaq5ccCTAOJiUqQh2rE2F3wgUekmgdlA==} @@ -3851,6 +3975,10 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-lru@11.4.7: + resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} + engines: {node: '>=12'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -5190,6 +5318,21 @@ snapshots: '@opentelemetry/api@1.9.0': optional: true + '@pixi/colord@2.9.6': {} + + '@pixi/react@8.0.5(@types/react@18.3.12)(pixi.js@8.17.1)(react@19.0.0-rc-66855b96-20241106)': + dependencies: + its-fine: 2.0.0(@types/react@18.3.12)(react@19.0.0-rc-66855b96-20241106) + pixi.js: 8.17.1 + react: 19.0.0-rc-66855b96-20241106 + react-reconciler: 0.31.0(react@19.0.0-rc-66855b96-20241106) + transitivePeerDependencies: + - '@types/react' + + '@pixi/tilemap@5.0.2(pixi.js@8.17.1)': + dependencies: + pixi.js: 8.17.1 + '@pkgjs/parseargs@0.11.0': optional: true @@ -5646,6 +5789,8 @@ snapshots: '@types/doctrine@0.0.9': {} + '@types/earcut@3.0.0': {} + '@types/estree@1.0.7': {} '@types/express-serve-static-core@4.19.6': @@ -5712,6 +5857,10 @@ snapshots: dependencies: '@types/react': 18.3.12 + '@types/react-reconciler@0.28.9(@types/react@18.3.12)': + dependencies: + '@types/react': 18.3.12 + '@types/react@18.3.12': dependencies: '@types/prop-types': 15.7.13 @@ -5866,8 +6015,12 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 + '@webgpu/types@0.1.69': {} + '@xmldom/xmldom@0.8.10': {} + '@xmldom/xmldom@0.8.11': {} + abbrev@1.1.1: {} abort-controller@3.0.0: @@ -6486,6 +6639,8 @@ snapshots: stream-shift: 1.0.3 optional: true + earcut@3.0.2: {} + eastasianwidth@0.2.0: {} ecdsa-sig-formatter@1.0.11: @@ -6641,6 +6796,8 @@ snapshots: event-target-shim@5.0.1: optional: true + eventemitter3@5.0.4: {} + expect-type@1.2.1: {} exponential-backoff@3.1.1: {} @@ -6897,6 +7054,10 @@ snapshots: dependencies: pump: 3.0.2 + gifuct-js@2.1.2: + dependencies: + js-binary-schema-parser: 2.0.3 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -7195,6 +7356,8 @@ snapshots: isexe@2.0.0: {} + ismobilejs@1.1.1: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -7216,6 +7379,13 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + its-fine@2.0.0(@types/react@18.3.12)(react@19.0.0-rc-66855b96-20241106): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@18.3.12) + react: 19.0.0-rc-66855b96-20241106 + transitivePeerDependencies: + - '@types/react' + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -7233,6 +7403,8 @@ snapshots: jose@4.15.9: {} + js-binary-schema-parser@2.0.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -7680,6 +7852,8 @@ snapshots: package-json-from-dist@1.0.1: {} + parse-svg-path@0.1.2: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -7711,6 +7885,23 @@ snapshots: pirates@4.0.6: {} + pixi-viewport@6.0.3(pixi.js@8.17.1): + dependencies: + pixi.js: 8.17.1 + + pixi.js@8.17.1: + dependencies: + '@pixi/colord': 2.9.6 + '@types/earcut': 3.0.0 + '@webgpu/types': 0.1.69 + '@xmldom/xmldom': 0.8.11 + earcut: 3.0.2 + eventemitter3: 5.0.4 + gifuct-js: 2.1.2 + ismobilejs: 1.1.1 + parse-svg-path: 0.1.2 + tiny-lru: 11.4.7 + playwright-core@1.51.1: {} playwright@1.51.1: @@ -7884,6 +8075,11 @@ snapshots: react-is@17.0.2: {} + react-reconciler@0.31.0(react@19.0.0-rc-66855b96-20241106): + dependencies: + react: 19.0.0-rc-66855b96-20241106 + scheduler: 0.25.0 + react@19.0.0-rc-66855b96-20241106: {} react@19.1.0: {} @@ -8043,6 +8239,8 @@ snapshots: sax@1.4.1: {} + scheduler@0.25.0: {} + scheduler@0.25.0-rc-66855b96-20241106: {} scheduler@0.26.0: {} @@ -8388,6 +8586,8 @@ snapshots: tiny-invariant@1.3.3: {} + tiny-lru@11.4.7: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} From 735cda05de6cb77d7985b0be38d4d52a91a7c5c9 Mon Sep 17 00:00:00 2001 From: Zuhanit Date: Sun, 29 Mar 2026 17:38:00 +0900 Subject: [PATCH 2/5] feat: add pixi.js styled viewport containers --- frontend/lib/pixi/layers/location.ts | 150 ++++++++++++++++++ frontend/lib/pixi/layers/sprite.ts | 159 +++++++++++++++++++ frontend/lib/pixi/layers/terrain.ts | 105 +++++++++++++ frontend/lib/pixi/layers/unit.ts | 222 +++++++++++++++++++++++++++ frontend/lib/pixi/setup.ts | 19 +++ frontend/lib/pixi/types.ts | 82 ++++++++++ 6 files changed, 737 insertions(+) create mode 100644 frontend/lib/pixi/layers/location.ts create mode 100644 frontend/lib/pixi/layers/sprite.ts create mode 100644 frontend/lib/pixi/layers/terrain.ts create mode 100644 frontend/lib/pixi/layers/unit.ts create mode 100644 frontend/lib/pixi/setup.ts create mode 100644 frontend/lib/pixi/types.ts diff --git a/frontend/lib/pixi/layers/location.ts b/frontend/lib/pixi/layers/location.ts new file mode 100644 index 0000000..b574650 --- /dev/null +++ b/frontend/lib/pixi/layers/location.ts @@ -0,0 +1,150 @@ +import { Container, Graphics, Text, TextStyle } from "pixi.js"; +import { Location } from "@/types/schemas/entities/Location"; +import { Asset } from "@/types/asset"; +import { Entity } from "@/types/schemas/entities/Entity"; + +class LocationRect extends Container { + readonly data!: Asset; + private bg!: Graphics; + private border!: Graphics; + private nameLabel!: Text; + private selectionBorder!: Graphics; + private _selected = false; + + constructor(data: Asset) { + super(); + this.data = data; + + const loc = data.data!; + const { left, top, right, bottom } = loc.transform.size; + const width = left + right; + const height = top + bottom; + + this.position.set(loc.transform.position.x, loc.transform.position.y); + + // Semi-transparent blue fill + this.bg = new Graphics(); + this.bg.rect(0, 0, width, height); + this.bg.fill({ color: 0x0000ff, alpha: 0.15 }); + this.addChild(this.bg); + + // Border + this.border = new Graphics(); + this.border.setStrokeStyle({ width: 3, color: 0x000000, alpha: 0.15 }); + this.border.rect(0, 0, width + 3, height + 3); + this.border.stroke(); + this.addChild(this.border); + + // Label + this.nameLabel = new Text({ + text: loc.name, + style: new TextStyle({ + fontSize: 40, + fontFamily: "serif", + fill: 0xffffff, + }), + }); + this.addChild(this.nameLabel); + + // Selection highlight + this.selectionBorder = new Graphics(); + this.selectionBorder.visible = false; + this.addChild(this.selectionBorder); + + this.eventMode = "static"; + this.cursor = "pointer"; + } + + set selected(value: boolean) { + if (this._selected === value) return; + this._selected = value; + this.selectionBorder.visible = value; + + if (value) { + const loc = this.data.data!; + const { left, top, right, bottom } = loc.transform.size; + const width = left + right; + const height = top + bottom; + + this.selectionBorder.clear(); + this.selectionBorder.setStrokeStyle({ width: 2, color: 0x00ff00 }); + this.selectionBorder.rect(0, 0, width, height); + this.selectionBorder.stroke(); + } + } + + get selected() { + return this._selected; + } +} + +export class LocationLayer extends Container { + private locationMap = new Map(); + private _locations: Asset[] = []; + private _selectedId: number | null = null; + private _onSelect: ((entity: Asset) => void) | null = null; + + set locations(value: Asset[]) { + this._locations = value; + if (this.locationMap) this.syncLocations(); + } + + set selectedId(value: number | null) { + this._selectedId = value; + this.locationMap?.forEach((rect, id) => { + rect.selected = id === value; + }); + } + + set onSelect(value: ((entity: Asset) => void) | null) { + this._onSelect = value; + } + + private syncLocations() { + const currentIds = new Set(); + + for (const asset of this._locations) { + const loc = asset.data!; + // Skip special location 63 + if (loc.id === 63) continue; + currentIds.add(loc.id); + } + + // Remove locations that no longer exist + for (const [id, rect] of this.locationMap) { + if (!currentIds.has(id)) { + this.removeChild(rect); + rect.destroy({ children: true }); + this.locationMap.delete(id); + } + } + + // Add or update locations + for (const asset of this._locations) { + const loc = asset.data!; + if (loc.id === 63) continue; + + const existing = this.locationMap.get(loc.id); + if (existing) { + existing.position.set( + loc.transform.position.x, + loc.transform.position.y, + ); + existing.selected = loc.id === this._selectedId; + continue; + } + + const rect = new LocationRect(asset); + + rect.on("pointertap", () => { + if (this._onSelect) { + this._onSelect(asset); + } + }); + + rect.selected = loc.id === this._selectedId; + this.locationMap.set(loc.id, rect); + this.addChild(rect); + } + } +} diff --git a/frontend/lib/pixi/layers/sprite.ts b/frontend/lib/pixi/layers/sprite.ts new file mode 100644 index 0000000..9202ed3 --- /dev/null +++ b/frontend/lib/pixi/layers/sprite.ts @@ -0,0 +1,159 @@ +import { + Container, + Texture, + Sprite as PixiSprite, + Graphics, +} from "pixi.js"; +import { Sprite } from "@/types/schemas/entities/Sprite"; +import { SCImageBundle } from "@/types/SCImage"; +import { Asset } from "@/types/asset"; +import { Entity } from "@/types/schemas/entities/Entity"; + +class SCSpriteDisplay extends Container { + readonly data: Asset; + private sprite: PixiSprite; + private selectionBorder: Graphics; + private _selected = false; + + constructor(texture: Texture, data: Asset) { + super(); + this.data = data; + + this.sprite = new PixiSprite(texture); + this.sprite.anchor.set(0.5); + this.addChild(this.sprite); + + this.selectionBorder = new Graphics(); + this.selectionBorder.visible = false; + this.addChild(this.selectionBorder); + + const spriteData = data.data!; + this.position.set( + spriteData.transform.position.x, + spriteData.transform.position.y, + ); + + this.eventMode = "static"; + this.cursor = "pointer"; + } + + set selected(value: boolean) { + if (this._selected === value) return; + this._selected = value; + this.selectionBorder.visible = value; + + if (value) { + const s = this.data.data!; + const { left, top, right, bottom } = s.transform.size; + + this.selectionBorder.clear(); + this.selectionBorder.setStrokeStyle({ width: 2, color: 0x00ff00 }); + this.selectionBorder.rect(-left, -top, left + right, top + bottom); + this.selectionBorder.stroke(); + } + } + + get selected() { + return this._selected; + } +} + +async function createTextureFromBundle( + bundle: SCImageBundle, +): Promise { + const frame0 = bundle.meta[0]; + if (!frame0) throw new Error("Frame 0 not found in bundle meta"); + + const bitmap = await createImageBitmap(bundle.diffuse); + const frameBitmap = await createImageBitmap( + bitmap, + frame0.x, + frame0.y, + frame0.width, + frame0.height, + ); + + return Texture.from({ resource: frameBitmap, alphaMode: "premultiply-alpha-on-upload" }); +} + +export class SpriteLayer extends Container { + private spriteMap = new Map(); + private _sprites: Asset[] = []; + private _images: Map = new Map(); + private _selectedId: number | null = null; + private _onSelect: ((entity: Asset) => void) | null = null; + + set sprites(value: Asset[]) { + this._sprites = value; + if (this.spriteMap) this.syncSprites(); + } + + set images(value: Map) { + this._images = value; + if (this.spriteMap) this.syncSprites(); + } + + set selectedId(value: number | null) { + this._selectedId = value; + this.spriteMap?.forEach((display, id) => { + display.selected = id === value; + }); + } + + set onSelect(value: ((entity: Asset) => void) | null) { + this._onSelect = value; + } + + private async syncSprites() { + if (!this._sprites.length && this.spriteMap.size === 0) return; + + const currentIds = new Set(this._sprites.map((s) => s.data!.id)); + + // Remove sprites that no longer exist + for (const [id, display] of this.spriteMap) { + if (!currentIds.has(id)) { + this.removeChild(display); + display.destroy({ children: true }); + this.spriteMap.delete(id); + } + } + + // Add or update sprites + for (const asset of this._sprites) { + const spriteData = asset.data!; + const imageID = spriteData.definition.image.id; + const bundle = this._images.get(imageID); + if (!bundle) continue; + + const existing = this.spriteMap.get(spriteData.id); + if (existing) { + existing.position.set( + spriteData.transform.position.x, + spriteData.transform.position.y, + ); + existing.selected = spriteData.id === this._selectedId; + continue; + } + + try { + const texture = await createTextureFromBundle(bundle); + const display = new SCSpriteDisplay(texture, asset); + + display.on("pointertap", () => { + if (this._onSelect) { + this._onSelect(asset); + } + }); + + display.selected = spriteData.id === this._selectedId; + this.spriteMap.set(spriteData.id, display); + this.addChild(display); + } catch (e) { + console.warn( + `Failed to create sprite display for ${spriteData.name}:`, + e, + ); + } + } + } +} diff --git a/frontend/lib/pixi/layers/terrain.ts b/frontend/lib/pixi/layers/terrain.ts new file mode 100644 index 0000000..8107427 --- /dev/null +++ b/frontend/lib/pixi/layers/terrain.ts @@ -0,0 +1,105 @@ +import { Container, Texture } from "pixi.js"; +import { CompositeTilemap } from "@pixi/tilemap"; +import { Tile } from "@/types/schemas/entities/Tile"; + +const TILE_SIZE = 32; +const MEGATILE_BYTES = 3072; // 32 * 32 * 3 (RGB) + +export class TerrainLayer extends Container { + private tilemap = new CompositeTilemap(); + private _tiles: Tile[] = []; + private _tileGroup: number[][] = []; + private _tilesetData: Uint8Array | null = null; + private textureCache = new Map(); + + constructor() { + super(); + this.addChild(this.tilemap); + } + + set tiles(value: Tile[]) { + this._tiles = value; + if (this.tilemap) this.rebuild(); + } + + set tileGroup(value: number[][]) { + this._tileGroup = value; + if (this.tilemap) this.rebuild(); + } + + set tilesetData(value: Uint8Array | null) { + this._tilesetData = value; + this.textureCache?.clear(); + if (this.tilemap) this.rebuild(); + } + + private getMegatileTexture(megatileID: number): Texture | null { + if (!this._tilesetData) return null; + + const cached = this.textureCache.get(megatileID); + if (cached) return cached; + + const offset = megatileID * MEGATILE_BYTES; + const rgbData = this._tilesetData.slice(offset, offset + MEGATILE_BYTES); + + // Convert RGB to RGBA + const rgba = new Uint8Array(TILE_SIZE * TILE_SIZE * 4); + for (let i = 0; i < TILE_SIZE * TILE_SIZE; i++) { + rgba[i * 4] = rgbData[i * 3]; + rgba[i * 4 + 1] = rgbData[i * 3 + 1]; + rgba[i * 4 + 2] = rgbData[i * 3 + 2]; + rgba[i * 4 + 3] = 255; + } + + const texture = Texture.from({ + resource: rgba, + width: TILE_SIZE, + height: TILE_SIZE, + }); + + this.textureCache.set(megatileID, texture); + return texture; + } + + private rebuild() { + if (!this._tiles.length || !this._tileGroup.length || !this._tilesetData) { + return; + } + + this.tilemap.clear(); + + for (const tile of this._tiles) { + const group = this._tileGroup[tile.group]; + if (!group) continue; + + const megatileID = group[tile.tile_id]; + if (megatileID === undefined) continue; + + const texture = this.getMegatileTexture(megatileID); + if (!texture) continue; + + this.tilemap.tile( + texture, + tile.transform.position.x * TILE_SIZE, + tile.transform.position.y * TILE_SIZE, + ); + } + } + + changeTile(x: number, y: number, tile: Tile) { + const idx = this._tiles.findIndex( + (t) => + t.transform.position.x === x && t.transform.position.y === y, + ); + if (idx !== -1) { + this._tiles[idx] = tile; + } + this.rebuild(); + } + + destroy() { + this.textureCache.forEach((tex) => tex.destroy(true)); + this.textureCache.clear(); + super.destroy({ children: true }); + } +} diff --git a/frontend/lib/pixi/layers/unit.ts b/frontend/lib/pixi/layers/unit.ts new file mode 100644 index 0000000..517c213 --- /dev/null +++ b/frontend/lib/pixi/layers/unit.ts @@ -0,0 +1,222 @@ +import { Container, Texture, Sprite as PixiSprite, Graphics } from "pixi.js"; +import { Unit } from "@/types/schemas/entities/Unit"; +import { SCImageBundle } from "@/types/SCImage"; +import { Asset } from "@/types/asset"; +import { Entity } from "@/types/schemas/entities/Entity"; + +class UnitSprite extends Container { + readonly data: Asset; + private sprite: PixiSprite; + private selectionBorder: Graphics; + private _selected = false; + + constructor(texture: Texture, data: Asset) { + super(); + this.data = data; + + this.sprite = new PixiSprite(texture); + this.sprite.anchor.set(0.5); + this.addChild(this.sprite); + + this.selectionBorder = new Graphics(); + this.selectionBorder.visible = false; + this.addChild(this.selectionBorder); + + const unit = data.data!; + this.position.set(unit.transform.position.x, unit.transform.position.y); + + this.eventMode = "static"; + this.cursor = "pointer"; + } + + set selected(value: boolean) { + if (this._selected === value) return; + this._selected = value; + this.selectionBorder.visible = value; + + if (value) { + const unit = this.data.data!; + const { left, top, right, bottom } = unit.transform.size; + const [radiusX, radiusY] = [ + unit.unit_definition.size.placement_box_size.width, + unit.unit_definition.size.placement_box_size.height, + ]; + + console.log( + unit.transform.position.x, + unit.transform.position.y, + radiusX, + radiusY, + ); + this.selectionBorder + .clear() + .setStrokeStyle({ width: 2, color: 0x00ff00 }) + .ellipse(0, 0, radiusX, radiusY) + .stroke(); + // this.selectionBorder.rect(-left, -top, left + right, top + bottom); + } + } + + get selected() { + return this._selected; + } + + updateTexture(texture: Texture) { + this.sprite.texture = texture; + } +} + +async function createTextureFromBundle( + bundle: SCImageBundle, + teamColor?: [number, number, number], +): Promise { + const frame0 = bundle.meta[0]; + if (!frame0) throw new Error("Frame 0 not found in bundle meta"); + + const diffuseBitmap = await createImageBitmap(bundle.diffuse); + const frameBitmap = await createImageBitmap( + diffuseBitmap, + frame0.x, + frame0.y, + frame0.width, + frame0.height, + ); + + if (bundle.teamColor && teamColor) { + const tcBitmap = await createImageBitmap(bundle.teamColor); + const tcFrameBitmap = await createImageBitmap( + tcBitmap, + frame0.x, + frame0.y, + frame0.width, + frame0.height, + ); + + const canvas = new OffscreenCanvas(frame0.width, frame0.height); + const ctx = canvas.getContext("2d")!; + ctx.drawImage(frameBitmap, 0, 0); + + const tcCanvas = new OffscreenCanvas(frame0.width, frame0.height); + const tcCtx = tcCanvas.getContext("2d")!; + tcCtx.drawImage(tcFrameBitmap, 0, 0); + + const imageData = ctx.getImageData(0, 0, frame0.width, frame0.height); + const tcData = tcCtx.getImageData(0, 0, frame0.width, frame0.height); + const pixels = imageData.data; + const tcPixels = tcData.data; + + for (let i = 0; i < pixels.length; i += 4) { + const tcR = tcPixels[i]; + const tcG = tcPixels[i + 1]; + const tcB = tcPixels[i + 2]; + const tcA = tcPixels[i + 3]; + + if (tcR > 127 && tcG > 127 && tcB > 127 && tcA > 0) { + const wR = pixels[i] / 255; + const wG = pixels[i + 1] / 255; + const wB = pixels[i + 2] / 255; + + pixels[i] = Math.round(teamColor[0] * wR); + pixels[i + 1] = Math.round(teamColor[1] * wG); + pixels[i + 2] = Math.round(teamColor[2] * wB); + } + } + + ctx.putImageData(imageData, 0, 0); + const result = canvas.transferToImageBitmap(); + return Texture.from({ + resource: result, + alphaMode: "premultiply-alpha-on-upload", + }); + } + + return Texture.from({ + resource: frameBitmap, + alphaMode: "premultiply-alpha-on-upload", + }); +} + +export class UnitLayer extends Container { + private unitMap = new Map(); + private _units: Asset[] = []; + private _images: Map = new Map(); + private _selectedId: number | null = null; + private _onSelect: ((entity: Asset) => void) | null = null; + + set units(value: Asset[]) { + this._units = value; + if (this.unitMap) this.syncUnits(); + } + + set images(value: Map) { + this._images = value; + if (this.unitMap) this.syncUnits(); + } + + set selectedId(value: number | null) { + this._selectedId = value; + this.unitMap?.forEach((sprite, id) => { + sprite.selected = id === value; + }); + } + + set onSelect(value: ((entity: Asset) => void) | null) { + this._onSelect = value; + } + + private async syncUnits() { + if (!this._units.length && this.unitMap.size === 0) return; + + const currentIds = new Set(this._units.map((u) => u.data!.id)); + + // Remove units that no longer exist + for (const [id, sprite] of this.unitMap) { + if (!currentIds.has(id)) { + this.removeChild(sprite); + sprite.destroy({ children: true }); + this.unitMap.delete(id); + } + } + + // Add or update units + for (const asset of this._units) { + const unit = asset.data!; + const imageID = + unit.unit_definition.specification.graphics.sprite.image.id; + const bundle = this._images.get(imageID); + if (!bundle) continue; + + const existing = this.unitMap.get(unit.id); + if (existing) { + // Update position + existing.position.set( + unit.transform.position.x, + unit.transform.position.y, + ); + existing.selected = unit.id === this._selectedId; + continue; + } + + // Create new unit sprite + try { + const color = unit.owner?.rgb_color as + | [number, number, number] + | undefined; + const texture = await createTextureFromBundle(bundle, color); + const sprite = new UnitSprite(texture, asset); + + sprite.on("pointertap", () => { + if (this._onSelect) { + this._onSelect(asset); + } + }); + + sprite.selected = unit.id === this._selectedId; + this.unitMap.set(unit.id, sprite); + this.addChild(sprite); + } catch (e) { + console.warn(`Failed to create unit sprite for ${unit.name}:`, e); + } + } + } +} diff --git a/frontend/lib/pixi/setup.ts b/frontend/lib/pixi/setup.ts new file mode 100644 index 0000000..fc0c537 --- /dev/null +++ b/frontend/lib/pixi/setup.ts @@ -0,0 +1,19 @@ +import { extend } from "@pixi/react"; +import { Container, Graphics, Sprite, AnimatedSprite } from "pixi.js"; +import { Viewport } from "pixi-viewport"; +import { TerrainLayer } from "./layers/terrain"; +import { UnitLayer } from "./layers/unit"; +import { SpriteLayer } from "./layers/sprite"; +import { LocationLayer } from "./layers/location"; + +extend({ + Container, + Graphics, + Sprite, + AnimatedSprite, + Viewport, + TerrainLayer, + UnitLayer, + SpriteLayer, + LocationLayer, +}); diff --git a/frontend/lib/pixi/types.ts b/frontend/lib/pixi/types.ts new file mode 100644 index 0000000..0b80262 --- /dev/null +++ b/frontend/lib/pixi/types.ts @@ -0,0 +1,82 @@ +import { type Key, type Ref } from "react"; +import { Viewport } from "pixi-viewport"; +import { Tile } from "@/types/schemas/entities/Tile"; +import { Unit } from "@/types/schemas/entities/Unit"; +import { Sprite } from "@/types/schemas/entities/Sprite"; +import { Location as SCLocation } from "@/types/schemas/entities/Location"; +import { SCImageBundle } from "@/types/SCImage"; +import { Asset } from "@/types/asset"; +import { Entity } from "@/types/schemas/entities/Entity"; +import { TerrainLayer } from "./layers/terrain"; +import { UnitLayer } from "./layers/unit"; +import { SpriteLayer } from "./layers/sprite"; +import { LocationLayer } from "./layers/location"; + +type ViewportProps = { + key?: Key; + ref?: Ref; + children?: React.ReactNode; + events: any; + screenWidth: number; + screenHeight: number; + worldWidth: number; + worldHeight: number; +}; + +type TerrainLayerProps = { + key?: Key; + ref?: Ref; + tiles: Tile[]; + tileGroup: number[][]; + tilesetData: Uint8Array | null; +}; + +type UnitLayerProps = { + key?: Key; + ref?: Ref; + units: Asset[]; + images: Map; + selectedId: number | null; + onSelect: (entity: Asset) => void; +}; + +type SpriteLayerProps = { + key?: Key; + ref?: Ref; + sprites: Asset[]; + images: Map; + selectedId: number | null; + onSelect: (entity: Asset) => void; +}; + +type LocationLayerProps = { + key?: Key; + ref?: Ref; + locations: Asset[]; + selectedId: number | null; + onSelect: (entity: Asset) => void; +}; + +declare module "react" { + namespace JSX { + interface IntrinsicElements { + pixiViewport: ViewportProps; + terrainLayer: TerrainLayerProps; + unitLayer: UnitLayerProps; + spriteLayer: SpriteLayerProps; + locationLayer: LocationLayerProps; + } + } +} + +declare module "react/jsx-runtime" { + namespace JSX { + interface IntrinsicElements { + pixiViewport: ViewportProps; + terrainLayer: TerrainLayerProps; + unitLayer: UnitLayerProps; + spriteLayer: SpriteLayerProps; + locationLayer: LocationLayerProps; + } + } +} From 684e1b5d577846a71525d5f3a8f3428aaf33d8f5 Mon Sep 17 00:00:00 2001 From: Zuhanit Date: Sun, 29 Mar 2026 17:40:00 +0900 Subject: [PATCH 3/5] refactor: use pixi.js ref viewport --- frontend/components/core/drag-handler.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/components/core/drag-handler.tsx b/frontend/components/core/drag-handler.tsx index bd729dd..68cbb09 100644 --- a/frontend/components/core/drag-handler.tsx +++ b/frontend/components/core/drag-handler.tsx @@ -5,8 +5,8 @@ import { DragOverlay, UniqueIdentifier, useDndMonitor } from "@dnd-kit/core"; import { ReactElement, useEffect, useState } from "react"; import { DroppableContextKind } from "@/types/dnd"; import { useUsemapStore } from "@/components/pages/editor-page"; -import { Viewport } from "@/types/viewport"; -import { TILE_SIZE } from "@/lib/scterrain"; +import type { Viewport as PixiViewport } from "pixi-viewport"; +import type React from "react"; import { Unit } from "@/types/schemas/entities/Unit"; import { Entity, EntitySchema } from "@/types/schemas/entities/Entity"; import { SCImageRenderer } from "./renderer"; @@ -105,18 +105,22 @@ export function DragHandler() { const parsed = EntitySchema.safeParse(draggingAsset.data); if (!parsed.success) return; + const viewportRef = event.over.data.current?.viewportInstance as + | React.MutableRefObject + | undefined; + const viewport = viewportRef?.current; + if (!viewport) return; + const localX = event.active.rect.current.translated!.left - event.over.rect.left; const localY = event.active.rect.current.translated!.top - event.over.rect.top; - const viewport = event.over.data.current as Viewport; - const placementX = Math.floor(localX + viewport.startX * TILE_SIZE); - const placementY = Math.floor(localY + viewport.startY * TILE_SIZE); + const worldPos = viewport.toWorld(localX, localY); placeEntity(draggingAsset.data, { - x: placementX, - y: placementY, + x: Math.floor(worldPos.x), + y: Math.floor(worldPos.y), }); } } From c49d19956f74bd6c1821492c5826f03ea1944d79 Mon Sep 17 00:00:00 2001 From: Zuhanit Date: Sun, 29 Mar 2026 17:41:14 +0900 Subject: [PATCH 4/5] refactor: remove manual handling viewport, using pixi-viewport --- frontend/components/layout/viewport.tsx | 271 ++++++++++++------------ frontend/hooks/useDragViewport.ts | 66 ------ frontend/hooks/useElementResize.ts | 25 --- frontend/lib/entityUtils.ts | 21 -- 4 files changed, 140 insertions(+), 243 deletions(-) delete mode 100644 frontend/hooks/useDragViewport.ts delete mode 100644 frontend/hooks/useElementResize.ts delete mode 100644 frontend/lib/entityUtils.ts diff --git a/frontend/components/layout/viewport.tsx b/frontend/components/layout/viewport.tsx index dc938da..1d26fdd 100644 --- a/frontend/components/layout/viewport.tsx +++ b/frontend/components/layout/viewport.tsx @@ -1,159 +1,168 @@ "use client"; -import React, { useCallback, useRef } from "react"; -import { useEntireCanvas } from "@/hooks/useImage"; +import React, { useCallback, useMemo, useRef } from "react"; +import { Application, useApplication } from "@pixi/react"; +import { Viewport as PixiViewport } from "pixi-viewport"; import { TILE_SIZE } from "@/lib/scterrain"; -import { Viewport } from "@/types/viewport"; -import { useDragViewport } from "@/hooks/useDragViewport"; -import { useElementResize } from "@/hooks/useElementResize"; import { useDroppableContext } from "@/hooks/useDraggableAsset"; -import { findEntityAtPosition } from "@/lib/entityUtils"; import { useEntityStore } from "@/store/entityStore"; import { useUsemapStore } from "../pages/editor-page"; - -export const MapImage = ({ className }: { className?: string }) => { - const viewportCanvasRef = useRef(null); - const { image } = useEntireCanvas(); - - /** Controller for dragging viewport */ - const viewportRef = useRef({ - startX: 0, - startY: 0, - tileWidth: 40, - tileHeight: 75, - }); - - /** - * Viewport painting callback. - * when user dragging, or touch-moved viewport, viewport will be changed and - * entire viewport image need to repainted. - * */ - const paint = useCallback(() => { - const viewCanvas = viewportCanvasRef.current; - if (!viewCanvas || !image) return; - - const viewCtx = viewCanvas.getContext("2d")!; - const v = viewportRef.current; - - // 캔버스 크기 제한 (브라우저 한계: 보통 32767px) - const maxCanvasSize = 16000; - const canvasWidth = Math.min(v.tileWidth * TILE_SIZE, maxCanvasSize); - const canvasHeight = Math.min(v.tileHeight * TILE_SIZE, maxCanvasSize); - - viewCanvas.width = canvasWidth; - viewCanvas.height = canvasHeight; - - // 캔버스 완전히 지우기 - viewCtx.clearRect(0, 0, canvasWidth, canvasHeight); - - viewCtx.drawImage( - image, - v.startX * TILE_SIZE, - v.startY * TILE_SIZE, - canvasWidth, - canvasHeight, - 0, - 0, - canvasWidth, - canvasHeight, - ); - }, [image]); - +import { useImages } from "@/hooks/useImage"; +import useTileGroup from "@/hooks/useTileGroup"; +import useTilesetData from "@/hooks/useTilesetData"; +import { Unit } from "@/types/schemas/entities/Unit"; +import { Sprite } from "@/types/schemas/entities/Sprite"; +import { Tile } from "@/types/schemas/entities/Tile"; +import { Location } from "@/types/schemas/entities/Location"; +import { Asset } from "@/types/asset"; +import { Entity } from "@/types/schemas/entities/Entity"; + +import "@/lib/pixi/setup"; +import "@/lib/pixi/types"; + +function MapContent({ + viewportInstanceRef, +}: { + viewportInstanceRef: React.MutableRefObject; +}) { + const { app } = useApplication(); const usemap = useUsemapStore((state) => state.usemap); const setEntity = useEntityStore((state) => state.setEntity); const selectedEntity = useEntityStore((state) => state.entity); - const deleteEntity = useUsemapStore((state) => state.deleteEntity); - - const handleCanvasClick = useCallback( - (event: React.MouseEvent) => { - if (!usemap) return; - - // Canvas 요소의 bounding rect 가져오기 - const canvasRect = viewportCanvasRef.current!.getBoundingClientRect(); - // Canvas 내에서의 상대 좌표 - const relativeX = event.clientX - canvasRect.left; - const relativeY = event.clientY - canvasRect.top; - - // Canvas 스케일 팩터 계산 (실제 크기 vs CSS 크기) - const scaleX = - viewportCanvasRef.current!.width / - viewportCanvasRef.current!.clientWidth; - const scaleY = - viewportCanvasRef.current!.height / - viewportCanvasRef.current!.clientHeight; + const tileGroup = useTileGroup(); + const tilesetData = useTilesetData(); + + const tiles = useMemo(() => { + if (!usemap) return []; + return usemap.entities + .filter((e) => e.data?.kind === "Tile") + .map((e) => e.data) as Tile[]; + }, [usemap?.entities]); + + const unitAssets = useMemo(() => { + if (!usemap) return []; + return usemap.entities.filter( + (e) => e.data?.kind === "Unit", + ) as Asset[]; + }, [usemap?.entities]); + + const spriteAssets = useMemo(() => { + if (!usemap) return []; + return usemap.entities.filter( + (e) => e.data?.kind === "Sprite", + ) as Asset[]; + }, [usemap?.entities]); + + const locationAssets = useMemo(() => { + if (!usemap) return []; + return usemap.entities.filter( + (e) => e.data?.kind === "Location", + ) as Asset[]; + }, [usemap?.entities]); + + const requiredImageIDs = useMemo(() => { + const ids = new Set(); + for (const asset of unitAssets) { + ids.add( + asset.data!.unit_definition.specification.graphics.sprite.image.id, + ); + } + for (const asset of spriteAssets) { + ids.add(asset.data!.definition.image.id); + } + return ids; + }, [unitAssets, spriteAssets]); - // 스케일 팩터를 고려한 실제 캔버스 좌표 - const scaledX = relativeX * scaleX; - const scaledY = relativeY * scaleY; + const { data: imagesData } = useImages(requiredImageIDs, "sd"); - // Viewport offset을 고려한 실제 맵 좌표 - const mapX = scaledX + viewportRef.current.startX * TILE_SIZE; - const mapY = scaledY + viewportRef.current.startY * TILE_SIZE; + const handleSelect = useCallback( + (entity: Asset) => { + setEntity(entity); + }, + [setEntity], + ); - const units = usemap.entities.filter((e) => e.data?.kind === "Unit"); + const mapWidth = usemap?.terrain.size.width ?? 0; + const mapHeight = usemap?.terrain.size.height ?? 0; - const clickedEntity = findEntityAtPosition(mapX, mapY, units); + if (!usemap || !app) return null; - if (clickedEntity) { - setEntity(clickedEntity); - } - }, - [usemap], + return ( + { + viewportInstanceRef.current = ref; + if (ref && !ref.plugins.get("drag")) { + ref.drag().pinch().wheel().decelerate(); + ref.clamp({ + left: 0, + top: 0, + right: mapWidth * TILE_SIZE, + bottom: mapHeight * TILE_SIZE, + }); + } + }} + events={app.renderer.events} + screenWidth={app.renderer.width} + screenHeight={app.renderer.height} + worldWidth={mapWidth * TILE_SIZE} + worldHeight={mapHeight * TILE_SIZE} + > + + + + + ); +} - const handleDelete = () => { - console.log("yay"); - if (selectedEntity) { - deleteEntity(selectedEntity); - } - }; - const handleKeydown = (e: React.KeyboardEvent) => { - switch (e.key) { - case "Delete": { - console.log("ya"); - handleDelete(); - } - } - }; - - /** - * Viewport dragging handling hook. - */ - const { onMouseMove, onMouseUp, onMousedown, isDragging } = useDragViewport( - viewportRef, - paint, - handleCanvasClick, // 클릭 핸들러 전달 - ); +export const MapImage = ({ className }: { className?: string }) => { + const containerRef = useRef(null); + const viewportInstanceRef = useRef(null); const { setNodeRef } = useDroppableContext({ id: "viewport", kind: "viewport", - data: viewportRef.current, + data: { viewportInstance: viewportInstanceRef }, }); - useElementResize(viewportCanvasRef, (entry) => { - const { width, height } = entry.contentRect; - viewportRef.current.tileWidth = Math.floor(width / TILE_SIZE); - viewportRef.current.tileHeight = Math.floor(height / TILE_SIZE); - paint(); - }); + const combinedRef = useCallback( + (node: HTMLDivElement | null) => { + (containerRef as React.MutableRefObject).current = + node; + setNodeRef(node); + }, + [setNodeRef], + ); return ( -
- +
+ }> + +
); }; diff --git a/frontend/hooks/useDragViewport.ts b/frontend/hooks/useDragViewport.ts deleted file mode 100644 index ff0301f..0000000 --- a/frontend/hooks/useDragViewport.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { TILE_SIZE } from "@/lib/scterrain"; -import { useRef } from "react"; -import { Viewport } from "@/types/viewport"; - -/** - * Hook for handle dragging viewport. - * @param vpRef Viewport Ref - * @param onViewportChange Event handler for viewport changed. - * @param onCanvasClick Click handler for canvas clicks (when not dragging) - * @returns - */ -export function useDragViewport( - vpRef: React.MutableRefObject, - onViewportChange: () => void, - onCanvasClick?: (e: React.MouseEvent) => void, -) { - const isDragging = useRef(false); - const dragStart = useRef<{ x: number; y: number } | null>(); - const hasDragged = useRef(false); - - const onMousedown = (e: React.MouseEvent) => { - isDragging.current = true; - hasDragged.current = false; - dragStart.current = { x: e.clientX, y: e.clientY }; - }; - - const raf = useRef(0); - const onMouseMove = (e: React.MouseEvent) => { - if (!isDragging.current || !dragStart.current) return; - - const dx = e.clientX - dragStart.current.x; - const dy = e.clientY - dragStart.current.y; - - const deltaX = Math.round(dx / TILE_SIZE); - const deltaY = Math.round(dy / TILE_SIZE); - if (deltaX === 0 && deltaY === 0) return; - - hasDragged.current = true; // 드래그 했음을 기록 - vpRef.current.startX = Math.max(0, vpRef.current.startX - deltaX); - vpRef.current.startY = Math.max(0, vpRef.current.startY - deltaY); - dragStart.current = { x: e.clientX, y: e.clientY }; - - if (!raf.current) { - raf.current = requestAnimationFrame(() => { - onViewportChange(); - raf.current = 0; - }); - } - }; - - function onMouseUp(e: React.MouseEvent) { - const wasDragging = isDragging.current; - const didDrag = hasDragged.current; - - isDragging.current = false; - dragStart.current = null; - hasDragged.current = false; - - // 드래그하지 않고 단순 클릭한 경우 - if (wasDragging && !didDrag && onCanvasClick) { - onCanvasClick(e); - } - } - - return { onMousedown, onMouseMove, onMouseUp, isDragging }; -} diff --git a/frontend/hooks/useElementResize.ts b/frontend/hooks/useElementResize.ts deleted file mode 100644 index 48328d6..0000000 --- a/frontend/hooks/useElementResize.ts +++ /dev/null @@ -1,25 +0,0 @@ -// frontend/hooks/useElementResize.ts -import { useEffect } from "react"; - -type ResizeHandler = (entry: ResizeObserverEntry) => void; - -/** - * 특정 엘리먼트의 크기를 감시하고 크기가 변할 때마다 handler 를 호출한다. - * - * @param ref 크기를 감시할 DOM ref - * @param onResize (optional) ResizeObserverEntry 를 인자로 받는 콜백 - */ -export function useElementResize( - ref: React.RefObject, - onResize: ResizeHandler, -) { - useEffect(() => { - const target = ref.current; - if (!target) return; - - const observer = new ResizeObserver((entries) => onResize(entries[0])); - observer.observe(target); - - return () => observer.disconnect(); - }, [ref, onResize]); -} diff --git a/frontend/lib/entityUtils.ts b/frontend/lib/entityUtils.ts deleted file mode 100644 index 01a4cf1..0000000 --- a/frontend/lib/entityUtils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Asset } from "@/types/asset"; -import { Entity } from "@/types/schemas/entities/Entity"; - -export function findEntityAtPosition( - x: number, - y: number, - entities: Asset[], -): Asset | null { - const entitiesAtPosition = entities.filter((entity) => { - if (!entity.data) return; - const transform = entity.data!.transform; - return ( - x >= transform.position.x - transform.size.left && - x < transform.position.x + transform.size.right && - y >= transform.position.y - transform.size.top && - y < transform.position.y + transform.size.bottom - ); - }); - - return entitiesAtPosition[entitiesAtPosition.length - 1] || null; -} From de09098feb705c225e5f14f20dc5afeb3c8c83c7 Mon Sep 17 00:00:00 2001 From: Zuhanit Date: Mon, 30 Mar 2026 00:50:02 +0900 Subject: [PATCH 5/5] feat: add team color shader --- frontend/lib/pixi/layers/unit.ts | 257 +++++++++++++++++++++---------- 1 file changed, 172 insertions(+), 85 deletions(-) diff --git a/frontend/lib/pixi/layers/unit.ts b/frontend/lib/pixi/layers/unit.ts index 517c213..8d42a9c 100644 --- a/frontend/lib/pixi/layers/unit.ts +++ b/frontend/lib/pixi/layers/unit.ts @@ -1,21 +1,160 @@ -import { Container, Texture, Sprite as PixiSprite, Graphics } from "pixi.js"; +import { + Container, + Texture, + Graphics, + AnimatedSprite, + Rectangle, + TextureSource, + Filter, + GlProgram, + UniformGroup, +} from "pixi.js"; import { Unit } from "@/types/schemas/entities/Unit"; -import { SCImageBundle } from "@/types/SCImage"; +import { SCImageBundle, FrameRect } from "@/types/SCImage"; import { Asset } from "@/types/asset"; import { Entity } from "@/types/schemas/entities/Entity"; +// Those shader code are generate by AI. But I think it can be simplified. +// See https://github.com/saintofidiocy/SCR-Graphics. It will be helpful. +// Also we need to deal with another states(like Cloack, Hallucinated, etc). + +const TEAM_COLOR_VERT = ` +in vec2 aPosition; +out vec2 vTextureCoord; +out vec2 vFilterCoord; + +uniform vec4 uInputSize; +uniform vec4 uOutputFrame; +uniform vec4 uOutputTexture; + +vec4 filterVertexPosition(void) { + vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy; + position.x = position.x * (2.0 / uOutputTexture.x) - 1.0; + position.y = position.y * (2.0 * uOutputTexture.z / uOutputTexture.y) - uOutputTexture.z; + return vec4(position, 0.0, 1.0); +} + +vec2 filterTextureCoord(void) { + return aPosition * (uOutputFrame.zw * uInputSize.zw); +} + +void main(void) { + gl_Position = filterVertexPosition(); + vTextureCoord = filterTextureCoord(); + vFilterCoord = aPosition; +} +`; + +const TEAM_COLOR_FRAG = ` +in vec2 vTextureCoord; +in vec2 vFilterCoord; +out vec4 finalColor; + +uniform sampler2D uTexture; +uniform sampler2D uMaskTexture; +uniform vec3 uTeamColor; +uniform vec4 uMaskFrame; + +void main() { + vec4 diffuse = texture(uTexture, vTextureCoord); + vec2 maskUV = uMaskFrame.xy + vFilterCoord * uMaskFrame.zw; + vec4 mask = texture(uMaskTexture, maskUV); + + bool applyTeamColor = mask.r > 0.5; + + if (applyTeamColor) { + finalColor = vec4(uTeamColor * diffuse.rgb, diffuse.a); + } else { + finalColor = diffuse; + } +} +`; + +class TeamColorFilter extends Filter { + private maskFrameRects: FrameRect[]; + private maskWidth: number; + private maskHeight: number; + + constructor( + teamColor: [number, number, number], + maskSource: TextureSource, + maskFrameRects: FrameRect[], + ) { + const glProgram = GlProgram.from({ + vertex: TEAM_COLOR_VERT, + fragment: TEAM_COLOR_FRAG, + name: "team-color-filter", + }); + + const teamUniforms = new UniformGroup({ + uTeamColor: { + value: new Float32Array([ + teamColor[0] / 255, + teamColor[1] / 255, + teamColor[2] / 255, + ]), + type: "vec3", + }, + uMaskFrame: { + value: new Float32Array([0, 0, 1, 1]), + type: "vec4", + }, + }); + + super({ + glProgram, + resources: { + teamUniforms, + uMaskTexture: maskSource, + uMaskSampler: maskSource.style, + }, + padding: 0, + }); + + this.maskFrameRects = maskFrameRects; + this.maskWidth = maskSource.width; + this.maskHeight = maskSource.height; + this.updateFrame(0); + } + + updateFrame(index: number) { + const rect = this.maskFrameRects[index]; + if (!rect) return; + const maskFrame = this.resources.teamUniforms.uniforms + .uMaskFrame as Float32Array; + maskFrame[0] = rect.x / this.maskWidth; + maskFrame[1] = rect.y / this.maskHeight; + maskFrame[2] = rect.width / this.maskWidth; + maskFrame[3] = rect.height / this.maskHeight; + } +} + class UnitSprite extends Container { readonly data: Asset; - private sprite: PixiSprite; + private sprite: AnimatedSprite; private selectionBorder: Graphics; private _selected = false; - constructor(texture: Texture, data: Asset) { + constructor( + textures: Texture[], + data: Asset, + teamColorFilter?: TeamColorFilter, + ) { super(); this.data = data; - this.sprite = new PixiSprite(texture); + this.sprite = new AnimatedSprite(textures, true); + this.sprite.animationSpeed = 0.4; // 24 / 60 + this.sprite.play(); this.sprite.anchor.set(0.5); + + if (teamColorFilter) { + this.sprite.filters = [teamColorFilter]; + this.sprite.onFrameChange = (frame: number) => { + teamColorFilter.updateFrame(frame); + }; + } + this.addChild(this.sprite); this.selectionBorder = new Graphics(); @@ -37,23 +176,14 @@ class UnitSprite extends Container { if (value) { const unit = this.data.data!; const { left, top, right, bottom } = unit.transform.size; - const [radiusX, radiusY] = [ - unit.unit_definition.size.placement_box_size.width, - unit.unit_definition.size.placement_box_size.height, - ]; - - console.log( - unit.transform.position.x, - unit.transform.position.y, - radiusX, - radiusY, - ); + + // FIXME: Use selection circle image instead of drawing rect. This can referenced by: + // unit.unit_definition.specification.graphics.sprite.selection_circle_image_id this.selectionBorder .clear() .setStrokeStyle({ width: 2, color: 0x00ff00 }) - .ellipse(0, 0, radiusX, radiusY) + .rect(-left, -top, left + right, top + bottom) .stroke(); - // this.selectionBorder.rect(-left, -top, left + right, top + bottom); } } @@ -68,72 +198,20 @@ class UnitSprite extends Container { async function createTextureFromBundle( bundle: SCImageBundle, - teamColor?: [number, number, number], -): Promise { - const frame0 = bundle.meta[0]; - if (!frame0) throw new Error("Frame 0 not found in bundle meta"); - +): Promise { const diffuseBitmap = await createImageBitmap(bundle.diffuse); - const frameBitmap = await createImageBitmap( - diffuseBitmap, - frame0.x, - frame0.y, - frame0.width, - frame0.height, - ); - - if (bundle.teamColor && teamColor) { - const tcBitmap = await createImageBitmap(bundle.teamColor); - const tcFrameBitmap = await createImageBitmap( - tcBitmap, - frame0.x, - frame0.y, - frame0.width, - frame0.height, - ); - - const canvas = new OffscreenCanvas(frame0.width, frame0.height); - const ctx = canvas.getContext("2d")!; - ctx.drawImage(frameBitmap, 0, 0); - - const tcCanvas = new OffscreenCanvas(frame0.width, frame0.height); - const tcCtx = tcCanvas.getContext("2d")!; - tcCtx.drawImage(tcFrameBitmap, 0, 0); - - const imageData = ctx.getImageData(0, 0, frame0.width, frame0.height); - const tcData = tcCtx.getImageData(0, 0, frame0.width, frame0.height); - const pixels = imageData.data; - const tcPixels = tcData.data; - - for (let i = 0; i < pixels.length; i += 4) { - const tcR = tcPixels[i]; - const tcG = tcPixels[i + 1]; - const tcB = tcPixels[i + 2]; - const tcA = tcPixels[i + 3]; - - if (tcR > 127 && tcG > 127 && tcB > 127 && tcA > 0) { - const wR = pixels[i] / 255; - const wG = pixels[i + 1] / 255; - const wB = pixels[i + 2] / 255; - - pixels[i] = Math.round(teamColor[0] * wR); - pixels[i + 1] = Math.round(teamColor[1] * wG); - pixels[i + 2] = Math.round(teamColor[2] * wB); - } - } + const diffuseSource = TextureSource.from(diffuseBitmap); - ctx.putImageData(imageData, 0, 0); - const result = canvas.transferToImageBitmap(); - return Texture.from({ - resource: result, - alphaMode: "premultiply-alpha-on-upload", - }); + const frameTextures: Texture[] = []; + for (const rect of Object.values(bundle.meta)) { + const frame = new Rectangle(rect.x, rect.y, rect.width, rect.height); + frameTextures.push(new Texture({ source: diffuseSource, frame })); } - return Texture.from({ - resource: frameBitmap, - alphaMode: "premultiply-alpha-on-upload", - }); + if (frameTextures.length === 0) + throw new Error("Cannot find frame image in bundle."); + + return frameTextures; } export class UnitLayer extends Container { @@ -199,11 +277,20 @@ export class UnitLayer extends Container { // Create new unit sprite try { - const color = unit.owner?.rgb_color as - | [number, number, number] - | undefined; - const texture = await createTextureFromBundle(bundle, color); - const sprite = new UnitSprite(texture, asset); + const textures = await createTextureFromBundle(bundle); + + let filter: TeamColorFilter | undefined; + if (bundle.teamColor) { + const color = (unit.owner?.rgb_color as + | [number, number, number] + | undefined) ?? [255, 255, 255]; + const teamColorBitmap = await createImageBitmap(bundle.teamColor); + const teamColorSource = TextureSource.from(teamColorBitmap); + const maskFrameRects = Object.values(bundle.meta); + filter = new TeamColorFilter(color, teamColorSource, maskFrameRects); + } + + const sprite = new UnitSprite(textures, asset, filter); sprite.on("pointertap", () => { if (this._onSelect) {