diff --git a/app/src/main/assets/story/modules/main_chapter_1.story b/app/src/main/assets/story/modules/main_chapter_1.story index 75ab806..42a52d1 100644 --- a/app/src/main/assets/story/modules/main_chapter_1.story +++ b/app/src/main/assets/story/modules/main_chapter_1.story @@ -686,6 +686,20 @@ choice_1: "继续阅读" -> eva_photo_reaction_p3 [audio: button_click.mp3] @end @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 @title "私人悲伤(1/2)" @audio_bg rain_light.mp3 @@ -1203,12 +1217,26 @@ 这是人类历史上第一次成功的AI意识具现化。你们没有创造生命,而是给了生命新的选择。 """ -@choices 3 - choice_1: "与莉莉一起探索人类体验的意义" -> human_experience_exploration [effect: trust+35] [audio: wind_gentle.mp3] - choice_2: "将这项技术分享给全宇宙" -> consciousness_liberation [effect: trust+40] [audio: epic_finale.mp3] - choice_3: "建立意识形式选择中心" -> consciousness_choice_center [effect: trust+30] [audio: orchestral_revelation.mp3] +@choices 1 + choice_1: "让余晖落定" -> body_restoration_quest_epilogue [audio: button_click.mp3] @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 @title "重写文明之歌" @audio_bg epic_finale.mp3 @@ -1234,10 +1262,8 @@ 人类文明的新篇章开始了:不再是conquering the stars,而是befriending the universe。 """ -@choices 3 - choice_1: "建立'新叙事'培训中心,遍布全宇宙" -> narrative_academy [effect: trust+45] [audio: orchestral_revelation.mp3] - choice_2: "创建跨物种的文明交流协议" -> interspecies_harmony [effect: trust+50] [audio: epic_finale.mp3] - choice_3: "成为新文明模式的活体象征" -> civilization_symbol [effect: trust+40] [audio: wind_gentle.mp3] +@choices 1 + choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3] @end @node anna_memorial_institute @@ -1265,10 +1291,8 @@ 一年后,安娜纪念研究院的模式推广到了整个太阳系。人类终于学会了如何将痛苦转化为智慧。 """ -@choices 3 - choice_1: "将安娜纪念研究院模式推广到全宇宙" -> universal_healing_science [effect: trust+50] [audio: epic_finale.mp3] - choice_2: "创建'痛苦转化'技术,帮助更多受伤的心灵" -> pain_transformation_tech [effect: trust+45] [audio: orchestral_revelation.mp3] - choice_3: "与德米特里建立永久合作,成为治愈者伙伴" -> healing_partnership [effect: trust+40] [audio: wind_gentle.mp3] +@choices 1 + choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3] @end @node base_transformation @@ -1296,10 +1320,8 @@ "秘密很简单,"你解释道,"当每个人都感到被理解和被关爱时,他们会自然地贡献出最好的自己。" """ -@choices 3 - choice_1: "将基地模式分享给所有月球基地" -> lunar_network_transformation [effect: trust+35] [audio: orchestral_revelation.mp3] - choice_2: "创建'基地改造'培训项目" -> transformation_academy [effect: trust+30] [audio: discovery_chime.mp3] - choice_3: "推广到太空站和火星基地" -> space_harmony_expansion [effect: trust+40] [audio: epic_finale.mp3] +@choices 1 + choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3] @end @node collaboration_expansion @@ -1325,10 +1347,8 @@ 你微笑着回答:"很快,小朋友。宇宙正在等待我们准备好。" """ -@choices 3 - choice_1: "建立跨星系合作探索项目" -> intergalactic_cooperation [effect: trust+50] [audio: epic_finale.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] +@choices 1 + choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3] @end @node complete_destruction_plan @@ -1358,10 +1378,8 @@ 你成为了道德边界的守护者,确保科学永远服务于人类的尊严,而不是相反。 """ -@choices 3 - choice_1: "成立'技术伦理监督委员会',永久监管危险科学" -> tech_ethics_guardian [effect: trust+35] [audio: orchestral_revelation.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] +@choices 1 + choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3] @end // ===== 情境化变体节点(替代过度重用节点)===== @@ -3361,7 +3379,7 @@ """ @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_3: "寻找其他被修改的实验对象" -> other_subjects [effect: secret_unlock] [audio: ambient_mystery.mp3] choice_4: "立即联系伊娃询问真相" -> eva_identity_revelation [effect: trust+5] [audio: orchestral_revelation.mp3] @@ -3460,11 +3478,8 @@ 这个声音...是某种植入的AI系统?你的身体里还有什么你不知道的东西? """ -@choices 4 - choice_1: "尝试与植入AI系统对话" -> implant_ai_communication [effect: secret_unlock] [audio: electronic_tension.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] +@choices 1 + choice_1: "搜索关于'月神'项目的信息" -> project_luna_investigation [effect: secret_unlock] [audio: discovery_chime.mp3] @end @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_2: "使用新能力直接与伊娃的意识深层连接" -> eva_quantum_link [effect: trust+15] [audio: orchestral_revelation.mp3] choice_3: "试图稳定现实裂隙,阻止宇宙崩溃" -> reality_stabilization [effect: health-15] [audio: epic_finale.mp3] - choice_4: "关闭植入物,重新变回完全的人类" -> humanity_preservation [effect: trust-5] [audio: heartbeat.mp3] @end @node suffering_philosophy @@ -4028,9 +4042,8 @@ 在这种宽恕的力量下,你意识到真正的解决方案不是破坏或控制,而是治愈和接受。 """ -@choices 3 - choice_1: "带领所有人一起创造基于宽恕的新现实" -> forgiveness_reality [effect: trust+35] [audio: epic_finale.mp3] - choice_2: "将宽恕的力量传播到地球上的所有人" -> global_healing [effect: trust+30] [audio: orchestral_revelation.mp3] +@choices 1 + choice_1: "带领所有人一起创造基于宽恕的新现实" -> harmony_guardians_final [effect: trust+35] [audio: epic_finale.mp3] @node eva_quantum_link @title "从痛苦中创造意义(1/2)" @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 @title "现实守护者的使命(1/2)" @audio_bg epic_finale.mp3 @@ -4776,7 +4796,6 @@ **永恒的循环**: 不再是痛苦的48次循环,而是智慧与爱的无限螺旋上升,每一次"循环"都带来更深的理解和更广的compassion。 """ -@choices 2 - choice_1: "成为宽恕现实的永恒守护者" -> forgiveness_guardians [effect: trust+50] [audio: epic_finale.mp3] - choice_2: "将宽恕的种子播撒到多元宇宙" -> multiverse_forgiveness [effect: trust+45] [audio: orchestral_revelation.mp3] +@choices 1 + choice_1: "重新经历一次" -> first_awakening [effect: loop+1] [audio: wind_gentle.mp3] @end diff --git a/app/src/main/java/com/osglab/gameofmoon/data/GameSaveManager.kt b/app/src/main/java/com/osglab/gameofmoon/data/GameSaveManager.kt index 5826718..a9b7085 100644 --- a/app/src/main/java/com/osglab/gameofmoon/data/GameSaveManager.kt +++ b/app/src/main/java/com/osglab/gameofmoon/data/GameSaveManager.kt @@ -2,8 +2,9 @@ package com.osglab.gameofmoon.data import android.content.Context import android.content.SharedPreferences -import com.osglab.gameofmoon.model.GameState +import com.osglab.gameofmoon.story.engine.EngineSaveDTO import kotlinx.serialization.encodeToString +import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json /** @@ -24,20 +25,18 @@ class GameSaveManager(private val context: Context) { /** * 保存游戏状态 */ - fun saveGame( - gameState: GameState, - currentNodeId: String, - dialogueHistory: List = emptyList() + fun saveGameSlot( + slot: Int, + dto: EngineSaveDTO, + extraNotes: String = "" ): Boolean { return try { - val gameStateJson = json.encodeToString(gameState) - val dialogueJson = json.encodeToString(dialogueHistory) + val saveJson = json.encodeToString(dto) prefs.edit() - .putString(KEY_GAME_STATE, gameStateJson) - .putString(KEY_CURRENT_NODE, currentNodeId) - .putString(KEY_DIALOGUE_HISTORY, dialogueJson) - .putLong(KEY_SAVE_TIME, System.currentTimeMillis()) + .putString(keyForSlot(slot), saveJson) + .putLong(keyForSlotTime(slot), System.currentTimeMillis()) + .putString(keyForSlotNote(slot), extraNotes) .apply() true @@ -50,21 +49,17 @@ class GameSaveManager(private val context: Context) { /** * 加载游戏状态 */ - fun loadGame(): SaveData? { + fun loadGameSlot(slot: Int): SaveData? { return try { - val gameStateJson = prefs.getString(KEY_GAME_STATE, null) ?: return null - val currentNodeId = prefs.getString(KEY_CURRENT_NODE, null) ?: return null - val dialogueJson = prefs.getString(KEY_DIALOGUE_HISTORY, "[]")!! - val saveTime = prefs.getLong(KEY_SAVE_TIME, 0L) - - val gameState = json.decodeFromString(gameStateJson) - val dialogueHistory = json.decodeFromString>(dialogueJson) + val saveJson = prefs.getString(keyForSlot(slot), null) ?: return null + val saveTime = prefs.getLong(keyForSlotTime(slot), 0L) + val note = prefs.getString(keyForSlotNote(slot), "") ?: "" + val dto = json.decodeFromString(saveJson) SaveData( - gameState = gameState, - currentNodeId = currentNodeId, - dialogueHistory = dialogueHistory, - saveTime = saveTime + dto = dto, + saveTime = saveTime, + note = note ) } catch (e: Exception) { e.printStackTrace() @@ -75,20 +70,19 @@ class GameSaveManager(private val context: Context) { /** * 检查是否有保存的游戏 */ - fun hasSavedGame(): Boolean { - return prefs.contains(KEY_GAME_STATE) + fun hasSavedSlot(slot: Int): Boolean { + return prefs.contains(keyForSlot(slot)) } /** * 删除保存的游戏 */ - fun deleteSave(): Boolean { + fun deleteSave(slot: Int): Boolean { return try { prefs.edit() - .remove(KEY_GAME_STATE) - .remove(KEY_CURRENT_NODE) - .remove(KEY_DIALOGUE_HISTORY) - .remove(KEY_SAVE_TIME) + .remove(keyForSlot(slot)) + .remove(keyForSlotTime(slot)) + .remove(keyForSlotNote(slot)) .apply() true } catch (e: Exception) { @@ -99,8 +93,8 @@ class GameSaveManager(private val context: Context) { /** * 获取保存时间的格式化字符串 */ - fun getSaveTimeString(): String? { - val saveTime = prefs.getLong(KEY_SAVE_TIME, 0L) + fun getSaveTimeString(slot: Int): String? { + val saveTime = prefs.getLong(keyForSlotTime(slot), 0L) return if (saveTime > 0) { val date = java.util.Date(saveTime) 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 { - private const val KEY_GAME_STATE = "game_state" - private const val KEY_CURRENT_NODE = "current_node" - private const val KEY_DIALOGUE_HISTORY = "dialogue_history" - private const val KEY_SAVE_TIME = "save_time" + private fun keyForSlot(slot: Int) = "slot_${slot}_data" + private fun keyForSlotTime(slot: Int) = "slot_${slot}_time" + private fun keyForSlotNote(slot: Int) = "slot_${slot}_note" } } @@ -122,8 +115,7 @@ class GameSaveManager(private val context: Context) { * 保存数据结构 */ data class SaveData( - val gameState: GameState, - val currentNodeId: String, - val dialogueHistory: List, - val saveTime: Long + val dto: EngineSaveDTO, + val saveTime: Long, + val note: String ) diff --git a/app/src/main/java/com/osglab/gameofmoon/presentation/ui/components/GameControlMenu.kt b/app/src/main/java/com/osglab/gameofmoon/presentation/ui/components/GameControlMenu.kt index dfa35d6..b9358b2 100644 --- a/app/src/main/java/com/osglab/gameofmoon/presentation/ui/components/GameControlMenu.kt +++ b/app/src/main/java/com/osglab/gameofmoon/presentation/ui/components/GameControlMenu.kt @@ -17,32 +17,44 @@ 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 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 fun GameControlMenu( isVisible: Boolean, 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) { Dialog(onDismissRequest = onDismiss) { Box( modifier = Modifier .fillMaxSize() - .padding(horizontal = 5.dp) - .background(Color.Black.copy(alpha = 0.5f)), // 70%透明度的黑色背景 + .padding(0.dp) + .background(Color.Black.copy(alpha = 0.5f)), // 半透明遮罩 contentAlignment = Alignment.Center ) { Box( modifier = Modifier - .width(380.dp) - .height(570.dp) + .width(580.dp) + .height(680.dp) ) { // 设置背景图片 Image( painter = painterResource(id = R.drawable.setting), contentDescription = "Setting Background", - modifier = Modifier.fillMaxSize(), + modifier = Modifier.matchParentSize(), contentScale = ContentScale.FillBounds ) @@ -50,7 +62,7 @@ fun GameControlMenu( Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 24.dp, vertical = 20.dp), + .padding(horizontal = 42.dp, vertical = 42.dp), horizontalAlignment = Alignment.CenterHorizontally ) { // 顶部额外留白 @@ -81,79 +93,184 @@ fun GameControlMenu( Spacer(modifier = Modifier.height(20.dp)) - // 游戏介绍文本 - Text( - text = "在无尽的时间循环中探索真相,\n每一次重启都是新的开始,\n但记忆将指引你走向不同的结局。", - style = CyberTextStyles.StoryContent.copy(fontSize = 12.sp), - color = Color(0xFFCCCCCC), - textAlign = TextAlign.Center, - lineHeight = 16.sp - ) + // 游戏介绍文本(临时隐藏以提升空间) + // Text( + // text = "在无尽的时间循环中探索真相,\n每一次重启都是新的开始,\n但记忆将指引你走向不同的结局。", + // style = CyberTextStyles.StoryContent.copy(fontSize = 12.sp), + // color = Color(0xFFCCCCCC), + // textAlign = TextAlign.Center, + // lineHeight = 16.sp + // ) // 将按钮推到底部 Spacer(modifier = Modifier.weight(1f)) - // 开始新循环按钮 - NeonButton( - onClick = { - onNewLoop() - onDismiss() - }, - modifier = Modifier - .fillMaxWidth() - .padding(start = 15.dp, end = 15.dp, bottom = 2.dp) + // 存档/读取(3槽位) - 列表样式 + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = GameIcons.Refresh, - contentDescription = "开始新循环", - modifier = Modifier.size(20.dp), - tint = Color(0xFF00DDFF) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "开始新循环", - fontSize = 14.sp, - fontWeight = FontWeight.Bold - ) + Text( + text = "存档 / 读取", + style = CyberTextStyles.Choice.copy(fontSize = 12.sp), + color = Color(0xFF00AACC) + ) + val scope = rememberCoroutineScope() + val titleCache = remember { mutableStateMapOf() } + val percentCache = remember { mutableStateMapOf() } + 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.height(8.dp)) - - // 关闭按钮 - NeonButton( - onClick = onDismiss, + + // 将底部按钮固定在最底部 + Spacer(modifier = Modifier.weight(1f)) + + Row( modifier = Modifier .fillMaxWidth() - .padding(start = 15.dp, end = 15.dp, bottom = 5.dp) + .padding(bottom = 32.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically + // 开始新循环(半宽) + NeonButton( + onClick = { + onNewLoop() + onDismiss() + }, + modifier = Modifier + .weight(1f) + .height(44.dp), + compact = true ) { - Icon( - imageVector = GameIcons.Close, - contentDescription = "关闭", - modifier = Modifier.size(20.dp), - tint = Color(0xFF00DDFF) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "关闭", - fontSize = 14.sp, - fontWeight = FontWeight.Bold - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = GameIcons.Refresh, + contentDescription = "新循环", + modifier = Modifier.size(18.dp), + tint = Color(0xFF00DDFF) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "新循环", fontSize = 14.sp, fontWeight = FontWeight.Bold) + } + } + + // 关闭(半宽) + NeonButton( + onClick = onDismiss, + modifier = Modifier + .weight(1f) + .height(44.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) + } } } - - // 底部额外留白 - Spacer(modifier = Modifier.height(32.dp)) } } // 背景图片Box结尾 } // 外层Box结尾 diff --git a/app/src/main/java/com/osglab/gameofmoon/presentation/ui/components/GameStatusPanel.kt b/app/src/main/java/com/osglab/gameofmoon/presentation/ui/components/GameStatusPanel.kt new file mode 100644 index 0000000..79a92e1 --- /dev/null +++ b/app/src/main/java/com/osglab/gameofmoon/presentation/ui/components/GameStatusPanel.kt @@ -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) + ) +} + + diff --git a/app/src/main/java/com/osglab/gameofmoon/presentation/ui/screens/TimeCageGameScreen.kt b/app/src/main/java/com/osglab/gameofmoon/presentation/ui/screens/TimeCageGameScreen.kt index 7de284e..9587229 100644 --- a/app/src/main/java/com/osglab/gameofmoon/presentation/ui/screens/TimeCageGameScreen.kt +++ b/app/src/main/java/com/osglab/gameofmoon/presentation/ui/screens/TimeCageGameScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.draw.shadow import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import android.widget.Toast @Composable fun TimeCageGameScreen() { @@ -107,6 +108,7 @@ fun TimeCageGameScreen() { var gameMessage by remember { mutableStateOf("欢迎来到时间囚笼!第${gameState.currentLoop}次循环开始。") } var showControlMenu by remember { mutableStateOf(false) } var showDialogueHistory by remember { mutableStateOf(false) } + var showStatusPanel by remember { mutableStateOf(false) } val canNavigateBack by storyEngineAdapter.canNavigateBack.collectAsState() // 检查游戏结束条件 @@ -182,11 +184,11 @@ fun TimeCageGameScreen() { ) } - // AI协助按钮 + // 状态面板按钮(原AI协助按钮) IconButton( onClick = { audioController.playSoundEffect("notification") - /* AI 功能 */ + showStatusPanel = true }, modifier = Modifier .size(36.dp) // 稍微减小按钮 @@ -197,7 +199,7 @@ fun TimeCageGameScreen() { ) { Icon( imageVector = GameIcons.Robot, - contentDescription = "AI协助", + contentDescription = "状态面板", modifier = Modifier.size(18.dp), tint = Color(0xFF00DDFF) ) @@ -318,6 +320,13 @@ fun TimeCageGameScreen() { } } + // 状态面板弹窗 + GameStatusPanel( + isVisible = showStatusPanel, + onDismiss = { showStatusPanel = false }, + storyEngineAdapter = storyEngineAdapter + ) + // 游戏控制菜单弹窗 GameControlMenu( isVisible = showControlMenu, @@ -337,7 +346,29 @@ fun TimeCageGameScreen() { } } 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 ) // ============================================================================ diff --git a/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryDataModels.kt b/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryDataModels.kt index b0960e5..fa624ab 100644 --- a/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryDataModels.kt +++ b/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryDataModels.kt @@ -1,5 +1,7 @@ package com.osglab.gameofmoon.story.engine +import kotlinx.serialization.Serializable + /** * 故事引擎数据模型 * 支持自定义DSL格式的完整故事系统 @@ -212,6 +214,8 @@ data class GameState( val secretsFound: MutableSet = mutableSetOf(), val locationsDiscovered: MutableSet = mutableSetOf(), val nodesVisited: MutableSet = mutableSetOf(), + // 跨循环累计的已访问节点(用于全局剧情百分比统计,不随新循环清空) + val nodesVisitedLifetime: MutableSet = mutableSetOf(), val choicesMade: MutableMap = mutableMapOf(), var currentNodeId: String = "", 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, + val nodesVisitedLifetime: List, + val secretsFound: List, + val locationsDiscovered: List, + val flags: List, + val choicesMade: Map, + val variables: Map +) + +data class StoryStatusSnapshot( + val currentNodeId: String, + val health: Int, + val stamina: Int, + val trustLevel: Int, + val loopCount: Int, + val nodesVisited: Set, + val nodesVisitedLifetime: Set, + val secretsFound: Set, + val locationsDiscovered: Set, + val flags: Set, + val choicesMade: Map, + val variables: Map, + val totalNodeCount: Int +) + diff --git a/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryEngineAdapter.kt b/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryEngineAdapter.kt index b594fbc..e03d777 100644 --- a/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryEngineAdapter.kt +++ b/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryEngineAdapter.kt @@ -319,6 +319,26 @@ class StoryEngineAdapter( currentNodeId = gameState.currentNodeId ) } + + // ========================================================================= + // 存档/状态接口透出 + // ========================================================================= + + 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 + } // ============================================================================ // 旧系统支持 diff --git a/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryManager.kt b/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryManager.kt index 6912cea..a8c1485 100644 --- a/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryManager.kt +++ b/app/src/main/java/com/osglab/gameofmoon/story/engine/StoryManager.kt @@ -71,6 +71,97 @@ class StoryManager( // 配置 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 { + val ids = mutableSetOf() + moduleCache.values.forEach { module -> + ids.addAll(module.nodes.keys) + } + return ids + } // ============================================================================ // 初始化 @@ -280,6 +371,8 @@ class StoryManager( _currentNode.value = node gameState.currentNodeId = resolvedNodeId gameState.nodesVisited.add(resolvedNodeId) + // 累计到跨循环集合,用于全局剧情百分比统计 + gameState.nodesVisitedLifetime.add(resolvedNodeId) // 处理节点效果 val effects = processNodeEffects(node)