From 937e45c203f64a352ca35fffa74012501e0167de Mon Sep 17 00:00:00 2001 From: cocos02 Date: Sun, 7 Jun 2026 16:48:52 +0800 Subject: [PATCH] feat: P1 core - seamless video switching, conditional branches, save/load - VideoManager: A/B dual-buffered video with crossfade transitions and candidate preloading - Engine: condition-based choice filtering, ChoiceSystem timer, resumeScene for save/load - SceneManager: getCandidateUrls for preloading next scenes - SaveSystem: Dexie.js IndexedDB multi-slot save/load - ChoiceSystem: timed choices with countdown and auto-default on timeout - GamePlayer: dual video elements with crossfade CSS - ChoicePanel: timer progress bar and countdown text - SaveLoadMenu: save/load UI component - App.vue: menu trigger, dual video refs, save/load integration - gameStore: timer state, saves list - demo.json: conditional choice example (secret ending, requires trust >= 80) - ROADMAP: mark P1 as completed --- ROADMAP.md | 21 ++-- engine/core/Engine.ts | 74 +++++++++++++- engine/core/SceneManager.ts | 28 +++++- engine/core/VideoManager.ts | 159 ++++++++++++++++++++++++------- engine/systems/ChoiceSystem.ts | 75 +++++++++++++++ engine/systems/SaveSystem.ts | 72 ++++++++++++++ engine/types.ts | 2 + package-lock.json | 6 ++ package.json | 5 +- public/scenes/demo.json | 17 ++++ src/App.vue | 66 ++++++++++++- src/components/ChoicePanel.vue | 50 +++++++++- src/components/GamePlayer.vue | 21 ++-- src/components/SaveLoadMenu.vue | 157 ++++++++++++++++++++++++++++++ src/composables/useGameEngine.ts | 52 +++++++++- src/stores/gameStore.ts | 29 +++++- 16 files changed, 763 insertions(+), 71 deletions(-) create mode 100644 engine/systems/ChoiceSystem.ts create mode 100644 engine/systems/SaveSystem.ts create mode 100644 src/components/SaveLoadMenu.vue diff --git a/ROADMAP.md b/ROADMAP.md index 2c0c718..d8fb30e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,16 +140,19 @@ interface SaveData { - [x] `public/scenes/demo.json` — 编写一段简单剧情(7 个场景节点) - [x] 验证:从 demo.json 加载场景,能走通 开始→选择→分支播放→结束 流程 -### P1 核心 — 无缝切换 + 条件分支 + 存档(1-2 周) +### P1 核心 — 无缝切换 + 条件分支 + 存档(1-2 周)✅ 已完成 2026-06-07 -- [ ] `engine/core/VideoManager.ts` 升级 — A/B 双缓冲,预加载候选视频,CSS 交叉淡化 -- [ ] `engine/core/SceneManager.ts` 升级 — 支持条件分支(根据 variables/flags 过滤选项) -- [ ] `engine/systems/SaveSystem.ts` — Dexie.js IndexedDB 存取,多槽位 -- [ ] `engine/systems/ChoiceSystem.ts` — 限时选择倒计时,超时默认选择(第一项或配置的默认项) -- [ ] `src/components/SaveLoadMenu.vue` — 存档/读档 UI -- [ ] `src/stores/gameStore.ts` — Pinia 全局状态管理 -- [ ] `src/composables/` — 三个 composable 桥接层 -- [ ] 验证:分支剧情走通,存档读档正常,视频切换无明显黑屏 +- [x] `engine/core/VideoManager.ts` 升级 — A/B 双缓冲,预加载候选视频,CSS 交叉淡化 +- [x] `engine/core/SceneManager.ts` 升级 — 支持条件分支(根据 variables/flags 过滤选项) +- [x] `engine/systems/SaveSystem.ts` — Dexie.js IndexedDB 存取,多槽位 +- [x] `engine/systems/ChoiceSystem.ts` — 限时选择倒计时,超时默认选择 +- [x] `src/components/SaveLoadMenu.vue` — 存档/读档 UI +- [x] `src/stores/gameStore.ts` — Pinia 全局状态管理(含计时器、存档列表) +- [x] `src/composables/useGameEngine.ts` — 桥接层(双 video、存档、计时器) +- [x] `src/components/GamePlayer.vue` — 双 video 元素 + 交叉淡化 CSS +- [x] `src/components/ChoicePanel.vue` — 倒计时进度条 + 计时文字 +- [x] `src/App.vue` — 整合 SaveLoadMenu、双 video、计时器 +- [x] 验证:条件分支走通,存档读档正常,视频切换交叉淡化 ### P2 进阶 — QTE + 字幕 + 多存档槽(1 周) diff --git a/engine/core/Engine.ts b/engine/core/Engine.ts index ffff2b4..05c951c 100644 --- a/engine/core/Engine.ts +++ b/engine/core/Engine.ts @@ -2,6 +2,7 @@ import type { SceneNode, Choice, EngineEvent } from '../types' import { SceneManager } from './SceneManager' import { VideoManager } from './VideoManager' import { StateManager } from './StateManager' +import { ChoiceSystem } from '../systems/ChoiceSystem' type EventHandler = (...args: any[]) => void @@ -9,15 +10,18 @@ export class Engine { sceneManager: SceneManager videoManager: VideoManager stateManager: StateManager + choiceSystem: ChoiceSystem private currentScene: SceneNode | null = null private events: Map> = new Map() - private ended: boolean = false + private ended = false + private isInitialScene = true constructor() { this.sceneManager = new SceneManager() this.videoManager = new VideoManager() this.stateManager = new StateManager() + this.choiceSystem = new ChoiceSystem() } on(event: EngineEvent, handler: EventHandler) { @@ -35,6 +39,7 @@ export class Engine { start() { this.ended = false + this.isInitialScene = true const startScene = this.sceneManager.getStartScene() this.goToScene(startScene) } @@ -46,18 +51,42 @@ export class Engine { this.stateManager.apply(scene.onEnter) } + const preloadUrls = this.sceneManager.getCandidateUrls( + scene, + (conds) => conds ? this.stateManager.evaluate(conds) : true + ) + this.videoManager.onEnd(() => { this.emit('videoEnd', scene) this.onVideoEnd(scene) }) - this.videoManager.play(scene.videoUrl) + if (this.isInitialScene) { + this.isInitialScene = false + this.videoManager.playInitial(scene.videoUrl, preloadUrls) + } else { + this.videoManager.switchTo(scene.videoUrl, preloadUrls) + } + this.emit('sceneChange', scene) } private onVideoEnd(scene: SceneNode) { - if (scene.choices && scene.choices.length > 0) { - this.emit('choiceRequest', scene.choices) + const validChoices = this.getValidChoices(scene) + + if (validChoices.length > 0) { + this.emit('choiceRequest', validChoices) + + this.choiceSystem.start( + validChoices, + (timerState) => { + this.emit('choiceTimer', timerState) + }, + (defaultChoice) => { + this.emit('choiceTimeout', defaultChoice) + this.makeChoice(defaultChoice) + } + ) } else if (scene.nextScene) { const next = this.sceneManager.getScene(scene.nextScene) if (next) { @@ -70,6 +99,13 @@ export class Engine { } } + getValidChoices(scene: SceneNode): Choice[] { + if (!scene.choices) return [] + return scene.choices.filter((c) => + !c.conditions || this.stateManager.evaluate(c.conditions) + ) + } + makeChoice(choice: Choice) { if (!this.currentScene) return @@ -96,6 +132,36 @@ export class Engine { this.emit('gameEnd') } + resumeScene(sceneId: string, savedState: { variables: Record; flags: string[]; history: any[] }) { + this.stateManager.variables = { ...savedState.variables } + this.stateManager.flags = new Set(savedState.flags) + this.stateManager.history = [...savedState.history] + + const scene = this.sceneManager.getScene(sceneId) + if (!scene) { + this.endGame() + return + } + + this.ended = false + this.isInitialScene = false + + const preloadUrls = this.sceneManager.getCandidateUrls( + scene, + (conds) => conds ? this.stateManager.evaluate(conds) : true + ) + + this.videoManager.switchTo(scene.videoUrl, preloadUrls) + + this.videoManager.onEnd(() => { + this.emit('videoEnd', scene) + this.onVideoEnd(scene) + }) + + this.currentScene = scene + this.emit('sceneChange', scene) + } + destroy() { this.videoManager.detach() this.events.clear() diff --git a/engine/core/SceneManager.ts b/engine/core/SceneManager.ts index b24559b..7fa794a 100644 --- a/engine/core/SceneManager.ts +++ b/engine/core/SceneManager.ts @@ -1,4 +1,4 @@ -import type { GameData, SceneNode } from '../types' +import type { GameData, SceneNode, Choice, Condition } from '../types' export class SceneManager { private scenes: Record = {} @@ -22,4 +22,30 @@ export class SceneManager { getAllSceneIds(): string[] { return Object.keys(this.scenes) } + + getCandidateTargetIds(scene: SceneNode, evaluateCondition: (conds?: Condition[]) => boolean): string[] { + const targets: string[] = [] + + if (scene.choices) { + for (const choice of scene.choices) { + if (!choice.conditions || evaluateCondition(choice.conditions)) { + if (!targets.includes(choice.targetScene)) { + targets.push(choice.targetScene) + } + } + } + } + + if (scene.nextScene && !targets.includes(scene.nextScene)) { + targets.push(scene.nextScene) + } + + return targets + } + + getCandidateUrls(scene: SceneNode, evaluateCondition: (conds?: Condition[]) => boolean): string[] { + return this.getCandidateTargetIds(scene, evaluateCondition) + .map(id => this.scenes[id]?.videoUrl) + .filter((url): url is string => !!url) + } } diff --git a/engine/core/VideoManager.ts b/engine/core/VideoManager.ts index 46cecef..c2f9491 100644 --- a/engine/core/VideoManager.ts +++ b/engine/core/VideoManager.ts @@ -2,53 +2,134 @@ type VideoEndCallback = () => void type TimeUpdateCallback = (time: number) => void export class VideoManager { - private videoEl: HTMLVideoElement | null = null + private elA: HTMLVideoElement | null = null + private elB: HTMLVideoElement | null = null + private activeSlot: 'A' | 'B' = 'A' + private crossFadeMs = 300 private onEndCallback: VideoEndCallback | null = null private onTimeCallback: TimeUpdateCallback | null = null - private lastSrc: string = '' + private currentSrc = '' + private preloaded: Map<'A' | 'B', string> = new Map() + private switching = false - attach(videoEl: HTMLVideoElement) { - this.videoEl = videoEl - videoEl.addEventListener('ended', this.handleEnded) - videoEl.addEventListener('timeupdate', this.handleTimeUpdate) + private get active(): HTMLVideoElement { + return this.activeSlot === 'A' ? this.elA! : this.elB! + } + + private get inactive(): HTMLVideoElement { + return this.activeSlot === 'A' ? this.elB! : this.elA! + } + + private get inactiveKey(): 'A' | 'B' { + return this.activeSlot === 'A' ? 'B' : 'A' + } + + attach(elA: HTMLVideoElement, elB: HTMLVideoElement) { + this.elA = elA + this.elB = elB + for (const el of [elA, elB]) { + el.addEventListener('ended', this.handleEnded) + el.addEventListener('timeupdate', this.handleTimeUpdate) + el.style.position = 'absolute' + el.style.inset = '0' + el.style.width = '100%' + el.style.height = '100%' + el.style.objectFit = 'contain' + el.style.transition = 'none' + } + elB.style.opacity = '0' } detach() { - if (!this.videoEl) return - this.videoEl.removeEventListener('ended', this.handleEnded) - this.videoEl.removeEventListener('timeupdate', this.handleTimeUpdate) - this.videoEl = null + for (const el of [this.elA, this.elB]) { + if (!el) continue + el.removeEventListener('ended', this.handleEnded) + el.removeEventListener('timeupdate', this.handleTimeUpdate) + el.pause() + el.removeAttribute('src') + } + this.elA = null + this.elB = null } - play(src: string) { - if (!this.videoEl) return - if (this.lastSrc !== src) { - this.videoEl.src = src - this.lastSrc = src - if (this.videoEl.readyState >= 1) { - this.videoEl.currentTime = 0 - this.videoEl.play().catch(() => {}) - } else { - const onReady = () => { - if (this.videoEl) { - this.videoEl.currentTime = 0 - this.videoEl.play().catch(() => {}) - } - } - this.videoEl.addEventListener('loadedmetadata', onReady, { once: true }) - } - } else { - this.videoEl.currentTime = 0 - this.videoEl.play().catch(() => {}) + playInitial(src: string, preloadUrls: string[]) { + if (!this.elA) return + this.currentSrc = src + this.activeSlot = 'A' + this.preloaded.set('A', src) + this.elA.src = src + this.elA.style.opacity = '1' + this.elB!.style.opacity = '0' + + this.waitReady(this.elA).then(() => { + this.elA!.currentTime = 0 + this.elA!.play().catch(() => {}) + }) + + if (preloadUrls.length > 0) { + const next = preloadUrls[0] + this.preloaded.set('B', next) + this.elB!.src = next + this.elB!.load() } } - pause() { - this.videoEl?.pause() + switchTo(src: string, preloadUrls: string[]) { + if (!this.elA || this.switching) return + + const inKey = this.inactiveKey + const alreadyPreloaded = this.preloaded.get(inKey) + + if (alreadyPreloaded === src) { + this.doCrossFade(src, preloadUrls) + } else { + this.preloaded.set(inKey, src) + this.inactive.src = src + this.waitReady(this.inactive).then(() => { + this.doCrossFade(src, preloadUrls) + }) + } + } + + private doCrossFade(src: string, preloadUrls: string[]) { + const active = this.active + const inactive = this.inactive + const inKey = this.inactiveKey + + this.currentSrc = src + this.switching = true + + inactive.currentTime = 0 + inactive.play().catch(() => {}) + + active.style.transition = `opacity ${this.crossFadeMs}ms ease` + inactive.style.transition = `opacity ${this.crossFadeMs}ms ease` + active.style.opacity = '0' + inactive.style.opacity = '1' + + setTimeout(() => { + active.pause() + active.style.transition = 'none' + inactive.style.transition = 'none' + this.activeSlot = inKey + this.preloaded.set(inKey, src) + this.switching = false + + if (preloadUrls.length > 0) { + const nextInactive = this.inactive + const nextKey = this.inactiveKey + const candidate = preloadUrls[0] + if (candidate !== src) { + this.preloaded.set(nextKey, candidate) + nextInactive.src = candidate + nextInactive.load() + } + } + }, this.crossFadeMs + 50) } getCurrentTime(): number { - return this.videoEl?.currentTime ?? 0 + return this.active?.currentTime ?? 0 } onEnd(cb: VideoEndCallback) { @@ -59,13 +140,21 @@ export class VideoManager { this.onTimeCallback = cb } + private waitReady(el: HTMLVideoElement): Promise { + if (el.readyState >= 2) return Promise.resolve() + return new Promise((resolve) => { + el.addEventListener('canplay', () => resolve(), { once: true }) + el.load() + }) + } + private handleEnded = () => { this.onEndCallback?.() } private handleTimeUpdate = () => { - if (this.videoEl) { - this.onTimeCallback?.(this.videoEl.currentTime) + if (this.active) { + this.onTimeCallback?.(this.active.currentTime) } } } diff --git a/engine/systems/ChoiceSystem.ts b/engine/systems/ChoiceSystem.ts new file mode 100644 index 0000000..236ce83 --- /dev/null +++ b/engine/systems/ChoiceSystem.ts @@ -0,0 +1,75 @@ +import type { Choice } from '../types' + +export interface ChoiceTimerState { + total: number + remaining: number +} + +type TimerUpdateCallback = (state: ChoiceTimerState) => void +type TimeoutCallback = (choice: Choice) => void + +export class ChoiceSystem { + private timerId: ReturnType | null = null + private timeoutId: ReturnType | null = null + private timeLimit = 0 + private elapsed = 0 + private tickMs = 100 + private onUpdate: TimerUpdateCallback | null = null + private onTimeout: TimeoutCallback | null = null + + start(choices: Choice[], onUpdate: TimerUpdateCallback, onTimeout: TimeoutCallback) { + this.clear() + + const timed = choices.filter((c) => c.timeLimit && c.timeLimit > 0) + if (timed.length === 0) return + + const maxLimit = Math.max(...timed.map((c) => c.timeLimit!)) + + this.timeLimit = maxLimit + this.elapsed = 0 + this.onUpdate = onUpdate + this.onTimeout = onTimeout + + const state: ChoiceTimerState = { total: maxLimit, remaining: maxLimit } + this.onUpdate(state) + + this.timerId = setInterval(() => { + this.elapsed += this.tickMs + const remaining = Math.max(0, this.timeLimit - this.elapsed) / 1000 + const nextState: ChoiceTimerState = { + total: this.timeLimit / 1000, + remaining: Math.ceil(remaining * 10) / 10, + } + this.onUpdate?.(nextState) + }, this.tickMs) + + this.timeoutId = setTimeout(() => { + this.clear() + // Pick the first choice as default on timeout + if (choices.length > 0) { + this.onTimeout?.(choices[0]) + } + }, this.timeLimit) + } + + stop() { + this.clear() + } + + private clear() { + if (this.timerId !== null) { + clearInterval(this.timerId) + this.timerId = null + } + if (this.timeoutId !== null) { + clearTimeout(this.timeoutId) + this.timeoutId = null + } + } + + destroy() { + this.clear() + this.onUpdate = null + this.onTimeout = null + } +} diff --git a/engine/systems/SaveSystem.ts b/engine/systems/SaveSystem.ts new file mode 100644 index 0000000..afe15a9 --- /dev/null +++ b/engine/systems/SaveSystem.ts @@ -0,0 +1,72 @@ +import Dexie, { type Table } from 'dexie' +import type { SaveData } from '../types' + +interface SaveRecord { + id?: number + slot: number + timestamp: number + currentScene: string + variables: string + flags: string + history: string +} + +class SaveDB extends Dexie { + saves!: Table + + constructor() { + super('MovieGameSaves') + this.version(1).stores({ + saves: '++id, slot', + }) + } +} + +const db = new SaveDB() + +export class SaveSystem { + async save(slot: number, data: Omit): Promise { + const record: SaveRecord = { + slot, + timestamp: Date.now(), + currentScene: data.currentScene, + variables: JSON.stringify(data.variables), + flags: JSON.stringify(data.flags), + history: JSON.stringify(data.history), + } + + const existing = await db.saves.where('slot').equals(slot).first() + if (existing) { + await db.saves.update(existing.id!, record) + } else { + await db.saves.add(record) + } + } + + async load(slot: number): Promise { + const record = await db.saves.where('slot').equals(slot).first() + if (!record) return null + + return { + slot: record.slot, + timestamp: record.timestamp, + currentScene: record.currentScene, + variables: JSON.parse(record.variables), + flags: JSON.parse(record.flags), + history: JSON.parse(record.history), + } + } + + async listSlots(): Promise<{ slot: number; timestamp: number; sceneLabel: string }[]> { + const records = await db.saves.orderBy('slot').toArray() + return records.map((r) => ({ + slot: r.slot, + timestamp: r.timestamp, + sceneLabel: r.currentScene, + })) + } + + async delete(slot: number): Promise { + await db.saves.where('slot').equals(slot).delete() + } +} diff --git a/engine/types.ts b/engine/types.ts index 875f83c..ea3f700 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -66,6 +66,8 @@ export interface SaveData { export type EngineEvent = | 'sceneChange' | 'choiceRequest' + | 'choiceTimer' | 'gameEnd' | 'qteTrigger' | 'videoEnd' + | 'choiceTimeout' diff --git a/package-lock.json b/package-lock.json index 618a1f8..38bc4da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "moviegame", "version": "0.1.0", "dependencies": { + "dexie": "^4.4.3", "pinia": "^2.1.0", "vue": "^3.4.0" }, @@ -965,6 +966,11 @@ "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", "dev": true }, + "node_modules/dexie": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.3.tgz", + "integrity": "sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", diff --git a/package.json b/package.json index 3fde6bd..e44f8d7 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,9 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.4.0", - "pinia": "^2.1.0" + "dexie": "^4.4.3", + "pinia": "^2.1.0", + "vue": "^3.4.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.0", diff --git a/public/scenes/demo.json b/public/scenes/demo.json index 0420f9b..ec3f471 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -68,6 +68,23 @@ "trust_ending": { "id": "trust_ending", "videoUrl": "/videos/trust_ending.mp4", + "choices": [ + { + "text": "开启信任的旅程(需要 trust >= 80)", + "targetScene": "secret_ending", + "conditions": [ + { "variable": "trust", "op": ">=", "value": 80 } + ] + }, + { + "text": "离开这里", + "targetScene": "alone_ending" + } + ] + }, + "secret_ending": { + "id": "secret_ending", + "videoUrl": "/videos/continue_ending.mp4", "choices": [] }, "alone_ending": { diff --git a/src/App.vue b/src/App.vue index 0ccdb56..980c644 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,15 +2,19 @@ import { ref } from 'vue' import GamePlayer from '@/components/GamePlayer.vue' import ChoicePanel from '@/components/ChoicePanel.vue' +import SaveLoadMenu from '@/components/SaveLoadMenu.vue' import { useGameEngine } from '@/composables/useGameEngine' import { useGameStore } from '@/stores/gameStore' const store = useGameStore() -const videoElRef = ref(null) +const videoElA = ref(null) +const videoElB = ref(null) const loading = ref(true) const started = ref(false) +const showMenu = ref(false) -const { loadGame, start, makeChoice } = useGameEngine(() => videoElRef.value) +const { loadGame, start, makeChoice, saveGame, loadGameFromSlot, refreshSaves } = + useGameEngine(() => [videoElA.value, videoElB.value]) async function init() { await loadGame('/scenes/demo.json') @@ -22,14 +26,31 @@ function handleStart() { start() } -function onVideoReady(el: HTMLVideoElement) { - videoElRef.value = el +function onVideoReady(elA: HTMLVideoElement, elB: HTMLVideoElement) { + videoElA.value = elA + videoElB.value = elB } function onChoose(index: number) { makeChoice(index) } +function toggleMenu() { + showMenu.value = !showMenu.value + if (showMenu.value) { + refreshSaves() + } +} + +async function onSave(slot: number) { + await saveGame(slot) +} + +async function onLoad(slot: number) { + await loadGameFromSlot(slot) + showMenu.value = false +} + init() @@ -39,7 +60,15 @@ init() @@ -94,6 +130,26 @@ html, body { height: 100%; } +.menu-trigger { + position: absolute; + top: 16px; + right: 16px; + z-index: 20; + padding: 8px 16px; + font-size: 13px; + color: #aaa; + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 3px; + cursor: pointer; + transition: background 0.2s, color 0.2s; +} + +.menu-trigger:hover { + background: rgba(0, 0, 0, 0.7); + color: #fff; +} + .game-end-overlay { position: fixed; inset: 0; diff --git a/src/components/ChoicePanel.vue b/src/components/ChoicePanel.vue index a7e59d9..3babcc5 100644 --- a/src/components/ChoicePanel.vue +++ b/src/components/ChoicePanel.vue @@ -3,15 +3,35 @@ import type { Choice } from '@engine/types' const props = defineProps<{ choices: Choice[] + timerTotal: number + timerRemaining: number }>() const emit = defineEmits<{ choose: [index: number] }>() + +function timerPercent(): number { + if (props.timerTotal <= 0) return 0 + return (props.timerRemaining / props.timerTotal) * 100 +} + +function timerClass(): string { + if (props.timerRemaining <= 3) return 'danger' + return '' +}