dsh-screen-beyond 0.0.0-stage → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -3
- package/cordis.patch.yml +6 -0
- package/examples/CREDITS.md +17 -0
- package/examples/dafeiyu-ai-avatar.png +0 -0
- package/examples/dafeiyu-ai-candidate-2.png +0 -0
- package/examples/dafeiyu-avatar-cc-by-nc-sa.png +0 -0
- package/examples/dafeiyu-avatar-preview.png +0 -0
- package/examples/dafeiyu-character-card.json +24 -0
- package/lib/client.js +235 -0
- package/lib/index.js +149 -0
- package/package.json +47 -3
- package/runtime/chat.html +215 -0
- package/runtime/config.example.json +93 -0
- package/runtime/env/README.md +14 -0
- package/runtime/env/agent_ssh.py +73 -0
- package/runtime/env/installers/ADBKeyboard.apk +0 -0
- package/runtime/env/phone_ctl.py +244 -0
- package/runtime/env/space_ctl.py +163 -0
- package/runtime/env/space_ensure.py +65 -0
- package/runtime/env/start_screen.ps1 +16 -0
- package/runtime/env/start_space.ps1 +33 -0
- package/runtime/env/stop_screen.ps1 +10 -0
- package/runtime/example-character.json +24 -0
- package/runtime/map.html +784 -0
- package/runtime/memory.mjs +205 -0
- package/runtime/model.html +363 -0
- package/runtime/models/chat-mway331n.png +0 -0
- package/runtime/models/dafeiyu-chibi.gltf +1 -0
- package/runtime/models/demo-town.gltf +1 -0
- package/runtime/models/demo-town2.gltf +1 -0
- package/runtime/models/duck.glb +0 -0
- package/runtime/models/map-map-mwb087zu-mwb0880c.png +0 -0
- package/runtime/models/map-map-mwb0bfsb-mwb0bft6.png +0 -0
- package/runtime/models/map-map-mwb0c9iw-mwb0c9j2.png +0 -0
- package/runtime/models/map-map-mwb0d8cc-mwb0d8d2.png +0 -0
- package/runtime/models/map-map-mwb0fm53-mwb0fm59.png +0 -0
- package/runtime/persona.example.md +34 -0
- package/runtime/pet/default-avatar.png +0 -0
- package/runtime/pet/pet.example.json +49 -0
- package/runtime/pet/pet.ps1 +532 -0
- package/runtime/screen.html +125 -0
- package/runtime/server.mjs +2659 -0
- package/runtime/settings.html +1015 -0
- package/runtime/stage.html +195 -0
- package/runtime/theme.css +210 -0
- package/runtime/tutorial.md +116 -0
- package/runtime/vendor/addons/loaders/GLTFLoader.js +4663 -0
- package/runtime/vendor/addons/utils/BufferGeometryUtils.js +1375 -0
- package/runtime/vendor/three.module.min.js +6 -0
- package/runtime/wx.js +164 -0
- package/scripts/backup.ps1 +79 -0
- package/start-pure-chat.cmd +26 -0
- package/start-pure-chat.sh +4 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// 记忆系统:事实卡 + 遗忘/强化 + 每日日记
|
|
5
|
+
// 设计要点:
|
|
6
|
+
// - 核心记忆(pinned / strength>=core) 不衰减,永远在提示词里
|
|
7
|
+
// - 其它事实按半衰期逐渐减弱;提到一次就强化一次(越常提越难忘)
|
|
8
|
+
// - 弱到阈值以下进入"模糊"(不再注入,但保留在档案里,可被用户查阅/恢复)
|
|
9
|
+
// - 每天一篇纯文本日记,用户可读;只有用户主动给她看时她才读得到
|
|
10
|
+
export class MemoryStore {
|
|
11
|
+
constructor(cfg) {
|
|
12
|
+
this.dir = cfg.path;
|
|
13
|
+
this.diaryDir = cfg.diaryPath;
|
|
14
|
+
this.cfg = {
|
|
15
|
+
halfLifeDays: cfg.halfLifeDays ?? 5,
|
|
16
|
+
forgetThreshold: cfg.forgetThreshold ?? 0.12,
|
|
17
|
+
reinforceBoost: cfg.reinforceBoost ?? 0.22,
|
|
18
|
+
maxInjected: cfg.maxInjected ?? 8,
|
|
19
|
+
coreStrength: cfg.coreStrength ?? 0.85,
|
|
20
|
+
maxFactChars: cfg.maxFactChars ?? 90,
|
|
21
|
+
maxPromptChars: cfg.maxPromptChars ?? 900,
|
|
22
|
+
};
|
|
23
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
24
|
+
fs.mkdirSync(this.diaryDir, { recursive: true });
|
|
25
|
+
this.file = path.join(this.dir, 'facts.json');
|
|
26
|
+
this.facts = this.load();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
load() {
|
|
30
|
+
try { const a = JSON.parse(fs.readFileSync(this.file, 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
31
|
+
}
|
|
32
|
+
save() { fs.writeFileSync(this.file, JSON.stringify(this.facts, null, 1), 'utf8'); }
|
|
33
|
+
|
|
34
|
+
setParams(params = {}) {
|
|
35
|
+
for (const k of ['halfLifeDays', 'forgetThreshold', 'reinforceBoost', 'maxInjected', 'coreStrength', 'maxPromptChars']) {
|
|
36
|
+
if (params[k] !== undefined && Number.isFinite(Number(params[k]))) this.cfg[k] = Number(params[k]);
|
|
37
|
+
}
|
|
38
|
+
return this.cfg;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
nextId() { return this.facts.reduce((m, f) => Math.max(m, f.id || 0), 0) + 1; }
|
|
42
|
+
|
|
43
|
+
// 时间衰减:strength 按半衰期指数衰减;pinned 不衰减
|
|
44
|
+
decay(now = Date.now()) {
|
|
45
|
+
let changed = false;
|
|
46
|
+
for (const f of this.facts) {
|
|
47
|
+
if (f.pinned) { f.strength = 1; continue; }
|
|
48
|
+
const last = f.lastSeenAt || f.createdAt || now;
|
|
49
|
+
const days = (now - last) / 86400000;
|
|
50
|
+
if (days <= 0) continue;
|
|
51
|
+
const factor = Math.pow(0.5, days / this.cfg.halfLifeDays);
|
|
52
|
+
const next = Math.max(0, (f.strength ?? 0.5) * factor);
|
|
53
|
+
if (Math.abs(next - (f.strength ?? 0)) > 0.001) { f.strength = Number(next.toFixed(4)); changed = true; }
|
|
54
|
+
if (f.strength < this.cfg.forgetThreshold && !f.archived) { f.archived = true; changed = true; }
|
|
55
|
+
}
|
|
56
|
+
if (changed) this.save();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 写入/强化:相似事实合并强化,新事实新建
|
|
60
|
+
addFacts(items, now = Date.now()) {
|
|
61
|
+
if (!Array.isArray(items)) return [];
|
|
62
|
+
const added = [];
|
|
63
|
+
for (const it of items) {
|
|
64
|
+
const text = String(it?.text ?? '').trim().slice(0, this.cfg.maxFactChars);
|
|
65
|
+
if (!text) continue;
|
|
66
|
+
const tags = Array.isArray(it?.tags) ? it.tags.map((t) => String(t).slice(0, 12)).slice(0, 6) : [];
|
|
67
|
+
const importance = Math.max(0, Math.min(1, Number(it?.importance) || 0.5));
|
|
68
|
+
const existing = this.findSimilar(text);
|
|
69
|
+
if (existing) {
|
|
70
|
+
existing.hitCount = (existing.hitCount || 1) + 1;
|
|
71
|
+
existing.lastSeenAt = now;
|
|
72
|
+
existing.strength = Math.min(1, (existing.strength ?? 0.3) + this.cfg.reinforceBoost * (0.6 + importance * 0.8));
|
|
73
|
+
existing.importance = Math.max(existing.importance ?? 0, importance);
|
|
74
|
+
existing.archived = existing.strength >= this.cfg.forgetThreshold ? false : existing.archived;
|
|
75
|
+
for (const t of tags) if (!existing.tags.includes(t)) existing.tags.push(t);
|
|
76
|
+
added.push({ merged: true, id: existing.id, text: existing.text, strength: existing.strength });
|
|
77
|
+
} else {
|
|
78
|
+
const f = {
|
|
79
|
+
id: this.nextId(), text, tags,
|
|
80
|
+
importance,
|
|
81
|
+
strength: Math.min(1, 0.35 + importance * 0.5),
|
|
82
|
+
createdAt: now, lastSeenAt: now, hitCount: 1,
|
|
83
|
+
pinned: importance >= 0.95,
|
|
84
|
+
archived: false,
|
|
85
|
+
};
|
|
86
|
+
this.facts.push(f);
|
|
87
|
+
added.push({ merged: false, id: f.id, text: f.text, strength: f.strength });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
this.save();
|
|
91
|
+
return added;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 强化已有事实(检索命中/被使用时调用)
|
|
95
|
+
reinforce(id, now = Date.now()) {
|
|
96
|
+
const f = this.facts.find((x) => x.id === id);
|
|
97
|
+
if (!f) return;
|
|
98
|
+
f.hitCount = (f.hitCount || 1) + 1;
|
|
99
|
+
f.lastSeenAt = now;
|
|
100
|
+
f.strength = Math.min(1, (f.strength ?? 0.3) + this.cfg.reinforceBoost * 0.5);
|
|
101
|
+
if (f.archived && f.strength >= this.cfg.forgetThreshold) f.archived = false;
|
|
102
|
+
this.save();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
bigrams(s) {
|
|
106
|
+
const clean = String(s).toLowerCase().replace(/[\s,。!?、,.!?;;::""''()()【】\[\]]+/g, ' ');
|
|
107
|
+
const set = new Set();
|
|
108
|
+
for (const seg of clean.split(/\s+/)) {
|
|
109
|
+
if (!seg) continue;
|
|
110
|
+
if (seg.length === 1) { set.add(seg); continue; }
|
|
111
|
+
for (let i = 0; i < seg.length - 1; i++) set.add(seg.slice(i, i + 2));
|
|
112
|
+
}
|
|
113
|
+
return set;
|
|
114
|
+
}
|
|
115
|
+
similarity(a, b) {
|
|
116
|
+
const A = this.bigrams(a), B = this.bigrams(b);
|
|
117
|
+
if (!A.size || !B.size) return 0;
|
|
118
|
+
let inter = 0;
|
|
119
|
+
for (const x of A) if (B.has(x)) inter++;
|
|
120
|
+
return inter / Math.min(A.size, B.size);
|
|
121
|
+
}
|
|
122
|
+
findSimilar(text) {
|
|
123
|
+
let best = null, bestScore = 0;
|
|
124
|
+
for (const f of this.facts) {
|
|
125
|
+
if (f.archived) continue;
|
|
126
|
+
const s = this.similarity(text, f.text + ' ' + (f.tags || []).join(' '));
|
|
127
|
+
if (s > bestScore) { bestScore = s; best = f; }
|
|
128
|
+
}
|
|
129
|
+
return bestScore >= 0.55 ? best : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 检索:相关度 + 强度 + 新近度;核心记忆永远带上
|
|
133
|
+
retrieve(query, now = Date.now()) {
|
|
134
|
+
this.decay(now);
|
|
135
|
+
const core = this.facts.filter((f) => f.pinned || (f.strength ?? 0) >= this.cfg.coreStrength);
|
|
136
|
+
const rest = this.facts.filter((f) => !core.includes(f) && !f.archived);
|
|
137
|
+
const scored = [];
|
|
138
|
+
for (const f of rest) {
|
|
139
|
+
const rel = this.similarity(query, f.text + ' ' + (f.tags || []).join(' '));
|
|
140
|
+
const strength = f.strength ?? 0.3;
|
|
141
|
+
const ageDays = (now - (f.lastSeenAt || now)) / 86400000;
|
|
142
|
+
const recency = Math.max(0, 1 - ageDays / 30);
|
|
143
|
+
const score = rel * 0.55 + strength * 0.3 + recency * 0.15 + (f.importance || 0.5) * 0.05;
|
|
144
|
+
scored.push({ f, score, rel });
|
|
145
|
+
}
|
|
146
|
+
scored.sort((a, b) => b.score - a.score);
|
|
147
|
+
const picked = [];
|
|
148
|
+
const used = new Set();
|
|
149
|
+
for (const c of core) { used.add(c.id); picked.push(c); }
|
|
150
|
+
for (const s of scored) {
|
|
151
|
+
if (picked.length >= this.cfg.maxInjected) break;
|
|
152
|
+
if (used.has(s.f.id)) continue;
|
|
153
|
+
if (s.score < 0.28) break;
|
|
154
|
+
used.add(s.f.id);
|
|
155
|
+
picked.push(s.f);
|
|
156
|
+
}
|
|
157
|
+
for (const f of picked) this.reinforce(f.id, now);
|
|
158
|
+
return picked;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
promptBlock(query) {
|
|
162
|
+
const picked = this.retrieve(query);
|
|
163
|
+
if (!picked.length) return '';
|
|
164
|
+
const lines = [];
|
|
165
|
+
let chars = 0;
|
|
166
|
+
for (const f of picked) {
|
|
167
|
+
const s = (f.strength ?? 0.3);
|
|
168
|
+
const m = s >= 0.85 ? '很清楚' : s >= 0.6 ? '记得' : s >= 0.3 ? '有点印象' : '模糊记得';
|
|
169
|
+
const line = `- (${m})${f.text}`;
|
|
170
|
+
if (chars + line.length > this.cfg.maxPromptChars) break;
|
|
171
|
+
lines.push(line);
|
|
172
|
+
chars += line.length;
|
|
173
|
+
}
|
|
174
|
+
return lines.length ? '## 你记得的事(越清楚越重要,模糊的可能是快忘了)\n' + lines.join('\n') : '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
stats() {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
this.decay(now);
|
|
180
|
+
const live = this.facts.filter((f) => !f.archived);
|
|
181
|
+
const archived = this.facts.filter((f) => f.archived);
|
|
182
|
+
return {
|
|
183
|
+
total: this.facts.length,
|
|
184
|
+
live: live.length,
|
|
185
|
+
archived: archived.length,
|
|
186
|
+
core: this.facts.filter((f) => f.pinned || (f.strength ?? 0) >= this.cfg.coreStrength).length,
|
|
187
|
+
strongest: [...live].sort((a, b) => (b.strength ?? 0) - (a.strength ?? 0)).slice(0, 5).map((f) => ({ text: f.text, strength: f.strength })),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------- 日记 ----------
|
|
192
|
+
diaryPath(date) { return path.join(this.diaryDir, `${date}.md`); }
|
|
193
|
+
hasDiary(date) { return fs.existsSync(this.diaryPath(date)); }
|
|
194
|
+
readDiary(date) { try { return fs.readFileSync(this.diaryPath(date), 'utf8'); } catch { return ''; } }
|
|
195
|
+
writeDiary(date, text) {
|
|
196
|
+
fs.writeFileSync(this.diaryPath(date), String(text).trim() + '\n', 'utf8');
|
|
197
|
+
return this.diaryPath(date);
|
|
198
|
+
}
|
|
199
|
+
listDiaries(limit = 60) {
|
|
200
|
+
try {
|
|
201
|
+
return fs.readdirSync(this.diaryDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
|
|
202
|
+
.sort().reverse().slice(0, limit).map((f) => f.replace('.md', ''));
|
|
203
|
+
} catch { return []; }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>3D 舞台 · 屏幕彼端</title>
|
|
3
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4
|
+
<style>
|
|
5
|
+
html,body{margin:0;height:100%;background:#0b0f14;overflow:hidden;font:14px/1.6 "Segoe UI","Microsoft YaHei",sans-serif;color:#d7dee8}
|
|
6
|
+
#app{position:fixed;inset:0}
|
|
7
|
+
#hud{position:fixed;left:14px;top:12px;background:rgba(8,13,20,.78);border:1px solid #2a3745;border-radius:10px;padding:10px 14px;font-size:13px;line-height:1.8;backdrop-filter:blur(4px)}
|
|
8
|
+
#hud .name{font-weight:600;font-size:15px}
|
|
9
|
+
#hud .row{font-size:12px;color:#9fb3c8}
|
|
10
|
+
#hud a{color:#59a6ff;text-decoration:none;font-size:12px;margin-right:10px}
|
|
11
|
+
#hud .hint{font-size:11px;color:#5a6878;margin-top:4px}
|
|
12
|
+
#loading{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#0b0f14;color:#7f8c9b;font-size:13px;z-index:9}
|
|
13
|
+
</style>
|
|
14
|
+
<link rel="stylesheet" href="/theme.css"></head><body>
|
|
15
|
+
<div id="app"></div>
|
|
16
|
+
<div id="wxOverlay" style="position:fixed;inset:0;pointer-events:none;z-index:2"></div>
|
|
17
|
+
<div id="hud">
|
|
18
|
+
<div class="name" id="hName">…</div>
|
|
19
|
+
<div class="row" id="hWhere">…</div>
|
|
20
|
+
<div class="row" id="hStatus">…</div>
|
|
21
|
+
<div class="row" id="hMoney">…</div>
|
|
22
|
+
<div class="row"><a href="/">聊天</a><a href="/screen" target="_blank">屏幕</a><a href="/map">2D 地图</a><a href="/stage">2D 舞台</a><a href="/settings">设置</a></div>
|
|
23
|
+
<div class="hint">拖拽画面旋转 · 滚轮缩放 · 地图模型在「地图→地图设置→地图模型」里配</div>
|
|
24
|
+
</div>
|
|
25
|
+
<div id="loading">three.js 加载中…</div>
|
|
26
|
+
<script src="/wx.js"></script>
|
|
27
|
+
<script type="importmap">{"imports":{"three":"/vendor/three.module.min.js","three/addons/":"/vendor/addons/"}}</script>
|
|
28
|
+
<script>
|
|
29
|
+
window.addEventListener('error', (e) => { const el = document.getElementById('loading'); if (el) el.textContent = 'JS错误:' + (e.message || e.error); });
|
|
30
|
+
window.addEventListener('unhandledrejection', (e) => { const el = document.getElementById('loading'); if (el) el.textContent = 'Promise错误:' + (e.reason && e.reason.message ? e.reason.message : e.reason); });
|
|
31
|
+
</script>
|
|
32
|
+
<script type="module">
|
|
33
|
+
import * as THREE from 'three';
|
|
34
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
35
|
+
|
|
36
|
+
const app = document.getElementById('app');
|
|
37
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
38
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
39
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
40
|
+
renderer.shadowMap.enabled = true;
|
|
41
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
42
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
43
|
+
renderer.toneMappingExposure = 1.25;
|
|
44
|
+
app.appendChild(renderer.domElement);
|
|
45
|
+
const scene = new THREE.Scene();
|
|
46
|
+
scene.fog = new THREE.Fog(0x1a2c3a, 140, 320);
|
|
47
|
+
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 500);
|
|
48
|
+
|
|
49
|
+
const WORLD_W = 120, WORLD_H = 77; // 世界尺寸(对应地图归一化坐标)
|
|
50
|
+
function toWorld(x, y) { return new THREE.Vector3((x - 0.5) * WORLD_W, 0, (y - 0.5) * WORLD_H); }
|
|
51
|
+
|
|
52
|
+
// 灯光
|
|
53
|
+
const hemi = new THREE.HemisphereLight(0xdceeff, 0x2a3a2a, 1.0); scene.add(hemi);
|
|
54
|
+
const dir = new THREE.DirectionalLight(0xfff4e0, 2.3);
|
|
55
|
+
dir.position.set(40, 70, 25);
|
|
56
|
+
dir.castShadow = true;
|
|
57
|
+
dir.shadow.mapSize.set(2048, 2048);
|
|
58
|
+
dir.shadow.camera.left = -90; dir.shadow.camera.right = 90;
|
|
59
|
+
dir.shadow.camera.top = 90; dir.shadow.camera.bottom = -90;
|
|
60
|
+
dir.shadow.camera.near = 1; dir.shadow.camera.far = 220;
|
|
61
|
+
dir.shadow.bias = -0.0005;
|
|
62
|
+
scene.add(dir);
|
|
63
|
+
const ambient = new THREE.AmbientLight(0x445566, 0.65); scene.add(ambient);
|
|
64
|
+
|
|
65
|
+
// 地面 + 主题
|
|
66
|
+
const themeColors = { town: 0x1d3a33, coast: 0x14425c, city: 0x22303c, forest: 0x1b3325 };
|
|
67
|
+
let ground, grid;
|
|
68
|
+
function buildGround(theme, imageUrl) {
|
|
69
|
+
if (ground) { scene.remove(ground); ground.geometry.dispose(); ground.material.dispose(); ground = null; }
|
|
70
|
+
const g = new THREE.PlaneGeometry(WORLD_W, WORLD_H);
|
|
71
|
+
const m = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.95, metalness: 0 });
|
|
72
|
+
if (imageUrl) {
|
|
73
|
+
const tex = new THREE.TextureLoader().load(imageUrl);
|
|
74
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
75
|
+
m.map = tex; m.color.set(0xffffff);
|
|
76
|
+
} else {
|
|
77
|
+
m.color.set(themeColors[theme] || themeColors.town);
|
|
78
|
+
}
|
|
79
|
+
ground = new THREE.Mesh(g, m); ground.rotation.x = -Math.PI / 2; ground.position.y = -0.01; ground.receiveShadow = true; scene.add(ground);
|
|
80
|
+
const sky = { town: 0x0d1a24, coast: 0x0a1c2c, city: 0x0d1218, forest: 0x0b1812 }[theme] || 0x0d1a24;
|
|
81
|
+
scene.background = new THREE.Color(sky);
|
|
82
|
+
scene.fog.color.set(sky);
|
|
83
|
+
if (!grid) {
|
|
84
|
+
grid = new THREE.GridHelper(WORLD_W, 24, 0x2a3a44, 0x22303a);
|
|
85
|
+
grid.position.y = 0; scene.add(grid);
|
|
86
|
+
}
|
|
87
|
+
// 道路(简化十字路)
|
|
88
|
+
for (const r of roadMeshes || []) scene.remove(r);
|
|
89
|
+
roadMeshes = [];
|
|
90
|
+
const roadMat = new THREE.MeshStandardMaterial({ color: 0x2a3a44, roughness: 0.9 });
|
|
91
|
+
const road1 = new THREE.Mesh(new THREE.PlaneGeometry(WORLD_W, 7), roadMat);
|
|
92
|
+
road1.rotation.x = -Math.PI / 2; road1.rotation.z = 0.05; road1.position.set(0, 0.01, 0); scene.add(road1);
|
|
93
|
+
const road2 = new THREE.Mesh(new THREE.PlaneGeometry(6, WORLD_H), roadMat);
|
|
94
|
+
road2.rotation.x = -Math.PI / 2; road2.position.set(0, 0.01, 6); scene.add(road2);
|
|
95
|
+
roadMeshes.push(road1, road2);
|
|
96
|
+
}
|
|
97
|
+
let roadMeshes = [];
|
|
98
|
+
|
|
99
|
+
// 地点标记(小圆柱 + 文字精灵)
|
|
100
|
+
let markerGroup = new THREE.Group(); scene.add(markerGroup);
|
|
101
|
+
function labelSprite(text, icon) {
|
|
102
|
+
const c = document.createElement('canvas'); c.width = 256; c.height = 128;
|
|
103
|
+
const ctx = c.getContext('2d');
|
|
104
|
+
ctx.fillStyle = 'rgba(10,17,24,0.85)'; roundRect(ctx, 16, 30, 224, 68, 16); ctx.fill();
|
|
105
|
+
ctx.strokeStyle = '#2f6bd8'; ctx.lineWidth = 4; roundRect(ctx, 16, 30, 224, 68, 16); ctx.stroke();
|
|
106
|
+
ctx.fillStyle = '#d7e3ee'; ctx.font = 'bold 40px "Microsoft YaHei", sans-serif'; ctx.textAlign = 'center';
|
|
107
|
+
ctx.fillText((icon || '') + ' ' + text, 128, 78);
|
|
108
|
+
const tex = new THREE.CanvasTexture(c); tex.needsUpdate = true;
|
|
109
|
+
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false, fog: false });
|
|
110
|
+
const sp = new THREE.Sprite(mat); sp.scale.set(13, 6.5, 1); return sp;
|
|
111
|
+
}
|
|
112
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
113
|
+
ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
114
|
+
ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
|
|
115
|
+
}
|
|
116
|
+
let placesKey = '';
|
|
117
|
+
function buildPlaces(places) {
|
|
118
|
+
const key = JSON.stringify(places.map(p => [p.id, p.x, p.y, p.name, p.icon]));
|
|
119
|
+
if (key === placesKey) return;
|
|
120
|
+
placesKey = key;
|
|
121
|
+
markerGroup.clear();
|
|
122
|
+
for (const p of places) {
|
|
123
|
+
const v = toWorld(p.x, p.y);
|
|
124
|
+
if (p.image) {
|
|
125
|
+
const tex = new THREE.TextureLoader().load(p.image);
|
|
126
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
127
|
+
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, fog: false }));
|
|
128
|
+
sp.scale.set(7, 7, 1); sp.position.set(v.x, 4.5, v.z); markerGroup.add(sp);
|
|
129
|
+
} else {
|
|
130
|
+
const mat = new THREE.MeshStandardMaterial({ color: 0x2f6bd8, emissive: 0x123056, roughness: .6 });
|
|
131
|
+
const pin = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 1.0, 2.4, 16), mat);
|
|
132
|
+
pin.position.set(v.x, 1.2, v.z); markerGroup.add(pin);
|
|
133
|
+
}
|
|
134
|
+
const label = labelSprite(p.name, p.image ? '' : p.icon); label.position.set(v.x, 8, v.z); markerGroup.add(label);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 角色
|
|
139
|
+
let charGroup = new THREE.Group(); scene.add(charGroup);
|
|
140
|
+
let charKind = '', charUrl = '', mixer = null, charModel = null;
|
|
141
|
+
let spriteTex = null, spritePlane = null, fallbackBody = null, shadowBlob = null;
|
|
142
|
+
function clearChar() {
|
|
143
|
+
charGroup.clear();
|
|
144
|
+
if (mixer) { mixer.stopAllAction(); mixer = null; }
|
|
145
|
+
if (spriteTex) { spriteTex.dispose(); spriteTex = null; }
|
|
146
|
+
spritePlane = null; charModel = null; fallbackBody = null;
|
|
147
|
+
}
|
|
148
|
+
async function buildChar(character) {
|
|
149
|
+
const model = character.model || { type: 'none', url: '', scale: 1 };
|
|
150
|
+
const kindKey = model.type + '|' + (model.url || '') + '|' + (model.scale || 1);
|
|
151
|
+
if (kindKey === charKind) return;
|
|
152
|
+
charKind = kindKey;
|
|
153
|
+
clearChar();
|
|
154
|
+
const scale = Number(model.scale) || 1;
|
|
155
|
+
const loader = new THREE.TextureLoader();
|
|
156
|
+
const makeSprite = (url, height = 20) => new Promise((resolve) => {
|
|
157
|
+
loader.load(url, (tex) => {
|
|
158
|
+
spriteTex = tex;
|
|
159
|
+
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, fog: false });
|
|
160
|
+
const sp = new THREE.Sprite(mat);
|
|
161
|
+
const aspect = (tex.image && tex.image.width && tex.image.height) ? tex.image.width / tex.image.height : 1;
|
|
162
|
+
sp.scale.set(height * aspect, height, 1);
|
|
163
|
+
charGroup.add(sp); spritePlane = sp; resolve();
|
|
164
|
+
}, undefined, () => resolve());
|
|
165
|
+
});
|
|
166
|
+
const makeFallback = () => {
|
|
167
|
+
fallbackBody = new THREE.Group();
|
|
168
|
+
const body = new THREE.Mesh(new THREE.CapsuleGeometry(1.4, 4, 6, 12), new THREE.MeshStandardMaterial({ color: 0x5f8fe8, roughness: .5 }));
|
|
169
|
+
body.position.y = 3.4; fallbackBody.add(body);
|
|
170
|
+
const head = new THREE.Mesh(new THREE.SphereGeometry(1.5, 20, 16), new THREE.MeshStandardMaterial({ color: 0xffe9dc, roughness: .6 }));
|
|
171
|
+
head.position.y = 7.2; fallbackBody.add(head);
|
|
172
|
+
charGroup.add(fallbackBody);
|
|
173
|
+
};
|
|
174
|
+
const nameTag = labelSprite(character.name || '她', '');
|
|
175
|
+
nameTag.scale.set(11, 5.5, 1);
|
|
176
|
+
nameTag.position.set(0, 26, 0);
|
|
177
|
+
charGroup.add(nameTag);
|
|
178
|
+
const blob = new THREE.Mesh(new THREE.CircleGeometry(3.2, 24), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.32 }));
|
|
179
|
+
blob.rotation.x = -Math.PI / 2; blob.position.y = 0.02; charGroup.add(blob); shadowBlob = blob;
|
|
180
|
+
const ring = new THREE.Mesh(new THREE.RingGeometry(3.4, 4.2, 32), new THREE.MeshBasicMaterial({ color: 0x59a6ff, transparent: true, opacity: 0.7, side: THREE.DoubleSide }));
|
|
181
|
+
ring.rotation.x = -Math.PI / 2; ring.position.y = 0.05; charGroup.add(ring);
|
|
182
|
+
if (model.type === '3d' && model.url) {
|
|
183
|
+
try {
|
|
184
|
+
const gltf = await new GLTFLoader().loadAsync(model.url);
|
|
185
|
+
gltf.scene.traverse((o) => { if (o.isMesh) o.castShadow = true; });
|
|
186
|
+
charModel = gltf.scene;
|
|
187
|
+
charModel.scale.setScalar(scale);
|
|
188
|
+
const box = new THREE.Box3().setFromObject(charModel);
|
|
189
|
+
charModel.position.y = -box.min.y;
|
|
190
|
+
charGroup.add(charModel);
|
|
191
|
+
if (gltf.animations && gltf.animations.length) { mixer = new THREE.AnimationMixer(charModel); mixer.clipAction(gltf.animations[0]).play(); }
|
|
192
|
+
} catch (e) { makeFallback(); }
|
|
193
|
+
} else if ((model.type === 'sprite' || model.type === 'live2d') && model.url) {
|
|
194
|
+
await makeSprite(model.url, 20 * scale);
|
|
195
|
+
if (!spritePlane) makeFallback();
|
|
196
|
+
} else if (character.avatar) {
|
|
197
|
+
await makeSprite(character.avatar, 20 * scale);
|
|
198
|
+
if (!spritePlane) makeFallback();
|
|
199
|
+
} else {
|
|
200
|
+
makeFallback();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 路径线
|
|
205
|
+
let routeLine = null;
|
|
206
|
+
function drawPath(path) {
|
|
207
|
+
if (routeLine) { scene.remove(routeLine); routeLine.geometry.dispose(); routeLine = null; }
|
|
208
|
+
if (!path || path.length < 2) return;
|
|
209
|
+
const pts = path.map(p => { const v = toWorld(p.x, p.y); v.y = 0.3; return v; });
|
|
210
|
+
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
|
211
|
+
const mat = new THREE.LineDashedMaterial({ color: 0x7db7ff, dashSize: 2.4, gapSize: 1.2, linewidth: 3 });
|
|
212
|
+
routeLine = new THREE.Line(geo, mat); routeLine.computeLineDistances(); scene.add(routeLine);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 状态轮询
|
|
216
|
+
let stage = null;
|
|
217
|
+
async function poll() {
|
|
218
|
+
try { const r = await fetch('/api/stage'); stage = await r.json(); apply(); }
|
|
219
|
+
catch (e) { const el = document.getElementById('loading'); if (el) el.textContent = 'stage 拉取失败:' + e.message; }
|
|
220
|
+
}
|
|
221
|
+
function characterWorldPos() {
|
|
222
|
+
if (!stage) return new THREE.Vector3();
|
|
223
|
+
const loc = stage.location;
|
|
224
|
+
if (loc.state === 'stationary') {
|
|
225
|
+
const p = stage.map.places.find(x => x.id === loc.placeId) || { x: 0.5, y: 0.5 };
|
|
226
|
+
return toWorld(p.x, p.y);
|
|
227
|
+
}
|
|
228
|
+
if (loc.state === 'traveling' && loc.traveling && loc.traveling.path && loc.traveling.path.length > 1) {
|
|
229
|
+
const t = loc.traveling;
|
|
230
|
+
const path = t.path;
|
|
231
|
+
const prog = Math.min(1, Math.max(0, (Date.now() - t.startedAt) / Math.max(1, t.durationMs)));
|
|
232
|
+
let total = 0; const segs = [];
|
|
233
|
+
for (let i = 0; i < path.length - 1; i++) { const d = Math.hypot(path[i].x - path[i + 1].x, path[i].y - path[i + 1].y); segs.push(d); total += d; }
|
|
234
|
+
let target = total * prog;
|
|
235
|
+
for (let i = 0; i < segs.length; i++) {
|
|
236
|
+
if (target <= segs[i] || i === segs.length - 1) {
|
|
237
|
+
const r = segs[i] ? target / segs[i] : 0;
|
|
238
|
+
const nx = path[i].x + (path[i + 1].x - path[i].x) * Math.min(1, Math.max(0, r));
|
|
239
|
+
const ny = path[i].y + (path[i + 1].y - path[i].y) * Math.min(1, Math.max(0, r));
|
|
240
|
+
return toWorld(nx, ny);
|
|
241
|
+
}
|
|
242
|
+
target -= segs[i];
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return toWorld(0.5, 0.2);
|
|
246
|
+
}
|
|
247
|
+
function apply() {
|
|
248
|
+
if (!stage) return;
|
|
249
|
+
const loc = stage.location, ch = stage.character;
|
|
250
|
+
document.getElementById('loading').style.display = 'none';
|
|
251
|
+
document.getElementById('hName').textContent = ch.name || '她';
|
|
252
|
+
const where = loc.state === 'intercity' ? ('前往 ' + (loc.intercity && loc.intercity.toMapName))
|
|
253
|
+
: loc.state === 'traveling' ? ('去 ' + (loc.traveling && loc.traveling.toName) + ' 的路上')
|
|
254
|
+
: (loc.icon + ' ' + loc.placeName + ' · ' + stage.map.name);
|
|
255
|
+
document.getElementById('hWhere').textContent = where + ' · ' + stage.weather.icon + stage.weather.label + (stage.weather.temp !== null && stage.weather.temp !== undefined ? ' ' + stage.weather.temp + '°C' : '');
|
|
256
|
+
const wm2 = stage.wallet;
|
|
257
|
+
const hm = document.getElementById('hMoney');
|
|
258
|
+
if (hm) hm.textContent = wm2 ? ('💰 ' + (wm2.spendable !== undefined ? wm2.spendable : wm2.balance) + ' 元 · 🪙 剩余 ' + Number(wm2.remainingTokens || 0).toLocaleString() + ' token') : '';
|
|
259
|
+
document.getElementById('hStatus').textContent = '体力 ' + stage.energy + ' · ' + stage.animationHint
|
|
260
|
+
+ (loc.state === 'traveling' ? ' · ' + loc.traveling.modeLabel + ' · 剩 ' + Math.max(1, Math.ceil(loc.traveling.etaSec / 60)) + ' 分钟' : '');
|
|
261
|
+
const phase = (function(h){ if (h>=5&&h<7) return 'dawn'; if (h>=7&&h<17) return 'day'; if (h>=17&&h<19.5) return 'dusk'; return 'night'; })(new Date().getHours() + new Date().getMinutes()/60);
|
|
262
|
+
const wxKind = (stage.weather && stage.weather.kind) || 'sunny';
|
|
263
|
+
if (window.CompanionWeatherFx) CompanionWeatherFx.set(document.getElementById('wxOverlay'), wxKind, (stage.weather && stage.weather.fx) || {});
|
|
264
|
+
let wxSky = { rain: 0x1a2531, snow: 0x2a3640, sunny: 0x0d1a24, hot: 0x2a1a12, cold: 0x16222e, windy: 0x19232c, cloudy: 0x141d26 }[wxKind] || 0x0d1a24;
|
|
265
|
+
const todSky = { dawn: 0x3a2a3a, day: 0x0d1a24, dusk: 0x2a1a24, night: 0x05070f }[phase];
|
|
266
|
+
if (phase === 'night') wxSky = 0x05070f;
|
|
267
|
+
else if (phase === 'dawn' || phase === 'dusk') wxSky = new THREE.Color(wxSky).lerp(new THREE.Color(todSky), 0.45).getHex();
|
|
268
|
+
scene.background = new THREE.Color(wxSky);
|
|
269
|
+
scene.fog.color.set(wxSky);
|
|
270
|
+
const dayFactor = phase === 'day' ? 1 : phase === 'dawn' || phase === 'dusk' ? 0.7 : 0.35;
|
|
271
|
+
hemi.intensity = phase === 'day' ? 1.0 : phase === 'night' ? 0.35 : 0.68;
|
|
272
|
+
ambient.intensity = phase === 'day' ? 0.65 : phase === 'night' ? 0.22 : 0.45;
|
|
273
|
+
dir.intensity = (wxKind === 'rain' || wxKind === 'snow' ? 1.5 : (wxKind === 'sunny' ? 2.4 : 2.0)) * dayFactor;
|
|
274
|
+
buildGround(stage.map.theme, stage.map.image && stage.map.image.url ? stage.map.image.url : '');
|
|
275
|
+
buildEnv(stage.map);
|
|
276
|
+
buildPlaces(stage.map.places);
|
|
277
|
+
buildChar(ch).catch(() => {});
|
|
278
|
+
drawPath(loc.state === 'traveling' && loc.traveling ? loc.traveling.path : null);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 地图模型(3D 地图导入)
|
|
282
|
+
let envGroup = new THREE.Group(); scene.add(envGroup);
|
|
283
|
+
let envKey = '';
|
|
284
|
+
async function buildEnv(map) {
|
|
285
|
+
const model = map.model;
|
|
286
|
+
const key = model && model.url ? (model.url + '|' + (model.scale || 1) + '|' + (model.rotationY || 0) + '|' + (model.yOffset || 0)) : 'proc|' + (map.theme || 'town');
|
|
287
|
+
if (key === envKey) return;
|
|
288
|
+
envKey = key;
|
|
289
|
+
envGroup.clear();
|
|
290
|
+
const hideProcedural = !!(model && model.url);
|
|
291
|
+
if (ground) ground.visible = !hideProcedural;
|
|
292
|
+
if (grid) grid.visible = !hideProcedural;
|
|
293
|
+
for (const r of roadMeshes || []) r.visible = !hideProcedural;
|
|
294
|
+
if (!hideProcedural) return;
|
|
295
|
+
try {
|
|
296
|
+
const gltf = await new GLTFLoader().loadAsync(model.url);
|
|
297
|
+
const obj = gltf.scene;
|
|
298
|
+
obj.scale.setScalar(Number(model.scale) || 1);
|
|
299
|
+
obj.rotation.y = (Number(model.rotationY) || 0) * Math.PI / 180;
|
|
300
|
+
obj.position.y = Number(model.yOffset) || 0;
|
|
301
|
+
obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
|
|
302
|
+
envGroup.add(obj);
|
|
303
|
+
} catch (e) {
|
|
304
|
+
document.getElementById('hStatus').textContent = '地图模型加载失败:' + (e && e.message ? e.message : e);
|
|
305
|
+
if (ground) ground.visible = true;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 相机:跟随 + 拖拽旋转 + 滚轮缩放
|
|
310
|
+
let camTarget = new THREE.Vector3(0, 0, 0);
|
|
311
|
+
let yaw = 0.9, pitch = 0.72, dist = 105;
|
|
312
|
+
let dragging = false, lastX = 0, lastY = 0;
|
|
313
|
+
renderer.domElement.addEventListener('pointerdown', (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; });
|
|
314
|
+
window.addEventListener('pointerup', () => { dragging = false; });
|
|
315
|
+
window.addEventListener('pointermove', (e) => {
|
|
316
|
+
if (!dragging) return;
|
|
317
|
+
yaw -= (e.clientX - lastX) * 0.005;
|
|
318
|
+
pitch = Math.min(1.35, Math.max(0.18, pitch - (e.clientY - lastY) * 0.004));
|
|
319
|
+
lastX = e.clientX; lastY = e.clientY;
|
|
320
|
+
});
|
|
321
|
+
renderer.domElement.addEventListener('wheel', (e) => {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
dist = Math.min(260, Math.max(30, dist + (e.deltaY > 0 ? 8 : -8)));
|
|
324
|
+
}, { passive: false });
|
|
325
|
+
function animate() {
|
|
326
|
+
requestAnimationFrame(animate);
|
|
327
|
+
const p = characterWorldPos();
|
|
328
|
+
camTarget.lerp(p, 0.08);
|
|
329
|
+
const cx = camTarget.x + Math.sin(yaw) * Math.cos(pitch) * dist;
|
|
330
|
+
const cz = camTarget.z + Math.cos(yaw) * Math.cos(pitch) * dist;
|
|
331
|
+
const cyv = camTarget.y + 6 + Math.sin(pitch) * dist;
|
|
332
|
+
camera.position.lerp(new THREE.Vector3(cx, cyv, cz), 0.1);
|
|
333
|
+
camera.lookAt(camTarget.x, camTarget.y + 5, camTarget.z);
|
|
334
|
+
const prev = charGroup.position.clone();
|
|
335
|
+
charGroup.position.lerp(p, 0.12);
|
|
336
|
+
if (spritePlane) spritePlane.lookAt(camera.position);
|
|
337
|
+
for (const c of charGroup.children) { if (c.isSprite && c.position.y > 20) c.lookAt(camera.position); }
|
|
338
|
+
const st = stage && stage.location ? stage.location : null;
|
|
339
|
+
const traveling = !!(st && st.traveling);
|
|
340
|
+
if (traveling) {
|
|
341
|
+
const dx = charGroup.position.x - prev.x, dz = charGroup.position.z - prev.z;
|
|
342
|
+
if (Math.abs(dx) + Math.abs(dz) > 0.0005) {
|
|
343
|
+
const targetYaw = Math.atan2(dx, dz);
|
|
344
|
+
let cur = charGroup.rotation.y;
|
|
345
|
+
let d = targetYaw - cur;
|
|
346
|
+
while (d > Math.PI) d -= Math.PI * 2;
|
|
347
|
+
while (d < -Math.PI) d += Math.PI * 2;
|
|
348
|
+
charGroup.rotation.y = cur + d * 0.15;
|
|
349
|
+
}
|
|
350
|
+
charGroup.position.y = Math.abs(Math.sin(performance.now() / 160)) * 0.35;
|
|
351
|
+
} else {
|
|
352
|
+
charGroup.position.y *= 0.9;
|
|
353
|
+
}
|
|
354
|
+
if (mixer) mixer.update(0.016);
|
|
355
|
+
renderer.render(scene, camera);
|
|
356
|
+
}
|
|
357
|
+
window.addEventListener('resize', () => {
|
|
358
|
+
camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
|
|
359
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
360
|
+
});
|
|
361
|
+
poll(); setInterval(poll, 1000);
|
|
362
|
+
animate();
|
|
363
|
+
</script></body></html>
|
|
Binary file
|