fix(看板娘): 深色模式下气泡一直是白的;睡着时进比赛会一直睡、点不醒;第一次缩到边上时图会被挤变形

- 深色气泡从 11-03 起就没生效过:scoped 样式把 `:global(html.dark) .bubble` 编译成了只剩 `html.dark`。
- 睡着时进比赛先叫醒;她主动开口也算醒;有人举手、卡住了这类要紧的事,点醒时直接说正事,不先说「没睡」。
- 三张探头图先下好;图没到之前按比例缩着放,不拉伸。
- 立绘没下到(断网、部署后老页面拿旧地址)就接着显示上一张,不再变成空白。
- 跨天的旧账被生闷气、越点越烦顶掉时不算说过,下次点开还会说。
- 左右探头时气泡尖角对准她的头;没有 requestIdleCallback 的浏览器(iPad Safari)不再报错。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-04 19:32:25 +08:00
1 parent bcd328a196
commit b99f619c0a
3 files changed
+67 -36

No files matched your search

+3 -2
View File
@@ -47,7 +47,8 @@ const MEMORY_DAYS = 7
/** 几个东八区日历日以前;不在 MEMORY_DAYS 以内、或之后已经说过那句了,就是 null */
function daysAgo(at: string | null, saidAt: string | null) {
if (!at || (saidAt && saidAt > at)) return null
// 按时刻比,别按字符串:库里出来的带微秒、位数不一定一样
if (!at || (saidAt && Date.parse(saidAt) > Date.parse(at))) return null
const days = dayNumber(calendarDay()) - dayNumber(calendarDay(at))
return days >= 1 && days <= MEMORY_DAYS ? days : null
}
@@ -90,7 +91,7 @@ async function memoryOf(userId: number) {
grudgeDaysAgo: daysAgo(row?.sulk_at ?? null, row?.grudge_at ?? null),
patDaysAgo: daysAgo(row?.pat_at ?? null, row?.pat_said_at ?? null),
solvedBeforeToday: row?.solved_before ?? 0,
closerSaidToday: !!row?.closer_at && row.closer_at >= today,
closerSaidToday: !!row?.closer_at && Date.parse(row.closer_at) >= Date.parse(today),
}
}
+53 -26
View File
@@ -30,7 +30,7 @@ import { TIME_ZONE_OFFSET_MINUTES } from '@oj2/contract'
import { dailyPose, festivalPose, napPose, PEEK_SIZE, poseUrl, situationPose, type MascotPose } from 'shared/mascot/poses'
import { MASCOT_LINES, type MascotSituation } from 'shared/mascot/lines'
import { currentMascotGuide, fillLine, mascotBox, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
import { affinityTier, logMascotEvent, notePoke, personalize, refreshMascotProfile, speechRequest } from 'shared/mascot/behavior'
import { affinityTier, logMascotEvent, noteMemorySaid, notePoke, personalize, refreshMascotProfile, speechRequest } from 'shared/mascot/behavior'
import { useAuthModalStore } from 'shared/store/authModal'
import { useCollabStore } from 'shared/store/collab'
import { useUserStore } from 'shared/store/user'
@@ -320,17 +320,19 @@ function close() {
function toggleBubble() {
if (bubble.value) return close()
if (silent.value) return
if (napping.value) {
// 睡着了被点醒:先说这一句,再点才说正事
napping.value = false
const text = pick('wake')
say({ text, pose: situationPose('wake'), situation: 'wake' })
logMascotEvent({ kind: 'open', situation: 'wake', line: text })
return
}
let guide = resolveGuide()
if (!userStore.isTeacherOrAbove) guide = personalize(guide)
const serious = SERIOUS.has(guide.situation)
if (napping.value) {
napping.value = false
// 睡着了被点醒:先说这一句,再点才说正事。要紧的事(有人举手、卡住了)直接说
if (!serious) {
const text = pick('wake')
say({ text, pose: situationPose('wake'), situation: 'wake' })
logMascotEvent({ kind: 'open', situation: 'wake', line: text })
return
}
}
if (sulking.value) {
// 生着闷气只蹦一下。要紧的事(有人举手、卡住了)照说,气也就消了
if (!serious) return
@@ -343,6 +345,7 @@ function toggleBubble() {
const { text, situation } = pickFor(guide)
say({ text, actions: guide.actions?.slice(0, 3), situation: guide.situation, pose: situationPose(situation) })
if (guide.situation === 'sulk') startSulk()
noteMemorySaid(guide.situation)
notePoke()
logMascotEvent({ kind: 'open', situation: guide.situation, line: text })
}
@@ -459,6 +462,7 @@ watch(speechRequest, (speech) => {
if (performance.now() - lastSpokeAt < SPEAK_GAP_MS) return
lastSpokeAt = performance.now()
spokenKeys.add(speech.key)
napping.value = false
const text = fillLine(pick(speech.situation), speech)
say({ text, actions: speech.actions?.slice(0, 3), situation: speech.situation, pose: situationPose(speech.situation) }, SPOKEN_BUBBLE_MS)
logMascotEvent({ kind: 'speak', situation: speech.situation, line: text })
@@ -483,6 +487,9 @@ watch(silent, (quiet) => quiet && close())
/** 她靠上时气泡往下冒,免得顶出屏幕;在左半边时气泡贴左对齐 */
const bubbleBelow = computed(() => pos.y < 220)
const bubbleLeft = computed(() => (peek.value ? peek.value === 'left' : flipped.value))
/** 气泡尖角离气泡那一侧多远,对着她的头:全身和下边探头是 50,左右探头的框窄,头更靠边 */
const TAIL_OFFSET: Record<PeekSide | 'full', number> = { full: 50, bottom: 50, left: 24, right: 16 }
const tailOffset = computed(() => `${TAIL_OFFSET[peek.value ?? 'full']}px`)
// 告诉别的浮层她在哪(成就弹框会让开)
watchEffect(() => Object.assign(mascotBox, { x: pos.x, y: pos.y, width: size.value.width, height: size.value.height }))
@@ -526,8 +533,12 @@ useIntervalFn(() => {
idlePose.value = dailyPose(Date.now(), timeSituation() === 'offHours')
}
}, 30_000)
// 进了比赛就回平时那张
watch(silent, (quiet) => quiet && touched())
// 进了比赛就醒过来、回平时那张(比赛里点她不说话,不先醒就一直睡着)
watch(silent, (quiet) => {
if (!quiet) return
napping.value = false
touched()
})
watch(napping, (asleep) => !asleep && touched())
/**
@@ -536,32 +547,38 @@ watch(napping, (asleep) => !asleep && touched())
*/
const shownSrc = ref(poseUrl(pose.value))
const ready = new Set<string>([shownSrc.value])
function preload(url: string) {
if (ready.has(url)) return Promise.resolve()
/** 下好、解码完是 true;没下到(断网、部署后老页面拿旧地址 404)是 false,下次用到再试 */
function preload(url: string): Promise<boolean> {
if (ready.has(url)) return Promise.resolve(true)
const img = new Image()
img.src = url
return img
.decode()
.catch(() => {})
.then(() => {
return img.decode().then(
() => {
ready.add(url)
})
return true
},
() => false,
)
}
watch(
() => poseUrl(pose.value),
(url) => {
if (ready.has(url)) shownSrc.value = url
else preload(url).then(() => poseUrl(pose.value) === url && (shownSrc.value = url))
// 没下到就接着显示上一张,别换成空白
else preload(url).then((ok) => ok && poseUrl(pose.value) === url && (shownSrc.value = url))
},
)
// 按下去就该换「被拎起来」、连点就该换「被摸」,等那一下再下就慢了:进页面闲下来以后先把这两张下好
const PRELOAD_POSES: MascotPose[] = ['lifted', 'tail-grabbed', 'patted']
// 三张探头图也先下:缩进边上时框立刻变窄,图没到的话全身那张会挤在窄框里(下面 object-fit 兜着不变形)
const PRELOAD_POSES: MascotPose[] = ['lifted', 'tail-grabbed', 'patted', 'peek-bottom', 'peek-left', 'peek-right']
onMounted(() => {
window.addEventListener('resize', settle, { passive: true })
mascotBox.shown = true
// 机房 Chrome 105 有 requestIdleCallback;闲不下来最多等 5 秒
requestIdleCallback(() => PRELOAD_POSES.forEach((it) => preload(poseUrl(it))), { timeout: 5000 })
// 机房 Chrome 105 有 requestIdleCallback,iPad 上的 Safari 没有(宽屏也算电脑);闲不下来最多等 5 秒
const warm = () => PRELOAD_POSES.forEach((it) => preload(poseUrl(it)))
if ('requestIdleCallback' in window) requestIdleCallback(warm, { timeout: 5000 })
else setTimeout(warm, 2000)
})
onBeforeUnmount(() => {
mascotBox.shown = false
@@ -598,7 +615,14 @@ onBeforeUnmount(() => {
</div>
</div>
<Transition name="bubble">
<div v-if="bubble && !dragging" ref="bubbleEl" class="bubble" :class="{ below: bubbleBelow, left: bubbleLeft }" role="status">
<div
v-if="bubble && !dragging"
ref="bubbleEl"
class="bubble"
:class="{ below: bubbleBelow, left: bubbleLeft }"
:style="{ '--tail': tailOffset }"
role="status"
>
<p class="line">{{ bubble.text }}</p>
<div v-if="bubble.actions?.length" class="actions">
<n-button
@@ -660,7 +684,8 @@ onBeforeUnmount(() => {
flex-direction: column;
gap: 10px;
}
:global(html.dark) .bubble {
/* 别写成 :global(html.dark) .bubble:scoped 会把后半截丢掉,编译成只剩 html.dark,深色从来没生效过 */
html.dark .bubble {
--bubble-line: #7f94d4;
--bubble-bg: #1f2230;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
@@ -668,7 +693,7 @@ onBeforeUnmount(() => {
.bubble::after {
content: '';
position: absolute;
right: 50px;
right: var(--tail);
bottom: -9px;
width: 14px;
height: 14px;
@@ -683,7 +708,7 @@ onBeforeUnmount(() => {
}
.bubble.left::after {
right: auto;
left: 50px;
left: var(--tail);
}
.bubble.below {
bottom: auto;
@@ -754,6 +779,8 @@ onBeforeUnmount(() => {
img {
display: block;
object-fit: contain;
object-position: 50% 100%;
pointer-events: none;
-webkit-user-drag: none;
}
+11 -8
View File
@@ -137,8 +137,14 @@ function affinitySituation(tier: number): MascotSituation {
return `affinity.${level}` as MascotSituation
}
/** 这一页会话里翻过的旧账:说完到下次拉回新数之间别再说一遍 */
const memorySaid = new Set<'closer' | 'grudge' | 'pat'>()
/**
* 这一页会话里翻过的旧账:说完到下次拉回新数之间别再说一遍。真说出口了才记(noteMemorySaid),
* 被生闷气、越点越烦顶掉的不算
*/
const memorySaid = new Set<MascotSituation>()
export function noteMemorySaid(situation: MascotSituation) {
if (situation.startsWith('memory.')) memorySaid.add(situation)
}
function whenText(days: number) {
return days === 1 ? '昨天' : days === 2 ? '前天' : '前几天'
@@ -147,16 +153,13 @@ function whenText(days: number) {
/** 跨天记得的事,有就挑一件:今天升档 > 被惹生气 > 被摸头。每件只翻一次(后端看说没说过) */
function memoryGuide(profile: MascotProfile): Pick<MascotGuide, 'situation' | 'vars'> | null {
const memory = profile.memory
if (!memory.closerSaidToday && !memorySaid.has('closer') && tierOf(memory.solvedBeforeToday) < tierOf(profile.solved)) {
memorySaid.add('closer')
if (!memory.closerSaidToday && !memorySaid.has('memory.closer') && tierOf(memory.solvedBeforeToday) < tierOf(profile.solved)) {
return { situation: 'memory.closer' }
}
if (memory.grudgeDaysAgo !== null && !memorySaid.has('grudge')) {
memorySaid.add('grudge')
if (memory.grudgeDaysAgo !== null && !memorySaid.has('memory.grudge')) {
return { situation: 'memory.grudge', vars: { when: whenText(memory.grudgeDaysAgo) } }
}
if (memory.patDaysAgo !== null && !memorySaid.has('pat')) {
memorySaid.add('pat')
if (memory.patDaysAgo !== null && !memorySaid.has('memory.pat')) {
return { situation: 'memory.pat', vars: { when: whenText(memory.patDaysAgo) } }
}
return null