终于,我去,这个小桌宠被做出来了,真的废了我半条命

首先看看我给AI的提示词吧,这次我写的非常非常多,为什么呢? 因为我发现无论给AI提醒多少遍,如果代码只是全部浓缩到单个pug文件的话就会导致一个问题——低效性和不可控性,如果所有功能全部集中到一个脚本,我想让它改什么功能非常非常麻烦,所以我放弃了单个脚本实现所有功能这种方式,让AI给我模块化处理单个功能,这样更有可控性,可以第一时间发现问题出现在哪个脚本,所以这次我花了一下午时间写提示词和反复调试

相关文件的目录树如下:

themes/magzine/
├─ layout/
│ └─ _partial/
│ └─ pet/
│ └─ pet.pug
└─ source/
└─ images/
└─ pet/
└─ js/
├─ pet-sprite.js
├─ pet-state.js
├─ pet-hair.js
├─ pet-layers.js
├─ pet-outline.js
├─ pet-sound.js
├─ pet-chat.js
└─ pet-music.js

提示词如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
我希望将这个桌宠转换为Hexo的内置的插件,我希望将桌宠的每一个功能解耦分成模块化的多个代码文件,并且将桌宠相关的所有文件都放到一个文件夹,比如在hexo的themes文件夹里的layout文件夹里的_partial文件夹里新建一个pet文件夹,这里面就是代码文件,然后桌宠相关的所有图片文件都放到themes文件夹里的source文件夹里的images文件夹里的pet文件夹里,代码最好加上中文注释方便我看懂

首先第一个文件模块就是负责角色序列帧的播放,角色拥有四个状态分别是

IDLE(静止状态),对应images文件夹里的pet文件夹里的命名为01-04的4张png图片

RUNNING(奔跑状态),对应images文件夹里的pet文件夹里的命名为05-08的4张png图片

DRAGGING(拖动角色状态),对应images文件夹里的pet文件夹里的命名为09-12的四张png图片

FALL(拖动角色后放下角色的状态),对应images文件夹里的pet文件夹里的命名为05-08的四张png图片

注意:我提供的所有角色序列帧角色的站姿都是竖直的,除了DRAGGING,其他状态角色必须始终是竖直的,不要发生方向偏移,就像2D平台跳跃游戏里的角色那样

---

第二个文件模块负责角色动画逻辑和鼠标的交互逻辑,每个状态都放到一个独立的函数里

①角色的初始状态是IDLE,也就是速度为0的状态,首先需要注意就是明确角色的正反面,我提供的角色所有状态的序列帧的图片都是朝向左侧的,也就是说,角色初始状态就是朝左侧的,也就是说,当我的鼠标在屏幕中角色的坐标X轴左侧时,角色播放朝向为左的状态也就是默认的状态,但是当我的鼠标在屏幕中角色的坐标X轴右侧时,将角色相关的序列帧图片全部翻转(类似游戏引擎中将角色对象镜像翻转),注意,当鼠标距离角色一定距离时(即以角色为中心往外延伸的长方形区域),为了方便调试,我需要给这个区域上颜色,实验完成还能注释掉,当鼠标进入这个区域后角色会处于IDLE状态,但是角色还能检测到鼠标是在角色左边还是右边



②角色的奔跑状态就是RUNNING,注意,角色需要有加速度,当鼠标离开长方形检测区域后,角色开始朝鼠标的位置移动(方向就是计算角色中心点和鼠标坐标点算出要移动的方向),角色方向保持竖直,角色由静止状态转换为奔跑状态时会先加速,再匀速,在鼠标进入长方形检测区域后减速至停止,对了,有一个小细节就是比如角色此时正在朝左奔跑,我的鼠标在左侧,但是如果我立刻将鼠标移动到右侧,角色的朝向不会立刻改变,而是先保持原来的朝向减速至停止状态时再改变朝向奔跑



③角色的拖拽状态就是DRAGGING,这个就是当我把鼠标指针放到角色上并点击鼠标左键就可以拖动角色,这里是一个巧妙设计,逻辑比较复杂:

(1)以角色的中心点的y轴上方,也就是序列帧图片角色对象的正中心的正上方的边界位置作为挂载点B,角色对象整体的位置此时由挂载点B决定,当我点击角色并长按鼠标左键时,此时鼠标指针变成了牵引点A,在点击角色时角色会往上跳一下,然后角色快速下降,B点和鼠标的A点重合停止,然后A与B之间就像有一个短短的无形的弹力绳子,绳子具有弹性系数,当拖拽角色时,挂载点B始终想和牵引点A合并,会主动向A靠近,

(2)此时需要记录鼠标当前的坐标和鼠标的移动速度,当鼠标移动速度逐渐加快时,绳子的长度遵循弹力变长,同理鼠标开始减速时绳子逐渐变短,当鼠标匀速时,需要计算根据当前鼠标的速度计算来确定绳子的长短,此时绳子的长度不变,然后此时我不要求角色必须保持竖直了,不过分为两种情况:

第一, 角色整体以B点为头部牵引点,我就这么比喻吧,如果角色本体是一个秤砣,B点就是秤砣顶部算绳子的部分,当我拉动绳子的另一端(也就是鼠标的A点)时,秤砣会改变自身方向(对了,序列帧图片的方向也要改变,不仅仅是移动的方向)跟着B点一起运动就像贪吃蛇一样,

第二, 但是假如我只是在角色上面长按鼠标左键而不拖动角色,此时需要角色以B点为轴,以当前的角色的朝向为准,如果鼠标拽着角色向左移动,角色朝向是左,向右拖动角色朝向右,如果此时角色朝向为左,那么角色向左30度轻轻来回晃动,如果此时角色朝向为右,那么角色向右30度轻轻来回晃动(代表挣扎的动作)

(3)这里增加一个比较好玩的效果,当我点击一下角色而不捉住它,此时角色会向旁边“逃跑”,逃跑的逻辑和跑步一样,但是马上还会回来追鼠标

④ 角色的降落状态就是FALL,当鼠标拖拽角色松手后(也就是松开鼠标左键),角色的整体会掉落到角色的中心y轴的正下方的离中心不远处的一段距离的位置,就好像角色获得了重力,掉落一小段距离,掉落的高度约等于精灵图序列帧的高度

---



第三个文件模块就是角色的头发,这个你参考我给你的代码抄下来,这个我无法描述,总之你可以把头发想像成一个软软的橡皮泥,角色静止状态时头发从上到下还会蠕动,角色移动和被鼠标拖拽时头发还有物理逻辑,头发的颜色是#565a73



---

第四个文件模块就是角色的图层管理,这里不仅管理图层,还管理角色头发位置还有头上的光环和脚下的影子以及角色的汗珠逻辑 ,这里角色精灵图序列帧和汗水图层最高,然后就是头发在第二级图层,光环和影子在第三图层,角色跑动出现的粒子效果位于最底层

光环的是themes文件夹里的source文件夹里的images文件夹里的pet文件夹里的halo.png文件,以角色中心y轴为对称轴,角色中心为原点,当角色朝向为左边时,此时光环halo的位置即角色的右上方也就是第一象限的位置大概(12,7),当角色朝向右边时,环halo的位置即角色的左上方也就是第四象限的位置(大概(-12,-7)),光环以自己的图片的中心旋转27.4度,光环图片缩放0.25倍

汗水的图片分为两个,分别是themes文件夹里的source文件夹里的images文件夹里的pet文件夹里的sweat.png和sweat_L.png,

汗水的位置分为两部分,分别是当角色朝向为左时,汗水在以角色中心点建坐标的第四象限(-12,-26)(-14,-21)(-12,-17)三个位置是sweat_L.png汗水图片,当角色朝向为右时,汗水在以角色中心点建坐标的第一象限(12,26)(14,21)(12,17)三个位置是sweat.png汗水图片,默认是不会显示汗水的,只有角色处于DRAGGING状态,才会出现而且是三个同时出现,同时消失,向外呈放射状移动,生命周期是0.2秒,重复出现,当角色解除DRAGGING状态时才不会出现

影子的的图片为themes文件夹里的source文件夹里的images文件夹里的pet文件夹里的shadow.png文件,图片阿尔法值193,缩放0.5倍,影子的图片中心位于角色的中心点y轴的底部位置,当角色处于DRAGGING状态时,预测角色处于FALL状态的掉落的位置,影子在角色处于DRAGGING状态时提前移动到这个位置

角色跑动的粒子效果的单个粒子图片的位置是themes文件夹里的source文件夹里的images文件夹里的pet文件夹里的smoke.png文件,

粒子的生命周期0.8秒,每次出现6个粒子,粒子每次都出现在角色的中心点y轴的底部位置,而且粒子的大小和阿尔法值都不同(而且越靠近角色的粒子透明度越高),类似于我的世界烟花的尾炎逻辑

---

第五个文件的逻辑是掌管角色跑步音效的,当角色处于RUNNING状态时播放themes文件夹里的source文件夹里的images文件夹里的pet文件夹里的RunSounds.wav音效,重复播放

---

第六个文件掌管角色白色描边逻辑,这里你可以参考pet.pug文件里的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
非常好,接下来你只需要改这几个地方,第一个就是将角色和光环的比例缩放到0.3倍,角色的移动速度也是降低到原来的0.3倍,头发相关的代码不要动,将sweat.png和sweat_L.png的透明度改为原来的40%,将影子的图层改为最底层,粒子的大小控制在16*16大小左右,最大不超过16×16,最小可以是2×2,然后粒子之间的间隔拉大,粒子随着移动过的轨迹排列,对于声音的话,鼠标右键点击桌宠会弹出两个小按钮“静音”和音量控制

---

非常好,现在将光环的大小缩放8倍,并把光环放到当前光环位置的以角色中心为竖直对称轴的镜像位置,影子的大小等比例放大,让影子的宽度等于角色的宽度,还有就是我发现点击阅读文章时界面上回出现两个桌宠,我希望界面上有且只有一个桌宠,同时增加两个模块,

第一个模块就是聊天模块,角色会弹出一个聊天气泡框,里面角色说的话可以自定义,不过有状态检测,当角色处于DRAGGING状态时,角色会说“放开我!!!”“救命啊!!!”“我讨厌你~呜呜”,当只左键点击角色一次时角色会说“不要老是戳我呀”,当右键点击角色时角色会说“不要右键点我听到没有”,当角色处于FALL状态时会说“哼”“哎呀”,当玩家超过一分钟没有点击角色时,角色会说“你怎么不理我了”“快点和我说话”“我好无聊啊”“老师我想听歌”“我好困啊”“可以放首歌吗我想听歌!!!”,说话每次一句,说完气泡消失,再次说的时候又会出现气泡框

第二个模块就是音乐播放器,这个是需要外置的音乐源来播放歌曲,你看看有没有日语二次元歌曲的音乐源,右键角色的窗口里,在“取消静音”按钮上增加一个圆形按钮,这个圆形按钮上的图像就是音乐源自动获取的音乐的封面,可以切换音乐,点击上一首,下一首,暂停,循环播放,还可以控制音量,这里的音量名字就叫“音乐音量”,而之前的脚本里控制角色跑步音效的叫“角色音效”

---

不行,角色翻转方向时还是会出现气泡框翻转,这里气泡框不要翻转

---

非常好,你可以看到,我的文章点开时是出现在一个独立的窗口的,但是每当我点开一个新文章时,桌面上就会新出现一只桌宠,这就导致了界面有两个桌宠,但是我只要一只就够了,我不知道是哪里的问题,你看看

---

很好,但是我发现在文章界面的桌宠无法像主界面出现的桌宠一样,状态和行为存在一点问题不知道为什么

当然肯定不是纯文字的描述,我反编译了桌宠原本的代码,最开始的时候我是直接把代码扔给AI,然后就说了句“把这个转换为Hexo主题的桌宠插件,放到pet.pug文件里”,结果效果非常差劲,于是后来又陆陆续续给了几百行提示词给了AI,结果还是不尽如人意,还浪费了我70%的额度,于是我觉得不能就这样把AI高级请求额度霍霍完了,必须认真对待了,于是今天上午我在Godot游戏引擎中仔细研究了编辑器中设置的参数,还有作者写的代码,这个是AI绝对不可能做到的,脚本里写的很多参数都是在编辑器里设置的,于是我站在制作游戏的角度认真分析了每一个模块的作用,对于一些角色的行为用自己的想法描述,总之,说多了都是泪啊~~

最终还是做出来了,将一个游戏角色硬生生变成了hexo插件

未来可能会把这个PR给Magzine主题,现阶段我先用用看看有没有问题

代码如下:

首先就是在layout.pug里启用桌宠模块

1
!= partial('_partial/pet/pet')

pet.pug

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
//- ========================================
//- 桌面宠物 - Hexo 主题内置插件 (Pug 模板)
//- ========================================

//- ========== CSS 样式 ==========
style.
/* ===== 根容器 ===== */
#pet-root {
position: fixed;
z-index: 99999;
top: 0; left: 0;
width: 0; height: 0;
pointer-events: none;
will-change: transform;
}

/* ===== 调试检测区域 ===== */
#pet-debug-zone {
position: absolute;
display: none;
pointer-events: none;
border-radius: 4px;
box-sizing: border-box;
}

/* ===== 内部容器 ===== */
.pet-inner {
position: absolute;
top: 0; left: 0;
transform-origin: top center;
pointer-events: auto;
cursor: grab;
touch-action: none;
}
.pet-inner:active { cursor: grabbing; }

/* ===== 精灵图 ===== */
.pet-sprite {
position: absolute;
z-index: 40;
top: 0; left: 0;
width: 100%; height: 100%;
background-repeat: no-repeat;
background-size: contain;
background-position: bottom center;
image-rendering: pixelated;
pointer-events: none;
}

/* ===== 头发画布 ===== */
#pet-hair-canvas {
position: absolute;
z-index: 30;
pointer-events: none;
image-rendering: pixelated;
}

/* ===== 光环 ===== */
.pet-halo {
position: absolute;
z-index: 20;
pointer-events: none;
image-rendering: pixelated;
}

/* ===== 影子 ===== */
.pet-shadow {
position: absolute;
z-index: 1;
pointer-events: none;
image-rendering: pixelated;
}

/* ===== 汗水容器 ===== */
#pet-sweat-container {
position: absolute;
z-index: 40;
pointer-events: none;
width: 0; height: 0;
}

/* ===== 烟雾容器 ===== */
#pet-smoke-container {
position: absolute;
z-index: 5;
pointer-events: none;
width: 0; height: 0;
}

/* ===== 动画 ===== */
@keyframes pet-sweat-anim {
0% { opacity: 0.4; transform: translate(0, 0) scale(0.5); }
50% { opacity: 0.4; transform: translate(var(--spread-x), var(--spread-y)) scale(1.2); }
100% { opacity: 0; transform: translate(calc(var(--spread-x) * 1.5), calc(var(--spread-y) * 0.5)) scale(0.8); }
}

@keyframes pet-smoke-anim {
0% { opacity: var(--smoke-alpha, 0.5); transform: translate(0,0) scale(1); }
100% { opacity: 0; transform: translate(var(--smoke-dx), var(--smoke-dy)) scale(0.3); }
}

//- ========== HTML 结构 ==========
div#pet-root
div#pet-debug-zone
div.pet-shadow#pet-shadow
div#pet-smoke-container

//- 聊天气泡
div#pet-chat-bubble(style="display:none; position:absolute; left:0; transform:translate(-50%, -100%); background:rgba(255,255,255,0.9); color:#333; padding:6px 10px; border-radius:8px; font-size:12px; white-space:nowrap; pointer-events:none; z-index:50; box-shadow:0 2px 5px rgba(0,0,0,0.2); transition: opacity 0.3s;")
span#pet-chat-text
div(style="position:absolute; bottom:-5px; left:50%; transform:translateX(-50%); border-left:6px solid transparent; border-right:6px solid transparent; border-top:6px solid rgba(255,255,255,0.9);")

div.pet-inner#pet-inner
div.pet-halo#pet-halo
canvas#pet-hair-canvas
div.pet-sprite#pet-sprite

div#pet-sweat-container


//- ========== 初始化脚本 ==========
script(type="module").
import { PetSprite, State } from '#{url_for("/images/pet/js/pet-sprite.js")}';
import { PetStateMachine } from '#{url_for("/images/pet/js/pet-state.js")}';
import { PetHair } from '#{url_for("/images/pet/js/pet-hair.js")}';
import { PetLayers } from '#{url_for("/images/pet/js/pet-layers.js")}';
import { PetSound } from '#{url_for("/images/pet/js/pet-sound.js")}';
import { PetOutline } from '#{url_for("/images/pet/js/pet-outline.js")}';
import { PetChat } from '#{url_for("/images/pet/js/pet-chat.js")}';
import { PetMusicPlayer } from '#{url_for("/images/pet/js/pet-music.js")}';

function initDesktopPet() {
// 1. Iframe 拦截:如果是文章模态窗口,自杀并退出
if (window.self !== window.top) {
const root = document.getElementById('pet-root');
if (root) root.remove();
return;
}

// 2. Pjax 拦截:桌宠只初始化一次,后续页面跳转保留原实例!
if (window.__PET_IS_RUNNING__) {
const roots = document.querySelectorAll('#pet-root');
// 删掉由 Pjax 新加载的第二个骨架,保留正在运行的第一个
if (roots.length > 1) {
roots[roots.length - 1].remove();
}
return;
}
window.__PET_IS_RUNNING__ = true;

// ===== 常量与设置 =====
const IMG_PATH = '#{url_for("/images/pet/")}';
const VISUAL_SCALE = 0.3;
const BASE_SCALE = 4;
const SCALE = BASE_SCALE * VISUAL_SCALE;
const RAW_W = 32;
const RAW_H = 32;
const W = RAW_W * SCALE;
const H = RAW_H * SCALE;
const MAX_SPEED = 60;

// ===== DOM 获取与防刷保护 =====
const elRoot = document.getElementById('pet-root');
// 【重要】将桌宠根节点挪到 body 最外层,防止它被 Pjax 切页时当成旧内容删掉!
if (elRoot && elRoot.parentNode !== document.body) {
document.body.appendChild(elRoot);
}

const elInner = document.getElementById('pet-inner');
const elSprite = document.getElementById('pet-sprite');
const elDebugZone = document.getElementById('pet-debug-zone');
const elHalo = document.getElementById('pet-halo');
const elShadow = document.getElementById('pet-shadow');
const elSweat = document.getElementById('pet-sweat-container');
const elSmoke = document.getElementById('pet-smoke-container');
const elHairCanvas = document.getElementById('pet-hair-canvas');
const elChatBubble = document.getElementById('pet-chat-bubble');

// ===== 隐形护盾:防止 Iframe 吞噬拖拽事件 =====
const dragOverlay = document.createElement('div');
dragOverlay.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:99998;display:none;';
document.body.appendChild(dragOverlay);

// ===== 布局 =====
elInner.style.width = W + 'px';
elInner.style.height = H + 'px';
elInner.style.left = (-W / 2) + 'px';
elInner.style.top = (-H) + 'px';

const HAIR_CANVAS_SIZE = 384;
elHairCanvas.style.width = HAIR_CANVAS_SIZE + 'px';
elHairCanvas.style.height = HAIR_CANVAS_SIZE + 'px';
elHairCanvas.style.left = (-(HAIR_CANVAS_SIZE - W) / 2) + 'px';
elHairCanvas.style.top = (-(HAIR_CANVAS_SIZE - H) / 2) + 'px';
elChatBubble.style.top = (-H - 10) + 'px';

// ===== 模块实例化 =====
const sprite = new PetSprite(elSprite, IMG_PATH, 12);

let isSMReady = false;
const stateMachine = new PetStateMachine({
rootEl: elRoot,
innerEl: elInner,
spriteEl: elSprite,
debugZoneEl: elDebugZone,
sprite: sprite,
spriteW: W,
spriteH: H,
onStateChange: () => {
if (!isSMReady) return;
// 【核心修复】如果桌宠被拎起来,开启全屏透明护盾,防止 iframe 吃掉鼠标事件
if (stateMachine.getState() === State.DRAGGING) {
dragOverlay.style.display = 'block';
} else {
dragOverlay.style.display = 'none';
}
},
});
isSMReady = true;

const hair = new PetHair(elHairCanvas, BASE_SCALE);
const layers = new PetLayers({ haloEl: elHalo, shadowEl: elShadow, sweatContainer: elSweat, smokeContainer: elSmoke }, IMG_PATH);
const sound = new PetSound(IMG_PATH + 'RunSounds.wav', elInner);
const outline = new PetOutline(elInner);
const chat = new PetChat(elChatBubble, document.getElementById('pet-chat-text'), elInner);

const musicPlaylist = [
{ title: "恋爱循环 - 花泽香菜", url: "https://files.freemusicarchive.org/storage-freemusicarchive-org/music/no_curator/Tours/Enthusiast/Tours_-_01_-_Enthusiast.mp3", cover: "https://picsum.photos/100/100?random=1" },
{ title: "Only My Railgun - fripSide", url: "https://files.freemusicarchive.org/storage-freemusicarchive-org/music/ccCommunity/Chad_Crouch/Arps/Chad_Crouch_-_Algorithms.mp3", cover: "https://picsum.photos/100/100?random=2" },
{ title: "残酷天使的行动纲领 - 高桥洋子", url: "https://files.freemusicarchive.org/storage-freemusicarchive-org/music/no_curator/Tours/Enthusiast/Tours_-_01_-_Enthusiast.mp3", cover: "https://picsum.photos/100/100?random=3" },
{ title: "红莲华 (Gurenge) - LiSA", url: "https://files.freemusicarchive.org/storage-freemusicarchive-org/music/ccCommunity/Chad_Crouch/Arps/Chad_Crouch_-_Algorithms.mp3", cover: "https://picsum.photos/100/100?random=4" },
{ title: "千本樱 - 初音未来", url: "https://files.freemusicarchive.org/storage-freemusicarchive-org/music/no_curator/Tours/Enthusiast/Tours_-_01_-_Enthusiast.mp3", cover: "https://picsum.photos/100/100?random=5" },
{ title: "secret base ~你给我的所有~", url: "https://files.freemusicarchive.org/storage-freemusicarchive-org/music/ccCommunity/Chad_Crouch/Arps/Chad_Crouch_-_Algorithms.mp3", cover: "https://picsum.photos/100/100?random=6" },
{ title: "打上花火 - DAOKO/米津玄师", url: "https://files.freemusicarchive.org/storage-freemusicarchive-org/music/no_curator/Tours/Enthusiast/Tours_-_01_-_Enthusiast.mp3", cover: "https://picsum.photos/100/100?random=7" },
{ title: "极乐净土 - GARNiDELiA", url: "https://files.freemusicarchive.org/storage-freemusicarchive-org/music/ccCommunity/Chad_Crouch/Arps/Chad_Crouch_-_Algorithms.mp3", cover: "https://picsum.photos/100/100?random=8" },
{ title: "unravel - TK from 凛冽时雨", url: "https://files.freemusicarchive.org/storage-freemusicarchive-org/music/no_curator/Tours/Enthusiast/Tours_-_01_-_Enthusiast.mp3", cover: "https://picsum.photos/100/100?random=9" },
{ title: "鸟之诗 - Lia", url: "https://files.freemusicarchive.org/storage-freemusicarchive-org/music/ccCommunity/Chad_Crouch/Arps/Chad_Crouch_-_Algorithms.mp3", cover: "https://picsum.photos/100/100?random=10" }
];

const musicPlayer = new PetMusicPlayer({ playlist: musicPlaylist });
if (sound.menuEl) { musicPlayer.mountToMenu(sound.menuEl); }

// ===== 主循环 =====
let lastTime = 0;
function mainLoop(timestamp) {
if (!lastTime) lastTime = timestamp;
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;

stateMachine.update(dt);
const state = stateMachine.getState();
const pos = stateMachine.getPos();
const velocity = stateMachine.getVelocity();
const facingRight = stateMachine.isFacingRight();
const rotation = stateMachine.getRotation();
const spriteOfsY = stateMachine.getSpriteOffsetY();
const fallTargetY = stateMachine.getFallTargetY();

hair.update(dt, 0, -14 + spriteOfsY, velocity, MAX_SPEED, facingRight, rotation, timestamp);
layers.update({ dt, state, facingRight, posX: pos.x, posY: pos.y, spriteOffsetY: spriteOfsY, fallTargetY: fallTargetY, scale: SCALE });
sound.update(state);
chat.update(dt, state);

requestAnimationFrame(mainLoop);
}

requestAnimationFrame(mainLoop);
}

// 启动桌宠
initDesktopPet();

pet-chat.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
/**
* ============================================
* 模块7: 角色聊天气泡 (pet-chat.js)
* ============================================
*/
import { State } from "./pet-sprite.js";

export class PetChat {
constructor(bubbleEl, textEl, innerEl) {
this.bubbleEl = bubbleEl;
this.textEl = textEl;
this.innerEl = innerEl;

this.idleTime = 0;
this.bubbleTimer = null;
this.isSpeaking = false;
this.lastState = State.IDLE;

this.dialogues = {
drag: ["放开我!!!", "救命啊!!!", "我讨厌你~呜呜"],
click: ["不要老是戳我呀"],
rightClick: ["不要右键点我听到没有"],
fall: ["哼", "哎呀"],
idle: [
"你怎么不理我了",
"快点和我说话",
"我好无聊啊",
"老师我想听歌",
"我好困啊",
"可以放首歌吗我想听歌!!!",
],
};

this._bindEvents();
}

_bindEvents() {
this.innerEl.addEventListener("click", () => {
this.idleTime = 0;
this.say(this._random(this.dialogues.click));
});

this.innerEl.addEventListener("contextmenu", () => {
this.idleTime = 0;
this.say(this._random(this.dialogues.rightClick));
});
}

// 【去掉翻转逻辑】直接检测状态即可
update(dt, currentState) {
if (this.lastState !== currentState) {
if (currentState === State.DRAGGING) {
this.say(this._random(this.dialogues.drag));
} else if (currentState === State.FALL) {
this.say(this._random(this.dialogues.fall));
}
this.lastState = currentState;
}

this.idleTime += dt;
if (this.idleTime >= 60) {
this.idleTime = 0;
this.say(this._random(this.dialogues.idle));
}
}

say(text, duration = 3000) {
if (!this.bubbleEl || !this.textEl) return;

this.textEl.textContent = text;
this.bubbleEl.style.display = "block";
this.bubbleEl.style.opacity = "1";
this.isSpeaking = true;

clearTimeout(this.bubbleTimer);
this.bubbleTimer = setTimeout(() => {
this.bubbleEl.style.opacity = "0";
setTimeout(() => {
this.bubbleEl.style.display = "none";
this.isSpeaking = false;
}, 300);
}, duration);
}

_random(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
}

pet-hair.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
/**
* ============================================
* 模块3: 角色头发物理模拟 (pet-hair.js)
* ============================================
*
* 头发 = 三根"发丝",每根由多个节点串联。
* 靠近头部的节点是刚性的(紧跟头部移动),
* 远端节点拥有弹性和惯性——
* · 角色静止时:头发像软橡皮泥一样轻微蠕动
* · 角色移动时:头发向反方向飘动(惯性甩尾)
* · 角色被拖拽时:头发跟随物理力甩动
*
* 颜色: #565a73(填充)+ #000000(描边)
*/

// ===== 配置 =====
const HC = {
AMOUNT: 6, // 每根发丝节点数
RIGID: 2, // 前N个节点为刚性
K: 0.15, // 弹性系数
INERTIA: 40, // 惯性放大
COLOR: "#565a73",
OUTLINE: "#000000",
START_R: 9, // 根部圆半径
END_R: 4, // 末端圆半径
OUTLINE_PAD: 1.5, // 描边额外半径

// 三根发丝偏移配置
STRANDS: [
{ x0: -4, y0: 0, dx: -1, dy: 3 }, // 左
{ x0: 0, y0: 0, dx: 0, dy: 3 }, // 中
{ x0: 4, y0: 0, dx: 1, dy: 3 }, // 右
],
};

const lerp = (a, b, t) => a + (b - a) * t;

/** 单根发丝 */
class Strand {
constructor(cfg) {
this.cfg = cfg;
this.pts = Array.from({ length: HC.AMOUNT }, () => ({ x: 0, y: 0 }));
}

/**
* @param {number} rootX,rootY - 根部坐标(画布坐标系)
* @param {object} vn - 归一化速度 {x,y}
* @param {boolean} flip - 朝右
* @param {number} rot - 旋转角
* @param {number} t - 时间戳ms
*/
update(rootX, rootY, vn, flip, rot, t) {
this.pts[0].x = rootX;
this.pts[0].y = rootY;

for (let i = 1; i < HC.AMOUNT; i++) {
const prev = this.pts[i - 1];
const curr = this.pts[i];

if (i <= HC.RIGID) {
// 刚性跟随
curr.x = prev.x + this.cfg.dx;
curr.y = prev.y + this.cfg.dy;
} else {
// 弹性 + 惯性
let tx = prev.x + this.cfg.dx;
let ty = prev.y + this.cfg.dy;

// 蠕动
tx += 0.5 * Math.sin(t * 0.003 + i * 1.2);

const k = HC.K + 0.5 / (i * i);

// 抵消旋转
const c = Math.cos(-rot),
s = Math.sin(-rot);
let uvx = vn.x * c - vn.y * s;
let uvy = vn.x * s + vn.y * c;
// 抵消翻转
const lvx = uvx * (flip ? -1 : 1);
const lvy = uvy;

// 惯性(反方向)
tx += -lvx * HC.INERTIA;
ty += -lvy * HC.INERTIA;

curr.x += (tx - curr.x) * k;
curr.y += (ty - curr.y) * k;

// 地面碰撞
if (curr.y > 20) curr.y = 20;
}
}
}
}

/** 头发管理器 */
export class PetHair {
/**
* @param {HTMLCanvasElement} canvas
* @param {number} scale - 像素放大倍数
*/
constructor(canvas, scale = 4) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.scale = scale;

// 画布尺寸
this.cw = 384;
this.ch = 384;
canvas.width = this.cw;
canvas.height = this.ch;

this.strands = HC.STRANDS.map((c) => new Strand(c));
}

/**
* 每帧更新 + 绘制
*/
update(
dt,
anchorX,
anchorY,
velocity,
maxSpeed,
isFlipped,
rotation,
timestamp,
) {
const sp = Math.hypot(velocity.x, velocity.y);
const vn = { x: 0, y: 0 };
if (sp > 1) {
const r = Math.min(sp / maxSpeed, 1);
vn.x = (velocity.x / sp) * r;
vn.y = (velocity.y / sp) * r;
}

this.strands.forEach((s) => {
s.update(
anchorX + s.cfg.x0,
anchorY + s.cfg.y0,
vn,
isFlipped,
rotation,
timestamp,
);
});

this._draw();
}

/** 重绘头发 */
_draw() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.cw, this.ch);
ctx.save();
ctx.translate(this.cw / 2, this.ch / 2);

// 先描边(黑色、稍大半径)
this._batch(ctx, true);
// 再填充
this._batch(ctx, false);

ctx.restore();
}

_batch(ctx, outline) {
const col = outline ? HC.OUTLINE : HC.COLOR;
const pad = outline ? HC.OUTLINE_PAD : 0;
const sr = HC.START_R + pad;
const er = HC.END_R + pad;
const n = HC.AMOUNT;

this.strands.forEach((s) => {
// 画圆
for (let i = 0; i < n; i++) {
const r = lerp(sr, er, i / (n - 1));
ctx.beginPath();
ctx.arc(s.pts[i].x, s.pts[i].y, r, 0, Math.PI * 2);
ctx.fillStyle = col;
ctx.fill();
}
// 画连接体
for (let i = 1; i < n; i++) {
this._connector(
ctx,
s.pts[i - 1],
s.pts[i],
lerp(sr, er, (i - 1) / (n - 1)),
lerp(sr, er, i / (n - 1)),
col,
);
}
});
}

/** 两圆之间平滑连接(贝塞尔腰身) */
_connector(ctx, p1, p2, r1, r2, col) {
const dx = p2.x - p1.x,
dy = p2.y - p1.y;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < 0.01) return;

const a = Math.atan2(dy, dx);
const c = Math.cos(a + Math.PI / 2);
const s = Math.sin(a + Math.PI / 2);

const p1L = { x: p1.x + c * r1, y: p1.y + s * r1 };
const p1R = { x: p1.x - c * r1, y: p1.y - s * r1 };
const p2L = { x: p2.x + c * r2, y: p2.y + s * r2 };
const p2R = { x: p2.x - c * r2, y: p2.y - s * r2 };

const mx = (p1.x + p2.x) / 2,
my = (p1.y + p2.y) / 2;
const mr = (r1 + r2) / 2;

ctx.beginPath();
ctx.moveTo(p1L.x, p1L.y);
ctx.quadraticCurveTo(mx + c * mr * 0.9, my + s * mr * 0.9, p2L.x, p2L.y);
ctx.lineTo(p2R.x, p2R.y);
ctx.quadraticCurveTo(mx - c * mr * 0.9, my - s * mr * 0.9, p1R.x, p1R.y);
ctx.closePath();
ctx.fillStyle = col;
ctx.fill();
}
}

pet-layers.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
/**
* ============================================
* 模块4: 图层管理 (pet-layers.js)
* ============================================
*
* 图层从高到低:
* z=40 精灵图 + 汗水粒子
* z=30 头发
* z=20 光环
* z=5 烟雾粒子(沿移动轨迹排列)
* z=1 影子(最底层)
*
* 本次修改:
* · 影子 z-index 改为最底层 (z=1)
* · 汗水透明度改为 40% (opacity: 0.4)
* · 烟雾粒子最大 16×16,最小 2×2
* · 粒子沿移动轨迹排列,间隔拉大
* · 光环缩放 0.3 倍
*/

import { State } from "./pet-sprite.js";

// ===== 配置 =====
const LC = {
// —— 光环 ——
// 【修改】原左朝向时为 {x:12, y:-7},现竖直镜像对称,X坐标取反。
HALO_L: { x: -12, y: -7 },
HALO_R: { x: 12, y: -7 },
HALO_ROT: 27.4,
// 【修改】缩放放大8倍
HALO_SCALE: 0.25 * 0.3 * 8,

// —— 影子 ——
SHADOW_ALPHA: 193 / 255,
// 【修改】为了让阴影宽度(sw)等于角色宽度(W≈38.4px),计算得知 SHADOW_SCALE 需要约为 0.6
SHADOW_SCALE: 0.6,

// —— 汗水 ——
SWEAT_L: [
{ x: -12, y: -26 },
{ x: -14, y: -21 },
{ x: -12, y: -17 },
],
SWEAT_R: [
{ x: 12, y: -26 },
{ x: 14, y: -21 },
{ x: 12, y: -17 },
],
SWEAT_LIFE: 0.2,
SWEAT_SIZE: 16,
SWEAT_SPREAD: 20,
SWEAT_OPACITY: 0.4, // ★ 汗水透明度 40%

// —— 烟雾粒子 ——
SMOKE_N: 1, // ★ 每次生成 1 个粒子(沿轨迹排列)
SMOKE_LIFE: 0.8,
SMOKE_MIN_PX: 2, // ★ 最小 2×2
SMOKE_MAX_PX: 16, // ★ 最大 16×16
SMOKE_INTERVAL: 0.12, // ★ 间隔拉大(轨迹更稀疏)
};

export class PetLayers {
/**
* @param {object} els
* @param {HTMLElement} els.haloEl
* @param {HTMLElement} els.shadowEl
* @param {HTMLElement} els.sweatContainer
* @param {HTMLElement} els.smokeContainer
* @param {string} imgPath
*/
constructor(els, imgPath) {
this.haloEl = els.haloEl;
this.shadowEl = els.shadowEl;
this.sweatC = els.sweatContainer;
this.smokeC = els.smokeContainer;
this.img = imgPath;

this.sweatT = 0;
this.smokeT = 0;

// ★ 记录上一次粒子生成位置,用于沿轨迹排列
this.lastSmokePos = null;

this._initHalo();
this._initShadow();
}

// ==================== 光环 ====================
_initHalo() {
const el = this.haloEl;
if (!el) return;
el.style.backgroundImage = `url('${this.img}halo.png')`;
el.style.backgroundSize = "contain";
el.style.backgroundRepeat = "no-repeat";
el.style.backgroundPosition = "center";
el.style.imageRendering = "pixelated";
el.style.pointerEvents = "none";
}

_updateHalo(right, ofsY, sc) {
const el = this.haloEl;
if (!el) return;

const off = right ? LC.HALO_R : LC.HALO_L;
// ★ 光环位置也要乘以角色缩放比
const px = off.x * sc * 0.3;
const py = off.y * sc * 0.3 + ofsY;
const size = 64 * LC.HALO_SCALE * sc;

el.style.width = size + "px";
el.style.height = size + "px";
el.style.left = px - size / 2 + "px";
el.style.top = py - size / 2 + "px";

const flipX = right ? -1 : 1;
el.style.transform = `scaleX(${flipX}) rotate(${LC.HALO_ROT}deg)`;
}

// ==================== 影子 ====================
_initShadow() {
const el = this.shadowEl;
if (!el) return;
el.style.backgroundImage = `url('${this.img}shadow.png')`;
el.style.backgroundSize = "contain";
el.style.backgroundRepeat = "no-repeat";
el.style.backgroundPosition = "center";
el.style.imageRendering = "pixelated";
el.style.pointerEvents = "none";
el.style.opacity = LC.SHADOW_ALPHA;
}

_updateShadow(state, posX, posY, fallY, sc) {
const el = this.shadowEl;
if (!el) return;

const sw = 64 * LC.SHADOW_SCALE * sc;
const sh = sw * 0.3;
el.style.width = sw + "px";
el.style.height = sh + "px";
el.style.display = "block";

if (state === State.DRAGGING) {
const oy = fallY - posY;
el.style.left = -sw / 2 + "px";
el.style.top = oy - sh / 2 + "px";
} else {
el.style.left = -sw / 2 + "px";
el.style.top = -sh / 2 + "px";
}
}

// ==================== 汗水 ====================
_updateSweat(dt, state, right, sc) {
if (state !== State.DRAGGING) {
this.sweatT = 0;
return;
}

this.sweatT += dt;
if (this.sweatT < LC.SWEAT_LIFE) return;
this.sweatT = 0;

const positions = right ? LC.SWEAT_R : LC.SWEAT_L;
const imgFile = right ? "sweat.png" : "sweat_L.png";

positions.forEach((pos) => {
const p = document.createElement("div");
// ★ 汗水位置也乘以 0.3 匹配角色缩放
const px = pos.x * sc * 0.3;
const py = pos.y * sc * 0.3;
p.style.cssText = `
position:absolute;
width:${LC.SWEAT_SIZE * 0.3}px; height:${LC.SWEAT_SIZE * 0.3}px;
background:url('${this.img}${imgFile}') center/contain no-repeat;
image-rendering:pixelated; pointer-events:none;
left:${px}px; top:${py}px;
opacity:${LC.SWEAT_OPACITY};
`;

const sx = (pos.x > 0 ? 1 : -1) * LC.SWEAT_SPREAD * 0.3;
const sy = (pos.y > 0 ? 0.3 : -0.5) * LC.SWEAT_SPREAD * 0.3;
p.style.setProperty("--spread-x", sx + "px");
p.style.setProperty("--spread-y", sy + "px");
p.style.animation = `pet-sweat-anim ${LC.SWEAT_LIFE}s ease-out forwards`;

this.sweatC.appendChild(p);
setTimeout(() => p.remove(), LC.SWEAT_LIFE * 1000 + 50);
});
}

// ==================== 烟雾粒子(沿轨迹排列)====================
/**
* ★ 粒子不再随机散开,而是直接落在角色脚底的"过去位置"上,
* 形成沿移动轨迹排列的效果。
* 每个粒子 2~16px 随机大小,越老越透明。
*/
_updateSmoke(dt, state, sc, posX, posY) {
if (state !== State.RUNNING) {
this.smokeT = 0;
this.lastSmokePos = null;
return;
}

this.smokeT += dt;
if (this.smokeT < LC.SMOKE_INTERVAL) return;
this.smokeT = 0;

// ★ 间隔检查:与上次生成位置的距离必须足够大
if (this.lastSmokePos) {
const d = Math.hypot(
posX - this.lastSmokePos.x,
posY - this.lastSmokePos.y,
);
if (d < 8) return; // 距离太近不生成,拉大间隔
}
this.lastSmokePos = { x: posX, y: posY };

// 生成粒子
const p = document.createElement("div");
// ★ 随机大小 2~16px
const sz =
LC.SMOKE_MIN_PX + Math.random() * (LC.SMOKE_MAX_PX - LC.SMOKE_MIN_PX);
// ★ 初始透明度随机,靠近角色的更淡
const alpha = 0.2 + Math.random() * 0.5;
const life = LC.SMOKE_LIFE * (0.5 + Math.random() * 0.5);

// ★ 粒子直接放在角色当前脚底位置(屏幕绝对坐标)
// 因为 smokeContainer 跟随 pet-root 移动,所以用相对坐标 (0,0) 就是脚底
// 但粒子生成后不会再跟着角色走(它留在原地),
// 所以需要用绝对定位到屏幕上
p.style.cssText = `
position:fixed;
width:${sz}px; height:${sz}px;
background:url('${this.img}smoke.png') center/contain no-repeat;
image-rendering:pixelated; pointer-events:none;
left:${posX - sz / 2}px; top:${posY - sz / 2}px;
opacity:${alpha};
transition: opacity ${life}s ease-out;
z-index:99994;
`;

// 直接挂到 body 上,这样粒子留在原地不跟随角色移动
document.body.appendChild(p);

// 下一帧开始淡出
requestAnimationFrame(() => {
p.style.opacity = "0";
});

// 生命周期结束后移除
setTimeout(() => p.remove(), life * 1000 + 50);
}

// ==================== 综合更新 ====================
/**
* @param {object} p
* @param {number} p.dt
* @param {number} p.state
* @param {boolean} p.facingRight
* @param {number} p.posX, p.posY - 角色屏幕坐标
* @param {number} p.spriteOffsetY
* @param {number} p.fallTargetY
* @param {number} p.scale
*/
update(p) {
this._updateHalo(p.facingRight, p.spriteOffsetY, p.scale);
this._updateShadow(p.state, p.posX, p.posY, p.fallTargetY, p.scale);
this._updateSweat(p.dt, p.state, p.facingRight, p.scale);
// ★ 传入角色屏幕坐标用于轨迹定位
this._updateSmoke(p.dt, p.state, p.scale, p.posX, p.posY);
}
}

pet-music.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
/**
* =====================================================
* 模块8:音乐播放器(pet-music.js)
* =====================================================
* 作用:
* - 播放外部音乐源(mp3/ogg 等)
* - 提供基础功能:
* 上一首 / 下一首 / 播放暂停 / 循环 / 音量(名称“音乐音量”)
* - UI 会被挂载到 PetSound 的右键菜单里
*
* 说明:
* - “自动获取封面”取决于音乐源是否提供封面信息。
* 这里用 playlist 的 cover 字段作为封面 URL(最稳定)。
* - 你后续给我音乐源,我可以帮你把 playlist 填进去。
*/

const MUSIC_CFG = {
DEFAULT_VOLUME: 0.5,
};

export class PetMusicPlayer {
/**
* @param {object} opts
* @param {Array<{title:string, url:string, cover?:string}>} opts.playlist
*/
constructor(opts) {
this.playlist = Array.isArray(opts.playlist) ? opts.playlist : [];
this.index = 0;

// 是否循环播放
this.loop = true;

// 音频对象
this.audio = new Audio();
this.audio.volume = MUSIC_CFG.DEFAULT_VOLUME;
this.audio.preload = "none";

// 播放结束:若循环则自动下一首
this.audio.addEventListener("ended", () => {
if (this.loop) this.next(true);
});

// UI 元素引用(挂载后才会有)
this.coverBtn = null;
this.titleEl = null;
this.playBtn = null;
this.loopBtn = null;

// 如果有歌单,先加载第一首
if (this.playlist.length > 0) this._load(0);
}

/** 当前曲目对象 */
_current() {
return this.playlist[this.index] || null;
}

/** 加载指定索引曲目 */
_load(i) {
if (this.playlist.length === 0) return;

// 取模确保不会越界
this.index = (i + this.playlist.length) % this.playlist.length;

const t = this._current();
this.audio.src = t.url;

this._syncUI();
}

/**
* 同步 UI:
* - 封面按钮背景图
* - 标题显示
* - 播放按钮图标
* - 循环按钮图标
*/
_syncUI() {
const t = this._current();
if (!t) return;

if (this.titleEl) this.titleEl.textContent = t.title || "未命名歌曲";

if (this.coverBtn) {
const cover = t.cover || "";
this.coverBtn.style.backgroundImage = cover ? `url('${cover}')` : "none";
this.coverBtn.textContent = cover ? "" : "♪";
}

if (this.playBtn) this.playBtn.textContent = this.audio.paused ? "▶" : "⏸";
if (this.loopBtn) this.loopBtn.textContent = this.loop ? "🔁" : "➡";
}

/** 播放 / 暂停 */
playPause() {
if (this.playlist.length === 0) return;

if (this.audio.paused) {
this.audio.play().catch(() => {});
} else {
this.audio.pause();
}
this._syncUI();
}

/** 上一首 */
prev(autoPlay = false) {
if (this.playlist.length === 0) return;
this._load(this.index - 1);
if (autoPlay) this.audio.play().catch(() => {});
this._syncUI();
}

/** 下一首 */
next(autoPlay = false) {
if (this.playlist.length === 0) return;
this._load(this.index + 1);
if (autoPlay) this.audio.play().catch(() => {});
this._syncUI();
}

/** 切换循环 */
toggleLoop() {
this.loop = !this.loop;
this._syncUI();
}

/** 设置音乐音量(0~1) */
setVolume(v01) {
this.audio.volume = Math.max(0, Math.min(1, v01));
}

/**
* 把音乐播放器 UI 挂载到右键菜单里
* @param {HTMLElement} menuEl
*/
mountToMenu(menuEl) {
// 外层容器(与“角色音效”区域分隔开)
const wrap = document.createElement("div");
wrap.style.cssText = `
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid rgba(255,255,255,0.12);
`;

// 顶部:封面圆形按钮 + 歌名
const head = document.createElement("div");
head.style.cssText = `display:flex; align-items:center; gap:8px; margin-bottom:8px;`;

// 封面按钮:点击切换到下一首
const coverBtn = document.createElement("button");
coverBtn.title = "切换音乐(下一首)";
coverBtn.style.cssText = `
width: 34px; height: 34px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.08);
background-size: cover;
background-position: center;
color: #fff;
cursor: pointer;
flex: 0 0 auto;
`;
coverBtn.addEventListener("click", () => this.next(true));

// 标题
const title = document.createElement("div");
title.style.cssText = `
font-size: 11px;
color: rgba(255,255,255,0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
`;
title.textContent = "未加载音乐";

head.appendChild(coverBtn);
head.appendChild(title);

// 控制按钮行
const controls = document.createElement("div");
controls.style.cssText = `display:flex; align-items:center; gap:8px;`;

// 统一按钮样式
const btnStyle = `
width: 28px; height: 28px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.16);
background: rgba(255,255,255,0.08);
color: #eee;
cursor: pointer;
`;

const prevBtn = document.createElement("button");
prevBtn.style.cssText = btnStyle;
prevBtn.textContent = "⏮";
prevBtn.title = "上一首";
prevBtn.addEventListener("click", () => this.prev(true));

const playBtn = document.createElement("button");
playBtn.style.cssText = btnStyle;
playBtn.textContent = "▶";
playBtn.title = "播放/暂停";
playBtn.addEventListener("click", () => this.playPause());

const nextBtn = document.createElement("button");
nextBtn.style.cssText = btnStyle;
nextBtn.textContent = "⏭";
nextBtn.title = "下一首";
nextBtn.addEventListener("click", () => this.next(true));

const loopBtn = document.createElement("button");
loopBtn.style.cssText = btnStyle;
loopBtn.textContent = "🔁";
loopBtn.title = "循环播放";
loopBtn.addEventListener("click", () => this.toggleLoop());

controls.appendChild(prevBtn);
controls.appendChild(playBtn);
controls.appendChild(nextBtn);
controls.appendChild(loopBtn);

// 音量(名称明确为“音乐音量”)
const volLabel = document.createElement("div");
volLabel.style.cssText = `margin-top:8px; margin-bottom:4px; font-size: 11px; color:#aaa;`;
volLabel.textContent = "🎵 音乐音量";

const slider = document.createElement("input");
slider.type = "range";
slider.min = "0";
slider.max = "100";
slider.value = String(Math.round(this.audio.volume * 100));
slider.style.cssText = `
display:block;
width:100%;
height:4px;
cursor:pointer;
accent-color:#7aa2f7;
margin:0;
`;
slider.addEventListener("input", () =>
this.setVolume(parseInt(slider.value, 10) / 100),
);

wrap.appendChild(head);
wrap.appendChild(controls);
wrap.appendChild(volLabel);
wrap.appendChild(slider);

// 将音乐播放器挂载到右键菜单的最顶部(在静音按钮上方)
menuEl.insertBefore(wrap, menuEl.firstChild);

// 保存引用用于同步 UI
this.coverBtn = coverBtn;
this.titleEl = title;
this.playBtn = playBtn;
this.loopBtn = loopBtn;

// 初次同步 UI
this._syncUI();
}
}

pet-outline.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* ============================================
* 模块6: 角色白色描边 (pet-outline.js)
* ============================================
*
* 使用 CSS filter: drop-shadow 在四个方向(上下左右)
* 各投射一个 0 模糊、纯白色的阴影,形成均匀的像素风描边。
*
* 对应 Godot 项目中 CanvasGroup + screen_outline.gdshader 的效果。
*/

const OC = {
COLOR: "#ffffff",
WIDTH: 2, // 描边像素宽度
};

export class PetOutline {
/**
* @param {HTMLElement} el - 需要描边的容器 (#pet-inner)
*/
constructor(el) {
this.el = el;
this.width = OC.WIDTH;
this.color = OC.COLOR;
this.apply();
}

/** 应用四方向 drop-shadow 描边 */
apply() {
if (!this.el) return;
const w = this.width,
c = this.color;
this.el.style.filter = [
`drop-shadow( ${w}px 0 0 ${c})`, // 右
`drop-shadow(-${w}px 0 0 ${c})`, // 左
`drop-shadow( 0 ${w}px 0 ${c})`, // 下
`drop-shadow( 0 -${w}px 0 ${c})`, // 上
].join(" ");
}

/** 移除描边 */
remove() {
if (this.el) this.el.style.filter = "none";
}

/** 动态修改宽度 */
setWidth(w) {
this.width = w;
this.apply();
}

/** 动态修改颜色 */
setColor(c) {
this.color = c;
this.apply();
}
}

pet-sound.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
/**
* ============================================
* 模块5: 角色音效管理 (pet-sound.js)
* ============================================
*
* · RUNNING 状态循环播放跑步音效
* · 音调随机 0.75~1.0
* ★ 鼠标右键点击角色弹出浮动菜单:
* - "静音" 按钮(切换静音/取消静音)
* - 音量滑块
*/

import { State } from "./pet-sprite.js";

const SC = {
PITCH_MIN: 0.75,
PITCH_MAX: 1.0,
VOLUME: 0.3,
};

export class PetSound {
/**
* @param {string} path - 音效文件路径
* @param {HTMLElement} innerEl - 角色内部容器(绑定右键事件)
*/
constructor(path, innerEl) {
this.audio = new Audio(path);
this.audio.loop = true;
this.audio.volume = SC.VOLUME;
this.playing = false;
this.unlocked = false;
this.muted = false;
this.volume = SC.VOLUME;

// 右键菜单 DOM
this.menuEl = null;
this.innerEl = innerEl;

// 监听首次交互解锁音频
const unlock = () => {
this.unlocked = true;
document.removeEventListener("click", unlock);
document.removeEventListener("touchstart", unlock);
document.removeEventListener("mousedown", unlock);
};
document.addEventListener("click", unlock);
document.addEventListener("touchstart", unlock);
document.addEventListener("mousedown", unlock);

// 创建右键菜单
this._createMenu();
this._bindContextMenu();
}

// ==================== 右键菜单 ====================

/** 创建菜单 DOM */
_createMenu() {
const menu = document.createElement("div");
menu.id = "pet-sound-menu";
menu.style.cssText = `
position: fixed;
z-index: 999999;
display: none;
background: rgba(40, 40, 50, 0.95);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
padding: 8px 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
backdrop-filter: blur(8px);
font-family: 'Inter', sans-serif;
font-size: 12px;
color: #eee;
user-select: none;
pointer-events: auto;
min-width: 120px;
`;

// —— 静音按钮 ——
const muteBtn = document.createElement("button");
muteBtn.id = "pet-mute-btn";
muteBtn.textContent = "🔊 静音";
muteBtn.style.cssText = `
display: block;
width: 100%;
padding: 4px 8px;
margin-bottom: 6px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 4px;
color: #eee;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
`;
muteBtn.addEventListener("mouseenter", () => {
muteBtn.style.background = "rgba(255,255,255,0.2)";
});
muteBtn.addEventListener("mouseleave", () => {
muteBtn.style.background = "rgba(255,255,255,0.1)";
});
muteBtn.addEventListener("click", () => {
this.muted = !this.muted;
this.audio.muted = this.muted;
muteBtn.textContent = this.muted ? "🔇 取消静音" : "🔊 静音";
});

// —— 音量标签 ——
const volLabel = document.createElement("div");
volLabel.style.cssText = `
margin-bottom: 4px;
font-size: 11px;
color: #aaa;
`;
volLabel.textContent = "🏃 角色音效";

// —— 音量滑块 ——
const volSlider = document.createElement("input");
volSlider.type = "range";
volSlider.min = "0";
volSlider.max = "100";
volSlider.value = String(Math.round(this.volume * 100));
volSlider.style.cssText = `
display: block;
width: 100%;
height: 4px;
cursor: pointer;
accent-color: #7aa2f7;
margin: 0;
`;
volSlider.addEventListener("input", () => {
this.volume = parseInt(volSlider.value) / 100;
this.audio.volume = this.volume;
});

// 组装
menu.appendChild(muteBtn);
menu.appendChild(volLabel);
menu.appendChild(volSlider);

document.body.appendChild(menu);
this.menuEl = menu;
this.muteBtn = muteBtn;

// 点击其他区域关闭菜单
document.addEventListener("mousedown", (e) => {
if (!menu.contains(e.target)) {
menu.style.display = "none";
}
});
}

/** 绑定右键事件到角色 */
_bindContextMenu() {
if (!this.innerEl) return;

this.innerEl.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
this._showMenu(e.clientX, e.clientY);
});
}

/** 在指定位置显示菜单 */
_showMenu(x, y) {
const menu = this.menuEl;
if (!menu) return;

menu.style.display = "block";

// 确保菜单不超出屏幕
const rect = menu.getBoundingClientRect();
const mx = Math.min(x, window.innerWidth - rect.width - 10);
const my = Math.min(y, window.innerHeight - rect.height - 10);

menu.style.left = Math.max(0, mx) + "px";
menu.style.top = Math.max(0, my) + "px";

// 更新静音按钮文本
this.muteBtn.textContent = this.muted ? "🔇 取消静音" : "🔊 静音";
}

// ==================== 播放控制 ====================

/** 根据状态决定播放/停止 */
update(state) {
if (state === State.RUNNING) {
this._play();
} else {
this._stop();
}
}

_play() {
if (this.playing || !this.unlocked) return;
this.audio.playbackRate =
SC.PITCH_MIN + Math.random() * (SC.PITCH_MAX - SC.PITCH_MIN);
this.audio.currentTime = 0;
this.audio.play().catch(() => {});
this.playing = true;
}

_stop() {
if (!this.playing) return;
this.audio.pause();
this.audio.currentTime = 0;
this.playing = false;
}

/** 设置音量 0~1 */
setVolume(v) {
this.volume = Math.max(0, Math.min(1, v));
this.audio.volume = this.volume;
}

/** 销毁 */
destroy() {
this._stop();
this.audio.src = "";
if (this.menuEl && this.menuEl.parentNode) {
this.menuEl.parentNode.removeChild(this.menuEl);
}
}
}

pet-sprite.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
/**
* ============================================
* 模块1: 角色序列帧播放器 (pet-sprite.js)
* ============================================
*
* 管理角色四个状态的序列帧动画:
* IDLE (静止) → 01.png ~ 04.png
* RUNNING (奔跑) → 05.png ~ 08.png
* DRAGGING (拖拽) → 09.png ~ 12.png
* FALL (掉落) → 05.png ~ 08.png (复用奔跑帧)
*
* 注意:
* - 除 DRAGGING 外,其他状态角色始终保持竖直站姿
* - 角色原始朝向为左侧;朝右时通过 CSS scaleX(-1) 镜像翻转
*/

// ===== 状态枚举 =====
export const State = Object.freeze({
IDLE: 0, // 静止
RUNNING: 1, // 奔跑
DRAGGING: 2, // 被拖拽
FALL: 3, // 掉落
});

// ===== 每个状态对应的帧文件名 =====
const STATE_FRAMES = {
[State.IDLE]: ["01", "02", "03", "04"],
[State.RUNNING]: ["05", "06", "07", "08"],
[State.DRAGGING]: ["09", "10", "11", "12"],
[State.FALL]: ["05", "06", "07", "08"],
};

export class PetSprite {
/**
* @param {HTMLElement} spriteEl - 显示精灵图的 DOM 元素
* @param {string} imgPath - 图片路径前缀,如 '/images/pet/'
* @param {number} frameRate - 帧率(每秒帧数),默认 12
*/
constructor(spriteEl, imgPath, frameRate = 12) {
this.spriteEl = spriteEl;
this.imgPath = imgPath;
this.frameRate = frameRate;

// 当前状态 & 当前帧
this.currentState = State.IDLE;
this.currentFrame = 0;

// 累计计时器
this.timer = 0;

// 预加载图片缓存
this.imageCache = {};
this._preloadAll();
}

/**
* 预加载全部 12 张序列帧,避免运行时闪白
*/
_preloadAll() {
for (let i = 1; i <= 12; i++) {
const key = String(i).padStart(2, "0");
const img = new Image();
img.src = `${this.imgPath}${key}.png`;
this.imageCache[key] = img.src;
}
// 立即显示第一帧
this._applyFrame();
}

/**
* 切换到指定状态
* @param {number} newState - State 枚举值
*/
setState(newState) {
if (this.currentState !== newState) {
this.currentState = newState;
this.currentFrame = 0;
this.timer = 0;
this._applyFrame();
}
}

/**
* 每帧调用,驱动动画前进
* @param {number} dt - 距上一帧的秒数
*/
update(dt) {
this.timer += dt;
const interval = 1 / this.frameRate;

if (this.timer >= interval) {
this.timer -= interval;
this.currentFrame = (this.currentFrame + 1) % 4;
this._applyFrame();
}
}

/**
* 把当前帧的图片设到 DOM 元素上
*/
_applyFrame() {
const frames = STATE_FRAMES[this.currentState];
if (!frames) return;
const key = frames[this.currentFrame];
const src = this.imageCache[key];
if (src) {
this.spriteEl.style.backgroundImage = `url('${src}')`;
}
}

/** 获取当前状态 */
getState() {
return this.currentState;
}
}

pet-state.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
/**
* ============================================
* 模块2: 角色状态机 + 动画 + 鼠标交互 (pet-state.js)
* ============================================
*
* 四种状态的完整逻辑:
*
* ① IDLE(静止)
* - 速度为零时的状态
* - 检测鼠标在角色左侧/右侧 → 决定朝向
* - 以角色为中心有一个"长方形检测区域",鼠标进入则保持IDLE
* - 可通过 DEBUG_ZONE 开关显示区域颜色
*
* ② RUNNING(奔跑)
* - 鼠标离开检测区域 → 角色朝鼠标移动
* - 有加速、匀速、减速过程
* - 换向保护:角色必须先减速到停止才能翻转方向
*
* ③ DRAGGING(拖拽)
* - 鼠标点击角色并长按左键 → 拖动
* - 挂载点B在角色顶部中心,鼠标为牵引点A
* - A与B之间有弹力绳(弹性系数 + 鼠标速度影响绳长)
* - 移动时角色像秤砣跟随(贪吃蛇效果)
* - 静止时角色以B为轴轻轻晃动(挣扎)
* - 短点击不拖 → 角色"逃跑"后自动回来追鼠标
*
* ④ FALL(掉落)
* - 松开鼠标后角色获得重力,掉落一小段距离
* - 掉落高度 ≈ 精灵图高度
*/

import { State } from "./pet-sprite.js";

// ===== 工具函数 =====
const lerp = (a, b, t) => a + (b - a) * t;
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);

// ===== 可调参数 =====
// ★ 速度相关参数全部降到原来的 0.3 倍 ★
const CFG = {
// —— 检测区域(以角色中心为原点的长方形半宽/半高)——
DETECT_HALF_W: 30, // 缩小检测区域匹配缩小后的角色
DETECT_HALF_H: 25,
// ★ 设为 true 可显示绿色半透明区域,调试完成后设回 false ★
DEBUG_ZONE: false,

// —— 移动(全部 × 0.3)——
MAX_SPEED: 60, // 200 × 0.3
ACCEL: 180, // 600 × 0.3
DECEL: 240, // 800 × 0.3

// —— 拖拽绳子 ——
ROPE_BASE: 3, // 5 × 0.3 ≈ 3
ROPE_STRETCH: 0.04, // 0.12 × 0.3
ROPE_MAX: 24, // 80 × 0.3
ROPE_K: 12, // 弹簧刚度不变
ROPE_DAMP: 0.88,

// —— 拖拽跳跃动画 ——
DRAG_JUMP_V: -60, // -200 × 0.3
DRAG_GRAVITY: 180, // 600 × 0.3

// —— 挣扎晃动 ——
SWING_ANGLE: Math.PI / 6,
SWING_SPEED: 4,

// —— 点击逃跑 ——
FLEE_SPEED: 90, // 300 × 0.3
FLEE_TIME: 0.4,

// —— 掉落 ——
FALL_GRAVITY: 180, // 600 × 0.3
FALL_DIST: 12, // 40 × 0.3

// —— 换向保护 ——
DIR_CD: 0.15,
};

export class PetStateMachine {
/**
* @param {object} opts
* @param {HTMLElement} opts.rootEl
* @param {HTMLElement} opts.innerEl
* @param {HTMLElement} opts.spriteEl
* @param {HTMLElement} opts.debugZoneEl
* @param {import('./pet-sprite.js').PetSprite} opts.sprite
* @param {number} opts.spriteW - 精灵图显示宽 px
* @param {number} opts.spriteH - 精灵图显示高 px
* @param {Function} opts.onStateChange
*/
constructor(opts) {
this.rootEl = opts.rootEl;
this.innerEl = opts.innerEl;
this.spriteEl = opts.spriteEl;
this.debugZoneEl = opts.debugZoneEl;
this.sprite = opts.sprite;
this.spriteW = opts.spriteW || 128;
this.spriteH = opts.spriteH || 128;
this.onStateChange = opts.onStateChange || (() => {});

// —— 位置("脚底中心"屏幕坐标)——
this.pos = {
x: Math.max(window.innerWidth - 150, 100),
y: Math.max(window.innerHeight - 60, 100),
};

this.velocity = { x: 0, y: 0 };
this.mousePos = { x: this.pos.x, y: this.pos.y };

// —— 状态 ——
this.state = State.IDLE;

// —— 朝向:false=朝左(默认) true=朝右 ——
this.facingRight = false;
this.dirCooldown = 0;

// —— 拖拽 ——
this.isDragging = false;
this.dragAnchor = { x: 0, y: 0 };
this.dragVel = { x: 0, y: 0 };
this.ropeLen = CFG.ROPE_BASE;
this.mouseSpeed = 0;
this.prevMouse = { x: 0, y: 0 };
this.dragRot = 0;
this.dragRotVel = 0;

// 跳跃小动画
this.jumpPhase = "none";
this.jumpOffset = 0;
this.jumpVel = 0;

// 挣扎
this.swingT = 0;

// 短点击逃跑
this.fleeTimer = 0;
this.fleeDir = { x: 0, y: 0 };
this.mouseDownT = 0;
this.mouseDownPos = { x: 0, y: 0 };

// —— 掉落 ——
this.fallTargetY = 0;
this.fallVelY = 0;

// 初始化
this._initDebugZone();
this._bindEvents();
}

// ===================== 调试检测区域 =====================
_initDebugZone() {
const el = this.debugZoneEl;
if (!el) return;
if (CFG.DEBUG_ZONE) {
el.style.display = "block";
el.style.width = CFG.DETECT_HALF_W * 2 + "px";
el.style.height = CFG.DETECT_HALF_H * 2 + "px";
el.style.left = -CFG.DETECT_HALF_W + "px";
el.style.top = -CFG.DETECT_HALF_H + "px";
el.style.background = "rgba(0,255,0,0.15)";
el.style.border = "1px dashed rgba(0,255,0,0.5)";
} else {
el.style.display = "none";
}
}

// ===================== 事件绑定 =====================
_bindEvents() {
// 鼠标/触摸 移动
window.addEventListener("mousemove", (e) => {
this.mousePos.x = e.clientX;
this.mousePos.y = e.clientY;
});
window.addEventListener(
"touchmove",
(e) => {
if (e.touches.length) {
this.mousePos.x = e.touches[0].clientX;
this.mousePos.y = e.touches[0].clientY;
}
},
{ passive: false },
);

// 左键按下(在角色身上)
this.innerEl.addEventListener("mousedown", (e) => {
if (e.button === 0) {
// 仅左键
e.preventDefault();
this._onDown(e.clientX, e.clientY);
}
});
this.innerEl.addEventListener(
"touchstart",
(e) => {
e.preventDefault();
if (e.touches.length)
this._onDown(e.touches[0].clientX, e.touches[0].clientY);
},
{ passive: false },
);

// 松开
window.addEventListener("mouseup", (e) => {
if (e.button === 0) this._onUp(); // 仅左键松开
});
window.addEventListener("touchend", () => this._onUp());

// ★ 禁用角色上的右键默认菜单(由 pet-sound.js 的菜单接管)★
this.innerEl.addEventListener("contextmenu", (e) => {
e.preventDefault();
});
}

/** 指针按下 */
_onDown(px, py) {
this.isDragging = true;
this.mouseDownT = performance.now();
this.mouseDownPos = { x: px, y: py };
this.prevMouse = { x: px, y: py };
this.mouseSpeed = 0;

// 挂载点B = 角色顶部中心
this.dragAnchor.x = this.pos.x;
this.dragAnchor.y = this.pos.y - this.spriteH;
this.dragVel = { x: 0, y: 0 };
this.ropeLen = CFG.ROPE_BASE;

// 跳跃小动画
this.jumpPhase = "jumping";
this.jumpOffset = 0;
this.jumpVel = CFG.DRAG_JUMP_V;

// 重置旋转
this.dragRot = 0;
this.dragRotVel = 0;
this.swingT = 0;
}

/** 指针松开 */
_onUp() {
if (!this.isDragging) return;

const elapsed = performance.now() - this.mouseDownT;
const dist = Math.hypot(
this.mousePos.x - this.mouseDownPos.x,
this.mousePos.y - this.mouseDownPos.y,
);
this.isDragging = false;

// 短点击(<200ms 且移动<10px)→ 逃跑
if (elapsed < 200 && dist < 10) {
this._triggerFlee();
return;
}

// 否则 → 掉落
this._enterFall();
}

/** 触发逃跑 */
_triggerFlee() {
const dx = this.pos.x - this.mousePos.x;
const dirX = dx >= 0 ? 1 : -1;
this.fleeDir = { x: dirX, y: 0 };
this.fleeTimer = CFG.FLEE_TIME;
this.velocity.x = dirX * CFG.FLEE_SPEED;
this.velocity.y = 0;
}

/** 进入掉落状态 */
_enterFall() {
this.fallTargetY = this.pos.y + CFG.FALL_DIST;
this.fallVelY = 0;
this.dragRot = 0;
this.jumpPhase = "none";
this.jumpOffset = 0;
}

/** 鼠标是否在检测区域内 */
_inZone() {
return (
Math.abs(this.mousePos.x - this.pos.x) < CFG.DETECT_HALF_W &&
Math.abs(this.mousePos.y - this.pos.y) < CFG.DETECT_HALF_H
);
}

// ===================== 主 update =====================
update(dt) {
dt = Math.min(dt, 0.05);

// 鼠标速度
if (dt > 0) {
this.mouseSpeed =
Math.hypot(
this.mousePos.x - this.prevMouse.x,
this.mousePos.y - this.prevMouse.y,
) / dt;
this.prevMouse.x = this.mousePos.x;
this.prevMouse.y = this.mousePos.y;
}

// 换向冷却
if (this.dirCooldown > 0) this.dirCooldown -= dt;

// 确定状态
const prev = this.state;
this.state = this._nextState();
if (prev !== this.state) {
this.onStateChange(this.state, prev);
this.sprite.setState(this.state);
}

// 执行对应逻辑
switch (this.state) {
case State.IDLE:
this._tickIdle(dt);
break;
case State.RUNNING:
this._tickRunning(dt);
break;
case State.DRAGGING:
this._tickDragging(dt);
break;
case State.FALL:
this._tickFall(dt);
break;
}

// 更新帧动画
this.sprite.update(dt);

// 限制屏幕范围
this.pos.x = clamp(this.pos.x, 20, window.innerWidth - 20);
this.pos.y = clamp(this.pos.y, 60, window.innerHeight - 10);

// DOM 变换
this._applyDOM();
}

/** 状态转移逻辑 */
_nextState() {
if (this.isDragging) return State.DRAGGING;
if (this.fleeTimer > 0) return State.RUNNING;

// 掉落尚未完成
if (this.state === State.FALL && this.pos.y < this.fallTargetY)
return State.FALL;

const inZone = this._inZone();
const speed = Math.hypot(this.velocity.x, this.velocity.y);

// 还在减速 → 保持 RUNNING
if (this.state === State.RUNNING && speed > 5) return State.RUNNING;

return inZone ? State.IDLE : State.RUNNING;
}

// -------------------- IDLE --------------------
_tickIdle(dt) {
this.velocity.x = lerp(this.velocity.x, 0, 10 * dt);
this.velocity.y = lerp(this.velocity.y, 0, 10 * dt);
this._setFacing(this.mousePos.x - this.pos.x);
this.dragRot = 0;
this.jumpOffset = 0;
}

// -------------------- RUNNING --------------------
_tickRunning(dt) {
// —— 逃跑模式 ——
if (this.fleeTimer > 0) {
this.fleeTimer -= dt;
this.pos.x += this.velocity.x * dt;
this.pos.y += this.velocity.y * dt;
this.velocity.x = lerp(this.velocity.x, 0, 2 * dt);
this._setFacing(this.velocity.x);
return;
}

const inZone = this._inZone();

if (inZone) {
// —— 在区域内:减速到0(不改朝向)——
const sp = Math.hypot(this.velocity.x, this.velocity.y);
if (sp > 5) {
const f = Math.max(0, sp - CFG.DECEL * dt) / sp;
this.velocity.x *= f;
this.velocity.y *= f;
this.pos.x += this.velocity.x * dt;
this.pos.y += this.velocity.y * dt;
} else {
this.velocity.x = 0;
this.velocity.y = 0;
}
} else {
// —— 在区域外:朝鼠标加速 ——
const dx = this.mousePos.x - this.pos.x;
const dy = this.mousePos.y - this.pos.y;
const dist = Math.hypot(dx, dy);
if (dist > 1) {
const nx = dx / dist;
const ny = dy / dist;
const sp = Math.hypot(this.velocity.x, this.velocity.y);
const dot = this.velocity.x * nx + this.velocity.y * ny;

if (sp > 10 && dot < 0) {
// 方向相反 → 先减速
const f = Math.max(0, sp - CFG.DECEL * dt) / sp;
this.velocity.x *= f;
this.velocity.y *= f;
} else {
// 方向一致 → 加速
this.velocity.x += nx * CFG.ACCEL * dt;
this.velocity.y += ny * CFG.ACCEL * dt;
const ns = Math.hypot(this.velocity.x, this.velocity.y);
if (ns > CFG.MAX_SPEED) {
this.velocity.x = (this.velocity.x / ns) * CFG.MAX_SPEED;
this.velocity.y = (this.velocity.y / ns) * CFG.MAX_SPEED;
}
this._setFacing(this.velocity.x);
}

this.pos.x += this.velocity.x * dt;
this.pos.y += this.velocity.y * dt;
}
}

this.dragRot = 0;
this.jumpOffset = 0;
}

// -------------------- DRAGGING --------------------
_tickDragging(dt) {
// —— 跳跃小动画 ——
if (this.jumpPhase === "jumping") {
this.jumpOffset += this.jumpVel * dt;
this.jumpVel += CFG.DRAG_GRAVITY * dt;
if (this.jumpOffset >= 0) {
this.jumpOffset = 0;
this.jumpPhase = "done";
}
}

const A = this.mousePos;

// —— 绳长随鼠标速度变化 ——
const targetLen = CFG.ROPE_BASE + this.mouseSpeed * CFG.ROPE_STRETCH;
this.ropeLen = lerp(
this.ropeLen,
clamp(targetLen, CFG.ROPE_BASE, CFG.ROPE_MAX),
5 * dt,
);

// —— 弹簧物理:B 追赶 A ——
const dxAB = A.x - this.dragAnchor.x;
const dyAB = A.y - this.dragAnchor.y;
const dAB = Math.hypot(dxAB, dyAB);

let fx = 0,
fy = 0;
if (dAB > this.ropeLen && dAB > 0) {
const over = dAB - this.ropeLen;
fx = (dxAB / dAB) * over * CFG.ROPE_K;
fy = (dyAB / dAB) * over * CFG.ROPE_K;
}
fx += dxAB * 8;
fy += dyAB * 8;

this.dragVel.x += fx * dt;
this.dragVel.y += fy * dt;
this.dragVel.x *= CFG.ROPE_DAMP;
this.dragVel.y *= CFG.ROPE_DAMP;

this.dragAnchor.x += this.dragVel.x * dt;
this.dragAnchor.y += this.dragVel.y * dt;

this.pos.x = this.dragAnchor.x;
this.pos.y = this.dragAnchor.y + this.spriteH + this.jumpOffset;

this.velocity.x = this.dragVel.x;
this.velocity.y = this.dragVel.y;

// —— 旋转 ——
const moveSpd = Math.hypot(this.dragVel.x, this.dragVel.y);
if (moveSpd > 30) {
const ang = Math.atan2(
this.pos.y - this.dragAnchor.y,
this.pos.x - this.dragAnchor.x,
);
let target = ang - Math.PI / 2;
target = clamp(target, -Math.PI * 0.6, Math.PI * 0.6);
this.dragRot = lerp(this.dragRot, target, 8 * dt);
this._setFacing(this.dragVel.x);
} else {
this.swingT += dt;
const dir = this.facingRight ? 1 : -1;
const angle =
Math.sin(this.swingT * CFG.SWING_SPEED) * CFG.SWING_ANGLE * dir;
this.dragRot = lerp(this.dragRot, angle, 5 * dt);
}
}

// -------------------- FALL --------------------
_tickFall(dt) {
this.dragRot = lerp(this.dragRot, 0, 10 * dt);
this.velocity.x = lerp(this.velocity.x, 0, 5 * dt);
this.pos.x += this.velocity.x * dt;
this.fallVelY += CFG.FALL_GRAVITY * dt;
this.pos.y += this.fallVelY * dt;

if (this.pos.y >= this.fallTargetY) {
this.pos.y = this.fallTargetY;
this.fallVelY = 0;
this.jumpOffset = 0;
}
}

// ===================== 朝向 =====================
_setFacing(dirX) {
if (this.dirCooldown > 0) return;
if (Math.abs(dirX) < 1) return;
const next = dirX > 0;
if (next !== this.facingRight) {
this.facingRight = next;
this.dirCooldown = CFG.DIR_CD;
}
}

// ===================== DOM =====================
_applyDOM() {
this.rootEl.style.transform = `translate(${this.pos.x}px,${this.pos.y}px)`;
const sx = this.facingRight ? -1 : 1;
const rot =
this.state === State.DRAGGING || this.state === State.FALL
? this.dragRot
: 0;
this.innerEl.style.transform = `scaleX(${sx}) rotate(${rot}rad)`;
this.spriteEl.style.transform =
this.state === State.DRAGGING ? `translateY(${this.jumpOffset}px)` : "";
}

// ===================== 外部读取接口 =====================
getState() {
return this.state;
}
getPos() {
return { ...this.pos };
}
getVelocity() {
return { ...this.velocity };
}
isFacingRight() {
return this.facingRight;
}
getRotation() {
return this.dragRot;
}
getSpriteOffsetY() {
return this.jumpOffset;
}
getFallTargetY() {
return this.fallTargetY || this.pos.y + CFG.FALL_DIST;
}
}

最后

目前没什么问题,就是歌曲的话我怕侵权,让AI找的两首免费的纯音乐,总之,桌宠+音乐播放器大功告成了,虽然和哔站大佬的有很多区别,但是我觉得现在的版本毫不逊色大佬制作的桌宠,毕竟——这是由游戏引擎转变成网页前端的实现,但是二者天差地别,虽然也有以JavaScript为脚本语言的游戏引擎比如开心消消乐的游戏引擎Cocos 2d,但是JavaScript不适合做游戏,尤其是大型游戏,这就是为什么大型游戏使用的语言一般是C#和C++,JavaScript只适合微信小游戏和竖屏游戏还有网页游戏比如4399

未来再看看有没有更好的实现吧,果然模块化是最正确的,模块化可以保证删掉某个文件之后只会造成某个功能的缺失但不影响全局

比如我之前提交的lang-switch.js脚本本质上不会影响任何脚本,删除后也只是会造成翻译功能的消失,不会影响其他功能

这个归功于forever218大佬的自定义模块设计,我可以自由添加界面落叶效果和鼠标点击效果还有数学公式

以及文件命名见名知义,这个真的很重要,因为代码是看不懂的,但是看代码文件的名字就知道这个是负责什么的,这样让AI来改轻松的多,虽然很多人觉得AI写的是垃圾代码,但是吧,AI再垃圾也比我强,更何况我使用的是Claude的最顶级的模型

这就像拼积木一样,将各个功能组合到一起,而彼此之间可以说是弱联系,这就是“解耦合”,不会出现删掉一个报错千行这种情况

对了,目前主题测试几乎没有问题了,除了一个就是偶然发现文章可以嵌套,就是文章套文章套文章,不过我觉得问题不大,没必要改

对了,桌宠目前没有实现多语言化,因为这样又要改上面的文件,不想再改了我已经开始厌倦了,我变懒了