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
package/runtime/map.html
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>地图 · 屏幕彼端</title>
|
|
3
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4
|
+
<style>
|
|
5
|
+
:root{color-scheme:dark}
|
|
6
|
+
*{box-sizing:border-box}
|
|
7
|
+
body{margin:0;height:100vh;display:flex;flex-direction:column;background:#0b0f14;color:#d7dee8;font:14px/1.6 "Segoe UI","Microsoft YaHei",sans-serif}
|
|
8
|
+
header{display:flex;align-items:center;gap:10px;padding:9px 16px;border-bottom:1px solid #1d242e;background:#11161d;flex-wrap:wrap}
|
|
9
|
+
header h1{font-size:15px;margin:0;font-weight:600}
|
|
10
|
+
header .now{font-size:13px;color:#8fc1ff}
|
|
11
|
+
header select,header input{background:#0b1118;border:1px solid #2a3745;color:#d7dee8;border-radius:8px;padding:5px 8px;font:inherit}
|
|
12
|
+
header .spacer{flex:1}
|
|
13
|
+
header a,header button{color:#93a1b1;text-decoration:none;font-size:13px;padding:5px 12px;border:1px solid #2a3745;border-radius:8px;background:transparent;cursor:pointer}
|
|
14
|
+
header a:hover,header button:hover{color:#e8eef6;border-color:#3b82f6}
|
|
15
|
+
header button.primary{background:#2563eb;border-color:#2563eb;color:#fff}
|
|
16
|
+
main{flex:1;display:flex;gap:14px;padding:12px 16px;min-height:0}
|
|
17
|
+
#mapCol{flex:1;display:flex;flex-direction:column;gap:10px;min-width:0}
|
|
18
|
+
#mapWrap{position:relative;flex:1;min-height:0;border-radius:14px;overflow:hidden;border:1px solid #26333f;background:#0d1319}
|
|
19
|
+
#mapPins{position:absolute;inset:0;z-index:4}
|
|
20
|
+
#routeLayer{position:absolute;inset:0;pointer-events:none}
|
|
21
|
+
#mePin{position:absolute;z-index:5;transform:translate(-50%,-100%);pointer-events:none;display:none;text-align:center;filter:drop-shadow(0 3px 6px rgba(0,0,0,.65))}
|
|
22
|
+
#mePin .avatar{width:38px;height:38px;border-radius:50%;border:2px solid #8fc1ff;background:#16202b center/cover;display:flex;align-items:center;justify-content:center;font-size:18px;margin:0 auto;font-weight:600}
|
|
23
|
+
#mePin .tag{font-size:11px;background:#16304f;border:1px solid #2f6bd8;color:#cfe0f7;padding:0 6px;border-radius:8px;white-space:nowrap;margin-top:2px}
|
|
24
|
+
#scaleBar{position:absolute;left:14px;bottom:14px;background:rgba(8,13,20,.78);border:1px solid #2a3745;border-radius:8px;padding:6px 10px;font-size:11px;color:#9fb3c8;user-select:none}
|
|
25
|
+
#scaleBar .bar{height:6px;border:1px solid #9fb3c8;border-top:0;margin-top:4px}
|
|
26
|
+
#bigTravel{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:rgba(6,10,16,.72);backdrop-filter:blur(2px)}
|
|
27
|
+
#bigTravel .box{background:#101b29;border:1px solid #2f6bd8;border-radius:16px;padding:22px 34px;text-align:center;min-width:300px;box-shadow:0 10px 40px rgba(0,0,0,.5)}
|
|
28
|
+
#bigTravel .icon{font-size:52px;line-height:1.1}
|
|
29
|
+
#bigTravel .title{font-size:18px;font-weight:600;margin:8px 0 4px}
|
|
30
|
+
#bigTravel .sub{font-size:12px;color:#9fb3c8}
|
|
31
|
+
#bigTravel .prog{height:6px;background:#1a2431;border-radius:99px;margin-top:14px;overflow:hidden}
|
|
32
|
+
#bigTravel .prog i{display:block;height:100%;width:0;background:linear-gradient(90deg,#2563eb,#22c55e);transition:width .5s linear}
|
|
33
|
+
.hint{font-size:12px;color:#5a6878}
|
|
34
|
+
aside{width:370px;display:flex;flex-direction:column;gap:10px;min-height:0;overflow:auto}
|
|
35
|
+
.card{background:#101720;border:1px solid #1c2733;border-radius:12px;padding:12px 13px}
|
|
36
|
+
.card h2{font-size:13px;margin:0 0 9px;color:#93a6bd;font-weight:600}
|
|
37
|
+
.fact{border:1px solid #1c2733;border-radius:10px;padding:8px 9px;margin-bottom:8px;background:#0d141c}
|
|
38
|
+
.row{display:flex;gap:7px;align-items:center;margin:5px 0}
|
|
39
|
+
.row label{color:#8496ab;font-size:12px;flex:0 0 62px}
|
|
40
|
+
input[type=text],select{background:#0b1118;border:1px solid #26333f;color:#d7dee8;border-radius:8px;padding:5px 8px;font:inherit;font-size:13px;outline:none;width:100%}
|
|
41
|
+
header select{width:auto;min-width:120px}
|
|
42
|
+
header #mapSel{min-width:190px}
|
|
43
|
+
input:focus,select:focus{border-color:#2563eb}
|
|
44
|
+
.btn{background:#1b2430;color:#cfdae8;border:1px solid #2a3745;border-radius:8px;padding:5px 10px;font:inherit;cursor:pointer;font-size:12px}
|
|
45
|
+
.btn.primary{background:#2563eb;border-color:#2563eb;color:#fff}
|
|
46
|
+
.btn.danger{background:#3a1d1d;border-color:#5a2a2a;color:#ff9c9c}
|
|
47
|
+
.btn:hover{filter:brightness(1.12)}
|
|
48
|
+
.small{font-size:12px;color:#7d8fa5}
|
|
49
|
+
#tod{position:absolute;inset:0;pointer-events:none;z-index:2;transition:background .8s}
|
|
50
|
+
#tod.dawn{background:linear-gradient(180deg, rgba(255,170,120,.28), rgba(120,90,160,.12) 55%, rgba(255,140,90,.10))}
|
|
51
|
+
#tod.day{background:rgba(255,240,200,.04)}
|
|
52
|
+
#tod.dusk{background:linear-gradient(180deg, rgba(255,120,60,.22), rgba(90,40,90,.20) 60%, rgba(20,10,40,.30))}
|
|
53
|
+
#tod.night{background:rgba(4,8,26,.52)}
|
|
54
|
+
#tod.night::after{content:'';position:absolute;inset:0;background-image:radial-gradient(1.5px 1.5px at 20% 18%, rgba(255,255,255,.9) 50%, transparent 51%),radial-gradient(1.5px 1.5px at 42% 8%, rgba(255,255,255,.75) 50%, transparent 51%),radial-gradient(2px 2px at 68% 24%, rgba(255,255,255,.85) 50%, transparent 51%),radial-gradient(1.2px 1.2px at 84% 12%, rgba(255,255,255,.7) 50%, transparent 51%),radial-gradient(1.4px 1.4px at 30% 36%, rgba(255,255,255,.55) 50%, transparent 51%);background-size:320px 220px;animation:twinkle 4s ease-in-out infinite alternate}
|
|
55
|
+
@keyframes twinkle{from{opacity:.55}to{opacity:1}}
|
|
56
|
+
#wx{position:absolute;inset:0;pointer-events:none;overflow:hidden;z-index:3}
|
|
57
|
+
#toast{position:fixed;right:20px;bottom:20px;background:#1f6feb;color:#fff;padding:9px 16px;border-radius:8px;opacity:0;transition:.2s;z-index:9}
|
|
58
|
+
.chip{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid #2a3745;color:#93a1b1}
|
|
59
|
+
#iconPalette{position:fixed;z-index:20;background:#101b29;border:1px solid #2f6bd8;border-radius:10px;padding:8px;display:none;grid-template-columns:repeat(8,1fr);gap:4px;box-shadow:0 10px 30px rgba(0,0,0,.5)}
|
|
60
|
+
#iconPalette button{font-size:18px;background:transparent;border:0;padding:3px;cursor:pointer;border-radius:6px}
|
|
61
|
+
#iconPalette button:hover{background:#16304f}
|
|
62
|
+
</style>
|
|
63
|
+
<link rel="stylesheet" href="/theme.css"></head>
|
|
64
|
+
<body>
|
|
65
|
+
<header>
|
|
66
|
+
<h1>🗺️ <span id="mapName">地图</span></h1>
|
|
67
|
+
<select id="mapSel" onchange="switchMap(this.value)"></select>
|
|
68
|
+
<select id="longMode" title="换地图时的交通方式">
|
|
69
|
+
<option value="auto">自动选长途</option>
|
|
70
|
+
<option value="coach">🚌 长途汽车</option>
|
|
71
|
+
<option value="rail">🚄 高铁</option>
|
|
72
|
+
<option value="plane">✈️ 飞机</option>
|
|
73
|
+
</select>
|
|
74
|
+
<button onclick="renameMap()">改名</button>
|
|
75
|
+
<button onclick="newMap()">+ 新地图</button>
|
|
76
|
+
<button onclick="delMap()">删地图</button>
|
|
77
|
+
<span class="now" id="nowPlace">…</span>
|
|
78
|
+
<div class="spacer"></div>
|
|
79
|
+
<a href="/screen" target="_blank">屏幕</a>
|
|
80
|
+
<a href="/">聊天</a>
|
|
81
|
+
<a href="/settings">设置</a>
|
|
82
|
+
<button class="primary" onclick="saveMap()">保存地图</button>
|
|
83
|
+
</header>
|
|
84
|
+
<main>
|
|
85
|
+
<div id="mapCol">
|
|
86
|
+
<div id="mapWrap">
|
|
87
|
+
<div id="tod" class="day"></div>
|
|
88
|
+
<div id="wx" style="position:absolute;inset:0;pointer-events:none;overflow:hidden"></div>
|
|
89
|
+
<div id="mapBg" style="position:absolute;inset:0;display:none"><img id="mapBgImg" alt="" style="width:100%;height:100%;object-fit:fill;display:block"></div>
|
|
90
|
+
<div id="mapPins"></div>
|
|
91
|
+
<div id="roadLayer" style="position:absolute;inset:0;pointer-events:none"></div>
|
|
92
|
+
<div id="routeLayer"></div>
|
|
93
|
+
<div id="roadBar" style="position:absolute;left:12px;top:12px;display:none;gap:6px;align-items:center;background:rgba(8,13,20,.88);border:1px solid #2f6bd8;border-radius:10px;padding:6px 10px;font-size:12px;z-index:6">
|
|
94
|
+
<span style="color:#bfd4ef">画道路:点地图加点</span>
|
|
95
|
+
<button class="btn" onclick="roadNew()">新道路</button>
|
|
96
|
+
<button class="btn" onclick="roadUndo()">撤销一点</button>
|
|
97
|
+
<button class="btn" onclick="roadClear()">清空</button>
|
|
98
|
+
<button class="btn primary" onclick="roadSave()">保存道路</button>
|
|
99
|
+
<button class="btn" onclick="toggleRoadEditor()">结束</button>
|
|
100
|
+
</div>
|
|
101
|
+
<div id="mePin"><div class="avatar" id="meAva">🐟</div><div class="tag" id="meTag">…</div></div>
|
|
102
|
+
<div id="scaleBar"><span id="scaleText">…</span><div class="bar" id="scaleLine"></div></div>
|
|
103
|
+
<div id="bigTravel"><div class="box">
|
|
104
|
+
<div class="icon" id="bigIcon">🚄</div>
|
|
105
|
+
<div class="title" id="bigTitle">坐高铁中</div>
|
|
106
|
+
<div class="sub" id="bigSub"></div>
|
|
107
|
+
<div class="prog"><i id="bigProg"></i></div>
|
|
108
|
+
<div class="sub" id="bigEta" style="margin-top:6px"></div>
|
|
109
|
+
</div></div>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="hint">拖动图钉调整位置 · 她出门会沿道路走,速度按交通方式算 · 换地图 = 长途旅行</div>
|
|
112
|
+
</div>
|
|
113
|
+
<aside>
|
|
114
|
+
<div class="card"><h2>当前</h2>
|
|
115
|
+
<div class="row" style="align-items:center;gap:12px;margin:0"><div id="curIcon" style="font-size:34px">📍</div><div style="flex:1">
|
|
116
|
+
<div id="curName" style="font-weight:600;font-size:15px">…</div>
|
|
117
|
+
<div class="small" id="curDesc"></div></div></div>
|
|
118
|
+
<div class="small" id="curFeel" style="margin-top:6px;color:#9fb3c8"></div>
|
|
119
|
+
<div id="travelCard" style="margin-top:6px;padding:8px 10px;border:1px solid #2f6bd8;background:#0f1825;border-radius:8px;font-size:12px;color:#bfd4ef"></div>
|
|
120
|
+
<div class="small" id="curMoney" style="margin-top:4px"></div>
|
|
121
|
+
<div class="row" style="margin-top:8px;justify-content:space-between"><span class="small" id="curSince"></span>
|
|
122
|
+
<label class="small" style="display:flex;align-items:center;gap:6px;flex:0 0 auto"><input type="checkbox" id="freeWalk" onchange="toggleFreeWalk()"> 让她自由走动</label></div>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="card"><h2>地图设置</h2>
|
|
125
|
+
<div class="row"><label>地图全宽</label><input type="text" id="widthMeters" placeholder="例如 2000" style="width:90px"><span class="small">米</span></div>
|
|
126
|
+
<div class="row"><label>交通方式</label><select id="transport"><option value="auto">自动</option><option value="walk">🚶 步行</option><option value="bike">🚲 骑车</option><option value="bus">🚌 公交</option><option value="car">🚗 自驾</option><option value="metro">🚇 地铁</option></select></div>
|
|
127
|
+
<div class="row"><label>地图风格</label><select id="theme"><option value="town">🏘️ 小镇</option><option value="coast">🌊 海边</option><option value="city">🏙️ 城市</option><option value="forest">🌲 森林</option></select></div>
|
|
128
|
+
<div class="row"><label>天气来源</label><select id="weatherMode" onchange="saveWeatherMode()"><option value="manual">手动选择</option><option value="real">真实天气(按设备定位)</option></select></div>
|
|
129
|
+
<div class="row"><label>天气</label><select id="weather" onchange="saveWeather()"><option value="sunny">☀️ 晴天</option><option value="cloudy">☁️ 多云</option><option value="rain">🌧️ 下雨</option><option value="snow">❄️ 下雪</option><option value="hot">🥵 热</option><option value="cold">🧣 冷</option><option value="windy">💨 大风</option></select></div>
|
|
130
|
+
<div class="small" id="realWeatherInfo"></div>
|
|
131
|
+
<div class="row"><button class="btn" onclick="relocateWeather()">重新定位并刷新天气</button></div>
|
|
132
|
+
<div class="row"><label>体力</label><div style="flex:1"><div class="bar" style="height:8px;background:#1a2431;border-radius:99px;overflow:hidden"><i id="energyBar" style="display:block;height:100%;width:0;background:linear-gradient(90deg,#22c55e,#f59e0b)"></i></div><span class="small" id="energyText">…</span></div></div>
|
|
133
|
+
<div id="capsLine" class="small" style="margin-top:4px;color:#9fb3c8"></div>
|
|
134
|
+
<div class="row"><label>路线</label><button class="btn" onclick="toggleRoadEditor()">✏️ 画道路(自定义路线)</button></div>
|
|
135
|
+
<div class="small">画完道路后,她出门会沿你画的路线走(不画就沿默认道路)</div>
|
|
136
|
+
<div class="row"><label>地图视图</label><select id="displayMode" onchange="saveDisplayMode()"><option value="2d">2D 平面</option><option value="3d">3D 模型</option></select></div>
|
|
137
|
+
<div class="row"><label>2D 底图</label><button class="btn" onclick="document.getElementById('mapImgFile').click()">上传图片</button><input type="file" id="mapImgFile" accept="image/*" style="display:none" onchange="uploadMapImage(this)"><button class="btn danger" style="margin-left:6px" onclick="clearMapImage()">清除</button></div>
|
|
138
|
+
<div class="row"><label>底图地址</label><input type="text" id="mapImgUrl" placeholder="/models/xxx.png(或留空)"></div>
|
|
139
|
+
<div class="row"><label>地图模型</label><input type="text" id="mmUrl" placeholder="/models/demo-town.gltf 或 .glb 地址"></div>
|
|
140
|
+
<div class="row"><label>模型缩放</label><input type="number" id="mmScale" step="0.1" placeholder="1" style="width:80px"><span class="small">倍</span></div>
|
|
141
|
+
<div class="row"><label>模型旋转</label><input type="number" id="mmRot" placeholder="0" style="width:80px"><span class="small">度</span></div>
|
|
142
|
+
<div class="row"><label>高度偏移</label><input type="number" id="mmYoff" step="0.5" placeholder="0" style="width:80px"></div>
|
|
143
|
+
<div class="row"><button class="btn primary" onclick="saveMapModel()">应用地图模型</button><button class="btn" onclick="window.open('/model','_blank')">预览 3D</button></div>
|
|
144
|
+
<div class="small" id="modelHint"></div>
|
|
145
|
+
<div class="small">导入 3D 地图:把 .glb/.gltf 放 models/ 目录,填 /models/xxx.glb,然后打开 3D 舞台 /model 查看</div>
|
|
146
|
+
<div class="small">地图全宽决定比例尺和通勤时间;换地图是长途,按右上角"长途交通"选择</div>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="card"><h2>不会做地图/建模? <span class="chip">也能用</span></h2>
|
|
149
|
+
<div class="small" style="line-height:1.9">
|
|
150
|
+
内置 3D 小镇和角色只是占位示例(能用但不好看),不用自己做也能直接用;想要好看的请看教程 #3 的资源清单。<br>
|
|
151
|
+
想换的话:<br>
|
|
152
|
+
· 地图 → 上传 2D 底图,或填 3D 模型地址(.glb/.gltf)<br>
|
|
153
|
+
· 地点 → 「导入地点」支持 JSON / CSV / 每行一个名字<br>
|
|
154
|
+
· 图标 → 每个地点可「换图标图」<br>
|
|
155
|
+
实在不会做也没关系:用内置场景,只改地点名字和描述就行。
|
|
156
|
+
</div>
|
|
157
|
+
<div class="row" style="margin-top:8px"><button class="btn" onclick="useBuiltinScene()">用内置 3D 场景</button><button class="btn" onclick="window.open('/settings#tutorial','_blank')">看导入教程</button></div>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="card"><h2>地点列表 <span class="chip">点图标可换</span></h2>
|
|
160
|
+
<div class="row"><button class="btn" onclick="document.getElementById('placeImportFile').click()">导入地点</button>
|
|
161
|
+
<input type="file" id="placeImportFile" accept=".json,.csv,.txt,text/plain,application/json" style="display:none" onchange="importPlacesFile(this)">
|
|
162
|
+
<button class="btn" onclick="exportPlaces()">导出地点</button>
|
|
163
|
+
<span class="small">支持 JSON / CSV / 每行一个名字</span></div>
|
|
164
|
+
<div id="placeList"></div>
|
|
165
|
+
<div class="row"><input type="text" id="newPlaceName" placeholder="新地点名字"><button class="btn" onclick="addPlace()">+ 添加</button></div>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="card"><h2>她的时段习惯 <span class="chip">可编辑权重</span></h2>
|
|
168
|
+
<div class="row" style="margin:0 0 8px"><label class="small" style="display:flex;align-items:center;gap:6px;flex:0 0 auto"><input type="checkbox" id="smartRoutine" onchange="saveRoutineHandler()"> 智能权重</label>
|
|
169
|
+
<span class="small" style="flex:1">开启后:随记忆/天气/体力/最近习惯自动微调</span></div>
|
|
170
|
+
<div class="small" id="visitInfo" style="margin-bottom:6px"></div>
|
|
171
|
+
<div class="small" style="margin-bottom:6px">每个时段她想待在哪:数字是权重(0=不去,越大越常去)</div>
|
|
172
|
+
<div id="routineList"></div>
|
|
173
|
+
<div class="flex" style="display:flex;gap:8px;margin-top:6px"><button class="btn" onclick="addRoutineBand()">+ 时段</button><button class="btn primary" onclick="saveRoutineHandler()">保存习惯</button></div>
|
|
174
|
+
</div>
|
|
175
|
+
<div class="card"><h2>习惯参考(不锁死)</h2><div class="small" style="margin-bottom:6px">作息只是她的倾向,去不去由她自己定</div><div id="schedList"></div></div>
|
|
176
|
+
</aside>
|
|
177
|
+
</main>
|
|
178
|
+
<div id="toast"></div>
|
|
179
|
+
<div id="iconPalette"></div>
|
|
180
|
+
<script src="/wx.js"></script>
|
|
181
|
+
<script>
|
|
182
|
+
const $ = (s) => document.querySelector(s);
|
|
183
|
+
let DATA = null, PLACES = [], SCHEDULES = [], CURRENT = null, TRANSPORTS = {}, TIMER = null, PALETTE_TARGET = null, CAPS = {}, WEATHERS = {}, ENERGY = 80, ROUTINE = null, RPLACES = [], VISITS = {}, ROADS = [], EDITING_ROADS = false, ROAD_CUR = [], WXFX = {};
|
|
184
|
+
const EMOJIS = ['🏠','🏫','🏢','📚','🌳','☕','🍚','🛒','🏥','🏦','🚉','✈️','🎬','🏬','🏋️','📖','💻','🌉','🏞️','⛰️','🍜','🍰','🍺','🎮','🎤','🧺','🛏️','🚿','🌇','🌃','🏪','💊','🐟','🐳','⭐','📍','🎪','🚏','🌲'];
|
|
185
|
+
function esc(s){ return String(s??'').replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
|
186
|
+
function toast(t){ const el=$('#toast'); el.textContent=t; el.style.opacity=1; setTimeout(()=>el.style.opacity=0,1600); }
|
|
187
|
+
async function api(path, opts){ const r = await fetch(path, opts); if(!r.ok) throw new Error('HTTP '+r.status); return await r.json(); }
|
|
188
|
+
async function saveJson(path, obj){ return await api(path, {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(obj)}); }
|
|
189
|
+
function fmtDist(m){ return m >= 1000 ? (m/1000).toFixed(2).replace(/0$/,'') + ' km' : Math.round(m)+' m'; }
|
|
190
|
+
function fmtEta(sec){ if (sec < 60) return '不到 1 分钟'; if (sec < 3600) return Math.ceil(sec/60) + ' 分钟'; return Math.floor(sec/3600) + ' 小时 ' + Math.ceil((sec%3600)/60) + ' 分'; }
|
|
191
|
+
function guessIcon(name){
|
|
192
|
+
const s = String(name||'');
|
|
193
|
+
const map = [['家|宿舍|卧室', '🏠'],['学校|教室|课', '🏫'],['公司|办公', '🏢'],['图书馆|自习', '📚'],['公园|花园', '🌳'],['咖啡', '☕'],['食堂|饭|餐厅|吃', '🍚'],['超市|商场|购物', '🛒'],['医院|诊所', '🏥'],['银行', '🏦'],['车站|地铁站', '🚉'],['机场', '✈️'],['电影', '🎬'],['健身', '🏋️'],['书店', '📖'],['网吧|电脑', '💻'],['桥', '🌉'],['河|湖|海', '🏞️'],['山', '⛰️'],['酒吧', '🍺'],['游戏', '🎮'],['KTV|唱歌', '🎤']];
|
|
194
|
+
for (const [re, ic] of map) if (new RegExp(re).test(s)) return ic;
|
|
195
|
+
return EMOJIS[Math.floor(Math.random()*EMOJIS.length)];
|
|
196
|
+
}
|
|
197
|
+
function themeSvg(theme){
|
|
198
|
+
const t = theme || 'town';
|
|
199
|
+
const base = '<defs><linearGradient id="g1" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#0e1a2b"/><stop offset="100%" stop-color="#122334"/></linearGradient>'
|
|
200
|
+
+ '<linearGradient id="g2" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#16302b"/><stop offset="100%" stop-color="#1d3a33"/></linearGradient></defs>'
|
|
201
|
+
+ '<rect width="1000" height="640" fill="url(#g1)"/>';
|
|
202
|
+
let bg = '<rect y="26" width="1000" height="614" fill="url(#g2)"/>';
|
|
203
|
+
if (t === 'coast') bg += '<rect y="360" width="1000" height="280" fill="#10304a"/><path d="M0 380 Q 125 360 250 380 T 500 380 T 750 380 T 1000 380" fill="none" stroke="#2a6f96" stroke-width="8" opacity=".8"/><path d="M0 440 Q 125 420 250 440 T 500 440 T 750 440 T 1000 440" fill="none" stroke="#2a6f96" stroke-width="6" opacity=".55"/>'
|
|
204
|
+
+ '<ellipse cx="160" cy="300" rx="120" ry="40" fill="#2b3f33" opacity=".6"/>';
|
|
205
|
+
if (t === 'city') bg += '<g fill="#1c2b36" stroke="#33475a" stroke-width="2">'
|
|
206
|
+
+ '<rect x="120" y="140" width="90" height="190"/><rect x="230" y="90" width="70" height="240"/><rect x="330" y="170" width="110" height="160"/><rect x="620" y="120" width="80" height="210"/><rect x="720" y="180" width="120" height="150"/><rect x="860" y="100" width="70" height="230"/></g>'
|
|
207
|
+
+ '<g fill="#7fb2e8" opacity=".55">' + Array.from({length: 26}).map((_,i)=>'<rect x="'+(140+(i%6)*14)+'" y="'+(158+Math.floor(i/6)*26)+'" width="6" height="10"/>').join('') + '</g>';
|
|
208
|
+
if (t === 'forest') bg += '<g fill="#1e4637" stroke="#2d6b52" stroke-width="3">'
|
|
209
|
+
+ Array.from({length: 16}).map((_,i)=>'<circle cx="'+(70+(i%5)*210)+'" cy="'+(150+Math.floor(i/5)*150)+'" r="'+(26+(i%3)*8)+'"/>').join('') + '</g>'
|
|
210
|
+
+ '<path d="M-20 500 C 200 470, 400 540, 620 500 C 800 470, 900 520, 1020 490" fill="none" stroke="#174266" stroke-width="16" opacity=".8"/>';
|
|
211
|
+
const roads = '<path d="M-20 300 C 180 240, 320 330, 520 300 C 720 270, 820 330, 1020 290" fill="none" stroke="#2a3a44" stroke-width="46" stroke-linecap="round"/>'
|
|
212
|
+
+ '<path d="M-20 300 C 180 240, 320 330, 520 300 C 720 270, 820 330, 1020 290" fill="none" stroke="#3a4f5c" stroke-width="8" stroke-dasharray="26 22" opacity=".7"/>'
|
|
213
|
+
+ '<path d="M500 320 C 505 400, 470 520, 505 640" fill="none" stroke="#2a3a44" stroke-width="34" stroke-linecap="round"/>'
|
|
214
|
+
+ '<path d="M500 320 C 505 400, 470 520, 505 640" fill="none" stroke="#3a4f5c" stroke-width="6" stroke-dasharray="18 16" opacity=".7"/>';
|
|
215
|
+
const trees = '<g fill="#1e4637" stroke="#2d6b52" stroke-width="3">' + [[80,420],[180,520],[880,260],[900,520],[640,540],[300,560],[150,200],[720,540]].map(([x,y]) => '<g><circle cx="'+x+'" cy="'+y+'" r="16"/><circle cx="'+x+'" cy="'+y+'" r="6" fill="#2d6b52"/></g>').join('') + '</g>';
|
|
216
|
+
return '<svg viewBox="0 0 1000 640" preserveAspectRatio="none" style="position:absolute;inset:0;width:100%;height:100%" xmlns="http://www.w3.org/2000/svg">' + base + bg + roads + trees + '</svg>';
|
|
217
|
+
}
|
|
218
|
+
function drawTheme(){ const wrap = document.getElementById('mapWrap'); const old = document.getElementById('themeSvg'); if (old) old.remove(); wrap.insertAdjacentHTML('afterbegin', '<div id="themeSvg">' + themeSvg(DATA.map.theme) + '</div>'); document.getElementById('themeSvg').style.cssText='position:absolute;inset:0'; }
|
|
219
|
+
function placeEl(p){
|
|
220
|
+
const el = document.createElement('div');
|
|
221
|
+
el.className = 'pin';
|
|
222
|
+
el.style.cssText = 'position:absolute;left:'+(p.x*100)+'%;top:'+(p.y*100)+'%;transform:translate(-50%,-100%);cursor:grab;user-select:none;text-align:center;filter:drop-shadow(0 3px 4px rgba(0,0,0,.5))';
|
|
223
|
+
const iconHtml = p.image
|
|
224
|
+
? '<img src="'+esc(p.image)+'" alt="" style="width:28px;height:28px;object-fit:contain;display:inline-block">'
|
|
225
|
+
: '<span style="font-size:24px">'+esc(p.icon)+'</span>';
|
|
226
|
+
el.innerHTML = '<div>'+iconHtml+'</div>'
|
|
227
|
+
+ '<div style="font-size:11px;color:#d7e3ee;background:#14202c;padding:1px 7px;border-radius:8px;border:1px solid #2a3a4a;white-space:nowrap">'+esc(p.name)+'</div>'
|
|
228
|
+
+ '<div style="width:2px;height:8px;background:#2a3a4a;margin:0 auto"></div>';
|
|
229
|
+
el.addEventListener('pointerdown', (ev) => {
|
|
230
|
+
ev.preventDefault();
|
|
231
|
+
const box = document.getElementById('mapWrap').getBoundingClientRect();
|
|
232
|
+
const move = (e) => {
|
|
233
|
+
p.x = Math.max(0.05, Math.min(0.95, (e.clientX - box.left) / box.width));
|
|
234
|
+
p.y = Math.max(0.08, Math.min(0.92, (e.clientY - box.top) / box.height));
|
|
235
|
+
el.style.left = (p.x*100)+'%'; el.style.top = (p.y*100)+'%';
|
|
236
|
+
};
|
|
237
|
+
const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); };
|
|
238
|
+
document.addEventListener('pointermove', move);
|
|
239
|
+
document.addEventListener('pointerup', up);
|
|
240
|
+
});
|
|
241
|
+
return el;
|
|
242
|
+
}
|
|
243
|
+
function renderPins(){
|
|
244
|
+
const wrap = document.getElementById('mapPins');
|
|
245
|
+
wrap.innerHTML = '';
|
|
246
|
+
PLACES.forEach((p) => wrap.appendChild(placeEl(p)));
|
|
247
|
+
}
|
|
248
|
+
function pathPoints(){ const tv = CURRENT && CURRENT.traveling; return (tv && Array.isArray(tv.path) && tv.path.length > 1) ? tv.path : null; }
|
|
249
|
+
function pointAt(path, progress){
|
|
250
|
+
let total = 0; const segs = [];
|
|
251
|
+
for (let i=0;i<path.length-1;i++){ const a=path[i],b=path[i+1]; const d=Math.hypot(a.x-b.x,a.y-b.y); segs.push(d); total+=d; }
|
|
252
|
+
let target = total * progress;
|
|
253
|
+
for (let i=0;i<segs.length;i++){ if (target <= segs[i] || i===segs.length-1){ const a=path[i],b=path[i+1]; const r=segs[i]?target/segs[i]:0; return {x:a.x+(b.x-a.x)*Math.min(1,Math.max(0,r)), y:a.y+(b.y-a.y)*Math.min(1,Math.max(0,r))}; } target -= segs[i]; }
|
|
254
|
+
return path[path.length-1];
|
|
255
|
+
}
|
|
256
|
+
function drawRoute(){
|
|
257
|
+
const layer = document.getElementById('routeLayer');
|
|
258
|
+
layer.innerHTML = '';
|
|
259
|
+
const path = pathPoints();
|
|
260
|
+
if (!path) return;
|
|
261
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
262
|
+
svg.setAttribute('viewBox', '0 0 100 100'); svg.setAttribute('preserveAspectRatio', 'none');
|
|
263
|
+
svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%';
|
|
264
|
+
const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
|
265
|
+
pl.setAttribute('points', path.map(p => (p.x*100)+','+(p.y*100)).join(' '));
|
|
266
|
+
pl.setAttribute('fill', 'none'); pl.setAttribute('stroke', '#3b82f6'); pl.setAttribute('stroke-width', '0.5');
|
|
267
|
+
pl.setAttribute('stroke-dasharray', '1.6 1.2'); pl.setAttribute('opacity', '.85');
|
|
268
|
+
svg.appendChild(pl); layer.appendChild(svg);
|
|
269
|
+
}
|
|
270
|
+
function renderMe(){
|
|
271
|
+
const pin = document.getElementById('mePin');
|
|
272
|
+
if (!CURRENT) { pin.style.display='none'; return; }
|
|
273
|
+
const path = pathPoints();
|
|
274
|
+
let x, y;
|
|
275
|
+
if (path) {
|
|
276
|
+
const prog = Math.min(1, Math.max(0, (Date.now() - CURRENT.traveling.startedAt) / Math.max(1, CURRENT.traveling.durationMs)));
|
|
277
|
+
const pt = pointAt(path, prog);
|
|
278
|
+
x = pt.x; y = pt.y;
|
|
279
|
+
document.getElementById('meTag').textContent = CURRENT.traveling.modeIcon + ' ' + CURRENT.traveling.modeLabel + '中';
|
|
280
|
+
document.getElementById('meAva').textContent = CURRENT.avatar ? '' : '🐟';
|
|
281
|
+
document.getElementById('meAva').style.backgroundImage = CURRENT.avatar ? 'url('+CURRENT.avatar+')' : '';
|
|
282
|
+
} else if (CURRENT.intercity) {
|
|
283
|
+
pin.style.display='none'; return;
|
|
284
|
+
} else {
|
|
285
|
+
const p = PLACES.find(p => p.id === CURRENT.placeId);
|
|
286
|
+
if (!p) { pin.style.display='none'; return; }
|
|
287
|
+
x = p.x; y = p.y;
|
|
288
|
+
document.getElementById('meTag').textContent = '在这里';
|
|
289
|
+
document.getElementById('meAva').textContent = CURRENT.avatar ? '' : ((CAPS.name||'她')).slice(0,1);
|
|
290
|
+
document.getElementById('meAva').style.backgroundImage = CURRENT.avatar ? 'url('+CURRENT.avatar+')' : '';
|
|
291
|
+
}
|
|
292
|
+
pin.style.display = 'block';
|
|
293
|
+
pin.style.left = (x*100)+'%'; pin.style.top = (y*100)+'%';
|
|
294
|
+
}
|
|
295
|
+
function renderScale(){
|
|
296
|
+
const wrap = document.getElementById('mapWrap');
|
|
297
|
+
const width = Number(DATA && DATA.map && DATA.map.widthMeters) || 2000;
|
|
298
|
+
const px = Math.max(80, Math.round(wrap.clientWidth * 0.22));
|
|
299
|
+
document.getElementById('scaleLine').style.width = px + 'px';
|
|
300
|
+
document.getElementById('scaleText').textContent = '比例尺:' + fmtDist(width * (px / Math.max(1, wrap.clientWidth))) + '(地图全宽 ' + fmtDist(width) + ')';
|
|
301
|
+
}
|
|
302
|
+
function renderPlaceList(){
|
|
303
|
+
document.getElementById('placeList').innerHTML = PLACES.map((p, i) => '<div class="fact">'
|
|
304
|
+
+ '<div class="row" style="margin:0"><button class="btn" style="width:40px;font-size:16px" onclick="pickIcon(event,'+i+')">'+esc(p.icon)+'</button>'
|
|
305
|
+
+ '<input type="text" value="'+esc(p.name)+'" oninput="PLACES['+i+'].name=this.value">'
|
|
306
|
+
+ '<button class="btn danger" onclick="delPlace('+i+')">✕</button></div>'
|
|
307
|
+
+ '<div class="row"><label>描述</label><input type="text" value="'+esc(p.desc||'')+'" placeholder="这里是什么地方" oninput="PLACES['+i+'].desc=this.value"></div>'
|
|
308
|
+
+ '<div class="row"><label>她的感受</label><input type="text" value="'+esc(p.feel||'')+'" placeholder="她来这里会有什么感觉" oninput="PLACES['+i+'].feel=this.value"></div>'
|
|
309
|
+
+ '<div class="row" style="justify-content:flex-end">'
|
|
310
|
+
+ '<button class="btn" onclick="document.getElementById(\'placeImgFile-'+i+'\').click()">换图标图</button>'
|
|
311
|
+
+ '<input type="file" id="placeImgFile-'+i+'" accept="image/*" style="display:none" onchange="uploadPlaceImage('+i+', this)">'
|
|
312
|
+
+ (p.image ? '<button class="btn danger" onclick="clearPlaceImage('+i+')">清图标图</button>' : '')
|
|
313
|
+
+ '<button class="btn" onclick="goHere('+i+')">让她来这里</button></div></div>').join('');
|
|
314
|
+
}
|
|
315
|
+
async function loadRoutine(){
|
|
316
|
+
try {
|
|
317
|
+
const r = await api('/api/routine');
|
|
318
|
+
ROUTINE = r.routine; RPLACES = r.places || []; VISITS = r.visits || {};
|
|
319
|
+
const smart = document.getElementById('smartRoutine');
|
|
320
|
+
if (smart) smart.checked = ROUTINE.smart === true;
|
|
321
|
+
renderRoutine(); renderVisitInfo();
|
|
322
|
+
} catch(e) { const host = document.getElementById('routineList'); if (host) host.innerHTML = '<div class="small">习惯加载失败:'+esc(e.message)+'</div>'; }
|
|
323
|
+
}
|
|
324
|
+
function renderVisitInfo(){
|
|
325
|
+
const el = document.getElementById('visitInfo');
|
|
326
|
+
if (!el) return;
|
|
327
|
+
const list = Object.entries(VISITS || {}).map(([id, v]) => {
|
|
328
|
+
const p = RPLACES.find(x => x.id === id);
|
|
329
|
+
return { name: p ? p.icon + p.name : id, count: (v && v.count) || 0, lastAt: (v && v.lastAt) || 0 };
|
|
330
|
+
}).filter(x => x.count > 0).sort((a, b) => b.lastAt - a.lastAt).slice(0, 4);
|
|
331
|
+
el.textContent = list.length ? ('她最近去过:' + list.map(x => x.name + '×' + x.count).join('、')) : '她还没怎么出过门';
|
|
332
|
+
}
|
|
333
|
+
function currentHour(){ const d = new Date(); return d.getHours() + d.getMinutes()/60; }
|
|
334
|
+
function renderRoutine(){
|
|
335
|
+
const host = document.getElementById('routineList');
|
|
336
|
+
if (!host || !ROUTINE) return;
|
|
337
|
+
const h = currentHour();
|
|
338
|
+
host.innerHTML = ROUTINE.bands.map((b, i) => {
|
|
339
|
+
const active = (b.start <= b.end) ? (h >= b.start && h < b.end) : (h >= b.start || h < b.end);
|
|
340
|
+
return '<div class="fact" style="' + (active ? 'border-color:#2f6bd8;background:#0f1825;' : '') + '">'
|
|
341
|
+
+ '<div class="row" style="margin:0">'
|
|
342
|
+
+ '<input type="number" min="0" max="24" step="0.5" value="'+b.start+'" style="width:64px" onchange="ROUTINE.bands['+i+'].start=Number(this.value)">'
|
|
343
|
+
+ '<span class="small">点到</span>'
|
|
344
|
+
+ '<input type="number" min="0" max="24" step="0.5" value="'+b.end+'" style="width:64px" onchange="ROUTINE.bands['+i+'].end=Number(this.value)">'
|
|
345
|
+
+ '<label class="small" style="display:flex;align-items:center;gap:4px;flex:0 0 auto"><input type="checkbox" '+ (b.allowed !== false ? 'checked' : '') +' onchange="ROUTINE.bands['+i+'].allowed=this.checked"> 出门逛</label>'
|
|
346
|
+
+ '<button class="btn danger" style="margin-left:auto" onclick="delRoutineBand('+i+')">✕</button></div>'
|
|
347
|
+
+ '<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px">'
|
|
348
|
+
+ RPLACES.map(p => {
|
|
349
|
+
const w = (b.weights && b.weights[p.id]) || 0;
|
|
350
|
+
return '<label class="small" style="display:flex;align-items:center;gap:2px;flex:0 0 auto"><span>'+esc(p.icon)+'</span>'
|
|
351
|
+
+ '<input type="number" min="0" max="100" value="'+w+'" style="width:52px" onchange="setWeight('+i+',\''+p.id+'\',this.value)"></label>';
|
|
352
|
+
}).join('')
|
|
353
|
+
+ '</div></div>';
|
|
354
|
+
}).join('');
|
|
355
|
+
}
|
|
356
|
+
function setWeight(i, pid, val){
|
|
357
|
+
const b = ROUTINE.bands[i];
|
|
358
|
+
const v = Math.max(0, Math.min(100, Number(val) || 0));
|
|
359
|
+
if (v > 0) b.weights[pid] = v; else delete b.weights[pid];
|
|
360
|
+
}
|
|
361
|
+
function addRoutineBand(){
|
|
362
|
+
ROUTINE.bands.push({ start: 0, end: 24, allowed: true, weights: {} });
|
|
363
|
+
renderRoutine();
|
|
364
|
+
}
|
|
365
|
+
function delRoutineBand(i){ ROUTINE.bands.splice(i,1); renderRoutine(); }
|
|
366
|
+
async function saveRoutineHandler(){
|
|
367
|
+
const smart = document.getElementById('smartRoutine').checked;
|
|
368
|
+
const r = await saveJson('/api/routine', { bands: ROUTINE.bands, smart });
|
|
369
|
+
if (r.ok) { ROUTINE = r.routine; renderRoutine(); renderVisitInfo(); toast(smart ? '智能权重已开启' : '习惯已保存'); }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function renderSchedList(){
|
|
373
|
+
const host = document.getElementById('schedList');
|
|
374
|
+
if (!SCHEDULES.length) { host.innerHTML = '<div class="small">还没有作息,去设置→生活加几条</div>'; return; }
|
|
375
|
+
host.innerHTML = SCHEDULES.map((s, i) => '<div class="row"><span class="small" style="flex:1">'+esc(s.time)+' '+esc(s.label)+'</span>'
|
|
376
|
+
+ '<select style="width:120px" onchange="SCHEDULES['+i+'].place=this.value">'
|
|
377
|
+
+ PLACES.map(p => '<option value="'+esc(p.id)+'"'+(s.place===p.id?' selected':'')+'>'+esc(p.icon+' '+p.name)+'</option>').join('') + '</select></div>').join('');
|
|
378
|
+
}
|
|
379
|
+
function renderCurrent(cur){
|
|
380
|
+
CURRENT = cur || {};
|
|
381
|
+
const tc = document.getElementById('travelCard');
|
|
382
|
+
const big = document.getElementById('bigTravel');
|
|
383
|
+
if (cur.intercity) {
|
|
384
|
+
const ic = cur.intercity;
|
|
385
|
+
document.getElementById('curIcon').textContent = ic.modeIcon;
|
|
386
|
+
document.getElementById('curName').textContent = ic.modeLabel + '中 · 前往「' + ic.toMapName + '」';
|
|
387
|
+
document.getElementById('curDesc').textContent = ic.fromMapName + ' → ' + ic.toMapName;
|
|
388
|
+
document.getElementById('curFeel').textContent = '';
|
|
389
|
+
document.getElementById('curSince').textContent = '';
|
|
390
|
+
tc.style.display = 'none';
|
|
391
|
+
big.style.display = 'flex';
|
|
392
|
+
document.getElementById('bigIcon').textContent = ic.modeIcon;
|
|
393
|
+
document.getElementById('bigTitle').textContent = '坐' + ic.modeLabel + '中';
|
|
394
|
+
document.getElementById('bigSub').textContent = ic.fromMapName + ' → ' + ic.toMapName + ' · ' + ic.distanceKm + ' 公里';
|
|
395
|
+
document.getElementById('bigProg').style.width = Math.round(ic.progress*100) + '%';
|
|
396
|
+
document.getElementById('bigEta').textContent = '还有约 ' + fmtEta(ic.etaSec);
|
|
397
|
+
document.getElementById('nowPlace').textContent = ic.modeIcon + ' 前往 ' + ic.toMapName;
|
|
398
|
+
} else {
|
|
399
|
+
big.style.display = 'none';
|
|
400
|
+
document.getElementById('curIcon').textContent = cur.icon || '📍';
|
|
401
|
+
document.getElementById('curName').textContent = cur.traveling ? ('去「' + cur.name + '」的路上') : (cur.name + ' · ' + (cur.mapName || ''));
|
|
402
|
+
document.getElementById('curDesc').textContent = cur.traveling ? '' : (cur.desc || '');
|
|
403
|
+
document.getElementById('curFeel').textContent = (!cur.traveling && cur.feel) ? '她对这里的感受:' + cur.feel : '';
|
|
404
|
+
document.getElementById('curSince').textContent = (!cur.traveling && cur.minutes > 0) ? '已待约 ' + cur.minutes + ' 分钟' : '';
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
document.getElementById('nowPlace').textContent = cur.traveling ? ('🚶 前往 ' + cur.name) : (cur.icon + ' ' + cur.name);
|
|
408
|
+
if (cur.traveling) {
|
|
409
|
+
const t = cur.traveling;
|
|
410
|
+
tc.style.display = 'block';
|
|
411
|
+
tc.innerHTML = '正在从 <b>' + esc(t.fromName) + '</b> 去 <b>' + esc(t.toName) + '</b><br>' + t.modeIcon + ' ' + t.modeLabel + ' · ' + fmtDist(t.distanceMeters) + ' · 还有约 ' + fmtEta(t.etaSec);
|
|
412
|
+
} else tc.style.display = 'none';
|
|
413
|
+
}
|
|
414
|
+
document.getElementById('mapName').textContent = cur.mapName || '地图';
|
|
415
|
+
drawRoute(); renderMe();
|
|
416
|
+
}
|
|
417
|
+
function startTicker(){
|
|
418
|
+
if (TIMER) clearInterval(TIMER);
|
|
419
|
+
TIMER = setInterval(async () => {
|
|
420
|
+
if (!CURRENT) return;
|
|
421
|
+
if (CURRENT.traveling || CURRENT.intercity) {
|
|
422
|
+
renderMe();
|
|
423
|
+
if (CURRENT.intercity) {
|
|
424
|
+
const ic = CURRENT.intercity;
|
|
425
|
+
const prog = Math.min(1, (Date.now() - (CURRENT.since||Date.now())) / Math.max(1, ic.durationMs|| 1));
|
|
426
|
+
}
|
|
427
|
+
const t = CURRENT.traveling;
|
|
428
|
+
if (t) {
|
|
429
|
+
const left = Math.max(0, Math.round((t.durationMs - (Date.now() - t.startedAt)) / 1000));
|
|
430
|
+
const tc = document.getElementById('travelCard');
|
|
431
|
+
if (tc.style.display !== 'none') tc.innerHTML = '正在从 <b>' + esc(t.fromName) + '</b> 去 <b>' + esc(t.toName) + '</b><br>' + t.modeIcon + ' ' + t.modeLabel + ' · ' + fmtDist(t.distanceMeters) + ' · 还有约 ' + fmtEta(left);
|
|
432
|
+
}
|
|
433
|
+
const ic = CURRENT.intercity;
|
|
434
|
+
if (ic) {
|
|
435
|
+
const left = Math.max(0, Math.round(ic.etaSec - (Date.now() - CURRENT.since)/1000));
|
|
436
|
+
document.getElementById('bigEta').textContent = '还有约 ' + fmtEta(Math.max(0,left));
|
|
437
|
+
const prog = Math.min(1, (Date.now() - CURRENT.since) / Math.max(1, (ic.etaSec*1000 + (Date.now()-CURRENT.since) - ic.etaSec*1000)));
|
|
438
|
+
document.getElementById('bigProg').style.width = Math.round(ic.progress*100) + '%';
|
|
439
|
+
}
|
|
440
|
+
if ((t && Date.now() - t.startedAt >= t.durationMs) || (ic && ic.progress >= 1)) {
|
|
441
|
+
try { const loc = await api('/api/location'); await loadMapData(); renderCurrent(loc.location); } catch(e){}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}, 150);
|
|
445
|
+
}
|
|
446
|
+
async function loadMapData(){
|
|
447
|
+
DATA = await api('/api/map');
|
|
448
|
+
PLACES = DATA.map.places.map(p => ({...p}));
|
|
449
|
+
SCHEDULES = DATA.schedules || [];
|
|
450
|
+
TRANSPORTS = DATA.transports || {};
|
|
451
|
+
CAPS = DATA.capabilities || {};
|
|
452
|
+
WEATHERS = DATA.weathers || {};
|
|
453
|
+
ENERGY = Number(DATA.energy) || 0;
|
|
454
|
+
const wsel = document.getElementById('weather');
|
|
455
|
+
if (wsel && DATA.weather) wsel.value = DATA.weather;
|
|
456
|
+
renderCaps();
|
|
457
|
+
document.getElementById('mapSel').innerHTML = DATA.maps.map(m => '<option value="'+esc(m.id)+'"'+(m.id===DATA.activeMapId?' selected':'')+'>'+esc(m.name)+'('+m.placeCount+' 个地点)</option>').join('');
|
|
458
|
+
document.getElementById('widthMeters').value = DATA.map.widthMeters || 2000;
|
|
459
|
+
document.getElementById('transport').value = DATA.map.transport || 'auto';
|
|
460
|
+
document.getElementById('theme').value = DATA.map.theme || 'town';
|
|
461
|
+
const dm = document.getElementById('displayMode'); if (dm) dm.value = DATA.map.displayMode || '2d';
|
|
462
|
+
const mImg = DATA.map.image || {};
|
|
463
|
+
if (document.getElementById('mapImgUrl')) document.getElementById('mapImgUrl').value = mImg.url || '';
|
|
464
|
+
const bg = document.getElementById('mapBg'), bgImg = document.getElementById('mapBgImg'), themeSvgEl = document.getElementById('themeSvg');
|
|
465
|
+
if (bg && bgImg) {
|
|
466
|
+
if (mImg.url) { bg.style.display = 'block'; bgImg.src = mImg.url; if (themeSvgEl) themeSvgEl.style.display = 'none'; }
|
|
467
|
+
else { bg.style.display = 'none'; bgImg.src = ''; if (themeSvgEl) themeSvgEl.style.display = ''; }
|
|
468
|
+
}
|
|
469
|
+
const mModel = DATA.map.model || {};
|
|
470
|
+
if (document.getElementById('mmUrl')) document.getElementById('mmUrl').value = mModel.url || '';
|
|
471
|
+
if (document.getElementById('mmScale')) document.getElementById('mmScale').value = mModel.scale || 1;
|
|
472
|
+
if (document.getElementById('mmRot')) document.getElementById('mmRot').value = mModel.rotationY || 0;
|
|
473
|
+
if (document.getElementById('mmYoff')) document.getElementById('mmYoff').value = mModel.yOffset || 0;
|
|
474
|
+
document.getElementById('mapName').textContent = DATA.map.name;
|
|
475
|
+
ROADS = (DATA.map.roads || []).map(l => l.map(pt => ({ x: Number(pt[0]), y: Number(pt[1]) })));
|
|
476
|
+
renderRoads();
|
|
477
|
+
renderCurrent(DATA.location);
|
|
478
|
+
loadMoneyLine();
|
|
479
|
+
renderPlaceList(); renderSchedList(); renderPins(); drawTheme(); renderScale();
|
|
480
|
+
loadWeatherData();
|
|
481
|
+
startTicker();
|
|
482
|
+
}
|
|
483
|
+
function pickIcon(ev, i){
|
|
484
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
485
|
+
const pal = document.getElementById('iconPalette');
|
|
486
|
+
PALETTE_TARGET = i;
|
|
487
|
+
pal.innerHTML = EMOJIS.map(e => '<button onclick="setIcon(\'' + e + '\')">' + e + '</button>').join('');
|
|
488
|
+
const r = ev.currentTarget.getBoundingClientRect();
|
|
489
|
+
pal.style.display = 'grid';
|
|
490
|
+
pal.style.left = Math.min(window.innerWidth-330, r.left) + 'px';
|
|
491
|
+
pal.style.top = (r.bottom + 6) + 'px';
|
|
492
|
+
}
|
|
493
|
+
function setIcon(e){
|
|
494
|
+
if (PALETTE_TARGET != null) { PLACES[PALETTE_TARGET].icon = e; renderPlaceList(); renderPins(); }
|
|
495
|
+
document.getElementById('iconPalette').style.display = 'none';
|
|
496
|
+
}
|
|
497
|
+
document.addEventListener('click', (e) => { if (!e.target.closest('#iconPalette')) document.getElementById('iconPalette').style.display='none'; });
|
|
498
|
+
document.getElementById('mapWrap').addEventListener('click', (e) => {
|
|
499
|
+
if (!EDITING_ROADS) return;
|
|
500
|
+
if (e.target.closest('#roadBar') || e.target.closest('.pin')) return;
|
|
501
|
+
const box = document.getElementById('mapWrap').getBoundingClientRect();
|
|
502
|
+
const x = Math.max(0, Math.min(1, (e.clientX - box.left) / box.width));
|
|
503
|
+
const y = Math.max(0, Math.min(1, (e.clientY - box.top) / box.height));
|
|
504
|
+
ROAD_CUR.push({ x, y });
|
|
505
|
+
renderRoads();
|
|
506
|
+
});
|
|
507
|
+
async function saveMap(){
|
|
508
|
+
const r = await saveJson('/api/map', {
|
|
509
|
+
mapId: DATA.map.id, name: DATA.map.name, places: PLACES,
|
|
510
|
+
widthMeters: Number(document.getElementById('widthMeters').value) || 2000,
|
|
511
|
+
transport: document.getElementById('transport').value || 'auto',
|
|
512
|
+
theme: document.getElementById('theme').value || 'town',
|
|
513
|
+
schedules: SCHEDULES.map((s,i)=>({ index: s.index !== undefined ? s.index : i, place: s.place })),
|
|
514
|
+
});
|
|
515
|
+
if (r.ok) { toast('已保存'); await loadMapData(); }
|
|
516
|
+
}
|
|
517
|
+
function addPlace(){
|
|
518
|
+
const inp = document.getElementById('newPlaceName');
|
|
519
|
+
const name = (inp.value || '').trim(); if (!name) return;
|
|
520
|
+
PLACES.push({ id: 'p-'+Date.now().toString(36), name, icon: guessIcon(name), x: 0.25 + Math.random()*0.5, y: 0.2 + Math.random()*0.6, desc: '', feel: '' });
|
|
521
|
+
inp.value = ''; renderPlaceList(); renderSchedList(); renderPins();
|
|
522
|
+
}
|
|
523
|
+
function delPlace(i){ PLACES.splice(i,1); renderPlaceList(); renderSchedList(); renderPins(); }
|
|
524
|
+
function renderCaps(){
|
|
525
|
+
const el = document.getElementById('capsLine');
|
|
526
|
+
if (!el) return;
|
|
527
|
+
const parts = [];
|
|
528
|
+
if (CAPS.age) parts.push('她 ' + CAPS.age + ' 岁' + (CAPS.minor ? '(未成年)' : ''));
|
|
529
|
+
parts.push(CAPS.license ? '有驾照' : '没有驾照');
|
|
530
|
+
if (CAPS.pref === 'cheap') parts.push('出行省钱优先');
|
|
531
|
+
if (CAPS.pref === 'fast') parts.push('出行赶时间优先');
|
|
532
|
+
applyTimeOfDay();
|
|
533
|
+
applyWeatherFx(DATA.weather);
|
|
534
|
+
const w = WEATHERS[DATA.weather] || {};
|
|
535
|
+
if (w.label) parts.push('今天' + w.icon + w.label);
|
|
536
|
+
parts.push('体力 ' + ENERGY + '/100');
|
|
537
|
+
el.textContent = '她的情况:' + parts.join(' · ');
|
|
538
|
+
const bar = document.getElementById('energyBar');
|
|
539
|
+
if (bar) bar.style.width = Math.min(100, Math.max(0, ENERGY)) + '%';
|
|
540
|
+
const et = document.getElementById('energyText');
|
|
541
|
+
if (et) et.textContent = ENERGY < 25 ? '很累,走不动远路' : ENERGY < 50 ? '有点累' : '精神不错';
|
|
542
|
+
const sel = document.getElementById('transport');
|
|
543
|
+
const carOpt = sel && sel.querySelector('option[value="car"]');
|
|
544
|
+
if (carOpt) { carOpt.disabled = !CAPS.license; if (!CAPS.license && sel.value === 'car') sel.value = 'auto'; }
|
|
545
|
+
}
|
|
546
|
+
async function goHere(i){
|
|
547
|
+
const p = PLACES[i];
|
|
548
|
+
const modeSel = document.getElementById('transport').value || 'auto';
|
|
549
|
+
const body = { mapId: DATA.map.id, placeId: p.id, mode: modeSel === 'auto' ? undefined : modeSel };
|
|
550
|
+
let r = await saveJson('/api/location', body);
|
|
551
|
+
if (r.refused && r.needsConfirm) {
|
|
552
|
+
if (!confirm(r.reason + '\n\n(确定就让她去,她会跟你报备)')) { renderCurrent(r.location); return; }
|
|
553
|
+
body.confirmNight = true;
|
|
554
|
+
r = await saveJson('/api/location', body);
|
|
555
|
+
}
|
|
556
|
+
if (r.ok) { renderCurrent(r.location); toast('她出门了:前往「' + p.name + '」'); }
|
|
557
|
+
else if (r.refused) { toast('她不去:「' + r.reason + '」'); renderCurrent(r.location); }
|
|
558
|
+
else { toast(r.error || '出行失败'); }
|
|
559
|
+
}
|
|
560
|
+
function dayPhase(h){
|
|
561
|
+
if (h >= 5 && h < 7) return 'dawn';
|
|
562
|
+
if (h >= 7 && h < 17) return 'day';
|
|
563
|
+
if (h >= 17 && h < 19.5) return 'dusk';
|
|
564
|
+
return 'night';
|
|
565
|
+
}
|
|
566
|
+
function applyTimeOfDay(){
|
|
567
|
+
const el = document.getElementById('tod');
|
|
568
|
+
if (!el) return;
|
|
569
|
+
const d = new Date();
|
|
570
|
+
el.className = dayPhase(d.getHours() + d.getMinutes() / 60);
|
|
571
|
+
}
|
|
572
|
+
function applyWeatherFx(kind){
|
|
573
|
+
const el = document.getElementById('wx');
|
|
574
|
+
if (!el || !window.CompanionWeatherFx) return;
|
|
575
|
+
CompanionWeatherFx.set(el, kind, WXFX);
|
|
576
|
+
}
|
|
577
|
+
async function loadMoneyLine(){
|
|
578
|
+
try {
|
|
579
|
+
const r = await api('/api/economy');
|
|
580
|
+
const w = r.wallet || {}; const el = document.getElementById('curMoney');
|
|
581
|
+
if (el) el.textContent = '💰 ' + (w.spendable !== undefined ? w.spendable : w.balance) + ' 元 · 🪙 剩余 token ' + Number(w.remainingTokens || 0).toLocaleString() + (w.tokenMode ? '(钱=token)' : '');
|
|
582
|
+
} catch (e) {}
|
|
583
|
+
}
|
|
584
|
+
async function loadWeatherData(){
|
|
585
|
+
try {
|
|
586
|
+
const w = await api('/api/weather');
|
|
587
|
+
const modeSel = document.getElementById('weatherMode');
|
|
588
|
+
const wSel = document.getElementById('weather');
|
|
589
|
+
if (modeSel) modeSel.value = w.mode || 'manual';
|
|
590
|
+
if (wSel) { wSel.value = (w.mode === 'real' ? (w.kind || 'sunny') : (w.manual || w.kind || 'sunny')); wSel.disabled = (w.mode === 'real'); }
|
|
591
|
+
const info = document.getElementById('realWeatherInfo');
|
|
592
|
+
if (info) {
|
|
593
|
+
info.textContent = w.mode === 'real'
|
|
594
|
+
? ('真实天气:' + w.icon + ' ' + w.label + (w.temp !== null && w.temp !== undefined ? ' · ' + w.temp + '°C' : '') + (w.city ? ' · ' + w.city : '') + (w.source === 'fallback' ? '(获取失败,用手动值)' : ''))
|
|
595
|
+
: '手动模式:按你选的天气算';
|
|
596
|
+
}
|
|
597
|
+
if (w.weathers) WEATHERS = w.weathers;
|
|
598
|
+
WXFX = w.fx || {};
|
|
599
|
+
applyWeatherFx(w.kind);
|
|
600
|
+
} catch(e) {}
|
|
601
|
+
}
|
|
602
|
+
async function uploadMapImage(input){
|
|
603
|
+
const f = input.files && input.files[0];
|
|
604
|
+
if (!f) return;
|
|
605
|
+
if (f.size > 12 * 1024 * 1024) { toast('图片太大(<12MB)'); return; }
|
|
606
|
+
const reader = new FileReader();
|
|
607
|
+
reader.onload = async () => {
|
|
608
|
+
const r = await saveJson('/api/map/image', { mapId: DATA.map.id, dataUrl: reader.result });
|
|
609
|
+
if (r.ok) { toast('底图已上传'); await loadMapData(); }
|
|
610
|
+
};
|
|
611
|
+
reader.readAsDataURL(f);
|
|
612
|
+
}
|
|
613
|
+
async function clearMapImage(){
|
|
614
|
+
const r = await saveJson('/api/map/image', { mapId: DATA.map.id, dataUrl: '' });
|
|
615
|
+
if (r.ok) { toast('底图已清除'); await loadMapData(); }
|
|
616
|
+
}
|
|
617
|
+
async function importPlacesFile(input){
|
|
618
|
+
const f = input.files && input.files[0];
|
|
619
|
+
if (!f) return;
|
|
620
|
+
const text = await f.text();
|
|
621
|
+
input.value = '';
|
|
622
|
+
if (!text.trim()) { toast('文件是空的'); return; }
|
|
623
|
+
const r = await saveJson('/api/map/import', { mapId: DATA.map.id, content: text });
|
|
624
|
+
if (r.ok) {
|
|
625
|
+
toast('导入完成:新增 ' + r.added + ' 个地点' + (r.skipped ? ',跳过重名 ' + r.skipped + ' 个' : ''));
|
|
626
|
+
await loadMapData();
|
|
627
|
+
} else { toast(r.error || '导入失败'); }
|
|
628
|
+
}
|
|
629
|
+
function exportPlaces(){
|
|
630
|
+
const data = { map: DATA.map.name, places: PLACES.map(p => ({ name: p.name, icon: p.icon, image: p.image || null, x: p.x, y: p.y, desc: p.desc, feel: p.feel })), roads: ROADS.map(l => l.map(pt => [pt.x, pt.y])) };
|
|
631
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
632
|
+
const a = document.createElement('a');
|
|
633
|
+
a.href = URL.createObjectURL(blob);
|
|
634
|
+
a.download = (DATA.map.name || 'places') + '.json';
|
|
635
|
+
a.click();
|
|
636
|
+
URL.revokeObjectURL(a.href);
|
|
637
|
+
toast('已导出 ' + PLACES.length + ' 个地点');
|
|
638
|
+
}
|
|
639
|
+
function renderRoads(){
|
|
640
|
+
const layer = document.getElementById('roadLayer');
|
|
641
|
+
if (!layer) return;
|
|
642
|
+
const lines = ROADS.map(l => l.map(pt => [pt.x, pt.y]));
|
|
643
|
+
if (ROAD_CUR.length) lines.push(ROAD_CUR.map(pt => [pt.x, pt.y]));
|
|
644
|
+
if (!lines.length) { layer.innerHTML = ''; return; }
|
|
645
|
+
const svg = '<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="position:absolute;inset:0;width:100%;height:100%">'
|
|
646
|
+
+ lines.map((l) => '<polyline points="' + l.map(pt => (pt[0]*100)+','+(pt[1]*100)).join(' ') + '" fill="none" stroke="#22c55e" stroke-width="0.6" stroke-dasharray="1.4 0.8" opacity=".9"/>').join('')
|
|
647
|
+
+ lines.map((l) => l.map(pt => '<circle cx="'+(pt[0]*100)+'" cy="'+(pt[1]*100)+'" r="0.7" fill="#86efac"/>').join('')).join('')
|
|
648
|
+
+ '</svg>';
|
|
649
|
+
layer.innerHTML = svg;
|
|
650
|
+
}
|
|
651
|
+
function toggleRoadEditor(){
|
|
652
|
+
EDITING_ROADS = !EDITING_ROADS;
|
|
653
|
+
const bar = document.getElementById('roadBar');
|
|
654
|
+
if (bar) bar.style.display = EDITING_ROADS ? 'flex' : 'none';
|
|
655
|
+
if (EDITING_ROADS && !ROAD_CUR.length) ROAD_CUR = [];
|
|
656
|
+
renderRoads();
|
|
657
|
+
toast(EDITING_ROADS ? '画道路模式:点地图加点,点「保存道路」生效' : '已退出画道路');
|
|
658
|
+
}
|
|
659
|
+
function roadNew(){
|
|
660
|
+
if (ROAD_CUR.length >= 2) ROADS.push(ROAD_CUR);
|
|
661
|
+
ROAD_CUR = []; renderRoads();
|
|
662
|
+
}
|
|
663
|
+
function roadUndo(){ ROAD_CUR.pop(); renderRoads(); }
|
|
664
|
+
function roadClear(){ ROADS = []; ROAD_CUR = []; renderRoads(); toast('已清空道路(记得保存)'); }
|
|
665
|
+
async function roadSave(){
|
|
666
|
+
if (ROAD_CUR.length >= 2) { ROADS.push(ROAD_CUR); ROAD_CUR = []; }
|
|
667
|
+
const roads = ROADS.map(l => l.map(pt => [Number(pt.x.toFixed(4)), Number(pt.y.toFixed(4))]));
|
|
668
|
+
const r = await saveJson('/api/map', { mapId: DATA.map.id, roads });
|
|
669
|
+
if (r.ok) { toast('道路已保存(' + roads.length + ' 条)'); await loadMapData(); }
|
|
670
|
+
else toast(r.error || '保存失败');
|
|
671
|
+
}
|
|
672
|
+
async function uploadPlaceImage(i, input){
|
|
673
|
+
const f = input.files && input.files[0];
|
|
674
|
+
if (!f) return;
|
|
675
|
+
if (f.size > 6 * 1024 * 1024) { toast('图片太大(<6MB)'); return; }
|
|
676
|
+
const reader = new FileReader();
|
|
677
|
+
reader.onload = async () => {
|
|
678
|
+
const r = await saveJson('/api/place/image', { mapId: DATA.map.id, placeId: PLACES[i].id, dataUrl: reader.result });
|
|
679
|
+
if (r.ok) { toast('图标已换'); await loadMapData(); }
|
|
680
|
+
};
|
|
681
|
+
reader.readAsDataURL(f);
|
|
682
|
+
}
|
|
683
|
+
async function clearPlaceImage(i){
|
|
684
|
+
const r = await saveJson('/api/place/image', { mapId: DATA.map.id, placeId: PLACES[i].id, dataUrl: '' });
|
|
685
|
+
if (r.ok) { toast('已恢复 emoji 图标'); await loadMapData(); }
|
|
686
|
+
}
|
|
687
|
+
async function useBuiltinScene(){
|
|
688
|
+
const r = await saveJson('/api/map', {
|
|
689
|
+
mapId: DATA.map.id,
|
|
690
|
+
displayMode: '3d',
|
|
691
|
+
model: { url: '/models/demo-town2.gltf', scale: 1, rotationY: 0, yOffset: 0 },
|
|
692
|
+
});
|
|
693
|
+
if (r.ok) { toast('已切到内置 3D 场景(打开 3D 舞台查看)'); await loadMapData(); }
|
|
694
|
+
else toast(r.error || '切换失败');
|
|
695
|
+
}
|
|
696
|
+
async function saveDisplayMode(){
|
|
697
|
+
const mode = document.getElementById('displayMode').value;
|
|
698
|
+
const r = await saveJson('/api/map', { mapId: DATA.map.id, displayMode: mode });
|
|
699
|
+
if (r.ok) { toast(mode === '3d' ? '已切到 3D 视图(打开 /model 查看)' : '已切到 2D 平面视图'); await loadMapData(); }
|
|
700
|
+
}
|
|
701
|
+
async function saveMapImageUrl(){
|
|
702
|
+
const url = document.getElementById('mapImgUrl').value.trim();
|
|
703
|
+
await saveJson('/api/map', { mapId: DATA.map.id, image: url ? { url } : null });
|
|
704
|
+
await loadMapData(); toast(url ? '底图地址已设置' : '底图已清除');
|
|
705
|
+
}
|
|
706
|
+
async function saveMapModel(){
|
|
707
|
+
const dm = document.getElementById('displayMode') ? document.getElementById('displayMode').value : (DATA.map.displayMode || '2d');
|
|
708
|
+
const hint = document.getElementById('modelHint');
|
|
709
|
+
if (hint) hint.textContent = dm === '3d' ? '已应用:这些参数只影响 3D 舞台(/model)' : '注意:当前是 2D 视图,模型参数只在切到 3D 视图后才看得出效果';
|
|
710
|
+
const url = document.getElementById('mmUrl').value.trim();
|
|
711
|
+
const model = url ? {
|
|
712
|
+
url,
|
|
713
|
+
scale: Number(document.getElementById('mmScale').value) || 1,
|
|
714
|
+
rotationY: Number(document.getElementById('mmRot').value) || 0,
|
|
715
|
+
yOffset: Number(document.getElementById('mmYoff').value) || 0,
|
|
716
|
+
} : null;
|
|
717
|
+
const r = await saveJson('/api/map', { mapId: DATA.map.id, model });
|
|
718
|
+
if (r.ok) { toast(model ? '地图模型已设置,打开 /model 查看' : '已清除地图模型'); await loadMapData(); }
|
|
719
|
+
}
|
|
720
|
+
async function saveWeatherMode(){
|
|
721
|
+
const mode = document.getElementById('weatherMode').value;
|
|
722
|
+
await saveJson('/api/weather', { mode });
|
|
723
|
+
await loadWeatherData(); await loadMapData(); toast(mode === 'real' ? '已切换真实天气' : '已切换手动天气');
|
|
724
|
+
}
|
|
725
|
+
async function relocateWeather(){
|
|
726
|
+
await saveJson('/api/weather', { mode: 'real', refresh: true });
|
|
727
|
+
await loadWeatherData(); toast('已重新定位并刷新天气');
|
|
728
|
+
}
|
|
729
|
+
async function saveWeather(){
|
|
730
|
+
const w = document.getElementById('weather').value;
|
|
731
|
+
await saveJson('/api/weather', { mode: 'manual', manual: w });
|
|
732
|
+
await loadWeatherData(); await loadMapData(); toast('天气已切换');
|
|
733
|
+
}
|
|
734
|
+
async function toggleFreeWalk(){
|
|
735
|
+
const val = document.getElementById('freeWalk').checked;
|
|
736
|
+
await saveJson('/api/life', { locationFree: val });
|
|
737
|
+
toast(val ? '已开启:她可以自由走动' : '已关闭:她会待着不动');
|
|
738
|
+
}
|
|
739
|
+
async function switchMap(id){
|
|
740
|
+
if (!DATA || id === DATA.activeMapId) return;
|
|
741
|
+
const mode = document.getElementById('longMode').value || 'auto';
|
|
742
|
+
const target = DATA.maps.find(m => m.id === id);
|
|
743
|
+
if (!confirm('让她坐长途去「' + (target ? target.name : id) + '」?')) { await loadMapData(); return; }
|
|
744
|
+
let r = await saveJson('/api/map/activate', { id, mode: mode === 'auto' ? undefined : mode });
|
|
745
|
+
if (r.refused && r.needsConfirm) {
|
|
746
|
+
if (!confirm(r.reason + '\n\n(确定就让她去,她会跟你报备)')) { await loadMapData(); return; }
|
|
747
|
+
r = await saveJson('/api/map/activate', { id, mode: mode === 'auto' ? undefined : mode, confirmNight: true });
|
|
748
|
+
}
|
|
749
|
+
if (r.refused) { toast('她不去:「' + r.reason + '」'); await loadMapData(); return; }
|
|
750
|
+
await loadMapData(); await loadMapData();
|
|
751
|
+
toast('她出发去长途了');
|
|
752
|
+
}
|
|
753
|
+
async function renameMap(){
|
|
754
|
+
const name = prompt('这张地图叫什么?', DATA.map.name);
|
|
755
|
+
if (!name || !name.trim()) return;
|
|
756
|
+
DATA.map.name = name.trim();
|
|
757
|
+
await saveJson('/api/map', { mapId: DATA.map.id, name: DATA.map.name, places: PLACES });
|
|
758
|
+
await loadMapData();
|
|
759
|
+
}
|
|
760
|
+
async function newMap(){
|
|
761
|
+
const name = prompt('新地图叫什么?', '新小镇');
|
|
762
|
+
if (!name || !name.trim()) return;
|
|
763
|
+
await saveJson('/api/map/create', { name: name.trim() });
|
|
764
|
+
await loadMapData();
|
|
765
|
+
}
|
|
766
|
+
async function delMap(){
|
|
767
|
+
if (DATA.maps.length <= 1) { toast('至少保留一张地图'); return; }
|
|
768
|
+
if (!confirm('确定删除「' + DATA.map.name + '」这张地图?')) return;
|
|
769
|
+
await saveJson('/api/map/delete', { id: DATA.map.id });
|
|
770
|
+
await loadMapData();
|
|
771
|
+
}
|
|
772
|
+
window.addEventListener('resize', renderScale);
|
|
773
|
+
setInterval(applyTimeOfDay, 60000);
|
|
774
|
+
(async function init(){
|
|
775
|
+
try {
|
|
776
|
+
await loadMapData();
|
|
777
|
+
await loadRoutine();
|
|
778
|
+
try { const life = await api('/api/life'); document.getElementById('freeWalk').checked = life.life.locationFree !== false; } catch(e){}
|
|
779
|
+
} catch(e) {
|
|
780
|
+
document.getElementById('mapWrap').insertAdjacentHTML('beforeend', '<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#ff9c9c">地图加载失败:'+esc(e.message)+'</div>');
|
|
781
|
+
}
|
|
782
|
+
})();
|
|
783
|
+
</script>
|
|
784
|
+
</body></html>
|