fix 无效节点

This commit is contained in:
2025-09-16 16:48:12 +08:00
parent 93400900c0
commit 75f3456134
8 changed files with 665 additions and 149 deletions

View File

@@ -686,6 +686,20 @@
choice_1: "继续阅读" -> eva_photo_reaction_p3 [audio: button_click.mp3] choice_1: "继续阅读" -> eva_photo_reaction_p3 [audio: button_click.mp3]
@end @end
@node eva_photo_reaction_p3 @node eva_photo_reaction_p3
@title "伊娃的照片反应3/3"
@audio_bg heartbeat.mp3
@content """
风从温室的穹顶缓缓掠过。你看见她在光里,像一朵迟到的花,仍在寻找合适的季节。
你把手伸向空无之处,像在轻轻地为她抚平一层看不见的褶皱。
那一刻,你忽然知道,拯救不是把她拉向你,而是把她归还给自己。
"""
@choices 2
choice_1: "为她归还呼吸:以肉身迎回灵魂" -> body_restoration_quest [require: trust_level >= 8] [require: health >= 40] [require: stamina >= 30] [audio: orchestral_revelation.mp3]
choice_2: "将心安放片刻,我们继续准备与观察" -> private_grief [audio: rain_light.mp3]
@end
@node private_grief @node private_grief
@title "私人悲伤1/2" @title "私人悲伤1/2"
@audio_bg rain_light.mp3 @audio_bg rain_light.mp3
@@ -1203,12 +1217,26 @@
这是人类历史上第一次成功的AI意识具现化。你们没有创造生命而是给了生命新的选择。 这是人类历史上第一次成功的AI意识具现化。你们没有创造生命而是给了生命新的选择。
""" """
@choices 3 @choices 1
choice_1: "与莉莉一起探索人类体验的意义" -> human_experience_exploration [effect: trust+35] [audio: wind_gentle.mp3] choice_1: "让余晖落定" -> body_restoration_quest_epilogue [audio: button_click.mp3]
choice_2: "将这项技术分享给全宇宙" -> consciousness_liberation [effect: trust+40] [audio: epic_finale.mp3]
choice_3: "建立意识形式选择中心" -> consciousness_choice_center [effect: trust+30] [audio: orchestral_revelation.mp3]
@end @end
@node body_restoration_quest_epilogue
@title "重塑生命之旅·尾声"
@audio_bg orchestral_revelation.mp3
@content """
花园的清晨,风从玻璃穹顶缓缓掠过。莉莉学着把手贴在叶脉上,像第一次学会心跳的人那样,等待一阵温度从指尖开花。
你把工具轻轻递给她,螺丝在光里旋转,一声极细的咔哒,像命运重新落位。你们并肩坐着,呼吸彼此对齐。曾经被剥夺的感受逐条返航:汗水的盐分、脉搏的节律、相拥时胸腔共振的微光。你忽然明白,拯救并不在彼岸,而在此刻被完整地活着。
夜色降临,研究院的格言被点亮。你们把新的约定写在墙上:任何技术,只为治愈;任何选择,必以明示同意为门;任何试验,都以“可撤回”为界。痛苦不再是代价,而是被看见、被安放、被转化。
莉莉抬头看你,眼里有海一样的静度。她说:“姐姐,我回来了。”你点头。你知道这个回来了,不止是她。
"""
@choices 1
choice_1: "重新经历一次" -> first_awakening [effect: loop+1, secret+1] [audio: wind_gentle.mp3]
@end
@node civilization_renarration @node civilization_renarration
@title "重写文明之歌" @title "重写文明之歌"
@audio_bg epic_finale.mp3 @audio_bg epic_finale.mp3
@@ -1234,10 +1262,8 @@
人类文明的新篇章开始了不再是conquering the stars而是befriending the universe。 人类文明的新篇章开始了不再是conquering the stars而是befriending the universe。
""" """
@choices 3 @choices 1
choice_1: "建立'新叙事'培训中心,遍布全宇宙" -> narrative_academy [effect: trust+45] [audio: orchestral_revelation.mp3] choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3]
choice_2: "创建跨物种的文明交流协议" -> interspecies_harmony [effect: trust+50] [audio: epic_finale.mp3]
choice_3: "成为新文明模式的活体象征" -> civilization_symbol [effect: trust+40] [audio: wind_gentle.mp3]
@end @end
@node anna_memorial_institute @node anna_memorial_institute
@@ -1265,10 +1291,8 @@
一年后,安娜纪念研究院的模式推广到了整个太阳系。人类终于学会了如何将痛苦转化为智慧。 一年后,安娜纪念研究院的模式推广到了整个太阳系。人类终于学会了如何将痛苦转化为智慧。
""" """
@choices 3 @choices 1
choice_1: "将安娜纪念研究院模式推广到全宇宙" -> universal_healing_science [effect: trust+50] [audio: epic_finale.mp3] choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3]
choice_2: "创建'痛苦转化'技术,帮助更多受伤的心灵" -> pain_transformation_tech [effect: trust+45] [audio: orchestral_revelation.mp3]
choice_3: "与德米特里建立永久合作,成为治愈者伙伴" -> healing_partnership [effect: trust+40] [audio: wind_gentle.mp3]
@end @end
@node base_transformation @node base_transformation
@@ -1296,10 +1320,8 @@
"秘密很简单,"你解释道,"当每个人都感到被理解和被关爱时,他们会自然地贡献出最好的自己。" "秘密很简单,"你解释道,"当每个人都感到被理解和被关爱时,他们会自然地贡献出最好的自己。"
""" """
@choices 3 @choices 1
choice_1: "将基地模式分享给所有月球基地" -> lunar_network_transformation [effect: trust+35] [audio: orchestral_revelation.mp3] choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3]
choice_2: "创建'基地改造'培训项目" -> transformation_academy [effect: trust+30] [audio: discovery_chime.mp3]
choice_3: "推广到太空站和火星基地" -> space_harmony_expansion [effect: trust+40] [audio: epic_finale.mp3]
@end @end
@node collaboration_expansion @node collaboration_expansion
@@ -1325,10 +1347,8 @@
你微笑着回答:"很快,小朋友。宇宙正在等待我们准备好。" 你微笑着回答:"很快,小朋友。宇宙正在等待我们准备好。"
""" """
@choices 3 @choices 1
choice_1: "建立跨星系合作探索项目" -> intergalactic_cooperation [effect: trust+50] [audio: epic_finale.mp3] choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3]
choice_2: "创建'宇宙和谐使者'培训学院" -> cosmic_harmony_academy [effect: trust+45] [audio: orchestral_revelation.mp3]
choice_3: "专注于地球文明的深度转型" -> earth_transformation_focus [effect: trust+40] [audio: wind_gentle.mp3]
@end @end
@node complete_destruction_plan @node complete_destruction_plan
@@ -1358,10 +1378,8 @@
你成为了道德边界的守护者,确保科学永远服务于人类的尊严,而不是相反。 你成为了道德边界的守护者,确保科学永远服务于人类的尊严,而不是相反。
""" """
@choices 3 @choices 1
choice_1: "成立'技术伦理监督委员会',永久监管危险科学" -> tech_ethics_guardian [effect: trust+35] [audio: orchestral_revelation.mp3] choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3]
choice_2: "将你的故事传播到全宇宙,警告其他文明" -> universal_warning_beacon [effect: trust+40] [audio: epic_finale.mp3]
choice_3: "回到地球,专注于自然修复而非技术干预" -> natural_healing_path [effect: trust+30] [audio: wind_gentle.mp3]
@end @end
// ===== 情境化变体节点(替代过度重用节点)===== // ===== 情境化变体节点(替代过度重用节点)=====
@@ -3361,7 +3379,7 @@
""" """
@choices 4 @choices 4
choice_1: "尝试激活手臂植入物" -> implant_activation [effect: secret_unlock] [audio: electronic_tension.mp3] choice_1: "尝试激活手臂植入物" -> hidden_marks_discovery [effect: secret_unlock] [audio: electronic_tension.mp3]
choice_2: "探索'月神'项目的含义" -> project_luna_investigation [effect: secret_unlock] [audio: discovery_chime.mp3] choice_2: "探索'月神'项目的含义" -> project_luna_investigation [effect: secret_unlock] [audio: discovery_chime.mp3]
choice_3: "寻找其他被修改的实验对象" -> other_subjects [effect: secret_unlock] [audio: ambient_mystery.mp3] choice_3: "寻找其他被修改的实验对象" -> other_subjects [effect: secret_unlock] [audio: ambient_mystery.mp3]
choice_4: "立即联系伊娃询问真相" -> eva_identity_revelation [effect: trust+5] [audio: orchestral_revelation.mp3] choice_4: "立即联系伊娃询问真相" -> eva_identity_revelation [effect: trust+5] [audio: orchestral_revelation.mp3]
@@ -3460,11 +3478,8 @@
这个声音...是某种植入的AI系统你的身体里还有什么你不知道的东西 这个声音...是某种植入的AI系统你的身体里还有什么你不知道的东西
""" """
@choices 4 @choices 1
choice_1: "尝试与植入AI系统对话" -> implant_ai_communication [effect: secret_unlock] [audio: electronic_tension.mp3] choice_1: "搜索关于'月神'项目的信息" -> project_luna_investigation [effect: secret_unlock] [audio: discovery_chime.mp3]
choice_2: "立即尝试激活量子接口" -> quantum_interface_activation [effect: secret_unlock] [audio: time_distortion.mp3]
choice_3: "搜索关于'月神'项目的信息" -> project_luna_investigation [effect: secret_unlock] [audio: discovery_chime.mp3]
choice_4: "忽略警告,继续自我检查" -> deep_body_scan [effect: health-5] [audio: heartbeat.mp3]
@end @end
@node quantum_badge_analysis @node quantum_badge_analysis
@@ -3765,11 +3780,10 @@
在这种新的感知状态下,你意识到一个可怕的真相:时间锚的每次激活都会在现实中创造裂隙。这些裂隙正在累积,现实本身变得不稳定。如果继续下去,不仅地球会毁灭,整个宇宙的时间结构都可能崩溃。 在这种新的感知状态下,你意识到一个可怕的真相:时间锚的每次激活都会在现实中创造裂隙。这些裂隙正在累积,现实本身变得不稳定。如果继续下去,不仅地球会毁灭,整个宇宙的时间结构都可能崩溃。
""" """
@choices 4 @choices 3
choice_1: "尝试与时间锚中的47个自己沟通" -> temporal_selves_communion [effect: health-10] [audio: time_distortion.mp3] choice_1: "尝试与时间锚中的47个自己沟通" -> temporal_selves_communion [effect: health-10] [audio: time_distortion.mp3]
choice_2: "使用新能力直接与伊娃的意识深层连接" -> eva_quantum_link [effect: trust+15] [audio: orchestral_revelation.mp3] choice_2: "使用新能力直接与伊娃的意识深层连接" -> eva_quantum_link [effect: trust+15] [audio: orchestral_revelation.mp3]
choice_3: "试图稳定现实裂隙,阻止宇宙崩溃" -> reality_stabilization [effect: health-15] [audio: epic_finale.mp3] choice_3: "试图稳定现实裂隙,阻止宇宙崩溃" -> reality_stabilization [effect: health-15] [audio: epic_finale.mp3]
choice_4: "关闭植入物,重新变回完全的人类" -> humanity_preservation [effect: trust-5] [audio: heartbeat.mp3]
@end @end
@node suffering_philosophy @node suffering_philosophy
@@ -4028,9 +4042,8 @@
在这种宽恕的力量下,你意识到真正的解决方案不是破坏或控制,而是治愈和接受。 在这种宽恕的力量下,你意识到真正的解决方案不是破坏或控制,而是治愈和接受。
""" """
@choices 3 @choices 1
choice_1: "带领所有人一起创造基于宽恕的新现实" -> forgiveness_reality [effect: trust+35] [audio: epic_finale.mp3] choice_1: "带领所有人一起创造基于宽恕的新现实" -> harmony_guardians_final [effect: trust+35] [audio: epic_finale.mp3]
choice_2: "将宽恕的力量传播到地球上的所有人" -> global_healing [effect: trust+30] [audio: orchestral_revelation.mp3]
@node eva_quantum_link @node eva_quantum_link
@title "从痛苦中创造意义1/2" @title "从痛苦中创造意义1/2"
@audio_bg orchestral_revelation.mp3 @audio_bg orchestral_revelation.mp3
@@ -4188,6 +4201,13 @@
这是德米特里从未听过的录音片段——在最后的时刻,安娜成为了一个小英雄。 这是德米特里从未听过的录音片段——在最后的时刻,安娜成为了一个小英雄。
**治愈的洞察** **治愈的洞察**
"""
@choices 2
choice_1: "为安娜立灯塔:建立治愈与同意的研究院" -> anna_memorial_institute [audio: orchestral_revelation.mp3]
choice_2: "继续阅读" -> reality_stabilization [audio: button_click.mp3]
@end
@node reality_stabilization @node reality_stabilization
@title "现实守护者的使命1/2" @title "现实守护者的使命1/2"
@audio_bg epic_finale.mp3 @audio_bg epic_finale.mp3
@@ -4776,7 +4796,6 @@
**永恒的循环** **永恒的循环**
不再是痛苦的48次循环而是智慧与爱的无限螺旋上升每一次"循环"都带来更深的理解和更广的compassion。 不再是痛苦的48次循环而是智慧与爱的无限螺旋上升每一次"循环"都带来更深的理解和更广的compassion。
""" """
@choices 2 @choices 1
choice_1: "成为宽恕现实的永恒守护者" -> forgiveness_guardians [effect: trust+50] [audio: epic_finale.mp3] choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3]
choice_2: "将宽恕的种子播撒到多元宇宙" -> multiverse_forgiveness [effect: trust+45] [audio: orchestral_revelation.mp3]
@end @end

View File

@@ -2,8 +2,9 @@ package com.osglab.gameofmoon.data
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import com.osglab.gameofmoon.model.GameState import com.osglab.gameofmoon.story.engine.EngineSaveDTO
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
/** /**
@@ -24,20 +25,18 @@ class GameSaveManager(private val context: Context) {
/** /**
* 保存游戏状态 * 保存游戏状态
*/ */
fun saveGame( fun saveGameSlot(
gameState: GameState, slot: Int,
currentNodeId: String, dto: EngineSaveDTO,
dialogueHistory: List<String> = emptyList() extraNotes: String = ""
): Boolean { ): Boolean {
return try { return try {
val gameStateJson = json.encodeToString(gameState) val saveJson = json.encodeToString(dto)
val dialogueJson = json.encodeToString(dialogueHistory)
prefs.edit() prefs.edit()
.putString(KEY_GAME_STATE, gameStateJson) .putString(keyForSlot(slot), saveJson)
.putString(KEY_CURRENT_NODE, currentNodeId) .putLong(keyForSlotTime(slot), System.currentTimeMillis())
.putString(KEY_DIALOGUE_HISTORY, dialogueJson) .putString(keyForSlotNote(slot), extraNotes)
.putLong(KEY_SAVE_TIME, System.currentTimeMillis())
.apply() .apply()
true true
@@ -50,21 +49,17 @@ class GameSaveManager(private val context: Context) {
/** /**
* 加载游戏状态 * 加载游戏状态
*/ */
fun loadGame(): SaveData? { fun loadGameSlot(slot: Int): SaveData? {
return try { return try {
val gameStateJson = prefs.getString(KEY_GAME_STATE, null) ?: return null val saveJson = prefs.getString(keyForSlot(slot), null) ?: return null
val currentNodeId = prefs.getString(KEY_CURRENT_NODE, null) ?: return null val saveTime = prefs.getLong(keyForSlotTime(slot), 0L)
val dialogueJson = prefs.getString(KEY_DIALOGUE_HISTORY, "[]")!! val note = prefs.getString(keyForSlotNote(slot), "") ?: ""
val saveTime = prefs.getLong(KEY_SAVE_TIME, 0L) val dto = json.decodeFromString<EngineSaveDTO>(saveJson)
val gameState = json.decodeFromString<GameState>(gameStateJson)
val dialogueHistory = json.decodeFromString<List<String>>(dialogueJson)
SaveData( SaveData(
gameState = gameState, dto = dto,
currentNodeId = currentNodeId, saveTime = saveTime,
dialogueHistory = dialogueHistory, note = note
saveTime = saveTime
) )
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -75,20 +70,19 @@ class GameSaveManager(private val context: Context) {
/** /**
* 检查是否有保存的游戏 * 检查是否有保存的游戏
*/ */
fun hasSavedGame(): Boolean { fun hasSavedSlot(slot: Int): Boolean {
return prefs.contains(KEY_GAME_STATE) return prefs.contains(keyForSlot(slot))
} }
/** /**
* 删除保存的游戏 * 删除保存的游戏
*/ */
fun deleteSave(): Boolean { fun deleteSave(slot: Int): Boolean {
return try { return try {
prefs.edit() prefs.edit()
.remove(KEY_GAME_STATE) .remove(keyForSlot(slot))
.remove(KEY_CURRENT_NODE) .remove(keyForSlotTime(slot))
.remove(KEY_DIALOGUE_HISTORY) .remove(keyForSlotNote(slot))
.remove(KEY_SAVE_TIME)
.apply() .apply()
true true
} catch (e: Exception) { } catch (e: Exception) {
@@ -99,8 +93,8 @@ class GameSaveManager(private val context: Context) {
/** /**
* 获取保存时间的格式化字符串 * 获取保存时间的格式化字符串
*/ */
fun getSaveTimeString(): String? { fun getSaveTimeString(slot: Int): String? {
val saveTime = prefs.getLong(KEY_SAVE_TIME, 0L) val saveTime = prefs.getLong(keyForSlotTime(slot), 0L)
return if (saveTime > 0) { return if (saveTime > 0) {
val date = java.util.Date(saveTime) val date = java.util.Date(saveTime)
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()) java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
@@ -111,10 +105,9 @@ class GameSaveManager(private val context: Context) {
} }
companion object { companion object {
private const val KEY_GAME_STATE = "game_state" private fun keyForSlot(slot: Int) = "slot_${slot}_data"
private const val KEY_CURRENT_NODE = "current_node" private fun keyForSlotTime(slot: Int) = "slot_${slot}_time"
private const val KEY_DIALOGUE_HISTORY = "dialogue_history" private fun keyForSlotNote(slot: Int) = "slot_${slot}_note"
private const val KEY_SAVE_TIME = "save_time"
} }
} }
@@ -122,8 +115,7 @@ class GameSaveManager(private val context: Context) {
* 保存数据结构 * 保存数据结构
*/ */
data class SaveData( data class SaveData(
val gameState: GameState, val dto: EngineSaveDTO,
val currentNodeId: String, val saveTime: Long,
val dialogueHistory: List<String>, val note: String
val saveTime: Long
) )

View File

@@ -17,32 +17,44 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.osglab.gameofmoon.R import com.osglab.gameofmoon.R
import com.osglab.gameofmoon.presentation.ui.theme.GameIcons import com.osglab.gameofmoon.presentation.ui.theme.GameIcons
import androidx.compose.foundation.border
import androidx.compose.ui.text.style.TextOverflow
import com.osglab.gameofmoon.story.engine.EngineSaveDTO
import com.osglab.gameofmoon.story.engine.StoryEngineAdapter
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@Composable @Composable
fun GameControlMenu( fun GameControlMenu(
isVisible: Boolean, isVisible: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNewLoop: () -> Unit onNewLoop: () -> Unit,
// 新增存档与读取3槽位
onSaveSlot: (Int) -> Unit = {},
onLoadSlot: (Int) -> Unit = {},
getSlotTime: (Int) -> String? = { null },
getSlotDTO: (Int) -> EngineSaveDTO? = { null },
storyEngineAdapter: StoryEngineAdapter? = null
) { ) {
if (isVisible) { if (isVisible) {
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 5.dp) .padding(0.dp)
.background(Color.Black.copy(alpha = 0.5f)), // 70%透明度的黑色背景 .background(Color.Black.copy(alpha = 0.5f)), // 半透明遮罩
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(380.dp) .width(580.dp)
.height(570.dp) .height(680.dp)
) { ) {
// 设置背景图片 // 设置背景图片
Image( Image(
painter = painterResource(id = R.drawable.setting), painter = painterResource(id = R.drawable.setting),
contentDescription = "Setting Background", contentDescription = "Setting Background",
modifier = Modifier.fillMaxSize(), modifier = Modifier.matchParentSize(),
contentScale = ContentScale.FillBounds contentScale = ContentScale.FillBounds
) )
@@ -50,7 +62,7 @@ fun GameControlMenu(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 24.dp, vertical = 20.dp), .padding(horizontal = 42.dp, vertical = 42.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// 顶部额外留白 // 顶部额外留白
@@ -81,27 +93,143 @@ fun GameControlMenu(
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
// 游戏介绍文本 // 游戏介绍文本(临时隐藏以提升空间)
Text( // Text(
text = "在无尽的时间循环中探索真相,\n每一次重启都是新的开始,\n但记忆将指引你走向不同的结局。", // text = "在无尽的时间循环中探索真相,\n每一次重启都是新的开始,\n但记忆将指引你走向不同的结局。",
style = CyberTextStyles.StoryContent.copy(fontSize = 12.sp), // style = CyberTextStyles.StoryContent.copy(fontSize = 12.sp),
color = Color(0xFFCCCCCC), // color = Color(0xFFCCCCCC),
textAlign = TextAlign.Center, // textAlign = TextAlign.Center,
lineHeight = 16.sp // lineHeight = 16.sp
) // )
// 将按钮推到底部 // 将按钮推到底部
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// 开始新循环按钮 // 存档/读取3槽位 - 列表样式
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "存档 / 读取",
style = CyberTextStyles.Choice.copy(fontSize = 12.sp),
color = Color(0xFF00AACC)
)
val scope = rememberCoroutineScope()
val titleCache = remember { mutableStateMapOf<Int, String>() }
val percentCache = remember { mutableStateMapOf<Int, Float>() }
val totalNodes = remember { storyEngineAdapter?.getTotalNodeCount() ?: 0 }
(1..3).forEach { slot ->
val dto = getSlotDTO(slot)
val timeStr = getSlotTime(slot)
val explored = dto?.nodesVisitedLifetime?.size ?: 0
val percent = if (totalNodes > 0) (explored * 100f / totalNodes).coerceAtMost(100f) else 0f
val savedNodeId = dto?.currentNodeId
val title = remember(savedNodeId) { mutableStateOf(titleCache[slot] ?: (savedNodeId ?: "空槽位")) }
val percentState = remember { mutableStateOf(percentCache[slot] ?: percent) }
if (dto != null && storyEngineAdapter != null && !titleCache.containsKey(slot)) {
scope.launch {
val node = storyEngineAdapter.getNode(dto.currentNodeId)
val t = node?.title ?: dto.currentNodeId
titleCache[slot] = t
title.value = t
percentCache[slot] = percent
percentState.value = percent
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, Color(0x3300DDFF))
.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = title.value,
style = CyberTextStyles.Choice.copy(fontSize = 14.sp),
color = Color(0xFF00DDFF),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val sub = if (dto != null && timeStr != null) {
"${"%.1f".format(percentState.value)}% · ${timeStr}"
} else timeStr ?: "空槽位"
Text(
text = sub,
style = CyberTextStyles.Caption.copy(fontSize = 11.sp),
color = Color(0xFFAAAAAA),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
IconButton(onClick = {
onSaveSlot(slot)
// 保存后立即刷新本行的显示
val fresh = getSlotDTO(slot)
if (fresh != null) {
val nodeId = fresh.currentNodeId
val freshExplored = fresh.nodesVisitedLifetime.size
val freshPercent = if (totalNodes > 0) (freshExplored * 100f / totalNodes).coerceAtMost(100f) else 0f
percentCache[slot] = freshPercent
percentState.value = freshPercent
if (storyEngineAdapter != null) {
scope.launch {
val node = storyEngineAdapter.getNode(nodeId)
val t = node?.title ?: nodeId
titleCache[slot] = t
title.value = t
}
} else {
titleCache[slot] = nodeId
title.value = nodeId
}
}
}) {
Icon(
imageVector = GameIcons.Save,
contentDescription = "保存${slot}",
tint = Color(0xFF00DDFF)
)
}
IconButton(onClick = { onLoadSlot(slot) }, enabled = dto != null) {
Icon(
imageVector = GameIcons.Load,
contentDescription = "读取${slot}",
tint = if (dto != null) Color(0xFF00DDFF) else Color(0xFF666666)
)
}
}
}
}
// 将底部按钮固定在最底部
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 开始新循环(半宽)
NeonButton( NeonButton(
onClick = { onClick = {
onNewLoop() onNewLoop()
onDismiss() onDismiss()
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.padding(start = 15.dp, end = 15.dp, bottom = 2.dp) .height(44.dp),
compact = true
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -110,27 +238,22 @@ fun GameControlMenu(
) { ) {
Icon( Icon(
imageVector = GameIcons.Refresh, imageVector = GameIcons.Refresh,
contentDescription = "开始新循环", contentDescription = "新循环",
modifier = Modifier.size(20.dp), modifier = Modifier.size(18.dp),
tint = Color(0xFF00DDFF) tint = Color(0xFF00DDFF)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(text = "新循环", fontSize = 14.sp, fontWeight = FontWeight.Bold)
text = "开始新循环",
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
} }
} }
Spacer(modifier = Modifier.height(8.dp)) // 关闭(半宽)
// 关闭按钮
NeonButton( NeonButton(
onClick = onDismiss, onClick = onDismiss,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.padding(start = 15.dp, end = 15.dp, bottom = 5.dp) .height(44.dp),
compact = true
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -140,20 +263,14 @@ fun GameControlMenu(
Icon( Icon(
imageVector = GameIcons.Close, imageVector = GameIcons.Close,
contentDescription = "关闭", contentDescription = "关闭",
modifier = Modifier.size(20.dp), modifier = Modifier.size(18.dp),
tint = Color(0xFF00DDFF) tint = Color(0xFF00DDFF)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(text = "关闭", fontSize = 14.sp, fontWeight = FontWeight.Bold)
text = "关闭", }
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
} }
} }
// 底部额外留白
Spacer(modifier = Modifier.height(32.dp))
} }
} // 背景图片Box结尾 } // 背景图片Box结尾
} // 外层Box结尾 } // 外层Box结尾

View File

@@ -0,0 +1,204 @@
package com.osglab.gameofmoon.presentation.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.osglab.gameofmoon.R
import com.osglab.gameofmoon.presentation.ui.theme.GameIcons
import com.osglab.gameofmoon.story.engine.StoryEngineAdapter
@Composable
fun GameStatusPanel(
isVisible: Boolean,
onDismiss: () -> Unit,
storyEngineAdapter: StoryEngineAdapter
) {
if (!isVisible) return
val snapshot = remember { mutableStateOf(storyEngineAdapter.captureStatusSnapshot()) }
LaunchedEffect(isVisible) {
if (isVisible) snapshot.value = storyEngineAdapter.captureStatusSnapshot()
}
val s = snapshot.value
val explored = s.nodesVisitedLifetime.size
val total = s.totalNodeCount.coerceAtLeast(1)
val progress = (explored.toFloat() / total.toFloat())
// 隐藏分支目录7项
val hiddenBranches = listOf(
"stealth_observation" to "隐秘观察",
"eavesdropping" to "偷听",
"system_sabotage" to "系统破坏",
"data_extraction" to "访问机密数据库",
"garden_cooperation" to "秘密花园",
"hidden_records_discovery" to "隐藏记录发现",
"hidden_marks_discovery" to "隐藏标记的发现"
)
val discovered = hiddenBranches.count { (id, _) -> s.nodesVisitedLifetime.contains(id) }
Dialog(onDismissRequest = onDismiss) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.width(580.dp)
.height(680.dp)
) {
// 背景图与齿轮弹窗一致
Image(
painter = painterResource(id = R.drawable.setting),
contentDescription = "Setting Background",
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.FillBounds
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 56.dp, vertical = 42.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 顶部留少量空间
Spacer(modifier = Modifier.height(4.dp))
// 基础状态
InfoRow("信任", s.trustLevel.toString(), valueSmall = true)
InfoRow("生命", s.health.toString(), valueSmall = true)
InfoRow("体力", s.stamina.toString(), valueSmall = true)
InfoRow("循环", s.loopCount.toString())
Divider(color = Color(0x2222DDFF))
// 进度统计(精简)
// 剧情百分比
Text(
text = "剧情百分比:${"%.1f".format(progress * 100)}% (${explored}/${total})",
style = CyberTextStyles.StoryContent,
color = Color(0xFFCCCCCC),
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)
)
// 分支剧情探索度 + 掩码
Text(
text = "分支探索:${discovered}/${hiddenBranches.size}",
style = CyberTextStyles.StoryContent,
color = Color(0xFFCCCCCC),
modifier = Modifier.fillMaxWidth().padding(top = 2.dp, bottom = 4.dp)
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
hiddenBranches.forEach { (id, title) ->
val shown = if (s.nodesVisitedLifetime.contains(id)) title else "*******"
Text(
text = "${shown}",
style = CyberTextStyles.Caption,
color = Color(0xFFAAAAAA),
modifier = Modifier.padding(start = 2.dp)
)
}
}
Divider(color = Color(0x2222DDFF))
// 发现统计(精简)
Text("秘密:${s.secretsFound.size}", color = Color(0xFFCCCCCC), modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp))
if (s.secretsFound.isNotEmpty()) {
Text(s.secretsFound.joinToString(), color = Color(0xFF888888), modifier = Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 4.dp))
}
Text("地点:${s.locationsDiscovered.size}", color = Color(0xFFCCCCCC), modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp))
if (s.locationsDiscovered.isNotEmpty()) {
Text(s.locationsDiscovered.joinToString(), color = Color(0xFF888888), modifier = Modifier.fillMaxWidth().padding(start = 4.dp))
}
// 高级信息(精简)
if (s.flags.isNotEmpty()) Text("Flags: ${s.flags.joinToString()}", color = Color(0xFF888888), modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp))
// 变量白名单展示
val whitelist = listOf("eva_interactions", "sara_trust", "all_crew_saved", "harrison_recording_found")
val filteredVars = s.variables.filterKeys { it in whitelist }
if (filteredVars.isNotEmpty()) {
Text("变量(关键):", color = Color(0xFFCCCCCC), modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp))
filteredVars.forEach { (k, v) ->
Text("${k}: ${v}", color = Color(0xFF888888), modifier = Modifier.fillMaxWidth().padding(start = 4.dp))
}
}
// 底部关闭按钮(与设置弹窗风格一致)
Spacer(modifier = Modifier.weight(1f))
NeonButton(
onClick = onDismiss,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp)
.height(40.dp),
compact = true
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = GameIcons.Close,
contentDescription = "关闭",
modifier = Modifier.size(18.dp),
tint = Color(0xFF00DDFF)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "关闭", fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
}
@Composable
private fun InfoRow(label: String, value: String, valueSmall: Boolean = false) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(label, color = Color(0xFFCCCCCC), modifier = Modifier.padding(vertical = 2.dp))
val style = if (valueSmall) CyberTextStyles.Caption else CyberTextStyles.Choice
Text(value, color = Color(0xFF00DDFF), fontWeight = FontWeight.SemiBold, style = style)
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
color = Color(0xFF88CCFF),
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp, bottom = 2.dp)
)
}

View File

@@ -29,6 +29,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import android.widget.Toast
@Composable @Composable
fun TimeCageGameScreen() { fun TimeCageGameScreen() {
@@ -107,6 +108,7 @@ fun TimeCageGameScreen() {
var gameMessage by remember { mutableStateOf("欢迎来到时间囚笼!第${gameState.currentLoop}次循环开始。") } var gameMessage by remember { mutableStateOf("欢迎来到时间囚笼!第${gameState.currentLoop}次循环开始。") }
var showControlMenu by remember { mutableStateOf(false) } var showControlMenu by remember { mutableStateOf(false) }
var showDialogueHistory by remember { mutableStateOf(false) } var showDialogueHistory by remember { mutableStateOf(false) }
var showStatusPanel by remember { mutableStateOf(false) }
val canNavigateBack by storyEngineAdapter.canNavigateBack.collectAsState() val canNavigateBack by storyEngineAdapter.canNavigateBack.collectAsState()
// 检查游戏结束条件 // 检查游戏结束条件
@@ -182,11 +184,11 @@ fun TimeCageGameScreen() {
) )
} }
// AI协助按钮 // 状态面板按钮(原AI协助按钮
IconButton( IconButton(
onClick = { onClick = {
audioController.playSoundEffect("notification") audioController.playSoundEffect("notification")
/* AI 功能 */ showStatusPanel = true
}, },
modifier = Modifier modifier = Modifier
.size(36.dp) // 稍微减小按钮 .size(36.dp) // 稍微减小按钮
@@ -197,7 +199,7 @@ fun TimeCageGameScreen() {
) { ) {
Icon( Icon(
imageVector = GameIcons.Robot, imageVector = GameIcons.Robot,
contentDescription = "AI协助", contentDescription = "状态面板",
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = Color(0xFF00DDFF) tint = Color(0xFF00DDFF)
) )
@@ -318,6 +320,13 @@ fun TimeCageGameScreen() {
} }
} }
// 状态面板弹窗
GameStatusPanel(
isVisible = showStatusPanel,
onDismiss = { showStatusPanel = false },
storyEngineAdapter = storyEngineAdapter
)
// 游戏控制菜单弹窗 // 游戏控制菜单弹窗
GameControlMenu( GameControlMenu(
isVisible = showControlMenu, isVisible = showControlMenu,
@@ -337,7 +346,29 @@ fun TimeCageGameScreen() {
} }
} }
dialogueHistory = emptyList() dialogueHistory = emptyList()
},
onSaveSlot = { slot ->
val dto = storyEngineAdapter.captureSaveDTO()
val ok = saveManager.saveGameSlot(slot, dto)
val msg = if (ok) "保存成功(槽位${'$'}slot" else "保存失败(槽位${'$'}slot"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
},
onLoadSlot = { slot ->
val data = saveManager.loadGameSlot(slot)
if (data == null) {
Toast.makeText(context, "槽位${'$'}slot 无存档", Toast.LENGTH_SHORT).show()
} else {
coroutineScope.launch {
val ok = storyEngineAdapter.restoreFromDTO(data.dto)
showControlMenu = false // 自动关闭弹窗
val msg = if (ok) "读取成功,跳转到节点 ${data.dto.currentNodeId}" else "读取失败"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
} }
}
},
getSlotTime = { slot -> saveManager.getSaveTimeString(slot) },
getSlotDTO = { slot -> saveManager.loadGameSlot(slot)?.dto },
storyEngineAdapter = storyEngineAdapter
) )
// ============================================================================ // ============================================================================

View File

@@ -1,5 +1,7 @@
package com.osglab.gameofmoon.story.engine package com.osglab.gameofmoon.story.engine
import kotlinx.serialization.Serializable
/** /**
* 故事引擎数据模型 * 故事引擎数据模型
* 支持自定义DSL格式的完整故事系统 * 支持自定义DSL格式的完整故事系统
@@ -212,6 +214,8 @@ data class GameState(
val secretsFound: MutableSet<String> = mutableSetOf(), val secretsFound: MutableSet<String> = mutableSetOf(),
val locationsDiscovered: MutableSet<String> = mutableSetOf(), val locationsDiscovered: MutableSet<String> = mutableSetOf(),
val nodesVisited: MutableSet<String> = mutableSetOf(), val nodesVisited: MutableSet<String> = mutableSetOf(),
// 跨循环累计的已访问节点(用于全局剧情百分比统计,不随新循环清空)
val nodesVisitedLifetime: MutableSet<String> = mutableSetOf(),
val choicesMade: MutableMap<String, String> = mutableMapOf(), val choicesMade: MutableMap<String, String> = mutableMapOf(),
var currentNodeId: String = "", var currentNodeId: String = "",
var health: Int = 100, var health: Int = 100,
@@ -242,4 +246,40 @@ data class GameState(
} }
} }
// =========================================================================
// 可序列化的存档/状态快照
// =========================================================================
@Serializable
data class EngineSaveDTO(
val currentNodeId: String,
val health: Int,
val stamina: Int,
val trustLevel: Int,
val loopCount: Int,
val nodesVisited: List<String>,
val nodesVisitedLifetime: List<String>,
val secretsFound: List<String>,
val locationsDiscovered: List<String>,
val flags: List<String>,
val choicesMade: Map<String, String>,
val variables: Map<String, String>
)
data class StoryStatusSnapshot(
val currentNodeId: String,
val health: Int,
val stamina: Int,
val trustLevel: Int,
val loopCount: Int,
val nodesVisited: Set<String>,
val nodesVisitedLifetime: Set<String>,
val secretsFound: Set<String>,
val locationsDiscovered: Set<String>,
val flags: Set<String>,
val choicesMade: Map<String, String>,
val variables: Map<String, String>,
val totalNodeCount: Int
)

View File

@@ -320,6 +320,26 @@ class StoryEngineAdapter(
) )
} }
// =========================================================================
// 存档/状态接口透出
// =========================================================================
fun captureStatusSnapshot(): StoryStatusSnapshot {
return newStoryManager.captureStatusSnapshot()
}
fun captureSaveDTO(): EngineSaveDTO {
return newStoryManager.captureSaveDTO()
}
suspend fun restoreFromDTO(dto: EngineSaveDTO): Boolean {
return newStoryManager.restoreFromDTO(dto)
}
fun getTotalNodeCount(): Int {
return newStoryManager.getAllNodeIds().size
}
// ============================================================================ // ============================================================================
// 旧系统支持 // 旧系统支持
// ============================================================================ // ============================================================================

View File

@@ -72,6 +72,97 @@ class StoryManager(
// 配置 // 配置
private var storyConfig: StoryConfig? = null private var storyConfig: StoryConfig? = null
// =========================================================================
// 对外状态/存档接口
// =========================================================================
/**
* 捕获当前状态快照用于UI展示与存档
*/
fun captureStatusSnapshot(): StoryStatusSnapshot {
val gs = gameState
return StoryStatusSnapshot(
currentNodeId = gs.currentNodeId,
health = gs.health,
stamina = gs.stamina,
trustLevel = gs.trustLevel,
loopCount = gs.loopCount,
nodesVisited = gs.nodesVisited.toSet(),
nodesVisitedLifetime = gs.nodesVisitedLifetime.toSet(),
secretsFound = gs.secretsFound.toSet(),
locationsDiscovered = gs.locationsDiscovered.toSet(),
flags = gs.flags.toSet(),
choicesMade = gs.choicesMade.toMap(),
variables = gs.variables.mapValues { it.value.toString() },
totalNodeCount = getAllNodeIds().size
)
}
/**
* 构造可序列化的存档DTO
*/
fun captureSaveDTO(): EngineSaveDTO {
val s = captureStatusSnapshot()
return EngineSaveDTO(
currentNodeId = s.currentNodeId,
health = s.health,
stamina = s.stamina,
trustLevel = s.trustLevel,
loopCount = s.loopCount,
nodesVisited = s.nodesVisited.toList(),
nodesVisitedLifetime = s.nodesVisitedLifetime.toList(),
secretsFound = s.secretsFound.toList(),
locationsDiscovered = s.locationsDiscovered.toList(),
flags = s.flags.toList(),
choicesMade = s.choicesMade,
variables = s.variables
)
}
/**
* 从存档DTO恢复状态不进行向后兼容
*/
suspend fun restoreFromDTO(dto: EngineSaveDTO): Boolean {
return withContext(Dispatchers.Main) {
try {
gameState.apply {
currentNodeId = dto.currentNodeId
health = dto.health
stamina = dto.stamina
trustLevel = dto.trustLevel
loopCount = dto.loopCount
nodesVisited.clear(); nodesVisited.addAll(dto.nodesVisited)
nodesVisitedLifetime.clear(); nodesVisitedLifetime.addAll(dto.nodesVisitedLifetime)
secretsFound.clear(); secretsFound.addAll(dto.secretsFound)
locationsDiscovered.clear(); locationsDiscovered.addAll(dto.locationsDiscovered)
flags.clear(); flags.addAll(dto.flags)
choicesMade.clear(); choicesMade.putAll(dto.choicesMade)
variables.clear(); variables.putAll(dto.variables)
}
_gameStateFlow.value = gameState
// 导航到存档节点
when (val nav = navigateToNode(dto.currentNodeId)) {
is NavigationResult.Success -> true
is NavigationResult.Error -> false
}
} catch (e: Exception) {
_error.value = "Restore failed: ${e.message}"
false
}
}
}
/**
* 获取当前已加载模块的所有节点ID集合
*/
fun getAllNodeIds(): Set<String> {
val ids = mutableSetOf<String>()
moduleCache.values.forEach { module ->
ids.addAll(module.nodes.keys)
}
return ids
}
// ============================================================================ // ============================================================================
// 初始化 // 初始化
// ============================================================================ // ============================================================================
@@ -280,6 +371,8 @@ class StoryManager(
_currentNode.value = node _currentNode.value = node
gameState.currentNodeId = resolvedNodeId gameState.currentNodeId = resolvedNodeId
gameState.nodesVisited.add(resolvedNodeId) gameState.nodesVisited.add(resolvedNodeId)
// 累计到跨循环集合,用于全局剧情百分比统计
gameState.nodesVisitedLifetime.add(resolvedNodeId)
// 处理节点效果 // 处理节点效果
val effects = processNodeEffects(node) val effects = processNodeEffects(node)