ai-control-center 1.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/aicc.js +772 -0
- package/lib/actions/approve.js +71 -0
- package/lib/actions/assign-project.js +132 -0
- package/lib/actions/browser-test.js +64 -0
- package/lib/actions/cleanup.js +174 -0
- package/lib/actions/debug.js +298 -0
- package/lib/actions/deploy.js +1229 -0
- package/lib/actions/fix-bug.js +134 -0
- package/lib/actions/new-feature.js +255 -0
- package/lib/actions/reject.js +307 -0
- package/lib/actions/review.js +706 -0
- package/lib/actions/status.js +47 -0
- package/lib/agents/browser-qa-agent.js +611 -0
- package/lib/agents/payment-agent.js +116 -0
- package/lib/agents/suggestion-agent.js +88 -0
- package/lib/cli.js +303 -0
- package/lib/config.js +243 -0
- package/lib/hub/hub-server.js +440 -0
- package/lib/hub/project-poller.js +75 -0
- package/lib/hub/skill-registry.js +89 -0
- package/lib/hub/state-aggregator.js +204 -0
- package/lib/index.js +471 -0
- package/lib/init/doctor.js +523 -0
- package/lib/init/presets.js +222 -0
- package/lib/init/skill-fetcher.js +77 -0
- package/lib/init/wizard.js +973 -0
- package/lib/integrations/codex-runner.js +128 -0
- package/lib/integrations/github-actions.js +248 -0
- package/lib/integrations/github-reporter.js +229 -0
- package/lib/integrations/screenshot-store.js +102 -0
- package/lib/openclaw/bridge.js +650 -0
- package/lib/openclaw/generate-skill.js +235 -0
- package/lib/openclaw/openclaw.json +64 -0
- package/lib/orchestrator/autonomous-loop.js +429 -0
- package/lib/orchestrator/thread-triggers.js +63 -0
- package/lib/roleplay/agent-messenger.js +75 -0
- package/lib/roleplay/discussion-threads.js +303 -0
- package/lib/roleplay/health-monitor.js +121 -0
- package/lib/roleplay/pm-agent.js +513 -0
- package/lib/roleplay/roleplay-config.js +25 -0
- package/lib/roleplay/room.js +164 -0
- package/lib/shared/action-runner.js +2330 -0
- package/lib/shared/event-bus.js +185 -0
- package/lib/slack/bot.js +378 -0
- package/lib/telegram/bot.js +416 -0
- package/lib/telegram/commands.js +1267 -0
- package/lib/telegram/keyboards.js +113 -0
- package/lib/telegram/notifications.js +247 -0
- package/lib/twitch/bot.js +354 -0
- package/lib/twitch/commands.js +302 -0
- package/lib/twitch/notifications.js +63 -0
- package/lib/utils/achievements.js +191 -0
- package/lib/utils/activity-log.js +182 -0
- package/lib/utils/agent-leaderboard.js +119 -0
- package/lib/utils/audit-logger.js +232 -0
- package/lib/utils/codebase-context.js +288 -0
- package/lib/utils/codebase-indexer.js +381 -0
- package/lib/utils/config-schema.js +230 -0
- package/lib/utils/context-compressor.js +172 -0
- package/lib/utils/correlation.js +63 -0
- package/lib/utils/cost-tracker.js +423 -0
- package/lib/utils/cron-scheduler.js +53 -0
- package/lib/utils/db-adapter.js +293 -0
- package/lib/utils/display.js +272 -0
- package/lib/utils/errors.js +116 -0
- package/lib/utils/format.js +134 -0
- package/lib/utils/intent-engine.js +464 -0
- package/lib/utils/mcp-client.js +238 -0
- package/lib/utils/model-ab-test.js +164 -0
- package/lib/utils/notify.js +122 -0
- package/lib/utils/persona-loader.js +80 -0
- package/lib/utils/pipeline-lock.js +73 -0
- package/lib/utils/pipeline.js +214 -0
- package/lib/utils/plugin-runner.js +234 -0
- package/lib/utils/rate-limiter.js +84 -0
- package/lib/utils/rbac.js +74 -0
- package/lib/utils/runner.js +1809 -0
- package/lib/utils/security.js +191 -0
- package/lib/utils/self-healer.js +144 -0
- package/lib/utils/skill-loader.js +255 -0
- package/lib/utils/spinner.js +132 -0
- package/lib/utils/stage-queue.js +50 -0
- package/lib/utils/state-machine.js +89 -0
- package/lib/utils/status-bar.js +327 -0
- package/lib/utils/token-estimator.js +101 -0
- package/lib/utils/ux-analyzer.js +101 -0
- package/lib/utils/webhook-emitter.js +83 -0
- package/lib/web/public/css/styles.css +417 -0
- package/lib/web/public/dark-mode.js +44 -0
- package/lib/web/public/hub/kanban.html +206 -0
- package/lib/web/public/index.html +45 -0
- package/lib/web/public/js/app.js +71 -0
- package/lib/web/public/js/ask.js +110 -0
- package/lib/web/public/js/dashboard.js +165 -0
- package/lib/web/public/js/deploy.js +72 -0
- package/lib/web/public/js/feature.js +79 -0
- package/lib/web/public/js/health.js +65 -0
- package/lib/web/public/js/logs.js +93 -0
- package/lib/web/public/js/review.js +123 -0
- package/lib/web/public/js/ws-client.js +82 -0
- package/lib/web/public/office/css/office.css +678 -0
- package/lib/web/public/office/index.html +148 -0
- package/lib/web/public/office/js/achievements-ui.js +117 -0
- package/lib/web/public/office/js/character.js +1056 -0
- package/lib/web/public/office/js/chat-bubbles.js +177 -0
- package/lib/web/public/office/js/cost-overlay.js +123 -0
- package/lib/web/public/office/js/day-night.js +68 -0
- package/lib/web/public/office/js/effects.js +632 -0
- package/lib/web/public/office/js/engine.js +146 -0
- package/lib/web/public/office/js/feature-ticket.js +216 -0
- package/lib/web/public/office/js/hub-client.js +60 -0
- package/lib/web/public/office/js/main.js +1757 -0
- package/lib/web/public/office/js/office-layout.js +1524 -0
- package/lib/web/public/office/js/pathfinding.js +144 -0
- package/lib/web/public/office/js/pixel-sprites.js +1454 -0
- package/lib/web/public/office/js/progress-bars.js +117 -0
- package/lib/web/public/office/js/replay.js +191 -0
- package/lib/web/public/office/js/sound-effects.js +91 -0
- package/lib/web/public/office/js/sprite-renderer.js +211 -0
- package/lib/web/public/office/js/stamina-system.js +89 -0
- package/lib/web/public/office/js/ui.js +107 -0
- package/lib/web/public/onboarding/index.html +243 -0
- package/lib/web/public/timeline/index.html +195 -0
- package/lib/web/routes/api.js +499 -0
- package/lib/web/routes/logs.js +20 -0
- package/lib/web/routes/metrics.js +99 -0
- package/lib/web/server.js +183 -0
- package/lib/web/ws/handler.js +65 -0
- package/package.json +67 -0
- package/templates/agent-architect.md +69 -0
- package/templates/agent-gemini-pm.md +49 -0
- package/templates/agent-gemini-reviewer.md +52 -0
- package/templates/copilot-instructions.md +36 -0
- package/templates/pipelines/mobile.json +27 -0
- package/templates/pipelines/nodejs-api.json +27 -0
- package/templates/pipelines/python.json +27 -0
- package/templates/pipelines/react.json +27 -0
- package/templates/pipelines/salesforce.json +27 -0
- package/templates/role-gemini.md +97 -0
- package/templates/skill-architect.md +114 -0
- package/templates/skill-browser-qa.md +50 -0
- package/templates/skill-bug-from-qa.md +58 -0
- package/templates/skill-chatbot.md +93 -0
- package/templates/skill-implement.md +78 -0
- package/templates/skill-openclaw.md +174 -0
- package/templates/skill-payment.md +110 -0
- package/templates/skill-pm-spec.md +77 -0
- package/templates/skill-requirement-capture.md +97 -0
- package/templates/skill-review.md +108 -0
- package/templates/skill-reviewer-qa.md +44 -0
- package/templates/skill-suggestion.md +45 -0
- package/templates/skill-template.md +142 -0
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Character — pixel art RPG character with state machine, pathfinding,
|
|
3
|
+
* sprite animation, and idle social behaviors (wander, gossip, gym, eat, sleep).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { renderSprite, renderPixelText, PIXEL_SIZE } from './sprite-renderer.js';
|
|
7
|
+
import { getSprite, ROLE_COLORS } from './pixel-sprites.js';
|
|
8
|
+
import { getEffects } from './effects.js';
|
|
9
|
+
import { getPathfinder } from './pathfinding.js';
|
|
10
|
+
|
|
11
|
+
// ── Pipeline stage → character states ──────────────────────────────────────────
|
|
12
|
+
const STAGE_MAP = {
|
|
13
|
+
'': { pm: 'idle', arch: 'idle', coder: 'idle', deployer: 'idle' },
|
|
14
|
+
'inbox': { pm: 'working', arch: 'idle', coder: 'idle', deployer: 'idle' },
|
|
15
|
+
'spec': { pm: 'working', arch: 'idle', coder: 'idle', deployer: 'idle' },
|
|
16
|
+
'spec_complete': { pm: 'celebrating', arch: 'walking', coder: 'idle', deployer: 'idle' },
|
|
17
|
+
'architecture': { pm: 'idle', arch: 'designing', coder: 'idle', deployer: 'idle' },
|
|
18
|
+
'arch_complete': { pm: 'idle', arch: 'celebrating', coder: 'walking', deployer: 'idle' },
|
|
19
|
+
'implementation': { pm: 'idle', arch: 'idle', coder: 'working', deployer: 'idle' },
|
|
20
|
+
'implementation_complete': { pm: 'idle', arch: 'idle', coder: 'celebrating', deployer: 'idle' },
|
|
21
|
+
'review': { pm: 'reviewing', arch: 'idle', coder: 'waiting_input', deployer: 'idle' },
|
|
22
|
+
'review_complete': { pm: 'idle', arch: 'idle', coder: 'idle', deployer: 'idle' },
|
|
23
|
+
'approved': { pm: 'celebrating', arch: 'celebrating', coder: 'celebrating', deployer: 'waiting_input' },
|
|
24
|
+
'deploying': { pm: 'idle', arch: 'idle', coder: 'idle', deployer: 'deploying' },
|
|
25
|
+
'deployed': { pm: 'celebrating', arch: 'celebrating', coder: 'celebrating', deployer: 'celebrating' },
|
|
26
|
+
'rejected': { pm: 'frustrated', arch: 'idle', coder: 'fighting_bug', deployer: 'idle' },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ROLES = {
|
|
30
|
+
pm: { label: 'PM', spriteKey: 'pm', ai: 'PM', color: ROLE_COLORS.pm },
|
|
31
|
+
arch: { label: 'Architect', spriteKey: 'architect', ai: 'Architect', color: ROLE_COLORS.architect },
|
|
32
|
+
coder: { label: 'Coder', spriteKey: 'coder', ai: 'Coder', color: ROLE_COLORS.coder },
|
|
33
|
+
deployer: { label: 'Deployer', spriteKey: 'deployer', ai: 'Deployer', color: ROLE_COLORS.deployer },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const WALK_STATES = new Set(['walking', 'handing_off']);
|
|
37
|
+
const SPRITE_W = 16 * PIXEL_SIZE;
|
|
38
|
+
const SPRITE_H = 24 * PIXEL_SIZE;
|
|
39
|
+
|
|
40
|
+
// ── Idle behavior types ──────────────────────────────────────────────────────
|
|
41
|
+
const IDLE_BEHAVIORS = ['wander', 'gossip', 'gossip', 'gym', 'breakRoom', 'coffee', 'sleep', 'poke', 'highfive', 'phone', 'stretch', 'meeting'];
|
|
42
|
+
|
|
43
|
+
// Gossip phrases (what characters say when chatting idle)
|
|
44
|
+
const GOSSIP_LINES = [
|
|
45
|
+
// Work-related casual
|
|
46
|
+
'Did you see the new feature?',
|
|
47
|
+
'The tests are flaky again...',
|
|
48
|
+
'Need more coffee...',
|
|
49
|
+
'Sprint review tomorrow!',
|
|
50
|
+
'That bug was tricky!',
|
|
51
|
+
'Who broke the build?',
|
|
52
|
+
'Lunch plans?',
|
|
53
|
+
'Nice refactor!',
|
|
54
|
+
'PR looks good to me',
|
|
55
|
+
'Ship it!',
|
|
56
|
+
'Standup in 5 min',
|
|
57
|
+
'API is slow today...',
|
|
58
|
+
'Love the new UI!',
|
|
59
|
+
'Merge conflicts again...',
|
|
60
|
+
'Great code review!',
|
|
61
|
+
'Time for a break',
|
|
62
|
+
'The deploy went smooth!',
|
|
63
|
+
'Any blockers?',
|
|
64
|
+
'Working on the fix now',
|
|
65
|
+
'Almost done!',
|
|
66
|
+
// Office banter
|
|
67
|
+
'Who ate my lunch?',
|
|
68
|
+
'Meeting could be an email',
|
|
69
|
+
'The WiFi is slow again',
|
|
70
|
+
'Love this weather!',
|
|
71
|
+
'Weekend plans?',
|
|
72
|
+
'Did you watch the game?',
|
|
73
|
+
'New phone who dis?',
|
|
74
|
+
'Coffee run anyone?',
|
|
75
|
+
'The AC is freezing!',
|
|
76
|
+
'Happy Friday!',
|
|
77
|
+
// Tech humor
|
|
78
|
+
'It works on my machine',
|
|
79
|
+
'Have you tried restarting?',
|
|
80
|
+
'Stack Overflow saves lives',
|
|
81
|
+
'Git blame says it was you',
|
|
82
|
+
'Tabs or spaces?',
|
|
83
|
+
'Is it a feature or a bug?',
|
|
84
|
+
'The cloud is just servers',
|
|
85
|
+
'404 motivation not found',
|
|
86
|
+
'sudo make me a sandwich',
|
|
87
|
+
'There are 10 types of people',
|
|
88
|
+
// Social
|
|
89
|
+
'Great job yesterday!',
|
|
90
|
+
'How is your project going?',
|
|
91
|
+
'Any fun plans tonight?',
|
|
92
|
+
'That presentation was awesome',
|
|
93
|
+
'New coffee machine rocks!',
|
|
94
|
+
'The gym here is nice',
|
|
95
|
+
'Have you tried the new place?',
|
|
96
|
+
'Team dinner this week?',
|
|
97
|
+
'Congrats on the promotion!',
|
|
98
|
+
'Love your desk setup!',
|
|
99
|
+
// Philosophical
|
|
100
|
+
'Is AI coming for our jobs?',
|
|
101
|
+
'Agile or waterfall?',
|
|
102
|
+
'Monolith or microservices?',
|
|
103
|
+
'REST or GraphQL?',
|
|
104
|
+
'To deploy or not to deploy',
|
|
105
|
+
'The code review was rough',
|
|
106
|
+
'Thats a clever solution!',
|
|
107
|
+
'Who writes the docs anyway?',
|
|
108
|
+
// Daily life
|
|
109
|
+
'Traffic was terrible today',
|
|
110
|
+
'My cat stepped on my keyboard',
|
|
111
|
+
'Dog ate my USB cable',
|
|
112
|
+
'Forgot my badge again',
|
|
113
|
+
'Elevator is broken again',
|
|
114
|
+
'Parking was a nightmare',
|
|
115
|
+
'The vending machine is empty',
|
|
116
|
+
'Did you see the sunset?',
|
|
117
|
+
// Celebrations
|
|
118
|
+
'We hit the milestone!',
|
|
119
|
+
'Another sprint done!',
|
|
120
|
+
'Zero bugs this week!',
|
|
121
|
+
'Client loved the demo!',
|
|
122
|
+
'Performance is up 40%!',
|
|
123
|
+
'All tests green!',
|
|
124
|
+
'Deployment was flawless!',
|
|
125
|
+
'New record uptime!',
|
|
126
|
+
// Complaints
|
|
127
|
+
'JIRA is down again',
|
|
128
|
+
'Too many meetings today',
|
|
129
|
+
'My back hurts from sitting',
|
|
130
|
+
'Need a standing desk',
|
|
131
|
+
'Slack notifications overload',
|
|
132
|
+
'Email inbox is a disaster',
|
|
133
|
+
'The printer never works',
|
|
134
|
+
'VPN disconnected again',
|
|
135
|
+
// Random fun
|
|
136
|
+
'Anyone want to play ping pong?',
|
|
137
|
+
'Movie night this Friday?',
|
|
138
|
+
'Boba tea run?',
|
|
139
|
+
'Pizza or sushi for lunch?',
|
|
140
|
+
'New season is out on Netflix',
|
|
141
|
+
'That meme was hilarious',
|
|
142
|
+
'Best commit message ever',
|
|
143
|
+
'My code compiled first try!',
|
|
144
|
+
'Rubber duck debugging works',
|
|
145
|
+
'The intern fixed the bug!',
|
|
146
|
+
'Senior dev was wrong!',
|
|
147
|
+
'Pair programming is fun',
|
|
148
|
+
'Code kata this afternoon?',
|
|
149
|
+
'Hackathon next month!',
|
|
150
|
+
'Open source contribution day',
|
|
151
|
+
'Tech conference next week',
|
|
152
|
+
'New framework dropped!',
|
|
153
|
+
'TypeScript saves lives',
|
|
154
|
+
'Linting catches everything',
|
|
155
|
+
'CI/CD pipeline is beautiful',
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
// Thought bubbles shown while characters are actively working
|
|
159
|
+
const WORK_MESSAGES = {
|
|
160
|
+
pm: ['📝 Writing spec...', '🤔 Requirements?', '📊 Roadmap!', '🎯 Scope defined', '💼 Stakeholders', '🗺 User stories', '✅ Acceptance criteria'],
|
|
161
|
+
arch: ['🏗 Architecture...', '💡 Design pattern?', '🔧 Scalability!', '📐 Diagrams...', '⚡ Optimize!', '🧩 Microservices?', '📦 Clean layers'],
|
|
162
|
+
coder: ['💻 Coding...', '🔍 Debugging...', '🐛 Found it!', '⌨️ Refactor...', '✅ Tests pass!', '🔥 Edge case!', '💡 Better way?', '🚀 Almost done!'],
|
|
163
|
+
deployer: ['🚀 Deploying...', '⚙️ Config check', '🐳 Build image', '✅ Health check', '🔄 Rollback ready', '📦 Packaging...', '🌐 DNS updated'],
|
|
164
|
+
deployer_standby: ['📦 Package ready!', '🚀 Awaiting signal...', '⚙️ Preflight check...', '🔑 Credentials OK', '🐳 Docker image built', '✅ Env vars set', '🌐 Prod cluster ready', '🔄 Rollback plan set', '📋 Deploy checklist ✓', '💾 DB backup done'],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Feature-related discussion lines (used instead of gossip when team is working)
|
|
168
|
+
const FEATURE_DISCUSS_LINES = [
|
|
169
|
+
'About this feature...',
|
|
170
|
+
'The spec looks good!',
|
|
171
|
+
'Any edge cases?',
|
|
172
|
+
'Architecture ready?',
|
|
173
|
+
'Need your input!',
|
|
174
|
+
'Tests for this?',
|
|
175
|
+
'Lets sync up!',
|
|
176
|
+
'Good progress!',
|
|
177
|
+
'Blocking issue?',
|
|
178
|
+
'Almost there!',
|
|
179
|
+
'Deploy plan?',
|
|
180
|
+
'Review notes...',
|
|
181
|
+
'Scope change?',
|
|
182
|
+
'Nice design!',
|
|
183
|
+
'QA check?',
|
|
184
|
+
// Technical discussions
|
|
185
|
+
'What about error handling?',
|
|
186
|
+
'Should we add caching?',
|
|
187
|
+
'The API contract looks solid',
|
|
188
|
+
'Need a migration script?',
|
|
189
|
+
'Backwards compatible?',
|
|
190
|
+
'Performance impact?',
|
|
191
|
+
'Security review needed',
|
|
192
|
+
'Database schema change?',
|
|
193
|
+
'Unit test coverage?',
|
|
194
|
+
'Integration test plan?',
|
|
195
|
+
'Load testing results?',
|
|
196
|
+
'The mock data is ready',
|
|
197
|
+
'Code review comments addressed',
|
|
198
|
+
'Docs updated for this?',
|
|
199
|
+
'Release notes drafted',
|
|
200
|
+
// Planning
|
|
201
|
+
'Sprint capacity check?',
|
|
202
|
+
'Dependencies resolved?',
|
|
203
|
+
'Stakeholder approval?',
|
|
204
|
+
'UAT environment ready?',
|
|
205
|
+
'Feature flag needed?',
|
|
206
|
+
'Rollback strategy?',
|
|
207
|
+
'Monitoring in place?',
|
|
208
|
+
'Logging sufficient?',
|
|
209
|
+
'Alert thresholds set?',
|
|
210
|
+
'Timeline on track?',
|
|
211
|
+
// Collaboration
|
|
212
|
+
'Lets whiteboard this',
|
|
213
|
+
'Can you pair on this?',
|
|
214
|
+
'Review my approach?',
|
|
215
|
+
'Second opinion needed',
|
|
216
|
+
'Knowledge transfer done?',
|
|
217
|
+
'Handoff checklist ready',
|
|
218
|
+
'Demo prep started?',
|
|
219
|
+
'Client feedback incorporated',
|
|
220
|
+
'Tech debt noted for later',
|
|
221
|
+
'Clean up after merge',
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
// Rejection-escalating messages for coder (shown when fighting bugs after rejection)
|
|
225
|
+
const REJECTION_MESSAGES = [
|
|
226
|
+
['😤 Fix review...', '🐛 Hunt the bug', '💻 Rework it!', '🔍 What changed?'],
|
|
227
|
+
['😰 2nd reject!', '💦 Sweating...', '🙏 Please pass!', '😓 Nearly there'],
|
|
228
|
+
['😭 3rd reject!', '🙏🙏 Praying...', '⛪ Dear God...', '💦 Full panic!', '🆘 HELP!!!'],
|
|
229
|
+
['🤯 4th reject!', '🙏🙏🙏 GOD PLEASE', '😱 Why won\'t it pass', '💀 I give up...'],
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
// Social interaction lines
|
|
233
|
+
const POKE_LINES = [
|
|
234
|
+
'👉 Hey!', '👉 Wake up!', '😜 Gotcha!', '👊 Boop!',
|
|
235
|
+
'🤪 Surprise!', '😏 Guess who?', '👋 Yo!', '🫵 Tag!',
|
|
236
|
+
'😝 Poke poke!', '🤭 Hi there!', '😂 Made you look!',
|
|
237
|
+
'🤓 Did you know?', '🧐 Ahem!', '🫠 Bored!',
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
const HIGHFIVE_LINES = [
|
|
241
|
+
'✋ High five!', '🙌 Teamwork!', '🤝 Nice one!', '👏 Bravo!',
|
|
242
|
+
'💪 Nailed it!', '🎯 Bullseye!', '⭐ Star player!', '🏆 Champions!',
|
|
243
|
+
'🔥 On fire!', '💯 Perfect!', '🚀 To the moon!', '🎉 Celebrate!',
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const MEETING_LINES = [
|
|
247
|
+
'Lets sync on priorities',
|
|
248
|
+
'Quick status update?',
|
|
249
|
+
'Blocker discussion',
|
|
250
|
+
'Sprint retro notes',
|
|
251
|
+
'Timeline check-in',
|
|
252
|
+
'Dependency update',
|
|
253
|
+
'Risk assessment',
|
|
254
|
+
'Capacity planning',
|
|
255
|
+
'Action items review',
|
|
256
|
+
'Quick standup!',
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Time-of-day weighted behavior selection.
|
|
261
|
+
* Returns a behavior from IDLE_BEHAVIORS with preferences based on hour.
|
|
262
|
+
*/
|
|
263
|
+
function pickTimeAwareBehavior() {
|
|
264
|
+
const hour = new Date().getHours();
|
|
265
|
+
|
|
266
|
+
// Time-of-day preferences: [behavior, extraWeight]
|
|
267
|
+
const boosts = [];
|
|
268
|
+
|
|
269
|
+
if (hour >= 6 && hour <= 9) {
|
|
270
|
+
// Morning: prefer coffee
|
|
271
|
+
boosts.push(['coffee', 4]);
|
|
272
|
+
}
|
|
273
|
+
if (hour >= 22 || hour <= 5) {
|
|
274
|
+
// Late night: prefer sleep
|
|
275
|
+
boosts.push(['sleep', 5]);
|
|
276
|
+
}
|
|
277
|
+
if (hour >= 12 && hour <= 14) {
|
|
278
|
+
// Lunch time: prefer gym / breakRoom
|
|
279
|
+
boosts.push(['gym', 3]);
|
|
280
|
+
boosts.push(['breakRoom', 3]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Build weighted pool: start with base list (weight 1 each)
|
|
284
|
+
const pool = IDLE_BEHAVIORS.slice();
|
|
285
|
+
|
|
286
|
+
// Add extra copies for boosted behaviors
|
|
287
|
+
for (const [beh, weight] of boosts) {
|
|
288
|
+
for (let i = 0; i < weight; i++) pool.push(beh);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return pool[Math.floor(Math.random() * pool.length)];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export class Character {
|
|
295
|
+
constructor(role, projectName, x, y) {
|
|
296
|
+
this.role = role;
|
|
297
|
+
this.project = projectName;
|
|
298
|
+
this.meta = ROLES[role];
|
|
299
|
+
|
|
300
|
+
// Position
|
|
301
|
+
this.x = x;
|
|
302
|
+
this.y = y;
|
|
303
|
+
this.targetX = x;
|
|
304
|
+
this.targetY = y;
|
|
305
|
+
this.homeX = x; // workstation position
|
|
306
|
+
this.homeY = y;
|
|
307
|
+
this.speed = 50;
|
|
308
|
+
|
|
309
|
+
// Pathfinding
|
|
310
|
+
this.path = [];
|
|
311
|
+
this.pathIndex = 0;
|
|
312
|
+
|
|
313
|
+
// Facing direction
|
|
314
|
+
this.facing = 'down';
|
|
315
|
+
|
|
316
|
+
// State
|
|
317
|
+
this.state = 'idle';
|
|
318
|
+
this.prevState = 'idle';
|
|
319
|
+
this.stateTime = 0;
|
|
320
|
+
this.isWorking = false; // true when pipeline assigns work
|
|
321
|
+
this.isAtDesk = false; // true when working character has reached their desk
|
|
322
|
+
this.isHandingOff = false; // true when delivering a ticket to another character
|
|
323
|
+
|
|
324
|
+
// Project-specific color badge (set by createCharacters in main.js)
|
|
325
|
+
this.projectColor = '#888888';
|
|
326
|
+
this.projectIndex = 0;
|
|
327
|
+
this.rejectionCount = 0; // escalates desperate reactions after each review rejection
|
|
328
|
+
this.currentFeature = ''; // current feature name for work discussions
|
|
329
|
+
|
|
330
|
+
// Task progress (driven by pipeline stage duration)
|
|
331
|
+
this.taskProgress = 0; // 0.0 – 1.0
|
|
332
|
+
this.taskDuration = 0; // estimated seconds for current phase
|
|
333
|
+
|
|
334
|
+
// Idle behavior
|
|
335
|
+
this.idleBehavior = null; // current idle activity
|
|
336
|
+
this.idleTimer = 0; // countdown to next idle action
|
|
337
|
+
this.idleDuration = 0; // how long current idle activity lasts
|
|
338
|
+
this.gossipTarget = null; // character we're gossiping with
|
|
339
|
+
this.gossipLine = ''; // what we're saying
|
|
340
|
+
this.gossipShowTime = 0; // when to show the bubble
|
|
341
|
+
|
|
342
|
+
// Phone / stretch idle timers
|
|
343
|
+
this.phoneTimer = 0;
|
|
344
|
+
this.stretchTimer = 0;
|
|
345
|
+
|
|
346
|
+
// Work thought bubbles (shown while working)
|
|
347
|
+
this.workBubble = '';
|
|
348
|
+
this.workBubbleTimer = 2 + Math.random() * 4; // first bubble delay
|
|
349
|
+
this.workBubbleDuration = 0;
|
|
350
|
+
|
|
351
|
+
// Animation
|
|
352
|
+
this.animFrame = 0;
|
|
353
|
+
this.animTimer = 0;
|
|
354
|
+
this.animSpeed = 0.22;
|
|
355
|
+
|
|
356
|
+
// Rendering
|
|
357
|
+
this.bobOffset = 0;
|
|
358
|
+
this.shadow = true;
|
|
359
|
+
|
|
360
|
+
// Randomize initial idle timer so characters don't all act at once
|
|
361
|
+
this.idleTimer = 3 + Math.random() * 8;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Update state from pipeline stage */
|
|
365
|
+
setState(pipelineStage) {
|
|
366
|
+
const map = STAGE_MAP[pipelineStage] || STAGE_MAP[''];
|
|
367
|
+
const newState = map[this.role] || 'idle';
|
|
368
|
+
|
|
369
|
+
// Check if this is a "working" state
|
|
370
|
+
const workingStates = new Set(['working', 'designing', 'reviewing', 'deploying', 'fighting_bug', 'celebrating', 'frustrated', 'waiting_input', 'handing_off']);
|
|
371
|
+
this.isWorking = workingStates.has(newState);
|
|
372
|
+
|
|
373
|
+
if (newState !== this.state) {
|
|
374
|
+
this.prevState = this.state;
|
|
375
|
+
this.state = newState;
|
|
376
|
+
this.stateTime = 0;
|
|
377
|
+
this.animFrame = 0;
|
|
378
|
+
|
|
379
|
+
// If transitioning TO work, cancel idle behavior
|
|
380
|
+
if (this.isWorking) {
|
|
381
|
+
this.idleBehavior = null;
|
|
382
|
+
this.gossipTarget = null;
|
|
383
|
+
this.gossipLine = '';
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Temporarily override state for a reaction, then restore.
|
|
390
|
+
* @param {string} reactionState - e.g. 'celebrating', 'frustrated', 'waiting_input'
|
|
391
|
+
* @param {string} bubbleText - optional speech bubble text
|
|
392
|
+
* @param {number} durationSec - how long before reverting
|
|
393
|
+
*/
|
|
394
|
+
react(reactionState, bubbleText = '', durationSec = 3) {
|
|
395
|
+
if (this._reactionTimer) clearTimeout(this._reactionTimer);
|
|
396
|
+
const saved = this.state;
|
|
397
|
+
this.prevState = saved;
|
|
398
|
+
this.state = reactionState;
|
|
399
|
+
this.stateTime = 0;
|
|
400
|
+
this.animFrame = 0;
|
|
401
|
+
if (bubbleText) {
|
|
402
|
+
this._reactionBubble = bubbleText;
|
|
403
|
+
this._reactionBubbleTimer = durationSec;
|
|
404
|
+
}
|
|
405
|
+
this._reactionTimer = setTimeout(() => {
|
|
406
|
+
this._reactionTimer = null;
|
|
407
|
+
this._reactionBubble = '';
|
|
408
|
+
this.state = saved;
|
|
409
|
+
this.stateTime = 0;
|
|
410
|
+
}, durationSec * 1000);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Set home position (workstation) */
|
|
414
|
+
setHome(x, y) {
|
|
415
|
+
this.homeX = x;
|
|
416
|
+
this.homeY = y;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Move to target with pathfinding */
|
|
420
|
+
moveTo(tx, ty) {
|
|
421
|
+
this.targetX = tx;
|
|
422
|
+
this.targetY = ty;
|
|
423
|
+
const pf = getPathfinder();
|
|
424
|
+
this.path = pf.findPath(this.project, this.x, this.y, tx, ty);
|
|
425
|
+
this.pathIndex = 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Start an idle behavior (called from main.js idle system) */
|
|
429
|
+
startIdleBehavior(behavior, targetPos, gossipChar) {
|
|
430
|
+
// HARD BLOCK: working characters NEVER start idle behaviors
|
|
431
|
+
if (this.isWorking) return;
|
|
432
|
+
|
|
433
|
+
this.idleBehavior = behavior;
|
|
434
|
+
this.idleDuration = 5 + Math.random() * 10;
|
|
435
|
+
|
|
436
|
+
if (targetPos) {
|
|
437
|
+
this.moveTo(targetPos.x, targetPos.y);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (behavior === 'gossip' && gossipChar) {
|
|
441
|
+
this.gossipTarget = gossipChar;
|
|
442
|
+
// Use feature discussion lines when working, random gossip when idle
|
|
443
|
+
if (this.currentFeature) {
|
|
444
|
+
this.gossipLine = FEATURE_DISCUSS_LINES[Math.floor(Math.random() * FEATURE_DISCUSS_LINES.length)];
|
|
445
|
+
} else {
|
|
446
|
+
this.gossipLine = GOSSIP_LINES[Math.floor(Math.random() * GOSSIP_LINES.length)];
|
|
447
|
+
}
|
|
448
|
+
this.gossipShowTime = 0;
|
|
449
|
+
} else if (behavior === 'poke' && gossipChar) {
|
|
450
|
+
this.gossipTarget = gossipChar;
|
|
451
|
+
this.gossipLine = POKE_LINES[Math.floor(Math.random() * POKE_LINES.length)];
|
|
452
|
+
this.gossipShowTime = 0;
|
|
453
|
+
this.idleDuration = 3 + Math.random() * 3;
|
|
454
|
+
} else if (behavior === 'highfive' && gossipChar) {
|
|
455
|
+
this.gossipTarget = gossipChar;
|
|
456
|
+
this.gossipLine = HIGHFIVE_LINES[Math.floor(Math.random() * HIGHFIVE_LINES.length)];
|
|
457
|
+
gossipChar.gossipLine = HIGHFIVE_LINES[Math.floor(Math.random() * HIGHFIVE_LINES.length)];
|
|
458
|
+
gossipChar.gossipShowTime = 0;
|
|
459
|
+
this.gossipShowTime = 0;
|
|
460
|
+
this.idleDuration = 2 + Math.random() * 2;
|
|
461
|
+
} else if (behavior === 'phone') {
|
|
462
|
+
// Stay in place, check phone for 4-6 seconds
|
|
463
|
+
this.phoneTimer = 4 + Math.random() * 2;
|
|
464
|
+
this.idleDuration = this.phoneTimer + 0.5;
|
|
465
|
+
} else if (behavior === 'stretch') {
|
|
466
|
+
// Stay in place, stretch for 3 seconds
|
|
467
|
+
this.stretchTimer = 3;
|
|
468
|
+
this.idleDuration = 3.5;
|
|
469
|
+
} else if (behavior === 'meeting' && gossipChar) {
|
|
470
|
+
this.gossipTarget = gossipChar;
|
|
471
|
+
this.gossipLine = MEETING_LINES[Math.floor(Math.random() * MEETING_LINES.length)];
|
|
472
|
+
gossipChar.gossipLine = MEETING_LINES[Math.floor(Math.random() * MEETING_LINES.length)];
|
|
473
|
+
this.gossipShowTime = 0;
|
|
474
|
+
gossipChar.gossipShowTime = 0;
|
|
475
|
+
this.idleDuration = 6 + Math.random() * 4;
|
|
476
|
+
} else if (behavior === 'sleep') {
|
|
477
|
+
this.state = 'sleeping';
|
|
478
|
+
} else if (behavior === 'gym') {
|
|
479
|
+
this.state = 'working'; // reuse working animation for gym
|
|
480
|
+
} else if (behavior === 'coffee') {
|
|
481
|
+
this.state = 'coffee_break';
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Update position, animation, and idle behavior */
|
|
486
|
+
update(dt) {
|
|
487
|
+
this.stateTime += dt;
|
|
488
|
+
this.animTimer += dt;
|
|
489
|
+
|
|
490
|
+
// Auto-expire 'celebrating' after 7s — don't celebrate forever after deploy
|
|
491
|
+
if (this.state === 'celebrating' && this.stateTime > 7) {
|
|
492
|
+
this.state = 'idle';
|
|
493
|
+
this.isWorking = false;
|
|
494
|
+
this.stateTime = 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Advance animation frame
|
|
498
|
+
if (this.animTimer >= this.animSpeed) {
|
|
499
|
+
this.animTimer = 0;
|
|
500
|
+
this.animFrame = (this.animFrame + 1) % 4;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Idle behavior timer
|
|
504
|
+
if (!this.isWorking && this.idleBehavior) {
|
|
505
|
+
this.idleDuration -= dt;
|
|
506
|
+
if (this.idleDuration <= 0) {
|
|
507
|
+
// Return home
|
|
508
|
+
this.idleBehavior = null;
|
|
509
|
+
this.gossipTarget = null;
|
|
510
|
+
this.gossipLine = '';
|
|
511
|
+
this.state = 'idle';
|
|
512
|
+
this.moveTo(this.homeX, this.homeY);
|
|
513
|
+
this.idleTimer = 4 + Math.random() * 8;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Gossip bubble timing
|
|
517
|
+
if (this.idleBehavior === 'gossip' || this.idleBehavior === 'meeting') {
|
|
518
|
+
this.gossipShowTime += dt;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Phone timer countdown
|
|
522
|
+
if (this.idleBehavior === 'phone' && this.phoneTimer > 0) {
|
|
523
|
+
this.phoneTimer -= dt;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Stretch timer countdown
|
|
527
|
+
if (this.idleBehavior === 'stretch' && this.stretchTimer > 0) {
|
|
528
|
+
this.stretchTimer -= dt;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Idle timer (pick new behavior when timer runs out)
|
|
533
|
+
if (!this.isWorking && !this.idleBehavior) {
|
|
534
|
+
this.idleTimer -= dt;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Work thought bubbles (shown periodically while working)
|
|
538
|
+
if (this.isWorking) {
|
|
539
|
+
this.workBubbleTimer -= dt;
|
|
540
|
+
if (this.workBubbleTimer <= 0) {
|
|
541
|
+
// Use rejection-escalating messages for coder in fighting_bug state
|
|
542
|
+
let msgs;
|
|
543
|
+
if (this.state === 'fighting_bug' && this.rejectionCount > 0) {
|
|
544
|
+
const tier = Math.min(this.rejectionCount - 1, REJECTION_MESSAGES.length - 1);
|
|
545
|
+
msgs = REJECTION_MESSAGES[tier];
|
|
546
|
+
} else if (this.role === 'deployer' && this.state === 'waiting_input') {
|
|
547
|
+
msgs = WORK_MESSAGES.deployer_standby;
|
|
548
|
+
} else {
|
|
549
|
+
msgs = WORK_MESSAGES[this.role] || WORK_MESSAGES.coder;
|
|
550
|
+
}
|
|
551
|
+
this.workBubble = msgs[Math.floor(Math.random() * msgs.length)];
|
|
552
|
+
this.workBubbleDuration = 3.5;
|
|
553
|
+
// Deployer standby: cycle bubbles faster to look active
|
|
554
|
+
this.workBubbleTimer = (this.role === 'deployer' && this.state === 'waiting_input')
|
|
555
|
+
? 2 + Math.random() * 2
|
|
556
|
+
: 2 + Math.random() * 3;
|
|
557
|
+
}
|
|
558
|
+
if (this.workBubbleDuration > 0) {
|
|
559
|
+
this.workBubbleDuration -= dt;
|
|
560
|
+
if (this.workBubbleDuration <= 0) this.workBubble = '';
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
this.workBubble = '';
|
|
564
|
+
this.workBubbleTimer = 3 + Math.random() * 4;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Working characters stay at their desk — snap home and stop moving
|
|
568
|
+
if (this.isWorking && this.state !== 'handing_off') {
|
|
569
|
+
// If not yet at home, walk there first
|
|
570
|
+
const hx = this.homeX - this.x;
|
|
571
|
+
const hy = this.homeY - this.y;
|
|
572
|
+
const homeDist = Math.sqrt(hx * hx + hy * hy);
|
|
573
|
+
if (homeDist > 4) {
|
|
574
|
+
// Walk toward home desk — mark as not yet seated
|
|
575
|
+
this.isAtDesk = false;
|
|
576
|
+
const step = Math.min(this.speed * dt, homeDist);
|
|
577
|
+
this.x += (hx / homeDist) * step;
|
|
578
|
+
this.y += (hy / homeDist) * step;
|
|
579
|
+
if (Math.abs(hx) > Math.abs(hy)) {
|
|
580
|
+
this.facing = hx > 0 ? 'right' : 'left';
|
|
581
|
+
} else {
|
|
582
|
+
this.facing = hy > 0 ? 'down' : 'up';
|
|
583
|
+
}
|
|
584
|
+
} else {
|
|
585
|
+
// At desk — stay put, face forward
|
|
586
|
+
this.isAtDesk = true;
|
|
587
|
+
this.x = this.homeX;
|
|
588
|
+
this.y = this.homeY;
|
|
589
|
+
this.path = [];
|
|
590
|
+
this.pathIndex = 0;
|
|
591
|
+
this.facing = 'down';
|
|
592
|
+
}
|
|
593
|
+
} else {
|
|
594
|
+
// Follow path or move toward target (idle / walking characters)
|
|
595
|
+
let moveTargetX = this.targetX;
|
|
596
|
+
let moveTargetY = this.targetY;
|
|
597
|
+
|
|
598
|
+
if (this.path.length > 0 && this.pathIndex < this.path.length) {
|
|
599
|
+
moveTargetX = this.path[this.pathIndex].x;
|
|
600
|
+
moveTargetY = this.path[this.pathIndex].y;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const dx = moveTargetX - this.x;
|
|
604
|
+
const dy = moveTargetY - this.y;
|
|
605
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
606
|
+
|
|
607
|
+
if (dist > 2) {
|
|
608
|
+
const step = Math.min(this.speed * dt, dist);
|
|
609
|
+
this.x += (dx / dist) * step;
|
|
610
|
+
this.y += (dy / dist) * step;
|
|
611
|
+
|
|
612
|
+
if (Math.abs(dx) > Math.abs(dy)) {
|
|
613
|
+
this.facing = dx > 0 ? 'right' : 'left';
|
|
614
|
+
} else {
|
|
615
|
+
this.facing = dy > 0 ? 'down' : 'up';
|
|
616
|
+
}
|
|
617
|
+
} else {
|
|
618
|
+
this.x = moveTargetX;
|
|
619
|
+
this.y = moveTargetY;
|
|
620
|
+
if (this.path.length > 0 && this.pathIndex < this.path.length) {
|
|
621
|
+
this.pathIndex++;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Bob animation
|
|
627
|
+
if (this.state === 'working' || this.state === 'designing') {
|
|
628
|
+
this.bobOffset = Math.sin(this.stateTime * 4) * 1.5;
|
|
629
|
+
} else if (this.state === 'celebrating') {
|
|
630
|
+
this.bobOffset = Math.abs(Math.sin(this.stateTime * 6)) * -8;
|
|
631
|
+
} else if (this.state === 'sleeping') {
|
|
632
|
+
this.bobOffset = Math.sin(this.stateTime * 1.5) * 1;
|
|
633
|
+
} else if (this.state === 'frustrated' || this.state === 'rate_limited') {
|
|
634
|
+
this.bobOffset = Math.sin(this.stateTime * 12) * 1;
|
|
635
|
+
} else if (this.idleBehavior === 'gym') {
|
|
636
|
+
this.bobOffset = Math.abs(Math.sin(this.stateTime * 5)) * -3; // exercise bounce
|
|
637
|
+
} else if (this.idleBehavior === 'stretch' && this.stretchTimer > 0) {
|
|
638
|
+
// Alternate between normal and slightly taller to simulate stretching
|
|
639
|
+
this.bobOffset = Math.sin(this.stateTime * 3) * -4;
|
|
640
|
+
} else {
|
|
641
|
+
this.bobOffset = 0;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Render pixel art character */
|
|
646
|
+
render(ctx) {
|
|
647
|
+
const effects = getEffects();
|
|
648
|
+
|
|
649
|
+
// Determine sprite state
|
|
650
|
+
let spriteState = this.state;
|
|
651
|
+
const isMoving = Math.abs(this.targetX - this.x) > 2 || Math.abs(this.targetY - this.y) > 2;
|
|
652
|
+
|
|
653
|
+
if (isMoving || WALK_STATES.has(this.state)) {
|
|
654
|
+
if (this.facing === 'left' || this.facing === 'right') {
|
|
655
|
+
spriteState = 'walk_side';
|
|
656
|
+
} else if (this.facing === 'up') {
|
|
657
|
+
spriteState = 'walk_up';
|
|
658
|
+
} else {
|
|
659
|
+
spriteState = 'walk_down';
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const pixelData = getSprite(this.meta.spriteKey, spriteState, this.animFrame, this.project);
|
|
664
|
+
if (!pixelData) return;
|
|
665
|
+
|
|
666
|
+
// Shadow
|
|
667
|
+
if (this.shadow) {
|
|
668
|
+
ctx.save();
|
|
669
|
+
ctx.globalAlpha = 0.3;
|
|
670
|
+
ctx.fillStyle = '#000';
|
|
671
|
+
ctx.beginPath();
|
|
672
|
+
ctx.ellipse(this.x, this.y + 2, SPRITE_W / 3, 4, 0, 0, Math.PI * 2);
|
|
673
|
+
ctx.fill();
|
|
674
|
+
ctx.restore();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Sprite — apply team-specific tint overlay for visual distinction
|
|
678
|
+
renderSprite(ctx, pixelData, this.x, this.y, {
|
|
679
|
+
flipX: this.facing === 'left',
|
|
680
|
+
offsetY: this.bobOffset,
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// Team-colored name banner (replaces old badge — clearly shows which project)
|
|
684
|
+
const labelText = `${this.meta.label}`;
|
|
685
|
+
const labelW = labelText.length * 4 + 4;
|
|
686
|
+
ctx.fillStyle = this.projectColor;
|
|
687
|
+
ctx.globalAlpha = 0.85;
|
|
688
|
+
ctx.fillRect(this.x - labelW / 2 - 1, this.y + 2, labelW + 2, 8);
|
|
689
|
+
ctx.globalAlpha = 1;
|
|
690
|
+
renderPixelText(ctx, labelText, this.x, this.y + 3, '#fff', 1);
|
|
691
|
+
|
|
692
|
+
// State effects (lightbulb, confetti, etc.) — pass isAtDesk so work effects only show at desk
|
|
693
|
+
effects.renderStateOverlay(ctx, this.state, this.x, this.y, this.stateTime, this.isAtDesk);
|
|
694
|
+
|
|
695
|
+
// Gossip / meeting bubble
|
|
696
|
+
if ((this.idleBehavior === 'gossip' || this.idleBehavior === 'meeting') && this.gossipLine && this.gossipShowTime > 0.5) {
|
|
697
|
+
this._renderGossipBubble(ctx);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Reaction bubble (from react() call — shows immediately, higher priority)
|
|
701
|
+
if (this._reactionBubble) {
|
|
702
|
+
const savedBubble = this.workBubble;
|
|
703
|
+
this.workBubble = this._reactionBubble;
|
|
704
|
+
this.isWorking = true;
|
|
705
|
+
this._renderWorkBubble(ctx);
|
|
706
|
+
this.workBubble = savedBubble;
|
|
707
|
+
this.isWorking = !!savedBubble;
|
|
708
|
+
} else if (this.isWorking && this.workBubble) {
|
|
709
|
+
// Work thought bubble
|
|
710
|
+
this._renderWorkBubble(ctx);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Rejection desperation overlay (sweat drops + prayer hands above head)
|
|
714
|
+
if (this.state === 'fighting_bug' && this.rejectionCount >= 2) {
|
|
715
|
+
this._renderPrayerOverlay(ctx);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Idle activity indicator
|
|
719
|
+
if (this.idleBehavior && !isMoving) {
|
|
720
|
+
this._renderIdleIndicator(ctx);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Status badge shown above the character — only visible while working */
|
|
725
|
+
_renderStatusBadge(ctx) {
|
|
726
|
+
// Only show badge when actively working — hide when idle/ready/sleeping
|
|
727
|
+
if (!this.isWorking) return;
|
|
728
|
+
|
|
729
|
+
const workLabels = {
|
|
730
|
+
working: '⚡ WORKING',
|
|
731
|
+
designing: '📐 DESIGNING',
|
|
732
|
+
reviewing: '🔍 REVIEWING',
|
|
733
|
+
deploying: '🚀 DEPLOYING',
|
|
734
|
+
fighting_bug: '🐛 FIXING BUG',
|
|
735
|
+
celebrating: '🎉 DONE!',
|
|
736
|
+
frustrated: '😤 REJECTED',
|
|
737
|
+
waiting_input: this.role === 'deployer' ? '🚀 STANDBY' : '⏳ WAITING',
|
|
738
|
+
handing_off: '📄 HANDING OFF',
|
|
739
|
+
};
|
|
740
|
+
const statusText = workLabels[this.state] || '⚡ WORKING';
|
|
741
|
+
const bgColor = this.state === 'celebrating' ? '#27ae60' :
|
|
742
|
+
this.state === 'frustrated' ? '#c0392b' :
|
|
743
|
+
this.state === 'waiting_input' ? '#f39c12' :
|
|
744
|
+
'#2980b9';
|
|
745
|
+
|
|
746
|
+
// Draw badge above sprite (just below any progress bar / bubbles)
|
|
747
|
+
const badgeY = this.y - SPRITE_H + this.bobOffset - 6;
|
|
748
|
+
// Use native canvas text for emoji + crispness
|
|
749
|
+
ctx.save();
|
|
750
|
+
ctx.font = 'bold 7px monospace';
|
|
751
|
+
const metrics = ctx.measureText(statusText);
|
|
752
|
+
const bw = metrics.width + 6;
|
|
753
|
+
const bh = 10;
|
|
754
|
+
const bx = this.x - bw / 2;
|
|
755
|
+
|
|
756
|
+
// Background pill
|
|
757
|
+
ctx.globalAlpha = 0.88;
|
|
758
|
+
ctx.fillStyle = bgColor;
|
|
759
|
+
ctx.beginPath();
|
|
760
|
+
ctx.roundRect(bx, badgeY - bh, bw, bh, 3);
|
|
761
|
+
ctx.fill();
|
|
762
|
+
|
|
763
|
+
// Text
|
|
764
|
+
ctx.globalAlpha = 1;
|
|
765
|
+
ctx.fillStyle = '#fff';
|
|
766
|
+
ctx.textAlign = 'center';
|
|
767
|
+
ctx.textBaseline = 'middle';
|
|
768
|
+
ctx.fillText(statusText, this.x, badgeY - bh / 2);
|
|
769
|
+
ctx.restore();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
_renderGossipBubble(ctx) {
|
|
773
|
+
// Count only renderable chars for accurate bubble sizing
|
|
774
|
+
const upperText = this.gossipLine.toUpperCase();
|
|
775
|
+
const renderableLen = upperText.split('').filter(ch =>
|
|
776
|
+
/[A-Z0-9 .!?:\-/',()+=#_]/.test(ch)
|
|
777
|
+
).length;
|
|
778
|
+
const bubbleW = Math.min(renderableLen * 4 + 12, 100);
|
|
779
|
+
const bubbleH = 14;
|
|
780
|
+
// Offset bubble AWAY from gossip partner so they don't overlap
|
|
781
|
+
let xOffset = 0;
|
|
782
|
+
if (this.gossipTarget) {
|
|
783
|
+
xOffset = this.x <= this.gossipTarget.x ? -(bubbleW / 2 + 6) : (bubbleW / 2 + 6);
|
|
784
|
+
}
|
|
785
|
+
const bx = this.x + xOffset - bubbleW / 2;
|
|
786
|
+
const by = this.y - SPRITE_H - bubbleH - 8;
|
|
787
|
+
// Text center matches bubble center
|
|
788
|
+
const textCenterX = bx + bubbleW / 2;
|
|
789
|
+
|
|
790
|
+
// Bubble bg
|
|
791
|
+
ctx.fillStyle = '#fff';
|
|
792
|
+
ctx.fillRect(bx + 2, by, bubbleW - 4, bubbleH);
|
|
793
|
+
ctx.fillRect(bx, by + 2, bubbleW, bubbleH - 4);
|
|
794
|
+
|
|
795
|
+
// Border
|
|
796
|
+
ctx.fillStyle = '#555';
|
|
797
|
+
ctx.fillRect(bx + 2, by - 1, bubbleW - 4, 1);
|
|
798
|
+
ctx.fillRect(bx + 2, by + bubbleH, bubbleW - 4, 1);
|
|
799
|
+
ctx.fillRect(bx - 1, by + 2, 1, bubbleH - 4);
|
|
800
|
+
ctx.fillRect(bx + bubbleW, by + 2, 1, bubbleH - 4);
|
|
801
|
+
|
|
802
|
+
// Tail — point from bubble toward character
|
|
803
|
+
ctx.fillStyle = '#fff';
|
|
804
|
+
const tailX = Math.max(bx + 4, Math.min(this.x, bx + bubbleW - 6));
|
|
805
|
+
ctx.fillRect(tailX - 2, by + bubbleH, 3, 3);
|
|
806
|
+
ctx.fillRect(tailX, by + bubbleH + 3, 2, 2);
|
|
807
|
+
|
|
808
|
+
// Text (truncated to fit, centered in bubble)
|
|
809
|
+
const maxChars = Math.floor((bubbleW - 8) / 4);
|
|
810
|
+
const text = this.gossipLine.slice(0, maxChars).toUpperCase();
|
|
811
|
+
renderPixelText(ctx, text, textCenterX, by + 4, '#333', 1);
|
|
812
|
+
|
|
813
|
+
// Fade out after a while
|
|
814
|
+
if (this.gossipShowTime > 4) {
|
|
815
|
+
this.gossipShowTime = 0;
|
|
816
|
+
this.gossipLine = GOSSIP_LINES[Math.floor(Math.random() * GOSSIP_LINES.length)];
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
_renderWorkBubble(ctx) {
|
|
821
|
+
const text = this.workBubble;
|
|
822
|
+
// Emoji chars are wider — estimate width more generously
|
|
823
|
+
const hasEmoji = /[\u{1F000}-\u{1FFFF}]/u.test(text);
|
|
824
|
+
const charW = hasEmoji ? 5 : 4;
|
|
825
|
+
const bubbleW = Math.min(text.length * charW + 16, 120);
|
|
826
|
+
const bubbleH = 16;
|
|
827
|
+
const bx = this.x - bubbleW / 2;
|
|
828
|
+
const by = this.y - SPRITE_H - bubbleH - 10;
|
|
829
|
+
|
|
830
|
+
// Thought bubble style (rounded, lighter)
|
|
831
|
+
ctx.fillStyle = '#e8f4ff';
|
|
832
|
+
ctx.fillRect(bx + 2, by, bubbleW - 4, bubbleH);
|
|
833
|
+
ctx.fillRect(bx, by + 2, bubbleW, bubbleH - 4);
|
|
834
|
+
|
|
835
|
+
// Border (blue tint for thought bubble)
|
|
836
|
+
ctx.fillStyle = '#4080d0';
|
|
837
|
+
ctx.fillRect(bx + 2, by - 1, bubbleW - 4, 1);
|
|
838
|
+
ctx.fillRect(bx + 2, by + bubbleH, bubbleW - 4, 1);
|
|
839
|
+
ctx.fillRect(bx - 1, by + 2, 1, bubbleH - 4);
|
|
840
|
+
ctx.fillRect(bx + bubbleW, by + 2, 1, bubbleH - 4);
|
|
841
|
+
|
|
842
|
+
// Thought dots tail (instead of speech tail)
|
|
843
|
+
ctx.fillStyle = '#e8f4ff';
|
|
844
|
+
ctx.fillRect(this.x - 1, by + bubbleH, 2, 2);
|
|
845
|
+
ctx.fillStyle = '#4080d0';
|
|
846
|
+
ctx.fillRect(this.x, by + bubbleH + 2, 2, 2);
|
|
847
|
+
ctx.fillRect(this.x + 1, by + bubbleH + 5, 2, 2);
|
|
848
|
+
|
|
849
|
+
// Text — render emoji natively, pixel text for ASCII
|
|
850
|
+
if (hasEmoji) {
|
|
851
|
+
ctx.save();
|
|
852
|
+
ctx.font = '9px sans-serif';
|
|
853
|
+
ctx.textAlign = 'center';
|
|
854
|
+
ctx.textBaseline = 'middle';
|
|
855
|
+
ctx.fillStyle = '#1a3a7a';
|
|
856
|
+
ctx.fillText(text.slice(0, 20), this.x, by + bubbleH / 2);
|
|
857
|
+
ctx.restore();
|
|
858
|
+
} else {
|
|
859
|
+
const maxChars = Math.floor((bubbleW - 8) / 4);
|
|
860
|
+
renderPixelText(ctx, text.slice(0, maxChars), this.x, by + 4, '#1a3a7a', 1);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
_renderPrayerOverlay(ctx) {
|
|
865
|
+
const t = this.stateTime;
|
|
866
|
+
const count = this.rejectionCount;
|
|
867
|
+
const px = PIXEL_SIZE;
|
|
868
|
+
|
|
869
|
+
// Animated sweat drops (intensity increases with rejection count)
|
|
870
|
+
const drops = Math.min(count + 1, 4);
|
|
871
|
+
ctx.fillStyle = '#60a0ff';
|
|
872
|
+
for (let i = 0; i < drops; i++) {
|
|
873
|
+
const angle = (t * 2.5 + i * (Math.PI * 2 / drops)) % (Math.PI * 2);
|
|
874
|
+
const radius = 12 + i * 2;
|
|
875
|
+
const sx = this.x + Math.cos(angle) * radius;
|
|
876
|
+
const sy = (this.y - SPRITE_H * 0.6) + Math.sin(angle) * 6;
|
|
877
|
+
const size = 1 + (Math.sin(t * 3 + i) * 0.5 + 0.5);
|
|
878
|
+
ctx.fillRect(sx, sy, px * size, px * size * 2);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Prayer hands emoji above head (pulsing)
|
|
882
|
+
if (count >= 2) {
|
|
883
|
+
const pulse = 0.85 + Math.sin(t * 4) * 0.15;
|
|
884
|
+
ctx.save();
|
|
885
|
+
ctx.font = `${Math.round(11 * pulse)}px serif`;
|
|
886
|
+
ctx.textAlign = 'center';
|
|
887
|
+
ctx.globalAlpha = 0.9;
|
|
888
|
+
ctx.fillText('🙏', this.x, this.y - SPRITE_H - 18);
|
|
889
|
+
ctx.restore();
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Desperation sparkles / stress marks for count >= 3
|
|
893
|
+
if (count >= 3) {
|
|
894
|
+
ctx.fillStyle = '#ff6040';
|
|
895
|
+
const stressAngle = t * 5;
|
|
896
|
+
ctx.fillRect(this.x + Math.cos(stressAngle) * 16 - 1, this.y - SPRITE_H * 0.8 + Math.sin(stressAngle) * 8 - 1, 3, 3);
|
|
897
|
+
ctx.fillRect(this.x - Math.cos(stressAngle) * 14 - 1, this.y - SPRITE_H * 0.7 - Math.sin(stressAngle) * 6 - 1, 2, 2);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
_renderIdleIndicator(ctx) {
|
|
902
|
+
const pw = PIXEL_SIZE;
|
|
903
|
+
const ix = this.x;
|
|
904
|
+
const iy = this.y - SPRITE_H - 4;
|
|
905
|
+
|
|
906
|
+
switch (this.idleBehavior) {
|
|
907
|
+
case 'gym':
|
|
908
|
+
// Dumbbell icon above head
|
|
909
|
+
ctx.fillStyle = '#808088';
|
|
910
|
+
ctx.fillRect(ix - 6, iy, 12, 2);
|
|
911
|
+
ctx.fillStyle = '#606068';
|
|
912
|
+
ctx.fillRect(ix - 8, iy - 2, 4, 6);
|
|
913
|
+
ctx.fillRect(ix + 4, iy - 2, 4, 6);
|
|
914
|
+
break;
|
|
915
|
+
case 'breakRoom':
|
|
916
|
+
// Fork/knife icon
|
|
917
|
+
ctx.fillStyle = '#c0c0c0';
|
|
918
|
+
ctx.fillRect(ix - 3, iy - 2, 1, 6);
|
|
919
|
+
ctx.fillRect(ix + 2, iy - 2, 1, 6);
|
|
920
|
+
ctx.fillRect(ix - 4, iy - 2, 2, 2);
|
|
921
|
+
ctx.fillRect(ix + 2, iy, 2, 1);
|
|
922
|
+
break;
|
|
923
|
+
case 'phone':
|
|
924
|
+
// Small phone screen rectangle near character's hand
|
|
925
|
+
ctx.fillStyle = '#1a1a2e';
|
|
926
|
+
ctx.fillRect(ix + 6, iy + 4, 5, 8); // phone body
|
|
927
|
+
ctx.fillStyle = '#60c0ff'; // bright screen
|
|
928
|
+
ctx.fillRect(ix + 7, iy + 5, 3, 5); // screen
|
|
929
|
+
// Screen glow flicker
|
|
930
|
+
if (Math.sin(this.stateTime * 4) > 0) {
|
|
931
|
+
ctx.fillStyle = '#a0e0ff';
|
|
932
|
+
ctx.fillRect(ix + 7, iy + 5, 3, 1);
|
|
933
|
+
}
|
|
934
|
+
break;
|
|
935
|
+
case 'stretch':
|
|
936
|
+
// Motion lines "~" above the character
|
|
937
|
+
ctx.fillStyle = '#a0a0b0';
|
|
938
|
+
const wave = Math.sin(this.stateTime * 6);
|
|
939
|
+
// Left motion line
|
|
940
|
+
ctx.fillRect(ix - 8, iy - 4 + wave * 2, 4, 1);
|
|
941
|
+
ctx.fillRect(ix - 7, iy - 3 + wave * 2, 4, 1);
|
|
942
|
+
// Right motion line
|
|
943
|
+
ctx.fillRect(ix + 4, iy - 6 - wave * 2, 4, 1);
|
|
944
|
+
ctx.fillRect(ix + 5, iy - 5 - wave * 2, 4, 1);
|
|
945
|
+
// Top motion line
|
|
946
|
+
ctx.fillRect(ix - 2, iy - 8 + wave, 5, 1);
|
|
947
|
+
break;
|
|
948
|
+
case 'meeting':
|
|
949
|
+
// Small clipboard icon above head
|
|
950
|
+
ctx.fillStyle = '#c8a050';
|
|
951
|
+
ctx.fillRect(ix - 3, iy - 2, 6, 8); // clipboard body
|
|
952
|
+
ctx.fillStyle = '#f0e8d0';
|
|
953
|
+
ctx.fillRect(ix - 2, iy, 4, 5); // paper
|
|
954
|
+
ctx.fillStyle = '#888';
|
|
955
|
+
ctx.fillRect(ix - 1, iy - 3, 2, 2); // clip
|
|
956
|
+
break;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Draw task progress bar above character sprite */
|
|
961
|
+
_drawProgressBar(ctx) {
|
|
962
|
+
const barW = 28;
|
|
963
|
+
const barH = 4;
|
|
964
|
+
const barX = this.x - barW / 2;
|
|
965
|
+
const barY = this.y - SPRITE_H + this.bobOffset - barH - 3;
|
|
966
|
+
|
|
967
|
+
// Background
|
|
968
|
+
ctx.fillStyle = '#1a1a2e';
|
|
969
|
+
ctx.fillRect(barX - 1, barY - 1, barW + 2, barH + 2);
|
|
970
|
+
|
|
971
|
+
// Empty track
|
|
972
|
+
ctx.fillStyle = '#2a2a4a';
|
|
973
|
+
ctx.fillRect(barX, barY, barW, barH);
|
|
974
|
+
|
|
975
|
+
// Filled portion — color shifts by progress
|
|
976
|
+
const p = Math.max(0, Math.min(1, this.taskProgress));
|
|
977
|
+
const fillW = Math.floor(barW * p);
|
|
978
|
+
if (fillW > 0) {
|
|
979
|
+
if (p < 0.3) ctx.fillStyle = '#d04040';
|
|
980
|
+
else if (p < 0.7) ctx.fillStyle = '#d8c020';
|
|
981
|
+
else ctx.fillStyle = '#40d060';
|
|
982
|
+
ctx.fillRect(barX, barY, fillW, barH);
|
|
983
|
+
// Shine
|
|
984
|
+
ctx.fillStyle = '#ffffff30';
|
|
985
|
+
ctx.fillRect(barX, barY, fillW, 1);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Percentage
|
|
989
|
+
ctx.fillStyle = '#ffffff';
|
|
990
|
+
ctx.font = 'bold 5px monospace';
|
|
991
|
+
ctx.textAlign = 'center';
|
|
992
|
+
ctx.fillText(`${Math.floor(p * 100)}%`, this.x, barY + barH - 1);
|
|
993
|
+
ctx.textAlign = 'start';
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** Check if character wants a new idle behavior */
|
|
997
|
+
wantsIdleAction() {
|
|
998
|
+
return !this.isWorking && !this.isHandingOff && this.state !== 'rate_limited' && !this.idleBehavior && this.idleTimer <= 0;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
hitTest(wx, wy) {
|
|
1002
|
+
const halfW = SPRITE_W / 2;
|
|
1003
|
+
return wx >= this.x - halfW && wx <= this.x + halfW &&
|
|
1004
|
+
wy >= this.y - SPRITE_H && wy <= this.y + 8;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
getInfo() {
|
|
1008
|
+
let displayState = this.state;
|
|
1009
|
+
if (this.isWorking) {
|
|
1010
|
+
const workNames = {
|
|
1011
|
+
working: '⚡ Working',
|
|
1012
|
+
designing: '📐 Designing architecture',
|
|
1013
|
+
reviewing: '🔍 Reviewing code',
|
|
1014
|
+
deploying: '🚀 Deploying',
|
|
1015
|
+
fighting_bug: '🐛 Fixing bug',
|
|
1016
|
+
celebrating: '🎉 Celebrating',
|
|
1017
|
+
frustrated: '😤 Frustrated (rejected)',
|
|
1018
|
+
waiting_input: '⏳ Waiting for input',
|
|
1019
|
+
handing_off: '📄 Handing off',
|
|
1020
|
+
};
|
|
1021
|
+
displayState = workNames[this.state] || '⚡ Working';
|
|
1022
|
+
} else if (this.idleBehavior) {
|
|
1023
|
+
const idleNames = {
|
|
1024
|
+
wander: 'Wandering around',
|
|
1025
|
+
gossip: 'Chatting',
|
|
1026
|
+
gym: 'Working out',
|
|
1027
|
+
breakRoom: 'Having lunch',
|
|
1028
|
+
coffee: 'Getting coffee',
|
|
1029
|
+
sleep: 'Sleeping',
|
|
1030
|
+
poke: 'Poking around',
|
|
1031
|
+
highfive: 'High five!',
|
|
1032
|
+
phone: 'Checking phone',
|
|
1033
|
+
stretch: 'Stretching',
|
|
1034
|
+
meeting: 'Quick meeting',
|
|
1035
|
+
};
|
|
1036
|
+
displayState = idleNames[this.idleBehavior] || this.state;
|
|
1037
|
+
} else {
|
|
1038
|
+
displayState = '🟢 Ready';
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return {
|
|
1042
|
+
role: this.meta.label,
|
|
1043
|
+
ai: this.meta.ai,
|
|
1044
|
+
project: this.project,
|
|
1045
|
+
state: displayState,
|
|
1046
|
+
rawState: this.state,
|
|
1047
|
+
color: this.meta.color,
|
|
1048
|
+
spriteKey: this.meta.spriteKey,
|
|
1049
|
+
isPM: this.role === 'pm',
|
|
1050
|
+
isWorking: this.isWorking,
|
|
1051
|
+
idleBehavior: this.idleBehavior,
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
export { STAGE_MAP, ROLES, IDLE_BEHAVIORS, FEATURE_DISCUSS_LINES, pickTimeAwareBehavior };
|