landing.lua 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. -- First combat slice. Wave composition and in-game sequencing still require a live playtest.
  2. local Encounter = require("mission_ember.encounter")
  3. local ROSTER = require("mission_ember.roster")
  4. local GROUP_ORDER = {"catwalk_entry", "catwalk_mid", "pipe", "mercury",
  5. "bridge", "sun", "helipad", "passengers", "mercury_bonus"}
  6. -- These are authored volume crossings. Timing/group choices are reconstructed route policy.
  7. local ROUTE = {
  8. {trigger = "CATWALK_015_PERCENT_PLAYER_TRIGGER", groups = {"catwalk_mid"}},
  9. {trigger = "CATWALK_035_PERCENT_PLAYER_TRIGGER_80B3CC33", groups = {"pipe"}},
  10. {trigger = "CATWALK_100_PERCENT_PLAYER_TRIGGER", groups = {"mercury"}},
  11. {trigger = "PIPE_CROSSING_040_PERCENT_PLAYER_TRIGGER", groups = {"mercury"}, cue = 2},
  12. {trigger = "LANDING_MERCURY_040_PERCENT_PLAYER_TRIGGER", groups = {"mercury"}},
  13. {trigger = "BRIDGE_025_PERCENT_PLAYER_TRIGGER", groups = {"bridge"}},
  14. {trigger = "BRIDGE_075_PERCENT_PLAYER_TRIGGER", groups = {"sun"}},
  15. {trigger = "BRIDGE_100_PERCENT_PLAYER_TRIGGER", groups = {"helipad"}},
  16. }
  17. -- Counts are read from each objective's authored task-group array (source +136).
  18. local OBJECTIVES = {
  19. catwalk_entry = {"EMBER_POWERHOUSE_CATWALK_OBJECTIVE", 17}, -- 80B3CA03
  20. catwalk_mid = {"EMBER_POWERHOUSE_CATWALK_OBJECTIVE", 17},
  21. pipe = {"EMBER_POWERHOUSE_PIPE_CROSSING_OBJECTIVE", 3}, -- 80B3DCC7
  22. mercury = {"EMBER_POWERHOUSE_LANDING_MERCURY_OBJECTIVE", 11}, -- 80B3DCC1
  23. mercury_bonus = {"EMBER_POWERHOUSE_LANDING_MERCURY_OBJECTIVE", 11},
  24. bridge = {"EMBER_POWERHOUSE_BRIDGE_OBJECTIVE", 14}, -- 80B3C8D6
  25. passengers = {"EMBER_POWERHOUSE_BRIDGE_OBJECTIVE", 14},
  26. sun = {"EMBER_POWERHOUSE_LANDING_SUN_OBJECTIVE", 4}, -- 80B3DCC4
  27. helipad = {"EMBER_POWERHOUSE_HELIPAD_SUN_OBJECTIVE", 4}, -- 80B3DDB2
  28. }
  29. local BRIDGE_DEVICES = {
  30. "POWERHOUSE_BRIDGE_ARM_MERCURY_DEVICE",
  31. "POWERHOUSE_BRIDGE_GEAR_MERCURY_BOTTOM_DEVICE",
  32. "POWERHOUSE_BRIDGE_GEAR_MERCURY_TOP_DEVICE",
  33. "POWERHOUSE_BRIDGE_ARM_SUN_DEVICE",
  34. "POWERHOUSE_BRIDGE_GEAR_SUN_BOTTOM_DEVICE",
  35. "POWERHOUSE_BRIDGE_GEAR_SUN_TOP_DEVICE",
  36. }
  37. local function required(value, name)
  38. assert(value ~= nil, "mission_ember: missing SDK binding " .. name
  39. .. "; regenerate the SDK with the scenario-scoped squad linker")
  40. return value
  41. end
  42. return function(mission)
  43. local music = require("mission_ember.music")(mission)
  44. local entry = required(mission.states.STATE_80B3C09E_0008_0000_80B3C09C, "powerhouse state")
  45. local bridge = {}
  46. for index, name in ipairs(BRIDGE_DEVICES) do
  47. bridge[index] = required(mission.Slot[name], "Slot." .. name)
  48. end
  49. local bridge_objective = required(mission.Slot.EMBER_POWERHOUSE_BRIDGE_OBJECTIVE,
  50. "bridge objective")
  51. local directive = required(mission.Slot.M_DIRECTIVE_SENSOR_80B3C90A, "directive sensor")
  52. local audience = required(mission.Slot.M_ENGAGEMENT_SENSOR_80B3CB5E, "landing engagement")
  53. local clear = required(mission.Directive.FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_A70DA4A6,
  54. "landing clear directive")
  55. local controls = required(mission.Directive.FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_2700C0C5,
  56. "bridge controls directive")
  57. local follow = required(mission.Directive.FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_4EA80049,
  58. "fuel stream directive")
  59. local console = required(mission.Slot.LANDING_MERCURY_CONSOLE_BUTTON_GHOST_LINK, "Ghost console")
  60. local lever = required(mission.Slot.LANDING_MERCURY_DOOR_BUTTON_OBJECT, "Mercury door lever")
  61. local lever_device = required(mission.Slot.POWERHOUSE_LANDING_MERCURY_DOOR_LEVER_DEVICE,
  62. "Mercury lever animation")
  63. local door = required(mission.Slot.POWERHOUSE_LANDING_MERCURY_DOOR_DEVICE, "war-beast door")
  64. local cross = required(mission.Directive.FIND_AND_DISABLE_THE_ALMIGHTY_S_WEAPONS_AFBF882A,
  65. "cross bridge directive")
  66. local directives = {follow, clear, controls, cross}
  67. local navpoints = {
  68. required(mission.Slot.POWERHOUSE_DIRECTIVE_LANDING_MERCURY_CLEAR_NAV_POINT, "Mercury approach navpoint"),
  69. required(mission.Slot.POWERHOUSE_DIRECTIVE_LANDING_MERCURY_INTERACT_NAV_POINT, "console navpoint"),
  70. required(mission.Slot.HELIPAD_SUN_SQUAD_FORMATION_NAV_POINT, "helipad navpoint"),
  71. }
  72. local function show_directive(context, id)
  73. context:set_variable("ember.directive", id)
  74. context:set_variable("ember.navpoint", 0)
  75. context:slot(directive):set_directive{directive = directives[id], audience = context:slot(audience)}
  76. end
  77. local function dialogue(context, cue)
  78. context:slot(mission.Slot.M_DIALOG_SENSOR_80B3C90A):play_dialogue_cue{
  79. cue = required(mission.DialogueCue.M_DIALOG_SENSOR_80B3C90A["CUE_" .. cue], "dialogue cue"),
  80. }
  81. end
  82. local encounters, transport = {}, nil
  83. for _, group in ipairs(GROUP_ORDER) do
  84. local names = ROSTER[group]
  85. local squads = {}
  86. for index, name in ipairs(names) do
  87. -- Progression membership changes; their authored AI objective stays intact.
  88. local objective = (name == "BRIDGE_VIGNETTE_SUPPORT_A_SQUAD"
  89. or name == "BRIDGE_VIGNETTE_SUPPORT_B_SQUAD") and OBJECTIVES.bridge or OBJECTIVES[group]
  90. squads[index] = {
  91. name = name,
  92. id = required(mission.Squad[name], "Squad." .. name),
  93. sensor = required(mission.Slot[name], "Slot." .. name),
  94. objective = required(mission.Slot[objective[1]], "combat objective"),
  95. task_groups = objective[2],
  96. }
  97. end
  98. -- Retain the existing Mercury state key across a development-script reload.
  99. local key = group == "mercury" and "ember.landing" or "ember." .. group
  100. encounters[group] = Encounter.new(key, squads, function(context, state)
  101. if group == "mercury" then
  102. -- Earlier skipped volumes may first arrive while backtracking. They
  103. -- cannot replay the landing instruction after this combat is complete.
  104. context:set_variable("ember.route.progress", math.max(state:variable("ember.route.progress") or 0, 5))
  105. show_directive(context, 3)
  106. dialogue(context, 3)
  107. transport.prepare(context, state)
  108. context:slot(console):set_ghost_link{generation = 2, enabled = true}
  109. context:set_variable("ember.console.enabled", true)
  110. context:set_phase(3)
  111. -- Console completion, not combat clear, must extend the bridge.
  112. end
  113. end, group == "passengers")
  114. end
  115. transport = require("mission_ember.transport")(mission, encounters.passengers)
  116. for _, step in ipairs(ROUTE) do
  117. required(mission.Slot[step.trigger], "Slot." .. step.trigger)
  118. end
  119. local function update_darkness(context, state)
  120. if state:variable("ember.later.owns_hud") then return end
  121. if not state:variable("ember.route.initialized") then return end
  122. -- Helipad squads belong to the later section, ~450m south of this fight.
  123. -- Their preloaded population cannot hold the bridge's restriction open.
  124. local complete = true
  125. for _, group in ipairs({"bridge", "passengers", "sun"}) do
  126. if encounters[group]:phase(state) ~= 2 then complete = false; break end
  127. end
  128. local enabled = state:variable("ember.bridge.extended") == true
  129. and state:variable("ember.region") == entry.region_index and not complete
  130. if state:variable("ember.darkness.enabled") == enabled then return end
  131. context:slot(required(mission.Slot.HARD_WIPE_GLOBALS, "hard-wipe globals"))
  132. :set_darkness_zone{enabled = enabled}
  133. context:set_variable("ember.darkness.enabled", enabled)
  134. end
  135. local function update_guidance(context, state)
  136. if state:variable("ember.later.owns_hud") then return end
  137. if not state:variable("ember.route.initialized") then return end
  138. music.update(context, state)
  139. local target = 0
  140. if state:variable("ember.region") == entry.region_index
  141. and (state:variable("ember.wipe.stage") or 0) < 2 then
  142. local combat = false
  143. for _, group in ipairs({"catwalk_entry", "catwalk_mid", "pipe", "mercury", "mercury_bonus"}) do
  144. if encounters[group]:phase(state) == 1 then combat = true end
  145. end
  146. if state:variable("ember.bridge.extended") then
  147. for _, group in ipairs({"bridge", "passengers", "sun"}) do
  148. if encounters[group]:phase(state) ~= 2 then combat = true end
  149. end
  150. -- The distant helipad population is preloaded with the bridge;
  151. -- it suppresses guidance only after the route reaches that fight.
  152. local at_helipad = (state:variable("ember.route.progress") or 0) >= 8
  153. if at_helipad then combat = true end
  154. if not combat then target = 3 end
  155. elseif not combat then
  156. target = state:variable("ember.console.enabled") and 2 or 1
  157. end
  158. end
  159. if state:variable("ember.navpoint") == target then return end
  160. context:set_variable("ember.navpoint", target)
  161. context:slot(directive):set_directive{
  162. directive = directives[state:variable("ember.directive") or 1],
  163. audience = context:slot(audience),
  164. navpoint = target > 0 and context:slot(navpoints[target]) or nil,
  165. }
  166. end
  167. local function initialize(context)
  168. context:slot(audience):set_engagement_state{flags = 0, revision = 1}
  169. for _, device in ipairs(bridge) do
  170. -- These bridge animations use position 1 for retracted, 0 for extended.
  171. -- DF6C70 passes the value through; generic open/close names describe the value.
  172. context:slot(device):transition{transition = context.sdk.device_transitions.open, snap = true}
  173. end
  174. context:slot(bridge_objective):reset_objectives{}
  175. context:slot(console):set_ghost_link{generation = 1, enabled = false}
  176. context:slot(door):transition{transition = context.sdk.device_transitions.close, snap = true}
  177. context:slot(lever_device):transition{transition = context.sdk.device_transitions.close, snap = true}
  178. context:slot(lever):set_interactable_object{generation = 1}
  179. -- The Almighty's weapon is firing at the sun for the whole mission, and every region
  180. -- that shows the beam owns the device that drives it. Placing the beam without
  181. -- unlocking and powering its device leaves a dead prop.
  182. local beam = required(mission.Slot.POWERHOUSE_CORE_WEAPON_LASER_BEAM_DEVICE,
  183. "powerhouse core weapon laser beam device")
  184. context:slot(beam):transition{transition = context.sdk.device_transitions.unlock}
  185. context:slot(beam):transition{transition = context.sdk.device_transitions.power_on}
  186. end
  187. return {
  188. initial_state = entry,
  189. region = entry.region_index,
  190. enter = function(context, state)
  191. if state:variable("ember.route.initialized") then
  192. return
  193. end
  194. initialize(context)
  195. context:set_variable("ember.route.initialized", true)
  196. for index, step in ipairs(ROUTE) do
  197. if index < 6 then context:slot(mission.Slot[step.trigger]):fire_trigger{} end
  198. end
  199. context:set_phase(2)
  200. encounters.catwalk_entry:enter(context, state)
  201. show_directive(context, 1)
  202. end,
  203. client_state = function(context, state, event)
  204. if event.teleport_state == 0 then context:set_variable("ember.spawned", true) end
  205. if state:variable("ember.spawned") and not state:variable("ember.guidance")
  206. and event.region_index == nil and event.current_region_index == nil
  207. and event.spawn_state == nil and event.teleport_state == nil then
  208. context:set_variable("ember.guidance", true)
  209. context:slot(mission.Slot.M_DIALOG_SENSOR_80B3C90A):play_dialogue_cue{
  210. cue = mission.DialogueCue.M_DIALOG_SENSOR_80B3C90A.CUE_0,
  211. }
  212. end
  213. end,
  214. on_player_trigger = function(context, state, event)
  215. if not state:variable("ember.route.initialized") then return end
  216. for index, step in ipairs(ROUTE) do
  217. local slot = context:slot(mission.Slot[step.trigger])
  218. local key = "ember.route." .. step.trigger
  219. if event.registry_key == slot.registry_key and event.slot_type == slot.slot_type
  220. and event.slot_index == slot.slot_index and not state:variable(key) then
  221. -- A bridge volume can overlap the near landing. Do not consume its
  222. -- one-shot receipt or start later waves before the bridge is usable.
  223. if index >= 6 and not state:variable("ember.bridge.extended") then return end
  224. context:set_variable(key, true)
  225. if index <= (state:variable("ember.route.progress") or 0) then return end
  226. context:set_variable("ember.route.progress", index)
  227. -- Catch up earlier combat if a player jumped over a narrow volume.
  228. if step.groups then
  229. local last = step.groups[#step.groups]
  230. for _, group in ipairs(GROUP_ORDER) do
  231. encounters[group]:enter(context, state)
  232. if group == last then break end
  233. end
  234. end
  235. if index >= 3 and encounters.mercury:phase(state) == 1
  236. and not state:variable("ember.clear.instruction") then
  237. context:set_variable("ember.clear.instruction", true)
  238. show_directive(context, 2)
  239. end
  240. if step.cue then dialogue(context, step.cue) end
  241. return
  242. end
  243. end
  244. end,
  245. on_object_interacted = function(context, state, event)
  246. if not state:variable("ember.route.initialized") or state:variable("ember.lever.used") then return end
  247. local slot = context:slot(lever)
  248. if event.registry_key ~= slot.registry_key or event.slot_type ~= slot.slot_type
  249. or event.slot_index ~= slot.slot_index then return end
  250. context:set_variable("ember.lever.used", true)
  251. context:slot(lever_device):transition{transition = context.sdk.device_transitions.open}
  252. encounters.mercury_bonus:enter(context, state)
  253. context:slot(door):transition{transition = context.sdk.device_transitions.open}
  254. end,
  255. on_ghost_link_state = function(context, state, event)
  256. if not state:variable("ember.console.enabled") or state:variable("ember.bridge.extended") then return end
  257. local slot = context:slot(console)
  258. if event.registry_key ~= slot.registry_key or event.slot_type ~= slot.slot_type
  259. or event.slot_index ~= slot.slot_index or event.generation ~= (state:variable("ember.console.generation") or 2)
  260. or event.active or event.progress == nil or event.progress < 1 then return end
  261. for _, device in ipairs(bridge) do
  262. context:slot(device):transition{transition = context.sdk.device_transitions.close}
  263. end
  264. context:set_variable("ember.bridge.extended", true)
  265. update_darkness(context, state)
  266. -- Ship parents/cargo were prepared before Ghost became usable.
  267. -- Far-side hangar reinforcements start with bridge activation.
  268. -- Encounter state keeps later crossings and backtracking from respawning them.
  269. encounters.sun:enter(context, state)
  270. encounters.helipad:enter(context, state)
  271. transport.start(context, state)
  272. -- Arm crossing monitors when their route opens, so the approach to the
  273. -- console cannot consume a crossing before its encounter is eligible.
  274. for index = 6, #ROUTE do
  275. context:slot(mission.Slot[ROUTE[index].trigger]):fire_trigger{}
  276. end
  277. show_directive(context, 4)
  278. context:set_phase(4)
  279. end,
  280. update_darkness = update_darkness,
  281. update_guidance = update_guidance,
  282. reset_checkpoint = function(context, state)
  283. -- The user chose the checkpoint before scanning the console again.
  284. -- Completed catwalk/pipe/Mercury encounters and the bonus lever stay done.
  285. transport.reset(context, state)
  286. for _, group in ipairs({"bridge", "passengers", "sun", "helipad"}) do
  287. encounters[group]:reset(context)
  288. end
  289. for index = 6, #ROUTE do
  290. context:clear_variable("ember.route." .. ROUTE[index].trigger)
  291. end
  292. context:set_variable("ember.route.progress", 5)
  293. for _, device in ipairs(bridge) do
  294. context:slot(device):transition{transition = context.sdk.device_transitions.open, snap = true}
  295. end
  296. context:slot(bridge_objective):reset_objectives{}
  297. context:set_variable("ember.bridge.extended", false)
  298. context:set_variable("ember.darkness.enabled", false)
  299. context:slot(mission.Slot.HARD_WIPE_GLOBALS):set_darkness_zone{enabled = false}
  300. local revision = (state:variable("ember.console.generation") or 2) + 2
  301. context:set_variable("ember.console.generation", revision)
  302. transport.prepare(context, state)
  303. context:slot(console):set_ghost_link{generation = revision, enabled = true}
  304. context:set_variable("ember.console.enabled", true)
  305. show_directive(context, 3)
  306. context:set_phase(3)
  307. end,
  308. on_timer = transport.on_timer,
  309. on_actor_path_state = transport.on_path,
  310. on_squad_state = function(context, state, event)
  311. for _, group in ipairs(GROUP_ORDER) do
  312. encounters[group]:on_squad_state(context, state, event)
  313. end
  314. transport.on_squad(context, state, event)
  315. update_darkness(context, state)
  316. end,
  317. }
  318. end