feat(看板娘): 连着刷她会越来越不耐烦,刷多了背过身生闷气,摸摸头才哄好;台词一组听完才重复
上线第一天六成的点开离上一次不到 15 秒,学生是在一句一句地刷台词。 - 两次点开隔不到 20 秒算连着:第 3 次起不耐烦、第 5 次起炸毛、第 7 次背过身去生 30 秒闷气, 期间点她只蹦不说话、也不主动开口;连点摸到头就哄好。有人举手、卡住了这类要紧的照说。 - 台词按副牌抽,一组听完才会重复(原来只是不连着重复,第一天就有三成是听过的)。 - 空白题目页去掉「运行例子」只留「举手求助」,「做过了」去掉「下一道」:第一天都没人点, 没按钮的气泡 5 秒自己收起,不一直挡着。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f6f7a0c3af
commit
3987b83220
3 files changed
+99
-13
No files matched your search
@@ -163,8 +163,10 @@ export function useProblemMascotGuide(showResult: (segment: ResultSegment) => vo
|
|||||||
return guide(failed, ...actions)
|
return guide(failed, ...actions)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (problem.myStatus === SubmissionStatus.accepted) return guide('problem.solvedBefore', nextAction())
|
// 这两种只说话、只留举手:上线第一天「做过了」的「下一道」没人点过,空着代码跑例子也没意义,
|
||||||
if (codeStore.code.value.trim().length < 10) return guide(entry.entry === 'problemset' ? 'problem.inSet' : 'problem.blank', runSamples, help)
|
// 而空白页上「举手求助」是真有人点(不会下手)。没有按钮的气泡 5 秒自己收起,不一直挡着
|
||||||
|
if (problem.myStatus === SubmissionStatus.accepted) return guide('problem.solvedBefore')
|
||||||
|
if (codeStore.code.value.trim().length < 10) return guide(entry.entry === 'problemset' ? 'problem.inSet' : 'problem.blank', help)
|
||||||
const classGuide = classSituation(lesson)
|
const classGuide = classSituation(lesson)
|
||||||
if (classGuide?.situation === 'class.manyDone') return { ...guide('class.manyDone', runSamples, help), n: classGuide.n }
|
if (classGuide?.situation === 'class.manyDone') return { ...guide('class.manyDone', runSamples, help), n: classGuide.n }
|
||||||
return guide('problem.unsubmitted', runSamples, help)
|
return guide('problem.unsubmitted', runSamples, help)
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
* - 对学生按行为挑话(常犯的错、今天做了几道、熟不熟,见 shared/mascot/behavior.ts);
|
* - 对学生按行为挑话(常犯的错、今天做了几道、熟不熟,见 shared/mascot/behavior.ts);
|
||||||
* 题目页上有几件事她会主动开口,频率上限在下面 SPEAK_GAP_MS。
|
* 题目页上有几件事她会主动开口,频率上限在下面 SPEAK_GAP_MS。
|
||||||
* - 互动都记进 mascot_event(点开、点按钮、主动开口、摸头、嫌烦、被拖走)。
|
* - 互动都记进 mascot_event(点开、点按钮、主动开口、摸头、嫌烦、被拖走)。
|
||||||
|
* - 一句一句地刷(两次点开隔不到 BURST_GAP_MS):第 3 次起不耐烦、第 5 次起炸毛、第 7 次背过身去,
|
||||||
|
* 生 SULK_MS 的闷气,这期间点她只蹦不说话。生闷气时连点摸到头就哄好了。上线第一天六成的点开
|
||||||
|
* 离上一次不到 15 秒,学生是在刷台词,所以台词也按副牌抽(pick),一组听完才会重复。
|
||||||
* - 彩蛋:连着快点,有时候(每轮三分之一的机会)点到第 4~8 下(也是随机的)会被摸摸头(同一个仓库的 assets/rua.gif),摸的时候接着点就一直摸。
|
* - 彩蛋:连着快点,有时候(每轮三分之一的机会)点到第 4~8 下(也是随机的)会被摸摸头(同一个仓库的 assets/rua.gif),摸的时候接着点就一直摸。
|
||||||
*
|
*
|
||||||
* 位置只是这台电脑上的个人偏好,存 localStorage(读写都包 try/catch,拿不到就用默认位置)。
|
* 位置只是这台电脑上的个人偏好,存 localStorage(读写都包 try/catch,拿不到就用默认位置)。
|
||||||
@@ -52,6 +55,12 @@ const OVERCLICK = 8 // 一轮连点到这么多下还没摸到头,她就嫌烦
|
|||||||
const QUIET_BUBBLE_MS = 5000 // 没有按钮的气泡(只是一句话)过这么久自己收起
|
const QUIET_BUBBLE_MS = 5000 // 没有按钮的气泡(只是一句话)过这么久自己收起
|
||||||
const SPOKEN_BUBBLE_MS = 10_000 // 她主动说的话,有按钮也过这么久收起:没人点她,不该一直挡着
|
const SPOKEN_BUBBLE_MS = 10_000 // 她主动说的话,有按钮也过这么久收起:没人点她,不该一直挡着
|
||||||
const SPEAK_GAP_MS = 3 * 60_000 // 主动开口至少隔这么久,课上别老打岔
|
const SPEAK_GAP_MS = 3 * 60_000 // 主动开口至少隔这么久,课上别老打岔
|
||||||
|
// 连着刷台词。门槛按上线第一天的数定的:隔不到 20 秒算一串的话,到 3 次的串有 129 串、到 5 次的 50、到 7 次的 23
|
||||||
|
const BURST_GAP_MS = 20_000
|
||||||
|
const PESTER_MILD = 3
|
||||||
|
const PESTER_ANGRY = 5
|
||||||
|
const SULK_AT = 7
|
||||||
|
const SULK_MS = 30_000
|
||||||
|
|
||||||
type HAnchor = 'left' | 'right' | null
|
type HAnchor = 'left' | 'right' | null
|
||||||
type VAnchor = 'top' | 'bottom' | null
|
type VAnchor = 'top' | 'bottom' | null
|
||||||
@@ -89,6 +98,8 @@ 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)
|
||||||
|
/** 生闷气:背过身去(镜像反过来),点她不说话 */
|
||||||
|
const sulking = ref(false)
|
||||||
const body = ref<HTMLElement>()
|
const body = ref<HTMLElement>()
|
||||||
const squashEl = ref<HTMLElement>()
|
const squashEl = ref<HTMLElement>()
|
||||||
const bubbleEl = ref<HTMLElement>()
|
const bubbleEl = ref<HTMLElement>()
|
||||||
@@ -194,7 +205,12 @@ function onUp() {
|
|||||||
|
|
||||||
// 正在被摸,或者连点到数了:摸摸头,从这一下再摸满一轮
|
// 正在被摸,或者连点到数了:摸摸头,从这一下再摸满一轮
|
||||||
if (patting.value || streak >= patClicks) {
|
if (patting.value || streak >= patClicks) {
|
||||||
if (!patting.value) {
|
if (!patting.value && sulking.value) {
|
||||||
|
// 生着闷气被摸了头:哄好了
|
||||||
|
endSulk()
|
||||||
|
say({ text: pick('sulk.forgive') })
|
||||||
|
logMascotEvent({ kind: 'pat', situation: 'sulk.forgive' })
|
||||||
|
} else if (!patting.value) {
|
||||||
say({ text: pick('patting') })
|
say({ text: pick('patting') })
|
||||||
logMascotEvent({ kind: 'pat' })
|
logMascotEvent({ kind: 'pat' })
|
||||||
}
|
}
|
||||||
@@ -209,8 +225,8 @@ function onUp() {
|
|||||||
// 一轮里只有第一下开关气泡,后面连着点是在找彩蛋,不跟着一开一关
|
// 一轮里只有第一下开关气泡,后面连着点是在找彩蛋,不跟着一开一关
|
||||||
if (streak === 1) toggleBubble()
|
if (streak === 1) toggleBubble()
|
||||||
else if (streak === OVERCLICK) {
|
else if (streak === OVERCLICK) {
|
||||||
say({ text: pick('overclick') })
|
if (!sulking.value) say({ text: pick('overclick') })
|
||||||
logMascotEvent({ kind: 'overclick' })
|
logMascotEvent({ kind: 'overclick', situation: sulking.value ? 'sulk' : undefined })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,15 +263,50 @@ function close() {
|
|||||||
|
|
||||||
function toggleBubble() {
|
function toggleBubble() {
|
||||||
if (bubble.value) return close()
|
if (bubble.value) return close()
|
||||||
|
if (silent.value) 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)
|
||||||
|
if (sulking.value) {
|
||||||
|
// 生着闷气只蹦一下。要紧的事(有人举手、卡住了)照说,气也就消了
|
||||||
|
if (!serious) return
|
||||||
|
endSulk()
|
||||||
|
}
|
||||||
|
const now = performance.now()
|
||||||
|
burst = now - lastOpenAt < BURST_GAP_MS ? burst + 1 : 1
|
||||||
|
lastOpenAt = now
|
||||||
|
if (!serious) guide = pester(guide)
|
||||||
const text = pickFor(guide)
|
const text = pickFor(guide)
|
||||||
say({ text, actions: guide.actions?.slice(0, 3), situation: guide.situation })
|
say({ text, actions: guide.actions?.slice(0, 3), situation: guide.situation })
|
||||||
if (silent.value) return
|
if (guide.situation === 'sulk') startSulk()
|
||||||
notePoke()
|
notePoke()
|
||||||
logMascotEvent({ kind: 'open', situation: guide.situation, line: text })
|
logMascotEvent({ kind: 'open', situation: guide.situation, line: text })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 连着刷:越点越烦,点多了生闷气 ----
|
||||||
|
let burst = 0
|
||||||
|
let lastOpenAt = -Infinity
|
||||||
|
let sulkTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
/** 按这一串点到第几次换成不耐烦、炸毛的话;页面的按钮照给,真有事的还能办。背过身去就不给了 */
|
||||||
|
function pester(guide: MascotGuide): MascotGuide {
|
||||||
|
if (burst >= SULK_AT) return { situation: 'sulk' }
|
||||||
|
const situation = burst >= PESTER_ANGRY ? 'pester.angry' : burst >= PESTER_MILD ? 'pester.mild' : null
|
||||||
|
return situation ? { situation, actions: guide.actions } : guide
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSulk() {
|
||||||
|
sulking.value = true
|
||||||
|
clearTimeout(sulkTimer)
|
||||||
|
sulkTimer = setTimeout(endSulk, SULK_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function endSulk() {
|
||||||
|
sulking.value = false
|
||||||
|
burst = 0
|
||||||
|
clearTimeout(sulkTimer)
|
||||||
|
}
|
||||||
|
|
||||||
/** 先看全站的:没登录、老师这边有人举手;再问页面;都没有就打个招呼 */
|
/** 先看全站的:没登录、老师这边有人举手;再问页面;都没有就打个招呼 */
|
||||||
function resolveGuide(): MascotGuide {
|
function resolveGuide(): MascotGuide {
|
||||||
if (!userStore.isAuthed) return { situation: 'guest', actions: [{ label: '登录', run: authModal.openLoginModal }] }
|
if (!userStore.isAuthed) return { situation: 'guest', actions: [{ label: '登录', run: authModal.openLoginModal }] }
|
||||||
@@ -268,12 +319,14 @@ function resolveGuide(): MascotGuide {
|
|||||||
|
|
||||||
/** 这几种情况要紧,不拿彩蛋换掉 */
|
/** 这几种情况要紧,不拿彩蛋换掉 */
|
||||||
const SERIOUS = new Set<MascotSituation>(['guest', 'teacher.help', 'problem.stuck', 'first.meet'])
|
const SERIOUS = new Set<MascotSituation>(['guest', 'teacher.help', 'problem.stuck', 'first.meet'])
|
||||||
|
/** 越说越烦的那几句也不拿彩蛋换:不然炸毛炸到一半突然「服务器繁忙」 */
|
||||||
|
const PESTERED = new Set<MascotSituation>(['pester.mild', 'pester.angry', 'sulk'])
|
||||||
const RARE_CHANCE = 0.03
|
const RARE_CHANCE = 0.03
|
||||||
const TIME_CHANCE = 0.15
|
const TIME_CHANCE = 0.15
|
||||||
|
|
||||||
function pickFor(guide: MascotGuide) {
|
function pickFor(guide: MascotGuide) {
|
||||||
let situation = guide.situation
|
let situation = guide.situation
|
||||||
if (!SERIOUS.has(situation) && guide.n === undefined && !guide.vars) {
|
if (!SERIOUS.has(situation) && !PESTERED.has(situation) && guide.n === undefined && !guide.vars) {
|
||||||
const timely = timeSituation()
|
const timely = timeSituation()
|
||||||
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
|
||||||
@@ -281,12 +334,25 @@ function pickFor(guide: MascotGuide) {
|
|||||||
return fillLine(pick(situation), guide)
|
return fillLine(pick(situation), guide)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 同一组里不连着抽到同一句 */
|
/**
|
||||||
|
* 每组台词洗成一副牌,抽完再洗:一组听完之前不会重复。新洗的一副头一张不和上一句一样。
|
||||||
|
* 牌放在内存里,换页面不丢(看板娘挂在布局上),刷新才重洗
|
||||||
|
*/
|
||||||
|
const decks = new Map<MascotSituation, string[]>()
|
||||||
let lastLine = ''
|
let lastLine = ''
|
||||||
function pick(situation: MascotSituation) {
|
function pick(situation: MascotSituation) {
|
||||||
const pool: readonly string[] = MASCOT_LINES[situation]
|
let deck = decks.get(situation)
|
||||||
const choices = pool.length > 1 ? pool.filter((line) => line !== lastLine) : pool
|
if (!deck?.length) {
|
||||||
lastLine = choices[Math.floor(Math.random() * choices.length)]!
|
deck = [...MASCOT_LINES[situation]]
|
||||||
|
for (let i = deck.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[deck[i], deck[j]] = [deck[j]!, deck[i]!]
|
||||||
|
}
|
||||||
|
// 从末尾往前抽,所以要错开的是最后一张
|
||||||
|
if (deck.length > 1 && deck[deck.length - 1] === lastLine) [deck[0], deck[deck.length - 1]] = [deck[deck.length - 1]!, deck[0]!]
|
||||||
|
decks.set(situation, deck)
|
||||||
|
}
|
||||||
|
lastLine = deck.pop()!
|
||||||
return lastLine
|
return lastLine
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +389,7 @@ async function runAction(action: MascotAction) {
|
|||||||
let lastSpokeAt = -Infinity
|
let lastSpokeAt = -Infinity
|
||||||
const spokenKeys = new Set<string>()
|
const spokenKeys = new Set<string>()
|
||||||
watch(speechRequest, (speech) => {
|
watch(speechRequest, (speech) => {
|
||||||
if (!speech || silent.value || dragging.value || bubble.value || userStore.isTeacherOrAbove) return
|
if (!speech || silent.value || sulking.value || dragging.value || bubble.value || userStore.isTeacherOrAbove) return
|
||||||
if (document.visibilityState !== 'visible' || spokenKeys.has(speech.key)) return
|
if (document.visibilityState !== 'visible' || spokenKeys.has(speech.key)) return
|
||||||
if (performance.now() - lastSpokeAt < SPEAK_GAP_MS) return
|
if (performance.now() - lastSpokeAt < SPEAK_GAP_MS) return
|
||||||
lastSpokeAt = performance.now()
|
lastSpokeAt = performance.now()
|
||||||
@@ -364,6 +430,7 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('resize', settle)
|
window.removeEventListener('resize', settle)
|
||||||
clearTimeout(patTimer)
|
clearTimeout(patTimer)
|
||||||
clearTimeout(quietTimer)
|
clearTimeout(quietTimer)
|
||||||
|
clearTimeout(sulkTimer)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -372,7 +439,7 @@ onBeforeUnmount(() => {
|
|||||||
<div
|
<div
|
||||||
ref="body"
|
ref="body"
|
||||||
class="body"
|
class="body"
|
||||||
:class="{ pressed, patting, flipped }"
|
:class="{ pressed, patting, flipped: flipped !== sulking }"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@pointerdown="onDown"
|
@pointerdown="onDown"
|
||||||
@pointermove="onMove"
|
@pointermove="onMove"
|
||||||
|
|||||||
@@ -201,6 +201,23 @@ export const MASCOT_LINES = {
|
|||||||
'哼',
|
'哼',
|
||||||
],
|
],
|
||||||
patting: ['头发乱了', '再摸收费', '别摸了', '……就一下', '手拿开', '摸够了没', '……还行'],
|
patting: ['头发乱了', '再摸收费', '别摸了', '……就一下', '手拿开', '摸够了没', '……还行'],
|
||||||
|
// 一句一句地刷(两次点开隔不到 20 秒算连着):第 3 次起不耐烦,第 5 次起炸毛,第 7 次背过身去生一会儿闷气。
|
||||||
|
// 生闷气时点她只蹦不说话,连点摸到头才哄得好(Mascot.vue)
|
||||||
|
'pester.mild': [
|
||||||
|
'又点?',
|
||||||
|
'刚说过了',
|
||||||
|
'还点?',
|
||||||
|
'你没别的事干吗',
|
||||||
|
'我很忙的',
|
||||||
|
'点一次就够了',
|
||||||
|
'听见了听见了',
|
||||||
|
'一直戳我干嘛',
|
||||||
|
'题做完了?',
|
||||||
|
'你在数我有几句话?',
|
||||||
|
],
|
||||||
|
'pester.angry': ['再点我真不理你了', '烦死了', '最后警告', '你手不酸,我头晕', '够了够了', '再戳就背过去', '我数到三', '我要生气了,真的'],
|
||||||
|
sulk: ['哼,不理你了', '背过去了,别找我', '生气了', '……不想说话', '自己玩去'],
|
||||||
|
'sulk.forgive': ['……就原谅你这一次', '哼,看在摸头的份上', '……下不为例', '摸头就想了事?……行吧'],
|
||||||
overclick: ['同学,你够了', '再点我要生气了', '手不酸吗', '点我又不加分', '有这功夫做道题', '鼠标要坏了'],
|
overclick: ['同学,你够了', '再点我要生气了', '手不酸吗', '点我又不加分', '有这功夫做道题', '鼠标要坏了'],
|
||||||
rare: ['服务器繁忙', '让我想想……算了', '这个我不会,骗你的', '刚才说到哪了', '我去吃饭了,你做完告诉我'],
|
rare: ['服务器繁忙', '让我想想……算了', '这个我不会,骗你的', '刚才说到哪了', '我去吃饭了,你做完告诉我'],
|
||||||
'time.firstClass': ['醒了没', '早,题呢', '眼睛睁开', '早饭吃了没'],
|
'time.firstClass': ['醒了没', '早,题呢', '眼睛睁开', '早饭吃了没'],
|
||||||
|
|||||||
Reference in new issue
Block a user