@clawlabz/clawskin 1.0.0
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/CHANGELOG.md +98 -0
- package/CONTRIBUTING.md +40 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/docs/ARCHITECTURE.md +132 -0
- package/docs/PROGRESS.md +15 -0
- package/docs/ROADMAP.md +130 -0
- package/package.json +27 -0
- package/public/_headers +13 -0
- package/public/_redirects +1 -0
- package/public/app.html +595 -0
- package/public/css/app.css +379 -0
- package/public/css/style.css +415 -0
- package/public/index.html +324 -0
- package/public/js/app/AgentSlot.js +596 -0
- package/public/js/app/AgentStateMapper.js +213 -0
- package/public/js/app/ClawSkinApp.js +551 -0
- package/public/js/app/ConnectionPanel.js +142 -0
- package/public/js/app/DeviceIdentity.js +118 -0
- package/public/js/app/GatewayClient.js +284 -0
- package/public/js/app/SettingsManager.js +47 -0
- package/public/js/character/AnimationManager.js +69 -0
- package/public/js/character/BubbleManager.js +157 -0
- package/public/js/character/CharacterSprite.js +98 -0
- package/public/js/game.js +116 -0
- package/public/js/pets/Pet.js +303 -0
- package/public/js/pets/PetManager.js +102 -0
- package/public/js/scenes/CafeScene.js +594 -0
- package/public/js/scenes/HackerScene.js +527 -0
- package/public/js/scenes/OfficeScene.js +404 -0
- package/public/js/sprites/SpriteGenerator.js +927 -0
- package/public/js/state/AgentStateSync.js +89 -0
- package/public/js/state/DemoMode.js +65 -0
- package/public/js/ui/CharacterEditor.js +145 -0
- package/public/js/ui/ScenePicker.js +29 -0
- package/serve.cjs +132 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CharacterSprite.js — Manages character rendering in a scene
|
|
3
|
+
* Composites sprite frames from SpriteGenerator and handles positioning
|
|
4
|
+
*/
|
|
5
|
+
class CharacterSprite {
|
|
6
|
+
constructor(config, x, y, scale = 3) {
|
|
7
|
+
this.config = config || CharacterSprite.defaultConfig();
|
|
8
|
+
this.x = x;
|
|
9
|
+
this.y = y;
|
|
10
|
+
this.scale = scale;
|
|
11
|
+
this.spriteSheet = null;
|
|
12
|
+
this.animManager = new AnimationManager();
|
|
13
|
+
this.bubbleManager = new BubbleManager();
|
|
14
|
+
this.generator = new SpriteGenerator();
|
|
15
|
+
this.petCanvas = null;
|
|
16
|
+
this.petFrame = 0;
|
|
17
|
+
this.petTimer = 0;
|
|
18
|
+
this.regenerate();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static defaultConfig() {
|
|
22
|
+
return {
|
|
23
|
+
skinColor: SpriteGenerator.SKIN_TONES[0],
|
|
24
|
+
hairType: 0,
|
|
25
|
+
hairColor: SpriteGenerator.HAIR_COLORS[0],
|
|
26
|
+
outfitType: 'hoodie',
|
|
27
|
+
outfitColorIdx: 0,
|
|
28
|
+
accessory: null,
|
|
29
|
+
pet: null
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static randomConfig() {
|
|
34
|
+
const outfitTypes = Object.keys(SpriteGenerator.OUTFIT_COLORS);
|
|
35
|
+
const accessories = [null, 'glasses', 'hat', 'headphones', 'cap'];
|
|
36
|
+
const pets = [null, 'cat', 'dog', 'robot'];
|
|
37
|
+
return {
|
|
38
|
+
skinColor: SpriteGenerator.SKIN_TONES[Math.floor(Math.random() * 5)],
|
|
39
|
+
hairType: Math.floor(Math.random() * 5),
|
|
40
|
+
hairColor: SpriteGenerator.HAIR_COLORS[Math.floor(Math.random() * 7)],
|
|
41
|
+
outfitType: outfitTypes[Math.floor(Math.random() * outfitTypes.length)],
|
|
42
|
+
outfitColorIdx: Math.floor(Math.random() * 5),
|
|
43
|
+
accessory: accessories[Math.floor(Math.random() * accessories.length)],
|
|
44
|
+
pet: pets[Math.floor(Math.random() * pets.length)]
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
regenerate() {
|
|
49
|
+
this.generator.cache.clear();
|
|
50
|
+
this.spriteSheet = this.generator.generateCharacter(this.config);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
updateConfig(newConfig) {
|
|
54
|
+
this.config = { ...this.config, ...newConfig };
|
|
55
|
+
this.regenerate();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setState(state) {
|
|
59
|
+
this.animManager.setState(state);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
showBubble(text, type, duration) {
|
|
63
|
+
this.bubbleManager.show(text, type, duration);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
update(dt) {
|
|
67
|
+
this.animManager.update(dt);
|
|
68
|
+
this.bubbleManager.update(dt);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
render(ctx) {
|
|
72
|
+
if (!this.spriteSheet) return;
|
|
73
|
+
|
|
74
|
+
const frame = this.animManager.getCurrentFrame();
|
|
75
|
+
const sx = frame * 32;
|
|
76
|
+
|
|
77
|
+
ctx.save();
|
|
78
|
+
ctx.imageSmoothingEnabled = false;
|
|
79
|
+
|
|
80
|
+
// Draw character only — pets are now independent entities managed by PetManager
|
|
81
|
+
ctx.drawImage(
|
|
82
|
+
this.spriteSheet,
|
|
83
|
+
sx, 0, 32, 32,
|
|
84
|
+
this.x, this.y, 32 * this.scale, 32 * this.scale
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
ctx.restore();
|
|
88
|
+
|
|
89
|
+
// Draw bubble (not pixel-scaled)
|
|
90
|
+
this.bubbleManager.render(
|
|
91
|
+
ctx,
|
|
92
|
+
this.x + (32 * this.scale) / 2,
|
|
93
|
+
this.y
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (typeof window !== 'undefined') window.CharacterSprite = CharacterSprite;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* game.js — ClawSkin main entry point
|
|
3
|
+
* Initializes the pixel engine, scenes, character, and UI
|
|
4
|
+
*/
|
|
5
|
+
class ClawSkinGame {
|
|
6
|
+
constructor(canvasId, options = {}) {
|
|
7
|
+
this.canvas = document.getElementById(canvasId);
|
|
8
|
+
this.ctx = this.canvas.getContext('2d');
|
|
9
|
+
this.width = options.width || 480;
|
|
10
|
+
this.height = options.height || 320;
|
|
11
|
+
this.canvas.width = this.width;
|
|
12
|
+
this.canvas.height = this.height;
|
|
13
|
+
|
|
14
|
+
this.spriteGen = new SpriteGenerator();
|
|
15
|
+
this.scenes = [];
|
|
16
|
+
this.currentScene = null;
|
|
17
|
+
this.character = null;
|
|
18
|
+
this.stateSync = null;
|
|
19
|
+
this.lastTime = 0;
|
|
20
|
+
this.running = false;
|
|
21
|
+
|
|
22
|
+
// Status display
|
|
23
|
+
this.statusEl = options.statusEl ? document.getElementById(options.statusEl) : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
init(characterConfig = null) {
|
|
27
|
+
// Load saved config or use provided/default
|
|
28
|
+
const savedConfig = CharacterEditor.loadSaved();
|
|
29
|
+
const cfg = characterConfig || savedConfig || CharacterSprite.defaultConfig();
|
|
30
|
+
|
|
31
|
+
// Create scenes
|
|
32
|
+
this.scenes = [
|
|
33
|
+
new OfficeScene(this.canvas, this.ctx, this.spriteGen),
|
|
34
|
+
new HackerScene(this.canvas, this.ctx, this.spriteGen),
|
|
35
|
+
new CafeScene(this.canvas, this.ctx, this.spriteGen),
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Init all scenes
|
|
39
|
+
this.scenes.forEach(s => s.init());
|
|
40
|
+
|
|
41
|
+
// Set initial scene
|
|
42
|
+
this.currentScene = this.scenes[0];
|
|
43
|
+
|
|
44
|
+
// Create character
|
|
45
|
+
const pos = this.currentScene.getCharacterPosition();
|
|
46
|
+
this.character = new CharacterSprite(cfg, pos.x, pos.y);
|
|
47
|
+
|
|
48
|
+
// State sync (demo mode by default)
|
|
49
|
+
this.stateSync = new AgentStateSync(this.character, {
|
|
50
|
+
gatewayUrl: null, // Will use demo mode
|
|
51
|
+
onStateChange: (state) => {
|
|
52
|
+
if (this.statusEl) {
|
|
53
|
+
this.statusEl.textContent = this.character.animManager.getLabel();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
this.stateSync.connect();
|
|
58
|
+
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setScene(index) {
|
|
63
|
+
if (index < 0 || index >= this.scenes.length) return;
|
|
64
|
+
this.currentScene = this.scenes[index];
|
|
65
|
+
this.currentScene.init();
|
|
66
|
+
const pos = this.currentScene.getCharacterPosition();
|
|
67
|
+
this.character.x = pos.x;
|
|
68
|
+
this.character.y = pos.y;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
start() {
|
|
72
|
+
this.running = true;
|
|
73
|
+
this.lastTime = performance.now();
|
|
74
|
+
this._loop();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
stop() {
|
|
78
|
+
this.running = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_loop() {
|
|
82
|
+
if (!this.running) return;
|
|
83
|
+
const now = performance.now();
|
|
84
|
+
const dt = now - this.lastTime;
|
|
85
|
+
this.lastTime = now;
|
|
86
|
+
|
|
87
|
+
this._update(dt);
|
|
88
|
+
this._render();
|
|
89
|
+
|
|
90
|
+
requestAnimationFrame(() => this._loop());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_update(dt) {
|
|
94
|
+
if (this.currentScene) this.currentScene.update(dt);
|
|
95
|
+
if (this.character) this.character.update(dt);
|
|
96
|
+
if (this.stateSync) this.stateSync.update(dt);
|
|
97
|
+
|
|
98
|
+
// Update status display
|
|
99
|
+
if (this.statusEl && this.character) {
|
|
100
|
+
this.statusEl.textContent = this.character.animManager.getLabel();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_render() {
|
|
105
|
+
this.ctx.clearRect(0, 0, this.width, this.height);
|
|
106
|
+
this.ctx.imageSmoothingEnabled = false;
|
|
107
|
+
|
|
108
|
+
// Render scene background + furniture
|
|
109
|
+
if (this.currentScene) this.currentScene.render(this.ctx);
|
|
110
|
+
|
|
111
|
+
// Render character on top
|
|
112
|
+
if (this.character) this.character.render(this.ctx);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof window !== 'undefined') window.ClawSkinGame = ClawSkinGame;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pet.js — Independent pet entity with autonomous AI behavior
|
|
3
|
+
* Pets move freely around the office, interact with agents and other pets
|
|
4
|
+
*/
|
|
5
|
+
class Pet {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.id = config.id || Math.random().toString(36).slice(2, 8);
|
|
8
|
+
this.type = config.type || 'cat'; // 'cat' | 'dog' | 'robot' | 'bird' | 'hamster'
|
|
9
|
+
this.moveType = config.moveType || 'walk'; // 'walk' | 'fly' | 'crawl'
|
|
10
|
+
this.color = config.color || null; // body color (null = random)
|
|
11
|
+
this.x = config.x || 100;
|
|
12
|
+
this.y = config.y || 200;
|
|
13
|
+
this.scale = config.scale || 2.5;
|
|
14
|
+
this.speed = config.speed || 0.04;
|
|
15
|
+
this.facingLeft = false;
|
|
16
|
+
|
|
17
|
+
// Pick random color if none given
|
|
18
|
+
if (!this.color) {
|
|
19
|
+
const palette = Pet.COLORS[this.type] || Pet.COLORS.cat;
|
|
20
|
+
this.color = palette[Math.floor(Math.random() * palette.length)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Animation
|
|
24
|
+
this.frame = 0;
|
|
25
|
+
this.frameTimer = 0;
|
|
26
|
+
this.frameInterval = 600; // ms per frame
|
|
27
|
+
|
|
28
|
+
// AI behavior
|
|
29
|
+
this.state = 'idle'; // 'idle' | 'moving' | 'sleeping' | 'interacting'
|
|
30
|
+
this.target = null; // { x, y }
|
|
31
|
+
this.actionTimer = 0;
|
|
32
|
+
this.nextActionTime = 3000 + Math.random() * 5000;
|
|
33
|
+
this.interactLabel = null; // what we're interacting with
|
|
34
|
+
|
|
35
|
+
// Flying pets: sine wave bob
|
|
36
|
+
this._flyTime = Math.random() * 10000;
|
|
37
|
+
this._flyBaseY = this.y;
|
|
38
|
+
this._stuckTimer = 0;
|
|
39
|
+
|
|
40
|
+
// Sprite generator (shared)
|
|
41
|
+
this.generator = config.generator || new SpriteGenerator();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pet type metadata */
|
|
45
|
+
static TYPES = {
|
|
46
|
+
cat: { moveType: 'walk', speed: 0.04, scale: 2.5, sleepChance: 0.20 },
|
|
47
|
+
dog: { moveType: 'walk', speed: 0.05, scale: 2.5, sleepChance: 0.10 },
|
|
48
|
+
robot: { moveType: 'walk', speed: 0.03, scale: 2.5, sleepChance: 0.05 },
|
|
49
|
+
bird: { moveType: 'fly', speed: 0.06, scale: 2.0, sleepChance: 0.10 },
|
|
50
|
+
hamster: { moveType: 'crawl',speed: 0.02, scale: 2.0, sleepChance: 0.30 },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Color palettes per type */
|
|
54
|
+
static COLORS = {
|
|
55
|
+
cat: ['#FF9900','#333333','#FFFFFF','#C0C0C0','#D2691E','#FFD700','#8B4513'],
|
|
56
|
+
dog: ['#D2956A','#8B4513','#F5DEB3','#FFD700','#333333','#FFFFFF','#A0522D'],
|
|
57
|
+
robot: ['#95A5A6','#3498DB','#E74C3C','#2ECC71','#F39C12','#9B59B6','#1ABC9C'],
|
|
58
|
+
bird: ['#4FC3F7','#E74C3C','#FFD700','#2ECC71','#FF9800','#9C27B0','#F48FB1'],
|
|
59
|
+
hamster: ['#F5DEB3','#D2B48C','#FFFFFF','#FFD700','#C0C0C0','#DEB887','#FFDAB9'],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
static create(type, generator, sceneBounds, color) {
|
|
63
|
+
const meta = Pet.TYPES[type] || Pet.TYPES.cat;
|
|
64
|
+
return new Pet({
|
|
65
|
+
type,
|
|
66
|
+
moveType: meta.moveType,
|
|
67
|
+
speed: meta.speed,
|
|
68
|
+
scale: meta.scale,
|
|
69
|
+
color: color || null, // null → random in constructor
|
|
70
|
+
x: 50 + Math.random() * ((sceneBounds?.w || 600) - 100),
|
|
71
|
+
y: (sceneBounds?.floorY || 160) + 10 + Math.random() * ((sceneBounds?.h || 400) - (sceneBounds?.floorY || 160) - 40),
|
|
72
|
+
generator,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
update(dt, agents, otherPets, sceneBounds, obstacles) {
|
|
77
|
+
// Animation frame cycling
|
|
78
|
+
this.frameTimer += dt;
|
|
79
|
+
if (this.frameTimer > this.frameInterval) {
|
|
80
|
+
this.frameTimer = 0;
|
|
81
|
+
this.frame = (this.frame + 1) % 2;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Flying bob
|
|
85
|
+
if (this.moveType === 'fly') {
|
|
86
|
+
this._flyTime += dt;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// AI decision timer
|
|
90
|
+
this.actionTimer += dt;
|
|
91
|
+
if (this.actionTimer > this.nextActionTime) {
|
|
92
|
+
this._decideAction(agents, otherPets, sceneBounds);
|
|
93
|
+
this.actionTimer = 0;
|
|
94
|
+
this.nextActionTime = 3000 + Math.random() * 8000;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Movement
|
|
98
|
+
if (this.state === 'moving' && this.target) {
|
|
99
|
+
this._updateMovement(dt, sceneBounds, obstacles);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_decideAction(agents, otherPets, bounds) {
|
|
104
|
+
const roll = Math.random();
|
|
105
|
+
const sleepChance = (Pet.TYPES[this.type] || {}).sleepChance || 0.15;
|
|
106
|
+
|
|
107
|
+
if (roll < 0.25 && agents && agents.length > 0) {
|
|
108
|
+
// Go to an agent — rub against their legs
|
|
109
|
+
const agent = agents[Math.floor(Math.random() * agents.length)];
|
|
110
|
+
const charW = 32 * (agent.character?.scale || 2.5);
|
|
111
|
+
this.target = {
|
|
112
|
+
x: agent.character.x + charW * 0.3 + Math.random() * 20,
|
|
113
|
+
y: agent.character.y + 32 * (agent.character?.scale || 2.5) - 10,
|
|
114
|
+
};
|
|
115
|
+
this.state = 'moving';
|
|
116
|
+
this.interactLabel = 'agent';
|
|
117
|
+
} else if (roll < 0.40 && otherPets && otherPets.length > 0) {
|
|
118
|
+
// Go to another pet
|
|
119
|
+
const candidates = otherPets.filter(p => p !== this);
|
|
120
|
+
if (candidates.length > 0) {
|
|
121
|
+
const other = candidates[Math.floor(Math.random() * candidates.length)];
|
|
122
|
+
this.target = { x: other.x + 15, y: other.y };
|
|
123
|
+
this.state = 'moving';
|
|
124
|
+
this.interactLabel = 'pet';
|
|
125
|
+
} else {
|
|
126
|
+
this._randomWalk(bounds);
|
|
127
|
+
}
|
|
128
|
+
} else if (roll < 0.60) {
|
|
129
|
+
// Random walk
|
|
130
|
+
this._randomWalk(bounds);
|
|
131
|
+
} else if (roll < 0.60 + sleepChance) {
|
|
132
|
+
// Sleep
|
|
133
|
+
this.state = 'sleeping';
|
|
134
|
+
this.target = null;
|
|
135
|
+
} else {
|
|
136
|
+
// Idle — stay put
|
|
137
|
+
this.state = 'idle';
|
|
138
|
+
this.target = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_randomWalk(bounds) {
|
|
143
|
+
const w = bounds?.w || 640;
|
|
144
|
+
const h = bounds?.h || 400;
|
|
145
|
+
const floorY = bounds?.floorY || 160;
|
|
146
|
+
const floorH = h - floorY;
|
|
147
|
+
this.target = {
|
|
148
|
+
x: 30 + Math.random() * (w - 60),
|
|
149
|
+
y: floorY + 5 + Math.random() * (floorH - 30), // roam entire floor area
|
|
150
|
+
};
|
|
151
|
+
this.state = 'moving';
|
|
152
|
+
this.interactLabel = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_updateMovement(dt, bounds, obstacles) {
|
|
156
|
+
if (!this.target) { this.state = 'idle'; return; }
|
|
157
|
+
|
|
158
|
+
const dx = this.target.x - this.x;
|
|
159
|
+
const dy = this.target.y - this.y;
|
|
160
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
161
|
+
|
|
162
|
+
if (dist <= 3) {
|
|
163
|
+
this.x = this.target.x;
|
|
164
|
+
this.y = this.target.y;
|
|
165
|
+
this.state = 'idle';
|
|
166
|
+
this.target = null;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Hamster: occasional speed burst
|
|
171
|
+
let speed = this.speed;
|
|
172
|
+
if (this.type === 'hamster' && Math.random() < 0.01) {
|
|
173
|
+
speed = 0.08; // sprint!
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Update facing direction
|
|
177
|
+
if (dx < -1) this.facingLeft = true;
|
|
178
|
+
else if (dx > 1) this.facingLeft = false;
|
|
179
|
+
|
|
180
|
+
let newX = this.x + (dx / dist) * speed * dt;
|
|
181
|
+
let newY = this.y + (dy / dist) * speed * dt;
|
|
182
|
+
const prevX = this.x;
|
|
183
|
+
const prevY = this.y;
|
|
184
|
+
|
|
185
|
+
// ── Collision detection against obstacles ──
|
|
186
|
+
if (obstacles && obstacles.length > 0) {
|
|
187
|
+
const petW = 16 * this.scale;
|
|
188
|
+
const petH = 16 * this.scale;
|
|
189
|
+
// Pet hitbox — small rect at pet center-bottom
|
|
190
|
+
const hw = 10, hh = 8;
|
|
191
|
+
const getHitbox = (px, py) => ({
|
|
192
|
+
x: px + petW / 2 - hw / 2,
|
|
193
|
+
y: py + petH - hh,
|
|
194
|
+
w: hw,
|
|
195
|
+
h: hh,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
for (const obs of obstacles) {
|
|
199
|
+
const hb = getHitbox(newX, newY);
|
|
200
|
+
const hit = hb.x < obs.x + obs.w && hb.x + hb.w > obs.x
|
|
201
|
+
&& hb.y < obs.y + obs.h && hb.y + hb.h > obs.y;
|
|
202
|
+
|
|
203
|
+
if (hit) {
|
|
204
|
+
// Try sliding: X-only or Y-only
|
|
205
|
+
const hbX = getHitbox(newX, this.y);
|
|
206
|
+
const xHit = hbX.x < obs.x + obs.w && hbX.x + hbX.w > obs.x
|
|
207
|
+
&& hbX.y < obs.y + obs.h && hbX.y + hbX.h > obs.y;
|
|
208
|
+
const hbY = getHitbox(this.x, newY);
|
|
209
|
+
const yHit = hbY.x < obs.x + obs.w && hbY.x + hbY.w > obs.x
|
|
210
|
+
&& hbY.y < obs.y + obs.h && hbY.y + hbY.h > obs.y;
|
|
211
|
+
|
|
212
|
+
if (xHit && yHit) {
|
|
213
|
+
newX = this.x; newY = this.y;
|
|
214
|
+
} else if (xHit) {
|
|
215
|
+
newX = this.x;
|
|
216
|
+
} else if (yHit) {
|
|
217
|
+
newY = this.y;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this.x = newX;
|
|
224
|
+
this.y = newY;
|
|
225
|
+
|
|
226
|
+
// Clamp within scene bounds — roam entire floor area
|
|
227
|
+
const w = bounds?.w || 640;
|
|
228
|
+
const bh = bounds?.h || 400;
|
|
229
|
+
const floorY = bounds?.floorY || Math.round(bh * 0.40);
|
|
230
|
+
this.x = Math.max(10, Math.min(w - 30, this.x));
|
|
231
|
+
this.y = Math.max(floorY + 5, Math.min(bh - 20, this.y));
|
|
232
|
+
|
|
233
|
+
// Anti-stuck: if barely moved, give up and pick a new target
|
|
234
|
+
const moved = Math.abs(this.x - prevX) + Math.abs(this.y - prevY);
|
|
235
|
+
if (moved < 0.05) {
|
|
236
|
+
this._stuckTimer += dt;
|
|
237
|
+
if (this._stuckTimer > 1200) {
|
|
238
|
+
this._stuckTimer = 0;
|
|
239
|
+
this.state = 'idle';
|
|
240
|
+
this.target = null;
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
this._stuckTimer = 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
render(ctx) {
|
|
248
|
+
const petCanvas = this.generator.generatePet(this.type, this.frame, this.color);
|
|
249
|
+
if (!petCanvas) return;
|
|
250
|
+
|
|
251
|
+
const drawW = 16 * this.scale;
|
|
252
|
+
const drawH = 16 * this.scale;
|
|
253
|
+
|
|
254
|
+
// Flying pets bob up and down
|
|
255
|
+
let drawY = this.y;
|
|
256
|
+
if (this.moveType === 'fly') {
|
|
257
|
+
drawY = this.y - 20 + Math.sin(this._flyTime * 0.003) * 8;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
ctx.save();
|
|
261
|
+
ctx.imageSmoothingEnabled = false;
|
|
262
|
+
|
|
263
|
+
// Flip horizontally if facing right (default sprite faces left)
|
|
264
|
+
if (!this.facingLeft) {
|
|
265
|
+
ctx.translate(this.x + drawW, drawY);
|
|
266
|
+
ctx.scale(-1, 1);
|
|
267
|
+
ctx.drawImage(petCanvas, 0, 0, drawW, drawH);
|
|
268
|
+
} else {
|
|
269
|
+
ctx.drawImage(petCanvas, this.x, drawY, drawW, drawH);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Shadow on ground
|
|
273
|
+
ctx.globalAlpha = 0.15;
|
|
274
|
+
ctx.fillStyle = '#000';
|
|
275
|
+
const shadowY = this.y + drawH - 2;
|
|
276
|
+
const shadowW = drawW * 0.7;
|
|
277
|
+
ctx.beginPath();
|
|
278
|
+
ctx.ellipse(
|
|
279
|
+
this.facingLeft ? this.x + drawW * 0.35 : this.x + drawW * 0.35,
|
|
280
|
+
shadowY,
|
|
281
|
+
shadowW / 2, 3, 0, 0, Math.PI * 2
|
|
282
|
+
);
|
|
283
|
+
ctx.fill();
|
|
284
|
+
ctx.globalAlpha = 1;
|
|
285
|
+
|
|
286
|
+
// Sleeping ZZZ
|
|
287
|
+
if (this.state === 'sleeping') {
|
|
288
|
+
ctx.font = '6px monospace';
|
|
289
|
+
ctx.fillStyle = '#87CEEB';
|
|
290
|
+
ctx.fillText('z', this.x + drawW, drawY - 2);
|
|
291
|
+
ctx.fillText('Z', this.x + drawW + 4, drawY - 8);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
ctx.restore();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Y value for depth sorting */
|
|
298
|
+
get sortY() {
|
|
299
|
+
return this.y;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (typeof window !== 'undefined') window.Pet = Pet;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PetManager.js — Manages all independent pets in the scene
|
|
3
|
+
* Handles creation, update loop, rendering, and configuration persistence
|
|
4
|
+
*/
|
|
5
|
+
class PetManager {
|
|
6
|
+
constructor(generator) {
|
|
7
|
+
this.pets = [];
|
|
8
|
+
this.generator = generator;
|
|
9
|
+
this._sceneBounds = { w: 640, h: 400, floorY: 160 };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Set scene dimensions for pet movement boundaries */
|
|
13
|
+
setSceneBounds(w, h) {
|
|
14
|
+
this._sceneBounds = { w, h, floorY: Math.round(h * 0.40) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Add a pet by type, optionally with a specific color */
|
|
18
|
+
addPet(type, color) {
|
|
19
|
+
const pet = Pet.create(type, this.generator, this._sceneBounds, color || null);
|
|
20
|
+
this.pets.push(pet);
|
|
21
|
+
this._saveConfig();
|
|
22
|
+
return pet;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Change a pet's color */
|
|
26
|
+
setPetColor(id, color) {
|
|
27
|
+
const pet = this.pets.find(p => p.id === id);
|
|
28
|
+
if (pet) {
|
|
29
|
+
pet.color = color;
|
|
30
|
+
this._saveConfig();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Remove a pet by id */
|
|
35
|
+
removePet(id) {
|
|
36
|
+
this.pets = this.pets.filter(p => p.id !== id);
|
|
37
|
+
this._saveConfig();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Remove all pets */
|
|
41
|
+
clearPets() {
|
|
42
|
+
this.pets = [];
|
|
43
|
+
this._saveConfig();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Update all pets */
|
|
47
|
+
update(dt, agents, obstacles) {
|
|
48
|
+
for (const pet of this.pets) {
|
|
49
|
+
const otherPets = this.pets.filter(p => p !== pet);
|
|
50
|
+
pet.update(dt, agents, otherPets, this._sceneBounds, obstacles);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Render all pets (caller should handle depth sorting if needed) */
|
|
55
|
+
render(ctx) {
|
|
56
|
+
// Sort pets by Y for back-to-front rendering
|
|
57
|
+
const sorted = [...this.pets].sort((a, b) => a.sortY - b.sortY);
|
|
58
|
+
for (const pet of sorted) {
|
|
59
|
+
pet.render(ctx);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Load pet configuration from localStorage */
|
|
64
|
+
loadConfig() {
|
|
65
|
+
try {
|
|
66
|
+
const data = JSON.parse(localStorage.getItem('clawskin_pets') || '[]');
|
|
67
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
68
|
+
this.pets = [];
|
|
69
|
+
for (const petData of data) {
|
|
70
|
+
if (petData.type && Pet.TYPES[petData.type]) {
|
|
71
|
+
this.addPet(petData.type, petData.color || null);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Save pet configuration to localStorage */
|
|
79
|
+
_saveConfig() {
|
|
80
|
+
try {
|
|
81
|
+
const data = this.pets.map(p => ({ type: p.type, color: p.color }));
|
|
82
|
+
localStorage.setItem('clawskin_pets', JSON.stringify(data));
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Initialize with some default pets if no saved config */
|
|
87
|
+
initDefaults() {
|
|
88
|
+
this.loadConfig();
|
|
89
|
+
if (this.pets.length === 0) {
|
|
90
|
+
// Start with a cat and a dog by default
|
|
91
|
+
this.addPet('cat');
|
|
92
|
+
this.addPet('dog');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Get all available pet types */
|
|
97
|
+
static getAvailableTypes() {
|
|
98
|
+
return Object.keys(Pet.TYPES);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (typeof window !== 'undefined') window.PetManager = PetManager;
|