合并外部维护版本并同步自动每日功能
This commit is contained in:
+715
-18
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user