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

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
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -4,10 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>直播联动后台</title>
<script type="module" crossorigin src="/admin/assets/index-BdYZGJMQ.js"></script>
<script type="module" crossorigin src="/admin/assets/index-DzEqvM4m.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BB8VhdaG.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+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();
+12
View File
@@ -386,5 +386,17 @@
{
"name": "霜仙花",
"image": "/uploads/group_霜仙花.webp"
},
{
"name": "植绒草",
"image": "/uploads/group_植绒草.webp"
},
{
"name": "金蕨",
"image": "/uploads/group_金蕨.webp"
},
{
"name": "冰醋栗",
"image": "/uploads/group_冰醋栗.webp"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.