feat(看板娘): 换成一整套全身立绘,说什么话摆什么表情;闲着会做自己的事、久了打盹,还能藏到屏幕边上只探个头

立绘换成 Sutera-Diffusus/dsh-whale-musume 的 92 张(MIT,许可原文放在图旁边),全部用上:
- 每组台词配一两张:做对了笑、做错了垮脸、编译错误慌、炸毛、背过身冒黑气、被摸完头脸红冒爱心……
- 被拎着上半身拖和揪着尾巴拖是两张;一直连着摸会被摸得笑出来。
- 5 分钟没人碰换一张日常(做饭、钓鱼、画画、听歌……,冬天下雪、春夏打伞,晚上穿睡衣),
  20 分钟睡着,点她先被吵醒说一句。比赛页不换。
- 往屏幕外拖过一截再松手,就缩进下、左、右那条边只探个头,往外一拖又出来。
- 节日当天换节日装(春节、中秋、圣诞、万圣、情人节)。
只有平时那张跟页面一起下,别的用到才下、下好再换,不闪白。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-04 19:24:00 +08:00
1 parent aa8a3b8bfc
commit bcd328a196
98 files changed
+530 -58

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sutera-Diffusus
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+195 -58
View File
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 看板娘,兼站内的引导员。现在只有一个形象(鲸鱼娘)。 * 看板娘,兼站内的引导员。现在只有一个形象(鲸鱼娘,全身 Q 版)。
* *
* 形象取自 MeteorNOX/DeepSeek-Balance-Whale-Widget 的 assets/DSniang1.png(裁掉透明边、缩到 2 倍显示尺寸转 webp)。 * 立绘一个姿势一张,取自 Sutera-Diffusus/dsh-whale-musume(MIT),哪种情况摆哪个姿势、
* 那个仓库的代码是 MIT,但 assets/ 下的图不在 MIT 里(见它的 PROVENANCE.md)。 * 节日换装都在 shared/mascot/poses.ts。平时那张跟页面一起下,别的用到才下,下好再换。
* *
* - 常驻在前台页面的角落,能拖;松手按「四分之一区」贴边:中心落在左/右四分之一就贴左/右缘, * - 常驻在前台页面的角落,能拖;松手按「四分之一区」贴边:中心落在左/右四分之一就贴左/右缘,
* 上/下四分之一就贴上/下缘,中间保持松手的位置。两个方向各判各的,所以能贴进角落。 * 上/下四分之一就贴上/下缘,中间保持松手的位置。两个方向各判各的,所以能贴进角落。
@@ -19,14 +19,15 @@
* - 一句一句地刷(两次点开隔不到 BURST_GAP_MS):第 3 次起不耐烦、第 5 次起炸毛、第 7 次背过身去, * - 一句一句地刷(两次点开隔不到 BURST_GAP_MS):第 3 次起不耐烦、第 5 次起炸毛、第 7 次背过身去,
* 生 SULK_MS 的闷气,这期间点她只蹦不说话。生闷气时连点摸到头就哄好了。上线第一天六成的点开 * 生 SULK_MS 的闷气,这期间点她只蹦不说话。生闷气时连点摸到头就哄好了。上线第一天六成的点开
* 离上一次不到 15 秒,学生是在刷台词,所以台词也按副牌抽(pick),一组听完才会重复。 * 离上一次不到 15 秒,学生是在刷台词,所以台词也按副牌抽(pick),一组听完才会重复。
* - 彩蛋:连着快点,有时候(每轮三分之一的机会)点到第 4~8 下(也是随机的)会被摸摸头(同一个仓库的 assets/rua.gif),摸的时候接着点就一直摸。 * - 姿势:被拖着是「被拎起来」,被摸头是「被摸」那张加上一下一下揉的动作,冒气泡时按说的那组话换,
* 生闷气时冒黑气。
* - 彩蛋:连着快点,有时候(每轮三分之一的机会)点到第 4~8 下(也是随机的)会被摸摸头,摸的时候接着点就一直摸。
* *
* 位置只是这台电脑上的个人偏好,存 localStorage(读写都包 try/catch,拿不到就用默认位置)。 * 位置只是这台电脑上的个人偏好,存 localStorage(读写都包 try/catch,拿不到就用默认位置)。
* 定位一律用 transform 的像素值:贴右缘时若换成 right:0,过渡没法在 auto 和数值之间插值,会瞬移。 * 定位一律用 transform 的像素值:贴右缘时若换成 right:0,过渡没法在 auto 和数值之间插值,会瞬移。
*/ */
import { TIME_ZONE_OFFSET_MINUTES } from '@oj2/contract' import { TIME_ZONE_OFFSET_MINUTES } from '@oj2/contract'
import imageUrl from 'shared/assets/mascot/whale.webp' import { dailyPose, festivalPose, napPose, PEEK_SIZE, poseUrl, situationPose, type MascotPose } from 'shared/mascot/poses'
import patUrl from 'shared/assets/mascot/whale-pat.gif'
import { MASCOT_LINES, type MascotSituation } from 'shared/mascot/lines' import { MASCOT_LINES, type MascotSituation } from 'shared/mascot/lines'
import { currentMascotGuide, fillLine, mascotBox, type MascotAction, type MascotGuide } from 'shared/mascot/guide' 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, notePoke, personalize, refreshMascotProfile, speechRequest } from 'shared/mascot/behavior'
@@ -34,15 +35,11 @@ import { useAuthModalStore } from 'shared/store/authModal'
import { useCollabStore } from 'shared/store/collab' import { useCollabStore } from 'shared/store/collab'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
const WIDTH = 132 const WIDTH = 144
const HEIGHT = 140 // 图是 264×280 const HEIGHT = 177 // 图是 288×354
const MARGIN = 12 const MARGIN = 12
const TOP_GAP = 72 // 贴上缘时让开顶栏 const TOP_GAP = 72 // 贴上缘时让开顶栏
const STORAGE_KEY = 'oj-mascot' const STORAGE_KEY = 'oj-mascot'
// 摸头动图是 128×128,人只占右下角、上面是手。放大到人和静态图一样大,
// 右边、人的下沿(第 117 行像素)对齐静态图,手伸出框外
const PAT_SIZE = 182
const PAT_BOTTOM = -Math.round((PAT_SIZE * 11) / 128)
const PAT_MS = 1200 const PAT_MS = 1200
// 每轮连点先抽这一轮有没有彩蛋,有的话再抽连点到第几下出。都猜不准才像彩蛋 // 每轮连点先抽这一轮有没有彩蛋,有的话再抽连点到第几下出。都猜不准才像彩蛋
const PAT_CHANCE = 1 / 3 const PAT_CHANCE = 1 / 3
@@ -64,14 +61,26 @@ const PESTER_MILD = 3
const PESTER_ANGRY = [5, 5, 6, 7, 8] as const const PESTER_ANGRY = [5, 5, 6, 7, 8] as const
const SULK_AT = [7, 7, 8, 9, 10] as const const SULK_AT = [7, 7, 8, 9, 10] as const
const SULK_MS = 30_000 const SULK_MS = 30_000
// 没人理她:这么久没碰就换一张日常(之后每隔这么久再换),再久就打盹。比赛页不换
const DAILY_MS = 5 * 60_000
const NAP_MS = 20 * 60_000
// 松手时身子露在屏幕外超过这么多,就缩进那条边只探个头
const PEEK_OUT = 0.35
// 按住下面这一截拖(裙子、尾巴)是揪着尾巴,上面是被拎起来
const TAIL_GRAB = 0.62
// 一直连着摸超过这么久,就被摸得笑出来
const GIGGLE_MS = 1800
type HAnchor = 'left' | 'right' | null type HAnchor = 'left' | 'right' | null
type VAnchor = 'top' | 'bottom' | null type VAnchor = 'top' | 'bottom' | null
type PeekSide = keyof typeof PEEK_SIZE
interface Place { interface Place {
h: HAnchor h: HAnchor
v: VAnchor v: VAnchor
x: number // 未贴边时的 left x: number // 未贴边时的 left;探头在下边时是探头图的 left
y: number // 未贴边时的 top y: number // 未贴边时的 top;探头在左右时是探头图的 top
/** 缩在哪条边上只探个头,没缩是 null */
peek: PeekSide | null
} }
function loadPlace(): Place { function loadPlace(): Place {
@@ -79,12 +88,15 @@ function loadPlace(): Place {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
if (raw) { if (raw) {
const p = JSON.parse(raw) as Partial<Place> const p = JSON.parse(raw) as Partial<Place>
if (typeof p.x === 'number' && typeof p.y === 'number') return { h: p.h ?? null, v: p.v ?? null, x: p.x, y: p.y } if (typeof p.x === 'number' && typeof p.y === 'number') {
const peek = p.peek && p.peek in PEEK_SIZE ? p.peek : null
return { h: p.h ?? null, v: p.v ?? null, x: p.x, y: p.y, peek }
}
} }
} catch { } catch {
// 隐私窗口、禁了站点数据:用默认位置 // 隐私窗口、禁了站点数据:用默认位置
} }
return { h: 'right', v: 'bottom', x: 0, y: 0 } return { h: 'right', v: 'bottom', x: 0, y: 0, peek: null }
} }
function savePlace(p: Place) { function savePlace(p: Place) {
@@ -101,6 +113,10 @@ const dragging = ref(false)
const pressed = ref(false) const pressed = ref(false)
const patting = ref(false) const patting = ref(false)
const flipped = ref(false) const flipped = ref(false)
/** 缩在哪条边上(同 place.peek,响应式的一份) */
const peek = ref<PeekSide | null>(place.peek)
/** 框的大小:探头时换成探头图的大小 */
const size = computed(() => (peek.value ? PEEK_SIZE[peek.value] : { width: WIDTH, height: HEIGHT }))
/** 生闷气:背过身去(镜像反过来),点她不说话 */ /** 生闷气:背过身去(镜像反过来),点她不说话 */
const sulking = ref(false) const sulking = ref(false)
const body = ref<HTMLElement>() const body = ref<HTMLElement>()
@@ -115,6 +131,13 @@ function clamp(v: number, lo: number, hi: number) {
function settle() { function settle() {
const vw = document.documentElement.clientWidth const vw = document.documentElement.clientWidth
const vh = window.innerHeight const vh = window.innerHeight
if (peek.value) {
const { width, height } = PEEK_SIZE[peek.value]
pos.x = peek.value === 'left' ? 0 : peek.value === 'right' ? vw - width : clamp(place.x, MARGIN, vw - width - MARGIN)
pos.y = peek.value === 'bottom' ? vh - height : clamp(place.y, TOP_GAP, vh - height)
flipped.value = false
return
}
pos.x = place.h === 'left' ? MARGIN : place.h === 'right' ? vw - WIDTH - MARGIN : clamp(place.x, MARGIN, vw - WIDTH - MARGIN) pos.x = place.h === 'left' ? MARGIN : place.h === 'right' ? vw - WIDTH - MARGIN : clamp(place.x, MARGIN, vw - WIDTH - MARGIN)
// 图的下沿是裁断的半身,贴底时直接坐在屏幕底边上,不留空 // 图的下沿是裁断的半身,贴底时直接坐在屏幕底边上,不留空
pos.y = place.v === 'top' ? TOP_GAP : place.v === 'bottom' ? vh - HEIGHT : clamp(place.y, TOP_GAP, vh - HEIGHT) pos.y = place.v === 'top' ? TOP_GAP : place.v === 'bottom' ? vh - HEIGHT : clamp(place.y, TOP_GAP, vh - HEIGHT)
@@ -127,6 +150,8 @@ settle()
let start: { px: number; py: number; x: number; y: number } | null = null let start: { px: number; py: number; x: number; y: number } | null = null
let moved = false let moved = false
/** 这一下拖是拎着上半身还是揪着尾巴 */
const grabbedTail = ref(false)
function onDown(e: PointerEvent) { function onDown(e: PointerEvent) {
if (e.button !== 0) return if (e.button !== 0) return
@@ -137,6 +162,8 @@ function onDown(e: PointerEvent) {
start = { px: e.clientX, py: e.clientY, x: pos.x, y: pos.y } start = { px: e.clientX, py: e.clientY, x: pos.x, y: pos.y }
moved = false moved = false
pressed.value = true pressed.value = true
grabbedTail.value = !peek.value && e.clientY - pos.y > HEIGHT * TAIL_GRAB
touched()
} }
function onMove(e: PointerEvent) { function onMove(e: PointerEvent) {
@@ -144,14 +171,30 @@ function onMove(e: PointerEvent) {
const dx = e.clientX - start.px const dx = e.clientX - start.px
const dy = e.clientY - start.py const dy = e.clientY - start.py
if (!moved && dx * dx + dy * dy < 9) return if (!moved && dx * dx + dy * dy < 9) return
if (!moved && peek.value) {
// 从边上拖出来:整个人出来,按住的地方大致还在手底下
const was = PEEK_SIZE[peek.value]
start.x += (was.width - WIDTH) / 2
start.y += was.height - HEIGHT
peek.value = null
}
moved = true moved = true
dragging.value = true dragging.value = true
pressed.value = false pressed.value = false
napping.value = false
pos.x = start.x + dx pos.x = start.x + dx
pos.y = start.y + dy pos.y = start.y + dy
flipped.value = pos.x + WIDTH / 2 < document.documentElement.clientWidth / 2 flipped.value = pos.x + WIDTH / 2 < document.documentElement.clientWidth / 2
} }
/** 松手时露在屏幕外多少:超过 PEEK_OUT 就缩进那条边。下边优先(多半是往下拖藏起来) */
function peekSideFor(vw: number, vh: number): PeekSide | null {
if (pos.y + HEIGHT - vh > HEIGHT * PEEK_OUT) return 'bottom'
if (-pos.x > WIDTH * PEEK_OUT) return 'left'
if (pos.x + WIDTH - vw > WIDTH * PEEK_OUT) return 'right'
return null
}
/** /**
* 每点一下都压扁再弹回来一次。按住时的压扁靠 .pressed 的过渡,可快速点一下从按下到松开只有几十毫秒, * 每点一下都压扁再弹回来一次。按住时的压扁靠 .pressed 的过渡,可快速点一下从按下到松开只有几十毫秒,
* 过渡还没走到位就松了,连点时看上去像卡住。用 Web Animations 播一遍完整的,每下都从头播 * 过渡还没走到位就松了,连点时看上去像卡住。用 Web Animations 播一遍完整的,每下都从头播
@@ -165,15 +208,10 @@ function boing() {
}) })
} }
// 动图 94KB,不在每个页面一打开就下:连点到第 2 下才预载,最少也还要再点两下才用得上
let patPreloaded = false
function preloadPat() {
if (patPreloaded) return
patPreloaded = true
new Image().src = patUrl
}
let patTimer: ReturnType<typeof setTimeout> | undefined let patTimer: ReturnType<typeof setTimeout> | undefined
let patStartedAt = 0
/** 摸了好一会儿,被摸得笑出来了 */
const giggling = ref(false)
let streak = 0 let streak = 0
let patClicks = rollPatClicks() let patClicks = rollPatClicks()
let lastClick = 0 let lastClick = 0
@@ -185,15 +223,25 @@ function onUp() {
dragging.value = false dragging.value = false
const vw = document.documentElement.clientWidth const vw = document.documentElement.clientWidth
const vh = window.innerHeight const vh = window.innerHeight
const side = peekSideFor(vw, vh)
if (side) {
// 探头图对着她原来的中间放
const { width, height } = PEEK_SIZE[side]
place.x = pos.x + (WIDTH - width) / 2
place.y = pos.y + (HEIGHT - height) / 2
} else {
const cx = pos.x + WIDTH / 2 const cx = pos.x + WIDTH / 2
const cy = pos.y + HEIGHT / 2 const cy = pos.y + HEIGHT / 2
place.h = cx < vw / 4 ? 'left' : cx > (vw * 3) / 4 ? 'right' : null place.h = cx < vw / 4 ? 'left' : cx > (vw * 3) / 4 ? 'right' : null
place.v = cy < vh / 4 ? 'top' : cy > (vh * 3) / 4 ? 'bottom' : null place.v = cy < vh / 4 ? 'top' : cy > (vh * 3) / 4 ? 'bottom' : null
place.x = pos.x place.x = pos.x
place.y = pos.y place.y = pos.y
}
place.peek = side
peek.value = side
settle() settle()
savePlace(place) savePlace(place)
logMascotEvent({ kind: 'move', line: `${place.h ?? '-'} ${place.v ?? '-'}` }) logMascotEvent({ kind: 'move', line: side ? `peek ${side}` : `${place.h ?? '-'} ${place.v ?? '-'}` })
return return
} }
boing() boing()
@@ -204,23 +252,26 @@ function onUp() {
patClicks = rollPatClicks() patClicks = rollPatClicks()
} }
lastClick = now lastClick = now
if (streak >= 2) preloadPat()
// 正在被摸,或者连点到数了:摸摸头,从这一下再摸满一轮 // 正在被摸,或者连点到数了:摸摸头,从这一下再摸满一轮
if (patting.value || streak >= patClicks) { if (patting.value || streak >= patClicks) {
if (!patting.value && sulking.value) { if (!patting.value && sulking.value) {
// 生着闷气被摸了头:哄好了 // 生着闷气被摸了头:哄好了
endSulk() endSulk()
say({ text: pick('sulk.forgive') }) say({ text: pick('sulk.forgive'), pose: 'blush' })
logMascotEvent({ kind: 'pat', situation: 'sulk.forgive' }) logMascotEvent({ kind: 'pat', situation: 'sulk.forgive' })
} else if (!patting.value) { } else if (!patting.value) {
say({ text: pick('patting') }) say({ text: pick('patting'), pose: situationPose('patting') })
logMascotEvent({ kind: 'pat' }) logMascotEvent({ kind: 'pat' })
} }
napping.value = false
if (!patting.value) patStartedAt = now
else if (now - patStartedAt > GIGGLE_MS) giggling.value = true
patting.value = true patting.value = true
clearTimeout(patTimer) clearTimeout(patTimer)
patTimer = setTimeout(() => { patTimer = setTimeout(() => {
patting.value = false patting.value = false
giggling.value = false
streak = 0 streak = 0
}, PAT_MS) }, PAT_MS)
return return
@@ -228,7 +279,7 @@ function onUp() {
// 一轮里只有第一下开关气泡,后面连着点是在找彩蛋,不跟着一开一关 // 一轮里只有第一下开关气泡,后面连着点是在找彩蛋,不跟着一开一关
if (streak === 1) toggleBubble() if (streak === 1) toggleBubble()
else if (streak === OVERCLICK) { else if (streak === OVERCLICK) {
if (!sulking.value) say({ text: pick('overclick') }) if (!sulking.value) say({ text: pick('overclick'), pose: situationPose('overclick') })
logMascotEvent({ kind: 'overclick', situation: sulking.value ? 'sulk' : undefined }) logMascotEvent({ kind: 'overclick', situation: sulking.value ? 'sulk' : undefined })
} }
} }
@@ -242,6 +293,8 @@ const authModal = useAuthModalStore()
interface Bubble { interface Bubble {
text: string text: string
/** 说这句时摆的姿势,气泡收起就回平时那张 */
pose?: MascotPose
actions?: MascotAction[] actions?: MascotAction[]
/** 记互动时带上,看哪种情况下的按钮有人点 */ /** 记互动时带上,看哪种情况下的按钮有人点 */
situation?: string situation?: string
@@ -267,6 +320,14 @@ function close() {
function toggleBubble() { function toggleBubble() {
if (bubble.value) return close() if (bubble.value) return close()
if (silent.value) return 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() let guide = resolveGuide()
if (!userStore.isTeacherOrAbove) guide = personalize(guide) if (!userStore.isTeacherOrAbove) guide = personalize(guide)
const serious = SERIOUS.has(guide.situation) const serious = SERIOUS.has(guide.situation)
@@ -279,8 +340,8 @@ function toggleBubble() {
burst = now - lastOpenAt < BURST_GAP_MS ? burst + 1 : 1 burst = now - lastOpenAt < BURST_GAP_MS ? burst + 1 : 1
lastOpenAt = now lastOpenAt = now
if (!serious) guide = pester(guide) if (!serious) guide = pester(guide)
const text = pickFor(guide) const { text, situation } = pickFor(guide)
say({ text, actions: guide.actions?.slice(0, 3), situation: guide.situation }) say({ text, actions: guide.actions?.slice(0, 3), situation: guide.situation, pose: situationPose(situation) })
if (guide.situation === 'sulk') startSulk() if (guide.situation === 'sulk') startSulk()
notePoke() notePoke()
logMascotEvent({ kind: 'open', situation: guide.situation, line: text }) logMascotEvent({ kind: 'open', situation: guide.situation, line: text })
@@ -335,7 +396,7 @@ function pickFor(guide: MascotGuide) {
if (Math.random() < RARE_CHANCE) situation = 'rare' if (Math.random() < RARE_CHANCE) situation = 'rare'
else if (timely && !userStore.isTeacherOrAbove && Math.random() < TIME_CHANCE) situation = timely else if (timely && !userStore.isTeacherOrAbove && Math.random() < TIME_CHANCE) situation = timely
} }
return fillLine(pick(situation), guide) return { text: fillLine(pick(situation), guide), situation }
} }
/** /**
@@ -399,7 +460,7 @@ watch(speechRequest, (speech) => {
lastSpokeAt = performance.now() lastSpokeAt = performance.now()
spokenKeys.add(speech.key) spokenKeys.add(speech.key)
const text = fillLine(pick(speech.situation), speech) const text = fillLine(pick(speech.situation), speech)
say({ text, actions: speech.actions?.slice(0, 3), situation: speech.situation }, SPOKEN_BUBBLE_MS) 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 }) logMascotEvent({ kind: 'speak', situation: speech.situation, line: text })
}) })
@@ -421,13 +482,86 @@ watch(silent, (quiet) => quiet && close())
/** 她靠上时气泡往下冒,免得顶出屏幕;在左半边时气泡贴左对齐 */ /** 她靠上时气泡往下冒,免得顶出屏幕;在左半边时气泡贴左对齐 */
const bubbleBelow = computed(() => pos.y < 220) const bubbleBelow = computed(() => pos.y < 220)
const bubbleLeft = computed(() => (peek.value ? peek.value === 'left' : flipped.value))
// 告诉别的浮层她在哪(成就弹框会让开) // 告诉别的浮层她在哪(成就弹框会让开)
watchEffect(() => Object.assign(mascotBox, { x: pos.x, y: pos.y, width: WIDTH, height: HEIGHT })) watchEffect(() => Object.assign(mascotBox, { x: pos.x, y: pos.y, width: size.value.width, height: size.value.height }))
// ---- 立绘 ----
/** 平时那张:节日当天换节日装。页面开着跨过零点也不换,刷新才换 */
const restPose: MascotPose = festivalPose() ?? 'idle'
const pose = computed<MascotPose>(() => {
// 缩在边上时一直是探头那张:别的姿势都是全身的,塞不进探头的框
if (peek.value) return `peek-${peek.value}`
if (dragging.value) return grabbedTail.value ? 'tail-grabbed' : 'lifted'
if (patting.value) return giggling.value ? 'giggle' : 'patted'
if (bubble.value?.pose) return bubble.value.pose
if (sulking.value) return 'fuming'
return idlePose.value ?? restPose
})
// ---- 没人理她:换日常、打盹 ----
/** 闲着时换的那张(日常或者睡着),被碰一下就回平时那张 */
const idlePose = ref<MascotPose | null>(null)
const napping = ref(false)
let lastTouch = performance.now()
let lastDailyAt = performance.now()
/** 被碰了一下:重新计时。睡着时只是按下去还不算醒,点开(说「没睡」)、拖起来、摸头才醒 */
function touched() {
lastTouch = performance.now()
lastDailyAt = lastTouch
if (!napping.value) idlePose.value = null
}
useIntervalFn(() => {
if (silent.value || peek.value || bubble.value || sulking.value || dragging.value || napping.value) return
if (document.visibilityState !== 'visible') return
const now = performance.now()
if (now - lastTouch >= NAP_MS) {
napping.value = true
idlePose.value = napPose()
} else if (now - lastDailyAt >= DAILY_MS) {
lastDailyAt = now
idlePose.value = dailyPose(Date.now(), timeSituation() === 'offHours')
}
}, 30_000)
// 进了比赛就回平时那张
watch(silent, (quiet) => quiet && touched())
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()
const img = new Image()
img.src = url
return img
.decode()
.catch(() => {})
.then(() => {
ready.add(url)
})
}
watch(
() => poseUrl(pose.value),
(url) => {
if (ready.has(url)) shownSrc.value = url
else preload(url).then(() => poseUrl(pose.value) === url && (shownSrc.value = url))
},
)
// 按下去就该换「被拎起来」、连点就该换「被摸」,等那一下再下就慢了:进页面闲下来以后先把这两张下好
const PRELOAD_POSES: MascotPose[] = ['lifted', 'tail-grabbed', 'patted']
onMounted(() => { onMounted(() => {
window.addEventListener('resize', settle, { passive: true }) window.addEventListener('resize', settle, { passive: true })
mascotBox.shown = true mascotBox.shown = true
// 机房 Chrome 105 有 requestIdleCallback;闲不下来最多等 5 秒
requestIdleCallback(() => PRELOAD_POSES.forEach((it) => preload(poseUrl(it))), { timeout: 5000 })
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
mascotBox.shown = false mascotBox.shown = false
@@ -439,11 +573,15 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<div class="mascot" :class="{ dragging }" :style="{ width: `${WIDTH}px`, height: `${HEIGHT}px`, transform: `translate3d(${pos.x}px, ${pos.y}px, 0)` }"> <div
class="mascot"
:class="{ dragging }"
:style="{ width: `${size.width}px`, height: `${size.height}px`, transform: `translate3d(${pos.x}px, ${pos.y}px, 0)` }"
>
<div <div
ref="body" ref="body"
class="body" class="body"
:class="{ pressed, patting, flipped: flipped !== sulking }" :class="{ pressed, patting, flipped: !peek && flipped !== sulking }"
aria-hidden="true" aria-hidden="true"
@pointerdown="onDown" @pointerdown="onDown"
@pointermove="onMove" @pointermove="onMove"
@@ -454,23 +592,13 @@ onBeforeUnmount(() => {
<div class="flip"> <div class="flip">
<div ref="squashEl" class="squash"> <div ref="squashEl" class="squash">
<div class="breathe"> <div class="breathe">
<img <img :src="shownSrc" :width="size.width" :height="size.height" alt="" draggable="false" />
v-if="patting"
class="pat"
:src="patUrl"
:width="PAT_SIZE"
:height="PAT_SIZE"
:style="{ bottom: `${PAT_BOTTOM}px` }"
alt=""
draggable="false"
/>
<img v-else :src="imageUrl" :width="WIDTH" :height="HEIGHT" alt="" draggable="false" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<Transition name="bubble"> <Transition name="bubble">
<div v-if="bubble && !dragging" ref="bubbleEl" class="bubble" :class="{ below: bubbleBelow, left: flipped }" role="status"> <div v-if="bubble && !dragging" ref="bubbleEl" class="bubble" :class="{ below: bubbleBelow, left: bubbleLeft }" role="status">
<p class="line">{{ bubble.text }}</p> <p class="line">{{ bubble.text }}</p>
<div v-if="bubble.actions?.length" class="actions"> <div v-if="bubble.actions?.length" class="actions">
<n-button <n-button
@@ -540,7 +668,7 @@ onBeforeUnmount(() => {
.bubble::after { .bubble::after {
content: ''; content: '';
position: absolute; position: absolute;
right: 40px; right: 50px;
bottom: -9px; bottom: -9px;
width: 14px; width: 14px;
height: 14px; height: 14px;
@@ -555,7 +683,7 @@ onBeforeUnmount(() => {
} }
.bubble.left::after { .bubble.left::after {
right: auto; right: auto;
left: 40px; left: 50px;
} }
.bubble.below { .bubble.below {
bottom: auto; bottom: auto;
@@ -616,21 +744,19 @@ onBeforeUnmount(() => {
.breathe { .breathe {
animation: mascot-breathe 3.6s ease-in-out infinite; animation: mascot-breathe 3.6s ease-in-out infinite;
} }
.dragging .breathe, .dragging .breathe {
.patting .breathe {
animation-play-state: paused; animation-play-state: paused;
} }
/* 被摸头:一下一下往下揉 */
.patting .breathe {
animation: mascot-rub 0.4s ease-in-out infinite;
}
img { img {
display: block; display: block;
pointer-events: none; pointer-events: none;
-webkit-user-drag: none; -webkit-user-drag: none;
} }
img.pat {
position: absolute;
right: 0;
max-width: none;
}
.breathe { .breathe {
position: relative; position: relative;
} }
@@ -645,8 +771,19 @@ img.pat {
} }
} }
@keyframes mascot-rub {
0%,
100% {
transform: scale(1, 1);
}
50% {
transform: scale(1.03, 0.95);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.breathe { .breathe,
.patting .breathe {
animation: none; animation: none;
} }
} }
+2
View File
@@ -239,6 +239,8 @@ export const MASCOT_LINES = {
'不理你', '不理你',
'哼', '哼',
], ],
// 好久没人理她,睡着了又被点醒(Mascot.vue 的 NAP_MS)
wake: ['……没睡!', '谁、谁睡了', '眯一下而已', '你吵醒我了', '我在闭目养神', '才没有流口水'],
patting: ['头发乱了', '再摸收费', '别摸了', '……就一下', '手拿开', '摸够了没', '……还行'], patting: ['头发乱了', '再摸收费', '别摸了', '……就一下', '手拿开', '摸够了没', '……还行'],
// 一句一句地刷(两次点开隔不到 20 秒算连着):第 3 次起不耐烦,第 5 次起炸毛,第 7 次背过身去生一会儿闷气。 // 一句一句地刷(两次点开隔不到 20 秒算连着):第 3 次起不耐烦,第 5 次起炸毛,第 7 次背过身去生一会儿闷气。
// 生闷气时点她只蹦不说话,连点摸到头才哄得好(Mascot.vue) // 生闷气时点她只蹦不说话,连点摸到头才哄得好(Mascot.vue)
+312
View File
@@ -0,0 +1,312 @@
import { TIME_ZONE_OFFSET_MINUTES } from '@oj2/contract'
import type { MascotSituation } from './lines'
/**
* 看板娘的立绘:一个姿势一张图,按她当下在干什么、说的是哪一组话挑。
*
* 图取自 Sutera-Diffusus/dsh-whale-musume 的 assets/generated/(MIT,许可原文在
* shared/assets/mascot/LICENSE),92 张全用上了。原图 512×512 透明底、人物站位一致:
* 统一裁掉左右各 48 像素、缩到 2 倍显示尺寸(288×354)转 webp;左右放不下的那十几张
* (钓鱼竿、扫帚、课桌……)整体缩小后放进同样大的框,脚底对齐。三张探头图另裁,见 PEEK_SIZE。
* 文件按姿势起名,不按原文件名:换形象时同名换图就行。
*
* 只有平时那张跟着页面一起下载,别的用到才下(Mascot.vue 先下好再换,不闪白)。
*/
const URLS = import.meta.glob<string>('../assets/mascot/*.webp', { eager: true, query: '?url', import: 'default' })
export type MascotPose =
// 平时、打招呼
| 'idle'
| 'greet'
| 'curious'
| 'wave'
| 'teasing'
| 'wink'
| 'bold'
| 'playful'
| 'excited'
| 'running'
// 被摆弄
| 'lifted'
| 'tail-grabbed'
| 'patted'
| 'giggle'
| 'blush'
| 'heart'
| 'touched'
| 'dizzy'
// 越点越烦
| 'doubt'
| 'unimpressed'
| 'angry'
| 'fuming'
// 做对了
| 'happy'
| 'thumbs'
| 'yes'
| 'deploy'
| 'smug'
| 'smirk'
| 'cool'
| 'cheer'
| 'party'
| 'done'
| 'sweep'
| 'star'
| 'trophy'
| 'worship'
| 'levelup'
| 'achievement'
// 做错了、卡住了
| 'sad'
| 'lose'
| 'broke'
| 'empty'
| 'cry'
| 'fluster'
| 'omg'
| 'shock'
| 'ram'
| 'sweat'
| 'thinking'
| 'ponder'
| 'idea'
| 'waiting'
| 'melt'
| 'calm'
| 'debug'
| 'review'
| 'sneak'
// 老师那边
| 'lecture'
| 'tool'
// 时间
| 'coffee'
| 'eat'
| 'snack'
| 'sleepy'
| 'hurry'
| 'night'
| 'pajama'
| 'stretch'
| 'nap-desk'
| 'nap-laptop'
// 闲着的日常
| 'cooking'
| 'fishing'
| 'gaming'
| 'painting'
| 'picnic'
| 'bath'
| 'music'
| 'tail-swing'
| 'phone'
| 'slack'
// 季节
| 'snow'
| 'cold'
| 'umbrella'
| 'rain'
| 'thunder'
// 节日装
| 'festival-spring'
| 'festival-mid-autumn'
| 'festival-christmas'
| 'festival-halloween'
| 'festival-valentine'
// 缩在屏幕边上只探个头
| 'peek-bottom'
| 'peek-left'
| 'peek-right'
export function poseUrl(pose: MascotPose): string {
return URLS[`../assets/mascot/${pose}.webp`] ?? URLS['../assets/mascot/idle.webp']!
}
/** 探头图的显示尺寸(图是这个的 2 倍)。下边那张是头和扒着边的手,左右两张是半个身子 */
export const PEEK_SIZE = {
bottom: { width: 150, height: 132 },
left: { width: 88, height: 177 },
right: { width: 82, height: 177 },
} as const
export function pick<T>(list: readonly T[]): T {
return list[Math.floor(Math.random() * list.length)]!
}
/** 说哪一组话时摆哪个姿势;给了几张的随机挑一张。没列的就是平时那张 */
const SITUATION_POSE: Partial<Record<MascotSituation, readonly MascotPose[]>> = {
// 打招呼、问路
greet: ['greet', 'wave', 'slack'],
'first.meet': ['greet'],
'teacher.greet': ['greet', 'wave'],
'home.back': ['cry', 'greet'],
guest: ['curious'],
problems: ['curious'],
notFound: ['curious', 'sweep'],
announcement: ['curious'],
'home.newAnnouncement': ['curious'],
submissions: ['curious', 'sneak'],
'submission.detail': ['debug', 'review'],
'contests.none': ['curious'],
'teacher.problem': ['curious', 'review'],
// 傲娇怼人、催着跑例子
'problem.unsubmitted': ['teasing'],
'problem.solvedBefore': ['teasing', 'unimpressed'],
'habit.noSamples': ['teasing'],
'habit.weekNoSamples': ['teasing'],
'home.assigned': ['teasing'],
'teacher.home.unassigned': ['teasing', 'empty'],
rank: ['teasing'],
'rank.gap': ['teasing', 'running'],
profile: ['teasing', 'smirk'],
'home.free': ['wink', 'playful'],
tutorial: ['wink', 'lecture'],
'learn.continue': ['wink'],
'learn.fresh': ['wink', 'lecture'],
'affinity.1': ['wink', 'playful'],
'affinity.2': ['wink', 'playful'],
'problem.samplesPassed': ['yes', 'deploy'],
'problem.challenge': ['bold'],
'contests.live': ['bold', 'running'],
'home.contestSoon': ['excited'],
pk: ['bold', 'playful'],
// 越点越烦
'pester.mild': ['doubt', 'unimpressed'],
familiar: ['doubt', 'unimpressed'],
'habit.aiHintMany': ['doubt'],
'problem.easyWrong': ['doubt'],
'habit.neverHelp': ['doubt'],
'pester.angry': ['angry'],
// 快点到嫌烦是被点晕了;背过身那会儿冒黑气
overclick: ['dizzy'],
sulk: ['fuming'],
'memory.grudge': ['angry'],
// 被摸完头
patting: ['blush', 'heart', 'touched'],
// 做对了
'problem.accepted': ['happy', 'thumbs'],
'problem.finally': ['happy', 'cheer'],
'home.done': ['done', 'sweep'],
'tutorial.passed': ['happy', 'thumbs'],
'flowchart.good': ['happy', 'cool'],
'today.count': ['happy', 'thumbs'],
'problem.firstTry': ['smug', 'smirk', 'cool'],
'rank.top': ['trophy'],
'class.firstSolver': ['cheer'],
'problemset.done': ['party'],
'home.knowledgeUp': ['levelup'],
achievement: ['achievement'],
'achievement.near': ['star'],
'problemset.lastOne': ['star'],
'home.knowledgeNext': ['star'],
// 做错了、卡住了
'problem.wrong': ['sad', 'lose'],
'problem.samplesFailed': ['sad', 'lose'],
'flowchart.low': ['sad', 'broke'],
'class.manyDone': ['sad'],
'learn.wrongExercise': ['sad'],
'problem.compileError': ['fluster', 'omg'],
'problem.runtimeError': ['shock', 'ram'],
'problem.timeout': ['shock', 'sweat'],
'teacher.help': ['shock'],
rare: ['shock', 'thumbs'],
'problem.blank': ['thinking', 'ponder'],
'problem.inSet': ['thinking', 'ponder'],
'problem.flowchart': ['thinking'],
'idle.stuck': ['thinking', 'ponder'],
'problem.hardWrong': ['thinking'],
'class.fewDone': ['thinking'],
'problem.hintReady': ['idea'],
'problemsets.unfinished': ['waiting', 'empty'],
'problemset.unfinished': ['waiting'],
'teacher.task.assigned': ['waiting', 'lecture'],
'teacher.task.unassigned': ['empty'],
'teacher.problems': ['tool'],
'teacher.submissions': ['review', 'sweat'],
'teacher.statistics': ['review'],
// 软下来的时候
'affinity.3': ['blush', 'touched'],
'affinity.4': ['worship'],
'memory.closer': ['blush'],
'memory.pat': ['blush'],
'sulk.forgive': ['blush', 'touched'],
'gentle.wrong': ['calm'],
'problem.stuck': ['blush', 'melt'],
// 时间彩蛋
'time.firstClass': ['coffee'],
'time.lunch': ['eat', 'snack'],
'time.afternoon': ['sleepy'],
'time.lastClass': ['hurry'],
offHours: ['night', 'pajama'],
wake: ['stretch'],
}
/** 老毛病那几组(habit.NameError …)没一一列,统一是不耐烦的那张 */
export function situationPose(situation: MascotSituation): MascotPose {
const poses = SITUATION_POSE[situation]
if (poses) return pick(poses)
return situation.startsWith('habit.') ? 'doubt' : 'idle'
}
// ---- 没人理她的时候 ----
/** 闲着随机换的日常 */
const DAILY: readonly MascotPose[] = ['cooking', 'fishing', 'gaming', 'painting', 'picnic', 'bath', 'music', 'tail-swing', 'phone', 'slack', 'snack', 'sweep']
/**
* 闲着时换哪一张:日常里随机一张,按月份掺上季节的(冬天下雪、怕冷,春夏打伞、踩水,盛夏怕打雷),
* 晚上和周末掺睡衣
*/
export function dailyPose(now = Date.now(), offHours = false): MascotPose {
const month = new Date(now + TIME_ZONE_OFFSET_MINUTES * 60_000).getUTCMonth() + 1
const pool: MascotPose[] = [...DAILY]
if (month === 12 || month <= 2) pool.push('snow', 'cold')
if (month >= 4 && month <= 6) pool.push('umbrella', 'rain')
if (month >= 7 && month <= 8) pool.push('thunder')
if (offHours) pool.push('pajama', 'night')
return pick(pool)
}
/** 好久没人理就睡着了 */
export function napPose(): MascotPose {
return pick(['nap-desk', 'nap-laptop', 'sleepy'] as const)
}
/**
* 农历节日的公历日期(春节是正月初一,中秋是八月十五),只能查表。用完了往后补几年,
* 没补上的年份就是不换装,不会出错
*/
const SPRING: Record<number, string> = { 2027: '02-06', 2028: '01-26', 2029: '02-13', 2030: '02-03' }
const MID_AUTUMN: Record<number, string> = { 2026: '09-25', 2027: '09-15', 2028: '10-03', 2029: '09-22', 2030: '09-12' }
const DAY_MS = 86_400_000
/** 东八区的「月-日」和日序号 */
function wallDay(ms: number) {
const wall = new Date(ms + TIME_ZONE_OFFSET_MINUTES * 60_000)
return { year: wall.getUTCFullYear(), md: wall.toISOString().slice(5, 10), day: Math.floor(wall.getTime() / DAY_MS) }
}
/** 离某个农历节日的当天差几天(今天在它前面是负数),这一年没查到就是 null */
function offsetFrom(table: Record<number, string>, now: number) {
const { year, day } = wallDay(now)
const md = table[year]
if (!md) return null
return day - Math.floor(Date.parse(`${year}-${md}T00:00:00Z`) / DAY_MS)
}
/**
* 节日当天平时那张换成节日装。春节是除夕到元宵(放寒假了,回来做题的才看得到),
* 中秋是前一天和当天(放假前最后一天在学校),别的就是那一天
*/
export function festivalPose(now = Date.now()): MascotPose | null {
const { md } = wallDay(now)
if (md === '02-14') return 'festival-valentine'
if (md === '10-31') return 'festival-halloween'
if (md === '12-24' || md === '12-25') return 'festival-christmas'
const spring = offsetFrom(SPRING, now)
if (spring !== null && spring >= -1 && spring <= 14) return 'festival-spring'
const midAutumn = offsetFrom(MID_AUTUMN, now)
if (midAutumn !== null && midAutumn >= -1 && midAutumn <= 0) return 'festival-mid-autumn'
return null
}