From 0379548a290cf761285adae495b6bba3f2d763c5 Mon Sep 17 00:00:00 2001 From: cocos02 Date: Fri, 12 Jun 2026 11:37:14 +0800 Subject: [PATCH] feat: full-screen StoryGallery with flow layout, startAtScene engine method, clickable flow nodes --- engine/core/Engine.ts | 25 +- src/App.vue | 3 +- src/components/StoryGallery.vue | 658 +++++++++++++------------------ src/components/TreeFlow.vue | 22 +- src/composables/useGameEngine.ts | 8 + 5 files changed, 327 insertions(+), 389 deletions(-) diff --git a/engine/core/Engine.ts b/engine/core/Engine.ts index 32f106a..bc52bc1 100644 --- a/engine/core/Engine.ts +++ b/engine/core/Engine.ts @@ -357,17 +357,30 @@ export class Engine { const scene = this.sceneManager.getScene(chapter.startScene) if (!scene) return - const defaultVars = chapter.defaultVariables - if (defaultVars) { - this.stateManager.variables = { ...defaultVars } + this.initChapterState(chapter) + this.ended = false + this.isInitialScene = false + this.goToScene(scene) + } + + private initChapterState(chapter: { defaultVariables?: Record }) { + if (chapter.defaultVariables) { + this.stateManager.variables = { ...chapter.defaultVariables } } else { - this.stateManager.init(this.sceneManager.chapters.length > 0 - ? {} // from chapters, use the chapter's defaultVariables or empty - : {}) + this.stateManager.init({}) } this.stateManager.flags = new Set() this.stateManager.history = [] + } + startAtScene(chapterId: string, sceneId: string) { + const chapter = this.sceneManager.getChapter(chapterId) + if (!chapter) return + + const scene = this.sceneManager.getScene(sceneId) + if (!scene) return + + this.initChapterState(chapter) this.ended = false this.isInitialScene = false this.goToScene(scene) diff --git a/src/App.vue b/src/App.vue index 7febc3b..3f634fc 100644 --- a/src/App.vue +++ b/src/App.vue @@ -43,7 +43,7 @@ const showTopBar = ref(true) let hideTopBarTimer: ReturnType | null = null const { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, startChapter, - skipScene, setSpeed, getSpeed, isSceneWatched, + skipScene, setSpeed, getSpeed, isSceneWatched, startAtScene, saveGame, loadGameFromSlot, refreshSaves, saveSystem, engine } = useGameEngine(() => [videoElA.value, videoElB.value]) @@ -363,6 +363,7 @@ init() :visited-ids="store.visitedSceneIds" :unlocked-chapter-ids="store.unlockedChapterIds" @start-chapter="(chId: string) => { showStoryGallery = false; onStartChapter(chId) }" + @start-at-scene="(chId: string, sceneId: string) => { showStoryGallery = false; started = true; startAtScene(chId, sceneId) }" @close="showStoryGallery = false" /> () -const selectedChapterId = ref(null) +const showChapterPicker = ref(false) -const selectedChapter = computed(() => - props.chapters.find(c => c.id === selectedChapterId.value) ?? null, -) +function chaptersByProgress() { + return [...props.chapters].sort((a, b) => { + const pa = chapterProgress(a.id).pct + const pb = chapterProgress(b.id).pct + return pb - pa + }) +} + +const defaultChapter = computed(() => { + const sorted = chaptersByProgress() + for (const ch of sorted) { + if (chapterProgress(ch.id).pct > 0) return ch + } + return sorted[0] ?? null +}) + +const currentChapterId = ref('') + +if (defaultChapter.value) { + currentChapterId.value = defaultChapter.value.id +} function selectChapter(chapterId: string) { if (!props.unlockedChapterIds.has(chapterId)) return - selectedChapterId.value = selectedChapterId.value === chapterId ? null : chapterId + currentChapterId.value = chapterId + showChapterPicker.value = false } function collectReachable(startId: string): Set { @@ -88,11 +108,6 @@ function chapterProgress(chapterId: string) { return { count, total: reachable.size, pct: Math.round((count / reachable.size) * 100) } } -function ringDash(pct: number): string { - const circum = 2 * Math.PI * 22 - return `${(pct / 100) * circum} ${circum}` -} - function lockHint(sceneId: string): string { for (const [, src] of Object.entries(props.scenes)) { if (src.choices) { @@ -165,135 +180,87 @@ function buildTreeForChapter(chapterId: string): PlayerTreeNode | null { return buildPlayerTree(ch.startScene, 0, new Set()) } -const totalChaptersComplete = computed(() => { - let count = 0 - for (const ch of props.chapters) { - if (chapterProgress(ch.id).pct > 0) count++ +function onSelectScene(sceneId: string) { + if (ch.value) { + emit('startAtScene', ch.value.id, sceneId) } - return count -}) +} + +const ch = computed(() => props.chapters.find(c => c.id === currentChapterId.value) ?? null) +const endings = computed(() => chapterEndings.value[currentChapterId.value] || []) +const progress = computed(() => chapterProgress(currentChapterId.value)) +const tree = computed(() => buildTreeForChapter(currentChapterId.value)) @@ -302,29 +269,27 @@ const totalChaptersComplete = computed(() => { .story-overlay { position: fixed; inset: 0; - background: radial-gradient(ellipse at center, rgba(20,16,10,0.92) 0%, rgba(8,6,4,0.97) 100%); + background: #080604; display: flex; align-items: stretch; justify-content: center; z-index: 200; - padding: 24px; + flex-direction: column; } .story-shell { width: 100%; - max-width: 1100px; + height: 100%; display: flex; flex-direction: column; - background: rgba(16,14,20,0.85); - border: 1px solid rgba(255,255,255,0.06); - border-radius: 12px; - overflow: hidden; } .story-header { display: flex; align-items: center; - padding: 20px 24px; + padding: 16px 20px; + flex-shrink: 0; + background: rgba(0,0,0,0.6); border-bottom: 1px solid rgba(255,255,255,0.05); } @@ -337,8 +302,6 @@ const totalChaptersComplete = computed(() => { border-radius: 4px; cursor: pointer; transition: all 0.15s; - width: 90px; - text-align: center; } .back-btn:hover { @@ -349,84 +312,211 @@ const totalChaptersComplete = computed(() => { .story-title { flex: 1; text-align: center; - font-size: 20px; + font-size: 18px; font-weight: 500; color: #c9a84c; - letter-spacing: 6px; + letter-spacing: 4px; } -.header-spacer { width: 90px; } +.icon-btn { + width: 32px; + height: 32px; + font-size: 14px; + color: #666; + background: none; + border: 1px solid rgba(255,255,255,0.08); + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} + +.icon-btn:hover { + color: #ccc; + border-color: rgba(255,255,255,0.2); +} .story-body { flex: 1; - display: flex; min-height: 0; + position: relative; + overflow: hidden; } -.chapter-sidebar { - width: 320px; - flex-shrink: 0; - border-right: 1px solid rgba(255,255,255,0.05); - padding: 12px; - display: flex; - flex-direction: column; - gap: 8px; - overflow-y: auto; +.story-body :deep(.tree-flow) { + width: 100%; + height: 100%; + max-height: none; } -.chapter-item { +.story-empty { display: flex; align-items: center; - gap: 12px; - padding: 10px 12px; - background: rgba(255,255,255,0.02); - border: 1px solid rgba(255,255,255,0.04); + justify-content: center; + height: 100%; + color: #444; + font-size: 14px; +} + +.story-footer { + display: flex; + align-items: center; + padding: 12px 20px; + flex-shrink: 0; + background: rgba(0,0,0,0.5); + border-top: 1px solid rgba(255,255,255,0.05); +} + +.footer-left { + flex: 1; + display: flex; + align-items: center; + gap: 16px; +} + +.footer-stat { + display: flex; + align-items: baseline; + gap: 4px; +} + +.stat-value { + font-size: 18px; + font-weight: 600; + color: #c9a84c; +} + +.stat-unit { + font-size: 12px; + color: #666; +} + +.footer-endings { + display: flex; + gap: 6px; +} + +.ending-chip { + padding: 3px 10px; + font-size: 11px; + color: #555; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 12px; +} + +.ending-chip.unlocked { + color: #c9a84c; + border-color: rgba(201,168,76,0.25); + background: rgba(201,168,76,0.06); +} + +.chapter-btn { + padding: 8px 20px; + font-size: 13px; + color: #888; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; +} + +.chapter-btn:hover { + color: #ccc; + background: rgba(255,255,255,0.08); +} + +.chapter-picker { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; +} + +.picker-panel { + background: #12121a; + border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; + padding: 32px 36px; + max-width: 700px; + width: 90%; +} + +.picker-title { + text-align: center; + font-size: 20px; + font-weight: 500; + color: #c9a84c; + letter-spacing: 3px; + margin-bottom: 24px; +} + +.picker-grid { + display: flex; + gap: 16px; + justify-content: center; +} + +.picker-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 14px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; cursor: pointer; - transition: all 0.2s; + transition: all 0.15s; + width: 160px; } -.chapter-item:hover:not(.locked) { - background: rgba(255,255,255,0.05); - border-color: rgba(255,255,255,0.1); +.picker-card:hover:not(.locked) { + background: rgba(255,255,255,0.06); + border-color: rgba(255,255,255,0.15); } -.chapter-item.selected { - background: rgba(201,168,76,0.08); - border-color: rgba(201,168,76,0.25); +.picker-card.active { + border-color: rgba(201,168,76,0.3); + background: rgba(201,168,76,0.06); } -.chapter-item.locked { - opacity: 0.35; +.picker-card.locked { + opacity: 0.3; cursor: default; } -.ch-thumb { - width: 90px; - height: 50px; - flex-shrink: 0; +.picker-thumb { + width: 120px; + height: 68px; background: rgba(0,0,0,0.4); border-radius: 4px; overflow: hidden; position: relative; } -.ch-thumb-img { +.picker-thumb-img { width: 100%; height: 100%; object-fit: cover; } -.ch-thumb-placeholder { +.picker-thumb-place { display: flex; align-items: center; justify-content: center; height: 100%; - font-size: 22px; + font-size: 24px; color: #444; } -.ch-lock { +.picker-lock { position: absolute; inset: 0; display: flex; @@ -436,236 +526,44 @@ const totalChaptersComplete = computed(() => { font-size: 18px; } -.ch-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; -} - -.ch-label { +.picker-label { font-size: 13px; color: #ddd; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } -.ch-progress { +.picker-progress { display: flex; align-items: center; gap: 6px; + width: 100%; } -.ch-ring { - width: 28px; - height: 28px; - flex-shrink: 0; -} - -.ring-fill { - transition: stroke-dasharray 0.6s ease; -} - -.ch-pct { - font-size: 12px; - color: #c9a84c; - font-weight: 600; -} - -.ch-locked-text { - font-size: 11px; - color: #555; -} - -.ch-start { - width: 30px; - height: 30px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 13px; - color: #c9a84c; - background: rgba(201,168,76,0.1); - border: 1px solid rgba(201,168,76,0.2); - border-radius: 50%; - cursor: pointer; - transition: all 0.15s; -} - -.ch-start:hover { - background: rgba(201,168,76,0.25); - color: #e0c060; -} - -.detail-area { - flex: 1; - padding: 20px 24px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 16px; -} - -.detail-empty { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - color: #444; -} - -.empty-icon { - font-size: 42px; - color: #333; -} - -.empty-text { - font-size: 14px; -} - -.detail-hero { - display: flex; - align-items: center; - gap: 16px; -} - -.hero-img { - width: 180px; - height: 100px; - object-fit: cover; - border-radius: 6px; - border: 1px solid rgba(255,255,255,0.08); -} - -.hero-title { - font-size: 22px; - font-weight: 500; - color: #e0d0a0; - letter-spacing: 2px; -} - -.detail-stats { - display: flex; - gap: 24px; -} - -.stat-box { - display: flex; - align-items: baseline; - gap: 4px; - padding: 10px 16px; - background: rgba(255,255,255,0.03); - border: 1px solid rgba(255,255,255,0.05); - border-radius: 6px; -} - -.stat-value { - font-size: 24px; - font-weight: 600; - color: #c9a84c; -} - -.stat-unit { - font-size: 14px; - color: #666; -} - -.stat-label { - font-size: 11px; - color: #555; - margin-left: 8px; -} - -.section-label { - font-size: 11px; - color: #555; - text-transform: uppercase; - letter-spacing: 2px; - margin-bottom: 8px; -} - -.detail-endings { - padding: 12px 0; - border-top: 1px solid rgba(255,255,255,0.04); -} - -.ending-chips { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.ending-chip { - padding: 5px 14px; - font-size: 12px; - color: #555; - background: rgba(255,255,255,0.02); - border: 1px solid rgba(255,255,255,0.06); - border-radius: 20px; -} - -.ending-chip.unlocked { - color: #c9a84c; - border-color: rgba(201,168,76,0.25); - background: rgba(201,168,76,0.06); -} - -.detail-tree { - flex: 1; - padding: 12px 0; - border-top: 1px solid rgba(255,255,255,0.04); - min-height: 100px; -} - -.tree-container { - background: rgba(0,0,0,0.2); - border: 1px solid rgba(255,255,255,0.04); - border-radius: 6px; - padding: 0; - overflow: auto; -} - -.tree-empty { - font-size: 12px; - color: #444; - text-align: center; - padding: 16px; -} - -.story-footer { - padding: 12px 24px; - border-top: 1px solid rgba(255,255,255,0.05); -} - -.footer-progress { - display: flex; - align-items: center; - gap: 12px; -} - -.footer-bar { +.picker-bar-bg { flex: 1; height: 3px; - background: rgba(255,255,255,0.06); + background: rgba(255,255,255,0.08); border-radius: 2px; overflow: hidden; } -.footer-bar-fill { +.picker-bar-fill { height: 100%; - background: linear-gradient(90deg, #8b6914, #c9a84c); + background: #c9a84c; border-radius: 2px; - transition: width 0.4s ease; } -.footer-text { - font-size: 11px; - color: #666; - white-space: nowrap; +.picker-pct { + font-size: 10px; + color: #888; +} + +.picker-fade-enter-active, +.picker-fade-leave-active { + transition: opacity 0.2s ease; +} + +.picker-fade-enter-from, +.picker-fade-leave-to { + opacity: 0; } diff --git a/src/components/TreeFlow.vue b/src/components/TreeFlow.vue index dbf7eb7..a8167c2 100644 --- a/src/components/TreeFlow.vue +++ b/src/components/TreeFlow.vue @@ -7,8 +7,13 @@ const props = defineProps<{ node: PlayerTreeNode | null }>() +const emit = defineEmits<{ + selectScene: [sceneId: string] +}>() + interface FlowNode { id: string + sceneId: string label: string visited: boolean isMystery: boolean @@ -33,7 +38,7 @@ const containerW = ref(800) const containerH = ref(400) function buildFlow(root: PlayerTreeNode) { - const dagreNodes: { id: string; parent: string | null; label: string; visited: boolean; isMystery: boolean; locked: boolean; lockHint?: string }[] = [] + const dagreNodes: { id: string; sceneId: string; parent: string | null; label: string; visited: boolean; isMystery: boolean; locked: boolean; lockHint?: string }[] = [] const dagreEdges: { from: string; to: string; visited: boolean }[] = [] function walk(node: PlayerTreeNode, parentId: string | null) { @@ -41,6 +46,7 @@ function buildFlow(root: PlayerTreeNode) { const dagreId = parentId ? `${parentId}/${node.sceneId}` : node.sceneId dagreNodes.push({ id: dagreId, + sceneId: node.sceneId, parent: parentId, label: node.label, visited: true, @@ -65,6 +71,7 @@ function buildFlow(root: PlayerTreeNode) { const mysteryId = `${dagreId}/__mystery` dagreNodes.push({ id: mysteryId, + sceneId: '', parent: dagreId, label: '? ?', visited: false, @@ -100,6 +107,7 @@ function buildFlow(root: PlayerTreeNode) { const pos = g.node(n.id) return { id: n.id, + sceneId: n.sceneId, label: n.label, visited: n.visited, isMystery: n.isMystery, @@ -221,9 +229,10 @@ const svgH = computed(() => containerH.value) v-for="n in nodes" :key="n.id" class="flow-node" - :class="{ visited: n.visited, mystery: n.isMystery, locked: n.locked }" + :class="{ visited: n.visited, mystery: n.isMystery, locked: n.locked, clickable: n.visited && n.sceneId }" :style="{ left: n.x + 'px', top: n.y + 'px', width: n.w + 'px', height: n.h + 'px' }" :title="n.lockHint || ''" + @click="n.visited && n.sceneId && emit('selectScene', n.sceneId)" > {{ n.visited ? '✦' : n.isMystery ? '?' : '⬜' }} {{ n.label }} @@ -264,6 +273,15 @@ const svgH = computed(() => containerH.value) overflow: hidden; } +.flow-node.clickable { + cursor: pointer; +} + +.flow-node.clickable:hover { + background: rgba(201, 168, 76, 0.22); + border-color: rgba(201, 168, 76, 0.5); +} + .flow-node.visited { background: rgba(201, 168, 76, 0.12); border: 1px solid rgba(201, 168, 76, 0.3); diff --git a/src/composables/useGameEngine.ts b/src/composables/useGameEngine.ts index ecd80db..52af15e 100644 --- a/src/composables/useGameEngine.ts +++ b/src/composables/useGameEngine.ts @@ -195,6 +195,13 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide engine.startChapter(chapterId) } + function startAtScene(chapterId: string, sceneId: string) { + const [elA, elB] = videoEls() + if (elA && elB) engine.videoManager.attach(elA, elB) + store.setGameEnded(false) + engine.startAtScene(chapterId, sceneId) + } + function skipScene() { engine.skipCurrentScene() } @@ -274,6 +281,7 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide makeChoice, clickHotspot, startChapter, + startAtScene, skipScene, setSpeed, getSpeed,