合并外部维护版本并同步自动每日功能

This commit is contained in:
2026-09-28 13:55:07 +08:00
parent 2d81615f78
commit 9040bcb5e5
59 changed files with 3176 additions and 179 deletions
+715 -18
View File
@@ -46,10 +46,10 @@
#viewport {
width: 100vw;
height: 100vh;
position: relative;
display: flex;
align-items: flex-start;
justify-content: flex-start;
background: transparent;
justify-content: flex-start; background: transparent;
overflow: hidden;
}
@@ -62,9 +62,13 @@
#canvas {
width: 1920px;
height: 1080px;
position: relative;
position: absolute;
left: 50%;
top: 50%;
margin-left: -960px;
margin-top: -540px;
background: transparent;
transform-origin: top left;
transform-origin: center center;
}
#frame {
@@ -1728,6 +1732,85 @@
body.theme-aurora .tutorial-text {
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.5), 0 0 16px rgba(150, 180, 255, 0.22);
}
/* 桌宠与状态环作为一个整体,只在游戏镂空区外游走。 */
.kirara-wanderer {
position: absolute;
left: 0;
top: 0;
z-index: 12;
width: 172px;
height: 140px;
display: flex;
align-items: center;
gap: 6px;
pointer-events: none;
will-change: transform;
}
.kirara-sprite {
width: 126px;
height: 140px;
flex: none;
object-fit: contain;
image-rendering: pixelated;
filter: drop-shadow(0 5px 1px rgba(5, 22, 18, .8));
}
.kirara-status {
width: 40px;
height: 40px;
flex: none;
overflow: visible;
filter: drop-shadow(0 2px 1px rgba(7, 22, 21, .8));
}
.kirara-ring-track, .kirara-ring-progress { shape-rendering: crispEdges; }
.kirara-ring-track.stamina { fill: #65562e; }
.kirara-ring-track.cleanliness { fill: #28566a; }
.kirara-ring-track.mood { fill: #70413d; }
.kirara-ring-progress.stamina { fill: #f8d76c; }
.kirara-ring-progress.cleanliness { fill: #7ccdf5; }
.kirara-ring-progress.mood { fill: #f58382; }
.kirara-need-bubble {
position: absolute;
z-index: 2;
box-sizing: border-box;
padding: 8px 11px;
border: 3px solid #3d3034;
background: #fff0c9;
color: #49372f;
box-shadow: 4px 4px 0 rgba(18, 33, 29, .65), inset 0 0 0 2px #fff9e5;
font-size: 16px;
font-weight: 800;
line-height: 22px;
letter-spacing: .2px;
white-space: nowrap;
text-shadow: none;
}
.kirara-need-bubble[hidden] { display: none; }
.kirara-need-bubble::after {
content: "";
position: absolute;
width: 9px;
height: 9px;
background: #fff0c9;
border: 3px solid #3d3034;
}
.kirara-need-bubble[data-place="side"]::after {
left: -12px;
top: 17px;
border-right: 0;
border-top: 0;
}
.kirara-need-bubble[data-place="above"]::after {
left: 39px;
bottom: -12px;
border-left: 0;
border-top: 0;
}
.kirara-need-bubble[data-place="below"]::after {
left: 39px;
top: -12px;
border-right: 0;
border-bottom: 0;
}
</style>
</head>
<body>
@@ -1749,6 +1832,20 @@
<!-- game-cutout 放在 #frame 外面, 不受 clip-path 裁剪, 保证镂空区域完全透明(OBS 穿透) -->
<div id="game-cutout"></div>
<!-- 镂空区外游走的桌宠;三层状态环跟随角色,不显示文字。 -->
<div id="kirara-wanderer" class="kirara-wanderer" aria-label="绮良良桌宠状态">
<canvas id="kirara-sprite" class="kirara-sprite" width="126" height="140" role="img" aria-label="像素绮良良桌宠"></canvas>
<svg class="kirara-status" viewBox="0 0 80 80" role="img" aria-label="外圈黄色体力、中圈蓝色清洁、内圈红色心情">
<path id="kirara-stamina-track" class="kirara-ring-track stamina"/>
<path id="kirara-stamina-ring" class="kirara-ring-progress stamina"/>
<path id="kirara-cleanliness-track" class="kirara-ring-track cleanliness"/>
<path id="kirara-cleanliness-ring" class="kirara-ring-progress cleanliness"/>
<path id="kirara-mood-track" class="kirara-ring-track mood"/>
<path id="kirara-mood-ring" class="kirara-ring-progress mood"/>
</svg>
<div id="kirara-need-bubble" class="kirara-need-bubble" role="status" aria-live="polite" hidden></div>
</div>
<!-- 游戏画面左侧:可用配置组 -->
<section class="panel available-groups-panel">
<div class="panel-title">可用配置组</div>
@@ -2120,14 +2217,603 @@
let performanceState = {cpu: null, memory: null, gpu: null, gpu_available: false};
let lastCoverKey = "";
// 在 2px 网格上逐格绘制,轮廓保持像素阶梯,进度仍可随数值变化。
const kiraraPixelRings = Object.fromEntries(
[["stamina", 32], ["cleanliness", 23], ["mood", 14]].map(([key, radius]) => {
const tiles = [];
for (let y = 4; y < 76; y += 2) {
for (let x = 4; x < 76; x += 2) {
const dx = x + 1 - 40;
const dy = y + 1 - 40;
if (Math.abs(Math.hypot(dx, dy) - radius) > 3.5) continue;
const angle = (Math.atan2(dy, dx) + Math.PI / 2 + Math.PI * 2) % (Math.PI * 2);
tiles.push({angle, path: `M${x} ${y}h2v2h-2z`});
}
}
document.getElementById("kirara-" + key + "-track")
.setAttribute("d", tiles.map(tile => tile.path).join(""));
return [key, tiles];
})
);
let kiraraSleeping = false;
let kiraraSleepStartedAt = 0;
const kiraraNeedMessages = {
stamina: ["饿饿,投喂一个饭团叭~", "饿饿,投喂一个薯条叭~"],
cleanliness: ["脏脏,投喂一个人气票叭~", "脏脏,投喂一个粉丝团灯牌叭~"]
};
const kiraraNeedValues = {stamina: 100, cleanliness: 100};
let kiraraNeedNextAt = null;
let kiraraNeedCurrent = null;
let kiraraNeedLastType = null;
function kiraraNeedDuration(value) {
if (value > 60) return 0;
if (value <= 0) return Infinity;
if (value >= 40) return (5 + (60 - value) / 4) * 1000;
if (value >= 20) return (10 + (40 - value)) * 1000;
return (30 + (20 - value) * 1.5) * 1000;
}
function updateKiraraNeedBubble(now) {
const bubble = document.getElementById("kirara-need-bubble");
const eligible = ["stamina", "cleanliness"]
.filter(type => kiraraNeedDuration(kiraraNeedValues[type]) > 0);
if (!eligible.length) {
kiraraNeedCurrent = null;
kiraraNeedNextAt = null;
bubble.hidden = true;
return;
}
if (kiraraNeedCurrent && !eligible.includes(kiraraNeedCurrent.type)) {
kiraraNeedCurrent = null;
kiraraNeedNextAt = now;
}
if (kiraraNeedNextAt === null) kiraraNeedNextAt = now;
if (now >= kiraraNeedNextAt) {
const empty = eligible.filter(type => kiraraNeedValues[type] === 0);
const choices = empty.length ? empty : eligible;
const type = choices.length === 1 ? choices[0]
: (kiraraNeedLastType && choices.includes(kiraraNeedLastType)
? choices.find(candidate => candidate !== kiraraNeedLastType)
: choices.reduce((a, b) => kiraraNeedValues[a] <= kiraraNeedValues[b] ? a : b));
const messages = kiraraNeedMessages[type];
kiraraNeedCurrent = {type, startedAt: now};
kiraraNeedLastType = type;
kiraraNeedNextAt = now + 60000;
bubble.textContent = messages[Math.floor(Math.random() * messages.length)];
}
const active = kiraraNeedCurrent &&
now - kiraraNeedCurrent.startedAt < kiraraNeedDuration(
kiraraNeedValues[kiraraNeedCurrent.type]);
bubble.hidden = !active;
if (!active) return;
if (kiraraPosition.x < 1306) {
bubble.dataset.place = "side";
bubble.style.left = "178px";
bubble.style.top = "19px";
} else {
const below = kiraraPosition.y < 85;
bubble.dataset.place = below ? "below" : "above";
bubble.style.left = `${Math.round(Math.min(kiraraPosition.x + 20, 1630) - kiraraPosition.x)}px`;
bubble.style.top = below ? "144px" : "-61px";
}
}
function renderKirara(pet) {
if (!pet) return;
for (const [key, tiles] of Object.entries(kiraraPixelRings)) {
const value = Math.max(0, Math.min(100, Number(pet[key]) || 0));
if (key in kiraraNeedValues) kiraraNeedValues[key] = value;
const ring = document.getElementById("kirara-" + key + "-ring");
ring.setAttribute("d", tiles
.filter(tile => tile.angle < value / 100 * Math.PI * 2)
.map(tile => tile.path).join(""));
}
const mood = Number(pet.mood);
if (!Number.isFinite(mood) || (mood < 50) === kiraraSleeping) return;
kiraraSleeping = mood < 50;
if (kiraraSleeping) {
kiraraSleepStartedAt = performance.now();
kiraraWalkEndsAt = 0;
kiraraWaypoints.length = 0;
kiraraIdle = null;
kiraraDirection = "front";
kiraraDrawnFrameKey = ""; // 强制立即重绘第一帧睡觉姿势
} else {
kiraraNextWalkAt = null;
kiraraNextIdleAt = null;
}
}
// Crops follow each generated sprite's transparent silhouette.
const kiraraSpriteBoxes = {
"kirara_walk_front": [[133,15,407,670], [617,15,392,665], [1126,15,503,672], [1629,15,388,664]],
"kirara_walk_back": [[68,19,420,691], [563,27,416,685], [1064,19,416,692], [1561,19,420,692]],
"kirara_walk_left": [[136,28,407,679], [543,28,537,679], [1151,28,446,679], [1673,28,448,679]],
"kirara_walk_right": [[33,136,397,691], [456,133,399,694], [894,137,419,690], [1330,137,408,690]],
"kirara_eat_rice": [[14,42,425,681], [510,42,424,681], [1005,42,425,681], [1502,42,424,681]],
"kirara_eat_dango": [[49,13,443,690], [592,13,443,690], [1135,13,443,690], [1677,13,444,690]],
"kirara_bath_sponge": [[62,21,437,677], [592,21,429,677], [1143,21,450,677], [1686,21,432,677]],
"kirara_bath_shower": [[62,24,405,676], [618,24,389,676], [1133,24,437,676], [1639,102,492,599]],
"kirara_idle_delivery": [[92,23,436,676], [580,21,460,678], [1118,23,444,676], [1647,23,433,676]],
"kirara_idle_reading": [[59,14,425,692], [601,14,428,692], [1144,44,427,662], [1687,13,424,693]],
"kirara_idle_phone": [[138,19,405,682], [543,19,484,681], [1118,20,511,682], [1629,18,405,683]],
"kirara_idle_sleep": [[72,81,471,595], [605,128,467,548], [1122,99,469,577], [1647,69,465,607]],
"kirara_idle_stretch": [[65,133,365,587], [527,133,430,587], [1022,154,450,566], [1553,134,365,586]],
"kirara_idle_groom": [[84,38,413,650], [612,38,409,650], [1153,38,421,650], [1696,38,416,650]],
"kirara_idle_butterfly": [[46,138,418,592], [537,138,430,592], [1061,123,381,607], [1515,111,424,619]],
"kirara_idle_tea": [[66,13,438,684], [609,13,437,684], [1153,13,436,684], [1695,13,436,684]],
"kirara_idle_yarn": [[108,26,419,663], [606,26,418,663], [1149,26,418,663], [1646,26,419,663]],
"kirara_idle_humming": [[76,71,420,671], [561,72,417,670], [1005,72,416,670], [1487,71,420,671]],
"kirara_rest_breathe": [[23,16,426,679], [571,15,420,680], [1104,20,433,675], [1664,16,424,679]],
"kirara_emotion_plus3_a": [[69,59,429,627], [622,33,411,594], [1139,10,426,583], [1676,71,428,615]],
"kirara_emotion_plus3_b": [[56,35,459,654], [573,35,504,654], [1135,32,458,657], [1653,35,466,654]],
"kirara_emotion_plus1_a": [[65,14,415,678], [611,14,412,678], [1133,14,431,678], [1695,14,413,678]],
"kirara_emotion_plus1_b": [[63,16,435,683], [608,37,447,664], [1136,28,430,673], [1679,16,432,683]],
"kirara_emotion_minus1_a": [[73,30,440,662], [606,30,433,662], [1153,30,415,662], [1664,30,438,662]],
"kirara_emotion_minus1_b": [[146,49,397,638], [543,47,501,640], [1151,44,408,643], [1637,49,418,638]],
"kirara_emotion_minus3_a": [[52,61,427,644], [534,71,495,634], [1081,61,474,644], [1555,67,476,638]],
"kirara_emotion_minus3_b": [[52,27,450,665], [598,23,466,669], [1139,15,472,679], [1676,29,444,663]],
};
const kiraraSpriteAsset = name => ({
src: `/uploads/${name}.png`, boxes: kiraraSpriteBoxes[name]
});
const kiraraWalkAssets = Object.fromEntries(
["front", "back", "left", "right"].map(direction =>
[direction, kiraraSpriteAsset(`kirara_walk_${direction}`)])
);
const kiraraActionAssets = {
feed: [kiraraSpriteAsset("kirara_eat_rice"), kiraraSpriteAsset("kirara_eat_dango")],
bath: [kiraraSpriteAsset("kirara_bath_sponge"), kiraraSpriteAsset("kirara_bath_shower")]
};
const kiraraIdleAssets = Object.fromEntries(
["delivery", "reading", "phone", "sleep", "stretch",
"groom", "butterfly", "tea", "yarn", "humming"].map(type =>
[type, kiraraSpriteAsset(`kirara_idle_${type}`)])
);
const kiraraRestAsset = kiraraSpriteAsset("kirara_rest_breathe");
const kiraraEmotionAssets = {
"3": [kiraraSpriteAsset("kirara_emotion_plus3_a"), kiraraSpriteAsset("kirara_emotion_plus3_b")],
"1": [kiraraSpriteAsset("kirara_emotion_plus1_a"), kiraraSpriteAsset("kirara_emotion_plus1_b")],
"-1": [kiraraSpriteAsset("kirara_emotion_minus1_a"), kiraraSpriteAsset("kirara_emotion_minus1_b")],
"-3": [kiraraSpriteAsset("kirara_emotion_minus3_a"), kiraraSpriteAsset("kirara_emotion_minus3_b")],
};
let kiraraDirection = "front";
let kiraraDrawnFrameKey = "";
const kiraraActionQueue = [];
const kiraraActionVariant = {feed: 0, bath: 0};
let kiraraAction = null;
const kiraraIdleTypes = ["delivery", "reading", "phone", "stretch", "groom",
"butterfly", "tea", "yarn", "humming"];
let kiraraIdle = null;
let kiraraNextIdleAt = null;
let kiraraRestActive = false;
let kiraraRestStartedAt = 0;
const kiraraEmotionQueue = [];
const kiraraEmotionVariant = {"3": 0, "1": 0, "-1": 0, "-3": 0};
const kiraraEmotionSeen = new Set();
const kiraraEmotionSeenOrder = [];
const kiraraEmotionOpenedAtClientTs = Date.now() / 1000;
let kiraraEmotionBaseline = null;
let kiraraEmotionAction = null;
function drawKiraraSpriteFrame(asset, frame, key, action) {
if (!asset.image?.complete || !asset.image.naturalWidth || key === kiraraDrawnFrameKey) return;
const box = asset.boxes[frame];
if (!box) return;
// 源矩形越界夹紧:部分浏览器对越界 drawImage 会整帧不绘制,
// 配合 clearRect 会表现为"桌宠消失"。
let [sx, sy, sw, sh] = box;
const maxX = asset.image.naturalWidth, maxY = asset.image.naturalHeight;
if (sx < 0) { sw += sx; sx = 0; }
if (sy < 0) { sh += sy; sy = 0; }
if (sx + sw > maxX) sw = maxX - sx;
if (sy + sh > maxY) sh = maxY - sy;
if (sw <= 0 || sh <= 0) return;
try {
const canvas = document.getElementById("kirara-sprite");
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, 126, 140);
ctx.imageSmoothingEnabled = false;
const scale = Math.min(
130 / Math.max(...asset.boxes.map(box => box[3])),
120 / Math.max(...asset.boxes.map(box => box[2]))
);
const width = sw * scale;
const height = sh * scale;
ctx.drawImage(asset.image, sx, sy, sw, sh,
(126 - width) / 2, 136 - height, width, height);
canvas.dataset.action = action;
canvas.dataset.direction = action ? "front" : kiraraDirection;
canvas.dataset.frame = String(frame);
kiraraDrawnFrameKey = key;
} catch (e) {
console.error("kirara draw", e);
window.__kiraraError = String(e && e.stack || e);
}
}
function drawKiraraWalkFrame(direction, frame) {
drawKiraraSpriteFrame(kiraraWalkAssets[direction], frame,
"walk:" + direction + ":" + frame, "");
}
function drawKiraraActionFrame(action, frame) {
drawKiraraSpriteFrame(kiraraActionAssets[action.type][action.variant], frame,
"action:" + action.type + ":" + action.variant + ":" + frame, action.type);
}
function drawKiraraIdleFrame(idle, phase) {
drawKiraraSpriteFrame(kiraraIdleAssets[idle.type], phase,
"idle:" + idle.type + ":" + phase, idle.type);
}
function drawKiraraSleepFrame(phase) {
drawKiraraSpriteFrame(kiraraIdleAssets.sleep, phase,
"sleep:" + phase, "sleep");
}
function drawKiraraRestFrame(phase) {
drawKiraraSpriteFrame(kiraraRestAsset, phase,
"rest:" + phase, "rest");
}
function drawKiraraEmotionFrame(action, frame) {
drawKiraraSpriteFrame(kiraraEmotionAssets[String(action.delta)][action.variant], frame,
"emotion:" + action.delta + ":" + action.variant + ":" + frame,
"emotion");
}
for (const [direction, asset] of Object.entries(kiraraWalkAssets)) {
asset.image = new Image();
asset.image.onload = () => {
if (!kiraraSleeping && !kiraraIdle && !kiraraAction && !kiraraEmotionAction && kiraraDirection === direction) {
kiraraDrawnFrameKey = "";
drawKiraraWalkFrame(direction, 1);
}
};
asset.image.src = asset.src;
}
for (const [type, variants] of Object.entries(kiraraActionAssets)) {
for (const [variant, asset] of variants.entries()) {
asset.image = new Image();
asset.image.onload = () => {
if (!kiraraSleeping && kiraraAction?.type === type && kiraraAction.variant === variant) {
kiraraDrawnFrameKey = "";
drawKiraraActionFrame(kiraraAction, 0);
}
};
asset.image.src = asset.src;
}
}
for (const [type, asset] of Object.entries(kiraraIdleAssets)) {
asset.image = new Image();
asset.image.onload = () => {
if (!kiraraAction && !kiraraEmotionAction && ((type === "sleep" && kiraraSleeping) ||
(!kiraraSleeping && kiraraIdle?.type === type))) {
kiraraDrawnFrameKey = "";
if (type === "sleep") drawKiraraSleepFrame(0);
else drawKiraraIdleFrame(kiraraIdle, 0);
}
};
asset.image.src = asset.src;
}
kiraraRestAsset.image = new Image();
kiraraRestAsset.image.onload = () => {
if (kiraraRestActive && !kiraraSleeping && !kiraraIdle &&
!kiraraAction && !kiraraEmotionAction) {
kiraraDrawnFrameKey = "";
drawKiraraRestFrame(Math.floor((performance.now() - kiraraRestStartedAt) / 900) % 4);
}
};
kiraraRestAsset.image.src = kiraraRestAsset.src;
for (const [delta, variants] of Object.entries(kiraraEmotionAssets)) {
for (const [variant, asset] of variants.entries()) {
asset.image = new Image();
asset.image.onload = () => {
if (!kiraraAction && kiraraEmotionAction?.delta === Number(delta) &&
kiraraEmotionAction.variant === variant) {
kiraraDrawnFrameKey = "";
drawKiraraEmotionFrame(kiraraEmotionAction, 0);
}
};
asset.image.src = asset.src;
}
}
// 以完整桌宠(含状态环)的外接矩形规划路径。上、右、下三条通道
// 均与游戏镂空矩形保持至少 16px 间距,转弯经过通道交叠处。
const kiraraPosition = {x: 1450, y: 590};
const kiraraWaypoints = [];
let kiraraSpeed = 25;
let kiraraNextWalkAt = null;
let kiraraWalkStartedAt = 0;
let kiraraWalkEndsAt = 0;
let kiraraLastFrame = 0;
let kiraraStepDistance = 0;
function kiraraRandom(min, max) {
return min + Math.random() * (max - min);
}
function kiraraPoint(zone) {
if (zone === "top") return {x: kiraraRandom(16, 1682), y: kiraraRandom(16, 54)};
if (zone === "bottom") return {x: kiraraRandom(16, 1682), y: kiraraRandom(866, 924)};
return {x: kiraraRandom(1306, 1682), y: kiraraRandom(16, 924)};
}
function kiraraJunction(edge) {
return {x: kiraraRandom(1306, 1682),
y: edge === "top" ? kiraraRandom(16, 54) : kiraraRandom(866, 924)};
}
function appendKiraraOrthogonalPoint(point) {
const from = kiraraWaypoints[kiraraWaypoints.length - 1] || kiraraPosition;
if (Math.abs(point.x - from.x) > 0.01 && Math.abs(point.y - from.y) > 0.01) {
kiraraWaypoints.push(Math.random() < 0.5
? {x: point.x, y: from.y}
: {x: from.x, y: point.y});
}
kiraraWaypoints.push(point);
}
function chooseKiraraRoute() {
const kiraraZone = kiraraPosition.x >= 1306 ? "right"
: kiraraPosition.y <= 54 ? "top" : "bottom";
const zones = ["top", "right", "bottom"];
const destination = zones[Math.floor(Math.random() * zones.length)];
if (destination !== kiraraZone) {
if (kiraraZone !== "right" && destination !== "right") {
appendKiraraOrthogonalPoint(kiraraJunction(kiraraZone));
appendKiraraOrthogonalPoint(kiraraJunction(destination));
} else {
appendKiraraOrthogonalPoint(kiraraJunction(
kiraraZone === "right" ? destination : kiraraZone));
}
}
appendKiraraOrthogonalPoint(kiraraPoint(destination));
}
function advanceKirara(distanceBudget) {
// 一帧只走一条水平或垂直路段,转角处不出现斜向位移。
for (let i = 0; distanceBudget > 0.01 && i < 64; i++) {
if (kiraraWaypoints.length === 0) chooseKiraraRoute();
const next = kiraraWaypoints[0];
const dx = next.x - kiraraPosition.x;
const dy = next.y - kiraraPosition.y;
const distance = Math.abs(dx) + Math.abs(dy);
if (distance <= 0.01) {
kiraraWaypoints.shift();
continue;
}
kiraraDirection = Math.abs(dx) > 0.01
? (dx > 0 ? "right" : "left")
: (dy > 0 ? "front" : "back");
if (distance <= distanceBudget) {
kiraraPosition.x = next.x;
kiraraPosition.y = next.y;
kiraraStepDistance += distance;
kiraraWaypoints.shift();
} else {
if (Math.abs(dx) > 0.01) kiraraPosition.x += Math.sign(dx) * distanceBudget;
else kiraraPosition.y += Math.sign(dy) * distanceBudget;
kiraraStepDistance += distanceBudget;
}
break;
}
}
function queueKiraraGiftAnimations(gift) {
// 动画按礼物名义恢复量计算;数值满 100 时照样播放。
for (const [type, field] of [["feed", "pet_animation_stamina"], ["bath", "pet_animation_cleanliness"]]) {
const raw = Number(gift[field]);
const points = Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0;
if (points > 0) kiraraActionQueue.push({type, remaining: points});
}
}
function syncKiraraEmotionEvents(events, serverTs) {
if (!Array.isArray(events)) return;
if (kiraraEmotionBaseline === null) {
const serverNow = Number(serverTs);
const clientNow = Date.now() / 1000;
kiraraEmotionBaseline = Number.isFinite(serverNow) && serverNow > 0
? serverNow - (clientNow - kiraraEmotionOpenedAtClientTs)
: kiraraEmotionOpenedAtClientTs;
}
for (const event of events) {
const id = String(event.id || "");
const delta = Number(event.delta);
if (!id || !kiraraEmotionAssets[String(delta)] ||
Number(event.ts) <= kiraraEmotionBaseline || kiraraEmotionSeen.has(id)) continue;
kiraraEmotionSeen.add(id);
kiraraEmotionSeenOrder.push(id);
if (kiraraEmotionSeenOrder.length > 128) {
kiraraEmotionSeen.delete(kiraraEmotionSeenOrder.shift());
}
kiraraEmotionQueue.push({delta});
}
}
function updateKiraraEmotionAction(now) {
if (!kiraraEmotionAction && kiraraEmotionQueue.length) {
const job = kiraraEmotionQueue.shift();
kiraraWalkEndsAt = 0;
kiraraWaypoints.length = 0;
kiraraIdle = null;
kiraraDirection = "front";
kiraraEmotionAction = {
delta: job.delta,
startedAt: now,
endsAt: now + 3000
};
const key = String(kiraraEmotionAction.delta);
kiraraEmotionAction.variant = job.variant ?? kiraraEmotionVariant[key]++ % 2;
}
if (kiraraEmotionAction && now >= kiraraEmotionAction.endsAt) {
kiraraEmotionAction = null;
kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
return updateKiraraEmotionAction(now);
}
return kiraraEmotionAction;
}
function nextKiraraAction(startedAt) {
const job = kiraraActionQueue[0];
if (!job) return null;
job.remaining--;
if (job.remaining === 0) kiraraActionQueue.shift();
const variant = kiraraActionVariant[job.type]++ % 2;
return {type: job.type, variant, startedAt, endsAt: startedAt + 5000};
}
function updateKiraraAction(now) {
if (!kiraraAction && kiraraActionQueue.length) {
if (kiraraEmotionAction) {
kiraraEmotionQueue.unshift({
delta: kiraraEmotionAction.delta,
variant: kiraraEmotionAction.variant
});
kiraraEmotionAction = null;
}
kiraraWalkEndsAt = 0;
kiraraWaypoints.length = 0;
kiraraIdle = null;
kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
kiraraDirection = "front";
kiraraAction = nextKiraraAction(now);
}
while (kiraraAction && now >= kiraraAction.endsAt) {
if (kiraraActionQueue.length) {
kiraraAction = nextKiraraAction(kiraraAction.endsAt);
} else {
kiraraAction = null;
kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
}
}
return kiraraAction;
}
function animateKirara(now) {
// 任何一帧异常都不允许杀死渲染循环(否则桌宠永久消失)。
try {
updateKiraraNeedBubble(now);
const action = updateKiraraAction(now);
if (action) {
kiraraRestActive = false;
kiraraLastFrame = now;
document.getElementById("kirara-wanderer").style.transform =
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
const bob = -Math.abs(Math.sin((now - action.startedAt) / 500 * Math.PI)) * 2;
document.getElementById("kirara-sprite").style.transform =
`translateY(${bob.toFixed(1)}px)`;
drawKiraraActionFrame(action, Math.floor((now - action.startedAt) / 260) % 4);
requestAnimationFrame(animateKirara);
return;
}
const emotionAction = updateKiraraEmotionAction(now);
if (emotionAction) {
kiraraRestActive = false;
kiraraLastFrame = now;
document.getElementById("kirara-wanderer").style.transform =
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
document.getElementById("kirara-sprite").style.transform = "none";
drawKiraraEmotionFrame(emotionAction,
Math.min(3, Math.floor((now - emotionAction.startedAt) / 650)));
requestAnimationFrame(animateKirara);
return;
}
if (kiraraSleeping) {
kiraraRestActive = false;
kiraraLastFrame = now;
document.getElementById("kirara-wanderer").style.transform =
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
document.getElementById("kirara-sprite").style.transform = "none";
drawKiraraSleepFrame(Math.floor((now - kiraraSleepStartedAt) / 650) % 4);
requestAnimationFrame(animateKirara);
return;
}
if (kiraraNextIdleAt === null) kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
if (kiraraIdle && now >= kiraraIdle.endsAt) {
kiraraIdle = null;
kiraraDirection = "front";
}
if (!kiraraIdle && now >= kiraraNextIdleAt) {
kiraraIdle = {
type: kiraraIdleTypes[Math.floor(Math.random() * kiraraIdleTypes.length)],
startedAt: now,
endsAt: now + kiraraRandom(15000, 20000)
};
kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
kiraraWalkEndsAt = 0;
kiraraWaypoints.length = 0;
kiraraDirection = "front";
}
if (kiraraIdle) {
kiraraRestActive = false;
kiraraLastFrame = now;
document.getElementById("kirara-wanderer").style.transform =
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
document.getElementById("kirara-sprite").style.transform = "none";
drawKiraraIdleFrame(kiraraIdle,
Math.floor((now - kiraraIdle.startedAt) / 360) % 4);
requestAnimationFrame(animateKirara);
return;
}
if (kiraraNextWalkAt === null) kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
if (now >= kiraraNextWalkAt) {
kiraraWalkStartedAt = now;
kiraraWalkEndsAt = now + kiraraRandom(10000, 15000);
kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
kiraraSpeed = kiraraRandom(21, 35); // 原先 42–70 px/s 的一半
}
if (kiraraWalkEndsAt) {
const activeUntil = Math.min(now, kiraraWalkEndsAt);
const elapsed = Math.max(0, activeUntil - Math.max(kiraraLastFrame, kiraraWalkStartedAt)) / 1000;
advanceKirara(kiraraSpeed * elapsed);
}
const moving = now < kiraraWalkEndsAt;
if (!moving && kiraraWalkEndsAt) {
kiraraWalkEndsAt = 0;
kiraraWaypoints.length = 0;
}
if (!moving) kiraraDirection = "front";
if (moving) kiraraRestActive = false;
else if (!kiraraRestActive) {
kiraraRestActive = true;
kiraraRestStartedAt = now;
}
kiraraLastFrame = now;
document.getElementById("kirara-wanderer").style.transform =
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
const bob = moving ? -Math.abs(Math.sin(kiraraStepDistance / 10 * Math.PI)) * 3 : 0;
document.getElementById("kirara-sprite").style.transform =
`translateY(${bob.toFixed(1)}px)`;
if (moving) {
drawKiraraWalkFrame(kiraraDirection, Math.floor(kiraraStepDistance / 5) % 4);
} else if (kiraraRestAsset.image.complete && kiraraRestAsset.image.naturalWidth) {
drawKiraraRestFrame(Math.floor((now - kiraraRestStartedAt) / 900) % 4);
} else {
drawKiraraWalkFrame("front", 1);
}
} catch (e) {
console.error("kirara anim", e);
window.__kiraraError = String(e && e.stack || e);
}
requestAnimationFrame(animateKirara);
}
async function fetchState() {
try {
const r = await fetch("/api/state");
const d = await r.json();
renderTask(d.current_group, d.group_start_time, d.current_admin, d.users, d.default_running, d.bgi_running, d.current_group_uses_nahida_collect);
renderQueue(d.queue, d.users, d.current_admin);
initGiftFxBaseline(d.recent_gifts);
initGiftFxBaseline(d.server_ts);
giftFx.sync(d.recent_gifts);
renderKirara(d.pet);
syncKiraraEmotionEvents(d.pet_emotion_events, d.server_ts);
} catch (e) {
console.error("state", e);
}
@@ -2516,17 +3202,16 @@
const noteEl = document.getElementById("music-note");
const listEl = document.getElementById("playlist");
const monitor = musicState.monitor || {};
const updatedAtMs = Number(monitor.updated_at || 0) * 1000;
const monitorStale = !updatedAtMs || Date.now() - updatedAtMs > 10000;
const monitorOffline = monitor.online === false || monitorStale;
// 以 mpv 播放器真实状态为准(已移除 SMTC 系统媒体监听):
// idle 或没有曲目时为“未播放”,不再显示“音乐源离线 / SMTC”。
const isIdle = !!musicState.idle || !cur.title || cur.title === "暂无歌曲";
if (monitorOffline) {
titleEl.textContent = "音乐源离线";
artistEl.textContent = "等待网易云 SMTC 状态";
if (isIdle) {
titleEl.textContent = "未播放";
artistEl.textContent = "等待点歌或背景歌单";
coverEl.innerHTML = "♪";
lastCoverKey = "";
noteEl.textContent = "监听未运行或状态已过期";
noteEl.textContent = "点歌或开启背景歌单后将自动播放";
} else if (!cur.title && !cur.artist) {
titleEl.textContent = "未配置";
artistEl.textContent = "等待数据源";
@@ -2578,6 +3263,8 @@
particles: [],
rafId: null,
lastTs: 0,
baselineInitialized: false,
openedAtClientTs: Date.now() / 1000,
bannerTimer: null,
queue: [],
playing: false,
@@ -2743,14 +3430,22 @@
const fresh = gifts.filter(g => Number(g.ts || 0) > this.lastTs);
if (fresh.length === 0) return;
this.lastTs = Math.max(...gifts.map(g => Number(g.ts || 0)));
for (const g of fresh) this.enqueue(g);
for (const g of fresh) {
this.enqueue(g);
queueKiraraGiftAnimations(g);
}
},
};
// 首次加载时把已有礼物的最大 ts 设为基线,避免刷新页面重放旧礼物
function initGiftFxBaseline(gifts) {
if (giftFx.lastTs > 0 || !Array.isArray(gifts) || gifts.length === 0) return;
giftFx.lastTs = Math.max(...gifts.map(g => Number(g.ts || 0)));
// 用页面打开时刻作为一次性基线:跳过刷新前的礼物,同时保留打开后的首件礼物。
function initGiftFxBaseline(serverTs) {
if (giftFx.baselineInitialized) return;
const serverNow = Number(serverTs);
const clientNow = Date.now() / 1000;
giftFx.lastTs = Number.isFinite(serverNow) && serverNow > 0
? serverNow - (clientNow - giftFx.openedAtClientTs)
: giftFx.openedAtClientTs;
giftFx.baselineInitialized = true;
}
@@ -2764,6 +3459,8 @@
applyCommandHints(fallbackCommandHints);
startTutorialCarousel();
renderKirara({stamina: 100, cleanliness: 100, mood: 100});
requestAnimationFrame(animateKirara);
fetchFrontendConfig();
fetchState();
fetchLog();