diff --git a/package-lock.json b/package-lock.json index 45a22cb..7369c26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,22 +42,26 @@ "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "three": "^0.183.2", "ws": "^8.19.0", "zod": "^3.24.2", "zustand": "^5.0.3" }, "devDependencies": { + "@codevibesmatter/kata": "^0.3.0", "@tauri-apps/cli": "^2.2.0", "@testing-library/jest-dom": "^6.7.0", "@testing-library/react": "^16.3.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@types/react-syntax-highlighter": "^15.5.13", + "@types/three": "^0.183.1", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.4", "archiver": "^7.0.1", "autoprefixer": "^10.4.20", "concurrently": "^9.1.2", + "esbuild": "^0.25.12", "jsdom": "^26.1.0", "license-checker-rseidelsohn": "^4.4.2", "postcss": "^8.5.1", @@ -402,6 +406,23 @@ "node": ">=6.9.0" } }, + "node_modules/@codevibesmatter/kata": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@codevibesmatter/kata/-/kata-0.3.0.tgz", + "integrity": "sha512-6w+zDRY5pPH94URl98qTqKUsOKQaanxaTyb2nsvgCvEmSnB1tR1QpDH3poP7wZkk+Dc2SQDFv6e6Qlz74N1Esg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "zod": "^3.25.76" + }, + "bin": { + "kata": "kata" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -517,6 +538,13 @@ "node": ">=18" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2831,6 +2859,13 @@ } } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2996,12 +3031,42 @@ "@types/react": "*" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3164,6 +3229,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-2.0.0.tgz", @@ -3286,6 +3358,13 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -4388,6 +4467,13 @@ "pend": "~1.2.0" } }, + "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/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5180,6 +5266,19 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "26.1.0", "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz", @@ -5745,6 +5844,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "dev": true, + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -8153,6 +8259,12 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", @@ -8594,18 +8706,15 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", + "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "esbuild": "^0.24.2", + "postcss": "^8.5.1", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" @@ -8691,35 +8800,470 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": ">=18" } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" } }, "node_modules/vitest": { diff --git a/package.json b/package.json index ae94eda..607b2e9 100644 --- a/package.json +++ b/package.json @@ -88,22 +88,26 @@ "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "three": "^0.183.2", "ws": "^8.19.0", "zod": "^3.24.2", "zustand": "^5.0.3" }, "devDependencies": { + "@codevibesmatter/kata": "^0.3.0", + "@tauri-apps/cli": "^2.2.0", "@testing-library/jest-dom": "^6.7.0", "@testing-library/react": "^16.3.0", - "@tauri-apps/cli": "^2.2.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@types/react-syntax-highlighter": "^15.5.13", + "@types/three": "^0.183.1", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.4", "archiver": "^7.0.1", "autoprefixer": "^10.4.20", "concurrently": "^9.1.2", + "esbuild": "^0.25.12", "jsdom": "^26.1.0", "license-checker-rseidelsohn": "^4.4.2", "postcss": "^8.5.1", diff --git a/src/App.tsx b/src/App.tsx index f66f095..66d2afe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,9 +17,12 @@ import { useAppInit } from '@/hooks/useAppInit' import { useChatStore } from '@/store/chat' import { useTitleStore } from '@/store/titles' import { useAgentStore } from '@/store/agents' -import { MessageCircle, Settings, Plug, Clock, Sparkles, Loader2, Wrench } from 'lucide-react' +import { MessageCircle, Settings, Plug, Clock, Sparkles, Loader2, Wrench, Hammer } from 'lucide-react' +import { lazy, Suspense } from 'react' -export type View = 'chat' | 'soul' | 'cron' | 'settings' | 'plugins' | 'skills' +const LazyWorkshopView = lazy(() => import('@/workshop/components/WorkshopView').then(m => ({ default: m.WorkshopView }))) + +export type View = 'chat' | 'soul' | 'cron' | 'settings' | 'plugins' | 'skills' | 'workshop' type OnboardTransitionStage = 'idle' | 'closing' | 'opening' const RECONFIGURE_SWITCH_DELAY_MS = 180 @@ -47,6 +50,7 @@ function App() { { id: 'cron' as View, icon: Clock, label: t('nav.cron') }, { id: 'plugins' as View, icon: Plug, label: t('nav.plugins') }, { id: 'skills' as View, icon: Wrench, label: t('nav.skills') }, + { id: 'workshop' as View, icon: Hammer, label: t('nav.workshop') }, { id: 'settings' as View, icon: Settings, label: t('nav.settings') }, ] @@ -107,6 +111,11 @@ function App() { case 'cron': return case 'plugins': return case 'skills': return + case 'workshop': return ( + }> + + + ) case 'settings': return default: return } @@ -122,16 +131,18 @@ function App() { >
-
-

- {currentView === 'chat' && conversation ? headerTitle : ''} -

-
- - + {currentView !== 'workshop' && ( +
+

+ {currentView === 'chat' && conversation ? headerTitle : ''} +

+
+ + +
-
- + )} + {currentView !== 'workshop' && }
{renderView()}
diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts index 2289943..d4a8f69 100644 --- a/src/hooks/useChat.ts +++ b/src/hooks/useChat.ts @@ -7,6 +7,7 @@ import { useTitleStore } from '@/store/titles' import { streamChat, abortChat } from '@/services/ai' import { toolsApi } from '@/services/api' import type { Message } from '@/types' +import { workshopCallbacks } from '@/workshop/bridge/ClawBoxEventTap' export function useChat() { const chatStore = useChatStore() @@ -63,9 +64,16 @@ export function useChat() { await streamChat( { sessionKey: convId, prompt, thinking: thinking || 'off' }, { - onText: (content) => chatStore.appendTextBlock(finalConvId, assistantId, content), - onReasoning: (content) => chatStore.appendReasoningBlock(finalConvId, assistantId, content), + onText: (content) => { + chatStore.appendTextBlock(finalConvId, assistantId, content) + workshopCallbacks.onText(content) + }, + onReasoning: (content) => { + chatStore.appendReasoningBlock(finalConvId, assistantId, content) + workshopCallbacks.onReasoning(content) + }, onToolStart: (data) => { + workshopCallbacks.onToolStart(data) chatStore.addToolCallBlock(finalConvId, assistantId, { toolName: data.name, toolCallId: data.toolCallId, @@ -97,16 +105,21 @@ export function useChat() { }) }, onToolUpdate: (data) => chatStore.updateToolCallBlock(finalConvId, assistantId, data.toolCallId, { + // Note: tool_update events don't map to a distinct workshop event toolName: data.name || 'tool', result: data.result, status: 'running', }), - onToolEnd: (data) => chatStore.updateToolCallBlock(finalConvId, assistantId, data.toolCallId, { - toolName: data.name || 'tool', - result: data.result, - status: data.error ? 'error' : 'completed', - }), + onToolEnd: (data) => { + workshopCallbacks.onToolEnd(data) + chatStore.updateToolCallBlock(finalConvId, assistantId, data.toolCallId, { + toolName: data.name || 'tool', + result: data.result, + status: data.error ? 'error' : 'completed', + }) + }, onDone: () => { + workshopCallbacks.onDone() chatStore.updateMessage(finalConvId, assistantId, { isLoading: false }) chatStore.setConversationStreaming(finalConvId, false) if (chatStore.currentConversationId !== finalConvId) { @@ -114,6 +127,7 @@ export function useChat() { } }, onError: (error) => { + workshopCallbacks.onError(error) chatStore.updateMessage(finalConvId, assistantId, { isLoading: false, error: true, blocks: [{ type: 'text', content: `Error: ${error}` }], diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index ea49ca0..0693642 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -5,8 +5,13 @@ "cron": "Cron", "plugins": "Plugins", "skills": "Skills", + "workshop": "Workshop", "settings": "Settings" }, + "workshop": { + "title": "Workshop", + "placeholder": "3D visualization of agent activity. Start a chat to see the workshop come alive." + }, "chat": { "welcomeMessage": "How can I help you?", "welcomeMessages": [ diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 23daca8..4d06af1 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -5,8 +5,13 @@ "cron": "任务", "plugins": "插件", "skills": "技能", + "workshop": "工作坊", "settings": "设置" }, + "workshop": { + "title": "工作坊", + "placeholder": "代理活动的3D可视化。开始对话后,工作坊将活跃起来。" + }, "chat": { "welcomeMessage": "有什么可以帮忙的?", "welcomeMessages": [ diff --git a/src/workshop/bridge/ClawBoxEventTap.ts b/src/workshop/bridge/ClawBoxEventTap.ts new file mode 100644 index 0000000..c50289d --- /dev/null +++ b/src/workshop/bridge/ClawBoxEventTap.ts @@ -0,0 +1,52 @@ +/** + * ClawBoxEventTap — Hooks into the existing chat streaming callbacks + * to forward events to the Workshop EventBridge. + * + * This is a singleton that wraps the streamChat callbacks to also + * publish events to the workshop scene. + */ + +import type { EventBridge } from './EventBridge' + +let activeBridge: EventBridge | null = null + +/** Set the active bridge instance (called when Workshop mounts) */ +export function setActiveBridge(bridge: EventBridge | null): void { + activeBridge = bridge +} + +/** Get the active bridge (used by the callback wrappers) */ +export function getActiveBridge(): EventBridge | null { + return activeBridge +} + +/** + * Workshop-aware callback wrappers. + * These are called alongside the existing chat store callbacks + * in useChat.ts to forward events to the workshop. + */ +export const workshopCallbacks = { + onText: (content: string) => { + activeBridge?.onText(content) + }, + + onReasoning: (content: string) => { + activeBridge?.onReasoning(content) + }, + + onToolStart: (data: { name: string; toolCallId: string; args: Record }) => { + activeBridge?.onToolStart(data) + }, + + onToolEnd: (data: { toolCallId: string; name?: string; result: string; error?: boolean }) => { + activeBridge?.onToolEnd(data) + }, + + onDone: () => { + activeBridge?.onDone() + }, + + onError: (error: string) => { + activeBridge?.onError(error) + }, +} diff --git a/src/workshop/bridge/EventBridge.ts b/src/workshop/bridge/EventBridge.ts new file mode 100644 index 0000000..4c3b58c --- /dev/null +++ b/src/workshop/bridge/EventBridge.ts @@ -0,0 +1,217 @@ +/** + * EventBridge — Translates ClawBox SSE chat events into Vibecraft2 EventBus events. + * + * This is the key integration layer: ClawBox's streamChat callbacks produce + * normalized workshop events which are translated to Vibecraft2's EventBus format. + */ + +import { nanoid } from 'nanoid' +import { EventBus, type EventContext } from '../events/EventBus' +import { getStationForTool, type ToolCategory, type StationType, TOOL_STATION_MAP } from '../types' +import { getToolIcon, getToolContext } from '../utils/ToolUtils' +import { useWorkshopStore, type WorkshopFeedItem } from '../store/workshop' + +/** Map ClawBox tool names to Vibecraft2 tool categories */ +function toolNameToCategory(name: string): ToolCategory { + const map: Record = { + Read: 'read', + Write: 'write', + Edit: 'edit', + Bash: 'execute', + Grep: 'search', + Glob: 'search', + WebFetch: 'network', + WebSearch: 'network', + Task: 'delegate', + TodoWrite: 'plan', + AskUserQuestion: 'interact', + NotebookEdit: 'edit', + } + return map[name] ?? 'other' +} + +export class EventBridge { + private bus: EventBus + private context: EventContext + private activeToolIds = new Map() // toolCallId → toolUseId + + constructor(bus: EventBus, context: EventContext) { + this.bus = bus + this.context = context + } + + updateContext(context: Partial): void { + Object.assign(this.context, context) + } + + /** Called when SSE emits tool_start */ + onToolStart(data: { name: string; toolCallId: string; args: Record }): void { + const toolUseId = nanoid() + this.activeToolIds.set(data.toolCallId, toolUseId) + + this.bus.emit('tool_start', { + id: nanoid(), + timestamp: Date.now(), + type: 'tool_start', + agentId: 'clawbox-main', + source: 'clawbox', + tool: { + name: data.name, + category: toolNameToCategory(data.name), + id: toolUseId, + }, + input: data.args, + context: getToolContext(data.name, data.args) ?? undefined, + }, this.context) + + // Also emit legacy pre_tool_use for handlers that listen to that + this.bus.emit('pre_tool_use', { + id: nanoid(), + timestamp: Date.now(), + type: 'pre_tool_use', + sessionId: 'clawbox-main', + cwd: '', + tool: data.name, + toolInput: data.args, + toolUseId, + }, this.context) + + this.addFeedItem({ + id: nanoid(), + timestamp: Date.now(), + type: 'tool_start', + label: `${data.name}`, + detail: getToolContext(data.name, data.args) ?? undefined, + icon: getToolIcon(data.name), + }) + } + + /** Called when SSE emits tool_end */ + onToolEnd(data: { toolCallId: string; name?: string; result: string; error?: boolean }): void { + const toolUseId = this.activeToolIds.get(data.toolCallId) ?? nanoid() + this.activeToolIds.delete(data.toolCallId) + + const toolName = data.name ?? 'tool' + + this.bus.emit('tool_end', { + id: nanoid(), + timestamp: Date.now(), + type: 'tool_end', + agentId: 'clawbox-main', + source: 'clawbox', + tool: { + name: toolName, + category: toolNameToCategory(toolName), + id: toolUseId, + }, + success: !data.error, + }, this.context) + + this.bus.emit('post_tool_use', { + id: nanoid(), + timestamp: Date.now(), + type: 'post_tool_use', + sessionId: 'clawbox-main', + cwd: '', + tool: toolName, + toolInput: {}, + toolResponse: {}, + toolUseId, + success: !data.error, + }, this.context) + + this.addFeedItem({ + id: nanoid(), + timestamp: Date.now(), + type: 'tool_end', + label: `${toolName} ${data.error ? 'failed' : 'done'}`, + detail: data.result?.slice(0, 100), + icon: data.error ? '\u274C' : '\u2705', + }) + } + + /** Called when SSE emits text */ + onText(content: string): void { + // Only add feed items for substantial text (debounced) + if (content.length > 20) { + this.addFeedItem({ + id: nanoid(), + timestamp: Date.now(), + type: 'text', + label: 'Response', + detail: content.slice(0, 80), + icon: '\u{1F4AC}', + }) + } + } + + /** Called when SSE emits reasoning */ + onReasoning(content: string): void { + this.bus.emit('agent_thinking', { + id: nanoid(), + timestamp: Date.now(), + type: 'agent_thinking', + agentId: 'clawbox-main', + source: 'clawbox', + }, this.context) + + if (content.length > 20) { + this.addFeedItem({ + id: nanoid(), + timestamp: Date.now(), + type: 'reasoning', + label: 'Thinking', + detail: content.slice(0, 80), + icon: '\u{1F4AD}', + }) + } + } + + /** Called when SSE emits done */ + onDone(): void { + this.bus.emit('agent_idle', { + id: nanoid(), + timestamp: Date.now(), + type: 'agent_idle', + agentId: 'clawbox-main', + source: 'clawbox', + }, this.context) + + this.bus.emit('stop', { + id: nanoid(), + timestamp: Date.now(), + type: 'stop', + sessionId: 'clawbox-main', + cwd: '', + stopHookActive: false, + }, this.context) + + this.addFeedItem({ + id: nanoid(), + timestamp: Date.now(), + type: 'done', + label: 'Complete', + icon: '\u2728', + }) + } + + /** Called when SSE emits error */ + onError(error: string): void { + this.addFeedItem({ + id: nanoid(), + timestamp: Date.now(), + type: 'error', + label: 'Error', + detail: error.slice(0, 100), + icon: '\u{1F6A8}', + }) + } + + private addFeedItem(item: WorkshopFeedItem): void { + useWorkshopStore.getState().addFeedItem(item) + } + + dispose(): void { + this.activeToolIds.clear() + } +} diff --git a/src/workshop/components/WorkshopCanvas.tsx b/src/workshop/components/WorkshopCanvas.tsx new file mode 100644 index 0000000..cf52f84 --- /dev/null +++ b/src/workshop/components/WorkshopCanvas.tsx @@ -0,0 +1,110 @@ +/** + * WorkshopCanvas — React host for the Three.js workshop scene. + * Handles mount, resize, animate, and dispose lifecycle. + */ + +import { useEffect, useRef, useCallback } from 'react' +import { WorkshopScene } from '../scene/WorkshopScene' +import { EventBus } from '../events/EventBus' +import { EventBridge } from '../bridge/EventBridge' +import { setActiveBridge } from '../bridge/ClawBoxEventTap' +import { registerAllHandlers } from '../events/handlers' +import { Claude } from '../entities/ClaudeMon' +import { useWorkshopStore } from '../store/workshop' + +interface WorkshopCanvasProps { + className?: string +} + +export function WorkshopCanvas({ className }: WorkshopCanvasProps) { + const containerRef = useRef(null) + const sceneRef = useRef(null) + const busRef = useRef(null) + const bridgeRef = useRef(null) + const claudeRef = useRef(null) + + const setupScene = useCallback(() => { + const container = containerRef.current + if (!container) return + + // Create event bus and scene + const bus = new EventBus() + busRef.current = bus + + const scene = new WorkshopScene(container, bus) + sceneRef.current = scene + + // Register event handlers + registerAllHandlers(bus) + + // Create a default zone for the main agent + scene.createZone('clawbox-main') + + // Create the Claude character + const claude = new Claude(scene, { startStation: 'center' }) + claudeRef.current = claude + + // Create event bridge with context + const bridge = new EventBridge(bus, { + scene, + session: { + id: 'clawbox-main', + color: 0x4ac8e8, + claude, + subagents: null, + zone: scene.zones.get('clawbox-main') ?? null, + stats: { + toolsUsed: 0, + filesTouched: new Set(), + activeSubagents: 0, + }, + }, + soundEnabled: false, + }) + bridgeRef.current = bridge + + // Register bridge as active tap + setActiveBridge(bridge) + + // Mark scene as ready + useWorkshopStore.getState().setSceneReady(true) + }, []) + + useEffect(() => { + setupScene() + + return () => { + // Cleanup + setActiveBridge(null) + useWorkshopStore.getState().setSceneReady(false) + + if (claudeRef.current) { + claudeRef.current.dispose() + claudeRef.current = null + } + + if (bridgeRef.current) { + bridgeRef.current.dispose() + bridgeRef.current = null + } + + if (busRef.current) { + busRef.current.clear() + busRef.current = null + } + + if (sceneRef.current) { + sceneRef.current.dispose() + sceneRef.current = null + } + } + }, [setupScene]) + + return ( +
+ ) +} diff --git a/src/workshop/components/WorkshopControls.tsx b/src/workshop/components/WorkshopControls.tsx new file mode 100644 index 0000000..26bf841 --- /dev/null +++ b/src/workshop/components/WorkshopControls.tsx @@ -0,0 +1,32 @@ +/** + * WorkshopControls — Camera and settings controls for the workshop view. + */ + +import { Eye, EyeOff, RotateCcw } from 'lucide-react' +import { useWorkshopStore } from '../store/workshop' + +export function WorkshopControls() { + const showFeed = useWorkshopStore((s) => s.settings.showFeed) + const updateSettings = useWorkshopStore((s) => s.updateSettings) + const clearFeed = useWorkshopStore((s) => s.clearFeed) + + return ( +
+ + +
+ ) +} diff --git a/src/workshop/components/WorkshopFeedOverlay.tsx b/src/workshop/components/WorkshopFeedOverlay.tsx new file mode 100644 index 0000000..10796f2 --- /dev/null +++ b/src/workshop/components/WorkshopFeedOverlay.tsx @@ -0,0 +1,75 @@ +/** + * WorkshopFeedOverlay — Activity feed panel overlaid on the 3D scene. + * Shows real-time events from the chat bridge. + */ + +import { useEffect, useRef } from 'react' +import { useWorkshopStore } from '../store/workshop' + +export function WorkshopFeedOverlay() { + const feed = useWorkshopStore((s) => s.feed) + const showFeed = useWorkshopStore((s) => s.settings.showFeed) + const scrollRef = useRef(null) + + // Auto-scroll to bottom on new items + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [feed.length]) + + if (!showFeed) return null + + return ( +
+
+
+

+ Activity +

+
+
+ {feed.length === 0 ? ( +

+ Waiting for activity... +

+ ) : ( + feed.map((item) => ( +
+
+ {item.icon && {item.icon}} + {item.label} + + {formatTime(item.timestamp)} + +
+ {item.detail && ( +

+ {item.detail} +

+ )} +
+ )) + )} +
+
+
+ ) +} + +function formatTime(ts: number): string { + const d = new Date(ts) + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} diff --git a/src/workshop/components/WorkshopView.tsx b/src/workshop/components/WorkshopView.tsx new file mode 100644 index 0000000..91a4b54 --- /dev/null +++ b/src/workshop/components/WorkshopView.tsx @@ -0,0 +1,24 @@ +/** + * WorkshopView — Top-level workshop tab view. + * Full-screen layout with 3D canvas, feed overlay, and controls. + * No PageShell — takes over the entire content area. + */ + +import { WorkshopCanvas } from './WorkshopCanvas' +import { WorkshopFeedOverlay } from './WorkshopFeedOverlay' +import { WorkshopControls } from './WorkshopControls' + +export function WorkshopView() { + return ( +
+ {/* 3D Scene */} + + + {/* Overlays (pointer-events-none container, children opt-in) */} +
+ + +
+
+ ) +} diff --git a/src/workshop/entities/ClaudeMon.ts b/src/workshop/entities/ClaudeMon.ts new file mode 100644 index 0000000..d6b8f4c --- /dev/null +++ b/src/workshop/entities/ClaudeMon.ts @@ -0,0 +1,685 @@ +/** + * ClaudeMon — Vendored from Vibecraft2. Cute robot character for the workshop. + * Adapted: accepts WorkshopScene via interface, no audio dependency. + */ + +import * as THREE from 'three' +import type { StationType } from '../types' +import type { CharacterState, CharacterOptions, ICharacter } from './ICharacter' +import { + IdleBehaviorManager, + WorkingBehaviorManager, + STATION_ANIMATIONS, + type CharacterParts, +} from './animations' + +export type ClaudeState = CharacterState +export type ClaudeOptions = CharacterOptions + +interface SceneHost { + scene: THREE.Scene + stations: Map + onRender(cb: (delta: number) => void): void + offRender(cb: (delta: number) => void): void +} + +const DEFAULT_OPTIONS: Required = { + scale: 1, + color: 0x2a3a4a, + statusColor: 0x4ade80, + startStation: 'center', +} + +export class Claude implements ICharacter { + public readonly mesh: THREE.Group + public state: CharacterState = 'idle' + public currentStation: StationType = 'center' + public readonly id: string + + private host: SceneHost + private options: Required + private targetPosition: THREE.Vector3 | null = null + private moveSpeed = 3 + private bobTime = 0 + private workTime = 0 + private thinkTime = 0 + private updateCallback: ((delta: number) => void) | null = null + + private head: THREE.Group + private visor: THREE.Mesh + private leftEye: THREE.Mesh + private rightEye: THREE.Mesh + private body: THREE.Group + private leftArm: THREE.Group + private rightArm: THREE.Group + private antenna: THREE.Group + private statusRing: THREE.Mesh + private thoughtBubbles: THREE.Group + private glowAccents: THREE.Group + + private idleBehaviorManager: IdleBehaviorManager + private workingBehaviorManager: WorkingBehaviorManager + + constructor(host: SceneHost, options: ClaudeOptions = {}) { + this.host = host + this.options = { ...DEFAULT_OPTIONS, ...options } + this.id = Math.random().toString(36).substring(2, 9) + this.mesh = new THREE.Group() + + this.head = this.createHead() + this.visor = this.head.getObjectByName('visor') as THREE.Mesh + this.leftEye = this.head.getObjectByName('leftEye') as THREE.Mesh + this.rightEye = this.head.getObjectByName('rightEye') as THREE.Mesh + this.body = this.createBody() + this.leftArm = this.createArm(-1) + this.rightArm = this.createArm(1) + this.antenna = this.createAntenna() + this.statusRing = this.createStatusRing() + this.thoughtBubbles = this.createThoughtBubbles() + this.glowAccents = this.createGlowAccents() + + this.mesh.add(this.head) + this.mesh.add(this.body) + this.mesh.add(this.leftArm) + this.mesh.add(this.rightArm) + this.mesh.add(this.antenna) + this.mesh.add(this.statusRing) + this.mesh.add(this.thoughtBubbles) + this.mesh.add(this.glowAccents) + + this.idleBehaviorManager = new IdleBehaviorManager() + this.workingBehaviorManager = new WorkingBehaviorManager() + + this.mesh.scale.setScalar(this.options.scale) + + this.currentStation = this.options.startStation + const startStation = host.stations.get(this.options.startStation) + if (startStation) { + this.mesh.position.copy(startStation.position) + } + + host.scene.add(this.mesh) + + this.updateCallback = (delta: number) => this.update(delta) + host.onRender(this.updateCallback) + } + + private createHead(): THREE.Group { + const group = new THREE.Group() + + const headGeometry = new THREE.SphereGeometry(0.28, 32, 32) + headGeometry.scale(1, 0.9, 0.85) + const headMaterial = new THREE.MeshStandardMaterial({ + color: this.options.color, + roughness: 0.3, + metalness: 0.7, + }) + const head = new THREE.Mesh(headGeometry, headMaterial) + head.castShadow = true + group.add(head) + + const visorGeometry = new THREE.PlaneGeometry(0.32, 0.18) + const visorMaterial = new THREE.MeshBasicMaterial({ + color: 0x1a1a2e, + transparent: true, + opacity: 0.9, + }) + const visor = new THREE.Mesh(visorGeometry, visorMaterial) + visor.name = 'visor' + visor.position.set(0, 0.02, 0.24) + group.add(visor) + + const frameGeometry = new THREE.RingGeometry(0.17, 0.19, 32) + frameGeometry.scale(1, 0.6, 1) + const frameMaterial = new THREE.MeshBasicMaterial({ + color: 0x67e8f9, + transparent: true, + opacity: 0.6, + }) + const frame = new THREE.Mesh(frameGeometry, frameMaterial) + frame.position.set(0, 0.02, 0.241) + group.add(frame) + + const eyeShape = new THREE.Shape() + const eyeW = 0.032 + const eyeH = 0.045 + const eyeR = 0.012 + eyeShape.moveTo(-eyeW / 2 + eyeR, -eyeH / 2) + eyeShape.lineTo(eyeW / 2 - eyeR, -eyeH / 2) + eyeShape.quadraticCurveTo(eyeW / 2, -eyeH / 2, eyeW / 2, -eyeH / 2 + eyeR) + eyeShape.lineTo(eyeW / 2, eyeH / 2 - eyeR) + eyeShape.quadraticCurveTo(eyeW / 2, eyeH / 2, eyeW / 2 - eyeR, eyeH / 2) + eyeShape.lineTo(-eyeW / 2 + eyeR, eyeH / 2) + eyeShape.quadraticCurveTo(-eyeW / 2, eyeH / 2, -eyeW / 2, eyeH / 2 - eyeR) + eyeShape.lineTo(-eyeW / 2, -eyeH / 2 + eyeR) + eyeShape.quadraticCurveTo(-eyeW / 2, -eyeH / 2, -eyeW / 2 + eyeR, -eyeH / 2) + + const eyeGeometry = new THREE.ShapeGeometry(eyeShape) + const eyeMaterial = new THREE.MeshBasicMaterial({ color: 0x67e8f9 }) + + const leftEye = new THREE.Mesh(eyeGeometry, eyeMaterial.clone()) + leftEye.name = 'leftEye' + leftEye.position.set(-0.07, 0.03, 0.242) + group.add(leftEye) + + const rightEye = new THREE.Mesh(eyeGeometry, eyeMaterial.clone()) + rightEye.name = 'rightEye' + rightEye.position.set(0.07, 0.03, 0.242) + group.add(rightEye) + + const mouthCurve = new THREE.QuadraticBezierCurve3( + new THREE.Vector3(-0.04, 0, 0), + new THREE.Vector3(0, -0.015, 0), + new THREE.Vector3(0.04, 0, 0) + ) + const mouthPoints = mouthCurve.getPoints(10) + const mouthGeometry = new THREE.BufferGeometry().setFromPoints(mouthPoints) + const mouthMaterial = new THREE.LineBasicMaterial({ + color: 0x67e8f9, + transparent: true, + opacity: 0.8, + }) + const mouth = new THREE.Line(mouthGeometry, mouthMaterial) + mouth.position.set(0, -0.04, 0.242) + group.add(mouth) + + const panelGeometry = new THREE.RingGeometry(0.27, 0.275, 32, 1, 0, Math.PI) + const panelMaterial = new THREE.MeshBasicMaterial({ + color: 0x1a2a3a, + side: THREE.DoubleSide, + }) + const panelLine = new THREE.Mesh(panelGeometry, panelMaterial) + panelLine.rotation.x = Math.PI / 2 + panelLine.position.y = 0.05 + group.add(panelLine) + + const earGeometry = new THREE.CylinderGeometry(0.06, 0.06, 0.04, 16) + const earMaterial = new THREE.MeshStandardMaterial({ + color: 0x3a4a5a, + roughness: 0.4, + metalness: 0.6, + }) + + const leftEar = new THREE.Mesh(earGeometry, earMaterial) + leftEar.rotation.z = Math.PI / 2 + leftEar.position.set(-0.26, 0.02, 0) + group.add(leftEar) + + const rightEar = new THREE.Mesh(earGeometry, earMaterial) + rightEar.rotation.z = Math.PI / 2 + rightEar.position.set(0.26, 0.02, 0) + group.add(rightEar) + + group.position.y = 0.52 + return group + } + + private createBody(): THREE.Group { + const group = new THREE.Group() + + const bodyGeometry = new THREE.CylinderGeometry(0.18, 0.22, 0.3, 16) + const bodyMaterial = new THREE.MeshStandardMaterial({ + color: this.options.color, + roughness: 0.35, + metalness: 0.65, + }) + const body = new THREE.Mesh(bodyGeometry, bodyMaterial) + body.castShadow = true + group.add(body) + + const panelGeometry = new THREE.PlaneGeometry(0.16, 0.12) + const panelMaterial = new THREE.MeshBasicMaterial({ + color: 0x1a1a2e, + transparent: true, + opacity: 0.8, + }) + const panel = new THREE.Mesh(panelGeometry, panelMaterial) + panel.position.set(0, 0.02, 0.18) + group.add(panel) + + const lightGeometry = new THREE.CircleGeometry(0.03, 16) + const lightMaterial = new THREE.MeshBasicMaterial({ + color: 0xa78bfa, + transparent: true, + opacity: 0.9, + }) + const chestLight = new THREE.Mesh(lightGeometry, lightMaterial) + chestLight.position.set(0, 0.02, 0.181) + chestLight.name = 'chestLight' + group.add(chestLight) + + const beltGeometry = new THREE.TorusGeometry(0.2, 0.02, 8, 32) + const beltMaterial = new THREE.MeshStandardMaterial({ + color: 0x4a5a6a, + roughness: 0.3, + metalness: 0.7, + }) + const belt = new THREE.Mesh(beltGeometry, beltMaterial) + belt.rotation.x = Math.PI / 2 + belt.position.y = -0.12 + group.add(belt) + + const legGeometry = new THREE.CylinderGeometry(0.06, 0.07, 0.15, 12) + const legMaterial = new THREE.MeshStandardMaterial({ + color: 0x3a4a5a, + roughness: 0.4, + metalness: 0.6, + }) + + const leftLeg = new THREE.Mesh(legGeometry, legMaterial) + leftLeg.position.set(-0.1, -0.22, 0) + leftLeg.castShadow = true + group.add(leftLeg) + + const rightLeg = new THREE.Mesh(legGeometry, legMaterial) + rightLeg.position.set(0.1, -0.22, 0) + rightLeg.castShadow = true + group.add(rightLeg) + + const footGeometry = new THREE.SphereGeometry(0.07, 12, 8) + footGeometry.scale(1.2, 0.5, 1.3) + const footMaterial = new THREE.MeshStandardMaterial({ + color: 0x2a3a4a, + roughness: 0.4, + metalness: 0.6, + }) + + const leftFoot = new THREE.Mesh(footGeometry, footMaterial) + leftFoot.position.set(-0.1, -0.32, 0.02) + leftFoot.name = 'leftFoot' + group.add(leftFoot) + + const rightFoot = new THREE.Mesh(footGeometry, footMaterial) + rightFoot.position.set(0.1, -0.32, 0.02) + rightFoot.name = 'rightFoot' + group.add(rightFoot) + + group.position.y = 0.22 + return group + } + + private createArm(side: number): THREE.Group { + const group = new THREE.Group() + + const shoulderGeometry = new THREE.SphereGeometry(0.05, 12, 12) + const jointMaterial = new THREE.MeshStandardMaterial({ + color: 0x4a5a6a, + roughness: 0.3, + metalness: 0.7, + }) + const shoulder = new THREE.Mesh(shoulderGeometry, jointMaterial) + group.add(shoulder) + + const armGeometry = new THREE.CylinderGeometry(0.035, 0.04, 0.15, 10) + const armMaterial = new THREE.MeshStandardMaterial({ + color: 0x3a4a5a, + roughness: 0.4, + metalness: 0.6, + }) + const arm = new THREE.Mesh(armGeometry, armMaterial) + arm.position.y = -0.1 + arm.castShadow = true + group.add(arm) + + const handGeometry = new THREE.SphereGeometry(0.045, 12, 12) + const hand = new THREE.Mesh(handGeometry, jointMaterial) + hand.position.y = -0.18 + hand.name = 'hand' + group.add(hand) + + group.position.set(side * 0.24, 0.26, 0) + return group + } + + private createAntenna(): THREE.Group { + const group = new THREE.Group() + + const baseGeometry = new THREE.CylinderGeometry(0.03, 0.04, 0.04, 12) + const baseMaterial = new THREE.MeshStandardMaterial({ + color: 0x3a4a5a, + roughness: 0.4, + metalness: 0.6, + }) + group.add(new THREE.Mesh(baseGeometry, baseMaterial)) + + const stalkGeometry = new THREE.CylinderGeometry(0.015, 0.02, 0.12, 8) + const stalkMaterial = new THREE.MeshStandardMaterial({ + color: 0x4a5a6a, + roughness: 0.3, + metalness: 0.7, + }) + const stalk = new THREE.Mesh(stalkGeometry, stalkMaterial) + stalk.position.y = 0.08 + group.add(stalk) + + const tipGeometry = new THREE.SphereGeometry(0.035, 12, 12) + const tipMaterial = new THREE.MeshBasicMaterial({ + color: 0x67e8f9, + transparent: true, + opacity: 0.9, + }) + const tip = new THREE.Mesh(tipGeometry, tipMaterial) + tip.position.y = 0.16 + tip.name = 'antennaTip' + group.add(tip) + + group.position.set(0, 0.78, 0) + return group + } + + private createGlowAccents(): THREE.Group { + const group = new THREE.Group() + + const lineMaterial = new THREE.MeshBasicMaterial({ + color: 0x67e8f9, + transparent: true, + opacity: 0.4, + }) + + const lineGeometry = new THREE.PlaneGeometry(0.01, 0.2) + + const leftLine = new THREE.Mesh(lineGeometry, lineMaterial.clone()) + leftLine.position.set(-0.12, 0.22, 0.19) + group.add(leftLine) + + const rightLine = new THREE.Mesh(lineGeometry, lineMaterial.clone()) + rightLine.position.set(0.12, 0.22, 0.19) + group.add(rightLine) + + return group + } + + private createStatusRing(): THREE.Mesh { + const geometry = new THREE.RingGeometry(0.28, 0.32, 32) + const material = new THREE.MeshBasicMaterial({ + color: this.options.statusColor, + transparent: true, + opacity: 0.5, + side: THREE.DoubleSide, + }) + const ring = new THREE.Mesh(geometry, material) + ring.rotation.x = -Math.PI / 2 + ring.position.y = 0.01 + return ring + } + + private createThoughtBubbles(): THREE.Group { + const group = new THREE.Group() + + const sizes = [0.04, 0.06, 0.09] + const positions = [ + { x: 0.3, y: 0.75, z: 0.1 }, + { x: 0.42, y: 0.9, z: 0.12 }, + { x: 0.52, y: 1.1, z: 0.14 }, + ] + + sizes.forEach((size, i) => { + const geometry = new THREE.CircleGeometry(size, 6) + const material = new THREE.MeshBasicMaterial({ + color: 0x67e8f9, + transparent: true, + opacity: 0.6, + }) + const bubble = new THREE.Mesh(geometry, material) + bubble.position.set(positions[i].x, positions[i].y, positions[i].z) + bubble.rotation.z = Math.PI / 6 + bubble.userData.baseY = positions[i].y + bubble.userData.offset = i * 0.7 + group.add(bubble) + }) + + group.visible = false + return group + } + + moveTo(station: StationType): void { + const targetStation = this.host.stations.get(station) + if (!targetStation) return + + this.targetPosition = targetStation.position.clone() + this.currentStation = station + this.state = 'walking' + this.updateStatusColor() + } + + moveToPosition(position: THREE.Vector3, station: StationType): void { + this.targetPosition = position.clone() + this.currentStation = station + this.state = 'walking' + this.updateStatusColor() + } + + setState(state: CharacterState): void { + const parts = this.getCharacterParts() + + if (this.state === 'idle' && state !== 'idle') { + this.idleBehaviorManager.stop(parts) + } + if (this.state === 'working' && state !== 'working') { + this.workingBehaviorManager.stop(parts) + } + + this.state = state + this.updateStatusColor() + + if (state === 'working') { + this.workTime = 0 + this.workingBehaviorManager.start(this.currentStation, parts) + } else if (state === 'thinking') { + this.thinkTime = 0 + } + } + + private getCharacterParts(): CharacterParts { + return { + head: this.head, + leftEye: this.leftEye, + rightEye: this.rightEye, + leftArm: this.leftArm, + rightArm: this.rightArm, + antenna: this.antenna, + body: this.body, + mesh: this.mesh, + } + } + + private updateStatusColor(): void { + const material = this.statusRing.material as THREE.MeshBasicMaterial + const antennaTip = this.antenna.getObjectByName('antennaTip') as THREE.Mesh + const antennaMaterial = antennaTip.material as THREE.MeshBasicMaterial + const leftEyeMat = this.leftEye.material as THREE.MeshBasicMaterial + const rightEyeMat = this.rightEye.material as THREE.MeshBasicMaterial + + switch (this.state) { + case 'idle': + material.color.setHex(0x4ade80) + material.opacity = 0.5 + antennaMaterial.color.setHex(0x4ade80) + leftEyeMat.color.setHex(0x67e8f9) + rightEyeMat.color.setHex(0x67e8f9) + break + case 'walking': + material.color.setHex(0x60a5fa) + material.opacity = 0.6 + antennaMaterial.color.setHex(0x60a5fa) + leftEyeMat.color.setHex(0x60a5fa) + rightEyeMat.color.setHex(0x60a5fa) + break + case 'working': + material.color.setHex(0xfbbf24) + material.opacity = 0.7 + antennaMaterial.color.setHex(0xfbbf24) + leftEyeMat.color.setHex(0xfbbf24) + rightEyeMat.color.setHex(0xfbbf24) + break + case 'thinking': + material.color.setHex(0xa78bfa) + material.opacity = 0.6 + antennaMaterial.color.setHex(0xa78bfa) + leftEyeMat.color.setHex(0xa78bfa) + rightEyeMat.color.setHex(0xa78bfa) + break + } + } + + private update(delta: number): void { + if (this.targetPosition && this.state === 'walking') { + const direction = this.targetPosition.clone().sub(this.mesh.position) + const distance = direction.length() + + if (distance > 0.1) { + direction.normalize() + const moveDistance = Math.min(this.moveSpeed * delta, distance) + this.mesh.position.add(direction.multiplyScalar(moveDistance)) + + const angle = Math.atan2(direction.x, direction.z) + this.mesh.rotation.y = angle + + this.bobTime += delta * 12 + this.head.position.y = 0.52 + Math.abs(Math.sin(this.bobTime)) * 0.04 + this.leftArm.rotation.x = Math.sin(this.bobTime) * 0.4 + this.rightArm.rotation.x = Math.sin(this.bobTime + Math.PI) * 0.4 + + const leftFoot = this.body.getObjectByName('leftFoot') as THREE.Mesh + const rightFoot = this.body.getObjectByName('rightFoot') as THREE.Mesh + if (leftFoot && rightFoot) { + leftFoot.position.y = -0.32 + Math.max(0, Math.sin(this.bobTime)) * 0.03 + rightFoot.position.y = -0.32 + Math.max(0, Math.sin(this.bobTime + Math.PI)) * 0.03 + } + + this.antenna.rotation.x = Math.sin(this.bobTime * 1.5) * 0.15 + this.leftEye.scale.setScalar(1.1) + this.rightEye.scale.setScalar(1.1) + } else { + this.mesh.position.copy(this.targetPosition) + this.targetPosition = null + this.setState(this.currentStation === 'center' ? 'idle' : 'working') + } + } + + if (this.state === 'idle') { + this.bobTime += delta * 2 + const behaviorPlaying = this.idleBehaviorManager.update(this.getCharacterParts(), delta) + + if (!behaviorPlaying) { + this.head.position.y = 0.52 + Math.sin(this.bobTime) * 0.015 + this.antenna.rotation.z = Math.sin(this.bobTime * 0.7) * 0.1 + this.leftArm.rotation.x = Math.sin(this.bobTime * 0.5) * 0.05 + this.rightArm.rotation.x = Math.sin(this.bobTime * 0.5 + 0.5) * 0.05 + + const blinkCycle = (this.bobTime * 0.4) % (Math.PI * 2) + if (blinkCycle < 0.15) { + this.leftEye.scale.setScalar(0.3) + this.rightEye.scale.setScalar(0.3) + } else { + this.leftEye.scale.setScalar(1) + this.rightEye.scale.setScalar(1) + } + } + } + + if (this.state === 'working') { + this.workTime += delta + this.workingBehaviorManager.update(this.getCharacterParts(), delta) + + this.thinkTime += delta * 3 + this.thoughtBubbles.visible = true + this.thoughtBubbles.scale.setScalar(0.5) + this.thoughtBubbles.children.forEach((bubble) => { + const mesh = bubble as THREE.Mesh + const baseY = mesh.userData.baseY as number + const offset = mesh.userData.offset as number + mesh.position.y = baseY + Math.sin(this.thinkTime * 3 + offset) * 0.03 + const mat = mesh.material as THREE.MeshBasicMaterial + mat.opacity = 0.3 + Math.sin(this.thinkTime * 4 + offset) * 0.2 + }) + } + + if (this.state === 'thinking') { + this.thinkTime += delta * 2 + this.head.rotation.z = Math.sin(this.thinkTime * 0.5) * 0.1 + this.head.position.y = 0.52 + Math.sin(this.thinkTime) * 0.01 + this.rightArm.rotation.x = -0.8 + this.rightArm.rotation.z = -0.3 + Math.sin(this.thinkTime) * 0.05 + this.leftArm.rotation.x = Math.sin(this.thinkTime * 0.7) * 0.1 + this.antenna.rotation.z = Math.sin(this.thinkTime) * 0.2 + this.antenna.rotation.x = Math.sin(this.thinkTime * 0.7) * 0.15 + this.leftEye.position.x = -0.07 + Math.sin(this.thinkTime * 0.5) * 0.02 + this.rightEye.position.x = 0.07 + Math.sin(this.thinkTime * 0.5) * 0.02 + this.leftEye.position.y = 0.03 + 0.01 + this.rightEye.position.y = 0.03 + 0.01 + + this.thoughtBubbles.visible = true + this.thoughtBubbles.scale.setScalar(1) + this.thoughtBubbles.children.forEach((bubble) => { + const mesh = bubble as THREE.Mesh + const baseY = mesh.userData.baseY as number + const offset = mesh.userData.offset as number + mesh.position.y = baseY + Math.sin(this.thinkTime * 2 + offset) * 0.05 + const mat = mesh.material as THREE.MeshBasicMaterial + mat.opacity = 0.5 + Math.sin(this.thinkTime * 3 + offset) * 0.3 + }) + } else if (this.state !== 'working') { + this.thoughtBubbles.visible = false + this.leftEye.position.set(-0.07, 0.03, 0.242) + this.rightEye.position.set(0.07, 0.03, 0.242) + this.head.rotation.z = 0 + this.rightArm.rotation.z = 0 + } + + this.statusRing.rotation.z += delta * 0.3 + + this.glowAccents.children.forEach((line, i) => { + const mat = (line as THREE.Mesh).material as THREE.MeshBasicMaterial + mat.opacity = 0.3 + Math.sin(Date.now() * 0.002 + i) * 0.2 + }) + + const antennaTip = this.antenna.getObjectByName('antennaTip') as THREE.Mesh + if (antennaTip) { + const mat = antennaTip.material as THREE.MeshBasicMaterial + mat.opacity = 0.7 + Math.sin(Date.now() * 0.003) * 0.2 + } + + const chestLight = this.body.getObjectByName('chestLight') as THREE.Mesh + if (chestLight) { + const mat = chestLight.material as THREE.MeshBasicMaterial + mat.opacity = 0.6 + Math.sin(Date.now() * 0.004) * 0.3 + } + } + + getIdleBehaviorNames(): string[] { + return this.idleBehaviorManager.getBehaviorNames() + } + + playIdleBehavior(name: string): boolean { + if (this.state !== 'idle') this.setState('idle') + return this.idleBehaviorManager.forcePlay(name, this.getCharacterParts()) + } + + playRandomIdleBehavior(): string | null { + if (this.state !== 'idle') this.setState('idle') + return this.idleBehaviorManager.forcePlayRandom(this.getCharacterParts()) + } + + dispose(): void { + if (this.updateCallback) { + this.host.offRender(this.updateCallback) + this.updateCallback = null + } + + this.host.scene.remove(this.mesh) + + const disposeMesh = (obj: THREE.Object3D) => { + if (obj instanceof THREE.Mesh || obj instanceof THREE.Line) { + obj.geometry.dispose() + if (Array.isArray(obj.material)) { + obj.material.forEach(m => m.dispose()) + } else if (obj.material) { + obj.material.dispose() + } + } + } + + this.mesh.traverse(disposeMesh) + } +} diff --git a/src/workshop/entities/ICharacter.ts b/src/workshop/entities/ICharacter.ts new file mode 100644 index 0000000..cb46e65 --- /dev/null +++ b/src/workshop/entities/ICharacter.ts @@ -0,0 +1,29 @@ +/** + * ICharacter — Vendored from Vibecraft2. Character interface for workshop entities. + */ + +import * as THREE from 'three' +import type { StationType } from '../types' + +export type CharacterState = 'idle' | 'walking' | 'working' | 'thinking' + +export interface CharacterOptions { + scale?: number + color?: number + statusColor?: number + startStation?: StationType +} + +export interface ICharacter { + readonly mesh: THREE.Group + state: CharacterState + currentStation: StationType + readonly id: string + moveTo(station: StationType): void + moveToPosition(position: THREE.Vector3, station: StationType): void + setState(state: CharacterState): void + dispose(): void +} + +export type CharacterModel = 'claudemon' +export const DEFAULT_CHARACTER_MODEL: CharacterModel = 'claudemon' diff --git a/src/workshop/entities/SubagentManager.ts b/src/workshop/entities/SubagentManager.ts new file mode 100644 index 0000000..85d6640 --- /dev/null +++ b/src/workshop/entities/SubagentManager.ts @@ -0,0 +1,109 @@ +/** + * SubagentManager — Vendored from Vibecraft2. Manages subagent visualizations. + */ + +import * as THREE from 'three' +import { Claude, type ClaudeOptions } from './ClaudeMon' + +export interface Subagent { + id: string + toolUseId: string + claude: Claude + spawnTime: number + description?: string +} + +const TINT_LEVELS = [0.35, 0.5, 0.65, 0.8] + +function lightenColor(baseHex: number, amount: number): number { + const base = new THREE.Color(baseHex) + const white = new THREE.Color(0xffffff) + base.lerp(white, amount) + return base.getHex() +} + +interface SceneHost { + scene: THREE.Scene + stations: Map + onRender(cb: (delta: number) => void): void + offRender(cb: (delta: number) => void): void +} + +export class SubagentManager { + private host: SceneHost + private subagents: Map = new Map() + private spawnIndex = 0 + private parentColor: number + + constructor(host: SceneHost, parentColor?: number) { + this.host = host + this.parentColor = parentColor ?? 0x4ac8e8 + } + + setParentColor(color: number): void { + this.parentColor = color + } + + spawn(toolUseId: string, description?: string): Subagent { + if (this.subagents.has(toolUseId)) { + return this.subagents.get(toolUseId)! + } + + const tint = TINT_LEVELS[this.spawnIndex % TINT_LEVELS.length] + const color = lightenColor(this.parentColor, tint) + this.spawnIndex++ + + const options: ClaudeOptions = { + scale: 0.6, + color, + statusColor: color, + startStation: 'portal', + } + + const claude = new Claude(this.host as any, options) + claude.setState('thinking') + + const offset = this.subagents.size * 0.5 + const angle = this.subagents.size * Math.PI * 0.4 + claude.mesh.position.x += Math.sin(angle) * offset + claude.mesh.position.z += Math.cos(angle) * offset + + const subagent: Subagent = { + id: claude.id, + toolUseId, + claude, + spawnTime: Date.now(), + description, + } + + this.subagents.set(toolUseId, subagent) + return subagent + } + + remove(toolUseId: string): void { + const subagent = this.subagents.get(toolUseId) + if (subagent) { + subagent.claude.dispose() + this.subagents.delete(toolUseId) + } + } + + get(toolUseId: string): Subagent | undefined { + return this.subagents.get(toolUseId) + } + + getAll(): Subagent[] { + return Array.from(this.subagents.values()) + } + + get count(): number { + return this.subagents.size + } + + dispose(): void { + for (const subagent of this.subagents.values()) { + subagent.claude.dispose() + } + this.subagents.clear() + } +} diff --git a/src/workshop/entities/animations/AnimationTypes.ts b/src/workshop/entities/animations/AnimationTypes.ts new file mode 100644 index 0000000..cbbf2bb --- /dev/null +++ b/src/workshop/entities/animations/AnimationTypes.ts @@ -0,0 +1,215 @@ +/** + * AnimationTypes - Shared types and utilities for character animations + * + * This module provides the foundation for all animation behaviors: + * - Common interfaces (CharacterParts, AnimationBehavior) + * - Easing functions for smooth motion + * - Category system for organizing behaviors + * + * To add new animations: + * 1. Create behavior objects implementing AnimationBehavior + * 2. Add to the appropriate registry (IDLE_BEHAVIORS, STATION_ANIMATIONS, etc.) + * 3. Optionally tag with categories for filtering + */ + +import * as THREE from 'three' + +// ============================================================================ +// Character Parts - What can be animated +// ============================================================================ + +/** Character parts that behaviors can animate */ +export interface CharacterParts { + head: THREE.Group + leftEye: THREE.Mesh + rightEye: THREE.Mesh + leftArm: THREE.Group + rightArm: THREE.Group + antenna: THREE.Group + body: THREE.Group + mesh: THREE.Group // Root mesh for whole-body animations +} + +// ============================================================================ +// Animation Behavior Interface +// ============================================================================ + +/** Categories for organizing and filtering behaviors */ +export type AnimationCategory = + | 'idle' // Random idle fidgets + | 'dance' // Dance moves + | 'emote' // Emotional expressions + | 'work' // Station-specific work + | 'reaction' // Success/error/completion reactions + | 'transition' // State change animations + +/** + * Base animation behavior interface + * + * All animations follow this contract: + * - name: unique identifier + * - duration: seconds for one cycle + * - update: called each frame with progress 0→1 + * - reset: cleanup when animation ends (optional but recommended) + */ +export interface AnimationBehavior { + /** Unique name for debugging and lookup */ + name: string + + /** Duration of one animation cycle in seconds */ + duration: number + + /** Categories for filtering (e.g., ['idle', 'dance']) */ + categories?: AnimationCategory[] + + /** + * Update the animation + * @param parts - Character parts to animate + * @param progress - Animation progress 0→1 + * @param deltaTime - Frame delta time in seconds + */ + update: (parts: CharacterParts, progress: number, deltaTime: number) => void + + /** + * Reset character to default pose (called when animation ends) + * Important: Always implement this to avoid stuck poses! + */ + reset?: (parts: CharacterParts) => void +} + +/** Idle behavior with weight for random selection */ +export interface IdleBehavior extends AnimationBehavior { + /** Probability weight (higher = more likely to be picked) */ + weight: number +} + +/** Working behavior with loop control */ +export interface WorkingBehavior extends AnimationBehavior { + /** If true, animation loops until stopped */ + loop: boolean +} + +/** Reaction behavior (success, error, etc.) */ +export interface ReactionBehavior extends AnimationBehavior { + /** When to trigger: 'success', 'error', 'complete', etc. */ + trigger: string +} + +// ============================================================================ +// Easing Functions - For smooth, natural motion +// ============================================================================ + +/** Smooth ease in-out (slow start, fast middle, slow end) */ +export const easeInOut = (t: number): number => + t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2 + +/** Ease out (fast start, slow end) */ +export const easeOut = (t: number): number => + 1 - Math.pow(1 - t, 3) + +/** Ease in (slow start, fast end) */ +export const easeIn = (t: number): number => + t * t * t + +/** Bounce easing (playful bouncy motion) */ +export const bounce = (t: number): number => { + const n1 = 7.5625 + const d1 = 2.75 + if (t < 1 / d1) return n1 * t * t + if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75 + if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375 + return n1 * (t -= 2.625 / d1) * t + 0.984375 +} + +/** Elastic easing (springy overshoot) */ +export const elastic = (t: number): number => { + if (t === 0 || t === 1) return t + const p = 0.3 + const s = p / 4 + return Math.pow(2, -10 * t) * Math.sin((t - s) * (2 * Math.PI) / p) + 1 +} + +/** Back easing (slight overshoot) */ +export const easeOutBack = (t: number): number => { + const c1 = 1.70158 + const c3 = c1 + 1 + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2) +} + +/** Linear (no easing) */ +export const linear = (t: number): number => t + +// ============================================================================ +// Animation Utilities +// ============================================================================ + +/** + * Interpolate between two values + */ +export const lerp = (a: number, b: number, t: number): number => + a + (b - a) * t + +/** + * Clamp a value between min and max + */ +export const clamp = (value: number, min: number, max: number): number => + Math.max(min, Math.min(max, value)) + +/** + * Map a value from one range to another + */ +export const mapRange = ( + value: number, + inMin: number, + inMax: number, + outMin: number, + outMax: number +): number => { + return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin) +} + +/** + * Create a ping-pong value (0→1→0) from progress + */ +export const pingPong = (t: number): number => + t < 0.5 ? t * 2 : 2 - t * 2 + +/** + * Create a stepped value (discrete steps instead of smooth) + */ +export const stepped = (t: number, steps: number): number => + Math.floor(t * steps) / steps + +// ============================================================================ +// Default Pose - Reset helper +// ============================================================================ + +/** + * Reset character to default idle pose + * Use this in reset() functions or call directly + */ +export const resetToDefaultPose = (parts: CharacterParts): void => { + // Head + parts.head.position.set(0, 0.52, 0) + parts.head.rotation.set(0, 0, 0) + + // Eyes + parts.leftEye.position.set(-0.07, 0.03, 0.242) + parts.rightEye.position.set(0.07, 0.03, 0.242) + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + + // Arms + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + + // Antenna + parts.antenna.rotation.set(0, 0, 0) + + // Body + parts.body.rotation.set(0, 0, 0) + + // Mesh (root) + parts.mesh.rotation.z = 0 + // Note: Don't reset mesh position as it's controlled by movement system +} diff --git a/src/workshop/entities/animations/IdleBehaviors.ts b/src/workshop/entities/animations/IdleBehaviors.ts new file mode 100644 index 0000000..bb2c07d --- /dev/null +++ b/src/workshop/entities/animations/IdleBehaviors.ts @@ -0,0 +1,884 @@ +/** + * IdleBehaviors - Modular idle animations for ClaudeMon + * + * Each behavior is a self-contained animation that plays during idle state. + * Easy to add, remove, or swap behaviors by editing the IDLE_BEHAVIORS array. + * + * Architecture: + * - Uses shared AnimationTypes for interfaces and utilities + * - Each behavior has: name, duration, weight (probability), update function + * - IdleBehaviorManager picks random behaviors based on weights + */ + +// Re-export shared types for convenience +export type { CharacterParts, IdleBehavior } from './AnimationTypes' + +import { + type CharacterParts, + type IdleBehavior, + easeInOut, + easeOut, + easeIn, + bounce, +} from './AnimationTypes' + +// ============================================================================ +// Idle Behaviors - Add new behaviors here! +// ============================================================================ + +const lookAround: IdleBehavior = { + name: 'lookAround', + duration: 3, + weight: 10, + update: (parts, progress) => { + // Look left, pause, look right, pause, center + const t = progress * 4 + let lookX = 0 + let lookY = 0 + + if (t < 1) { + // Look left + lookX = -easeInOut(t) * 0.03 + lookY = easeInOut(t) * 0.01 + } else if (t < 2) { + // Hold left, slight head tilt + lookX = -0.03 + lookY = 0.01 + parts.head.rotation.z = Math.sin((t - 1) * Math.PI) * 0.05 + } else if (t < 3) { + // Look right + const rt = t - 2 + lookX = -0.03 + easeInOut(rt) * 0.06 + lookY = 0.01 - easeInOut(rt) * 0.02 + } else { + // Return to center + const rt = t - 3 + lookX = 0.03 - easeOut(rt) * 0.03 + lookY = -0.01 + easeOut(rt) * 0.01 + parts.head.rotation.z = 0 + } + + parts.leftEye.position.x = -0.07 + lookX + parts.rightEye.position.x = 0.07 + lookX + parts.leftEye.position.y = 0.03 + lookY + parts.rightEye.position.y = 0.03 + lookY + }, + reset: (parts) => { + parts.leftEye.position.set(-0.07, 0.03, 0.242) + parts.rightEye.position.set(0.07, 0.03, 0.242) + parts.head.rotation.z = 0 + } +} + +const curiousTilt: IdleBehavior = { + name: 'curiousTilt', + duration: 2, + weight: 8, + update: (parts, progress) => { + // Tilt head curiously, antenna perks up + const t = progress < 0.5 ? easeOut(progress * 2) : easeIn((1 - progress) * 2) + + parts.head.rotation.z = t * 0.15 + parts.antenna.rotation.z = -t * 0.2 + parts.antenna.rotation.x = -t * 0.1 + + // Eyes widen slightly + const eyeScale = 1 + t * 0.15 + parts.leftEye.scale.setScalar(eyeScale) + parts.rightEye.scale.setScalar(eyeScale) + }, + reset: (parts) => { + parts.head.rotation.z = 0 + parts.antenna.rotation.z = 0 + parts.antenna.rotation.x = 0 + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + } +} + +const happyBounce: IdleBehavior = { + name: 'happyBounce', + duration: 1.5, + weight: 6, + update: (parts, progress) => { + // Quick happy bounces - whole body bounces! + const bounceCount = 3 + const t = progress * bounceCount + const bouncePhase = t % 1 + const bounceHeight = bounce(bouncePhase) * 0.12 * (1 - progress * 0.5) + + // Bounce the whole mesh, not just the head + parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + bounceHeight + + // Arms swing with bounces + const armSwing = Math.sin(t * Math.PI * 2) * 0.3 + parts.leftArm.rotation.x = armSwing + parts.rightArm.rotation.x = -armSwing + parts.leftArm.rotation.z = -0.1 - Math.abs(armSwing) * 0.2 + parts.rightArm.rotation.z = 0.1 + Math.abs(armSwing) * 0.2 + + // Antenna bounces with energy + parts.antenna.rotation.x = Math.sin(t * Math.PI * 2) * 0.2 + parts.antenna.rotation.z = Math.sin(t * Math.PI * 4) * 0.1 + }, + reset: (parts) => { + parts.mesh.position.y = parts.mesh.userData.originalY ?? 0 + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.antenna.rotation.set(0, 0, 0) + } +} + +const stretch: IdleBehavior = { + name: 'stretch', + duration: 2.5, + weight: 5, + update: (parts, progress) => { + // Big stretch - arms up, lean back + let armRaise = 0 + let lean = 0 + + if (progress < 0.3) { + // Arms going up + const t = easeOut(progress / 0.3) + armRaise = t + lean = t * 0.1 + } else if (progress < 0.7) { + // Hold stretch + armRaise = 1 + lean = 0.1 + // Slight wiggle at peak + const wiggle = Math.sin((progress - 0.3) * 20) * 0.02 + parts.leftArm.rotation.z = -0.3 + wiggle + parts.rightArm.rotation.z = 0.3 - wiggle + } else { + // Arms coming down + const t = easeIn((progress - 0.7) / 0.3) + armRaise = 1 - t + lean = 0.1 * (1 - t) + } + + parts.leftArm.rotation.x = -armRaise * 2.5 + parts.rightArm.rotation.x = -armRaise * 2.5 + parts.leftArm.rotation.z = -armRaise * 0.3 + parts.rightArm.rotation.z = armRaise * 0.3 + parts.head.rotation.x = lean + + // Eyes close slightly during stretch + const eyeSquint = armRaise * 0.5 + parts.leftEye.scale.y = 1 - eyeSquint + parts.rightEye.scale.y = 1 - eyeSquint + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.x = 0 + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + } +} + +const wave: IdleBehavior = { + name: 'wave', + duration: 2, + weight: 4, + update: (parts, progress) => { + // Friendly wave! + let armUp = 0 + let waveAngle = 0 + + if (progress < 0.2) { + // Raise arm + armUp = easeOut(progress / 0.2) + } else if (progress < 0.8) { + // Wave back and forth + armUp = 1 + const waveProgress = (progress - 0.2) / 0.6 + waveAngle = Math.sin(waveProgress * Math.PI * 4) * 0.4 + } else { + // Lower arm + armUp = 1 - easeIn((progress - 0.8) / 0.2) + } + + parts.rightArm.rotation.x = -armUp * 2.2 + parts.rightArm.rotation.z = armUp * 0.5 + waveAngle + + // Look at "camera" while waving + if (progress > 0.1 && progress < 0.9) { + parts.leftEye.position.z = 0.242 + 0.01 + parts.rightEye.position.z = 0.242 + 0.01 + } + }, + reset: (parts) => { + parts.rightArm.rotation.set(0, 0, 0) + parts.leftEye.position.z = 0.242 + parts.rightEye.position.z = 0.242 + } +} + +const doubleBlink: IdleBehavior = { + name: 'doubleBlink', + duration: 0.6, + weight: 12, + update: (parts, progress) => { + // Quick double blink + const t = progress * 2 + let eyeScale = 1 + + if (t < 0.5) { + // First blink + eyeScale = t < 0.25 ? 1 - easeIn(t * 4) * 0.9 : 0.1 + easeOut((t - 0.25) * 4) * 0.9 + } else if (t < 1) { + // Pause + eyeScale = 1 + } else if (t < 1.5) { + // Second blink + const bt = t - 1 + eyeScale = bt < 0.25 ? 1 - easeIn(bt * 4) * 0.9 : 0.1 + easeOut((bt - 0.25) * 4) * 0.9 + } + + parts.leftEye.scale.setScalar(eyeScale) + parts.rightEye.scale.setScalar(eyeScale) + }, + reset: (parts) => { + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + } +} + +const antennaTwitch: IdleBehavior = { + name: 'antennaTwitch', + duration: 0.8, + weight: 15, + update: (parts, progress) => { + // Quick antenna twitch like picking up a signal + const t = progress + let twitch = 0 + + if (t < 0.2) { + twitch = easeOut(t / 0.2) * 0.4 + } else if (t < 0.4) { + twitch = 0.4 - easeIn((t - 0.2) / 0.2) * 0.5 + } else if (t < 0.6) { + twitch = -0.1 + easeOut((t - 0.4) / 0.2) * 0.25 + } else { + twitch = 0.15 * (1 - easeOut((t - 0.6) / 0.4)) + } + + parts.antenna.rotation.z = twitch + parts.antenna.rotation.x = Math.abs(twitch) * 0.3 + }, + reset: (parts) => { + parts.antenna.rotation.z = 0 + parts.antenna.rotation.x = 0 + } +} + +const headShake: IdleBehavior = { + name: 'headShake', + duration: 1, + weight: 5, + update: (parts, progress) => { + // Playful head shake (like "no no no" but cute) + const shakes = 3 + const t = progress * shakes + const shake = Math.sin(t * Math.PI * 2) * (1 - progress) * 0.1 + + parts.head.rotation.y = shake + + // Eyes follow slightly + parts.leftEye.position.x = -0.07 - shake * 0.5 + parts.rightEye.position.x = 0.07 - shake * 0.5 + }, + reset: (parts) => { + parts.head.rotation.y = 0 + parts.leftEye.position.x = -0.07 + parts.rightEye.position.x = 0.07 + } +} + +const peek: IdleBehavior = { + name: 'peek', + duration: 2.5, + weight: 4, + update: (parts, progress) => { + // Peek to the side like looking around a corner + let lean = 0 + let eyeShift = 0 + + if (progress < 0.3) { + // Lean to peek + lean = easeOut(progress / 0.3) + eyeShift = lean + } else if (progress < 0.7) { + // Hold and look around + lean = 1 + const lookPhase = (progress - 0.3) / 0.4 + eyeShift = 1 + Math.sin(lookPhase * Math.PI * 2) * 0.3 + } else { + // Return + lean = 1 - easeIn((progress - 0.7) / 0.3) + eyeShift = lean + } + + parts.mesh.rotation.z = lean * 0.15 + parts.head.rotation.z = -lean * 0.1 // Counter-tilt head + parts.leftEye.position.x = -0.07 + eyeShift * 0.02 + parts.rightEye.position.x = 0.07 + eyeShift * 0.02 + }, + reset: (parts) => { + parts.mesh.rotation.z = 0 + parts.head.rotation.z = 0 + parts.leftEye.position.x = -0.07 + parts.rightEye.position.x = 0.07 + } +} + +const sleepyNod: IdleBehavior = { + name: 'sleepyNod', + duration: 3, + weight: 3, + update: (parts, progress) => { + // Getting sleepy... head nods forward then snaps back + let nod = 0 + let eyeOpen = 1 + + if (progress < 0.5) { + // Slowly nodding off + const t = easeIn(progress * 2) + nod = t * 0.2 + eyeOpen = 1 - t * 0.7 + } else if (progress < 0.55) { + // Snap awake! + const t = (progress - 0.5) / 0.05 + nod = 0.2 - t * 0.25 + eyeOpen = 0.3 + t * 0.9 + } else { + // Shake it off + const t = (progress - 0.55) / 0.45 + nod = -0.05 * (1 - easeOut(t)) + eyeOpen = 1.2 - t * 0.2 + // Little head shake + parts.head.rotation.y = Math.sin(t * Math.PI * 4) * 0.05 * (1 - t) + } + + parts.head.rotation.x = nod + parts.leftEye.scale.y = Math.max(0.1, eyeOpen) + parts.rightEye.scale.y = Math.max(0.1, eyeOpen) + parts.antenna.rotation.x = nod * 0.5 + }, + reset: (parts) => { + parts.head.rotation.x = 0 + parts.head.rotation.y = 0 + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + parts.antenna.rotation.x = 0 + } +} + +// ---------------------------------------------------------------------------- +// Dance Styles - Various dance moves for extra entertainment! +// ---------------------------------------------------------------------------- + +const discoFever: IdleBehavior = { + name: 'discoFever', + duration: 4, + weight: 3, + update: (parts, progress) => { + // Classic disco: point up alternating arms, hip sway + const beatTime = progress * 8 + const beat = Math.floor(beatTime) % 4 + const beatProgress = beatTime % 1 + + // Hip sway side to side + const sway = Math.sin(beatTime * Math.PI * 0.5) * 0.1 + parts.mesh.position.x = (parts.mesh.userData.originalX ?? 0) + sway + parts.body.rotation.z = -sway * 0.8 + + // Bounce on each beat + const bounce = Math.abs(Math.sin(beatProgress * Math.PI)) * 0.05 + parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + bounce + + // Alternating arm points to the sky! + if (beat < 2) { + // Right arm up pointing + parts.rightArm.rotation.x = -2.5 + parts.rightArm.rotation.z = 0.3 + Math.sin(beatProgress * Math.PI) * 0.2 + parts.leftArm.rotation.x = 0.3 + parts.leftArm.rotation.z = -0.2 + } else { + // Left arm up pointing + parts.leftArm.rotation.x = -2.5 + parts.leftArm.rotation.z = -0.3 - Math.sin(beatProgress * Math.PI) * 0.2 + parts.rightArm.rotation.x = 0.3 + parts.rightArm.rotation.z = 0.2 + } + + // Head follows the pointing arm + parts.head.rotation.z = beat < 2 ? 0.1 : -0.1 + parts.head.rotation.y = beat < 2 ? 0.15 : -0.15 + }, + reset: (parts) => { + parts.mesh.position.x = parts.mesh.userData.originalX ?? 0 + parts.mesh.position.y = parts.mesh.userData.originalY ?? 0 + parts.body.rotation.z = 0 + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + } +} + +const robotDance: IdleBehavior = { + name: 'robotDance', + duration: 3.5, + weight: 3, + update: (parts, progress) => { + // Mechanical robot dance - stiff, isolated movements + const phase = Math.floor(progress * 7) % 7 + const phaseProgress = (progress * 7) % 1 + const snap = phaseProgress < 0.2 ? easeOut(phaseProgress * 5) : 1 + + // Reset all rotations first + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + + switch (phase) { + case 0: // Arms out horizontal + parts.leftArm.rotation.z = -1.5 * snap + parts.rightArm.rotation.z = 1.5 * snap + break + case 1: // Arms bent at elbow (not possible with current rig, so arms forward) + parts.leftArm.rotation.x = -1.5 * snap + parts.rightArm.rotation.x = -1.5 * snap + break + case 2: // Head turn left + parts.head.rotation.y = -0.4 * snap + parts.leftArm.rotation.x = -1.5 + parts.rightArm.rotation.x = -1.5 + break + case 3: // Head turn right + parts.head.rotation.y = 0.4 * snap + parts.leftArm.rotation.x = -1.5 + parts.rightArm.rotation.x = -1.5 + break + case 4: // Body tilt left + parts.mesh.rotation.z = 0.15 * snap + parts.head.rotation.z = -0.1 * snap + break + case 5: // Body tilt right + parts.mesh.rotation.z = -0.15 * snap + parts.head.rotation.z = 0.1 * snap + break + case 6: // Return to center with bounce + const returnSnap = phaseProgress < 0.3 ? easeOut(phaseProgress * 3.3) : 1 + parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + (1 - returnSnap) * 0.05 + break + } + }, + reset: (parts) => { + parts.mesh.position.y = parts.mesh.userData.originalY ?? 0 + parts.mesh.rotation.z = 0 + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + } +} + +const headBanger: IdleBehavior = { + name: 'headBanger', + duration: 2.5, + weight: 2, + update: (parts, progress) => { + // Head banging! Heavy metal style + const bangSpeed = 6 + const t = progress * bangSpeed + const bangPhase = t % 1 + + // Intense head bang forward + const bangAngle = Math.sin(bangPhase * Math.PI) * 0.4 + parts.head.rotation.x = bangAngle + + // Arms pump with the beat + const armPump = Math.sin(bangPhase * Math.PI) * 0.5 + parts.leftArm.rotation.x = -0.5 - armPump + parts.rightArm.rotation.x = -0.5 - armPump + parts.leftArm.rotation.z = -0.3 + parts.rightArm.rotation.z = 0.3 + + // Slight body movement + parts.body.rotation.x = bangAngle * 0.3 + + // Antenna goes wild + parts.antenna.rotation.x = -bangAngle * 0.8 + parts.antenna.rotation.z = Math.sin(t * Math.PI * 2) * 0.2 + }, + reset: (parts) => { + parts.head.rotation.x = 0 + parts.body.rotation.x = 0 + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.antenna.rotation.set(0, 0, 0) + } +} + +const shuffleDance: IdleBehavior = { + name: 'shuffleDance', + duration: 3, + weight: 3, + update: (parts, progress) => { + // Shuffle side to side with arm pumps + const beatTime = progress * 6 + const beat = Math.floor(beatTime) % 2 + const beatProgress = beatTime % 1 + + // Shuffle position - quick snap to side, then slide back + const shuffleEase = beat === 0 + ? (beatProgress < 0.2 ? easeOut(beatProgress * 5) : 1 - easeIn((beatProgress - 0.2) / 0.8) * 0.5) + : (beatProgress < 0.2 ? easeOut(beatProgress * 5) : 1 - easeIn((beatProgress - 0.2) / 0.8) * 0.5) + + const shuffleX = beat === 0 ? shuffleEase * 0.15 : -shuffleEase * 0.15 + parts.mesh.position.x = (parts.mesh.userData.originalX ?? 0) + shuffleX + + // Body leans into the shuffle + parts.mesh.rotation.z = -shuffleX * 2 + + // Bounce on beat + const bounce = Math.sin(beatProgress * Math.PI) * 0.06 + parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + bounce + + // Arms pump up and down + const armPump = Math.sin(beatProgress * Math.PI) * 0.8 + parts.leftArm.rotation.x = -0.3 - armPump + parts.rightArm.rotation.x = -0.3 - armPump + + // Head bops + parts.head.rotation.z = shuffleX * 1.5 + }, + reset: (parts) => { + parts.mesh.position.x = parts.mesh.userData.originalX ?? 0 + parts.mesh.position.y = parts.mesh.userData.originalY ?? 0 + parts.mesh.rotation.z = 0 + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.z = 0 + } +} + +const twistDance: IdleBehavior = { + name: 'twistDance', + duration: 3, + weight: 3, + update: (parts, progress) => { + // Classic twist dance - rotate hips/body opposite to shoulders + const twistTime = progress * 4 + const twist = Math.sin(twistTime * Math.PI * 2) * 0.2 + + // Body twists one way + parts.body.rotation.y = twist + + // Head/shoulders twist the other way + parts.head.rotation.y = -twist * 0.8 + + // Arms out and swinging + parts.leftArm.rotation.z = -0.5 + parts.rightArm.rotation.z = 0.5 + parts.leftArm.rotation.y = twist * 2 + parts.rightArm.rotation.y = twist * 2 + + // Bounce while twisting + const bounce = Math.abs(Math.sin(twistTime * Math.PI * 2)) * 0.04 + parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + bounce + + // Slight side to side + parts.mesh.position.x = (parts.mesh.userData.originalX ?? 0) + twist * 0.3 + }, + reset: (parts) => { + parts.body.rotation.y = 0 + parts.head.rotation.y = 0 + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.mesh.position.x = parts.mesh.userData.originalX ?? 0 + parts.mesh.position.y = parts.mesh.userData.originalY ?? 0 + } +} + +const victoryDance: IdleBehavior = { + name: 'victoryDance', + duration: 2.5, + weight: 2, + update: (parts, progress) => { + // Celebratory fist pumps and jumping! + const beatTime = progress * 5 + const beat = Math.floor(beatTime) % 2 + const beatProgress = beatTime % 1 + + // Jump up! + const jumpHeight = Math.sin(beatProgress * Math.PI) * 0.15 + parts.mesh.position.y = (parts.mesh.userData.originalY ?? 0) + jumpHeight + + // Alternating fist pumps + if (beat === 0) { + parts.rightArm.rotation.x = -2.8 + parts.rightArm.rotation.z = 0.2 + Math.sin(beatProgress * Math.PI) * 0.3 + parts.leftArm.rotation.x = -0.5 + parts.leftArm.rotation.z = -0.2 + } else { + parts.leftArm.rotation.x = -2.8 + parts.leftArm.rotation.z = -0.2 - Math.sin(beatProgress * Math.PI) * 0.3 + parts.rightArm.rotation.x = -0.5 + parts.rightArm.rotation.z = 0.2 + } + + // Happy head movements + parts.head.rotation.z = Math.sin(beatTime * Math.PI * 2) * 0.1 + parts.head.rotation.y = Math.sin(beatTime * Math.PI) * 0.1 + + // Eyes excited (slightly bigger) + const excitement = 1 + Math.sin(beatProgress * Math.PI) * 0.1 + parts.leftEye.scale.setScalar(excitement) + parts.rightEye.scale.setScalar(excitement) + }, + reset: (parts) => { + parts.mesh.position.y = parts.mesh.userData.originalY ?? 0 + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + } +} + +const danceMoves: IdleBehavior = { + name: 'grooveDance', + duration: 3, + weight: 3, + update: (parts, progress) => { + // Little dance! Side to side with arm moves + const beatTime = progress * 8 + const beat = Math.floor(beatTime) % 4 + const beatProgress = beatTime % 1 + + // Body sway + const sway = Math.sin(beatTime * Math.PI) * 0.08 + parts.mesh.position.x = (parts.mesh.userData.originalX ?? 0) + sway + parts.mesh.rotation.z = -sway * 0.3 + + // Bounce on beat + const bouncePhase = Math.abs(Math.sin(beatProgress * Math.PI)) + parts.head.position.y = 0.52 + bouncePhase * 0.04 + + // Arms move based on beat + if (beat === 0 || beat === 2) { + parts.leftArm.rotation.z = -0.3 - bouncePhase * 0.2 + parts.rightArm.rotation.z = 0.3 + bouncePhase * 0.2 + } else { + parts.leftArm.rotation.x = -bouncePhase * 0.5 + parts.rightArm.rotation.x = -bouncePhase * 0.5 + } + + // Head bop + parts.head.rotation.z = Math.sin(beatTime * Math.PI * 2) * 0.05 + }, + reset: (parts) => { + parts.mesh.position.x = parts.mesh.userData.originalX ?? 0 + parts.mesh.rotation.z = 0 + parts.head.position.y = 0.52 + parts.head.rotation.z = 0 + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + } +} + +// ============================================================================ +// Behavior Registry - Add/remove behaviors here! +// ============================================================================ + +export const IDLE_BEHAVIORS: IdleBehavior[] = [ + // Basic idle animations + lookAround, + curiousTilt, + happyBounce, + stretch, + wave, + doubleBlink, + antennaTwitch, + headShake, + peek, + sleepyNod, + // Dance styles! + danceMoves, // grooveDance - basic side-to-side + discoFever, // 70s disco pointing + robotDance, // mechanical stiff moves + headBanger, // metal head bang + shuffleDance, // side shuffle with arm pumps + twistDance, // classic 60s twist + victoryDance, // celebratory fist pumps +] + +// ============================================================================ +// Behavior Manager +// ============================================================================ + +export class IdleBehaviorManager { + private behaviors: IdleBehavior[] + private currentBehavior: IdleBehavior | null = null + private behaviorProgress = 0 + private cooldown = 0 // Time until next behavior can start + + // === TUNING === + private readonly MIN_COOLDOWN = 2 // Minimum seconds between behaviors + private readonly MAX_COOLDOWN = 6 // Maximum seconds between behaviors + private readonly BASE_IDLE_WEIGHT = 20 // Weight for "do nothing" (just base idle) + + constructor(behaviors: IdleBehavior[] = IDLE_BEHAVIORS) { + this.behaviors = behaviors + this.cooldown = this.randomCooldown() + } + + private randomCooldown(): number { + return this.MIN_COOLDOWN + Math.random() * (this.MAX_COOLDOWN - this.MIN_COOLDOWN) + } + + private pickBehavior(): IdleBehavior | null { + // Calculate total weight including "do nothing" + const totalWeight = this.behaviors.reduce((sum, b) => sum + b.weight, 0) + this.BASE_IDLE_WEIGHT + + let roll = Math.random() * totalWeight + + // Check if we rolled "do nothing" + if (roll < this.BASE_IDLE_WEIGHT) { + return null + } + roll -= this.BASE_IDLE_WEIGHT + + // Find which behavior we rolled + for (const behavior of this.behaviors) { + roll -= behavior.weight + if (roll <= 0) { + return behavior + } + } + + return null + } + + /** + * Update the behavior manager + * @returns true if a behavior is currently playing + */ + update(parts: CharacterParts, deltaTime: number): boolean { + // If a behavior is playing, continue it + if (this.currentBehavior) { + this.behaviorProgress += deltaTime / this.currentBehavior.duration + + if (this.behaviorProgress >= 1) { + // Behavior finished + this.currentBehavior.reset?.(parts) + this.currentBehavior = null + this.behaviorProgress = 0 + this.cooldown = this.randomCooldown() + return false + } + + // Run the behavior + this.currentBehavior.update(parts, this.behaviorProgress, deltaTime) + return true + } + + // No behavior playing - count down cooldown + this.cooldown -= deltaTime + if (this.cooldown <= 0) { + // Try to start a new behavior + this.currentBehavior = this.pickBehavior() + if (this.currentBehavior) { + this.behaviorProgress = 0 + // Store original position for behaviors that move the mesh + parts.mesh.userData.originalX = parts.mesh.position.x + parts.mesh.userData.originalY = parts.mesh.position.y + return true + } else { + // Rolled "do nothing", set new cooldown + this.cooldown = this.randomCooldown() + } + } + + return false + } + + /** Force stop current behavior */ + stop(parts: CharacterParts): void { + if (this.currentBehavior) { + this.currentBehavior.reset?.(parts) + this.currentBehavior = null + this.behaviorProgress = 0 + } + } + + /** Check if a behavior is currently playing */ + isPlaying(): boolean { + return this.currentBehavior !== null + } + + /** Get current behavior name (for debugging) */ + getCurrentBehaviorName(): string | null { + return this.currentBehavior?.name ?? null + } + + /** Get list of all behavior names (for dev UI) */ + getBehaviorNames(): string[] { + return this.behaviors.map(b => b.name) + } + + /** Force play a specific behavior by name (for dev/testing) */ + forcePlay(name: string, parts: CharacterParts): boolean { + const behavior = this.behaviors.find(b => b.name === name) + if (!behavior) return false + + // Stop current behavior if any + if (this.currentBehavior) { + this.currentBehavior.reset?.(parts) + } + + // Start the requested behavior + this.currentBehavior = behavior + this.behaviorProgress = 0 + parts.mesh.userData.originalX = parts.mesh.position.x + parts.mesh.userData.originalY = parts.mesh.position.y + return true + } + + /** Force play a random behavior (guaranteed to play, ignores "do nothing" weight) */ + forcePlayRandom(parts: CharacterParts): string | null { + if (this.behaviors.length === 0) return null + + // Pick a random behavior (weighted, but excluding "do nothing") + const totalWeight = this.behaviors.reduce((sum, b) => sum + b.weight, 0) + let roll = Math.random() * totalWeight + + let chosen: IdleBehavior | null = null + for (const behavior of this.behaviors) { + roll -= behavior.weight + if (roll <= 0) { + chosen = behavior + break + } + } + + // Fallback to first behavior if somehow nothing was chosen + if (!chosen) chosen = this.behaviors[0] + + // Stop current behavior if any + if (this.currentBehavior) { + this.currentBehavior.reset?.(parts) + } + + // Start the chosen behavior + this.currentBehavior = chosen + this.behaviorProgress = 0 + parts.mesh.userData.originalX = parts.mesh.position.x + parts.mesh.userData.originalY = parts.mesh.position.y + + return chosen.name + } +} diff --git a/src/workshop/entities/animations/WorkingBehaviors.ts b/src/workshop/entities/animations/WorkingBehaviors.ts new file mode 100644 index 0000000..af4f194 --- /dev/null +++ b/src/workshop/entities/animations/WorkingBehaviors.ts @@ -0,0 +1,1127 @@ +/** + * WorkingBehaviors - Station-specific animations for ClaudeMon + * + * These animations play when Claude is "working" at a specific station. + * Each station has its own contextual animation. + * + * To add a new station animation: + * 1. Create a WorkingBehavior object + * 2. Add it to STATION_ANIMATIONS with the station name as key + */ + +import { + type CharacterParts, + type WorkingBehavior, + easeInOut, + easeOut, + pingPong, + elastic, + bounce, +} from './AnimationTypes' + +// Re-export for convenience +export type { WorkingBehavior } from './AnimationTypes' + +export type StationAnimations = { + [station: string]: WorkingBehavior[] +} + +// ============================================================================ +// Station Working Animations +// ============================================================================ + +/** Bookshelf (Read) - Reading a book, flipping pages */ +const readingBook: WorkingBehavior = { + name: 'readingBook', + loop: true, + duration: 4, + update: (parts, progress) => { + // Hold arms like reading a book + parts.leftArm.rotation.x = -1.2 + parts.leftArm.rotation.z = -0.3 + parts.rightArm.rotation.x = -1.2 + parts.rightArm.rotation.z = 0.3 + + // Eyes scan left to right (reading) + const readCycle = progress * 3 // 3 lines per cycle + const lineProgress = readCycle % 1 + const eyeX = (lineProgress < 0.8) + ? -0.02 + easeInOut(lineProgress / 0.8) * 0.04 // Read left to right + : 0.02 - easeOut((lineProgress - 0.8) / 0.2) * 0.04 // Quick return + + parts.leftEye.position.x = -0.07 + eyeX + parts.rightEye.position.x = 0.07 + eyeX + + // Slight head tilt while reading + parts.head.rotation.x = 0.15 // Looking down at book + parts.head.rotation.z = Math.sin(progress * Math.PI * 2) * 0.03 + + // Occasional page flip (at progress 0.5) + if (progress > 0.48 && progress < 0.55) { + const flipProgress = (progress - 0.48) / 0.07 + parts.rightArm.rotation.z = 0.3 + Math.sin(flipProgress * Math.PI) * 0.4 + } + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.leftEye.position.x = -0.07 + parts.rightEye.position.x = 0.07 + parts.head.rotation.set(0, 0, 0) + } +} + +/** Workbench (Edit) - Using tools, tinkering */ +const tinkering: WorkingBehavior = { + name: 'tinkering', + loop: true, + duration: 2.5, + update: (parts, progress) => { + // One arm holds work, other arm uses tool + parts.leftArm.rotation.x = -0.8 + parts.leftArm.rotation.z = -0.2 + + // Right arm hammering/working motion + const workCycle = progress * 4 + const hammerPhase = workCycle % 1 + const hammerMotion = Math.sin(hammerPhase * Math.PI) * 0.6 + parts.rightArm.rotation.x = -1.0 - hammerMotion + parts.rightArm.rotation.z = 0.1 + + // Head follows the work + parts.head.rotation.x = 0.1 + parts.head.rotation.y = Math.sin(progress * Math.PI * 2) * 0.1 + + // Body slight lean into work + parts.body.rotation.x = 0.05 + + // Eyes focused + const focus = Math.sin(workCycle * Math.PI) * 0.01 + parts.leftEye.position.y = 0.03 + focus + parts.rightEye.position.y = 0.03 + focus + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + parts.body.rotation.x = 0 + parts.leftEye.position.y = 0.03 + parts.rightEye.position.y = 0.03 + } +} + +/** Desk (Write) - Writing, thinking, scratching head */ +const writing: WorkingBehavior = { + name: 'writing', + loop: true, + duration: 3, + update: (parts, progress) => { + // Writing arm motion + const writeCycle = progress * 6 + const writePhase = writeCycle % 1 + + // Right arm writing small movements + parts.rightArm.rotation.x = -1.0 + parts.rightArm.rotation.z = 0.2 + Math.sin(writePhase * Math.PI * 2) * 0.15 + parts.rightArm.rotation.y = Math.sin(writePhase * Math.PI * 4) * 0.1 + + // Left arm resting on desk + parts.leftArm.rotation.x = -0.6 + parts.leftArm.rotation.z = -0.4 + + // Head looking down at paper + parts.head.rotation.x = 0.2 + + // Occasional pause to think (every cycle) + const thinkPause = Math.floor(writeCycle) % 3 === 2 + if (thinkPause && writePhase < 0.5) { + parts.head.rotation.x = 0.05 // Look up thinking + parts.head.rotation.z = 0.1 + parts.rightArm.rotation.x = -0.8 // Pause writing + } + + // Eyes follow writing + parts.leftEye.position.x = -0.07 + Math.sin(writePhase * Math.PI * 2) * 0.01 + parts.rightEye.position.x = 0.07 + Math.sin(writePhase * Math.PI * 2) * 0.01 + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + parts.leftEye.position.x = -0.07 + parts.rightEye.position.x = 0.07 + } +} + +/** Terminal (Bash) - Typing rapidly, looking at screen */ +const typing: WorkingBehavior = { + name: 'typing', + loop: true, + duration: 2, + update: (parts, progress) => { + // Both arms in typing position + const typeCycle = progress * 12 // Fast typing + const typePhase = typeCycle % 1 + + // Alternating arm typing motions + const leftType = Math.sin(typePhase * Math.PI * 2) * 0.1 + const rightType = Math.sin((typePhase + 0.5) * Math.PI * 2) * 0.1 + + parts.leftArm.rotation.x = -0.7 + leftType + parts.leftArm.rotation.z = -0.3 + parts.rightArm.rotation.x = -0.7 + rightType + parts.rightArm.rotation.z = 0.3 + + // Eyes scanning screen + const scanX = Math.sin(progress * Math.PI * 4) * 0.02 + parts.leftEye.position.x = -0.07 + scanX + parts.rightEye.position.x = 0.07 + scanX + + // Occasional head nod (understanding output) + const nodCycle = Math.floor(progress * 4) % 4 + if (nodCycle === 3) { + parts.head.rotation.x = Math.sin((progress * 4 % 1) * Math.PI) * 0.1 + } + + // Slight forward lean (focused) + parts.body.rotation.x = 0.05 + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.leftEye.position.x = -0.07 + parts.rightEye.position.x = 0.07 + parts.head.rotation.x = 0 + parts.body.rotation.x = 0 + } +} + +/** Scanner (Grep/Glob) - Scanning, searching, peering */ +const scanning: WorkingBehavior = { + name: 'scanning', + loop: true, + duration: 3, + update: (parts, progress) => { + // Hand shading eyes, searching pose + parts.rightArm.rotation.x = -2.0 + parts.rightArm.rotation.z = 0.5 + parts.rightArm.rotation.y = -0.3 + + // Other arm at side or pointing + const pointPhase = progress * 2 + if (Math.floor(pointPhase) % 2 === 1) { + // Pointing at something found + parts.leftArm.rotation.x = -1.5 + parts.leftArm.rotation.z = -0.3 + } else { + parts.leftArm.rotation.x = 0 + parts.leftArm.rotation.z = 0 + } + + // Head scanning left to right + const scanAngle = Math.sin(progress * Math.PI * 2) * 0.3 + parts.head.rotation.y = scanAngle + + // Eyes wide, searching + parts.leftEye.scale.setScalar(1.1) + parts.rightEye.scale.setScalar(1.1) + + // Eyes follow head direction + parts.leftEye.position.x = -0.07 + scanAngle * 0.05 + parts.rightEye.position.x = 0.07 + scanAngle * 0.05 + + // Slight body turn with head + parts.body.rotation.y = scanAngle * 0.3 + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.y = 0 + parts.body.rotation.y = 0 + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + parts.leftEye.position.x = -0.07 + parts.rightEye.position.x = 0.07 + } +} + +/** Antenna (WebFetch/WebSearch) - Receiving signals, tuning */ +const receiving: WorkingBehavior = { + name: 'receiving', + loop: true, + duration: 2.5, + update: (parts, progress) => { + // Antenna actively receiving - wobbles and perks + const signalStrength = Math.sin(progress * Math.PI * 8) * 0.3 + parts.antenna.rotation.z = signalStrength + parts.antenna.rotation.x = -0.1 + Math.abs(signalStrength) * 0.2 + + // Hand to "ear" (antenna) like listening + parts.rightArm.rotation.x = -2.2 + parts.rightArm.rotation.z = 0.8 + parts.rightArm.rotation.y = 0.3 + + // Other hand adjusting/tuning gesture + const tunePhase = progress * 4 + parts.leftArm.rotation.x = -1.0 + parts.leftArm.rotation.z = -0.2 + Math.sin(tunePhase * Math.PI) * 0.2 + + // Head tilted, listening + parts.head.rotation.z = 0.15 + parts.head.rotation.y = 0.1 + + // Eyes looking up at antenna/signal direction + parts.leftEye.position.y = 0.03 + 0.01 + parts.rightEye.position.y = 0.03 + 0.01 + }, + reset: (parts) => { + parts.antenna.rotation.set(0, 0, 0) + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + parts.leftEye.position.y = 0.03 + parts.rightEye.position.y = 0.03 + } +} + +/** Portal (Task) - Mystical gestures, channeling energy */ +const channeling: WorkingBehavior = { + name: 'channeling', + loop: true, + duration: 3, + update: (parts, progress) => { + // Arms raised, channeling pose + const channelPulse = Math.sin(progress * Math.PI * 4) + + parts.leftArm.rotation.x = -1.8 + channelPulse * 0.2 + parts.leftArm.rotation.z = -0.6 + parts.rightArm.rotation.x = -1.8 - channelPulse * 0.2 + parts.rightArm.rotation.z = 0.6 + + // Hands circle slightly (channeling motion) + const circlePhase = progress * Math.PI * 2 + parts.leftArm.rotation.y = Math.sin(circlePhase) * 0.3 + parts.rightArm.rotation.y = -Math.sin(circlePhase) * 0.3 + + // Body slight sway + parts.mesh.rotation.z = Math.sin(progress * Math.PI * 2) * 0.05 + + // Head looking at portal (forward/up) + parts.head.rotation.x = -0.1 + + // Eyes glowing effect (scale pulse) + const glowPulse = 1 + Math.sin(progress * Math.PI * 6) * 0.15 + parts.leftEye.scale.setScalar(glowPulse) + parts.rightEye.scale.setScalar(glowPulse) + + // Antenna resonating + parts.antenna.rotation.x = Math.sin(progress * Math.PI * 8) * 0.15 + parts.antenna.rotation.z = Math.sin(progress * Math.PI * 6) * 0.1 + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.mesh.rotation.z = 0 + parts.head.rotation.x = 0 + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + parts.antenna.rotation.set(0, 0, 0) + } +} + +/** Taskboard (TodoWrite) - Checking items, pointing at board */ +const checkingTasks: WorkingBehavior = { + name: 'checkingTasks', + loop: true, + duration: 3.5, + update: (parts, progress) => { + const taskCycle = progress * 3 // Check 3 items + const taskPhase = taskCycle % 1 + const taskIndex = Math.floor(taskCycle) % 3 + + // Point at different board positions (high, mid, low) + const boardY = [0.3, 0, -0.3][taskIndex] + + // Right arm pointing at board + parts.rightArm.rotation.x = -1.5 + boardY * 0.5 + parts.rightArm.rotation.z = 0.3 + + // Check motion (arm moves in checkmark) + if (taskPhase > 0.6 && taskPhase < 0.9) { + const checkProgress = (taskPhase - 0.6) / 0.3 + parts.rightArm.rotation.z = 0.3 + Math.sin(checkProgress * Math.PI) * 0.3 + parts.rightArm.rotation.x += Math.sin(checkProgress * Math.PI) * 0.2 + } + + // Left arm holding clipboard/list + parts.leftArm.rotation.x = -1.0 + parts.leftArm.rotation.z = -0.4 + + // Head follows pointing + parts.head.rotation.x = -boardY * 0.15 + parts.head.rotation.y = 0.2 + + // Nod when checking off + if (taskPhase > 0.8) { + parts.head.rotation.x += Math.sin((taskPhase - 0.8) * 5 * Math.PI) * 0.1 + } + + // Eyes scanning board + parts.leftEye.position.y = 0.03 - boardY * 0.01 + parts.rightEye.position.y = 0.03 - boardY * 0.01 + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + parts.leftEye.position.y = 0.03 + parts.rightEye.position.y = 0.03 + } +} + +/** Generic working animation for unmapped stations */ +const genericWorking: WorkingBehavior = { + name: 'genericWorking', + loop: true, + duration: 2, + update: (parts, progress) => { + // Simple focused working pose + parts.leftArm.rotation.x = -0.5 + parts.rightArm.rotation.x = -0.5 + + // Slight body movement showing activity + const activity = Math.sin(progress * Math.PI * 4) * 0.03 + parts.body.rotation.x = 0.05 + activity + + // Head slight movements (thinking) + parts.head.rotation.y = Math.sin(progress * Math.PI * 2) * 0.1 + parts.head.rotation.z = Math.sin(progress * Math.PI * 3) * 0.05 + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.body.rotation.x = 0 + parts.head.rotation.set(0, 0, 0) + } +} + +// ============================================================================ +// Alternative Station Animations +// ============================================================================ + +/** Bookshelf (Read) - Speed-reading through pages frantically */ +const speedReading: WorkingBehavior = { + name: 'speedReading', + loop: true, + duration: 2.5, + update: (parts, progress) => { + // Hold book close, hunched over + parts.leftArm.rotation.x = -1.4 + parts.leftArm.rotation.z = -0.2 + parts.rightArm.rotation.x = -1.4 + parts.rightArm.rotation.z = 0.2 + + // Rapid page flipping with right hand + const flipCycle = progress * 8 + const flipPhase = flipCycle % 1 + parts.rightArm.rotation.z = 0.2 + Math.sin(flipPhase * Math.PI) * 0.5 + + // Eyes darting across pages super fast + const dartCycle = progress * 16 + const eyeX = Math.sin(dartCycle * Math.PI) * 0.03 + parts.leftEye.position.x = -0.07 + eyeX + parts.rightEye.position.x = 0.07 + eyeX + + // Eyes get wider as excitement builds + const excitement = 1 + progress * 0.2 + parts.leftEye.scale.setScalar(excitement) + parts.rightEye.scale.setScalar(excitement) + + // Head jittering slightly from speed + parts.head.rotation.x = 0.15 + Math.sin(progress * Math.PI * 12) * 0.02 + parts.head.rotation.y = Math.sin(progress * Math.PI * 6) * 0.04 + + // Antenna vibrating with brain activity + parts.antenna.rotation.z = Math.sin(progress * Math.PI * 16) * 0.12 + parts.antenna.rotation.x = -0.1 + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.leftEye.position.x = -0.07 + parts.rightEye.position.x = 0.07 + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + parts.head.rotation.set(0, 0, 0) + parts.antenna.rotation.set(0, 0, 0) + } +} + +/** Workbench (Edit) - Precision surgery: careful, delicate adjustments */ +const precisionWork: WorkingBehavior = { + name: 'precisionWork', + loop: true, + duration: 4, + update: (parts, progress) => { + // Lean in close, body forward + parts.body.rotation.x = 0.12 + + // Left hand holds steady + parts.leftArm.rotation.x = -0.9 + parts.leftArm.rotation.z = -0.3 + + // Right arm making tiny precise movements + const precisionCycle = progress * 6 + const microPhase = precisionCycle % 1 + parts.rightArm.rotation.x = -1.1 + Math.sin(microPhase * Math.PI * 2) * 0.05 + parts.rightArm.rotation.y = Math.sin(microPhase * Math.PI * 4) * 0.04 + parts.rightArm.rotation.z = 0.15 + + // Squinting eyes (focused) + parts.leftEye.scale.setScalar(0.7) + parts.rightEye.scale.setScalar(0.7) + + // Head very still, slight tilt for better view + parts.head.rotation.x = 0.2 + parts.head.rotation.z = 0.08 + + // Periodic "step back and examine" (every 75%) + if (progress > 0.7 && progress < 0.9) { + const examineP = (progress - 0.7) / 0.2 + parts.body.rotation.x = 0.12 - easeOut(examineP) * 0.15 + parts.head.rotation.x = 0.2 - easeOut(examineP) * 0.25 + parts.leftEye.scale.setScalar(0.7 + easeOut(examineP) * 0.4) + parts.rightEye.scale.setScalar(0.7 + easeOut(examineP) * 0.4) + // Nod of approval + if (examineP > 0.6) { + parts.head.rotation.x += Math.sin((examineP - 0.6) * 2.5 * Math.PI) * 0.1 + } + } + + // Antenna barely moves - deep concentration + parts.antenna.rotation.x = Math.sin(progress * Math.PI * 2) * 0.03 + }, + reset: (parts) => { + parts.body.rotation.x = 0 + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + parts.head.rotation.set(0, 0, 0) + parts.antenna.rotation.set(0, 0, 0) + } +} + +/** Desk (Write) - Calligraphy: slow, artistic, admiring work */ +const calligraphy: WorkingBehavior = { + name: 'calligraphy', + loop: true, + duration: 5, + update: (parts, progress) => { + // Left arm holds paper steady at angle + parts.leftArm.rotation.x = -0.7 + parts.leftArm.rotation.z = -0.5 + parts.leftArm.rotation.y = 0.2 + + // Right arm - slow, sweeping brush strokes + const strokeCycle = progress * 3 + const strokePhase = strokeCycle % 1 + const strokeIndex = Math.floor(strokeCycle) % 3 + + // Different stroke directions per phase + const strokes = [ + { x: -1.0, yStart: -0.3, yEnd: 0.3, zStart: 0.1, zEnd: 0.4 }, // horizontal + { x: -0.8, yStart: 0.2, yEnd: -0.2, zStart: 0.3, zEnd: 0.1 }, // diagonal + { x: -1.2, yStart: 0.0, yEnd: 0.0, zStart: 0.15, zEnd: 0.35 }, // vertical + ] + const stroke = strokes[strokeIndex] + const smooth = easeInOut(strokePhase) + parts.rightArm.rotation.x = stroke.x + parts.rightArm.rotation.y = stroke.yStart + (stroke.yEnd - stroke.yStart) * smooth + parts.rightArm.rotation.z = stroke.zStart + (stroke.zEnd - stroke.zStart) * smooth + + // Head follows the brush + parts.head.rotation.x = 0.15 + parts.head.rotation.y = parts.rightArm.rotation.y * 0.3 + + // Dip brush between strokes + if (strokePhase > 0.9) { + const dipP = (strokePhase - 0.9) / 0.1 + parts.rightArm.rotation.x += Math.sin(dipP * Math.PI) * 0.3 + } + + // Eyes track the work with satisfaction + parts.leftEye.position.x = -0.07 + parts.rightArm.rotation.y * 0.02 + parts.rightEye.position.x = 0.07 + parts.rightArm.rotation.y * 0.02 + + // Occasional admiring pause - lean back + if (progress > 0.85) { + const admireP = (progress - 0.85) / 0.15 + parts.head.rotation.x = 0.15 - easeOut(admireP) * 0.2 + parts.head.rotation.z = easeOut(admireP) * 0.1 + } + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + parts.leftEye.position.x = -0.07 + parts.rightEye.position.x = 0.07 + } +} + +/** Terminal (Bash) - Hacker mode: intense rapid typing, screen glow */ +const hackerMode: WorkingBehavior = { + name: 'hackerMode', + loop: true, + duration: 3, + update: (parts, progress) => { + // Hunched forward intensely + parts.body.rotation.x = 0.1 + + // Super fast alternating typing + const typeCycle = progress * 20 + const typePhase = typeCycle % 1 + const leftType = Math.sin(typePhase * Math.PI * 2) * 0.15 + const rightType = Math.sin((typePhase + 0.5) * Math.PI * 2) * 0.15 + + parts.leftArm.rotation.x = -0.8 + leftType + parts.leftArm.rotation.z = -0.25 + parts.rightArm.rotation.x = -0.8 + rightType + parts.rightArm.rotation.z = 0.25 + + // Eyes locked on screen, slightly wide + parts.leftEye.scale.setScalar(1.15) + parts.rightEye.scale.setScalar(1.15) + + // Eyes scan rapidly - reading output + const scanX = Math.sin(progress * Math.PI * 8) * 0.025 + parts.leftEye.position.x = -0.07 + scanX + parts.rightEye.position.x = 0.07 + scanX + parts.leftEye.position.y = 0.03 + Math.sin(progress * Math.PI * 3) * 0.008 + parts.rightEye.position.y = 0.03 + Math.sin(progress * Math.PI * 3) * 0.008 + + // Head micro-movements - processing + parts.head.rotation.y = Math.sin(progress * Math.PI * 6) * 0.04 + parts.head.rotation.x = 0.05 + + // Antenna flickers like picking up data streams + parts.antenna.rotation.z = Math.sin(progress * Math.PI * 14) * 0.08 + parts.antenna.rotation.x = Math.sin(progress * Math.PI * 10) * 0.06 + + // Dramatic pause to read output (at 60%) + if (progress > 0.55 && progress < 0.7) { + const pauseP = (progress - 0.55) / 0.15 + // Hands lift off keyboard + parts.leftArm.rotation.x = -0.8 + easeOut(pauseP) * 0.3 + parts.rightArm.rotation.x = -0.8 + easeOut(pauseP) * 0.3 + // Eyes widen more + const widen = 1.15 + easeOut(pauseP) * 0.15 + parts.leftEye.scale.setScalar(widen) + parts.rightEye.scale.setScalar(widen) + // Quick head nod (got it!) + if (pauseP > 0.7) { + parts.head.rotation.x = 0.05 + Math.sin((pauseP - 0.7) * 3.3 * Math.PI) * 0.1 + } + } + }, + reset: (parts) => { + parts.body.rotation.x = 0 + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + parts.leftEye.position.set(-0.07, 0.03, 0.242) + parts.rightEye.position.set(0.07, 0.03, 0.242) + parts.head.rotation.set(0, 0, 0) + parts.antenna.rotation.set(0, 0, 0) + } +} + +/** Terminal (Bash) - Frustrated: type, error, facepalm, retry */ +const frustratedTyping: WorkingBehavior = { + name: 'frustratedTyping', + loop: true, + duration: 4, + update: (parts, progress) => { + if (progress < 0.4) { + // Phase 1: Typing confidently + const p = progress / 0.4 + const typeCycle = p * 12 + const typePhase = typeCycle % 1 + + parts.leftArm.rotation.x = -0.7 + Math.sin(typePhase * Math.PI * 2) * 0.1 + parts.leftArm.rotation.z = -0.3 + parts.rightArm.rotation.x = -0.7 + Math.sin((typePhase + 0.5) * Math.PI * 2) * 0.1 + parts.rightArm.rotation.z = 0.3 + parts.head.rotation.x = 0.05 + parts.body.rotation.x = 0.05 + } else if (progress < 0.55) { + // Phase 2: Error! Recoil + const p = (progress - 0.4) / 0.15 + const recoil = easeOut(p) + parts.body.rotation.x = 0.05 - recoil * 0.15 + parts.head.rotation.x = 0.05 - recoil * 0.2 + // Eyes go wide + parts.leftEye.scale.setScalar(1 + recoil * 0.3) + parts.rightEye.scale.setScalar(1 + recoil * 0.3) + // Arms up in surprise + parts.leftArm.rotation.x = -0.7 - recoil * 0.5 + parts.rightArm.rotation.x = -0.7 - recoil * 0.5 + parts.leftArm.rotation.z = -0.3 - recoil * 0.3 + parts.rightArm.rotation.z = 0.3 + recoil * 0.3 + } else if (progress < 0.75) { + // Phase 3: Facepalm / head shake + const p = (progress - 0.55) / 0.2 + // Right arm to face + parts.rightArm.rotation.x = -2.0 + parts.rightArm.rotation.z = 0.3 + parts.leftArm.rotation.x = -0.3 + parts.leftArm.rotation.z = -0.1 + // Slow head shake + parts.head.rotation.y = Math.sin(p * Math.PI * 3) * 0.2 + parts.head.rotation.x = 0.1 + // Eyes squint + parts.leftEye.scale.setScalar(0.6) + parts.rightEye.scale.setScalar(0.6) + parts.body.rotation.x = -0.05 + } else { + // Phase 4: Deep breath, back to typing with determination + const p = (progress - 0.75) / 0.25 + const recovery = easeInOut(p) + parts.rightArm.rotation.x = -2.0 + recovery * 1.3 + parts.rightArm.rotation.z = 0.3 + parts.leftArm.rotation.x = -0.3 - recovery * 0.4 + parts.leftArm.rotation.z = -0.1 - recovery * 0.2 + parts.head.rotation.y = 0 + parts.head.rotation.x = 0.1 - recovery * 0.05 + parts.body.rotation.x = -0.05 + recovery * 0.1 + // Eyes normalize + parts.leftEye.scale.setScalar(0.6 + recovery * 0.4) + parts.rightEye.scale.setScalar(0.6 + recovery * 0.4) + // Antenna perks up - renewed determination + parts.antenna.rotation.x = -recovery * 0.15 + } + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + parts.body.rotation.x = 0 + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + parts.antenna.rotation.set(0, 0, 0) + } +} + +/** Scanner (Grep/Glob) - Detective: magnifying glass, crouching, examining */ +const detective: WorkingBehavior = { + name: 'detective', + loop: true, + duration: 4, + update: (parts, progress) => { + // Crouching posture + parts.body.rotation.x = 0.1 + + // Right arm holding "magnifying glass" up to eye + parts.rightArm.rotation.x = -1.8 + parts.rightArm.rotation.z = 0.4 + parts.rightArm.rotation.y = -0.2 + + // Left arm behind back (classic detective) + parts.leftArm.rotation.x = 0.3 + parts.leftArm.rotation.z = -0.4 + parts.leftArm.rotation.y = 0.5 + + // Move magnifying glass around examining things + const examCycle = progress * 2 + const examPhase = examCycle % 1 + + if (Math.floor(examCycle) % 2 === 0) { + // Examining left side + const sweep = easeInOut(examPhase) + parts.head.rotation.y = 0.15 + sweep * 0.2 + parts.rightArm.rotation.y = -0.2 + sweep * 0.3 + parts.body.rotation.y = sweep * 0.1 + } else { + // Examining right side + const sweep = easeInOut(examPhase) + parts.head.rotation.y = 0.35 - sweep * 0.5 + parts.rightArm.rotation.y = 0.1 - sweep * 0.4 + parts.body.rotation.y = 0.1 - sweep * 0.2 + } + + // One eye big (looking through glass), one normal + parts.rightEye.scale.setScalar(1.3) + parts.leftEye.scale.setScalar(0.85) + + // Antenna twitching - picking up clues + parts.antenna.rotation.z = Math.sin(progress * Math.PI * 6) * 0.1 + parts.antenna.rotation.x = -0.1 + + // Periodic "aha!" moment + if (progress > 0.85 && progress < 0.95) { + const ahaP = (progress - 0.85) / 0.1 + parts.antenna.rotation.x = -0.1 - easeOut(ahaP) * 0.2 + parts.leftEye.scale.setScalar(0.85 + easeOut(ahaP) * 0.35) + parts.head.rotation.x = -easeOut(ahaP) * 0.15 + } + }, + reset: (parts) => { + parts.body.rotation.set(0, 0, 0) + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + parts.antenna.rotation.set(0, 0, 0) + } +} + +/** Antenna (WebFetch) - Broadcasting: arms up like an antenna, sending signals */ +const broadcasting: WorkingBehavior = { + name: 'broadcasting', + loop: true, + duration: 3, + update: (parts, progress) => { + // Both arms up like antenna receivers + const pulse = Math.sin(progress * Math.PI * 4) + parts.leftArm.rotation.x = -2.5 + parts.leftArm.rotation.z = -0.5 + pulse * 0.15 + parts.rightArm.rotation.x = -2.5 + parts.rightArm.rotation.z = 0.5 - pulse * 0.15 + + // Body sways with signal + parts.body.rotation.z = Math.sin(progress * Math.PI * 2) * 0.06 + parts.mesh.rotation.z = Math.sin(progress * Math.PI * 2) * 0.03 + + // Antenna goes wild - transmitting + parts.antenna.rotation.z = Math.sin(progress * Math.PI * 12) * 0.2 + parts.antenna.rotation.x = Math.sin(progress * Math.PI * 8) * 0.15 + + // Head tilts with signal direction + parts.head.rotation.z = Math.sin(progress * Math.PI * 3) * 0.1 + parts.head.rotation.y = Math.sin(progress * Math.PI * 2) * 0.1 + + // Eyes pulse like they're emitting/receiving light + const eyePulse = 0.9 + Math.sin(progress * Math.PI * 6) * 0.2 + parts.leftEye.scale.setScalar(eyePulse) + parts.rightEye.scale.setScalar(eyePulse) + + // Signal burst moments + if (progress > 0.45 && progress < 0.55) { + const burstP = (progress - 0.45) / 0.1 + const burst = Math.sin(burstP * Math.PI) + parts.leftArm.rotation.z = -0.5 - burst * 0.3 + parts.rightArm.rotation.z = 0.5 + burst * 0.3 + parts.leftEye.scale.setScalar(eyePulse + burst * 0.3) + parts.rightEye.scale.setScalar(eyePulse + burst * 0.3) + } + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.body.rotation.z = 0 + parts.mesh.rotation.z = 0 + parts.antenna.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + } +} + +/** Portal (Task) - Summoning: drawing circles, building energy, dramatic release */ +const summoning: WorkingBehavior = { + name: 'summoning', + loop: true, + duration: 4, + update: (parts, progress) => { + if (progress < 0.6) { + // Phase 1: Drawing summoning circles with arms + const p = progress / 0.6 + const circlePhase = p * Math.PI * 4 + + // Arms trace circles in opposite directions + parts.leftArm.rotation.x = -1.5 + Math.sin(circlePhase) * 0.4 + parts.leftArm.rotation.z = -0.4 + Math.cos(circlePhase) * 0.3 + parts.leftArm.rotation.y = Math.sin(circlePhase) * 0.3 + parts.rightArm.rotation.x = -1.5 - Math.sin(circlePhase) * 0.4 + parts.rightArm.rotation.z = 0.4 - Math.cos(circlePhase) * 0.3 + parts.rightArm.rotation.y = -Math.sin(circlePhase) * 0.3 + + // Head focused on the portal + parts.head.rotation.x = -0.05 + parts.head.rotation.y = Math.sin(p * Math.PI * 2) * 0.05 + + // Eyes focused and intense + parts.leftEye.scale.setScalar(1.1) + parts.rightEye.scale.setScalar(1.1) + + // Body slowly rising + parts.body.rotation.x = -p * 0.05 + + // Antenna building charge + parts.antenna.rotation.z = Math.sin(circlePhase * 2) * (0.05 + p * 0.1) + } else if (progress < 0.8) { + // Phase 2: Energy gathered - arms pull in, building power + const p = (progress - 0.6) / 0.2 + const gather = easeInOut(p) + + parts.leftArm.rotation.x = -1.5 + gather * 0.3 + parts.leftArm.rotation.z = -0.4 + gather * 0.2 + parts.rightArm.rotation.x = -1.5 + gather * 0.3 + parts.rightArm.rotation.z = 0.4 - gather * 0.2 + + // Crouch down to build energy + parts.body.rotation.x = -0.05 + gather * 0.12 + + // Eyes squinting - concentration + parts.leftEye.scale.setScalar(1.1 - gather * 0.3) + parts.rightEye.scale.setScalar(1.1 - gather * 0.3) + + // Antenna vibrates rapidly + parts.antenna.rotation.z = Math.sin(p * Math.PI * 20) * 0.15 + } else { + // Phase 3: Release! Arms thrown wide, dramatic + const p = (progress - 0.8) / 0.2 + const release = elastic(Math.min(p * 1.5, 1)) + + parts.leftArm.rotation.x = -1.2 - release * 1.0 + parts.leftArm.rotation.z = -0.2 - release * 0.8 + parts.rightArm.rotation.x = -1.2 - release * 1.0 + parts.rightArm.rotation.z = 0.2 + release * 0.8 + + // Head thrown back + parts.head.rotation.x = release * -0.2 + parts.body.rotation.x = 0.07 - release * 0.1 + + // Eyes wide with power + const widen = 1 + release * 0.4 + parts.leftEye.scale.setScalar(Math.min(widen, 1.4)) + parts.rightEye.scale.setScalar(Math.min(widen, 1.4)) + + // Antenna springs up + parts.antenna.rotation.x = -release * 0.3 + } + }, + reset: (parts) => { + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + parts.body.rotation.x = 0 + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + parts.antenna.rotation.set(0, 0, 0) + } +} + +/** Taskboard (TodoWrite) - Panicking: frantically checking items, overwhelmed */ +const panicking: WorkingBehavior = { + name: 'panicking', + loop: true, + duration: 3, + update: (parts, progress) => { + const panicCycle = progress * 5 + const panicPhase = panicCycle % 1 + + // Head darting between items + const item = Math.floor(panicCycle) % 5 + const headTargets = [-0.3, 0.2, -0.1, 0.3, -0.2] + const headYTargets = [0.3, 0.15, 0.25, 0.1, 0.35] + parts.head.rotation.y = headTargets[item] + Math.sin(panicPhase * Math.PI) * 0.05 + parts.head.rotation.x = headYTargets[item] * -0.3 + + // Frantic arm movements - checking things off + parts.rightArm.rotation.x = -1.5 + Math.sin(panicPhase * Math.PI * 2) * 0.4 + parts.rightArm.rotation.z = 0.3 + Math.sin(panicPhase * Math.PI * 3) * 0.2 + + // Left arm holding head in distress periodically + if (item % 3 === 2) { + parts.leftArm.rotation.x = -2.0 + parts.leftArm.rotation.z = -0.5 + } else { + parts.leftArm.rotation.x = -0.8 + parts.leftArm.rotation.z = -0.3 + } + + // Eyes wide with stress + parts.leftEye.scale.setScalar(1.2) + parts.rightEye.scale.setScalar(1.2) + + // Body jittery + parts.body.rotation.y = Math.sin(progress * Math.PI * 10) * 0.04 + parts.body.rotation.x = 0.05 + + // Antenna drooping from stress + parts.antenna.rotation.x = 0.15 + parts.antenna.rotation.z = Math.sin(progress * Math.PI * 8) * 0.08 + }, + reset: (parts) => { + parts.head.rotation.set(0, 0, 0) + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + parts.body.rotation.set(0, 0, 0) + parts.antenna.rotation.set(0, 0, 0) + } +} + +/** Center - Deep focus: hunched, minimal movement, laser concentration */ +const deepFocus: WorkingBehavior = { + name: 'deepFocus', + loop: true, + duration: 5, + update: (parts, progress) => { + // Very still, hunched forward + parts.body.rotation.x = 0.08 + + // Arms in "thinking" position - one on chin, one crossed + parts.rightArm.rotation.x = -1.8 + parts.rightArm.rotation.z = 0.3 + parts.rightArm.rotation.y = -0.2 + parts.leftArm.rotation.x = -0.6 + parts.leftArm.rotation.z = -0.4 + + // Head barely moves - deep thought + parts.head.rotation.x = 0.08 + parts.head.rotation.y = Math.sin(progress * Math.PI * 0.5) * 0.03 + + // Eyes narrow - intense focus + parts.leftEye.scale.setScalar(0.75) + parts.rightEye.scale.setScalar(0.75) + + // Occasional eye shift (considering different angles) + const thinkCycle = progress * 3 + if (Math.floor(thinkCycle) % 3 === 1) { + const lookP = thinkCycle % 1 + parts.leftEye.position.x = -0.07 + Math.sin(lookP * Math.PI) * 0.015 + parts.rightEye.position.x = 0.07 + Math.sin(lookP * Math.PI) * 0.015 + parts.leftEye.position.y = 0.03 + Math.sin(lookP * Math.PI) * 0.008 + parts.rightEye.position.y = 0.03 + Math.sin(lookP * Math.PI) * 0.008 + } + + // Antenna very slow sway - subconscious processing + parts.antenna.rotation.z = Math.sin(progress * Math.PI) * 0.04 + parts.antenna.rotation.x = -0.05 + + // Very subtle breathing motion + const breathe = Math.sin(progress * Math.PI * 2) * 0.01 + parts.body.rotation.x = 0.08 + breathe + }, + reset: (parts) => { + parts.body.rotation.x = 0 + parts.leftArm.rotation.set(0, 0, 0) + parts.rightArm.rotation.set(0, 0, 0) + parts.head.rotation.set(0, 0, 0) + parts.leftEye.scale.setScalar(1) + parts.rightEye.scale.setScalar(1) + parts.leftEye.position.set(-0.07, 0.03, 0.242) + parts.rightEye.position.set(0.07, 0.03, 0.242) + parts.antenna.rotation.set(0, 0, 0) + } +} + +// ============================================================================ +// Station to Animation Mapping +// ============================================================================ + +export const STATION_ANIMATIONS: StationAnimations = { + bookshelf: [readingBook, speedReading], + workbench: [tinkering, precisionWork], + desk: [writing, calligraphy], + terminal: [typing, hackerMode, frustratedTyping], + scanner: [scanning, detective], + antenna: [receiving, broadcasting], + portal: [channeling, summoning], + taskboard: [checkingTasks, panicking], + center: [genericWorking, deepFocus], +} + +// ============================================================================ +// Working Behavior Manager +// ============================================================================ + +export class WorkingBehaviorManager { + private currentBehavior: WorkingBehavior | null = null + private behaviorProgress = 0 + private currentStation: string | null = null + + /** + * Start a working animation for a specific station. + * Randomly selects from available animations for that station. + */ + start(station: string, parts: CharacterParts): void { + // Stop current behavior if any + if (this.currentBehavior) { + this.currentBehavior.reset?.(parts) + } + + // Get animation pool for this station + const pool = STATION_ANIMATIONS[station] ?? STATION_ANIMATIONS.center + // Randomly pick one + this.currentBehavior = pool[Math.floor(Math.random() * pool.length)] + this.currentStation = station + this.behaviorProgress = 0 + + // Store original positions + parts.mesh.userData.originalX = parts.mesh.position.x + parts.mesh.userData.originalY = parts.mesh.position.y + } + + /** + * Stop the current working animation + */ + stop(parts: CharacterParts): void { + if (this.currentBehavior) { + this.currentBehavior.reset?.(parts) + this.currentBehavior = null + this.currentStation = null + this.behaviorProgress = 0 + } + } + + /** + * Update the working animation + * @returns true if animation is playing + */ + update(parts: CharacterParts, deltaTime: number): boolean { + if (!this.currentBehavior) return false + + this.behaviorProgress += deltaTime / this.currentBehavior.duration + + // Loop the animation + if (this.behaviorProgress >= 1) { + if (this.currentBehavior.loop) { + this.behaviorProgress = this.behaviorProgress % 1 + } else { + this.stop(parts) + return false + } + } + + this.currentBehavior.update(parts, this.behaviorProgress, deltaTime) + return true + } + + /** + * Check if currently playing + */ + isPlaying(): boolean { + return this.currentBehavior !== null + } + + /** + * Get current station being animated + */ + getCurrentStation(): string | null { + return this.currentStation + } + + /** + * Get current behavior name + */ + getCurrentBehaviorName(): string | null { + return this.currentBehavior?.name ?? null + } +} diff --git a/src/workshop/entities/animations/index.ts b/src/workshop/entities/animations/index.ts new file mode 100644 index 0000000..4a26273 --- /dev/null +++ b/src/workshop/entities/animations/index.ts @@ -0,0 +1,42 @@ +/** + * Character Animations - Barrel Export + * + * Import everything animation-related from here: + * + * ```typescript + * import { + * // Types + * CharacterParts, + * IdleBehavior, + * WorkingBehavior, + * AnimationCategory, + * + * // Easing functions + * easeInOut, + * easeOut, + * bounce, + * + * // Utilities + * lerp, + * clamp, + * resetToDefaultPose, + * + * // Managers + * IdleBehaviorManager, + * WorkingBehaviorManager, + * + * // Registries + * IDLE_BEHAVIORS, + * STATION_ANIMATIONS, + * } from './animations' + * ``` + */ + +// Types and utilities +export * from './AnimationTypes' + +// Idle behaviors +export { IDLE_BEHAVIORS, IdleBehaviorManager } from './IdleBehaviors' + +// Working behaviors +export { STATION_ANIMATIONS, WorkingBehaviorManager } from './WorkingBehaviors' diff --git a/src/workshop/events/EventBus.ts b/src/workshop/events/EventBus.ts new file mode 100644 index 0000000..ff311fd --- /dev/null +++ b/src/workshop/events/EventBus.ts @@ -0,0 +1,114 @@ +/** + * EventBus — Vendored from Vibecraft2, adapted for ClawBox embedding. + * Removed singleton export; instances are created per workshop lifecycle. + */ + +import type { + PreToolUseEvent, + PostToolUseEvent, + StopEvent, + UserPromptSubmitEvent, + ClaudeEvent, + ToolStartEvent, + ToolEndEvent, + AgentIdleEvent, + AgentThinkingEvent, + UserInputEvent, + AgentNotificationEvent, + SubagentSpawnEvent, + SubagentEndEvent, +} from '../types' +import type { WorkshopScene } from '../scene/WorkshopScene' + +// ============================================================================ +// Types +// ============================================================================ + +export interface EventContext { + scene: WorkshopScene | null + session: SessionContext | null + soundEnabled: boolean +} + +export interface SessionContext { + id: string + color: number + claude: any + subagents: any + zone: any + stats: { + toolsUsed: number + filesTouched: Set + activeSubagents: number + } +} + +export interface EventTypeMap { + 'pre_tool_use': PreToolUseEvent + 'post_tool_use': PostToolUseEvent + 'stop': StopEvent + 'user_prompt_submit': UserPromptSubmitEvent + 'session_start': ClaudeEvent + 'notification': ClaudeEvent + 'tool_start': ToolStartEvent + 'tool_end': ToolEndEvent + 'agent_idle': AgentIdleEvent + 'agent_thinking': AgentThinkingEvent + 'user_input': UserInputEvent + 'agent_notification': AgentNotificationEvent + 'subagent_spawn': SubagentSpawnEvent + 'subagent_end': SubagentEndEvent +} + +export type EventType = keyof EventTypeMap + +export type EventHandler = ( + event: EventTypeMap[T], + context: EventContext +) => void + +// ============================================================================ +// EventBus Class +// ============================================================================ + +export class EventBus { + private handlers: Map>> = new Map() + + on(type: T, handler: EventHandler): () => void { + if (!this.handlers.has(type)) { + this.handlers.set(type, new Set()) + } + this.handlers.get(type)!.add(handler) + return () => { + this.handlers.get(type)?.delete(handler) + } + } + + emit(type: T, event: EventTypeMap[T], context: EventContext): void { + const handlers = this.handlers.get(type) + if (!handlers) return + + for (const handler of handlers) { + try { + handler(event, context) + } catch (error) { + console.error(`[Workshop EventBus] Error in handler for ${type}:`, error) + } + } + } + + off(type: EventType): void { + this.handlers.delete(type) + } + + clear(): void { + this.handlers.clear() + } + + getHandlerCount(type?: EventType): number { + if (type) return this.handlers.get(type)?.size ?? 0 + let total = 0 + for (const handlers of this.handlers.values()) total += handlers.size + return total + } +} diff --git a/src/workshop/events/handlers/animationHandlers.ts b/src/workshop/events/handlers/animationHandlers.ts new file mode 100644 index 0000000..008690e --- /dev/null +++ b/src/workshop/events/handlers/animationHandlers.ts @@ -0,0 +1,102 @@ +/** + * Animation Handlers + * + * Triggers context-aware animations based on tool completion. + * Claude reacts to what just happened with relevant idle animations. + * + * Vendored from Vibecraft2, adapted for ClawBox embedding. + * - EventBus passed as parameter (no singleton) + * - Types imported from ../../types + */ + +import type { EventBus } from '../EventBus' +import type { PostToolUseEvent } from '../../types' + +/** Inline BashToolInput shape (not exported from types) */ +interface BashToolInput { + command: string + description?: string +} + +// Command patterns for detecting specific bash operations +const PATTERNS = { + gitCommit: /\bgit\s+commit\b/, + gitPush: /\bgit\s+push\b/, + npmTest: /\b(npm\s+(run\s+)?test|jest|vitest|pytest|go\s+test|bun\s+test)\b/, + npmBuild: /\b(npm\s+run\s+build|make\b|cargo\s+build|tsc\b|vite\s+build)\b/, +} + +// Track recent reads for "exploring" detection +let recentReadCount = 0 +let recentReadTimer: ReturnType | null = null + +/** + * Pick an animation based on what just happened + */ +function pickAnimation(event: PostToolUseEvent): string | null { + const { tool, toolInput, success, duration } = event + + // Bash command patterns - most specific first + if (tool === 'Bash') { + const cmd = (toolInput as unknown as BashToolInput).command || '' + + // Git operations + if (PATTERNS.gitCommit.test(cmd) && success) return 'victoryDance' + if (PATTERNS.gitPush.test(cmd) && success) return 'wave' + + // Test results - animate regardless of other factors + if (PATTERNS.npmTest.test(cmd)) { + return success ? 'happyBounce' : 'headShake' + } + + // Build completion + if (PATTERNS.npmBuild.test(cmd) && success) return 'stretch' + } + + // Long-running commands make Claude sleepy + if (duration && duration > 10000) return 'sleepyNod' + + // Write = created something new + if (tool === 'Write' && success) return 'happyBounce' + + // Track reads for "exploring" behavior + if (tool === 'Read' && success) { + recentReadCount++ + if (recentReadTimer) clearTimeout(recentReadTimer) + recentReadTimer = setTimeout(() => { + recentReadCount = 0 + }, 5000) // Reset after 5s of no reads + + // Many reads in quick succession = exploring + if (recentReadCount >= 5) { + recentReadCount = 0 + return 'curiousTilt' + } + } + + // Generic failure - but not for every tool (too noisy) + // Only for important tools where failure is notable + if (!success && ['Bash', 'Write', 'Edit'].includes(tool)) { + return 'headShake' + } + + return null // No special animation +} + +export function registerAnimationHandlers(bus: EventBus): void { + bus.on('post_tool_use', (event: PostToolUseEvent, ctx) => { + // Need a character to animate + if (!ctx.session?.claude) return + + const animation = pickAnimation(event) + if (animation) { + // Small delay so character settles into idle first + setTimeout(() => { + // Re-check session exists (could be gone after delay) + if (ctx.session?.claude) { + ctx.session.claude.playIdleBehavior(animation) + } + }, 300) + } + }) +} diff --git a/src/workshop/events/handlers/characterHandlers.ts b/src/workshop/events/handlers/characterHandlers.ts new file mode 100644 index 0000000..3dce85d --- /dev/null +++ b/src/workshop/events/handlers/characterHandlers.ts @@ -0,0 +1,150 @@ +/** + * Character Movement Event Handlers + * + * Handles Claude character movement in response to tool use events. + * Moves character to appropriate stations and sets context labels. + * + * Supports both legacy Claude Code events (pre_tool_use/post_tool_use) + * and universal agent protocol events (tool_start/tool_end). + * + * Vendored from Vibecraft2, adapted for ClawBox embedding. + * - EventBus passed as parameter (no singleton) + * - Types imported from ../../types + * - Sound references removed + */ + +import type { EventBus } from '../EventBus' +import { getToolContext } from '../../utils/ToolUtils' +import { getStationForTool, getStationForCategory } from '../../types' +import type { + StationType, + PreToolUseEvent, + PostToolUseEvent, + StopEvent, + UserPromptSubmitEvent, + ToolStartEvent, + ToolEndEvent, + AgentIdleEvent, + UserInputEvent, + ToolCategory, +} from '../../types' + +/** + * Resolve station from either a tool name (legacy) or tool category (universal). + */ +function resolveStation(toolName: string, category?: string): StationType { + // If category is available, prefer it (universal protocol) + if (category) { + return getStationForCategory(category as ToolCategory) as StationType + } + // Fall back to tool name lookup (legacy) + return getStationForTool(toolName) +} + +/** + * Register character movement event handlers + */ +export function registerCharacterHandlers(bus: EventBus): void { + // Move character to station when tool starts (legacy) + bus.on('pre_tool_use', (event: PreToolUseEvent, ctx) => { + if (!ctx.session) return + + const station = resolveStation(event.tool) + + // Move character to station (skip 'center' - those are MCP browser tools) + if (station !== 'center') { + const zoneStation = ctx.session.zone.stations.get(station) + if (zoneStation) { + ctx.session.claude.moveToPosition(zoneStation.position, station) + } + } + + // Set context text above station + if (ctx.scene && station !== 'center') { + const context = getToolContext(event.tool, event.toolInput) + if (context) { + ctx.scene.setStationContext(station, context, event.sessionId) + } + + // Pulse station ring to highlight activity + ctx.scene.pulseStation(event.sessionId, station) + } + }) + + // Move character to station when tool starts (universal) + bus.on('tool_start', (event: ToolStartEvent, ctx) => { + if (!ctx.session) return + + const station = resolveStation(event.tool.name, event.tool.category) + + if (station !== 'center') { + const zoneStation = ctx.session.zone.stations.get(station) + if (zoneStation) { + ctx.session.claude.moveToPosition(zoneStation.position, station) + } + } + + if (ctx.scene && station !== 'center') { + const context = event.context || getToolContext(event.tool.name, event.input || {}) + if (context) { + ctx.scene.setStationContext(station, context, event.agentId) + } + ctx.scene.pulseStation(event.agentId, station) + } + }) + + // Set idle state when tool completes (legacy) + bus.on('post_tool_use', (_event: PostToolUseEvent, ctx) => { + if (!ctx.session) return + + // Only set idle if character isn't walking + if (ctx.session.claude.state !== 'walking') { + ctx.session.claude.setState('idle') + } + }) + + // Set idle state when tool completes (universal) + bus.on('tool_end', (_event: ToolEndEvent, ctx) => { + if (!ctx.session) return + if (ctx.session.claude.state !== 'walking') { + ctx.session.claude.setState('idle') + } + }) + + // Move character back to center when stopped (legacy) + bus.on('stop', (event: StopEvent, ctx) => { + if (!ctx.session || !ctx.scene) return + + // Move to zone center + const centerStation = ctx.session.zone.stations.get('center') + if (centerStation) { + ctx.session.claude.moveToPosition(centerStation.position, 'center') + } + + // Clear station context labels + ctx.scene.clearAllContexts(event.sessionId) + }) + + // Move character back to center when idle (universal) + bus.on('agent_idle', (event: AgentIdleEvent, ctx) => { + if (!ctx.session || !ctx.scene) return + + const centerStation = ctx.session.zone.stations.get('center') + if (centerStation) { + ctx.session.claude.moveToPosition(centerStation.position, 'center') + } + ctx.scene.clearAllContexts(event.agentId) + }) + + // Set thinking state when user submits prompt (legacy) + bus.on('user_prompt_submit', (_event: UserPromptSubmitEvent, ctx) => { + if (!ctx.session) return + ctx.session.claude.setState('thinking') + }) + + // Set thinking state when user sends input (universal) + bus.on('user_input', (_event: UserInputEvent, ctx) => { + if (!ctx.session) return + ctx.session.claude.setState('thinking') + }) +} diff --git a/src/workshop/events/handlers/feedHandlers.ts b/src/workshop/events/handlers/feedHandlers.ts new file mode 100644 index 0000000..3e6fc01 --- /dev/null +++ b/src/workshop/events/handlers/feedHandlers.ts @@ -0,0 +1,35 @@ +/** + * Feed/UI Event Handlers + * + * Handles thinking indicator visibility in the activity feed. + * + * Vendored from Vibecraft2, adapted for ClawBox embedding. + * - EventBus passed as parameter (no singleton) + * - Types imported from ../../types + * - FeedManager DOM references removed (no-op stubs for now; + * will be wired to React state later) + */ + +import type { EventBus } from '../EventBus' +import type { PreToolUseEvent, StopEvent } from '../../types' + +/** + * Register feed-related event handlers + */ +export function registerFeedHandlers(bus: EventBus): void { + // Hide thinking indicator when tool starts + // NOTE: feedManager removed; this is a no-op until wired to React state + bus.on('pre_tool_use', (_event: PreToolUseEvent, _ctx) => { + // Original: ctx.feedManager.hideThinking(_event.sessionId) + // Will be wired to React state later + }) + + // Hide thinking indicator on stop + bus.on('stop', (_event: StopEvent, _ctx) => { + // Original: ctx.feedManager.hideThinking(event.sessionId) + // Will be wired to React state later + }) + + // NOTE: showThinking for user_prompt_submit is handled in main.ts + // AFTER feedManager.add() to ensure correct ordering in the feed +} diff --git a/src/workshop/events/handlers/index.ts b/src/workshop/events/handlers/index.ts new file mode 100644 index 0000000..a402e3d --- /dev/null +++ b/src/workshop/events/handlers/index.ts @@ -0,0 +1,42 @@ +/** + * Event Handlers - Barrel Export + * + * Import and call registerAllHandlers(bus) to set up EventBus handlers. + * + * Vendored from Vibecraft2, adapted for ClawBox embedding. + * - All register functions accept an EventBus instance (no singleton). + */ + +import type { EventBus } from '../EventBus' +import { registerSoundHandlers } from './soundHandlers' +import { registerNotificationHandlers } from './notificationHandlers' +import { registerCharacterHandlers } from './characterHandlers' +import { registerSubagentHandlers } from './subagentHandlers' +import { registerZoneHandlers } from './zoneHandlers' +import { registerFeedHandlers } from './feedHandlers' +import { registerAnimationHandlers } from './animationHandlers' + +/** + * Register all EventBus handlers + * Call this once during app initialization + */ +export function registerAllHandlers(bus: EventBus): void { + registerSoundHandlers(bus) + registerNotificationHandlers(bus) + registerCharacterHandlers(bus) + registerSubagentHandlers(bus) + registerZoneHandlers(bus) + registerFeedHandlers(bus) + registerAnimationHandlers(bus) +} + +// Re-export individual registrations for testing +export { + registerSoundHandlers, + registerNotificationHandlers, + registerCharacterHandlers, + registerSubagentHandlers, + registerZoneHandlers, + registerFeedHandlers, + registerAnimationHandlers, +} diff --git a/src/workshop/events/handlers/notificationHandlers.ts b/src/workshop/events/handlers/notificationHandlers.ts new file mode 100644 index 0000000..ca1e06e --- /dev/null +++ b/src/workshop/events/handlers/notificationHandlers.ts @@ -0,0 +1,227 @@ +/** + * Zone Notification Event Handlers + * + * Shows floating notifications above zones when tools complete. + * Uses ZoneNotifications system for tool-specific styling. + * + * Supports both legacy Claude Code events and universal agent protocol events. + * Universal events use category-based notification formatting. + * + * Vendored from Vibecraft2, adapted for ClawBox embedding. + * - EventBus passed as parameter (no singleton) + * - Types imported from ../../types + * - Sound references removed + * - Inline notification formatting (ZoneNotifications not yet vendored) + */ + +import type { EventBus } from '../EventBus' +import type { + PostToolUseEvent, + StationType, + ToolEndEvent, + ToolCategory, +} from '../../types' +import { getStationForTool, getStationForCategory } from '../../types' + +// ============================================================================ +// Notification text formatting helpers +// (Inlined from Vibecraft2 scene/ZoneNotifications — not yet vendored) +// ============================================================================ + +function formatFileChange( + fileName: string, + info: { added?: number; removed?: number; lines?: number }, +): string { + if (info.lines !== undefined) { + return `${fileName} (${info.lines} lines)` + } + const parts: string[] = [fileName] + if (info.added) parts.push(`+${info.added}`) + if (info.removed) parts.push(`-${info.removed}`) + return parts.join(' ') +} + +function formatCommandResult(command: string): string { + // Truncate long commands + const trimmed = command.trim() + if (trimmed.length > 40) return trimmed.slice(0, 37) + '...' + return trimmed +} + +function formatSearchResult(pattern: string): string { + if (pattern.length > 30) return pattern.slice(0, 27) + '...' + return pattern +} + +// ============================================================================ +// Legacy (tool-name based) notification text +// ============================================================================ + +/** + * Extract notification text from tool input based on tool name (legacy). + */ +function getNotificationTextByTool(tool: string, input: Record): string | null { + switch (tool) { + case 'Edit': { + const filePath = input.file_path as string | undefined + if (filePath) { + const fileName = filePath.split('/').pop() || filePath + const oldStr = input.old_string as string | undefined + const newStr = input.new_string as string | undefined + if (oldStr && newStr) { + const oldLines = (oldStr.match(/\n/g) || []).length + 1 + const newLines = (newStr.match(/\n/g) || []).length + 1 + const added = Math.max(0, newLines - oldLines) + const removed = Math.max(0, oldLines - newLines) + return formatFileChange(fileName, { added, removed }) + } + return fileName + } + return null + } + case 'Write': { + const filePath = input.file_path as string | undefined + if (filePath) { + const fileName = filePath.split('/').pop() || filePath + const content = input.content as string | undefined + if (content) { + const lines = (content.match(/\n/g) || []).length + 1 + return formatFileChange(fileName, { lines }) + } + return fileName + } + return null + } + case 'Read': { + const filePath = input.file_path as string | undefined + if (filePath) return filePath.split('/').pop() || filePath + return null + } + case 'Bash': { + const command = input.command as string | undefined + return command ? formatCommandResult(command) : null + } + case 'Grep': + case 'Glob': { + const pattern = input.pattern as string | undefined + return pattern ? formatSearchResult(pattern) : null + } + case 'WebFetch': + case 'WebSearch': { + const url = input.url as string | undefined + const query = input.query as string | undefined + if (url) { + try { return new URL(url).hostname } catch { return url.slice(0, 30) } + } + if (query) return formatSearchResult(query) + return null + } + case 'Task': { + const description = input.description as string | undefined + return description ? description.slice(0, 25) : null + } + case 'TodoWrite': { + const todos = input.todos as Array<{ content?: string }> | undefined + return (todos && todos.length > 0) ? `${todos.length} items` : null + } + default: + return null + } +} + +// ============================================================================ +// Universal (category-based) notification text +// ============================================================================ + +/** + * Extract notification text from tool output based on category (universal). + */ +function getNotificationTextByCategory( + category: ToolCategory, + toolName: string, + output?: Record, +): string | null { + // Use the tool name as a basic notification + const name = toolName.length > 25 ? toolName.slice(0, 25) + '...' : toolName + switch (category) { + case 'read': + case 'write': + case 'edit': + return output?.file_path + ? (output.file_path as string).split('/').pop() || name + : name + case 'execute': + return output?.command + ? formatCommandResult(output.command as string) + : name + case 'search': + return output?.pattern + ? formatSearchResult(output.pattern as string) + : name + case 'network': + if (output?.url) { + try { return new URL(output.url as string).hostname } catch { /* fallthrough */ } + } + return name + case 'delegate': + return output?.description + ? (output.description as string).slice(0, 25) + : name + case 'plan': + return name + default: + return name + } +} + +// ============================================================================ +// Handler registration +// ============================================================================ + +/** + * Register notification-related event handlers + */ +export function registerNotificationHandlers(bus: EventBus): void { + // Tool completion notifications (legacy) + bus.on('post_tool_use', (event: PostToolUseEvent, ctx) => { + if (!event.success || !ctx.scene) return + + const input = event.toolInput as Record + const notificationText = getNotificationTextByTool(event.tool, input) + + if (notificationText) { + ctx.scene.zoneNotifications.showForTool(event.sessionId, event.tool, notificationText) + + const station = getStationForTool(event.tool) + if (station !== 'center') { + ctx.scene.stationPanels.addToolUse(event.sessionId, station, { + text: notificationText, + success: event.success, + }) + } + } + }) + + // Tool completion notifications (universal) + bus.on('tool_end', (event: ToolEndEvent, ctx) => { + if (!event.success || !ctx.scene) return + + const notificationText = getNotificationTextByCategory( + event.tool.category, + event.tool.name, + event.output, + ) + + if (notificationText) { + ctx.scene.zoneNotifications.showForTool(event.agentId, event.tool.name, notificationText) + + const station = getStationForCategory(event.tool.category) as StationType + if (station !== 'center') { + ctx.scene.stationPanels.addToolUse(event.agentId, station as StationType, { + text: notificationText, + success: event.success, + }) + } + } + }) +} diff --git a/src/workshop/events/handlers/soundHandlers.ts b/src/workshop/events/handlers/soundHandlers.ts new file mode 100644 index 0000000..12fdfd2 --- /dev/null +++ b/src/workshop/events/handlers/soundHandlers.ts @@ -0,0 +1,21 @@ +/** + * Sound Event Handlers — NO-OP VERSION + * + * Sound is not vendored in ClawBox. This module exports a no-op + * registration function to keep the handler barrel consistent. + * + * The original Vibecraft2 version registers spatial audio for tool + * start/end, git commits, subagent spawn/despawn, stop, prompt, + * and notification events via soundManager. + */ + +import type { EventBus } from '../EventBus' + +/** + * Register sound-related event handlers (no-op in ClawBox) + */ +export function registerSoundHandlers(_bus: EventBus): void { + // Sound is not vendored — intentionally empty. + // See Vibecraft2 src/events/handlers/soundHandlers.ts for the + // full implementation if audio support is added later. +} diff --git a/src/workshop/events/handlers/subagentHandlers.ts b/src/workshop/events/handlers/subagentHandlers.ts new file mode 100644 index 0000000..97685b0 --- /dev/null +++ b/src/workshop/events/handlers/subagentHandlers.ts @@ -0,0 +1,82 @@ +/** + * Subagent Event Handlers + * + * Handles spawning and removing subagent visualizations + * when Task tools start and complete. + * + * Supports both legacy Claude Code events (Task tool) and + * universal agent protocol events (delegate category / subagent_spawn). + * + * Vendored from Vibecraft2, adapted for ClawBox embedding. + * - EventBus passed as parameter (no singleton) + * - Types imported from ../../types + */ + +import type { EventBus } from '../EventBus' +import type { + PreToolUseEvent, + PostToolUseEvent, + ToolStartEvent, + ToolEndEvent, + SubagentSpawnEvent, + SubagentEndEvent, +} from '../../types' + +/** + * Register subagent-related event handlers + */ +export function registerSubagentHandlers(bus: EventBus): void { + // Spawn subagent when Task tool starts (legacy) + bus.on('pre_tool_use', (event: PreToolUseEvent, ctx) => { + if (!ctx.session) return + if (event.tool !== 'Task') return + + const description = (event.toolInput as { description?: string }).description + ctx.session.subagents.spawn(event.toolUseId, description) + ctx.session.stats.activeSubagents = ctx.session.subagents.count + }) + + // Remove subagent when Task tool completes (legacy) + bus.on('post_tool_use', (event: PostToolUseEvent, ctx) => { + if (!ctx.session) return + if (event.tool !== 'Task') return + + ctx.session.subagents.remove(event.toolUseId) + ctx.session.stats.activeSubagents = ctx.session.subagents.count + }) + + // Spawn subagent when delegate tool starts (universal) + bus.on('tool_start', (event: ToolStartEvent, ctx) => { + if (!ctx.session) return + if (event.tool.category !== 'delegate') return + + const description = event.context || event.input?.description as string | undefined + ctx.session.subagents.spawn(event.tool.id, description) + ctx.session.stats.activeSubagents = ctx.session.subagents.count + }) + + // Remove subagent when delegate tool completes (universal) + bus.on('tool_end', (event: ToolEndEvent, ctx) => { + if (!ctx.session) return + if (event.tool.category !== 'delegate') return + + ctx.session.subagents.remove(event.tool.id) + ctx.session.stats.activeSubagents = ctx.session.subagents.count + }) + + // Explicit subagent spawn event (universal) + bus.on('subagent_spawn', (event: SubagentSpawnEvent, ctx) => { + if (!ctx.session) return + const id = event.toolUseId || event.id + ctx.session.subagents.spawn(id, event.description) + ctx.session.stats.activeSubagents = ctx.session.subagents.count + }) + + // Explicit subagent end event (universal) + bus.on('subagent_end', (event: SubagentEndEvent, ctx) => { + if (!ctx.session) return + const id = event.toolUseId || event.id + ctx.session.subagents.remove(id) + ctx.session.stats.activeSubagents = ctx.session.subagents.count + }) +} diff --git a/src/workshop/events/handlers/zoneHandlers.ts b/src/workshop/events/handlers/zoneHandlers.ts new file mode 100644 index 0000000..e3bbed3 --- /dev/null +++ b/src/workshop/events/handlers/zoneHandlers.ts @@ -0,0 +1,36 @@ +/** + * Zone Status Event Handlers + * + * Handles zone status updates (working, attention, etc.) + * and attention states for questions and completion. + * + * Vendored from Vibecraft2, adapted for ClawBox embedding. + * - EventBus passed as parameter (no singleton) + * - Types imported from ../../types + */ + +import type { EventBus } from '../EventBus' +import type { StopEvent, UserPromptSubmitEvent } from '../../types' + +/** + * Register zone status event handlers + */ +export function registerZoneHandlers(bus: EventBus): void { + // Set attention state when Claude stops (finished work) + bus.on('stop', (event: StopEvent, ctx) => { + if (!ctx.session || !ctx.scene) return + + // Set finished attention - agent completed its work + ctx.scene.setZoneAttention(event.sessionId, 'finished') + ctx.scene.setZoneStatus(event.sessionId, 'attention') // Red glow + }) + + // Clear attention and set working when user submits prompt + bus.on('user_prompt_submit', (event: UserPromptSubmitEvent, ctx) => { + if (!ctx.session || !ctx.scene) return + + // Clear attention - user is now engaged + ctx.scene.clearZoneAttention(event.sessionId) + ctx.scene.setZoneStatus(event.sessionId, 'working') // Cyan glow + }) +} diff --git a/src/workshop/scene/SpawnBeam.ts b/src/workshop/scene/SpawnBeam.ts new file mode 100644 index 0000000..e102a8d --- /dev/null +++ b/src/workshop/scene/SpawnBeam.ts @@ -0,0 +1,188 @@ +/** + * SpawnBeam - Glowing particle trail arcing between zones + * + * Used when the Architect spawns a sub-agent: a stream of particles + * travels along a quadratic bezier arc from the portal station to the + * new sub-agent zone center. + */ + +import * as THREE from 'three' + +// ============================================================================ +// Types +// ============================================================================ + +interface ActiveBeam { + points: THREE.Points + geometry: THREE.BufferGeometry + /** Per-particle progress along the arc (0→1), staggered */ + particleProgress: Float32Array + /** Bezier control points */ + p0: THREE.Vector3 + p1: THREE.Vector3 // elevated midpoint + p2: THREE.Vector3 + /** Overall beam age in seconds */ + age: number + color: THREE.Color +} + +// ============================================================================ +// Constants +// ============================================================================ + +const PARTICLE_COUNT = 15 +const BEAM_DURATION = 1.2 // total seconds +const TRAVEL_END = 0.7 // progress at which lead particle arrives +const FADE_START = 0.85 // progress at which fade begins +const ARC_HEIGHT = 4 // how high the midpoint control point rises +const PARTICLE_SIZE = 0.25 +const SCATTER_RADIUS = 0.3 // scatter at arrival + +// ============================================================================ +// SpawnBeamManager +// ============================================================================ + +export class SpawnBeamManager { + private scene: THREE.Scene + private beams: ActiveBeam[] = [] + + constructor(scene: THREE.Scene) { + this.scene = scene + } + + /** + * Launch a beam from one world position to another. + * @param from Source position (portal station) + * @param to Target position (zone center) + * @param color Beam color (hex number, e.g. 0x60a5fa) + */ + launch(from: THREE.Vector3, to: THREE.Vector3, color: number): void { + const geometry = new THREE.BufferGeometry() + const positions = new Float32Array(PARTICLE_COUNT * 3) + const alphas = new Float32Array(PARTICLE_COUNT) + const sizes = new Float32Array(PARTICLE_COUNT) + + // All particles start at the source + for (let i = 0; i < PARTICLE_COUNT; i++) { + positions[i * 3] = from.x + positions[i * 3 + 1] = from.y + positions[i * 3 + 2] = from.z + alphas[i] = 0 + sizes[i] = PARTICLE_SIZE + } + + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geometry.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1)) + geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)) + + const material = new THREE.PointsMaterial({ + color, + size: PARTICLE_SIZE, + transparent: true, + opacity: 1, + blending: THREE.AdditiveBlending, + depthWrite: false, + sizeAttenuation: true, + }) + + const points = new THREE.Points(geometry, material) + this.scene.add(points) + + // Quadratic bezier: midpoint elevated + const mid = new THREE.Vector3().lerpVectors(from, to, 0.5) + mid.y += ARC_HEIGHT + + // Stagger each particle so they form a trail + const particleProgress = new Float32Array(PARTICLE_COUNT) + for (let i = 0; i < PARTICLE_COUNT; i++) { + // Stagger: particle 0 leads, last particle trails + particleProgress[i] = -(i / PARTICLE_COUNT) * TRAVEL_END + } + + this.beams.push({ + points, + geometry, + particleProgress, + p0: from.clone(), + p1: mid, + p2: to.clone(), + age: 0, + color: new THREE.Color(color), + }) + } + + /** + * Advance all active beams. Called each frame from the render loop. + */ + update(delta: number): void { + const toRemove: number[] = [] + + for (let b = 0; b < this.beams.length; b++) { + const beam = this.beams[b] + beam.age += delta + + const overallProgress = beam.age / BEAM_DURATION + if (overallProgress >= 1) { + toRemove.push(b) + continue + } + + const pos = beam.geometry.getAttribute('position') as THREE.BufferAttribute + const material = beam.points.material as THREE.PointsMaterial + + // Global fade during the fade phase + if (overallProgress >= FADE_START) { + const fadeProg = (overallProgress - FADE_START) / (1 - FADE_START) + material.opacity = 1 - fadeProg + } + + const tmp = new THREE.Vector3() + + for (let i = 0; i < PARTICLE_COUNT; i++) { + // Advance this particle's progress along the arc + beam.particleProgress[i] += delta / (BEAM_DURATION * TRAVEL_END) + + const t = Math.max(0, Math.min(1, beam.particleProgress[i])) + + // Quadratic bezier: B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2 + const omt = 1 - t + tmp.set( + omt * omt * beam.p0.x + 2 * omt * t * beam.p1.x + t * t * beam.p2.x, + omt * omt * beam.p0.y + 2 * omt * t * beam.p1.y + t * t * beam.p2.y, + omt * omt * beam.p0.z + 2 * omt * t * beam.p1.z + t * t * beam.p2.z, + ) + + // Scatter slightly at arrival + if (t >= 0.95) { + const scatter = (t - 0.95) / 0.05 * SCATTER_RADIUS + tmp.x += (Math.random() - 0.5) * scatter + tmp.y += (Math.random() - 0.5) * scatter + tmp.z += (Math.random() - 0.5) * scatter + } + + pos.setXYZ(i, tmp.x, tmp.y, tmp.z) + } + + pos.needsUpdate = true + } + + // Remove finished beams (iterate in reverse to keep indices valid) + for (let i = toRemove.length - 1; i >= 0; i--) { + const beam = this.beams[toRemove[i]] + this.scene.remove(beam.points) + beam.geometry.dispose() + ;(beam.points.material as THREE.PointsMaterial).dispose() + this.beams.splice(toRemove[i], 1) + } + } + + /** Clean up all active beams */ + dispose(): void { + for (const beam of this.beams) { + this.scene.remove(beam.points) + beam.geometry.dispose() + ;(beam.points.material as THREE.PointsMaterial).dispose() + } + this.beams = [] + } +} diff --git a/src/workshop/scene/StationPanels.ts b/src/workshop/scene/StationPanels.ts new file mode 100644 index 0000000..a3d8cd3 --- /dev/null +++ b/src/workshop/scene/StationPanels.ts @@ -0,0 +1,328 @@ +/** + * Station Panels + * + * Shows recent tool activity history for each workstation. + * Toggled with P key, hidden by default. + */ + +import * as THREE from 'three' +import type { StationType } from '../types' + +export interface ToolHistoryItem { + text: string // "npm test" or "config.ts" + success: boolean + timestamp: number +} + +interface StationPanel { + sprite: THREE.Sprite + history: ToolHistoryItem[] + needsUpdate: boolean +} + +// Station display names and colors +const STATION_CONFIG: Record< + StationType, + { name: string; color: string; icon: string } +> = { + center: { name: 'CENTER', color: '#4ac8e8', icon: '' }, + bookshelf: { name: 'LIBRARY', color: '#fbbf24', icon: '' }, + desk: { name: 'DESK', color: '#4ade80', icon: '' }, + workbench: { name: 'WORKBENCH', color: '#f97316', icon: '' }, + terminal: { name: 'TERMINAL', color: '#22d3ee', icon: '' }, + scanner: { name: 'SCANNER', color: '#60a5fa', icon: '' }, + antenna: { name: 'ANTENNA', color: '#4ac8e8', icon: '' }, + portal: { name: 'PORTAL', color: '#22d3d8', icon: '' }, + taskboard: { name: 'TASKBOARD', color: '#fb923c', icon: '' }, +} + +// Station positions (relative to zone center) +const STATION_OFFSETS: Record = { + center: [0, 0, 0], + bookshelf: [0, 0, -4], + desk: [4, 0, 0], + workbench: [-4, 0, 0], + terminal: [0, 0, 4], + scanner: [3, 0, -3], + antenna: [-3, 0, -3], + portal: [-3, 0, 3], + taskboard: [3, 0, 3], +} + +const MAX_HISTORY = 3 +const CANVAS_WIDTH = 256 +const CANVAS_HEIGHT = 160 +const PANEL_SCALE = 2.5 + +export class StationPanels { + private panels: Map> = new Map() // zoneId -> stationType -> panel + private scene: THREE.Scene + private visible = false + + constructor(scene: THREE.Scene) { + this.scene = scene + } + + /** + * Create panels for a zone + */ + createPanelsForZone( + zoneId: string, + zonePosition: THREE.Vector3, + zoneColor: number + ): void { + const zonePanels = new Map() + + for (const [stationType, offset] of Object.entries(STATION_OFFSETS)) { + if (stationType === 'center') continue // Skip center station + + const sprite = this.createPanelSprite(stationType as StationType) + + // Position panel offset from station, raised and angled back + const [ox, , oz] = offset + sprite.position.set( + zonePosition.x + ox * 0.7, // Closer to center + zonePosition.y + 3.5, // Above station + zonePosition.z + oz * 0.7 + ) + + sprite.visible = this.visible + this.scene.add(sprite) + + zonePanels.set(stationType as StationType, { + sprite, + history: [], + needsUpdate: false, + }) + } + + this.panels.set(zoneId, zonePanels) + } + + /** + * Remove panels for a zone + */ + removePanelsForZone(zoneId: string): void { + const zonePanels = this.panels.get(zoneId) + if (!zonePanels) return + + for (const [, panel] of zonePanels) { + this.scene.remove(panel.sprite) + panel.sprite.material.map?.dispose() + ;(panel.sprite.material as THREE.SpriteMaterial).dispose() + } + + this.panels.delete(zoneId) + } + + /** + * Add a tool use to station history + */ + addToolUse( + zoneId: string, + station: StationType, + item: Omit + ): void { + const zonePanels = this.panels.get(zoneId) + if (!zonePanels) return + + const panel = zonePanels.get(station) + if (!panel) return + + // Add new item + panel.history.push({ + ...item, + timestamp: Date.now(), + }) + + // Trim to max + while (panel.history.length > MAX_HISTORY) { + panel.history.shift() + } + + panel.needsUpdate = true + } + + /** + * Toggle visibility of all panels + */ + setVisible(visible: boolean): void { + this.visible = visible + for (const [, zonePanels] of this.panels) { + for (const [, panel] of zonePanels) { + panel.sprite.visible = visible + } + } + } + + /** + * Get visibility state + */ + isVisible(): boolean { + return this.visible + } + + /** + * Update panels that need re-rendering + */ + update(): void { + for (const [, zonePanels] of this.panels) { + for (const [stationType, panel] of zonePanels) { + if (panel.needsUpdate) { + this.renderPanel(panel, stationType) + panel.needsUpdate = false + } + } + } + } + + /** + * Create a panel sprite for a station + */ + private createPanelSprite(stationType: StationType): THREE.Sprite { + const canvas = document.createElement('canvas') + canvas.width = CANVAS_WIDTH + canvas.height = CANVAS_HEIGHT + + const texture = new THREE.CanvasTexture(canvas) + texture.minFilter = THREE.LinearFilter + texture.magFilter = THREE.LinearFilter + + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false, + }) + + const sprite = new THREE.Sprite(material) + sprite.scale.set( + PANEL_SCALE, + PANEL_SCALE * (CANVAS_HEIGHT / CANVAS_WIDTH), + 1 + ) + + // Render initial state + this.renderPanelCanvas(canvas, stationType, []) + + return sprite + } + + /** + * Re-render a panel's canvas + */ + private renderPanel(panel: StationPanel, stationType: StationType): void { + const material = panel.sprite.material as THREE.SpriteMaterial + const texture = material.map as THREE.CanvasTexture + const canvas = texture.image as HTMLCanvasElement + + this.renderPanelCanvas(canvas, stationType, panel.history) + texture.needsUpdate = true + } + + /** + * Render panel content to canvas + */ + private renderPanelCanvas( + canvas: HTMLCanvasElement, + stationType: StationType, + history: ToolHistoryItem[] + ): void { + const ctx = canvas.getContext('2d')! + const config = STATION_CONFIG[stationType] + + // Clear + ctx.clearRect(0, 0, canvas.width, canvas.height) + + // Background + ctx.fillStyle = 'rgba(10, 15, 25, 0.9)' + this.roundRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8) + ctx.fill() + + // Border + ctx.strokeStyle = config.color + ctx.lineWidth = 2 + ctx.globalAlpha = 0.6 + this.roundRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8) + ctx.stroke() + ctx.globalAlpha = 1 + + // Header + ctx.fillStyle = config.color + ctx.font = 'bold 16px system-ui, -apple-system, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'top' + ctx.fillText(config.name, 20, 20) + + // Divider + ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)' + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(20, 44) + ctx.lineTo(canvas.width - 20, 44) + ctx.stroke() + + // History items + const startY = 54 + const lineHeight = 32 + + if (history.length === 0) { + ctx.fillStyle = 'rgba(255, 255, 255, 0.4)' + ctx.font = '13px system-ui, -apple-system, sans-serif' + ctx.fillText('No activity yet', 20, startY + 8) + } else { + ctx.font = '13px system-ui, -apple-system, sans-serif' + + history.forEach((item, i) => { + const y = startY + i * lineHeight + + // Status indicator + ctx.fillStyle = item.success ? '#4ade80' : '#f87171' + ctx.beginPath() + ctx.arc(26, y + 12, 4, 0, Math.PI * 2) + ctx.fill() + + // Text + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)' + const maxTextWidth = canvas.width - 60 + let displayText = item.text + + // Truncate if needed + if (ctx.measureText(displayText).width > maxTextWidth) { + while ( + ctx.measureText(displayText + '...').width > maxTextWidth && + displayText.length > 0 + ) { + displayText = displayText.slice(0, -1) + } + displayText += '...' + } + + ctx.fillText(displayText, 38, y + 8) + }) + } + } + + /** + * Draw a rounded rectangle path + */ + private roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number + ): void { + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + width - radius, y) + ctx.quadraticCurveTo(x + width, y, x + width, y + radius) + ctx.lineTo(x + width, y + height - radius) + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + ctx.lineTo(x + radius, y + height) + ctx.quadraticCurveTo(x, y + height, x, y + height - radius) + ctx.lineTo(x, y + radius) + ctx.quadraticCurveTo(x, y, x + radius, y) + ctx.closePath() + } +} diff --git a/src/workshop/scene/WorkshopScene.ts b/src/workshop/scene/WorkshopScene.ts new file mode 100644 index 0000000..9e3aae5 --- /dev/null +++ b/src/workshop/scene/WorkshopScene.ts @@ -0,0 +1,1903 @@ +/** + * WorkshopScene — Simplified 3D workshop environment for ClawBox + * + * Vendored and adapted from Vibecraft2's WorkshopScene.ts. + * Removed: audio, drawMode, text tiles, hex painting, permission modals, + * context menus, pending zone spinners, git labels, multiple camera animation + * targets, FPS counter. + * Kept: full visual quality, hex grid, stations, particles, camera, + * zone lifecycle, click pulses, notifications, panels, spawn beams. + */ + +import * as THREE from 'three' +import { OrbitControls } from 'three/addons/controls/OrbitControls.js' +import type { StationType } from '../types' +import { HexGrid } from '../utils/HexGrid' +import { ZoneNotifications, type NotificationStyle } from './ZoneNotifications' +import { SpawnBeamManager } from './SpawnBeam' +import { StationPanels } from './StationPanels' +import { + addBookshelfDetails, + addTerminalDetails, + addAntennaDetails, + addPortalDetails, + addScannerDetails, + addDeskDetails, + addWorkbenchDetails, + addTaskboardDetails, +} from './stations' + +// ============================================================================ +// Types +// ============================================================================ + +export interface Station { + type: StationType + position: THREE.Vector3 // World position (updated when zone elevation changes) + localPosition: THREE.Vector3 // Position relative to zone + mesh: THREE.Group + label: string + contextSprite?: THREE.Sprite +} + +export type AttentionReason = 'question' | 'finished' | 'error' | null + +export interface Zone { + id: string + group: THREE.Group + stations: Map + platform: THREE.Mesh + ring: THREE.Mesh + floor: THREE.Mesh + color: number + position: THREE.Vector3 + label?: THREE.Sprite + pulseIntensity: number + attentionReason: AttentionReason + attentionTime: number + particles: THREE.Points + particleVelocities: Float32Array + status: 'idle' | 'working' | 'waiting' | 'attention' | 'offline' + animationState?: 'entering' | 'exiting' + animationProgress?: number + elevation: number + edgeLines?: THREE.LineSegments + sideMesh?: THREE.Mesh +} + +export type CameraMode = 'focused' | 'overview' + +// Zone colors — ice/cyan theme +export const ZONE_COLORS = [ + 0x4ac8e8, // Cyan (primary) + 0x60a5fa, // Blue + 0x22d3d8, // Teal + 0x4ade80, // Green + 0xa78bfa, // Purple + 0xfbbf24, // Orange + 0xf472b6, // Pink + 0xa3e635, // Lime +] + +// ============================================================================ +// EventBus interface (optional dependency injection) +// ============================================================================ + +export interface EventBus { + emit(event: string, ...args: unknown[]): void + on(event: string, handler: (...args: unknown[]) => void): void + off(event: string, handler: (...args: unknown[]) => void): void +} + +// ============================================================================ +// WorkshopScene +// ============================================================================ + +export class WorkshopScene { + public scene: THREE.Scene + public camera: THREE.PerspectiveCamera + public renderer: THREE.WebGLRenderer + public controls: OrbitControls + + // Multi-zone support + public zones: Map = new Map() + public hexGrid: HexGrid + private zoneColorIndex = 0 + + // Camera modes + public cameraMode: CameraMode = 'focused' + public focusedZoneId: string | null = null + private onCameraModeChange: ((mode: CameraMode) => void) | null = null + private onZoneElevationChange: ((sessionId: string, elevation: number) => void) | null = null + + // Camera animation + private cameraTargetPos = new THREE.Vector3() + private cameraTargetLookAt = new THREE.Vector3() + private cameraAnimating = false + private readonly cameraLerpSpeed = 8 + + // Legacy single-zone compat (points to first zone) + public stations: Map = new Map() + + private container: HTMLElement + private eventBus: EventBus | null + private animationId: number | null = null + private onRenderCallbacks: Array<(delta: number) => void> = [] + private clock = new THREE.Clock() + + // Click pulse effects + private clickPulses: Array<{ + mesh: THREE.Mesh | THREE.Line + age: number + maxAge: number + type?: 'ring' | 'hex' | 'ripple' + delay?: number + startOpacity?: number + baseOpacity?: number + highlightColor?: THREE.Color + baseColor?: THREE.Color + }> = [] + + // Station glow pulses + private stationPulses: Array<{ + ring: THREE.Mesh + age: number + maxAge: number + baseOpacity: number + peakOpacity: number + }> = [] + + // Zone notification system + public zoneNotifications: ZoneNotifications + + // Station info panels + public stationPanels: StationPanels + + // Spawn beam effects + public spawnBeams: SpawnBeamManager + + // Ambient floating particles + private ambientParticles: THREE.Points | null = null + private ambientParticleData: Array<{ + baseY: number + phase: number + speed: number + radius: number + angle: number + }> = [] + + // Time accumulator for animations + private time = 0 + + // World hex grid overlay + private worldHexGrid: THREE.Group | THREE.LineSegments | null = null + + // Hover highlight + private hoverHighlight: THREE.Line | null = null + private hoverRaycaster = new THREE.Raycaster() + private hoverMouse = new THREE.Vector2() + private lastHoveredHex: { q: number; r: number } | null = null + + // World grid size (number of hex rings from center) + private gridRange = 20 + + // World floor for click detection + public worldFloor: THREE.Mesh | null = null + + // Floating notifications (legacy) + private notifications: Array<{ + sprite: THREE.Sprite + startY: number + age: number + maxAge: number + }> = [] + + constructor(container: HTMLElement, eventBus?: EventBus) { + this.container = container + this.eventBus = eventBus ?? null + + // Scene — dark blue-black like ice cave + this.scene = new THREE.Scene() + this.scene.background = new THREE.Color(0x080c14) + + // Hex grid for zone placement + this.hexGrid = new HexGrid(10, 1.0) + + // Camera + this.camera = new THREE.PerspectiveCamera( + 50, + container.clientWidth / container.clientHeight, + 0.1, + 500, + ) + const isMobile = window.innerWidth <= 640 + this.camera.position.set(isMobile ? 40 : 8, isMobile ? 32 : 6, isMobile ? 40 : 8) + this.camera.lookAt(0, 0, 0) + + // Renderer + this.renderer = new THREE.WebGLRenderer({ + antialias: false, + alpha: false, + powerPreference: 'high-performance', + }) + this.renderer.setSize(container.clientWidth, container.clientHeight) + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)) + this.renderer.shadowMap.enabled = true + this.renderer.shadowMap.type = THREE.BasicShadowMap + container.appendChild(this.renderer.domElement) + + // Controls + this.controls = new OrbitControls(this.camera, this.renderer.domElement) + this.controls.enableDamping = true + this.controls.dampingFactor = 0.05 + this.controls.maxPolarAngle = Math.PI / 2.1 + this.controls.minDistance = 5 + this.controls.maxDistance = 150 + this.controls.target.set(0, 0, 0) + + // Stop camera animation when user manually drags + this.controls.addEventListener('start', () => { + this.cameraAnimating = false + }) + + // Build the world + this.setupLighting() + this.createWorldFloor() + this.createWorldHexGrid() + this.createAmbientParticles() + this.setupHoverHighlight() + + // Subsystems + this.zoneNotifications = new ZoneNotifications(this.scene) + this.stationPanels = new StationPanels(this.scene) + this.spawnBeams = new SpawnBeamManager(this.scene) + + // Resize + window.addEventListener('resize', this.handleResize) + } + + // ========================================================================== + // Lighting + // ========================================================================== + + private setupLighting(): void { + // Ambient + const ambient = new THREE.AmbientLight(0x606080, 0.8) + this.scene.add(ambient) + + // Directional (sun) + const sun = new THREE.DirectionalLight(0xfff5e6, 1.2) + sun.position.set(5, 10, 5) + sun.castShadow = true + sun.shadow.mapSize.width = 512 + sun.shadow.mapSize.height = 512 + sun.shadow.camera.near = 1 + sun.shadow.camera.far = 20 + sun.shadow.camera.left = -8 + sun.shadow.camera.right = 8 + sun.shadow.camera.top = 8 + sun.shadow.camera.bottom = -8 + this.scene.add(sun) + + // Hemisphere fill + const hemi = new THREE.HemisphereLight(0xfff5e6, 0x404060, 0.4) + this.scene.add(hemi) + } + + // ========================================================================== + // World floor (invisible, for raycasting) + // ========================================================================== + + private createWorldFloor(): void { + const geo = new THREE.PlaneGeometry(500, 500) + const mat = new THREE.MeshBasicMaterial({ visible: false }) + const floor = new THREE.Mesh(geo, mat) + floor.rotation.x = -Math.PI / 2 + floor.position.y = -0.05 + floor.name = 'worldFloor' + this.scene.add(floor) + this.worldFloor = floor + } + + // ========================================================================== + // World hex grid overlay (single merged LineSegments for performance) + // ========================================================================== + + private createWorldHexGrid(): void { + const hexRadius = this.hexGrid.hexRadius + const gridRange = this.gridRange + const vertices: number[] = [] + + // Precompute corner angles (pointy-top) + const angles: number[] = [] + for (let i = 0; i < 6; i++) { + angles.push((Math.PI / 3) * i - Math.PI / 2) + } + + for (let q = -gridRange; q <= gridRange; q++) { + for (let r = -gridRange; r <= gridRange; r++) { + if (Math.abs(q) + Math.abs(r) + Math.abs(-q - r) > gridRange * 2) continue + const { x, z } = this.hexGrid.axialToCartesian({ q, r }) + + for (let i = 0; i < 6; i++) { + const sa = angles[i] + const ea = angles[(i + 1) % 6] + vertices.push(x + hexRadius * Math.cos(sa), 0, z + hexRadius * Math.sin(sa)) + vertices.push(x + hexRadius * Math.cos(ea), 0, z + hexRadius * Math.sin(ea)) + } + } + } + + const geo = new THREE.BufferGeometry() + geo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)) + + const mat = new THREE.LineBasicMaterial({ + color: 0x4ac8e8, + transparent: true, + opacity: 0.35, + }) + + const lines = new THREE.LineSegments(geo, mat) + lines.position.y = 0.01 + this.scene.add(lines) + this.worldHexGrid = lines + } + + // ========================================================================== + // Hover highlight + // ========================================================================== + + private setupHoverHighlight(): void { + const hexRadius = this.hexGrid.hexRadius + const pts: THREE.Vector3[] = [] + for (let i = 0; i <= 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 2 + pts.push(new THREE.Vector3(hexRadius * Math.cos(angle), 0.03, hexRadius * Math.sin(angle))) + } + + const geo = new THREE.BufferGeometry().setFromPoints(pts) + const mat = new THREE.LineBasicMaterial({ color: 0x8eeeff, transparent: true, opacity: 0.7 }) + + this.hoverHighlight = new THREE.Line(geo, mat) + this.hoverHighlight.visible = false + this.scene.add(this.hoverHighlight) + + this.renderer.domElement.addEventListener('mousemove', this.handleHover) + this.renderer.domElement.addEventListener('mouseleave', this.handleHoverLeave) + } + + private handleHover = (event: MouseEvent): void => { + if (!this.hoverHighlight || !this.worldFloor) return + + const rect = this.renderer.domElement.getBoundingClientRect() + this.hoverMouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 + this.hoverMouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 + + this.hoverRaycaster.setFromCamera(this.hoverMouse, this.camera) + const intersects = this.hoverRaycaster.intersectObject(this.worldFloor) + + if (intersects.length > 0) { + const point = intersects[0].point + const hexCoord = this.hexGrid.cartesianToHex(point.x, point.z) + const hexCenter = this.hexGrid.axialToCartesian(hexCoord) + + const isNewHex = + !this.lastHoveredHex || + this.lastHoveredHex.q !== hexCoord.q || + this.lastHoveredHex.r !== hexCoord.r + + if (isNewHex) { + this.lastHoveredHex = { q: hexCoord.q, r: hexCoord.r } + } + + this.hoverHighlight.position.set(hexCenter.x, 0, hexCenter.z) + this.hoverHighlight.visible = true + } else { + this.hoverHighlight.visible = false + this.lastHoveredHex = null + } + } + + private handleHoverLeave = (): void => { + if (this.hoverHighlight) this.hoverHighlight.visible = false + this.lastHoveredHex = null + } + + // ========================================================================== + // Ambient particles + // ========================================================================== + + private createAmbientParticles(): void { + const count = 60 + const positions = new Float32Array(count * 3) + + for (let i = 0; i < count; i++) { + const radius = 2 + Math.random() * 15 + const angle = Math.random() * Math.PI * 2 + const baseY = 6 + Math.random() * 12 + + positions[i * 3] = Math.cos(angle) * radius + positions[i * 3 + 1] = baseY + positions[i * 3 + 2] = Math.sin(angle) * radius + + this.ambientParticleData.push({ + baseY, + phase: Math.random() * Math.PI * 2, + speed: 0.3 + Math.random() * 0.5, + radius, + angle, + }) + } + + const geo = new THREE.BufferGeometry() + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + + const mat = new THREE.PointsMaterial({ + color: 0x4ac8e8, + size: 0.12, + transparent: true, + opacity: 0.4, + blending: THREE.AdditiveBlending, + depthWrite: false, + }) + + this.ambientParticles = new THREE.Points(geo, mat) + this.scene.add(this.ambientParticles) + } + + private updateAmbientParticles(delta: number): void { + if (!this.ambientParticles) return + + const positions = this.ambientParticles.geometry.attributes.position.array as Float32Array + + for (let i = 0; i < this.ambientParticleData.length; i++) { + const d = this.ambientParticleData[i] + d.angle += delta * 0.02 * d.speed + const yOff = Math.sin(this.time * d.speed + d.phase) * 1.5 + + positions[i * 3] = Math.cos(d.angle) * d.radius + positions[i * 3 + 1] = d.baseY + yOff + positions[i * 3 + 2] = Math.sin(d.angle) * d.radius + } + + this.ambientParticles.geometry.attributes.position.needsUpdate = true + } + + // ========================================================================== + // Zone creation + // ========================================================================== + + createZone( + sessionId: string, + options?: { color?: number; hintPosition?: { x: number; z: number } }, + ): Zone { + const existing = this.zones.get(sessionId) + if (existing) return existing + + // Hex placement + const hexCoord = options?.hintPosition + ? this.hexGrid.findNearestFree(this.hexGrid.cartesianToHex(options.hintPosition.x, options.hintPosition.z)) + : this.hexGrid.getNextInSpiral() + + this.hexGrid.occupy(hexCoord, sessionId) + + const zoneColor = options?.color ?? ZONE_COLORS[this.zoneColorIndex++ % ZONE_COLORS.length] + const { x, z } = this.hexGrid.axialToCartesian(hexCoord) + const position = new THREE.Vector3(x, 0, z) + + // Group + const group = new THREE.Group() + group.position.copy(position) + this.scene.add(group) + group.updateMatrixWorld(true) + + // Platform, ring, floor + const { platform, ring, floor } = this.createZonePlatform(group, zoneColor) + + // Stations + const stations = this.createZoneStations(group, zoneColor) + + // Floating label + const label = this.createZoneLabel(sessionId, zoneColor) + label.position.set(0, 4, 0) + group.add(label) + + // Particle system + const { particles, velocities } = this.createParticleSystem(zoneColor) + group.add(particles) + + // Edge lines (hidden until elevated) + const edgeLines = this.createZoneEdgeLines(zoneColor) + edgeLines.visible = false + this.scene.add(edgeLines) + edgeLines.position.copy(position) + + // Side mesh (hidden until elevated) + const sideMesh = this.createZoneSideMesh(zoneColor) + sideMesh.visible = false + this.scene.add(sideMesh) + sideMesh.position.copy(position) + + const zone: Zone = { + id: sessionId, + group, + stations, + platform, + ring, + floor, + color: zoneColor, + position, + label, + pulseIntensity: 0, + attentionReason: null, + attentionTime: 0, + particles, + particleVelocities: velocities, + status: 'idle', + animationState: 'entering', + animationProgress: 0, + elevation: 0, + edgeLines, + sideMesh, + } + + // Start at scale 0 for enter animation + group.scale.setScalar(0.01) + for (const station of stations.values()) station.mesh.visible = false + if (label) label.visible = false + particles.visible = false + + this.zones.set(sessionId, zone) + + // Register with subsystems + this.zoneNotifications.registerZone(sessionId, position) + this.stationPanels.createPanelsForZone(sessionId, position, zoneColor) + + // Legacy compat: first zone's stations become default + if (this.zones.size === 1) { + this.stations = stations + this.focusZone(sessionId) + } + + return zone + } + + getZone(sessionId: string): Zone | undefined { + return this.zones.get(sessionId) + } + + getZoneWorldPosition(sessionId: string): { x: number; z: number } | null { + const zone = this.zones.get(sessionId) + if (!zone) return null + return { x: zone.position.x, z: zone.position.z } + } + + getZoneHexPosition(sessionId: string): { q: number; r: number } | null { + const zone = this.zones.get(sessionId) + if (!zone) return null + return this.hexGrid.cartesianToHex(zone.position.x, zone.position.z) + } + + getZoneByIndex(index: number): Zone | undefined { + return Array.from(this.zones.values())[index] + } + + getZoneAtHex(hex: { q: number; r: number }): Zone | null { + const sessionId = this.hexGrid.getOccupant(hex) + if (!sessionId) return null + return this.zones.get(sessionId) ?? null + } + + // ========================================================================== + // Zone deletion + // ========================================================================== + + deleteZone(sessionId: string): boolean { + const zone = this.zones.get(sessionId) + if (!zone) return false + if (zone.animationState === 'exiting') return true + + zone.animationState = 'exiting' + zone.animationProgress = 0 + this.hexGrid.release(sessionId) + return true + } + + private finalizeZoneDelete(sessionId: string): void { + const zone = this.zones.get(sessionId) + if (!zone) return + + this.zoneNotifications.unregisterZone(sessionId) + this.stationPanels.removePanelsForZone(sessionId) + + this.scene.remove(zone.group) + zone.group.traverse((obj) => { + if (obj instanceof THREE.Mesh) { + obj.geometry?.dispose() + if (Array.isArray(obj.material)) { + obj.material.forEach((m) => m.dispose()) + } else if (obj.material) { + obj.material.dispose() + } + } else if (obj instanceof THREE.Sprite) { + obj.material.map?.dispose() + obj.material.dispose() + } else if (obj instanceof THREE.Points) { + obj.geometry?.dispose() + ;(obj.material as THREE.PointsMaterial)?.dispose() + } + }) + + if (zone.label) { + const mat = zone.label.material as THREE.SpriteMaterial + mat.map?.dispose() + mat.dispose() + } + + for (const station of zone.stations.values()) { + if (station.contextSprite) { + station.contextSprite.material.map?.dispose() + station.contextSprite.material.dispose() + } + } + + if (zone.edgeLines) { + this.scene.remove(zone.edgeLines) + zone.edgeLines.geometry.dispose() + ;(zone.edgeLines.material as THREE.LineBasicMaterial).dispose() + } + + if (zone.sideMesh) { + this.scene.remove(zone.sideMesh) + zone.sideMesh.geometry.dispose() + ;(zone.sideMesh.material as THREE.MeshStandardMaterial).dispose() + } + + this.zones.delete(sessionId) + + if (this.focusedZoneId === sessionId) { + this.focusedZoneId = null + } + } + + // ========================================================================== + // Camera + // ========================================================================== + + focusZone(sessionId: string, animate = true): void { + const zone = this.zones.get(sessionId) + if (!zone) return + + this.focusedZoneId = sessionId + this.cameraMode = 'focused' + + const target = zone.position.clone() + target.y += zone.elevation + + const isMobile = window.innerWidth <= 640 + const offset = isMobile ? new THREE.Vector3(40, 32, 40) : new THREE.Vector3(8, 6, 8) + const cameraPos = target.clone().add(offset) + + if (animate) { + this.animateCameraTo(cameraPos, target) + } else { + this.controls.target.copy(target) + this.camera.position.copy(cameraPos) + } + + this.notifyCameraModeChange() + } + + setOverviewMode(): void { + this.cameraMode = 'overview' + this.focusedZoneId = null + + if (this.zones.size === 0) return + + let minX = Infinity, + maxX = -Infinity, + minZ = Infinity, + maxZ = -Infinity + + for (const zone of this.zones.values()) { + minX = Math.min(minX, zone.position.x - 10) + maxX = Math.max(maxX, zone.position.x + 10) + minZ = Math.min(minZ, zone.position.z - 10) + maxZ = Math.max(maxZ, zone.position.z + 10) + } + + const cx = (minX + maxX) / 2 + const cz = (minZ + maxZ) / 2 + const extent = Math.max(maxX - minX, maxZ - minZ, 30) + + const isMobile = window.innerWidth <= 640 + const hMul = isMobile ? 1.0 : 0.8 + const height = extent * hMul + const lookAt = new THREE.Vector3(cx, 0, cz) + const pos = new THREE.Vector3(cx, height, cz + extent * (isMobile ? 0.2 : 0.3)) + + this.animateCameraTo(pos, lookAt) + this.notifyCameraModeChange() + } + + private animateCameraTo(position: THREE.Vector3, lookAt: THREE.Vector3): void { + this.cameraTargetPos.copy(position) + this.cameraTargetLookAt.copy(lookAt) + this.cameraAnimating = true + } + + onCameraMode(callback: (mode: CameraMode) => void): void { + this.onCameraModeChange = callback + } + + private notifyCameraModeChange(): void { + this.onCameraModeChange?.(this.cameraMode) + } + + // ========================================================================== + // Zone elevation + // ========================================================================== + + onZoneElevation(callback: (sessionId: string, elevation: number) => void): void { + this.onZoneElevationChange = callback + } + + private notifyZoneElevationChange(sessionId: string, elevation: number): void { + this.updateStationPositions(sessionId) + this.zoneNotifications.updateZoneElevation(sessionId, elevation) + this.onZoneElevationChange?.(sessionId, elevation) + } + + private updateStationPositions(sessionId: string): void { + const zone = this.zones.get(sessionId) + if (!zone) return + + const origScale = zone.group.scale.clone() + zone.group.scale.setScalar(1) + zone.group.updateMatrixWorld(true) + + for (const station of zone.stations.values()) { + const worldPos = station.localPosition.clone() + zone.group.localToWorld(worldPos) + station.position.copy(worldPos) + } + + zone.group.scale.copy(origScale) + zone.group.updateMatrixWorld(true) + } + + setZoneElevation(sessionId: string, elevation: number): void { + const zone = this.zones.get(sessionId) + if (!zone) return + + zone.elevation = elevation + zone.group.position.y = elevation + this.updateZoneEdgeLines(zone) + this.updateZoneSideMesh(zone) + this.notifyZoneElevationChange(sessionId, elevation) + } + + // ========================================================================== + // Zone platform / floor + // ========================================================================== + + private createHexagonShape(radius: number): THREE.Shape { + const shape = new THREE.Shape() + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 2 + const x = radius * Math.cos(angle) + const y = radius * Math.sin(angle) + if (i === 0) shape.moveTo(x, y) + else shape.lineTo(x, y) + } + shape.closePath() + return shape + } + + private createZonePlatform( + group: THREE.Group, + color: number, + ): { platform: THREE.Mesh; ring: THREE.Mesh; floor: THREE.Mesh } { + const hexRadius = 10 + + // Floor + const floorShape = this.createHexagonShape(hexRadius) + const floorGeo = new THREE.ShapeGeometry(floorShape) + const floorMat = new THREE.MeshStandardMaterial({ + color: 0x1a2535, + roughness: 0.7, + metalness: 0.15, + emissive: color, + emissiveIntensity: 0.02, + }) + const floor = new THREE.Mesh(floorGeo, floorMat) + floor.rotation.x = -Math.PI / 2 + floor.receiveShadow = true + group.add(floor) + + // Ring + const outerShape = this.createHexagonShape(hexRadius) + const innerShape = this.createHexagonShape(hexRadius - 0.5) + outerShape.holes.push(innerShape as unknown as THREE.Path) + const ringGeo = new THREE.ShapeGeometry(outerShape) + const ringMat = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: 0.5, + side: THREE.DoubleSide, + }) + const ring = new THREE.Mesh(ringGeo, ringMat) + ring.rotation.x = -Math.PI / 2 + ring.position.y = 0.02 + group.add(ring) + + // Center platform (pedestal) + const platformGeo = new THREE.CylinderGeometry(1, 1.2, 0.2, 6) + const platformMat = new THREE.MeshStandardMaterial({ + color, + roughness: 0.5, + metalness: 0.3, + emissive: color, + emissiveIntensity: 0.1, + }) + const platform = new THREE.Mesh(platformGeo, platformMat) + platform.position.y = 0.1 + platform.rotation.y = Math.PI / 6 + platform.receiveShadow = true + platform.castShadow = true + group.add(platform) + + return { platform, ring, floor } + } + + // ========================================================================== + // Zone edge lines + side mesh (shown when elevated) + // ========================================================================== + + private createZoneEdgeLines(color: number): THREE.LineSegments { + const hexRadius = 10 + const positions: number[] = [] + + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 2 + const x = hexRadius * Math.cos(angle) + const z = hexRadius * Math.sin(angle) + positions.push(x, 0, z, x, 1, z) + } + + for (let i = 0; i < 6; i++) { + const a1 = (Math.PI / 3) * i - Math.PI / 2 + const a2 = (Math.PI / 3) * ((i + 1) % 6) - Math.PI / 2 + positions.push( + hexRadius * Math.cos(a1), 1, hexRadius * Math.sin(a1), + hexRadius * Math.cos(a2), 1, hexRadius * Math.sin(a2), + ) + } + + const geo = new THREE.BufferGeometry() + geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + + const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.8, linewidth: 2 }) + return new THREE.LineSegments(geo, mat) + } + + private createZoneSideMesh(color: number): THREE.Mesh { + const hexRadius = 10 + const vertexCount = 6 * 6 + const positions = new Float32Array(vertexCount * 3) + const normals = new Float32Array(vertexCount * 3) + + let idx = 0 + for (let i = 0; i < 6; i++) { + const a1 = (Math.PI / 3) * i - Math.PI / 2 + const a2 = (Math.PI / 3) * ((i + 1) % 6) - Math.PI / 2 + const x1 = hexRadius * Math.cos(a1), z1 = hexRadius * Math.sin(a1) + const x2 = hexRadius * Math.cos(a2), z2 = hexRadius * Math.sin(a2) + const midAngle = (a1 + a2) / 2 + const nx = Math.cos(midAngle), nz = Math.sin(midAngle) + + // Triangle 1 + positions[idx * 3] = x1; positions[idx * 3 + 1] = 0; positions[idx * 3 + 2] = z1 + normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++ + positions[idx * 3] = x2; positions[idx * 3 + 1] = 0; positions[idx * 3 + 2] = z2 + normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++ + positions[idx * 3] = x2; positions[idx * 3 + 1] = 1; positions[idx * 3 + 2] = z2 + normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++ + + // Triangle 2 + positions[idx * 3] = x1; positions[idx * 3 + 1] = 0; positions[idx * 3 + 2] = z1 + normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++ + positions[idx * 3] = x2; positions[idx * 3 + 1] = 1; positions[idx * 3 + 2] = z2 + normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++ + positions[idx * 3] = x1; positions[idx * 3 + 1] = 1; positions[idx * 3 + 2] = z1 + normals[idx * 3] = nx; normals[idx * 3 + 1] = 0; normals[idx * 3 + 2] = nz; idx++ + } + + const geo = new THREE.BufferGeometry() + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3)) + + const mat = new THREE.MeshStandardMaterial({ + color, + transparent: true, + opacity: 0.7, + side: THREE.DoubleSide, + }) + + return new THREE.Mesh(geo, mat) + } + + updateZoneEdgeLines(zone: Zone): void { + if (!zone.edgeLines) return + const el = zone.elevation + if (el <= 0) { zone.edgeLines.visible = false; return } + + zone.edgeLines.visible = true + const pos = zone.edgeLines.geometry.attributes.position as THREE.BufferAttribute + const hexRadius = 10 + let idx = 0 + + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 2 + const x = hexRadius * Math.cos(angle) + const z = hexRadius * Math.sin(angle) + pos.setXYZ(idx++, x, 0, z) + pos.setXYZ(idx++, x, el, z) + } + + for (let i = 0; i < 6; i++) { + const a1 = (Math.PI / 3) * i - Math.PI / 2 + const a2 = (Math.PI / 3) * ((i + 1) % 6) - Math.PI / 2 + pos.setXYZ(idx++, hexRadius * Math.cos(a1), el, hexRadius * Math.sin(a1)) + pos.setXYZ(idx++, hexRadius * Math.cos(a2), el, hexRadius * Math.sin(a2)) + } + + pos.needsUpdate = true + } + + updateZoneSideMesh(zone: Zone): void { + if (!zone.sideMesh) return + const el = zone.elevation + if (el <= 0) { zone.sideMesh.visible = false; return } + + zone.sideMesh.visible = true + const pos = zone.sideMesh.geometry.attributes.position as THREE.BufferAttribute + const hexRadius = 10 + let idx = 0 + + for (let i = 0; i < 6; i++) { + const a1 = (Math.PI / 3) * i - Math.PI / 2 + const a2 = (Math.PI / 3) * ((i + 1) % 6) - Math.PI / 2 + const x1 = hexRadius * Math.cos(a1), z1 = hexRadius * Math.sin(a1) + const x2 = hexRadius * Math.cos(a2), z2 = hexRadius * Math.sin(a2) + + pos.setXYZ(idx++, x1, 0, z1) + pos.setXYZ(idx++, x2, 0, z2) + pos.setXYZ(idx++, x2, el, z2) + + pos.setXYZ(idx++, x1, 0, z1) + pos.setXYZ(idx++, x2, el, z2) + pos.setXYZ(idx++, x1, el, z1) + } + + pos.needsUpdate = true + } + + // ========================================================================== + // Stations + // ========================================================================== + + private createZoneStations(group: THREE.Group, zoneColor: number): Map { + const stations = new Map() + + const configs: Array<{ + type: StationType + position: [number, number, number] + label: string + color: number + }> = [ + { type: 'center', position: [0, 0, 0], label: 'Center', color: zoneColor }, + { type: 'bookshelf', position: [0, 0, -4], label: 'Library', color: 0x2a4a5a }, + { type: 'desk', position: [4, 0, 0], label: 'Desk', color: 0x3a4a5a }, + { type: 'workbench', position: [-4, 0, 0], label: 'Workbench', color: 0x3a4a55 }, + { type: 'terminal', position: [0, 0, 4], label: 'Terminal', color: 0x1a2a3a }, + { type: 'scanner', position: [3, 0, -3], label: 'Scanner', color: 0x2a4a6a }, + { type: 'antenna', position: [-3, 0, -3], label: 'Antenna', color: 0x3a5a6a }, + { type: 'portal', position: [-3, 0, 3], label: 'Portal', color: 0x3a4a6a }, + { type: 'taskboard', position: [3, 0, 3], label: 'Task Board', color: 0x3a4a5a }, + ] + + for (const cfg of configs) { + stations.set(cfg.type, this.createStationInZone(group, cfg)) + } + + return stations + } + + private createStationInZone( + zoneGroup: THREE.Group, + config: { type: StationType; position: [number, number, number]; label: string; color: number }, + ): Station { + const stationGroup = new THREE.Group() + const [x, y, z] = config.position + + if (config.type === 'center') { + stationGroup.position.set(x, y, z) + zoneGroup.add(stationGroup) + + const localPos = new THREE.Vector3(x, 0.3, z) + const worldPos = localPos.clone() + zoneGroup.localToWorld(worldPos) + + return { type: config.type, position: worldPos, localPosition: localPos, mesh: stationGroup, label: config.label } + } + + // Base/table + const baseGeo = new THREE.BoxGeometry(1.5, 0.8, 1) + const baseMat = new THREE.MeshStandardMaterial({ color: config.color, roughness: 0.7, metalness: 0.2 }) + const base = new THREE.Mesh(baseGeo, baseMat) + base.position.y = 0.4 + base.castShadow = true + base.receiveShadow = true + stationGroup.add(base) + + // Station-specific details + switch (config.type) { + case 'bookshelf': addBookshelfDetails(stationGroup); break + case 'desk': addDeskDetails(stationGroup); break + case 'workbench': addWorkbenchDetails(stationGroup); break + case 'terminal': addTerminalDetails(stationGroup); break + case 'antenna': addAntennaDetails(stationGroup); break + case 'portal': addPortalDetails(stationGroup); break + case 'scanner': addScannerDetails(stationGroup); break + case 'taskboard': addTaskboardDetails(stationGroup); break + } + + // Station indicator ring + const ringGeo = new THREE.RingGeometry(0.9, 1, 32) + const ringMat = new THREE.MeshBasicMaterial({ color: config.color, transparent: true, opacity: 0.3, side: THREE.DoubleSide }) + const ring = new THREE.Mesh(ringGeo, ringMat) + ring.rotation.x = -Math.PI / 2 + ring.position.y = 0.02 + stationGroup.add(ring) + + stationGroup.position.set(x, y, z) + zoneGroup.add(stationGroup) + + // Calculate world position for Claude to stand + const localPos = new THREE.Vector3(x, 0.3, z) + const toCenter = new THREE.Vector3(-x, 0, -z).normalize() + localPos.add(toCenter.multiplyScalar(1.2)) + const worldPos = localPos.clone() + zoneGroup.localToWorld(worldPos) + + return { type: config.type, position: worldPos, localPosition: localPos, mesh: stationGroup, label: config.label } + } + + // ========================================================================== + // Zone labels + // ========================================================================== + + private createZoneLabel(sessionId: string, color: number): THREE.Sprite { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + canvas.width = 512 + canvas.height = 96 + + this.drawLabelText(ctx, canvas.width, canvas.height, color, sessionId.slice(0, 8)) + + const texture = new THREE.CanvasTexture(canvas) + texture.minFilter = THREE.LinearFilter + texture.magFilter = THREE.LinearFilter + + const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }) + const sprite = new THREE.Sprite(mat) + sprite.scale.set(5, 1.2, 1) + return sprite + } + + private drawLabelText( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + color: number, + text: string, + keybind?: string, + ): void { + const colorHex = `#${color.toString(16).padStart(6, '0')}` + ctx.clearRect(0, 0, width, height) + + ctx.font = '600 36px system-ui, -apple-system, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + // Truncate if needed + let display = text + const maxW = width - 80 + let tw = ctx.measureText(display).width + if (tw > maxW) { + while (tw > maxW && display.length > 3) { + display = display.slice(0, -1) + tw = ctx.measureText(display + '\u2026').width + } + display += '\u2026' + } + + const full = keybind ? `${keybind} ${display}` : display + const cx = width / 2 + const cy = height / 2 + + // Dark backdrop + ctx.save() + ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 12 + ctx.fillStyle = 'rgba(0,0,0,0.8)' + ctx.fillText(full, cx, cy); ctx.fillText(full, cx, cy) + ctx.restore() + + // Outer glow + ctx.save() + ctx.shadowColor = colorHex; ctx.shadowBlur = 30 + ctx.fillStyle = colorHex; ctx.globalAlpha = 0.3 + ctx.fillText(full, cx, cy); ctx.fillText(full, cx, cy) + ctx.restore() + + // Middle glow + ctx.save() + ctx.shadowColor = colorHex; ctx.shadowBlur = 12 + ctx.fillStyle = colorHex; ctx.globalAlpha = 0.5 + ctx.fillText(full, cx, cy) + ctx.restore() + + // Inner glow + ctx.save() + ctx.shadowColor = colorHex; ctx.shadowBlur = 4 + ctx.fillStyle = colorHex; ctx.globalAlpha = 0.8 + ctx.fillText(full, cx, cy) + ctx.restore() + + // Crisp white text + ctx.fillStyle = '#ffffff' + ctx.fillText(full, cx, cy) + } + + updateZoneLabel(sessionId: string, newLabel: string, keybind?: string): void { + const zone = this.zones.get(sessionId) + if (!zone?.label) return + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + canvas.width = 512 + canvas.height = 96 + + this.drawLabelText(ctx, canvas.width, canvas.height, zone.color, newLabel, keybind) + + const mat = zone.label.material as THREE.SpriteMaterial + mat.map?.dispose() + const texture = new THREE.CanvasTexture(canvas) + texture.minFilter = THREE.LinearFilter + texture.magFilter = THREE.LinearFilter + mat.map = texture + mat.needsUpdate = true + } + + // ========================================================================== + // Station context text + // ========================================================================== + + private createTextSprite(text: string, color = '#ffffff'): THREE.Sprite { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + canvas.width = 512 + canvas.height = 96 + const maxW = canvas.width - 60 + + let fontSize = 28 + const minFontSize = 14 + ctx.font = `600 ${fontSize}px system-ui, -apple-system, sans-serif` + while (ctx.measureText(text).width > maxW && fontSize > minFontSize) { + fontSize -= 2 + ctx.font = `600 ${fontSize}px system-ui, -apple-system, sans-serif` + } + + let display = text + if (ctx.measureText(text).width > maxW) { + if (text.includes('/')) { + const parts = text.split('/') + display = parts.length >= 2 ? '.../' + parts.slice(-2).join('/') : '.../' + parts.pop()! + } + if (ctx.measureText(display).width > maxW) { + const mc = Math.floor(maxW / (fontSize * 0.6)) + const half = Math.floor((mc - 3) / 2) + display = text.slice(0, half) + '...' + text.slice(-half) + } + } + + const cx = canvas.width / 2 + const cy = canvas.height / 2 + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + // Dark backdrop + ctx.save() + ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 10 + ctx.fillStyle = 'rgba(0,0,0,0.8)' + ctx.fillText(display, cx, cy); ctx.fillText(display, cx, cy) + ctx.restore() + + // Outer glow + ctx.save() + ctx.shadowColor = color; ctx.shadowBlur = 20 + ctx.fillStyle = color; ctx.globalAlpha = 0.4 + ctx.fillText(display, cx, cy); ctx.fillText(display, cx, cy) + ctx.restore() + + // Inner glow + ctx.save() + ctx.shadowColor = color; ctx.shadowBlur = 6 + ctx.fillStyle = color; ctx.globalAlpha = 0.7 + ctx.fillText(display, cx, cy) + ctx.restore() + + // Main text + ctx.fillStyle = '#ffffff' + ctx.fillText(display, cx, cy) + + const texture = new THREE.CanvasTexture(canvas) + texture.minFilter = THREE.LinearFilter + texture.magFilter = THREE.LinearFilter + + const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }) + const sprite = new THREE.Sprite(mat) + sprite.scale.set(4, 0.8, 1) + return sprite + } + + setStationContext(stationType: StationType, context: string | null, sessionId?: string): void { + let stations: Map + if (sessionId) { + const zone = this.zones.get(sessionId) + if (!zone) return + stations = zone.stations + } else { + stations = this.stations + } + + const station = stations.get(stationType) + if (!station) return + + if (station.contextSprite) { + station.mesh.remove(station.contextSprite) + station.contextSprite.material.map?.dispose() + station.contextSprite.material.dispose() + station.contextSprite = undefined + } + + if (context) { + const c = this.getStationColor(stationType) + station.contextSprite = this.createTextSprite(context, c) + station.contextSprite.position.set(0, 2.5, 0) + station.mesh.add(station.contextSprite) + } + } + + clearAllContexts(sessionId?: string): void { + if (sessionId) { + const zone = this.zones.get(sessionId) + if (!zone) return + for (const [type] of zone.stations) this.setStationContext(type, null, sessionId) + } else { + for (const [zoneId, zone] of this.zones) { + for (const [type] of zone.stations) this.setStationContext(type, null, zoneId) + } + } + } + + private getStationColor(type: StationType): string { + const colors: Record = { + center: '#4ac8e8', + bookshelf: '#fbbf24', + desk: '#4ade80', + workbench: '#f97316', + terminal: '#22d3ee', + scanner: '#60a5fa', + antenna: '#4ac8e8', + portal: '#22d3d8', + taskboard: '#fb923c', + } + return colors[type] || '#ffffff' + } + + // ========================================================================== + // Zone activity / attention + // ========================================================================== + + pulseZone(sessionId: string): void { + const zone = this.zones.get(sessionId) + if (!zone) return + zone.pulseIntensity = 1.0 + this.emitParticles(zone) + } + + setZoneAttention(sessionId: string, reason: AttentionReason): void { + const zone = this.zones.get(sessionId) + if (!zone) return + zone.attentionReason = reason + zone.attentionTime = 0 + } + + clearZoneAttention(sessionId: string): void { + const zone = this.zones.get(sessionId) + if (!zone) return + zone.attentionReason = null + zone.attentionTime = 0 + zone.ring.scale.setScalar(1) + } + + setZoneStatus(sessionId: string, status: Zone['status']): void { + const zone = this.zones.get(sessionId) + if (!zone) return + + zone.status = status + const floorMat = zone.floor.material as THREE.MeshStandardMaterial + const ringMat = zone.ring.material as THREE.MeshBasicMaterial + + const statusColors: Record = { + idle: { emissive: zone.color, intensity: 0.02, ring: zone.color, ringOpacity: 0.4 }, + working: { emissive: 0x22d3ee, intensity: 0.08, ring: 0x22d3ee, ringOpacity: 0.5 }, + waiting: { emissive: 0xfbbf24, intensity: 0.06, ring: 0xfbbf24, ringOpacity: 0.6 }, + attention: { emissive: 0xf87171, intensity: 0.10, ring: 0xf87171, ringOpacity: 0.7 }, + offline: { emissive: 0x404050, intensity: 0.01, ring: 0x404050, ringOpacity: 0.2 }, + } + + const c = statusColors[status] + floorMat.emissive.setHex(c.emissive) + floorMat.emissiveIntensity = c.intensity + ringMat.color.setHex(c.ring) + ringMat.opacity = c.ringOpacity + } + + getZonesNeedingAttention(): { id: string; reason: AttentionReason }[] { + const result: { id: string; reason: AttentionReason }[] = [] + for (const [id, zone] of this.zones) { + if (zone.attentionReason) result.push({ id, reason: zone.attentionReason }) + } + return result + } + + // ========================================================================== + // Station pulse + // ========================================================================== + + pulseStation(sessionId: string, stationType: StationType): void { + const zone = this.zones.get(sessionId) + if (!zone) return + + const station = zone.stations.get(stationType) + if (!station || stationType === 'center') return + + let ring: THREE.Mesh | undefined + station.mesh.traverse((child) => { + if (child instanceof THREE.Mesh && child.geometry instanceof THREE.RingGeometry) ring = child + }) + if (!ring) return + + const ringMat = ring.material as THREE.MeshBasicMaterial + const baseOpacity = ringMat.opacity + + if (this.stationPulses.some((p) => p.ring === ring)) return + + this.stationPulses.push({ + ring, + age: 0, + maxAge: 1.3, + baseOpacity, + peakOpacity: Math.min(1, baseOpacity + 0.5), + }) + } + + // ========================================================================== + // Particles + // ========================================================================== + + private createParticleSystem(color: number): { particles: THREE.Points; velocities: Float32Array } { + const count = 20 + const positions = new Float32Array(count * 3) + const velocities = new Float32Array(count * 3) + + for (let i = 0; i < count; i++) { + positions[i * 3] = 0 + positions[i * 3 + 1] = -1000 + positions[i * 3 + 2] = 0 + } + + const geo = new THREE.BufferGeometry() + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + + const mat = new THREE.PointsMaterial({ + color, + size: 0.15, + transparent: true, + opacity: 0.8, + blending: THREE.AdditiveBlending, + depthWrite: false, + }) + + return { particles: new THREE.Points(geo, mat), velocities } + } + + private emitParticles(zone: Zone): void { + const positions = zone.particles.geometry.attributes.position.array as Float32Array + const velocities = zone.particleVelocities + let activated = 0 + + for (let i = 0; i < positions.length / 3 && activated < 5; i++) { + if (positions[i * 3 + 1] < -5) { + positions[i * 3] = (Math.random() - 0.5) * 2 + positions[i * 3 + 1] = 0.5 + positions[i * 3 + 2] = (Math.random() - 0.5) * 2 + + velocities[i * 3] = (Math.random() - 0.5) * 2 + velocities[i * 3 + 1] = 2 + Math.random() * 2 + velocities[i * 3 + 2] = (Math.random() - 0.5) * 2 + activated++ + } + } + + zone.particles.geometry.attributes.position.needsUpdate = true + } + + // ========================================================================== + // Click pulses + // ========================================================================== + + spawnClickPulse(x: number, z: number, color = 0x4ac8e8, y = 0.03): void { + const hexRadius = this.hexGrid.hexRadius + const clickedHex = this.hexGrid.cartesianToHex(x, z) + + // Expanding ring + const ringGeo = new THREE.RingGeometry(0.2, 0.4, 32) + const ringMat = new THREE.MeshBasicMaterial({ + color: 0x8eefff, + transparent: true, + opacity: 0.9, + side: THREE.DoubleSide, + blending: THREE.AdditiveBlending, + depthWrite: false, + }) + const ring = new THREE.Mesh(ringGeo, ringMat) + ring.rotation.x = -Math.PI / 2 + ring.position.set(x, y, z) + this.scene.add(ring) + + this.clickPulses.push({ mesh: ring, age: 0, maxAge: 0.5, type: 'ring' }) + + // Hex wave ripple + const spawnHexRing = (ringNum: number, strength: number) => { + const hexes = ringNum === 0 ? [clickedHex] : this.getHexRing(clickedHex, ringNum) + + for (const hex of hexes) { + const center = this.hexGrid.axialToCartesian(hex) + const pts: THREE.Vector3[] = [] + for (let i = 0; i <= 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 2 + pts.push(new THREE.Vector3( + center.x + hexRadius * Math.cos(angle), + 0.02, + center.z + hexRadius * Math.sin(angle), + )) + } + + const geo = new THREE.BufferGeometry().setFromPoints(pts) + const mat = new THREE.LineBasicMaterial({ + color: 0x8eefff, + transparent: true, + opacity: strength, + blending: THREE.AdditiveBlending, + depthWrite: false, + }) + const line = new THREE.Line(geo, mat) + this.scene.add(line) + + this.clickPulses.push({ + mesh: line, + age: 0, + maxAge: 0.5, + type: 'ripple', + startOpacity: strength, + }) + } + } + + const maxRings = 7 + const msPerRing = 45 + + for (let r = 0; r <= maxRings; r++) { + const strength = Math.pow(0.6, r) + if (strength < 0.03) continue + if (r === 0) { + spawnHexRing(0, strength) + } else { + setTimeout(() => spawnHexRing(r, strength), r * msPerRing) + } + } + } + + private getHexRing(center: { q: number; r: number }, ring: number): Array<{ q: number; r: number }> { + if (ring === 0) return [center] + + const results: Array<{ q: number; r: number }> = [] + const directions = [ + { q: 1, r: 0 }, { q: 1, r: -1 }, { q: 0, r: -1 }, + { q: -1, r: 0 }, { q: -1, r: 1 }, { q: 0, r: 1 }, + ] + + let hex = { q: center.q + ring, r: center.r } + + for (let side = 0; side < 6; side++) { + for (let step = 0; step < ring; step++) { + results.push({ ...hex }) + const dir = directions[(side + 2) % 6] + hex = { q: hex.q + dir.q, r: hex.r + dir.r } + } + } + + return results + } + + private updateClickPulses(delta: number): void { + for (let i = this.clickPulses.length - 1; i >= 0; i--) { + const pulse = this.clickPulses[i] + + if (pulse.delay && pulse.delay > 0) { pulse.delay -= delta; continue } + + pulse.age += delta + const progress = pulse.age / pulse.maxAge + + if (progress >= 1) { + this.scene.remove(pulse.mesh) + pulse.mesh.geometry.dispose() + ;(pulse.mesh.material as THREE.Material).dispose() + this.clickPulses.splice(i, 1) + } else if (pulse.type === 'ring') { + const scale = 1 + progress * 4 + pulse.mesh.scale.set(scale, scale, 1) + ;(pulse.mesh.material as THREE.MeshBasicMaterial).opacity = 0.9 * (1 - progress * progress) + } else if (pulse.type === 'ripple') { + const mat = pulse.mesh.material as THREE.LineBasicMaterial + const peak = pulse.startOpacity ?? 1.0 + mat.opacity = peak * Math.pow(1 - progress, 2) + } else { + const pulsePhase = Math.sin(progress * Math.PI * 2) * 0.3 + const fadeOut = 1 - progress * progress + ;(pulse.mesh.material as THREE.LineBasicMaterial).opacity = Math.min(1, (0.7 + pulsePhase) * fadeOut) + } + } + } + + private updateStationPulses(delta: number): void { + for (let i = this.stationPulses.length - 1; i >= 0; i--) { + const pulse = this.stationPulses[i] + pulse.age += delta + const progress = pulse.age / pulse.maxAge + + if (progress >= 1) { + (pulse.ring.material as THREE.MeshBasicMaterial).opacity = pulse.baseOpacity + this.stationPulses.splice(i, 1) + } else { + const mat = pulse.ring.material as THREE.MeshBasicMaterial + const fadeInEnd = 0.23 + const holdEnd = 0.62 + + let opacity: number + if (progress < fadeInEnd) { + opacity = pulse.baseOpacity + (pulse.peakOpacity - pulse.baseOpacity) * (progress / fadeInEnd) + } else if (progress < holdEnd) { + opacity = pulse.peakOpacity + } else { + const t = (progress - holdEnd) / (1 - holdEnd) + opacity = pulse.peakOpacity - (pulse.peakOpacity - pulse.baseOpacity) * t + } + mat.opacity = opacity + } + } + } + + // ========================================================================== + // Spawn beams + // ========================================================================== + + launchSpawnBeam(fromSessionId: string, toSessionId: string): void { + const fromZone = this.zones.get(fromSessionId) + const toZone = this.zones.get(toSessionId) + if (!fromZone || !toZone) return + + const portal = fromZone.stations.get('portal') + if (!portal) return + + const from = new THREE.Vector3( + fromZone.position.x + portal.localPosition.x, + fromZone.elevation + 0.5, + fromZone.position.z + portal.localPosition.z, + ) + const to = new THREE.Vector3( + toZone.position.x, + toZone.elevation + 0.5, + toZone.position.z, + ) + + this.spawnBeams.launch(from, to, toZone.color) + } + + // ========================================================================== + // Floating notifications (legacy) + // ========================================================================== + + showNotification(sessionId: string, text: string, color = '#4ade80'): void { + const zone = this.zones.get(sessionId) + if (!zone) return + + const sprite = this.createNotificationSprite(text, color) + const zoneCenter = zone.floor.position.clone() + const startY = 2.5 + sprite.position.set(zoneCenter.x, startY, zoneCenter.z) + this.scene.add(sprite) + + this.notifications.push({ sprite, startY, age: 0, maxAge: 3 }) + } + + private createNotificationSprite(text: string, color: string): THREE.Sprite { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + canvas.width = 512 + canvas.height = 64 + + const fontSize = 24 + ctx.font = `600 ${fontSize}px ui-monospace, SFMono-Regular, monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + const cx = canvas.width / 2 + const cy = canvas.height / 2 + + const tw = ctx.measureText(text).width + const pad = 20 + const pw = tw + pad * 2 + const ph = 40 + + ctx.fillStyle = 'rgba(0,0,0,0.85)' + ctx.beginPath() + ctx.roundRect(cx - pw / 2, cy - ph / 2, pw, ph, 8) + ctx.fill() + + ctx.fillStyle = color + ctx.fillText(text, cx, cy) + + const texture = new THREE.CanvasTexture(canvas) + texture.needsUpdate = true + const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 1, depthTest: false }) + const sprite = new THREE.Sprite(mat) + sprite.scale.set(4, 0.5, 1) + return sprite + } + + private updateNotifications(delta: number): void { + for (let i = this.notifications.length - 1; i >= 0; i--) { + const n = this.notifications[i] + n.age += delta + const progress = n.age / n.maxAge + + if (progress >= 1) { + this.scene.remove(n.sprite) + n.sprite.material.map?.dispose() + n.sprite.material.dispose() + this.notifications.splice(i, 1) + } else { + n.sprite.position.y = n.startY + progress * 1.5 + const fadeStart = 0.6 + n.sprite.material.opacity = progress < fadeStart ? 1 : 1 - (progress - fadeStart) / (1 - fadeStart) + } + } + } + + // ========================================================================== + // Resize + // ========================================================================== + + private handleResize = (): void => { + const w = this.container.clientWidth + const h = this.container.clientHeight + this.camera.aspect = w / h + this.camera.updateProjectionMatrix() + this.renderer.setSize(w, h) + } + + // ========================================================================== + // Render callbacks + // ========================================================================== + + onRender(callback: (delta: number) => void): void { + this.onRenderCallbacks.push(callback) + } + + offRender(callback: (delta: number) => void): void { + const idx = this.onRenderCallbacks.indexOf(callback) + if (idx !== -1) this.onRenderCallbacks.splice(idx, 1) + } + + // ========================================================================== + // Animation loop + // ========================================================================== + + start(): void { + const animate = () => { + this.animationId = requestAnimationFrame(animate) + const delta = this.clock.getDelta() + + // Camera animation + if (this.cameraAnimating) { + const lf = 1 - Math.exp(-this.cameraLerpSpeed * delta) + this.camera.position.lerp(this.cameraTargetPos, lf) + this.controls.target.lerp(this.cameraTargetLookAt, lf) + + if ( + this.camera.position.distanceTo(this.cameraTargetPos) < 0.01 && + this.controls.target.distanceTo(this.cameraTargetLookAt) < 0.01 + ) { + this.camera.position.copy(this.cameraTargetPos) + this.controls.target.copy(this.cameraTargetLookAt) + this.cameraAnimating = false + } + } + + this.controls.update() + this.time += delta + + // External callbacks + for (const cb of this.onRenderCallbacks) cb(delta) + + // Ambient particles + this.updateAmbientParticles(delta) + + // Zone animations + const zonesToFinalize: string[] = [] + for (const zone of this.zones.values()) { + // Enter animation + if (zone.animationState === 'entering') { + zone.animationProgress = Math.min(1, (zone.animationProgress ?? 0) + delta * 2) + const t = zone.animationProgress + const eased = 1 - Math.pow(1 - t, 3) + + zone.group.scale.setScalar(eased) + + const ringMat = zone.ring.material as THREE.MeshBasicMaterial + const floorMat = zone.floor.material as THREE.MeshStandardMaterial + ringMat.opacity = eased * 0.4 + floorMat.opacity = eased + + if (t > 0.5) { + for (const s of zone.stations.values()) s.mesh.visible = true + zone.particles.visible = true + } + if (t > 0.7 && zone.label) zone.label.visible = true + if (t >= 1) { + zone.animationState = undefined + zone.animationProgress = undefined + } + } + // Exit animation + else if (zone.animationState === 'exiting') { + zone.animationProgress = Math.min(1, (zone.animationProgress ?? 0) + delta * 2.5) + const t = zone.animationProgress + const eased = 1 - Math.pow(t, 2) + + zone.group.scale.setScalar(Math.max(0.01, eased)) + + const ringMat = zone.ring.material as THREE.MeshBasicMaterial + const floorMat = zone.floor.material as THREE.MeshStandardMaterial + ringMat.opacity = eased * 0.4 + floorMat.opacity = eased + + if (t > 0.3) { if (zone.label) zone.label.visible = false; zone.particles.visible = false } + if (t > 0.5) { for (const s of zone.stations.values()) s.mesh.visible = false } + if (t >= 1) zonesToFinalize.push(zone.id) + } + + // Station floating bob + for (const station of zone.stations.values()) { + if (station.type !== 'center') { + station.mesh.position.y = Math.sin(this.time * 1.5 + station.position.x * 0.5) * 0.03 + } + } + + // Ring pulse / attention animation + if (zone.attentionReason) { + zone.attentionTime += delta + if (zone.attentionReason === 'finished') { + const p = Math.sin(zone.attentionTime * 2) * 0.5 + 0.5 + zone.ring.scale.setScalar(1 + p * 0.02) + } else { + const p = Math.sin(zone.attentionTime * 4) * 0.5 + 0.5 + zone.ring.scale.setScalar(1 + p * 0.08) + } + } else if (zone.pulseIntensity > 0) { + zone.pulseIntensity = Math.max(0, zone.pulseIntensity - delta * 0.5) + zone.ring.scale.setScalar(1 + zone.pulseIntensity * 0.05) + } else { + zone.ring.scale.setScalar(1) + } + + // Update zone particles + const positions = zone.particles.geometry.attributes.position.array as Float32Array + const velocities = zone.particleVelocities + let needsUpdate = false + + for (let i = 0; i < positions.length / 3; i++) { + if (positions[i * 3 + 1] > -5) { + positions[i * 3] += velocities[i * 3] * delta + positions[i * 3 + 1] += velocities[i * 3 + 1] * delta + positions[i * 3 + 2] += velocities[i * 3 + 2] * delta + velocities[i * 3 + 1] -= 5 * delta + if (positions[i * 3 + 1] < 0) positions[i * 3 + 1] = -1000 + needsUpdate = true + } + } + if (needsUpdate) zone.particles.geometry.attributes.position.needsUpdate = true + } + + // Finalize deletions + for (const id of zonesToFinalize) this.finalizeZoneDelete(id) + + // Update effects + this.updateClickPulses(delta) + this.updateStationPulses(delta) + this.updateNotifications(delta) + + // Update subsystems + this.zoneNotifications.update(delta) + this.spawnBeams.update(delta) + this.stationPanels.update() + + // Render + this.renderer.render(this.scene, this.camera) + } + + animate() + } + + stop(): void { + if (this.animationId !== null) { + cancelAnimationFrame(this.animationId) + this.animationId = null + } + } + + // ========================================================================== + // Dispose + // ========================================================================== + + dispose(): void { + this.stop() + this.clearAllContexts() + this.zoneNotifications.dispose() + + for (const notif of this.notifications) { + this.scene.remove(notif.sprite) + notif.sprite.material.map?.dispose() + notif.sprite.material.dispose() + } + this.notifications = [] + + for (const pulse of this.clickPulses) { + this.scene.remove(pulse.mesh) + pulse.mesh.geometry.dispose() + ;(pulse.mesh.material as THREE.MeshBasicMaterial).dispose() + } + this.clickPulses = [] + + this.renderer.domElement.removeEventListener('mousemove', this.handleHover) + this.renderer.domElement.removeEventListener('mouseleave', this.handleHoverLeave) + window.removeEventListener('resize', this.handleResize) + this.renderer.dispose() + this.container.removeChild(this.renderer.domElement) + } +} diff --git a/src/workshop/scene/ZoneNotifications.ts b/src/workshop/scene/ZoneNotifications.ts new file mode 100644 index 0000000..5e3e5b8 --- /dev/null +++ b/src/workshop/scene/ZoneNotifications.ts @@ -0,0 +1,378 @@ +/** + * ZoneNotifications - Floating notification system for zones + * + * Shows contextual notifications that float up and fade out above zones. + * Useful for showing file changes, command results, search results, etc. + * + * Features: + * - Multiple notification styles (success, info, warning, error) + * - Stacking: multiple notifications don't overlap + * - Configurable duration and animation + * - Icon support for quick visual scanning + */ + +import * as THREE from 'three' + +// ============================================================================ +// Types +// ============================================================================ + +export type NotificationStyle = 'success' | 'info' | 'warning' | 'error' | 'muted' + +export interface NotificationOptions { + /** Text to display */ + text: string + /** Optional icon/emoji prefix */ + icon?: string + /** Style determines color */ + style?: NotificationStyle + /** Duration in seconds (default: 3) */ + duration?: number + /** Custom color override (hex string like '#ff0000') */ + color?: string +} + +interface ActiveNotification { + sprite: THREE.Sprite + zoneId: string + startY: number + targetY: number // For stacking + age: number + maxAge: number + slot: number // Vertical slot for stacking +} + +// ============================================================================ +// Style Configuration +// ============================================================================ + +const STYLE_COLORS: Record = { + success: '#4ade80', // Green + info: '#60a5fa', // Blue + warning: '#fbbf24', // Amber + error: '#f87171', // Red + muted: '#9ca3af', // Gray +} + +// Tool to style mapping +const TOOL_STYLES: Record = { + // File operations + Read: { style: 'info', icon: '📖' }, + Edit: { style: 'warning', icon: '✏️' }, + Write: { style: 'success', icon: '📝' }, + + // Search operations + Grep: { style: 'info', icon: '🔍' }, + Glob: { style: 'info', icon: '📁' }, + + // Terminal + Bash: { style: 'muted', icon: '⚡' }, + + // Web + WebFetch: { style: 'info', icon: '🌐' }, + WebSearch: { style: 'info', icon: '🔎' }, + + // Tasks + Task: { style: 'success', icon: '🚀' }, + TodoWrite: { style: 'info', icon: '☑️' }, + + // Other + AskUserQuestion: { style: 'warning', icon: '❓' }, + NotebookEdit: { style: 'warning', icon: '📓' }, +} + +// ============================================================================ +// ZoneNotifications Class +// ============================================================================ + +export class ZoneNotifications { + private scene: THREE.Scene + private notifications: ActiveNotification[] = [] + private zonePositions: Map = new Map() + private zoneElevations: Map = new Map() + + // Configuration + private readonly BASE_Y = 2.5 // Starting height above zone + private readonly STACK_SPACING = 0.6 // Vertical space between stacked notifications + private readonly MAX_STACK = 5 // Max notifications per zone + private readonly FLOAT_DISTANCE = 1.5 // How far to float up + private readonly DEFAULT_DURATION = 3 // Default seconds + + constructor(scene: THREE.Scene) { + this.scene = scene + } + + /** + * Register a zone's position for notifications + */ + registerZone(zoneId: string, position: THREE.Vector3): void { + this.zonePositions.set(zoneId, position.clone()) + } + + /** + * Unregister a zone + */ + unregisterZone(zoneId: string): void { + this.zonePositions.delete(zoneId) + this.zoneElevations.delete(zoneId) + // Remove any active notifications for this zone + this.clearZone(zoneId) + } + + /** + * Update a zone's elevation (for raised zones) + */ + updateZoneElevation(zoneId: string, elevation: number): void { + this.zoneElevations.set(zoneId, elevation) + } + + /** + * Show a notification above a zone + */ + show(zoneId: string, options: NotificationOptions): void { + const zonePos = this.zonePositions.get(zoneId) + if (!zonePos) return + + const style = options.style ?? 'info' + const color = options.color ?? STYLE_COLORS[style] + const duration = options.duration ?? this.DEFAULT_DURATION + const displayText = options.icon ? `${options.icon} ${options.text}` : options.text + + // Find next available slot for this zone + const slot = this.findNextSlot(zoneId) + if (slot >= this.MAX_STACK) { + // Too many notifications, skip or remove oldest + this.removeOldestForZone(zoneId) + } + + // Create sprite + const sprite = this.createSprite(displayText, color) + + // Position with stacking offset, accounting for zone elevation + const zoneElevation = this.zoneElevations.get(zoneId) ?? 0 + const startY = zoneElevation + this.BASE_Y + slot * this.STACK_SPACING + sprite.position.set(zonePos.x, startY, zonePos.z) + this.scene.add(sprite) + + this.notifications.push({ + sprite, + zoneId, + startY, + targetY: startY + this.FLOAT_DISTANCE, + age: 0, + maxAge: duration, + slot, + }) + } + + /** + * Show a tool-specific notification with automatic styling + */ + showForTool( + zoneId: string, + tool: string, + text: string, + options?: Partial + ): void { + const toolConfig = TOOL_STYLES[tool] ?? { style: 'info' as NotificationStyle, icon: '🔧' } + + this.show(zoneId, { + text, + icon: options?.icon ?? toolConfig.icon, + style: options?.style ?? toolConfig.style, + duration: options?.duration, + color: options?.color, + }) + } + + /** + * Clear all notifications for a zone + */ + clearZone(zoneId: string): void { + for (let i = this.notifications.length - 1; i >= 0; i--) { + if (this.notifications[i].zoneId === zoneId) { + this.removeNotification(i) + } + } + } + + /** + * Update all notifications (call from render loop) + */ + update(delta: number): void { + for (let i = this.notifications.length - 1; i >= 0; i--) { + const notif = this.notifications[i] + notif.age += delta + + const progress = notif.age / notif.maxAge + + if (progress >= 1) { + this.removeNotification(i) + } else { + // Animate position: ease out float + const floatProgress = 1 - Math.pow(1 - progress, 2) + const y = notif.startY + (notif.targetY - notif.startY) * floatProgress + notif.sprite.position.y = y + + // Animate opacity: stay visible, then fade + const fadeStart = 0.6 + const opacity = progress < fadeStart + ? 1 + : 1 - Math.pow((progress - fadeStart) / (1 - fadeStart), 2) + notif.sprite.material.opacity = opacity + } + } + } + + /** + * Dispose all resources + */ + dispose(): void { + for (let i = this.notifications.length - 1; i >= 0; i--) { + this.removeNotification(i) + } + this.zonePositions.clear() + } + + // ============================================================================ + // Private Methods + // ============================================================================ + + private createSprite(text: string, color: string): THREE.Sprite { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + + canvas.width = 512 + canvas.height = 96 + + // Font setup + const fontSize = 32 + ctx.font = `600 ${fontSize}px ui-monospace, SFMono-Regular, "SF Mono", monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + const centerX = canvas.width / 2 + const centerY = canvas.height / 2 + + // Measure text for pill background + const textWidth = ctx.measureText(text).width + const padding = 24 + const pillWidth = Math.min(canvas.width - 20, textWidth + padding * 2) + const pillHeight = 56 + const pillX = centerX - pillWidth / 2 + const pillY = centerY - pillHeight / 2 + + // Draw pill background + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)' + ctx.beginPath() + ctx.roundRect(pillX, pillY, pillWidth, pillHeight, 10) + ctx.fill() + + // Draw border + ctx.strokeStyle = color + ctx.lineWidth = 2 + ctx.globalAlpha = 0.5 + ctx.stroke() + ctx.globalAlpha = 1 + + // Draw text + ctx.fillStyle = color + + // Truncate if needed + let displayText = text + while (ctx.measureText(displayText).width > pillWidth - padding * 2 && displayText.length > 10) { + displayText = displayText.slice(0, -4) + '...' + } + ctx.fillText(displayText, centerX, centerY) + + // Create texture and sprite + const texture = new THREE.CanvasTexture(canvas) + texture.needsUpdate = true + + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 1, + depthTest: false, + }) + + const sprite = new THREE.Sprite(material) + sprite.scale.set(7, 1.3, 1) + + return sprite + } + + private findNextSlot(zoneId: string): number { + const usedSlots = new Set() + for (const notif of this.notifications) { + if (notif.zoneId === zoneId) { + usedSlots.add(notif.slot) + } + } + + // Find first available slot + for (let i = 0; i < this.MAX_STACK; i++) { + if (!usedSlots.has(i)) return i + } + return this.MAX_STACK + } + + private removeOldestForZone(zoneId: string): void { + for (let i = 0; i < this.notifications.length; i++) { + if (this.notifications[i].zoneId === zoneId) { + this.removeNotification(i) + return + } + } + } + + private removeNotification(index: number): void { + const notif = this.notifications[index] + this.scene.remove(notif.sprite) + notif.sprite.material.map?.dispose() + notif.sprite.material.dispose() + this.notifications.splice(index, 1) + } +} + +// ============================================================================ +// Helper Functions for Common Notifications +// ============================================================================ + +/** + * Format a file change notification + */ +export function formatFileChange( + fileName: string, + options?: { added?: number; removed?: number; lines?: number } +): string { + if (!options) return fileName + + const parts: string[] = [] + if (options.added && options.added > 0) parts.push(`+${options.added}`) + if (options.removed && options.removed > 0) parts.push(`-${options.removed}`) + if (options.lines) parts.push(`${options.lines} lines`) + + return parts.length > 0 ? `${fileName} ${parts.join(', ')}` : fileName +} + +/** + * Format a command result notification + */ +export function formatCommandResult(command: string, maxLength = 30): string { + // Extract just the command name, not args + const cmdName = command.split(' ')[0].split('/').pop() || command + if (cmdName.length <= maxLength) return cmdName + return cmdName.slice(0, maxLength - 3) + '...' +} + +/** + * Format a search result notification + */ +export function formatSearchResult(pattern: string, matchCount?: number): string { + const truncatedPattern = pattern.length > 20 ? pattern.slice(0, 17) + '...' : pattern + if (matchCount !== undefined) { + return `"${truncatedPattern}" → ${matchCount} matches` + } + return `"${truncatedPattern}"` +} diff --git a/src/workshop/scene/stations/AntennaStation.ts b/src/workshop/scene/stations/AntennaStation.ts new file mode 100644 index 0000000..630b1b9 --- /dev/null +++ b/src/workshop/scene/stations/AntennaStation.ts @@ -0,0 +1,70 @@ +/** + * Antenna Station - WebFetch/WebSearch station decorations + */ + +import * as THREE from 'three' + +export function addAntennaDetails(group: THREE.Group): void { + const metalMaterial = new THREE.MeshStandardMaterial({ + color: 0x666677, + metalness: 0.7, + roughness: 0.3, + }) + + // Main tower pole + const tower = new THREE.Mesh( + new THREE.CylinderGeometry(0.04, 0.06, 1.2), + metalMaterial + ) + tower.position.set(0, 1.4, 0) + group.add(tower) + + // Cross beams (lattice tower look) + for (const y of [1.0, 1.4, 1.8]) { + const beam = new THREE.Mesh( + new THREE.BoxGeometry(0.4, 0.02, 0.02), + metalMaterial + ) + beam.position.set(0, y, 0) + group.add(beam) + } + + // Satellite dish at top + const dishGeometry = new THREE.SphereGeometry(0.25, 12, 6, 0, Math.PI) + const dishMaterial = new THREE.MeshStandardMaterial({ + color: 0xaaaabb, + metalness: 0.8, + roughness: 0.2, + side: THREE.DoubleSide, + }) + const dish = new THREE.Mesh(dishGeometry, dishMaterial) + dish.position.set(0.15, 1.85, 0) + dish.rotation.x = -Math.PI / 3 + dish.rotation.z = -0.3 + group.add(dish) + + // Signal waves (decorative rings) + const waveMaterial = new THREE.MeshBasicMaterial({ + color: 0x66aaff, + transparent: true, + opacity: 0.3, + side: THREE.DoubleSide, + }) + for (let i = 0; i < 2; i++) { + const wave = new THREE.Mesh( + new THREE.RingGeometry(0.15 + i * 0.12, 0.18 + i * 0.12, 16), + waveMaterial + ) + wave.position.set(0.3 + i * 0.15, 1.95, 0.1) + wave.rotation.y = Math.PI / 3 + group.add(wave) + } + + // Small blinking light at top + const light = new THREE.Mesh( + new THREE.SphereGeometry(0.03, 8, 8), + new THREE.MeshBasicMaterial({ color: 0xff4444 }) + ) + light.position.set(0, 2.0, 0) + group.add(light) +} diff --git a/src/workshop/scene/stations/BookshelfStation.ts b/src/workshop/scene/stations/BookshelfStation.ts new file mode 100644 index 0000000..ebebb86 --- /dev/null +++ b/src/workshop/scene/stations/BookshelfStation.ts @@ -0,0 +1,45 @@ +/** + * Bookshelf Station - Library/reading station decorations + */ + +import * as THREE from 'three' + +export function addBookshelfDetails(group: THREE.Group): void { + const shelfMaterial = new THREE.MeshStandardMaterial({ + color: 0x3a5a6a, // Blue-gray metallic + roughness: 0.6, + metalness: 0.3, + }) + + // Vertical sides + for (const xOffset of [-0.7, 0.7]) { + const side = new THREE.Mesh( + new THREE.BoxGeometry(0.1, 1.5, 0.8), + shelfMaterial + ) + side.position.set(xOffset, 1.15, 0) + side.castShadow = true + group.add(side) + } + + // Shelves + for (const yOffset of [0.9, 1.4]) { + const shelf = new THREE.Mesh( + new THREE.BoxGeometry(1.4, 0.05, 0.8), + shelfMaterial + ) + shelf.position.set(0, yOffset, 0) + group.add(shelf) + } + + // Books (simple colored boxes) + const bookColors = [0xcc3333, 0x33cc33, 0x3333cc, 0xcccc33, 0xcc33cc] + for (let i = 0; i < 5; i++) { + const book = new THREE.Mesh( + new THREE.BoxGeometry(0.15, 0.35, 0.5), + new THREE.MeshStandardMaterial({ color: bookColors[i] }) + ) + book.position.set(-0.4 + i * 0.2, 1.1, 0) + group.add(book) + } +} diff --git a/src/workshop/scene/stations/DeskStation.ts b/src/workshop/scene/stations/DeskStation.ts new file mode 100644 index 0000000..01a3384 --- /dev/null +++ b/src/workshop/scene/stations/DeskStation.ts @@ -0,0 +1,38 @@ +/** + * Desk Station - Write tool station decorations + */ + +import * as THREE from 'three' + +export function addDeskDetails(group: THREE.Group): void { + // Notepad/paper + const paperGeometry = new THREE.BoxGeometry(0.6, 0.02, 0.8) + const paperMaterial = new THREE.MeshStandardMaterial({ + color: 0xf5f5dc, + roughness: 0.9, + }) + const paper = new THREE.Mesh(paperGeometry, paperMaterial) + paper.position.set(0, 0.82, 0) + group.add(paper) + + // Pencil + const pencilGeometry = new THREE.CylinderGeometry(0.02, 0.02, 0.4, 8) + const pencilMaterial = new THREE.MeshStandardMaterial({ + color: 0xffd700, + }) + const pencil = new THREE.Mesh(pencilGeometry, pencilMaterial) + pencil.position.set(0.35, 0.85, 0.2) + pencil.rotation.z = Math.PI / 2 + pencil.rotation.y = 0.3 + group.add(pencil) + + // Ink pot + const inkGeometry = new THREE.CylinderGeometry(0.08, 0.1, 0.15, 16) + const inkMaterial = new THREE.MeshStandardMaterial({ + color: 0x1a1a2e, + metalness: 0.3, + }) + const ink = new THREE.Mesh(inkGeometry, inkMaterial) + ink.position.set(-0.4, 0.88, -0.2) + group.add(ink) +} diff --git a/src/workshop/scene/stations/PortalStation.ts b/src/workshop/scene/stations/PortalStation.ts new file mode 100644 index 0000000..0c2bb7f --- /dev/null +++ b/src/workshop/scene/stations/PortalStation.ts @@ -0,0 +1,32 @@ +/** + * Portal Station - Task/subagent spawning station decorations + */ + +import * as THREE from 'three' + +export function addPortalDetails(group: THREE.Group): void { + // Portal ring + const ringGeometry = new THREE.TorusGeometry(0.6, 0.1, 8, 32) + const ringMaterial = new THREE.MeshStandardMaterial({ + color: 0x8844ff, + emissive: 0x4422aa, + emissiveIntensity: 0.5, + }) + const ring = new THREE.Mesh(ringGeometry, ringMaterial) + ring.position.set(0, 1.3, 0) + ring.rotation.x = Math.PI / 2 + group.add(ring) + + // Portal center (glowing) + const portalGeometry = new THREE.CircleGeometry(0.5, 32) + const portalMaterial = new THREE.MeshBasicMaterial({ + color: 0xaa66ff, + transparent: true, + opacity: 0.5, + side: THREE.DoubleSide, + }) + const portal = new THREE.Mesh(portalGeometry, portalMaterial) + portal.position.set(0, 1.3, 0) + portal.rotation.x = Math.PI / 2 + group.add(portal) +} diff --git a/src/workshop/scene/stations/ScannerStation.ts b/src/workshop/scene/stations/ScannerStation.ts new file mode 100644 index 0000000..fbe0380 --- /dev/null +++ b/src/workshop/scene/stations/ScannerStation.ts @@ -0,0 +1,65 @@ +/** + * Scanner Station - Grep/Glob search station decorations + */ + +import * as THREE from 'three' + +export function addScannerDetails(group: THREE.Group): void { + // Magnifying glass handle + const handleMaterial = new THREE.MeshStandardMaterial({ + color: 0x4a5a6a, // Blue-gray + roughness: 0.5, + metalness: 0.4, + }) + const handle = new THREE.Mesh( + new THREE.CylinderGeometry(0.04, 0.05, 0.5, 12), + handleMaterial + ) + handle.position.set(0.15, 1.0, 0) + handle.rotation.z = -Math.PI / 4 + group.add(handle) + + // Magnifying glass rim + const rimMaterial = new THREE.MeshStandardMaterial({ + color: 0xc9a227, + metalness: 0.7, + roughness: 0.3, + }) + const rim = new THREE.Mesh( + new THREE.TorusGeometry(0.28, 0.04, 12, 24), + rimMaterial + ) + rim.position.set(-0.05, 1.35, 0) + rim.rotation.x = Math.PI / 2 + rim.rotation.y = 0.3 + group.add(rim) + + // Glass lens + const lensMaterial = new THREE.MeshStandardMaterial({ + color: 0xaaddff, + transparent: true, + opacity: 0.4, + metalness: 0.1, + roughness: 0.1, + }) + const lens = new THREE.Mesh( + new THREE.CircleGeometry(0.26, 24), + lensMaterial + ) + lens.position.set(-0.05, 1.35, 0.01) + lens.rotation.y = 0.3 + group.add(lens) + + // Glint/reflection on lens + const glint = new THREE.Mesh( + new THREE.CircleGeometry(0.06, 12), + new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.6, + }) + ) + glint.position.set(-0.12, 1.42, 0.02) + glint.rotation.y = 0.3 + group.add(glint) +} diff --git a/src/workshop/scene/stations/TaskboardStation.ts b/src/workshop/scene/stations/TaskboardStation.ts new file mode 100644 index 0000000..e2009e1 --- /dev/null +++ b/src/workshop/scene/stations/TaskboardStation.ts @@ -0,0 +1,39 @@ +/** + * Taskboard Station - TodoWrite station decorations + */ + +import * as THREE from 'three' + +export function addTaskboardDetails(group: THREE.Group): void { + // Board backing + const boardGeometry = new THREE.BoxGeometry(1.2, 0.9, 0.05) + const boardMaterial = new THREE.MeshStandardMaterial({ + color: 0x3a3a4e, + roughness: 0.8, + }) + const board = new THREE.Mesh(boardGeometry, boardMaterial) + board.position.set(0, 1.25, -0.3) + board.rotation.x = -0.1 + group.add(board) + + // Task cards (sticky notes) + const cardColors = [0x4ade80, 0xfbbf24, 0x60a5fa, 0xf472b6] + const cardPositions = [ + [-0.35, 1.4, -0.25], + [0.05, 1.4, -0.25], + [-0.35, 1.1, -0.25], + [0.05, 1.1, -0.25], + ] + + cardPositions.forEach((pos, i) => { + const cardGeometry = new THREE.BoxGeometry(0.3, 0.2, 0.01) + const cardMaterial = new THREE.MeshStandardMaterial({ + color: cardColors[i % cardColors.length], + roughness: 0.9, + }) + const card = new THREE.Mesh(cardGeometry, cardMaterial) + card.position.set(pos[0], pos[1], pos[2]) + card.rotation.x = -0.1 + group.add(card) + }) +} diff --git a/src/workshop/scene/stations/TerminalStation.ts b/src/workshop/scene/stations/TerminalStation.ts new file mode 100644 index 0000000..dc091af --- /dev/null +++ b/src/workshop/scene/stations/TerminalStation.ts @@ -0,0 +1,53 @@ +/** + * Terminal Station - Computer/CLI station decorations + */ + +import * as THREE from 'three' + +export function addTerminalDetails(group: THREE.Group): void { + // CRT Monitor frame + const frameGeometry = new THREE.BoxGeometry(1.1, 0.8, 0.3) + const frameMaterial = new THREE.MeshStandardMaterial({ + color: 0x2a2a35, + roughness: 0.8, + }) + const frame = new THREE.Mesh(frameGeometry, frameMaterial) + frame.position.set(0, 1.2, -0.25) + frame.rotation.x = -0.15 + frame.castShadow = true + group.add(frame) + + // Screen inset + const screenGeometry = new THREE.PlaneGeometry(0.85, 0.55) + const screenMaterial = new THREE.MeshStandardMaterial({ + color: 0x0a0a12, + emissive: 0x112244, + emissiveIntensity: 0.3, + }) + const screen = new THREE.Mesh(screenGeometry, screenMaterial) + screen.position.set(0, 1.22, -0.08) + screen.rotation.x = -0.15 + group.add(screen) + + // Terminal text "$ _" using a small plane with green tint + const promptGeometry = new THREE.PlaneGeometry(0.15, 0.08) + const promptMaterial = new THREE.MeshBasicMaterial({ + color: 0x44ff88, + transparent: true, + opacity: 0.9, + }) + const prompt = new THREE.Mesh(promptGeometry, promptMaterial) + prompt.position.set(-0.25, 1.18, -0.06) + prompt.rotation.x = -0.15 + group.add(prompt) + + // Keyboard + const keyboardGeometry = new THREE.BoxGeometry(0.7, 0.03, 0.25) + const keyboardMaterial = new THREE.MeshStandardMaterial({ + color: 0x1a1a22, + roughness: 0.7, + }) + const keyboard = new THREE.Mesh(keyboardGeometry, keyboardMaterial) + keyboard.position.set(0, 0.83, 0.2) + group.add(keyboard) +} diff --git a/src/workshop/scene/stations/WorkbenchStation.ts b/src/workshop/scene/stations/WorkbenchStation.ts new file mode 100644 index 0000000..c00e7b7 --- /dev/null +++ b/src/workshop/scene/stations/WorkbenchStation.ts @@ -0,0 +1,85 @@ +/** + * Workbench Station - Edit tool station decorations + */ + +import * as THREE from 'three' + +export function addWorkbenchDetails(group: THREE.Group): void { + const metalMaterial = new THREE.MeshStandardMaterial({ + color: 0x888899, + metalness: 0.8, + roughness: 0.2, + }) + + // Vice/clamp on the side + const viceBase = new THREE.Mesh( + new THREE.BoxGeometry(0.2, 0.15, 0.15), + metalMaterial + ) + viceBase.position.set(-0.55, 0.88, 0) + group.add(viceBase) + + const viceJaw = new THREE.Mesh( + new THREE.BoxGeometry(0.08, 0.2, 0.12), + metalMaterial + ) + viceJaw.position.set(-0.55, 1.0, 0.08) + group.add(viceJaw) + + // Hammer + const hammerHead = new THREE.Mesh( + new THREE.BoxGeometry(0.15, 0.08, 0.08), + metalMaterial + ) + hammerHead.position.set(0.25, 0.88, -0.15) + hammerHead.rotation.y = 0.4 + group.add(hammerHead) + + const hammerHandle = new THREE.Mesh( + new THREE.CylinderGeometry(0.02, 0.025, 0.3, 8), + new THREE.MeshStandardMaterial({ color: 0x4a5a6a, metalness: 0.3 }) // Blue-gray + ) + hammerHandle.position.set(0.35, 0.86, -0.08) + hammerHandle.rotation.z = Math.PI / 2 + hammerHandle.rotation.y = 0.4 + group.add(hammerHandle) + + // Gears (being worked on) + const gearMaterial = new THREE.MeshStandardMaterial({ + color: 0xf97316, + metalness: 0.6, + roughness: 0.3, + }) + const gear1 = new THREE.Mesh( + new THREE.TorusGeometry(0.1, 0.025, 8, 12), + gearMaterial + ) + gear1.position.set(0, 0.85, 0.1) + gear1.rotation.x = Math.PI / 2 + group.add(gear1) + + const gear2 = new THREE.Mesh( + new THREE.TorusGeometry(0.07, 0.02, 8, 10), + gearMaterial + ) + gear2.position.set(-0.15, 0.84, 0.15) + gear2.rotation.x = Math.PI / 2 + group.add(gear2) + + // Screwdriver + const screwdriverHandle = new THREE.Mesh( + new THREE.CylinderGeometry(0.03, 0.035, 0.12, 8), + new THREE.MeshStandardMaterial({ color: 0xcc3333 }) + ) + screwdriverHandle.position.set(0.4, 0.87, 0.2) + screwdriverHandle.rotation.z = Math.PI / 2 + 0.2 + group.add(screwdriverHandle) + + const screwdriverShaft = new THREE.Mesh( + new THREE.CylinderGeometry(0.012, 0.012, 0.15, 8), + metalMaterial + ) + screwdriverShaft.position.set(0.28, 0.85, 0.18) + screwdriverShaft.rotation.z = Math.PI / 2 + 0.2 + group.add(screwdriverShaft) +} diff --git a/src/workshop/scene/stations/index.ts b/src/workshop/scene/stations/index.ts new file mode 100644 index 0000000..319cc7a --- /dev/null +++ b/src/workshop/scene/stations/index.ts @@ -0,0 +1,19 @@ +/** + * Station Details - Modular station decoration functions + * + * Each station function adds decorative meshes to a station group. + * These are pure functions with no external dependencies beyond Three.js. + * + * Usage: + * import { addBookshelfDetails } from './stations' + * addBookshelfDetails(stationGroup) + */ + +export { addBookshelfDetails } from './BookshelfStation' +export { addTerminalDetails } from './TerminalStation' +export { addAntennaDetails } from './AntennaStation' +export { addPortalDetails } from './PortalStation' +export { addScannerDetails } from './ScannerStation' +export { addDeskDetails } from './DeskStation' +export { addWorkbenchDetails } from './WorkbenchStation' +export { addTaskboardDetails } from './TaskboardStation' diff --git a/src/workshop/store/workshop.ts b/src/workshop/store/workshop.ts new file mode 100644 index 0000000..238c0cc --- /dev/null +++ b/src/workshop/store/workshop.ts @@ -0,0 +1,49 @@ +import { create } from 'zustand' + +export interface WorkshopFeedItem { + id: string + timestamp: number + type: 'tool_start' | 'tool_end' | 'text' | 'reasoning' | 'done' | 'error' + label: string + detail?: string + icon?: string +} + +export interface WorkshopSettings { + showFeed: boolean + feedMaxItems: number +} + +interface WorkshopState { + feed: WorkshopFeedItem[] + settings: WorkshopSettings + isSceneReady: boolean + + addFeedItem: (item: WorkshopFeedItem) => void + clearFeed: () => void + setSceneReady: (ready: boolean) => void + updateSettings: (partial: Partial) => void +} + +export const useWorkshopStore = create((set) => ({ + feed: [], + settings: { + showFeed: true, + feedMaxItems: 100, + }, + isSceneReady: false, + + addFeedItem: (item) => + set((state) => ({ + feed: [...state.feed, item].slice(-state.settings.feedMaxItems), + })), + + clearFeed: () => set({ feed: [] }), + + setSceneReady: (ready) => set({ isSceneReady: ready }), + + updateSettings: (partial) => + set((state) => ({ + settings: { ...state.settings, ...partial }, + })), +})) diff --git a/src/workshop/types.ts b/src/workshop/types.ts new file mode 100644 index 0000000..30997fb --- /dev/null +++ b/src/workshop/types.ts @@ -0,0 +1,263 @@ +/** + * Workshop Types — Vendored from Vibecraft2 shared/types.ts + shared/agent-protocol.ts + * + * Merged and trimmed for ClawBox embedding. Removed WebSocket, session management, + * hex art, replay, config, and server-specific types not needed for the embedded viewer. + */ + +// ============================================================================ +// Tool Categories (from agent-protocol.ts) +// ============================================================================ + +export type ToolCategory = + | 'read' + | 'write' + | 'edit' + | 'execute' + | 'search' + | 'network' + | 'delegate' + | 'plan' + | 'interact' + | 'other' + +export const CATEGORY_STATION_MAP: Record = { + read: 'bookshelf', + write: 'desk', + edit: 'workbench', + execute: 'terminal', + search: 'scanner', + network: 'antenna', + delegate: 'portal', + plan: 'taskboard', + interact: 'center', + other: 'center', +} + +export function getStationForCategory(category: ToolCategory): string { + return CATEGORY_STATION_MAP[category] ?? 'center' +} + +export const CATEGORY_ICON_MAP: Record = { + read: '\u{1F4D6}', + write: '\u{1F4DD}', + edit: '\u270F\uFE0F', + execute: '\u{1F4BB}', + search: '\u{1F50D}', + network: '\u{1F310}', + delegate: '\u{1F916}', + plan: '\u{1F4CB}', + interact: '\u{2753}', + other: '\u{1F527}', +} + +// ============================================================================ +// Universal Agent Event Types (from agent-protocol.ts) +// ============================================================================ + +export type AgentEventType = + | 'tool_start' + | 'tool_end' + | 'agent_idle' + | 'agent_thinking' + | 'user_input' + | 'agent_start' + | 'agent_end' + | 'notification' + | 'subagent_spawn' + | 'subagent_end' + +export interface AgentEvent { + id: string + timestamp: number + type: AgentEventType + agentId: string + source: string + cwd?: string + metadata?: Record +} + +export interface ToolInfo { + name: string + category: ToolCategory + id: string +} + +export interface ToolStartEvent extends AgentEvent { + type: 'tool_start' + tool: ToolInfo + input?: Record + context?: string +} + +export interface ToolEndEvent extends AgentEvent { + type: 'tool_end' + tool: ToolInfo + success: boolean + duration?: number + output?: Record +} + +export interface AgentIdleEvent extends AgentEvent { + type: 'agent_idle' + reason?: string + response?: string +} + +export interface AgentThinkingEvent extends AgentEvent { + type: 'agent_thinking' +} + +export interface AgentStartEvent extends AgentEvent { + type: 'agent_start' + trigger?: 'startup' | 'resume' | 'user_input' | 'other' +} + +export interface AgentEndEvent extends AgentEvent { + type: 'agent_end' + reason?: string +} + +export interface UserInputEvent extends AgentEvent { + type: 'user_input' + text: string +} + +export interface AgentNotificationEvent extends AgentEvent { + type: 'notification' + message: string + level?: 'info' | 'warning' | 'error' | 'success' +} + +export interface SubagentSpawnEvent extends AgentEvent { + type: 'subagent_spawn' + parentAgentId: string + description?: string + toolUseId?: string +} + +export interface SubagentEndEvent extends AgentEvent { + type: 'subagent_end' + toolUseId?: string +} + +export type UniversalEvent = + | ToolStartEvent + | ToolEndEvent + | AgentIdleEvent + | AgentThinkingEvent + | AgentStartEvent + | AgentEndEvent + | UserInputEvent + | AgentNotificationEvent + | SubagentSpawnEvent + | SubagentEndEvent + +// ============================================================================ +// Legacy Hook Event Types (from types.ts) +// ============================================================================ + +export type HookEventType = + | 'pre_tool_use' + | 'post_tool_use' + | 'stop' + | 'subagent_stop' + | 'session_start' + | 'session_end' + | 'user_prompt_submit' + | 'notification' + | 'pre_compact' + +export type ToolName = + | 'Read' + | 'Write' + | 'Edit' + | 'Bash' + | 'Grep' + | 'Glob' + | 'WebFetch' + | 'WebSearch' + | 'Task' + | 'TodoWrite' + | 'AskUserQuestion' + | 'NotebookEdit' + | string + +export interface BaseEvent { + id: string + timestamp: number + type: HookEventType + sessionId: string + cwd: string +} + +export interface PreToolUseEvent extends BaseEvent { + type: 'pre_tool_use' + tool: ToolName + toolInput: Record + toolUseId: string + assistantText?: string +} + +export interface PostToolUseEvent extends BaseEvent { + type: 'post_tool_use' + tool: ToolName + toolInput: Record + toolResponse: Record + toolUseId: string + success: boolean + duration?: number +} + +export interface StopEvent extends BaseEvent { + type: 'stop' + stopHookActive: boolean + response?: string +} + +export interface UserPromptSubmitEvent extends BaseEvent { + type: 'user_prompt_submit' + prompt: string +} + +export type ClaudeEvent = + | PreToolUseEvent + | PostToolUseEvent + | StopEvent + | BaseEvent + +// ============================================================================ +// Visualization State (from types.ts) +// ============================================================================ + +export type ClaudeState = 'idle' | 'thinking' | 'working' | 'finished' + +export type StationType = + | 'center' + | 'bookshelf' + | 'desk' + | 'workbench' + | 'terminal' + | 'scanner' + | 'antenna' + | 'portal' + | 'taskboard' + +export const TOOL_STATION_MAP: Record = { + Read: 'bookshelf', + Write: 'desk', + Edit: 'workbench', + Bash: 'terminal', + Grep: 'scanner', + Glob: 'scanner', + WebFetch: 'antenna', + WebSearch: 'antenna', + Task: 'portal', + TodoWrite: 'taskboard', + AskUserQuestion: 'center', + NotebookEdit: 'desk', +} + +export function getStationForTool(tool: string): StationType { + return TOOL_STATION_MAP[tool] ?? 'center' +} diff --git a/src/workshop/utils/HexGrid.ts b/src/workshop/utils/HexGrid.ts new file mode 100644 index 0000000..b7f2aad --- /dev/null +++ b/src/workshop/utils/HexGrid.ts @@ -0,0 +1,219 @@ +/** + * HexGrid — Vendored from Vibecraft2. Hex coordinate math for zone placement. + */ + +export interface HexCoord { + q: number + r: number +} + +interface CubeCoord { + x: number + y: number + z: number +} + +const HEX_DIRECTIONS: HexCoord[] = [ + { q: 1, r: 0 }, + { q: 1, r: -1 }, + { q: 0, r: -1 }, + { q: -1, r: 0 }, + { q: -1, r: 1 }, + { q: 0, r: 1 }, +] + +export class HexGrid { + readonly hexRadius: number + readonly spacing: number + readonly hexWidth: number + readonly hexHeight: number + + private occupied = new Map() + private sessionToHex = new Map() + private spiralIndex = 0 + + constructor(hexRadius = 10, spacing = 1.1) { + this.hexRadius = hexRadius + this.spacing = spacing + this.hexWidth = Math.sqrt(3) * hexRadius * spacing + this.hexHeight = 2 * hexRadius * spacing + } + + axialToCartesian(hex: HexCoord): { x: number; z: number } { + const x = this.hexWidth * (hex.q + hex.r / 2) + const z = this.hexHeight * (3 / 4) * hex.r + return { x, z } + } + + cartesianToAxial(x: number, z: number): { q: number; r: number } { + const r = z / (this.hexHeight * 0.75) + const q = x / this.hexWidth - r / 2 + return { q, r } + } + + roundToHex(q: number, r: number): HexCoord { + const cube = this.axialToCube({ q, r }) + let rx = Math.round(cube.x) + let ry = Math.round(cube.y) + let rz = Math.round(cube.z) + + const dx = Math.abs(rx - cube.x) + const dy = Math.abs(ry - cube.y) + const dz = Math.abs(rz - cube.z) + + if (dx > dy && dx > dz) rx = -ry - rz + else if (dy > dz) ry = -rx - rz + else rz = -rx - ry + + return this.cubeToAxial({ x: rx, y: ry, z: rz }) + } + + cartesianToHex(x: number, z: number): HexCoord { + const { q, r } = this.cartesianToAxial(x, z) + return this.roundToHex(q, r) + } + + private axialToCube(hex: HexCoord): CubeCoord { + return { x: hex.q, z: hex.r, y: -hex.q - hex.r } + } + + private cubeToAxial(cube: CubeCoord): HexCoord { + return { q: cube.x, r: cube.z } + } + + hexKey(hex: HexCoord): string { + return `${hex.q},${hex.r}` + } + + parseHexKey(key: string): HexCoord { + const [q, r] = key.split(',').map(Number) + return { q, r } + } + + getNeighbors(hex: HexCoord): HexCoord[] { + return HEX_DIRECTIONS.map(dir => ({ + q: hex.q + dir.q, + r: hex.r + dir.r, + })) + } + + distance(a: HexCoord, b: HexCoord): number { + const cubeA = this.axialToCube(a) + const cubeB = this.axialToCube(b) + return Math.max( + Math.abs(cubeA.x - cubeB.x), + Math.abs(cubeA.y - cubeB.y), + Math.abs(cubeA.z - cubeB.z) + ) + } + + equals(a: HexCoord, b: HexCoord): boolean { + return a.q === b.q && a.r === b.r + } + + getHexesInRadius(center: HexCoord, radius: number): HexCoord[] { + const results: HexCoord[] = [] + for (let q = -radius + 1; q < radius; q++) { + for (let r = Math.max(-radius + 1, -q - radius + 1); r < Math.min(radius, -q + radius); r++) { + results.push({ q: center.q + q, r: center.r + r }) + } + } + return results + } + + occupy(hex: HexCoord, sessionId: string): void { + const key = this.hexKey(hex) + this.occupied.set(key, sessionId) + this.sessionToHex.set(sessionId, key) + } + + release(sessionId: string): void { + const key = this.sessionToHex.get(sessionId) + if (key) { + this.occupied.delete(key) + this.sessionToHex.delete(sessionId) + } + } + + isOccupied(hex: HexCoord): boolean { + return this.occupied.has(this.hexKey(hex)) + } + + getOccupant(hex: HexCoord): string | undefined { + return this.occupied.get(this.hexKey(hex)) + } + + getSessionHex(sessionId: string): HexCoord | undefined { + const key = this.sessionToHex.get(sessionId) + return key ? this.parseHexKey(key) : undefined + } + + get occupiedCount(): number { + return this.occupied.size + } + + findNearestFree(target: HexCoord): HexCoord { + if (!this.isOccupied(target)) return target + for (let ring = 1; ring <= 50; ring++) { + for (const hex of this.getHexesInRing(target, ring)) { + if (!this.isOccupied(hex)) return hex + } + } + return target + } + + getNextInSpiral(): HexCoord { + for (let i = this.spiralIndex; i < 1000; i++) { + const hex = this.indexToHexCoord(i) + if (!this.isOccupied(hex)) { + this.spiralIndex = i + 1 + return hex + } + } + return { q: 0, r: 0 } + } + + private getHexesInRing(center: HexCoord, ring: number): HexCoord[] { + if (ring === 0) return [center] + const results: HexCoord[] = [] + let hex: HexCoord = { q: center.q + ring, r: center.r } + for (let side = 0; side < 6; side++) { + for (let step = 0; step < ring; step++) { + results.push({ ...hex }) + hex = { + q: hex.q + HEX_DIRECTIONS[(side + 2) % 6].q, + r: hex.r + HEX_DIRECTIONS[(side + 2) % 6].r, + } + } + } + return results + } + + private indexToHexCoord(index: number): HexCoord { + if (index === 0) return { q: 0, r: 0 } + let ring = 1 + let ringStart = 1 + while (ringStart + ring * 6 <= index) { + ringStart += ring * 6 + ring++ + } + const posInRing = index - ringStart + const side = Math.floor(posInRing / ring) + const posOnSide = posInRing % ring + let q = ring + let r = 0 + for (let s = 0; s < side; s++) { + q += HEX_DIRECTIONS[(s + 2) % 6].q * ring + r += HEX_DIRECTIONS[(s + 2) % 6].r * ring + } + q += HEX_DIRECTIONS[(side + 2) % 6].q * posOnSide + r += HEX_DIRECTIONS[(side + 2) % 6].r * posOnSide + return { q, r } + } + + clear(): void { + this.occupied.clear() + this.sessionToHex.clear() + this.spiralIndex = 0 + } +} diff --git a/src/workshop/utils/ToolUtils.ts b/src/workshop/utils/ToolUtils.ts new file mode 100644 index 0000000..95400c8 --- /dev/null +++ b/src/workshop/utils/ToolUtils.ts @@ -0,0 +1,108 @@ +/** + * ToolUtils — Vendored from Vibecraft2. Tool display helpers. + */ + +import { CATEGORY_ICON_MAP } from '../types' +import type { ToolCategory } from '../types' + +export function getToolIcon(tool: string): string { + const icons: Record = { + Read: '\u{1F4D6}', + Edit: '\u270F\uFE0F', + Write: '\u{1F4DD}', + Bash: '\u{1F4BB}', + Grep: '\u{1F50D}', + Glob: '\u{1F4C1}', + WebFetch: '\u{1F310}', + WebSearch: '\u{1F50E}', + Task: '\u{1F916}', + TodoWrite: '\u{1F4CB}', + NotebookEdit: '\u{1F4D3}', + AskFollowupQuestion: '\u2753', + } + return icons[tool] ?? '\u{1F527}' +} + +export function getToolIconByCategory(category: ToolCategory): string { + return CATEGORY_ICON_MAP[category] ?? '\u{1F527}' +} + +export function getToolContext(tool: string, input: Record): string | null { + switch (tool) { + case 'Read': + case 'Write': + case 'Edit': + case 'NotebookEdit': { + const path = (input.file_path || input.notebook_path) as string + return path ? (path.split('/').pop() || path) : null + } + case 'Bash': { + const cmd = input.command as string + if (cmd) { + const firstLine = cmd.split('\n')[0] + return firstLine.length > 30 ? firstLine.slice(0, 30) + '...' : firstLine + } + return null + } + case 'Grep': { + const pattern = input.pattern as string + return pattern ? `/${pattern}/` : null + } + case 'Glob': + return (input.pattern as string) || null + case 'WebFetch': { + const url = input.url as string + if (url) { + try { return new URL(url).hostname } catch { return url.slice(0, 30) } + } + return null + } + case 'WebSearch': + return input.query ? `"${input.query}"` : null + case 'Task': + return (input.description as string) || null + case 'TodoWrite': + return 'Updating tasks' + default: + return null + } +} + +export function getToolContextByCategory( + category: ToolCategory, + input: Record, +): string | null { + switch (category) { + case 'read': + case 'write': + case 'edit': { + const path = (input.file_path || input.path || input.filename) as string + return path ? (path.split('/').pop() || path) : null + } + case 'execute': { + const cmd = (input.command || input.cmd) as string + if (cmd) { + const firstLine = cmd.split('\n')[0] + return firstLine.length > 30 ? firstLine.slice(0, 30) + '...' : firstLine + } + return null + } + case 'search': { + const pattern = (input.pattern || input.query || input.search) as string + return pattern ? `/${pattern}/` : null + } + case 'network': { + const url = (input.url || input.endpoint) as string + if (url) { + try { return new URL(url).hostname } catch { return url.slice(0, 30) } + } + return null + } + case 'delegate': + return (input.description || input.task) as string || null + case 'plan': + return (input.description || input.task) as string || null + default: + return null + } +} diff --git a/vite.config.ts b/vite.config.ts index 4472402..169bc7b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,6 +22,7 @@ export default defineConfig(async () => { manualChunks(id: string) { if (!id.includes('node_modules')) return if (id.includes('/react/') || id.includes('/react-dom/') || id.includes('/scheduler/')) return 'vendor-react' + if (id.includes('/three/')) return 'vendor-three' if (id.includes('@radix-ui')) return 'vendor-radix' if (id.includes('i18next')) return 'vendor-i18n' if (