dsh-pet-dom 2.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/README.md +77 -0
- package/assets/cover.png +0 -0
- package/assets/miku.gif +0 -0
- package/cordis.patch.yml +15 -0
- package/lib/brain.js +198 -0
- package/lib/console.js +422 -0
- package/lib/index.js +147 -0
- package/lib/mascot.js +1050 -0
- package/package.json +31 -0
package/lib/mascot.js
ADDED
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// dsh-pet-dom —— 客户端桌宠本体 v2.0(ESM 模块;大脑来自 lib/brain.js)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// 2.0 特性(M1+P2):
|
|
5
|
+
// · 默认在浏览器窗口最底下,正常运动
|
|
6
|
+
// · 表面收集:节流扫描视口内"高 DOM",作为可落脚的顶面(含 id)
|
|
7
|
+
// · 物理引擎感:重力 + 冲量跳跃 + 表面碰撞落地 + 落地压缩缓冲;跳跃高度可很高
|
|
8
|
+
// · 决策大脑(brain.js):扫描射线 → 平台图 → A* 路线 → 逐段执行;软随机 + 随机突发
|
|
9
|
+
// · 吸引距离缩小(≈1.35x 桌宠),拖拽为主
|
|
10
|
+
// 继承 1.0:精灵图帧循环 / 待机 / 胡桃摇 / 左右走 / 拖拽 / 位置记忆 / 单一互斥状态机
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_PHYS, raycast, buildGraph, planRoute, softPick, whimTick, pickGoal } from '/dsh-pet-dom/brain.js'
|
|
14
|
+
import { createPetConsole } from '/dsh-pet-dom/console.js'
|
|
15
|
+
|
|
16
|
+
window.__petDomLoaded = true
|
|
17
|
+
|
|
18
|
+
var SPRITE_URL = '/dsh-pet-dom/miku.gif'
|
|
19
|
+
var ACTIONS = {
|
|
20
|
+
idle: { frameWidth: 58.9, frameHeight: 62, frameCount: 19, offsetY: 0 },
|
|
21
|
+
active: { frameWidth: 84.0, frameHeight: 65, frameCount: 11, offsetY: -430 },
|
|
22
|
+
runRight: { frameWidth: 77.0, frameHeight: 65, frameCount: 7, offsetY: -65 },
|
|
23
|
+
runLeft: { frameWidth: 77.0, frameHeight: 65, frameCount: 7, offsetY: -65 },
|
|
24
|
+
jumpUp: { frameWidth: 77.8, frameHeight: 101, frameCount: 8, offsetY: -131 },
|
|
25
|
+
jumpHoriz: { frameWidth: 84.5, frameHeight: 67, frameCount: 8, offsetY: -233 }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
var STORAGE_KEY = 'pet_dom_position'
|
|
29
|
+
// 物理 & 2.0 参数
|
|
30
|
+
var G = 0.3 // 重力(px/frame²@60fps;低一点→腾空更慢更久,好落在 DOM 上)
|
|
31
|
+
var G_FALL = 0.15 // 下坠重力(vy>0 时用):比 G 低 → 下落更慢更"轻",落点更准
|
|
32
|
+
var V_FALL_MAX = 5 // 终端下落速度(px/帧):到达后匀速飘落 → 与高度无关,越高越不砸
|
|
33
|
+
var MOVE_INTERVAL = 2600 // 空闲决策周期(路线跟随更频繁)
|
|
34
|
+
var SURFACE_MIN_H = 24 // 表面最小高度(放宽:tab 栏/会话项/悬浮小 widget 都能进)
|
|
35
|
+
var SURFACE_MIN_W = 60 // 表面最小宽度
|
|
36
|
+
var SURFACE_MAX = 160 // 最多收集表面数(面多时决策图仍秒算)
|
|
37
|
+
var TOP_SAFE = 44 // 站立安全线:表面顶面 ≥ 此值(= 宠物帧高×0.7,站上后至少七成身体在屏内)
|
|
38
|
+
// 顶面过小(站上去必然整个悬出/超上界)的面不是合法表面 —— 检测时过滤,不是跳时才裁
|
|
39
|
+
var dedupGap = 90 // 表面合并距离(px):两表面边缘距离 < 此值 → 视为同一台(保留更大的);
|
|
40
|
+
// 控制台可调(0 = 不合并,仅完全重叠去重);默认 90px
|
|
41
|
+
var highlightAlpha = 0.2 // 高亮透明度(主线 alpha;上亮线 = alpha+0.12),控制台可调,默认 20%
|
|
42
|
+
var SURFACE_SCAN_MS = 1200 // 表面扫描节流(降频,避免布局抖动)
|
|
43
|
+
var SCAN_BUDGET = 2400 // 每次扫描最多看多少个元素(DSH Web 全页约 2.4k 元素,预算须覆盖内容区)
|
|
44
|
+
|
|
45
|
+
// 游戏模式(键盘接管;物理规则不变,玩家代替大脑)
|
|
46
|
+
var GAME_MODE = false
|
|
47
|
+
var gameDir = 0 // -1 左 / 0 / 1 右(键盘实时,空中可调)
|
|
48
|
+
var GAME_JUMP = 170 // 地面/表面起跳高度(need)
|
|
49
|
+
var GAME_JUMP_AIR = 150 // 多段跳(踩空气上天)高度:空中任意次按跳
|
|
50
|
+
var disableUpDown = false // 禁用 ↑/↓(与页面翻页冲突时开启;空格/W 跳、S 快落仍可用)
|
|
51
|
+
var highlightOn = false // 高亮可跳跃上界(顶边线)
|
|
52
|
+
var highlightColor = '#a855f7' // 高亮主题色
|
|
53
|
+
var jumpGoalId = null // 本次跳跃目标表面 id(精准吸附锁定;null=自由跳)
|
|
54
|
+
var petConsole = null // 控制台(init 里创建;模块顶层引用供 API)
|
|
55
|
+
|
|
56
|
+
// ---------- DOM ----------
|
|
57
|
+
var el = document.createElement('div')
|
|
58
|
+
el.className = 'starry-pet-dom'
|
|
59
|
+
el.style.position = 'fixed'
|
|
60
|
+
el.style.zIndex = '2147483001' // 极高层级:盖住页面一切,且高于控制台 UI(宠物不躲在控制台后面)
|
|
61
|
+
el.style.cursor = 'grab'
|
|
62
|
+
el.style.backgroundRepeat = 'no-repeat'
|
|
63
|
+
el.style.backgroundSize = 'auto'
|
|
64
|
+
el.style.pointerEvents = 'auto'
|
|
65
|
+
el.style.userSelect = 'none'
|
|
66
|
+
el.style.webkitUserSelect = 'none'
|
|
67
|
+
el.style.backgroundImage = "url('" + SPRITE_URL + "')"
|
|
68
|
+
document.body.appendChild(el)
|
|
69
|
+
var styleEl2 = document.createElement('style')
|
|
70
|
+
styleEl2.textContent = '.starry-pet-dom:active{cursor:grabbing}.starry-pet-dom{margin:0;padding:0;border:0}'
|
|
71
|
+
document.head.appendChild(styleEl2)
|
|
72
|
+
|
|
73
|
+
// ---------- 状态 ----------
|
|
74
|
+
var state = {
|
|
75
|
+
currentAction: 'idle',
|
|
76
|
+
currentFrame: 0,
|
|
77
|
+
position: { x: 0, y: 0 },
|
|
78
|
+
faceLeft: false,
|
|
79
|
+
dragStartX: 0, dragStartY: 0, dragMoved: false
|
|
80
|
+
}
|
|
81
|
+
var BEHAVIOR = 'idle' // idle | walk | shake | attract | jump | drag | fall
|
|
82
|
+
var vy = 0 // 垂直速度
|
|
83
|
+
var vx = 0 // 水平速度
|
|
84
|
+
var airborne = false // 是否腾空
|
|
85
|
+
var groundY = 0 // 当前地面(表面顶 或 窗口底)
|
|
86
|
+
var onSurface = null // 当前所站表面 rect(null=窗口底)
|
|
87
|
+
var surfaces = [] // 表面列表
|
|
88
|
+
var walkTargetX = null // 行走目标 x
|
|
89
|
+
var fallFromY = null // 下坠起点(跳过当前表面及以上,避免"下跳又落回原表面")
|
|
90
|
+
var jumpTargetX = null // 跳跃锚定 x(到达后停水平速度,避免飞过头)
|
|
91
|
+
var lastMouseMoveTime = Date.now()
|
|
92
|
+
var lastFrameTs = 0
|
|
93
|
+
var lastPhysicsTs = 0
|
|
94
|
+
var lastSurfaceAt = 0
|
|
95
|
+
var petIdCounter = 0 // 稳定表面 id(按元素分配,不随去重顺序漂移)
|
|
96
|
+
var lastGroundChk = 0
|
|
97
|
+
var lastMouseEvent = null
|
|
98
|
+
var lastMouse = { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
|
99
|
+
|
|
100
|
+
// 大脑/决策状态(P2/P3)
|
|
101
|
+
var currentRoute = null // 规划路线 [ids](P3 持久执行)
|
|
102
|
+
var routeIdx = 0 // 当前在路线的第几段(P3 逐段推进)
|
|
103
|
+
var currentGoal = null // 当前目标 id
|
|
104
|
+
var lastDecision = null // 决策日志(调试可视化)
|
|
105
|
+
var lastWhimAt = 0
|
|
106
|
+
var whimRestUntil = 0 // 突发后短暂休息(再回计划)
|
|
107
|
+
var seedRng = null // 种子化 RNG(调试重放);null = Math.random
|
|
108
|
+
function rng() { return seedRng ? seedRng() : Math.random() }
|
|
109
|
+
function mulberry32(a) {
|
|
110
|
+
a = a >>> 0
|
|
111
|
+
return function () {
|
|
112
|
+
a |= 0; a = (a + 0x6D2B79F5) | 0
|
|
113
|
+
var t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
114
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
115
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function setSeed(n) { seedRng = (n === undefined || n === null) ? null : mulberry32(n) }
|
|
119
|
+
|
|
120
|
+
// ---------- 工具 ----------
|
|
121
|
+
function curr() { return ACTIONS[state.currentAction] }
|
|
122
|
+
function frameWidth() { return curr().frameWidth }
|
|
123
|
+
function frameHeight() { return curr().frameHeight }
|
|
124
|
+
function currentOffsetY() { return curr().offsetY }
|
|
125
|
+
function currentFrameCount() { return curr().frameCount }
|
|
126
|
+
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) }
|
|
127
|
+
function feet() { return state.position.y + frameHeight() }
|
|
128
|
+
// 颜色派生:amt>0 混白(亮化),amt<0 混黑(加深)—— 返回 hex
|
|
129
|
+
function shade(hex, amt) {
|
|
130
|
+
var n = parseInt(hex.slice(1), 16) || 0
|
|
131
|
+
var r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255
|
|
132
|
+
var t = amt > 0 ? 255 : 0, a = Math.abs(amt)
|
|
133
|
+
r = Math.round(r + (t - r) * a); g = Math.round(g + (t - g) * a); b = Math.round(b + (t - b) * a)
|
|
134
|
+
return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0')
|
|
135
|
+
}
|
|
136
|
+
// hex → rgba(高亮线半透明用)
|
|
137
|
+
function hexToRgba(hex, alpha) {
|
|
138
|
+
var n = parseInt(hex.slice(1), 16) || 0
|
|
139
|
+
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')'
|
|
140
|
+
}
|
|
141
|
+
// ---------- OKLab/OKLCH 感知均匀色彩(Björn Ottosson 算法;Tailwind/现代 CSS 同思路)----------
|
|
142
|
+
// srgb(hex) → OKLab
|
|
143
|
+
function srgbToOklab(hex) {
|
|
144
|
+
var n = parseInt(hex.slice(1), 16)
|
|
145
|
+
var r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255
|
|
146
|
+
r = r <= 0.04045 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4)
|
|
147
|
+
g = g <= 0.04045 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4)
|
|
148
|
+
b = b <= 0.04045 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4)
|
|
149
|
+
var l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
|
|
150
|
+
var m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
|
|
151
|
+
var s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
|
|
152
|
+
var l3 = Math.cbrt(l), m3 = Math.cbrt(m), s3 = Math.cbrt(s)
|
|
153
|
+
return [
|
|
154
|
+
0.2104542553 * l3 + 0.7936177850 * m3 - 0.0040720468 * s3,
|
|
155
|
+
1.9779984951 * l3 - 2.4285922050 * m3 + 0.4505937099 * s3,
|
|
156
|
+
0.0259040371 * l3 + 0.7827717662 * m3 - 0.8086757660 * s3
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
// OKLab → srgb(hex)
|
|
160
|
+
function oklabToSrgb(L, a, b) {
|
|
161
|
+
var l3 = L + 0.3963377774 * a + 0.2158037573 * b
|
|
162
|
+
var m3 = L - 0.1055613458 * a - 0.0638541728 * b
|
|
163
|
+
var s3 = L - 0.0894841775 * a - 1.2914855480 * b
|
|
164
|
+
var l = l3 * l3 * l3, m = m3 * m3 * m3, s = s3 * s3 * s3
|
|
165
|
+
var r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
|
|
166
|
+
var g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
|
|
167
|
+
var b2 = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
|
|
168
|
+
var lin = function (v) { return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055 }
|
|
169
|
+
var to = function (v) { return Math.round(Math.max(0, Math.min(1, lin(v))) * 255).toString(16).padStart(2, '0') }
|
|
170
|
+
return '#' + to(r) + to(g) + to(b2)
|
|
171
|
+
}
|
|
172
|
+
function oklchOf(hex) {
|
|
173
|
+
var lab = srgbToOklab(hex)
|
|
174
|
+
var C = Math.sqrt(lab[1] * lab[1] + lab[2] * lab[2])
|
|
175
|
+
var H = Math.atan2(lab[2], lab[1]) * 180 / Math.PI
|
|
176
|
+
return [lab[0], C, H < 0 ? H + 360 : H]
|
|
177
|
+
}
|
|
178
|
+
function oklchToHex(L, C, H) {
|
|
179
|
+
var hr = H * Math.PI / 180
|
|
180
|
+
return oklabToSrgb(L, C * Math.cos(hr), C * Math.sin(hr))
|
|
181
|
+
}
|
|
182
|
+
// 【专门算法】派生色卡:OKLCH 中【色相 H 锁死】(100% 不出色系),
|
|
183
|
+
// 只动感知明度 L / 彩度 C 的阶梯(亮档降彩不刺眼、深档保彩不发灰)—— 感知均匀,档位分明
|
|
184
|
+
// 入口安全阀:用户自定义/主题色过浅(亮度>200)→ 先压深 45% 再派生,保证整条色带落在可见区
|
|
185
|
+
function derivePalette(color, n) {
|
|
186
|
+
var n16 = parseInt(color.slice(1), 16) || 0
|
|
187
|
+
var lum = 0.299 * ((n16 >> 16) & 255) + 0.587 * ((n16 >> 8) & 255) + 0.114 * (n16 & 255)
|
|
188
|
+
if (lum > 200) {
|
|
189
|
+
var r0 = Math.round(((n16 >> 16) & 255) * 0.55), g0 = Math.round(((n16 >> 8) & 255) * 0.55), b0 = Math.round((n16 & 255) * 0.55)
|
|
190
|
+
color = '#' + r0.toString(16).padStart(2, '0') + g0.toString(16).padStart(2, '0') + b0.toString(16).padStart(2, '0')
|
|
191
|
+
}
|
|
192
|
+
var c = oklchOf(color)
|
|
193
|
+
var L = c[0], C = c[1], H = c[2]
|
|
194
|
+
var DL = [0.02, -0.08, 0.10, -0.15, 0.16] // 明度阶梯(围绕主色)
|
|
195
|
+
var DC = [0.00, -0.03, -0.11, -0.05, -0.18] // 彩度阶梯:亮档降彩(浅而不灰)
|
|
196
|
+
var out = []
|
|
197
|
+
for (var i = 0; i < n; i++) {
|
|
198
|
+
var k = i % 5
|
|
199
|
+
out.push(oklchToHex(
|
|
200
|
+
Math.min(0.96, Math.max(0.30, L + DL[k])),
|
|
201
|
+
Math.max(0.02, C + DC[k]), H
|
|
202
|
+
))
|
|
203
|
+
}
|
|
204
|
+
return out
|
|
205
|
+
}
|
|
206
|
+
// 碰撞体:比精灵帧裁剪小 —— 宽度 = 帧宽 * 3/8(水平居中),高度略小;落点/贴边都以它为准
|
|
207
|
+
function collW() { return frameWidth() * 0.375 }
|
|
208
|
+
// 全部姿态里最宽的碰撞体(跳跃/腾空时都是 77.8~84.5 帧宽 → 29.2~31.7px),
|
|
209
|
+
// 传给大脑作 footprint:规划按"最坏情形"算站得住/落得下
|
|
210
|
+
function maxCollW() {
|
|
211
|
+
var m = 0
|
|
212
|
+
for (var k in ACTIONS) m = Math.max(m, ACTIONS[k].frameWidth * 0.375)
|
|
213
|
+
return m
|
|
214
|
+
}
|
|
215
|
+
function collX() { return state.position.x + (frameWidth() - collW()) / 2 }
|
|
216
|
+
function xOverlapColl(s) { return (collX() + collW() > s.x + 2) && (collX() < s.x + s.w - 2) }
|
|
217
|
+
|
|
218
|
+
// ---------- 渲染 ----------
|
|
219
|
+
function render() {
|
|
220
|
+
var w = frameWidth(), h = frameHeight()
|
|
221
|
+
el.style.width = w + 'px'
|
|
222
|
+
el.style.height = h + 'px'
|
|
223
|
+
el.style.left = state.position.x + 'px'
|
|
224
|
+
el.style.top = state.position.y + 'px'
|
|
225
|
+
el.style.backgroundPosition = (-state.currentFrame * w) + 'px ' + currentOffsetY() + 'px'
|
|
226
|
+
var mirror = state.currentAction === 'runLeft' || ((state.currentAction === 'jumpHoriz' || state.currentAction === 'jumpUp') && state.faceLeft)
|
|
227
|
+
el.style.transform = mirror ? 'rotateY(180deg)' : 'none'
|
|
228
|
+
}
|
|
229
|
+
function squash() {
|
|
230
|
+
el.style.transition = 'transform .06s ease'
|
|
231
|
+
el.style.transform = 'scaleY(0.86) scaleX(1.08)'
|
|
232
|
+
setTimeout(function () { el.style.transition = ''; el.style.transform = ''; if (state.faceLeft) el.style.transform = 'rotateY(180deg)' }, 110)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------- 位置持久化 ----------
|
|
236
|
+
function savePosition() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.position)) } catch (e) {} }
|
|
237
|
+
function readPosition() {
|
|
238
|
+
try {
|
|
239
|
+
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null')
|
|
240
|
+
if (saved && typeof saved.x === 'number' && typeof saved.y === 'number') return saved
|
|
241
|
+
} catch (e) {}
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
function clampPosition() {
|
|
245
|
+
var maxX = window.innerWidth - frameWidth()
|
|
246
|
+
state.position.x = Math.max(0, Math.min(maxX, state.position.x))
|
|
247
|
+
// 非腾空:不强制 y ≥ 0(允许随表面滚动骑出视口上沿——物理一致,滚回来即可见)
|
|
248
|
+
if (!airborne) { state.position.y = Math.min(window.innerHeight - frameHeight() - 2, state.position.y) }
|
|
249
|
+
savePosition()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------- 动作切换 ----------
|
|
253
|
+
function setAction(action) {
|
|
254
|
+
if (state.currentAction === action) return
|
|
255
|
+
state.currentAction = action
|
|
256
|
+
state.currentFrame = 0
|
|
257
|
+
if (action !== 'jumpHoriz' && action !== 'jumpUp') state.faceLeft = false
|
|
258
|
+
// 站在地面上时:动作帧高变化 → 自动把脚底对齐当前地面(防"落地瞬间姿态变化碰撞体变化→误判悬空")
|
|
259
|
+
if (!airborne && BEHAVIOR !== 'drag' && BEHAVIOR !== 'fall') {
|
|
260
|
+
state.position.y = groundY - frameHeight()
|
|
261
|
+
}
|
|
262
|
+
render()
|
|
263
|
+
}
|
|
264
|
+
function setBehavior(b) {
|
|
265
|
+
if (BEHAVIOR === b) return
|
|
266
|
+
BEHAVIOR = b
|
|
267
|
+
if (b !== 'walk') walkTargetX = null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------- 表面收集(节流扫描视口内高 DOM 顶面)----------
|
|
271
|
+
function collectSurfaces(force, fresh) {
|
|
272
|
+
var now = Date.now()
|
|
273
|
+
if (!force && now - lastSurfaceAt < SURFACE_SCAN_MS) return
|
|
274
|
+
// 移动/跳跃时不扫(减少卡顿);决策/启动时 force 扫
|
|
275
|
+
if (!force && (airborne || BEHAVIOR === 'walk' || BEHAVIOR === 'attract' || BEHAVIOR === 'jump')) return
|
|
276
|
+
lastSurfaceAt = now
|
|
277
|
+
var out = []
|
|
278
|
+
try {
|
|
279
|
+
// 通用遍历:整棵渲染树(含任意 shadow DOM 子树 —— 组件化 UI 的内部块与普通块同权)
|
|
280
|
+
var els = []
|
|
281
|
+
;(function scan(root) {
|
|
282
|
+
var list = root.querySelectorAll ? root.querySelectorAll('*') : []
|
|
283
|
+
for (var i = 0; i < list.length; i++) {
|
|
284
|
+
els.push(list[i])
|
|
285
|
+
if (list[i].shadowRoot) scan(list[i].shadowRoot)
|
|
286
|
+
}
|
|
287
|
+
})(document.body)
|
|
288
|
+
var steps = 0
|
|
289
|
+
var TAG_SKIP = { IMG: 1, SVG: 1, CANVAS: 1, VIDEO: 1, AUDIO: 1, IFRAME: 1, PICTURE: 1, EMBED: 1, OBJECT: 1 }
|
|
290
|
+
for (var i = 0; i < els.length; i++) {
|
|
291
|
+
var nm = els[i]
|
|
292
|
+
if (nm === el) continue
|
|
293
|
+
if (nm.closest('[data-pet-ignore]')) continue // 标记忽略的容器(如调试面板/测试 UI)
|
|
294
|
+
// 便宜预筛不计预算(文档序上万节点也不会把预算耗尽)
|
|
295
|
+
if (nm.offsetHeight < SURFACE_MIN_H || nm.offsetWidth < SURFACE_MIN_W) continue
|
|
296
|
+
if (TAG_SKIP[nm.tagName]) continue
|
|
297
|
+
steps++
|
|
298
|
+
if (steps > SCAN_BUDGET) break // 预算只消耗在"深检候选"上
|
|
299
|
+
// 统一法则(通解,不针对任何具体页面/组件):
|
|
300
|
+
// 一个元素是"表面" ⟺ ① 真实渲染(非 none/hidden/opacity>0.2)
|
|
301
|
+
// ② 属于内容层而非媒体资源(img/video/canvas/iframe 只是盒内资源,盒子才可站)
|
|
302
|
+
// ③ 尺寸 ≥ 最小可站(MIN_H×MIN_W,可调)
|
|
303
|
+
// ④ 顶面在视口内(top < 视口高 且 bottom > 0)
|
|
304
|
+
// ⑤ 不是全屏布局容器(≥0.8×视口 —— 布局层站上去无意义)
|
|
305
|
+
// 不再特判:fixed/sticky 位置(悬浮挂件可站)、顶部区域(顶部栏可站,悬挂由钳制+上界挤落兜底)
|
|
306
|
+
var cs = getComputedStyle(nm)
|
|
307
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) <= 0.2) continue
|
|
308
|
+
if (cs.pointerEvents === 'none') continue // 装饰/不可交互背景(法则②语义)
|
|
309
|
+
if (nm.getAttribute('aria-hidden') === 'true') continue
|
|
310
|
+
var r = nm.getBoundingClientRect()
|
|
311
|
+
if (r.width < SURFACE_MIN_W || r.height < SURFACE_MIN_H) continue
|
|
312
|
+
if (r.bottom <= 0 || r.top >= window.innerHeight) continue
|
|
313
|
+
if (r.top < TOP_SAFE) continue // 站立安全线:站上去明显超上界的面 → 不是表面(检测时过滤)
|
|
314
|
+
if (r.height >= window.innerHeight * 0.8) continue
|
|
315
|
+
if (!nm.__petId) nm.__petId = 's' + (petIdCounter++) // 稳定 id(元素件上)
|
|
316
|
+
out.push({ id: nm.__petId, x: r.left, y: r.top, w: r.width, h: r.height, el: nm })
|
|
317
|
+
}
|
|
318
|
+
out.sort(function (a, b) { return (a.y - b.y) || (a.x - b.x) })
|
|
319
|
+
var dedup = []
|
|
320
|
+
for (var k = 0; k < out.length; k++) {
|
|
321
|
+
var dup = false
|
|
322
|
+
for (var m = 0; m < dedup.length; m++) {
|
|
323
|
+
// 合并:两个台边缘距离 < dedupGap(可调,默认 3px)→ 视为同一台(不看尺寸差 —— 距离说了算);
|
|
324
|
+
// 保留"更长"的那个(面积大者占位,替换)
|
|
325
|
+
var d = dedup[m]
|
|
326
|
+
if (Math.abs(d.y - out[k].y) < dedupGap && Math.abs(d.x - out[k].x) < dedupGap) {
|
|
327
|
+
if (out[k].w * out[k].h > d.w * d.h) dedup[m] = out[k] // 更大(更长)的台留下
|
|
328
|
+
dup = true
|
|
329
|
+
break
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (!dup) dedup.push(out[k])
|
|
333
|
+
}
|
|
334
|
+
// 【浮动表面池】旧成员保留:仍连接且尺寸未剧变 → 留在列表(滚动/重排导致 rect 变化只更新坐标);
|
|
335
|
+
// 新扫到的面并进来(按元素去重);仅 删除 或 尺寸剧变 才移除。
|
|
336
|
+
// 这样"能跳的面 = 在列表的面 = 高亮的面"三者稳定一致(之前每轮全量重建 → 面进出列表 →
|
|
337
|
+
// 宠物站上了/跳得了某些面但高亮没跟上 = 你看到"默认模式能跳的面比高亮多")。
|
|
338
|
+
var merged = []
|
|
339
|
+
if (!fresh) {
|
|
340
|
+
for (var p = 0; p < surfaces.length && merged.length < SURFACE_MAX; p++) {
|
|
341
|
+
var oldS = surfaces[p]
|
|
342
|
+
if (!oldS.el || oldS.el.isConnected === false) continue
|
|
343
|
+
var orr
|
|
344
|
+
try { orr = oldS.el.getBoundingClientRect() } catch (e) { continue }
|
|
345
|
+
if (Math.abs(orr.height - oldS.h) > 60 || Math.abs(orr.width - oldS.w) > 60) continue // 尺寸剧变 → 重扫接管
|
|
346
|
+
merged.push({ id: oldS.id, x: orr.left, y: orr.top, w: orr.width, h: orr.height, el: oldS.el })
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (var q = 0; q < dedup.length && merged.length < SURFACE_MAX; q++) {
|
|
350
|
+
var has = false
|
|
351
|
+
for (var z = 0; z < merged.length; z++) { if (merged[z].el === dedup[q].el) { has = true; break } }
|
|
352
|
+
if (!has) merged.push(dedup[q])
|
|
353
|
+
}
|
|
354
|
+
surfaces = merged
|
|
355
|
+
if (highlightOn) applyHighlight() // 扫描后为(新)表面刷派生色高亮
|
|
356
|
+
} catch (e) {}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------- 物理(rAF 循环里:腾空时重力积分 + 表面碰撞落地)----------
|
|
360
|
+
function physics(now) {
|
|
361
|
+
if (!airborne) { lastPhysicsTs = now; return }
|
|
362
|
+
var dt = clamp((now - lastPhysicsTs) / 16.7, 0.05, 3)
|
|
363
|
+
lastPhysicsTs = now
|
|
364
|
+
var prevFeet = feet()
|
|
365
|
+
// 分段重力:上升段(vy≤0)用 G 保跳跃弧线;下坠段(vy>0)用低重力 + 终端速度
|
|
366
|
+
// (0.15 加速度只拖延"起步",速度仍会越积越大;终端速度让它变成恒定慢速飘落)
|
|
367
|
+
vy += (vy > 0 ? G_FALL : G) * dt
|
|
368
|
+
if (vy > V_FALL_MAX) vy = V_FALL_MAX
|
|
369
|
+
state.position.x += vx * dt
|
|
370
|
+
state.position.y += vy * dt
|
|
371
|
+
// x 夹在视口内(弧线全程飞,不再中途剪断水平速度)
|
|
372
|
+
var maxX = window.innerWidth - frameWidth()
|
|
373
|
+
if (state.position.x < 0) state.position.x = 0
|
|
374
|
+
if (state.position.x > maxX) state.position.x = maxX
|
|
375
|
+
var feetNow = feet()
|
|
376
|
+
if (vy > 0) {
|
|
377
|
+
var land = null
|
|
378
|
+
for (var i = 0; i < surfaces.length; i++) {
|
|
379
|
+
var s = surfaces[i]
|
|
380
|
+
// 分层实时 rect:距脚底 300px 内的面用实时(滚动/重排不悬空落旧位);远的用快照(160 面也不拖帧)
|
|
381
|
+
var sr = (s.el && Math.abs(s.y - feetNow) < 300) ? s.el.getBoundingClientRect() : s
|
|
382
|
+
if (sr.top < TOP_SAFE) continue // 跳跃实际表面同理:顶面过安全线 → 落点无效(穿透)
|
|
383
|
+
if (fallFromY !== null && sr.top <= fallFromY + 2) continue // 跳过当前表面及以上(下坠中)
|
|
384
|
+
var crossed = (sr.top >= prevFeet - 5 && sr.top <= feetNow) // 严格穿越(收紧防提前粘;每帧位移≤15px 不隧穿)
|
|
385
|
+
// 精准跳跃:near 吸附只对目标表面开放(±14);自由跳(游戏模式/落摔)放宽 ±18 —— 邻近层不粘,目标层稳吸
|
|
386
|
+
var near = (Math.abs(feetNow - sr.top) <= (jumpGoalId === null ? 18 : 14)) && (jumpGoalId === null || s.id === jumpGoalId)
|
|
387
|
+
var overlap = (collX() + collW() > sr.left + 2) && (collX() < sr.left + sr.width - 2)
|
|
388
|
+
if ((crossed || near) && overlap) {
|
|
389
|
+
if (!land || sr.top < land.y) land = { id: s.id, x: sr.left, y: sr.top, w: sr.width, h: sr.height, el: s.el }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (!land && feetNow >= window.innerHeight - 2) land = { x: 0, y: window.innerHeight, w: window.innerWidth, h: 0 }
|
|
393
|
+
if (land) doLand(land)
|
|
394
|
+
}
|
|
395
|
+
render()
|
|
396
|
+
}
|
|
397
|
+
function xOverlap(x, w, s) {
|
|
398
|
+
return (x + w > s.x + 2) && (x < s.x + s.w - 2)
|
|
399
|
+
}
|
|
400
|
+
function doLand(s) {
|
|
401
|
+
if (s.y >= window.innerHeight - 1) { groundY = window.innerHeight; onSurface = null }
|
|
402
|
+
else { groundY = s.y; onSurface = s }
|
|
403
|
+
state.position.y = Math.max(groundY - frameHeight(), -30) // 站立钳制:顶面在屏顶附近时"悬挂"(只露头 30px,不整体出屏)
|
|
404
|
+
vy = 0; vx = 0
|
|
405
|
+
airborne = false
|
|
406
|
+
fallFromY = null
|
|
407
|
+
jumpTargetX = null
|
|
408
|
+
jumpGoalId = null
|
|
409
|
+
setBehavior('idle')
|
|
410
|
+
setAction('idle')
|
|
411
|
+
squash()
|
|
412
|
+
render()
|
|
413
|
+
// P3 路线推进 / 失败恢复
|
|
414
|
+
var landedId = onSurface ? onSurface.id : null
|
|
415
|
+
if (currentRoute && routeIdx >= 0 && routeIdx + 1 < currentRoute.length) {
|
|
416
|
+
if (landedId === currentRoute[routeIdx + 1]) {
|
|
417
|
+
routeIdx++
|
|
418
|
+
var reachedGoal = (routeIdx + 1 >= currentRoute.length)
|
|
419
|
+
lastDecision = { t: Date.now(), cur: landedId, goal: reachedGoal ? null : currentGoal, route: reachedGoal ? null : currentRoute, mode: reachedGoal ? 'done' : 'advance' }
|
|
420
|
+
if (reachedGoal) { currentGoal = null; currentRoute = null; routeIdx = 0 }
|
|
421
|
+
} else {
|
|
422
|
+
// 落偏/摔到别处 → 恢复:清路线,下个周期由实际位置重规划
|
|
423
|
+
lastDecision = { t: Date.now(), cur: landedId, goal: null, route: null, mode: 'recover' }
|
|
424
|
+
currentGoal = null; currentRoute = null; routeIdx = 0
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------- 跳跃 / 落下 ----------
|
|
430
|
+
var jumpFrameMs = 100 // 跳跃动画帧间隔(按本次跳跃预计空中时长自适应;非跳跃动作 100ms 循环)
|
|
431
|
+
// 预计空中时长(帧):上升 tUp + 下坠(apex→目标顶面实际落差 dropH,不是 need!)
|
|
432
|
+
// 供弧线水平速度分配 + 动画帧速共用同一模型,避免"动画播完≠落地"
|
|
433
|
+
function airTimeFrames(need, rise) {
|
|
434
|
+
var tUp = Math.sqrt(2 * need / G) // 上升帧数 v0/G(v0=√(2·G·need))
|
|
435
|
+
var dropH = Math.max(40, need - rise) // apex → 目标顶面的实际坠落高度
|
|
436
|
+
var tDrop = 33 + Math.max(0, (dropH - 82) / 5) // G_FALL 0.15 加速到终端速度 5 的帧数 + 匀速段
|
|
437
|
+
return tUp + Math.max(24, tDrop)
|
|
438
|
+
}
|
|
439
|
+
// 按本次跳跃的预计空中时长算动画帧速(大跳 → 慢动作,小跳 → 快动作;播完≈落地)
|
|
440
|
+
function setJumpAnimSpeed(need, rise, frameCount) {
|
|
441
|
+
jumpFrameMs = Math.max(40, Math.min(250, airTimeFrames(need, rise) * 16.7 / frameCount))
|
|
442
|
+
}
|
|
443
|
+
// 横跳到目标表面顶面(物理冲量 + 水平速度,抛物线会自然落在目标上;支持向下弧线落)
|
|
444
|
+
function jumpOnto(s) {
|
|
445
|
+
if (airborne) return
|
|
446
|
+
var rise = feet() - s.y // >0 = 目标顶高于当前脚下(要向上跳);<0 = 目标更低(小抛后弧线下落)
|
|
447
|
+
if (rise < 0) fallFromY = feet() + 2 // 下跳弧线:跳过起点块及更上面的面 → "跳下去"不是绕圈落回原块
|
|
448
|
+
else fallFromY = null // 上跳:清下跳残留基线(否则挡中间层)
|
|
449
|
+
jumpGoalId = s.id ? s.id : null // 精准跳跃:锁定目标面(near 吸附仅对目标开放)
|
|
450
|
+
// 大余量:半隐式积分离散误差 + 跳跃帧高变化都算进去;需要多高就按比例再加
|
|
451
|
+
var need = Math.max(80, rise * 1.15 + 60)
|
|
452
|
+
// 峰顶自适应裁顶:峰顶不出屏幕顶(随脚下高度缩放 → 本就在高处时小跳;地面大跳保留)
|
|
453
|
+
need = Math.min(need, Math.max(24, feet() - 10))
|
|
454
|
+
var v0 = Math.sqrt(2 * G * need) // 初速度
|
|
455
|
+
vy = -v0
|
|
456
|
+
var targetX = clamp(s.x + s.w / 2 - frameWidth() / 2, 0, window.innerWidth - frameWidth())
|
|
457
|
+
var dirX = targetX - state.position.x
|
|
458
|
+
// 弧线物理:水平速度按整个飞行时间分配(上升+下坠到目标顶面)→ 完整抛物线弧线飞向落点;
|
|
459
|
+
// 不再"到目标 x 就停水平竖直砸落"(落地由 crossing 检测命中,doLand 清 vx)
|
|
460
|
+
var T = airTimeFrames(need, rise)
|
|
461
|
+
vx = clamp(dirX / T, -10, 10)
|
|
462
|
+
jumpTargetX = null
|
|
463
|
+
airborne = true
|
|
464
|
+
setBehavior('jump')
|
|
465
|
+
state.faceLeft = dirX < 0
|
|
466
|
+
// 双样式跳跃(v1 传统:横跳 h / 上跃 v):
|
|
467
|
+
// 横向距离小(近身跳,≤80px)→ 上跃样式 jumpUp(垂直弧,落回/落上目标)
|
|
468
|
+
// 横向距离大 → 横跳样式 jumpHoriz(弧线飞向落点)
|
|
469
|
+
var styleV = Math.abs(dirX) <= 80
|
|
470
|
+
setJumpAnimSpeed(need, rise, styleV ? ACTIONS.jumpUp.frameCount : ACTIONS.jumpHoriz.frameCount)
|
|
471
|
+
setAction(styleV ? 'jumpUp' : 'jumpHoriz')
|
|
472
|
+
render()
|
|
473
|
+
}
|
|
474
|
+
// 上跳查看(垂直到能看到该表面高度,再落回)
|
|
475
|
+
function jumpUpInspect(s) {
|
|
476
|
+
if (airborne) return
|
|
477
|
+
var rise = feet() - s.y
|
|
478
|
+
var need = Math.max(110, rise * 1.15 + 80)
|
|
479
|
+
vy = -Math.sqrt(2 * G * need)
|
|
480
|
+
vx = 0
|
|
481
|
+
airborne = true
|
|
482
|
+
setBehavior('jump')
|
|
483
|
+
state.faceLeft = false
|
|
484
|
+
setJumpAnimSpeed(need, rise, ACTIONS.jumpUp.frameCount)
|
|
485
|
+
setAction('jumpUp')
|
|
486
|
+
render()
|
|
487
|
+
}
|
|
488
|
+
// 从当前表面跳下(重力坠落到下一层/窗口底)
|
|
489
|
+
function fallDown() {
|
|
490
|
+
if (airborne) return
|
|
491
|
+
airborne = true
|
|
492
|
+
vy = 0.6
|
|
493
|
+
vx = 0
|
|
494
|
+
fallFromY = onSurface ? onSurface.y : groundY
|
|
495
|
+
jumpGoalId = null
|
|
496
|
+
setBehavior('fall')
|
|
497
|
+
setAction('idle')
|
|
498
|
+
render()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---------- 游戏模式(键盘接管;物理规则不变) ----------
|
|
502
|
+
function setGameMode(m) {
|
|
503
|
+
GAME_MODE = (m === true || m === 'game') // console 传 'game';API 传布尔 true
|
|
504
|
+
gameDir = 0
|
|
505
|
+
currentGoal = null; currentRoute = null; routeIdx = 0
|
|
506
|
+
if (!airborne && BEHAVIOR !== 'drag') { setBehavior('idle'); setAction('idle') }
|
|
507
|
+
render()
|
|
508
|
+
}
|
|
509
|
+
function setHighlight(on, color) {
|
|
510
|
+
highlightOn = !!on
|
|
511
|
+
if (color) highlightColor = color
|
|
512
|
+
applyHighlight()
|
|
513
|
+
}
|
|
514
|
+
function setDedupGap(px) {
|
|
515
|
+
dedupGap = Math.min(200, Math.max(0, parseInt(px, 10) || 3)) // 0~200px(用户上限)
|
|
516
|
+
collectSurfaces(true, true) // 合并阈值变化 → 清池全量重建(否则浮动池保留旧面,合并永不生效)
|
|
517
|
+
}
|
|
518
|
+
function setHighlightAlpha(v) {
|
|
519
|
+
highlightAlpha = Math.min(1, Math.max(0.05, parseFloat(v) || 0.5))
|
|
520
|
+
applyHighlight()
|
|
521
|
+
}
|
|
522
|
+
function setDisableUpDown(v) { disableUpDown = !!v }
|
|
523
|
+
// 高亮可跳跃上界:每个表面用【不同的派生色】(主题色系错色卡,按位置循环分配)——
|
|
524
|
+
// 色相±18°步进 + 明暗交替,相邻面板一眼可区分;每面双层线(上亮线 + 本面主色线)
|
|
525
|
+
var hlOwned = [] // 高亮所有权清单:上次刷过的元素(先清后刷 → 被合并/删除的面残留线被擦掉)
|
|
526
|
+
function applyHighlight() {
|
|
527
|
+
// 先擦旧:所有曾高亮的元素重置(被合并删除的面不再残留)
|
|
528
|
+
for (var r = 0; r < hlOwned.length; r++) {
|
|
529
|
+
if (hlOwned[r].style) hlOwned[r].style.boxShadow = ''
|
|
530
|
+
}
|
|
531
|
+
hlOwned = []
|
|
532
|
+
var arr = surfaces
|
|
533
|
+
if (!arr.length) return
|
|
534
|
+
if (!highlightOn) return
|
|
535
|
+
var sorted = arr.slice().sort(function (a, b) { return a.y - b.y }) // y 越小越高;按空间顺序分配错色
|
|
536
|
+
var pal = derivePalette(highlightColor, 5)
|
|
537
|
+
for (var i = 0; i < sorted.length; i++) {
|
|
538
|
+
var s = sorted[i]
|
|
539
|
+
if (!s.el) continue
|
|
540
|
+
var c = pal[i % pal.length] // 本面派生色(hex)
|
|
541
|
+
var a = highlightAlpha
|
|
542
|
+
s.el.style.boxShadow = 'inset 0 2px 0 ' + hexToRgba(shade(c, 0.35), Math.min(1, a + 0.12)) + ', inset 0 4px 0 ' + hexToRgba(c, a)
|
|
543
|
+
hlOwned.push(s.el)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// 起跳:地面/表面 → 第一次跳;空中 → 多段跳(踩空气上天,任意次;空中方向由键左右调整)
|
|
547
|
+
// 关键:起跳/二段跳都要清 fallFromY —— 摔落(fallDown)留下的"跳过当前面及以上"基线
|
|
548
|
+
// 会挡住所有比摔落起点高的层 → 玩家二段跳爬不回去 = "游戏模式跳不了高 UI 层"
|
|
549
|
+
function gameJump() {
|
|
550
|
+
// 上界裁顶(与默认模式 jumpOnto 同法则):apex 距屏顶 ≥10px —— 顶部起跳被"天花板"压住,不越界
|
|
551
|
+
var need = Math.min(!airborne ? GAME_JUMP : GAME_JUMP_AIR, Math.max(24, feet() - 10))
|
|
552
|
+
// 双样式随机(v1 传统 h/v):每次按跳随机 横跳(jumpHoriz) / 上跃(jumpUp)
|
|
553
|
+
var styleV = Math.random() < 0.45
|
|
554
|
+
if (!airborne) {
|
|
555
|
+
vy = -Math.sqrt(2 * G * need)
|
|
556
|
+
if (styleV) vx = 0
|
|
557
|
+
jumpTargetX = null
|
|
558
|
+
jumpGoalId = null // 玩家自由跳:不锁定目标(附近层都可吸附)
|
|
559
|
+
fallFromY = null
|
|
560
|
+
airborne = true
|
|
561
|
+
setBehavior('jump')
|
|
562
|
+
setAction(styleV ? 'jumpUp' : 'jumpHoriz')
|
|
563
|
+
} else {
|
|
564
|
+
vy = -Math.sqrt(2 * G * need)
|
|
565
|
+
if (styleV) vx = 0 // 上跃段:竖直;横跳段:保留水平(空中可继续左右调)
|
|
566
|
+
fallFromY = null // 二段跳:可爬回摔落起点以上的层
|
|
567
|
+
jumpGoalId = null
|
|
568
|
+
setAction(styleV ? 'jumpUp' : 'jumpHoriz') // 重播跳跃动画(帧归零)
|
|
569
|
+
}
|
|
570
|
+
render()
|
|
571
|
+
}
|
|
572
|
+
function gameFastFall() {
|
|
573
|
+
if (airborne) { vy = Math.max(vy, 9); return } // 空中:快落(加速度压低终端速度)
|
|
574
|
+
if (BEHAVIOR === 'drag' || BEHAVIOR === 'shake') return
|
|
575
|
+
fallDown() // 站在表面/地面:摔落(物理引擎接管,落到下方表面/窗口底)
|
|
576
|
+
}
|
|
577
|
+
function onKeyDown(e) {
|
|
578
|
+
if (!GAME_MODE) return
|
|
579
|
+
var t = e.target
|
|
580
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
|
|
581
|
+
var c = e.code
|
|
582
|
+
if (c === 'ArrowLeft' || c === 'KeyA') { gameDir = -1; e.preventDefault() }
|
|
583
|
+
else if (c === 'ArrowRight' || c === 'KeyD') { gameDir = 1; e.preventDefault() }
|
|
584
|
+
else if (c === 'Space' || c === 'ArrowUp' || c === 'KeyW') {
|
|
585
|
+
if (c === 'ArrowUp' && disableUpDown) return // 禁用上下键:↑ 不响应(空格/W 仍可跳)
|
|
586
|
+
gameJump(); e.preventDefault()
|
|
587
|
+
}
|
|
588
|
+
else if (c === 'ArrowDown' || c === 'KeyS') {
|
|
589
|
+
if (c === 'ArrowDown' && disableUpDown) return // 禁用上下键:↓ 不响应(S 仍快落)
|
|
590
|
+
gameFastFall(); e.preventDefault()
|
|
591
|
+
}
|
|
592
|
+
else if (c === 'KeyH') { if (!airborne) startShake() }
|
|
593
|
+
}
|
|
594
|
+
function onKeyUp(e) {
|
|
595
|
+
if (!GAME_MODE) return
|
|
596
|
+
var c = e.code
|
|
597
|
+
if ((c === 'ArrowLeft' || c === 'KeyA') && gameDir === -1) gameDir = 0
|
|
598
|
+
if ((c === 'ArrowRight' || c === 'KeyD') && gameDir === 1) gameDir = 0
|
|
599
|
+
}
|
|
600
|
+
// 游戏模式移动(tick 内每帧):地面左右跑;空中左右微调 vx(控制弧线方向)
|
|
601
|
+
function gameMove(fdt) {
|
|
602
|
+
if (!GAME_MODE) return
|
|
603
|
+
if (gameDir === 0) {
|
|
604
|
+
if (!airborne && BEHAVIOR === 'walk') { setBehavior('idle'); setAction('idle') }
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
if (airborne) {
|
|
608
|
+
vx = clamp(vx + gameDir * 0.35 * fdt, -7, 7)
|
|
609
|
+
state.faceLeft = gameDir < 0
|
|
610
|
+
} else {
|
|
611
|
+
var maxX = window.innerWidth - frameWidth()
|
|
612
|
+
state.position.x = clamp(state.position.x + gameDir * 3.4 * fdt, 0, maxX)
|
|
613
|
+
state.faceLeft = gameDir < 0
|
|
614
|
+
if (BEHAVIOR !== 'drag') { setBehavior('walk'); setAction(gameDir > 0 ? 'runRight' : 'runLeft') }
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---------- 地面行为 ----------
|
|
619
|
+
function startWalk() {
|
|
620
|
+
if (airborne) return
|
|
621
|
+
setBehavior('walk')
|
|
622
|
+
var half = window.innerWidth / 2
|
|
623
|
+
var cx = state.position.x + frameWidth() / 2
|
|
624
|
+
var dir = cx < half ? 'right' : 'left'
|
|
625
|
+
var dist = 220 + Math.random() * 300 // 更长距离 → 走得更久(步速已按 dt 放缓)
|
|
626
|
+
walkTargetX = dir === 'right'
|
|
627
|
+
? Math.min(window.innerWidth - frameWidth(), state.position.x + dist)
|
|
628
|
+
: Math.max(0, state.position.x - dist)
|
|
629
|
+
state.faceLeft = dir === 'left'
|
|
630
|
+
setAction(dir === 'right' ? 'runRight' : 'runLeft')
|
|
631
|
+
}
|
|
632
|
+
function startShake() {
|
|
633
|
+
if (airborne) return
|
|
634
|
+
setBehavior('shake')
|
|
635
|
+
setAction('active')
|
|
636
|
+
setTimeout(function () {
|
|
637
|
+
if (BEHAVIOR === 'shake' && !airborne) { setBehavior('idle'); setAction('idle') }
|
|
638
|
+
}, 4000)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// 找"更高"的表面(比当前地面高的、且横向上接近的)
|
|
642
|
+
function pickHigherSurface() {
|
|
643
|
+
var cx = collX() + collW() / 2 // 碰撞体中心
|
|
644
|
+
var best = null, bestScore = Infinity
|
|
645
|
+
for (var i = 0; i < surfaces.length; i++) {
|
|
646
|
+
var s = surfaces[i]
|
|
647
|
+
if (s.y >= groundY - 6) continue // 必须更高(顶面在当前位置上方)
|
|
648
|
+
var dx = Math.max(0, Math.max(cx - (s.x + s.w), s.x - cx))
|
|
649
|
+
if (dx > 220) continue // 太远
|
|
650
|
+
var score = Math.abs(groundY - s.y) * 0.6 + dx
|
|
651
|
+
if (score < bestScore) { bestScore = score; best = s }
|
|
652
|
+
}
|
|
653
|
+
return best
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ---------- 决策大脑(P2:扫描射线 → 平台图 → A* 路线 → 逐段执行;软随机 + 突发)----------
|
|
657
|
+
// brain 世界:surfaces → [{id,x,y,w,h}]
|
|
658
|
+
function brainWorld() {
|
|
659
|
+
return surfaces.map(function (s) { return { id: s.id, x: s.x, y: s.y, w: s.w, h: s.h } })
|
|
660
|
+
}
|
|
661
|
+
// 当前所站平台的 id(站在窗口底则 null)
|
|
662
|
+
function currentSurfaceId() {
|
|
663
|
+
if (!onSurface) return null
|
|
664
|
+
if (onSurface.id) return onSurface.id
|
|
665
|
+
for (var i = 0; i < surfaces.length; i++) if (surfaces[i].el === onSurface.el) return surfaces[i].id
|
|
666
|
+
return null
|
|
667
|
+
}
|
|
668
|
+
function findSurfaceById(id) {
|
|
669
|
+
for (var i = 0; i < surfaces.length; i++) if (surfaces[i].id === id) return surfaces[i]
|
|
670
|
+
return null
|
|
671
|
+
}
|
|
672
|
+
// 定向走:走向目标 x(targetX)
|
|
673
|
+
function walkTo(targetX) {
|
|
674
|
+
if (airborne) return
|
|
675
|
+
setBehavior('walk')
|
|
676
|
+
var maxX = window.innerWidth - frameWidth()
|
|
677
|
+
walkTargetX = clamp(targetX, 0, maxX)
|
|
678
|
+
state.faceLeft = walkTargetX < state.position.x
|
|
679
|
+
setAction(state.faceLeft ? 'runLeft' : 'runRight')
|
|
680
|
+
}
|
|
681
|
+
// 突发动作
|
|
682
|
+
function doWhim(type) {
|
|
683
|
+
if (type === 'hop') jumpOnto(currentOrNearby())
|
|
684
|
+
else if (type === 'shake') startShake()
|
|
685
|
+
else startWalk()
|
|
686
|
+
}
|
|
687
|
+
function currentOrNearby() {
|
|
688
|
+
// 突发跳:对着附近"更高"平台跳(若没有则小上跳一次;假目标随当前高度走,不再绝对贴顶)
|
|
689
|
+
var h = pickHigherSurface()
|
|
690
|
+
return h || { x: state.position.x, y: Math.max(44, groundY - 220), w: frameWidth(), h: frameHeight() }
|
|
691
|
+
}
|
|
692
|
+
// 执行路线一段:向 route[idx+1] 进发(跳上 / 走过去落下)
|
|
693
|
+
function executeStep(graph, route, idx) {
|
|
694
|
+
var nextId = route ? route[idx + 1] : null
|
|
695
|
+
var cur = route ? graph.byId[route[idx]] : null
|
|
696
|
+
if (!nextId) { if (rng() < 0.5) startWalk(); else startShake(); return }
|
|
697
|
+
var nextS = findSurfaceById(nextId)
|
|
698
|
+
if (!nextS) { if (rng() < 0.5) startWalk(); else startShake(); return }
|
|
699
|
+
var rise = cur ? (cur.y - nextS.y) : 0 // >0 = 目标更高
|
|
700
|
+
var targetX = nextS.x + nextS.w / 2 - frameWidth() / 2
|
|
701
|
+
if (rise > 0 || rng() < 0.5) {
|
|
702
|
+
jumpOnto(nextS) // 更高:必跳;平/更低:50% 也是弧线跳过去
|
|
703
|
+
} else {
|
|
704
|
+
walkTo(targetX) // 其余:走过去(窄平台走到边缘自然落)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// 最近平台(按水平距离)
|
|
709
|
+
function nearestPlatform() {
|
|
710
|
+
var cx = collX() + collW() / 2
|
|
711
|
+
var best = null, bestD = Infinity
|
|
712
|
+
for (var i = 0; i < surfaces.length; i++) {
|
|
713
|
+
var s = surfaces[i]
|
|
714
|
+
if (s.y >= groundY - 4) continue // 只看上方平台
|
|
715
|
+
var d = Math.abs((s.x + s.w / 2) - cx)
|
|
716
|
+
if (d < bestD) { bestD = d; best = s }
|
|
717
|
+
}
|
|
718
|
+
return best
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// 散步:在平台上(或窗口底)水平走走
|
|
722
|
+
function roam() {
|
|
723
|
+
var maxX = window.innerWidth - frameWidth()
|
|
724
|
+
var tx
|
|
725
|
+
if (onSurface && onSurface.w > 180) {
|
|
726
|
+
tx = onSurface.x + 50 + rng() * Math.max(10, (onSurface.w - 110 - frameWidth()))
|
|
727
|
+
} else {
|
|
728
|
+
tx = rng() * maxX
|
|
729
|
+
}
|
|
730
|
+
walkTo(clamp(tx, 0, maxX))
|
|
731
|
+
lastDecision = { t: Date.now(), cur: currentSurfaceId(), goal: null, route: null, mode: 'roam' }
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// 决策(大脑驱动):先续行当前路线,否则规划新路线
|
|
735
|
+
function decideNext() {
|
|
736
|
+
if (GAME_MODE) return // 游戏模式:玩家接管,大脑休息
|
|
737
|
+
if (BEHAVIOR !== 'idle' || airborne) return
|
|
738
|
+
if (Date.now() < whimRestUntil) return // 突发后短暂休息,再回计划
|
|
739
|
+
collectSurfaces()
|
|
740
|
+
// 随机突发(不打断腾空;在 idle/落地后触发;= 打断当前计划,真"任性")
|
|
741
|
+
var w = whimTick(Date.now(), lastWhimAt, [4500, 9000], rng)
|
|
742
|
+
if (w.trigger) {
|
|
743
|
+
lastWhimAt = Date.now()
|
|
744
|
+
whimRestUntil = Date.now() + 1200
|
|
745
|
+
currentGoal = null; currentRoute = null; routeIdx = 0
|
|
746
|
+
lastDecision = { t: Date.now(), cur: null, goal: null, route: null, mode: 'whim', type: w.type }
|
|
747
|
+
doWhim(w.type)
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
// 扫描 + 建图
|
|
751
|
+
var world = brainWorld()
|
|
752
|
+
if (!world.length) { if (rng() < 0.5) startWalk(); else startShake(); return }
|
|
753
|
+
var graph = buildGraph(world, DEFAULT_PHYS, maxCollW())
|
|
754
|
+
var curId = currentSurfaceId()
|
|
755
|
+
if (!curId) {
|
|
756
|
+
// 站在窗口底:走向最近的平台,够近就跳上去
|
|
757
|
+
var target = nearestPlatform()
|
|
758
|
+
if (target) {
|
|
759
|
+
var cx = collX() + collW() / 2
|
|
760
|
+
var dxNow = (target.x + target.w / 2) - cx
|
|
761
|
+
if (Math.abs(dxNow) > 260) { walkTo(target.x + target.w / 2 - frameWidth() / 2); return }
|
|
762
|
+
jumpOnto(target)
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
if (rng() < 0.5) startWalk(); else startShake()
|
|
766
|
+
return
|
|
767
|
+
}
|
|
768
|
+
// 续行:若还在路线上(当前站 = route[idx]),继续下一段
|
|
769
|
+
if (currentRoute && routeIdx >= 0 && currentRoute[routeIdx] === curId && routeIdx + 1 < currentRoute.length) {
|
|
770
|
+
executeStep(graph, currentRoute, routeIdx)
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
// 偶尔散散步(水平行走),与闯关穿插
|
|
774
|
+
if (rng() < 0.35) { roam(); return }
|
|
775
|
+
// 规划新目标/路线
|
|
776
|
+
var goal = pickGoal(graph, curId, rng)
|
|
777
|
+
if (goal && goal !== curId) {
|
|
778
|
+
var route = planRoute(graph, curId, goal)
|
|
779
|
+
lastDecision = { t: Date.now(), cur: curId, goal: goal, route: route || null, mode: 'plan' }
|
|
780
|
+
currentGoal = goal
|
|
781
|
+
currentRoute = route
|
|
782
|
+
routeIdx = 0
|
|
783
|
+
if (route && route.length >= 2) executeStep(graph, route, 0)
|
|
784
|
+
else { if (rng() < 0.5) startWalk(); else startShake() }
|
|
785
|
+
} else {
|
|
786
|
+
lastDecision = { t: Date.now(), cur: curId, goal: null, route: null, mode: 'wander' }
|
|
787
|
+
currentGoal = null; currentRoute = null; routeIdx = 0
|
|
788
|
+
if (rng() < 0.6) startWalk(); else startShake()
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ---------- 主循环(rAF:帧循环 + 行走 + 常驻引力 + 物理)----------
|
|
793
|
+
var lastTickTs = 0
|
|
794
|
+
function tick(now) {
|
|
795
|
+
var fdt = clamp((now - lastTickTs) / 16.7, 0.05, 3)
|
|
796
|
+
lastTickTs = now
|
|
797
|
+
// 帧推进:跳跃动画按本次跳跃时长自适应帧速(大跳慢动作/小跳快动作),非跳跃动作 100ms 循环
|
|
798
|
+
var frameMs = (state.currentAction === 'jumpUp' || state.currentAction === 'jumpHoriz') ? jumpFrameMs : 100
|
|
799
|
+
if (now - lastFrameTs >= frameMs) {
|
|
800
|
+
lastFrameTs = now
|
|
801
|
+
var jumpAct = state.currentAction === 'jumpUp' || state.currentAction === 'jumpHoriz'
|
|
802
|
+
state.currentFrame += 1
|
|
803
|
+
if (jumpAct) {
|
|
804
|
+
// 跳跃动画只播一遍:播完最后一帧 → 恢复默认 idle(不定格跳跃姿态;空中也回 idle 待机)
|
|
805
|
+
if (state.currentFrame >= currentFrameCount()) setAction('idle')
|
|
806
|
+
} else {
|
|
807
|
+
state.currentFrame = state.currentFrame % currentFrameCount()
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// 常驻引力:站地上但没贴住当前地面(悬空/地面变了)→ 立即下坠(缓冲阈值放宽到 12px)
|
|
811
|
+
if (!airborne && BEHAVIOR !== 'drag' && Math.abs(feet() - groundY) > 12) {
|
|
812
|
+
fallDown()
|
|
813
|
+
}
|
|
814
|
+
// 所站的 DOM 表面:删除(isConnected=false)→ 重力接管下落;
|
|
815
|
+
// 移动(如滚动上移/面板位移)→ 宠物"骑乘":按表面位移贴上去,不掉下来
|
|
816
|
+
if (!airborne && BEHAVIOR !== 'drag' && onSurface && onSurface.el && now - lastGroundChk > 150) {
|
|
817
|
+
lastGroundChk = now
|
|
818
|
+
var groundGone = onSurface.el.isConnected === false
|
|
819
|
+
if (!groundGone) {
|
|
820
|
+
try {
|
|
821
|
+
var nr = onSurface.el.getBoundingClientRect()
|
|
822
|
+
var dxs = nr.left - onSurface.x
|
|
823
|
+
var dys = nr.top - onSurface.y
|
|
824
|
+
if (Math.abs(dxs) > 0.5 || Math.abs(dys) > 0.5) {
|
|
825
|
+
if (dys > 6) {
|
|
826
|
+
// 表面向下移走(滚动下移/消息下移):不强制跟随 —— 支撑消失,重力系统接管(悬空下坠,落回或掉落)
|
|
827
|
+
onSurface = null
|
|
828
|
+
fallDown()
|
|
829
|
+
} else {
|
|
830
|
+
// 表面向上/横向移动:骑乘跟随(保持站在顶面;x 夹在视口内防穿帮)
|
|
831
|
+
state.position.x = clamp(state.position.x + dxs, 0, window.innerWidth - frameWidth())
|
|
832
|
+
state.position.y += dys
|
|
833
|
+
onSurface.x = nr.left; onSurface.y = nr.top
|
|
834
|
+
onSurface.w = nr.width; onSurface.h = nr.height
|
|
835
|
+
groundY = nr.top
|
|
836
|
+
render()
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
} catch (e) { groundGone = true }
|
|
840
|
+
}
|
|
841
|
+
// 横向逃逸兜底:表面横移超出视口/宠物被 clamp 跟不上(碰撞体与表面完全分离)→ 掉落
|
|
842
|
+
if (!groundGone && !airborne && onSurface &&
|
|
843
|
+
(collX() + collW() < onSurface.x - 4 || collX() > onSurface.x + onSurface.w + 4)) {
|
|
844
|
+
onSurface = null
|
|
845
|
+
fallDown()
|
|
846
|
+
}
|
|
847
|
+
if (groundGone) {
|
|
848
|
+
onSurface = null
|
|
849
|
+
fallDown()
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
// 上界限位:脚底越过站立安全线(骑乘/站立被推到顶部只剩不到七成身体在屏内)→ 强制挤落;
|
|
853
|
+
// 与收集过滤同一安全线:任何路径都踩不进"顶面 < TOP_SAFE 的表面"
|
|
854
|
+
if (!airborne && BEHAVIOR !== 'drag' && feet() < TOP_SAFE) {
|
|
855
|
+
fallDown()
|
|
856
|
+
onSurface = null
|
|
857
|
+
}
|
|
858
|
+
// 吸引是"短暂反应":鼠标停在附近 1.5s 不动 → 解除吸引回 idle(拖动为主)
|
|
859
|
+
if (BEHAVIOR === 'attract' && now - lastMouseMoveTime > 1500) {
|
|
860
|
+
setBehavior('idle')
|
|
861
|
+
setAction('idle')
|
|
862
|
+
}
|
|
863
|
+
// 行走(地面水平移动,按 dt 换算真实时间;走出表面边缘 → 掉落)
|
|
864
|
+
if (BEHAVIOR === 'walk' && walkTargetX !== null && !airborne) {
|
|
865
|
+
var dx = walkTargetX - state.position.x
|
|
866
|
+
var step = clamp(dx, -1.6 * fdt, 1.6 * fdt)
|
|
867
|
+
state.position.x += step
|
|
868
|
+
if (Math.abs(dx) <= 1.6 * fdt) {
|
|
869
|
+
walkTargetX = null
|
|
870
|
+
setBehavior('idle')
|
|
871
|
+
setAction('idle')
|
|
872
|
+
} else if (onSurface &&
|
|
873
|
+
(collX() + collW() < onSurface.x - 2 || collX() > onSurface.x + onSurface.w - 2)) {
|
|
874
|
+
fallDown() // 碰撞体走出表面 → 摔下去
|
|
875
|
+
}
|
|
876
|
+
clampPosition()
|
|
877
|
+
}
|
|
878
|
+
gameMove(fdt) // 游戏模式:键盘方向移动/空中调向
|
|
879
|
+
physics(now)
|
|
880
|
+
render()
|
|
881
|
+
requestAnimationFrame(tick)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ---------- 吸引(简单方法:宠物组件内 mousemove 触发一次反应,防抖动=一次 hover 只触发一次)----------
|
|
885
|
+
var mouseOverPet = false
|
|
886
|
+
function onPetMouse(e) {
|
|
887
|
+
lastMouseMoveTime = Date.now()
|
|
888
|
+
if (BEHAVIOR === 'drag' || airborne || BEHAVIOR === 'jump') return
|
|
889
|
+
if (mouseOverPet) return // 防抖动:已在宠物上时不重复触发
|
|
890
|
+
mouseOverPet = true
|
|
891
|
+
lastMouseEvent = e
|
|
892
|
+
// 吸引反应:朝鼠标方向短暂靠近一点(一次而已),随后回 idle
|
|
893
|
+
var cx = state.position.x + frameWidth() / 2
|
|
894
|
+
var ddir = (e.clientX - frameWidth() / 2) - state.position.x
|
|
895
|
+
setBehavior('attract')
|
|
896
|
+
state.faceLeft = ddir < 0
|
|
897
|
+
setAction(ddir >= 0 ? 'runRight' : 'runLeft')
|
|
898
|
+
state.position.x += clamp(ddir, -3, 3)
|
|
899
|
+
clampPosition()
|
|
900
|
+
render()
|
|
901
|
+
setTimeout(function () {
|
|
902
|
+
if (BEHAVIOR === 'attract') { setBehavior('idle'); setAction('idle'); render() }
|
|
903
|
+
}, 450)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ---------- 拖拽 ----------
|
|
907
|
+
function startDrag(e) {
|
|
908
|
+
e.stopPropagation()
|
|
909
|
+
setBehavior('drag')
|
|
910
|
+
state.dragMoved = false
|
|
911
|
+
state.dragStartX = state.position.x
|
|
912
|
+
state.dragStartY = state.position.y
|
|
913
|
+
var clientX, clientY
|
|
914
|
+
if (e.touches) { clientX = e.touches[0].clientX; clientY = e.touches[0].clientY }
|
|
915
|
+
else { clientX = e.clientX; clientY = e.clientY }
|
|
916
|
+
window.__petDomDragOffset = { x: clientX - state.position.x, y: clientY - state.position.y }
|
|
917
|
+
document.addEventListener('mousemove', onDrag)
|
|
918
|
+
document.addEventListener('mouseup', stopDrag)
|
|
919
|
+
document.addEventListener('touchmove', onDrag)
|
|
920
|
+
document.addEventListener('touchend', stopDrag)
|
|
921
|
+
setAction('active')
|
|
922
|
+
render()
|
|
923
|
+
}
|
|
924
|
+
function onDrag(e) {
|
|
925
|
+
if (BEHAVIOR !== 'drag') return
|
|
926
|
+
var clientX, clientY
|
|
927
|
+
if (e.touches) { clientX = e.touches[0].clientX; clientY = e.touches[0].clientY }
|
|
928
|
+
else { clientX = e.clientX; clientY = e.clientY }
|
|
929
|
+
var off = window.__petDomDragOffset
|
|
930
|
+
var nx = clientX - off.x, ny = clientY - off.y
|
|
931
|
+
state.position.x = clamp(nx, 0, window.innerWidth - frameWidth())
|
|
932
|
+
state.position.y = clamp(ny, 0, window.innerHeight - frameHeight())
|
|
933
|
+
if (Math.abs(state.position.x - state.dragStartX) > 4 || Math.abs(state.position.y - state.dragStartY) > 4) state.dragMoved = true
|
|
934
|
+
savePosition()
|
|
935
|
+
render()
|
|
936
|
+
}
|
|
937
|
+
function stopDrag() {
|
|
938
|
+
document.removeEventListener('mousemove', onDrag)
|
|
939
|
+
document.removeEventListener('mouseup', stopDrag)
|
|
940
|
+
document.removeEventListener('touchmove', onDrag)
|
|
941
|
+
document.removeEventListener('touchend', stopDrag)
|
|
942
|
+
// 先置腾空再恢复动作(否则 setAction 的"地面对齐"会把位置瞬间拉回地面)
|
|
943
|
+
airborne = true
|
|
944
|
+
vy = 0.6
|
|
945
|
+
vx = 0
|
|
946
|
+
setBehavior('idle')
|
|
947
|
+
setAction('idle')
|
|
948
|
+
// 松手后从释放点自然坠落,落到下方最近表面/窗口底
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ---------- 启动 ----------
|
|
952
|
+
function init() {
|
|
953
|
+
var saved = readPosition()
|
|
954
|
+
if (saved) {
|
|
955
|
+
state.position = saved
|
|
956
|
+
} else {
|
|
957
|
+
// 默认:浏览器窗口最底下
|
|
958
|
+
state.position = { x: window.innerWidth / 2 - frameWidth() / 2, y: window.innerHeight - frameHeight() - 2 }
|
|
959
|
+
}
|
|
960
|
+
groundY = window.innerHeight
|
|
961
|
+
onSurface = null
|
|
962
|
+
airborne = false
|
|
963
|
+
clampPosition()
|
|
964
|
+
collectSurfaces(true)
|
|
965
|
+
setInterval(collectSurfaces, SURFACE_SCAN_MS)
|
|
966
|
+
setInterval(decideNext, MOVE_INTERVAL)
|
|
967
|
+
window.addEventListener('resize', function () { collectSurfaces(true); clampPosition(); render() })
|
|
968
|
+
// 滚动节流刷新(capture 捕获所有滚动容器):新表面/高亮跟手,不等 1200ms 扫描
|
|
969
|
+
var scrollFlush = 0
|
|
970
|
+
window.addEventListener('scroll', function () {
|
|
971
|
+
var now2 = Date.now()
|
|
972
|
+
if (now2 - scrollFlush < 300) return
|
|
973
|
+
scrollFlush = now2
|
|
974
|
+
collectSurfaces(true)
|
|
975
|
+
if (highlightOn) applyHighlight()
|
|
976
|
+
}, { capture: true, passive: true })
|
|
977
|
+
// 控制台(右上角悬浮面板;双击宠物唤回)
|
|
978
|
+
petConsole = createPetConsole({
|
|
979
|
+
onMode: function (m) { setGameMode(m) },
|
|
980
|
+
onHighlight: function (on, color) { setHighlight(on, color) },
|
|
981
|
+
onTheme: function (color) { if (highlightOn) setHighlight(true, color) },
|
|
982
|
+
onDedupGap: function (px) { setDedupGap(px) },
|
|
983
|
+
onHighlightAlpha: function (v) { setHighlightAlpha(v) },
|
|
984
|
+
onDisableUpDown: function (v) { setDisableUpDown(v) }
|
|
985
|
+
})
|
|
986
|
+
window.addEventListener('keydown', onKeyDown)
|
|
987
|
+
window.addEventListener('keyup', onKeyUp)
|
|
988
|
+
window.addEventListener('blur', function () { gameDir = 0 })
|
|
989
|
+
el.addEventListener('dblclick', function () { if (petConsole && petConsole.show) petConsole.show() })
|
|
990
|
+
el.addEventListener('mousemove', onPetMouse)
|
|
991
|
+
el.addEventListener('mouseleave', function () { mouseOverPet = false })
|
|
992
|
+
el.addEventListener('mousedown', startDrag)
|
|
993
|
+
el.addEventListener('touchstart', startDrag)
|
|
994
|
+
el.addEventListener('click', function (e) { e.stopPropagation() })
|
|
995
|
+
render()
|
|
996
|
+
requestAnimationFrame(tick)
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ---------- 测试/开发控制 API ----------
|
|
1000
|
+
window.__petDom = {
|
|
1001
|
+
forceAction: function (name) {
|
|
1002
|
+
if (!ACTIONS[name]) return { ok: false, action: state.currentAction }
|
|
1003
|
+
airborne = false; vy = 0; vx = 0
|
|
1004
|
+
setBehavior('idle'); setAction(name); render()
|
|
1005
|
+
return { ok: true, action: name }
|
|
1006
|
+
},
|
|
1007
|
+
info: function () {
|
|
1008
|
+
return {
|
|
1009
|
+
action: state.currentAction, behavior: BEHAVIOR,
|
|
1010
|
+
x: Math.round(state.position.x), y: Math.round(state.position.y),
|
|
1011
|
+
groundY: Math.round(groundY), airborne: airborne,
|
|
1012
|
+
surfaces: surfaces.length, onSurface: onSurface ? Math.round(onSurface.y) : null,
|
|
1013
|
+
coll: { x: Math.round(collX()), y: Math.round(state.position.y + frameHeight() - frameHeight() * 0.92), w: Math.round(collW()), h: Math.round(frameHeight() * 0.92) },
|
|
1014
|
+
onEl: onSurface ? (onSurface.el ? ((onSurface.el.className + '').slice(0, 24) + '#' + (onSurface.el.tagName || '')) : 'NOEL') : null
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
jumpTo: function (i) { collectSurfaces(true); if (surfaces[i]) jumpOnto(surfaces[i]); return { surfaces: surfaces.length } },
|
|
1018
|
+
jumpInspect: function (i) { collectSurfaces(true); if (surfaces[i]) jumpUpInspect(surfaces[i]); return { surfaces: surfaces.length } },
|
|
1019
|
+
fall: function () { fallDown() },
|
|
1020
|
+
walk: function () { if (!airborne) startWalk() },
|
|
1021
|
+
shake: function () { if (!airborne) startShake() },
|
|
1022
|
+
surfaces: function () {
|
|
1023
|
+
collectSurfaces(true)
|
|
1024
|
+
return surfaces.map(function (s) { return { x: Math.round(s.x), y: Math.round(s.y), w: Math.round(s.w), h: Math.round(s.h) } })
|
|
1025
|
+
},
|
|
1026
|
+
// —— P2 大脑调试 ——
|
|
1027
|
+
world: function () { collectSurfaces(true); return brainWorld() },
|
|
1028
|
+
rays: function () {
|
|
1029
|
+
collectSurfaces(true)
|
|
1030
|
+
var from = { x: collX() + collW() / 2, y: state.position.y + frameHeight() }
|
|
1031
|
+
var w = brainWorld()
|
|
1032
|
+
return {
|
|
1033
|
+
from: { x: Math.round(from.x), y: Math.round(from.y) },
|
|
1034
|
+
ahead: raycast(w, from, 'ahead', 700) ? { id: raycast(w, from, 'ahead', 700).id, x: Math.round(raycast(w, from, 'ahead', 700).x), y: Math.round(raycast(w, from, 'ahead', 700).y) } : null,
|
|
1035
|
+
up: raycast(w, from, 'up', 500) ? { id: raycast(w, from, 'up', 500).id, x: Math.round(raycast(w, from, 'up', 500).x), y: Math.round(raycast(w, from, 'up', 500).y) } : null,
|
|
1036
|
+
down: raycast(w, from, 'down', 300) ? { id: raycast(w, from, 'down', 300).id, x: Math.round(raycast(w, from, 'down', 300).x), y: Math.round(raycast(w, from, 'down', 300).y) } : null
|
|
1037
|
+
}
|
|
1038
|
+
},
|
|
1039
|
+
route: function () { return { goal: currentGoal, route: currentRoute } },
|
|
1040
|
+
decision: function () { return lastDecision },
|
|
1041
|
+
setSeed: function (n) { setSeed(n); return n === undefined ? 'seed=off' : 'seed=' + n },
|
|
1042
|
+
planNow: function () { decideNext(); return lastDecision },
|
|
1043
|
+
// —— 控制台/游戏模式 ——
|
|
1044
|
+
gameMode: function () { return GAME_MODE },
|
|
1045
|
+
setGameMode: function (m) { setGameMode(m); return GAME_MODE },
|
|
1046
|
+
consoleShow: function () { if (petConsole && petConsole.show) petConsole.show() },
|
|
1047
|
+
setHighlight: function (on, color) { setHighlight(on, color); return highlightOn }
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
init()
|