Bläddra i källkod

Merge branch 'release/v0.3.0'

Iñigo Valentin 4 timmar sedan
förälder
incheckning
41bfcb491c
100 ändrade filer med 7203 tillägg och 1783 borttagningar
  1. 4 1
      .gitignore
  2. 5 1
      CMakeLists.txt
  3. 5 0
      data/data/config/config.cfg
  4. 6 1
      data/data/screens/_screens.xml
  5. 384 125
      data/data/screens/battle/battle_fx.lua
  6. 49 69
      data/data/screens/battle/battle_fx.xml
  7. 298 0
      data/data/screens/config_menu/config_menu.xml
  8. 0 22
      data/data/screens/item_menu/prototypes.xml
  9. 83 0
      data/data/screens/limit_menu/limit_menu.xml
  10. 149 0
      data/data/screens/magic_menu/magic_menu.xml
  11. 2 2
      data/data/screens/main_menu/main_menu.xml
  12. 99 0
      data/data/screens/phs_menu/phs_menu.xml
  13. 11 0
      data/data/screens/prototypes.xml
  14. 15 0
      data/data/screens/quit_menu/quit_menu.xml
  15. 241 0
      data/data/screens/status_menu/status_menu.xml
  16. 4 0
      data/data/scripts/_scripts.xml
  17. 25 0
      data/data/scripts/battle.lua
  18. 44 12
      data/data/scripts/battle/battle_ui.lua
  19. 159 0
      data/data/scripts/character_abilities.lua
  20. 165 0
      data/data/scripts/character_effects.lua
  21. 2 33
      data/data/scripts/config.lua
  22. 2 1
      data/data/scripts/debug_data.lua
  23. 5 0
      data/data/scripts/field.lua
  24. 136 125
      data/data/scripts/menu/begin_menu.lua
  25. 687 0
      data/data/scripts/menu/config_menu.lua
  26. 20 13
      data/data/scripts/menu/equip_menu.lua
  27. 24 31
      data/data/scripts/menu/item_menu.lua
  28. 235 0
      data/data/scripts/menu/limit_menu.lua
  29. 393 0
      data/data/scripts/menu/magic_menu.lua
  30. 182 161
      data/data/scripts/menu/main_menu.lua
  31. 1 0
      data/data/scripts/menu/materia_menu.lua
  32. 20 33
      data/data/scripts/menu/menu_helpers.lua
  33. 467 0
      data/data/scripts/menu/menu_lib.lua
  34. 47 18
      data/data/scripts/menu/name_menu.lua
  35. 31 44
      data/data/scripts/menu/pause_menu.lua
  36. 313 0
      data/data/scripts/menu/phs_menu.lua
  37. 49 0
      data/data/scripts/menu/quit_menu.lua
  38. 43 97
      data/data/scripts/menu/save_menu.lua
  39. 215 0
      data/data/scripts/menu/status_menu.lua
  40. 126 148
      data/data/scripts/save.lua
  41. 38 37
      data/data/scripts/system.lua
  42. 2 2
      data/data/system/system.fontdef
  43. 114 2
      data/data/texts/english/menu.xml
  44. 0 18
      data/plugins.cfg_win32
  45. 6 6
      data/resources.cfg
  46. 0 6
      data/resources.cfg_linux
  47. 0 461
      data/system.fontdef
  48. 0 0
      data/v-gears.cfg_linux
  49. 1 1
      debian/changelog
  50. 3 3
      debian/control
  51. 1 2
      debian/install
  52. 1 1
      debian/menu
  53. 19 3
      doc/BUILD.md
  54. 3 7
      doc/INSTALL.md
  55. 39 51
      doc/INSTALL_DATA.md
  56. 2 0
      doc/RUN.md
  57. 3 0
      src/CMakeLists.txt
  58. 4 4
      src/Version.h
  59. 47 2
      src/core/AudioManager.cpp
  60. 52 0
      src/core/AudioManager.h
  61. 3 9
      src/core/Background2D.cpp
  62. 29 0
      src/core/BattleManager.cpp
  63. 32 0
      src/core/BattleManager.h
  64. 21 0
      src/core/CameraManager.cpp
  65. 23 0
      src/core/CameraManager.h
  66. 13 0
      src/core/Entity.cpp
  67. 51 0
      src/core/Entity.h
  68. 34 8
      src/core/EntityManager.cpp
  69. 39 0
      src/core/EntityManager.h
  70. 87 0
      src/core/EntityModel.cpp
  71. 16 0
      src/core/EntityModel.h
  72. 88 0
      src/core/InputManager.cpp
  73. 76 0
      src/core/InputManager.h
  74. 20 0
      src/core/InputManagerCommands.h
  75. 65 0
      src/core/MovieCameraFrame.cpp
  76. 65 0
      src/core/MovieCameraFrame.h
  77. 316 0
      src/core/MoviePlayer.cpp
  78. 240 0
      src/core/MoviePlayer.h
  79. 55 0
      src/core/MovieState.cpp
  80. 100 0
      src/core/MovieState.h
  81. 85 48
      src/core/Savemap.cpp
  82. 23 112
      src/core/Savemap.h
  83. 14 8
      src/core/SavemapHandler.cpp
  84. 19 11
      src/core/SavemapHandler.h
  85. 4 11
      src/core/ScriptManager.cpp
  86. 44 4
      src/core/ScriptManagerBinds.h
  87. 25 0
      src/core/UiManager.cpp
  88. 45 0
      src/core/UiManager.h
  89. 25 12
      src/core/UiSprite.cpp
  90. 9 0
      src/core/UiSprite.h
  91. 3 0
      src/core/UiTextArea.cpp
  92. 8 0
      src/core/UiWidget.cpp
  93. 43 0
      src/core/UiWidget.h
  94. 4 0
      src/core/XmlScreenFile.cpp
  95. 9 0
      src/data/VGearsLGPArchive.h
  96. 25 0
      src/data/VGearsLGPArchiveSerializer.cpp
  97. 8 0
      src/data/VGearsLGPArchiveSerializer.h
  98. 277 17
      src/installer/BattleDataInstaller.cpp
  99. 71 0
      src/installer/BattleDataInstaller.h
  100. 8 0
      src/installer/CMakeLists.txt

+ 4 - 1
.gitignore

@@ -25,5 +25,8 @@ doc/FFVII/RAW/
 .cproject
 .directory
 v-gears-new*.png
-/.vscode/c_cpp_properties.json
+.vscode
 src/installer/MainWindow.ui.autosave
+
+# Generated from MainWindow.ui at build time.
+src/installer/ui_MainWindow.h

+ 5 - 1
CMakeLists.txt

@@ -1,7 +1,7 @@
 # Minimum required CMake and project declaration
 cmake_minimum_required(VERSION 3.5)
 # Enable both C and CXX languages so C and C++ toolchains are configured.
-project(V-Gears VERSION 0.2.0 LANGUAGES C CXX)
+project(V-Gears VERSION 0.3.0 LANGUAGES C CXX)
 
 # Provide a central common include for project-wide policies and defaults.
 list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/CMake")
@@ -39,6 +39,10 @@ endif()
 set(CMAKE_PACKAGE_ICON v-gears.png)
 
 # Generate version header into the build tree so source tree is not modified.
+set(VGEARS_VERSION ${PROJECT_VERSION})
+set(VGEARS_VERSION_MAJOR ${PROJECT_VERSION_MAJOR})
+set(VGEARS_VERSION_MINOR ${PROJECT_VERSION_MINOR})
+set(VGEARS_VERSION_PATCH ${PROJECT_VERSION_PATCH})
 configure_file(${CMAKE_CURRENT_SOURCE_DIR}/src/Version.h.in ${CMAKE_BINARY_DIR}/generated/Version.h @ONLY)
 include_directories(${CMAKE_BINARY_DIR}/generated)
 

+ 5 - 0
data/data/config/config.cfg

@@ -49,6 +49,11 @@ bind_game_event V "SELECT"
 bind_game_event PGDN "R1"
 bind_game_event PGUP "L1"
 
+bind_game_event Up "Up"
+bind_game_event Down "Down"
+bind_game_event Left "Left"
+bind_game_event Right "Right"
+
 bind_game_event LCtrl "run"
 bind_game_event Up "walk_up"
 bind_game_event Right "walk_right"

+ 6 - 1
data/data/screens/_screens.xml

@@ -7,10 +7,15 @@
     <prototype file_name="screens/main_menu/prototypes.xml" />
     <screen file_name="screens/main_menu/main_menu.xml" />
     <screen file_name="screens/pause_menu/pause_menu.xml" />
-    <prototype file_name="screens/item_menu/prototypes.xml" />
     <screen file_name="screens/item_menu/item_menu.xml" />
     <screen file_name="screens/equip_menu/equip_menu.xml" />
     <screen file_name="screens/materia_menu/materia_menu.xml" />
+    <screen file_name="screens/status_menu/status_menu.xml" />
+    <screen file_name="screens/magic_menu/magic_menu.xml" />
+    <screen file_name="screens/limit_menu/limit_menu.xml" />
+    <screen file_name="screens/quit_menu/quit_menu.xml" />
+    <screen file_name="screens/phs_menu/phs_menu.xml" />
+    <screen file_name="screens/config_menu/config_menu.xml" />
     <screen file_name="screens/name_menu/name_menu.xml" />
     <screen file_name="screens/save_menu/save_menu.xml" />
     <prototype file_name="screens/save_menu/prototypes.xml" />

+ 384 - 125
data/data/screens/battle/battle_fx.lua

@@ -2,48 +2,25 @@ if UiContainer == nil then UiContainer = {} end
 
 --- Battle visual effects.
 --
--- Simple generic effects: bursts of coloured particles by element, screen flashes and floating
+-- 3D effects and sprites made from the assets of the original game, floating
 -- numbers. Everything is made with the sprites and text areas of the BattleFx screen, moved from
 -- here every frame. Positions of the scene are projected to the UI with camera_manager.
 UiContainer.BattleFx = {
 
     --- Size of the pools of the screen. Must match battle_fx.xml.
-    PARTICLES = 60,
+    SPRITES = 40,
     NUMBERS = 8,
 
     --- Height, in scene units, above the position of a combatant where effects are shown.
     BODY_HEIGHT = 1.6,
 
-    --- Effects of each element: colour, particle count, speed (UI units per second), gravity (UI
-    -- units per second squared, negative makes them rise), size, life (seconds) and the flash
-    -- (colour and strength) of the spells of the element. "converge" makes the particles come to
-    -- the center instead of leaving it.
-    ELEMENTS = {
-        [0] = {colour = {1, 0.55, 0.1}, count = 24, speed = 90, gravity = -140, size = 10, life = 0.9, flash = {1, 0.5, 0.1, 0.3}},
-        [1] = {colour = {0.6, 0.9, 1}, count = 18, speed = 110, gravity = 180, size = 8, life = 0.8, flash = {0.6, 0.9, 1, 0.3}},
-        [2] = {colour = {1, 1, 0.4}, count = 16, speed = 260, gravity = 0, size = 6, life = 0.35, flash = {1, 1, 0.6, 0.55}},
-        [3] = {colour = {0.65, 0.45, 0.25}, count = 18, speed = 140, gravity = 320, size = 9, life = 0.9, up = true},
-        [4] = {colour = {0.5, 0.9, 0.3}, count = 16, speed = 50, gravity = -70, size = 9, life = 1.1, flash = {0.5, 0.9, 0.3, 0.2}},
-        [5] = {colour = {0.6, 0.3, 0.9}, count = 20, speed = 120, gravity = 0, size = 8, life = 0.8, converge = true, flash = {0.3, 0.1, 0.5, 0.35}},
-        [6] = {colour = {0.3, 0.6, 1}, count = 20, speed = 90, gravity = 240, size = 8, life = 0.9, flash = {0.3, 0.6, 1, 0.25}},
-        [7] = {colour = {0.8, 1, 0.9}, count = 16, speed = 240, gravity = 0, size = 5, life = 0.5, flash = {0.8, 1, 0.9, 0.2}},
-        [8] = {colour = {1, 1, 0.85}, count = 22, speed = 70, gravity = -100, size = 9, life = 1.0, flash = {1, 1, 0.9, 0.45}},
-    },
-
-    --- Effect of healing.
-    HEAL = {colour = {0.4, 1, 0.55}, count = 16, speed = 45, gravity = -80, size = 9, life = 1.0, flash = {0.4, 1, 0.55, 0.2}},
-
-    --- Effect of attacks without an element.
-    PHYSICAL = {colour = {1, 1, 1}, count = 9, speed = 150, gravity = 260, size = 6, life = 0.35},
-
     --- Colours of the floating numbers.
     COLOUR_DAMAGE = {1, 1, 1},
     COLOUR_HEAL = {0.4, 1, 0.5},
     COLOUR_MP = {0.5, 0.7, 1},
     COLOUR_MISS = {0.7, 0.7, 0.7},
 
-    --- Active particles and numbers.
-    particles = {},
+    --- Active numbers.
     numbers = {},
 
     --- Active screen flash.
@@ -52,6 +29,378 @@ UiContainer.BattleFx = {
     --- Game time of the last update.
     last_time = 0,
 
+    --- Sprite sheets of the original effects: the image (in images/), its size in pixels, the
+    -- frame list of the sprite file (Game.EffectSprites, from gamedata/effect_sprites.lua) and
+    -- the frames to play, in order.
+    SHEETS = {
+        -- Small star flare that grows and fades. 4 x 2 frames of 32 pixels.
+        sparkle = {image = "battle_fx/common_iyashi01.png", width = 128, height = 128,
+                   sprites = "common_iyashi2", frames = {1, 2, 4, 5, 6, 7, 8, 9}},
+        -- Big star flare. Frames of 64 pixels.
+        flare = {image = "battle_fx/common_iyashi00.png", width = 256, height = 128,
+                 sprites = "common_iyashi", frames = {1, 2, 3, 4, 5, 6}},
+    },
+
+    --- Active animated sprites, by pool index.
+    sprites = {},
+
+    --- Creates an animated sprite, that plays the frames of a sheet over its life.
+    --
+    -- @param p Table with sheet (name in SHEETS), x, y (UI), size (UI units), life, delay, vx, vy
+    -- (UI units per second) and tint (r, g, b).
+    spawn_sprite = function(self, p)
+        local sheet = self.SHEETS[p.sheet]
+        local list = Game.EffectSprites and Game.EffectSprites[sheet.sprites]
+        if list == nil then do return end end
+        local slot = nil
+        for i = 1, self.SPRITES do
+            if self.sprites[i] == nil then slot = i break end
+        end
+        if slot == nil then do return end end
+        p.slot = slot
+        p.sheet_data = sheet
+        p.list = list
+        p.age = 0
+        p.delay = p.delay or 0
+        self.sprites[slot] = p
+        local widget = ui_manager:get_widget("BattleFx.S" .. tostring(slot))
+        widget:set_visible(false)
+    end,
+
+    --- Advances the animated sprites.
+    update_sprites = function(self, dt)
+        for slot, p in pairs(self.sprites) do
+            p.age = p.age + dt
+            local widget = ui_manager:get_widget("BattleFx.S" .. tostring(slot))
+            local u = (p.age - p.delay) / p.life
+            if u >= 1 then
+                widget:set_visible(false)
+                self.sprites[slot] = nil
+            elseif u >= 0 then
+                local sheet = p.sheet_data
+                if not p.started then
+                    p.started = true
+                    widget:set_image(sheet.image)
+                    widget:set_additive(true)
+                    local tint = p.tint or {1, 1, 1}
+                    widget:set_colour(tint[1], tint[2], tint[3])
+                    widget:set_width(0, p.size)
+                    widget:set_height(0, p.size)
+                    widget:set_visible(true)
+                end
+                p.x = p.x + (p.vx or 0) * dt
+                p.y = p.y + (p.vy or 0) * dt
+                local frame = p.list[sheet.frames[math.min(#sheet.frames, math.floor(u * #sheet.frames) + 1)]]
+                widget:set_image_region(
+                  frame.u / sheet.width, frame.v / sheet.height,
+                  (frame.u + frame.w) / sheet.width, (frame.v + frame.h) / sheet.height
+                )
+                widget:set_x(0, p.x - p.size / 2)
+                widget:set_y(0, p.y - p.size / 2)
+                widget:set_alpha(1)
+            end
+        end
+    end,
+
+    --- Removes the animated sprites.
+    clear_sprites = function(self)
+        for slot, _ in pairs(self.sprites) do ui_manager:get_widget("BattleFx.S" .. tostring(slot)):set_visible(false) end
+        self.sprites = {}
+    end,
+
+    --- Healing: star flares that pop up around the target and rise.
+    heal_sparkles = function(self, x, y, power)
+        -- A big flare on the target.
+        for i = 1, 2 do
+            self:spawn_sprite({sheet = "flare", x = x, y = y - 15, size = 55 + 10 * power, life = 0.7, delay = 0.25 * (i - 1), vy = -25})
+        end
+        -- Small sparkles around, at different times.
+        for i = 1, 12 + 4 * power do
+            self:spawn_sprite({
+                sheet = "sparkle", x = x + (math.random() - 0.5) * 90, y = y + 20 - math.random() * 70,
+                size = 10 + 8 * math.random(), life = 0.5 + 0.3 * math.random(), delay = math.random() * 0.9,
+                vy = -30 - 30 * math.random(),
+            })
+        end
+    end,
+
+    --- Size of the effect models relative to the original ones. Must match
+    -- BattleManager::ATTACK_SCALE.
+    MODEL_UNIT = 0.0032,
+
+    --- Active 3D pieces of the effects, made from the meshes extracted from magic.lgp.
+    pieces = {},
+
+    --- False if the meshes of the effects aren't installed, so they aren't asked for again.
+    models_available = true,
+
+    --- Creates a 3D piece of an effect.
+    --
+    -- Meshes are static parts of the original effects. Each effect animates them from the update
+    -- function: scale, position and opacity. The mesh stands up with rx = 90, as the original
+    -- ones are lying along the Z axis of the model.
+    --
+    -- @param p Table with mesh (name from gamedata/effects.lua), x, y, z (scene position), life
+    -- (seconds), delay (seconds before it appears), blend (Entity.BLEND_*, additive by default),
+    -- rx, ry, rz (rotation of the mesh), and update, a function(piece, u, entity) called every
+    -- frame with u from 0 to 1 over the life of the piece.
+    spawn_piece = function(self, p)
+        if not self.models_available then do return end end
+        p.age = 0
+        p.delay = p.delay or 0
+        p.life = p.life or 1
+        self.pieces[#self.pieces + 1] = p
+    end,
+
+    --- Creates the entity of a piece, when its delay ends.
+    start_piece = function(self, p)
+        if not self.models_available then return false end
+        local name = battle_manager:add_effect_model(p.mesh, p.x, p.y, p.z, 1)
+        if name == nil or name == "" then
+            -- Not installed. Don't try again with any piece.
+            self.models_available = false
+            return false
+        end
+        p.entity_name = name
+        p.entity = entity_manager:get_entity(name)
+        p.entity:set_blend(p.blend or Entity.BLEND_ADDITIVE)
+        p.entity:set_model_rotation(p.rx or 0, p.ry or 0, p.rz or 0)
+        p.entity:set_alpha(0)
+        return true
+    end,
+
+    --- Advances the 3D pieces and removes the finished ones.
+    update_pieces = function(self, dt)
+        local alive = {}
+        for _, p in ipairs(self.pieces) do
+            p.age = p.age + dt
+            local keep = true
+            if p.age >= p.delay then
+                if p.entity == nil then keep = self:start_piece(p) end
+                if keep then
+                    local u = math.min(1, (p.age - p.delay) / p.life)
+                    local ok = pcall(p.update, p, u, p.entity)
+                    if not ok or u >= 1 then keep = false end
+                end
+            end
+            if keep then alive[#alive + 1] = p
+            elseif p.entity_name ~= nil then battle_manager:remove_effect_model(p.entity_name)
+            end
+        end
+        self.pieces = alive
+    end,
+
+    --- Removes the 3D pieces.
+    clear_pieces = function(self)
+        for _, p in ipairs(self.pieces) do
+            if p.entity_name ~= nil then battle_manager:remove_effect_model(p.entity_name) end
+        end
+        self.pieces = {}
+    end,
+
+    --- Fades in quickly, stays and fades out, for the opacity of a piece. u goes from 0 to 1.
+    fade = function(u, fade_in, fade_out)
+        if u < fade_in then return u / fade_in end
+        if u > 1 - fade_out then return (1 - u) / fade_out end
+        return 1
+    end,
+
+    --- Effects with meshes by the element of the attack. Each one gets the position of the target
+    -- in the scene and the strength of the spell (1 for the basic ones).
+    MODEL_EFFECTS = {
+        -- Fire: columns of flames that grow around the target.
+        [0] = function(self, x, y, z, power)
+            local count = 5 + math.floor(power * 2)
+            for i = 1, count do
+                local angle = (i / count) * 2 * math.pi + math.random() * 0.5
+                local radius = (i == 1) and 0 or (0.5 + 0.5 * math.random()) * power
+                local width = 11 + 5 * power
+                local height = 1 + 0.5 * power
+                self:spawn_piece({
+                    mesh = "magic_faira_" .. tostring((i - 1) % 8 + 1),
+                    x = x + math.cos(angle) * radius, y = y + math.sin(angle) * radius, z = z,
+                    rx = 90, life = 0.9 + 0.3 * math.random(), delay = 0.05 * i,
+                    update = function(piece, u, entity)
+                        local grow = math.min(1, u / 0.35)
+                        local unit = self.MODEL_UNIT
+                        entity:set_scale(
+                          width * unit * (0.5 + 0.5 * grow), width * unit * (0.4 + 1.2 * grow) * height,
+                          width * unit * (0.5 + 0.5 * grow)
+                        )
+                        entity:set_alpha(self.fade(u, 0.2, 0.5))
+                    end,
+                })
+            end
+        end,
+
+        -- Ice: shards that rise from the ground.
+        [1] = function(self, x, y, z, power)
+            local count = 4 + math.floor(power * 2)
+            for i = 1, count do
+                local angle = (i / count) * 2 * math.pi
+                local radius = (i == 1) and 0 or (0.3 + 0.5 * math.random()) * power
+                local size = 12 + 6 * power * math.random()
+                self:spawn_piece({
+                    mesh = "magic_freeze_ice_1",
+                    x = x + math.cos(angle) * radius, y = y + math.sin(angle) * radius, z = z + 0.2,
+                    rx = 0, life = 1.1, delay = 0.06 * i, blend = Entity.BLEND_ADDITIVE,
+                    update = function(piece, u, entity)
+                        local grow = math.min(1, u / 0.25)
+                        local unit = self.MODEL_UNIT
+                        entity:set_scale(size * unit * grow, size * unit * grow * 1.6, size * unit * grow)
+                        entity:set_alpha(self.fade(u, 0.15, 0.45))
+                    end,
+                })
+            end
+        end,
+
+        -- Bolt: a funnel of light that falls on the target.
+        [2] = function(self, x, y, z, power)
+            local width = 14 + 6 * power
+            for i = 1, 3 do
+                self:spawn_piece({
+                    mesh = "magic_thunder_thund_3",
+                    x = x, y = y, z = z + 2.6,
+                    rx = 90, life = 0.35, delay = 0.12 * (i - 1),
+                    update = function(piece, u, entity)
+                        local unit = self.MODEL_UNIT
+                        local drop = 1 - math.min(1, u / 0.5)
+                        entity:set_position(x, y, z + 0.2 + 2.4 * drop)
+                        entity:set_scale(width * unit * (0.6 + 0.8 * u), width * unit * 4, width * unit * (0.6 + 0.8 * u))
+                        entity:set_alpha(self.fade(u, 0.1, 0.5))
+                    end,
+                })
+            end
+        end,
+
+    },
+
+    --- Spawns a sphere made of two copies of the half-sphere mesh of the barriers, that grows
+    -- around the target, holds and fades out.
+    --
+    -- @param mesh Mesh name (radius 10 in the original units).
+    -- @param radius Final radius in scene units.
+    barrier_sphere = function(self, mesh, x, y, z, radius, life)
+        for _, rx in ipairs({90, -90}) do
+            self:spawn_piece({
+                mesh = mesh, x = x, y = y, z = z + 0.9, rx = rx, life = life,
+                update = function(piece, u, entity)
+                    local s = radius / 10 * (0.7 + 0.3 * math.min(1, u / 0.3))
+                    entity:set_scale(s, s, s)
+                    entity:set_alpha(self.fade(u, 0.15, 0.5) * 0.8)
+                end,
+            })
+        end
+    end,
+
+    --- Spawns a flat ring on the ground that expands and fades out.
+    --
+    -- @param mesh Mesh name.
+    -- @param mesh_radius Radius of the mesh in the original units.
+    -- @param radius Final radius in scene units.
+    ground_ring = function(self, mesh, mesh_radius, x, y, z, radius, life, delay)
+        self:spawn_piece({
+            mesh = mesh, x = x, y = y, z = z + 0.05, rx = 90, life = life, delay = delay or 0,
+            update = function(piece, u, entity)
+                local s = radius / mesh_radius * (0.3 + 0.7 * math.min(1, u / 0.5))
+                entity:set_scale(s, s, s)
+                entity:set_alpha(self.fade(u, 0.15, 0.5))
+            end,
+        })
+    end,
+
+    --- Effects of specific attacks, by attack id (the position in Game.Attacks). They are used
+    -- before the ones by element. Each one gets the position of the target and a flag that is
+    -- true when it's the first target of the attack. The ones that have sprites, instead of
+    -- meshes, get the position in the UI too.
+    ATTACK_EFFECTS = {
+        -- Regen: green sparkles that keep rising around the target.
+        [3] = function(self, x, y, z, power, ux, uy)
+            for i = 1, 16 do
+                self:spawn_sprite({
+                    sheet = "sparkle", x = ux + (math.random() - 0.5) * 70, y = uy + 25 - math.random() * 30,
+                    size = 10 + 8 * math.random(), life = 0.7 + 0.4 * math.random(), delay = math.random() * 1.2,
+                    vy = -35 - 25 * math.random(), tint = {0.4, 1, 0.5},
+                })
+            end
+        end,
+
+        -- Barrier: a sphere of light around the target.
+        [15] = function(self, x, y, z) self:barrier_sphere("common_bari_m", x, y, z, 1.1, 1.3) end,
+
+        -- MBarrier: the same with its own mesh.
+        [16] = function(self, x, y, z) self:barrier_sphere("magic_mbaria_jo_mba", x, y, z, 1.1, 1.3) end,
+
+        -- Reflect and Wall: both barriers at the same time.
+        [17] = function(self, x, y, z)
+            self:barrier_sphere("common_bari_m", x, y, z, 1.2, 1.5)
+            self:ground_ring("magic_stop_stop_1", 9, x, y, z, 1.3, 1.2)
+        end,
+        [18] = function(self, x, y, z)
+            self:barrier_sphere("common_bari_m", x, y, z, 1.1, 1.5)
+            self:barrier_sphere("magic_mbaria_jo_mba", x, y, z, 1.3, 1.5)
+        end,
+
+        -- Slow: a pair of needles that fall on the target.
+        [20] = function(self, x, y, z)
+            for i = 1, 3 do
+                self:spawn_piece({
+                    mesh = "magic_slou_hari", x = x, y = y, z = z + 1.4, life = 0.7, delay = 0.12 * (i - 1),
+                    update = function(piece, u, entity)
+                        local drop = 1 - math.min(1, u / 0.5)
+                        entity:set_position(x, y, z + 0.3 + 1.1 * drop)
+                        entity:set_scale(0.06, 0.06, 0.06)
+                        entity:set_alpha(self.fade(u, 0.1, 0.4))
+                    end,
+                })
+            end
+        end,
+
+        -- Stop: rings that close on the target.
+        [21] = function(self, x, y, z)
+            self:ground_ring("magic_stop_stop_1", 9, x, y, z, 1.4, 1.1)
+            self:ground_ring("magic_stop_stop_2", 9, x, y, z, 1.0, 1.1, 0.2)
+        end,
+
+        -- DeBarrier: a big circle of light on the ground.
+        [22] = function(self, x, y, z)
+            self:ground_ring("magic_debaria_light_1", 39, x, y, z, 1.6, 1.2)
+        end,
+
+        -- Death: the same kind of circle.
+        [24] = function(self, x, y, z)
+            self:ground_ring("magic_dethper_light_1", 39, x, y, z, 1.6, 1.2)
+        end,
+    },
+
+    --- Strength of a spell for the size of its effect: 1 for the basic one, 2 and 3 for the
+    -- upgrades (named like Fire2 and Fire3).
+    get_power = function(self, attack)
+        local level = tonumber(string.match(attack.name or "", "(%d)%s*$"))
+        if level == nil then return 1 end
+        return 1 + (level - 1) * 0.5
+    end,
+
+    --- Shows the effect of a spell on a target, made from the extracted assets: the one of the
+    -- attack if there is one, or the one of its element for the damage spells.
+    --
+    -- @return True if there is an effect for the attack.
+    play_model_effect = function(self, attack, side, index, heals)
+        if attack == nil or attack.id == nil or attack.id < 0 then do return false end end
+        local effect = self.ATTACK_EFFECTS[attack.id]
+        if effect == nil and not heals and (attack.damage.formula or 0) == 2
+          and attack.elements ~= nil and #attack.elements > 0 then
+            effect = self.MODEL_EFFECTS[attack.elements[1]]
+        end
+        if effect == nil or not self.models_available then return false end
+        local entity = UiContainer.BattleUi:get_entity(side, index)
+        if entity == nil then return false end
+        local x, y, z = entity:get_position()
+        local ux, uy = self:get_position(side, index, self.BODY_HEIGHT)
+        effect(self, x, y, z, self:get_power(attack), ux, uy)
+        return true
+    end,
+
     --- Shows the effects screen and clears everything.
     show = function(self)
         self:clear()
@@ -67,10 +416,10 @@ UiContainer.BattleFx = {
 
     --- Removes every effect.
     clear = function(self)
-        for _, particle in pairs(self.particles) do ui_manager:get_widget("BattleFx.P" .. tostring(particle.index)):set_visible(false) end
+        self:clear_pieces()
+        self:clear_sprites()
         for _, number in pairs(self.numbers) do ui_manager:get_widget("BattleFx.N" .. tostring(number.index)):set_visible(false) end
         ui_manager:get_widget("BattleFx.Flash"):set_visible(false)
-        self.particles = {}
         self.numbers = {}
         self.flash_state = nil
     end,
@@ -89,56 +438,6 @@ UiContainer.BattleFx = {
         return ux, uy
     end,
 
-    --- Finds a free particle of the pool.
-    free_particle = function(self)
-        for i = 1, self.PARTICLES do
-            if self.particles[i] == nil then return i end
-        end
-        return nil
-    end,
-
-    --- Creates a particle.
-    --
-    -- @param p Table with x, y, vx, vy (per second), gravity, life, size, colour and alpha.
-    spawn = function(self, p)
-        local i = self:free_particle()
-        if i == nil then do return end end
-        p.index = i
-        p.age = 0
-        self.particles[i] = p
-        local widget = ui_manager:get_widget("BattleFx.P" .. tostring(i))
-        widget:set_colour(p.colour[1], p.colour[2], p.colour[3])
-        widget:set_alpha(p.alpha or 1)
-        widget:set_width(0, p.size)
-        widget:set_height(0, p.size)
-        widget:set_x(0, p.x - p.size / 2)
-        widget:set_y(0, p.y - p.size / 2)
-        widget:set_visible(true)
-    end,
-
-    --- Creates a burst of particles from a preset around a position.
-    burst = function(self, preset, x, y)
-        for _ = 1, preset.count do
-            local angle = math.random() * 2 * math.pi
-            local speed = preset.speed * (0.4 + 0.6 * math.random())
-            local vx, vy = math.cos(angle) * speed, math.sin(angle) * speed
-            local px, py = x, y
-            if preset.converge then
-                -- Start on a ring and move to the center.
-                px, py = x - vx * 0.5, y - vy * 0.5
-                vx, vy = vx, vy
-            end
-            if preset.up then vy = -math.abs(vy) * 1.3 end
-            -- Slightly different tones.
-            local shade = 0.8 + 0.2 * math.random()
-            self:spawn({
-                x = px, y = py, vx = vx, vy = vy, gravity = preset.gravity,
-                life = preset.life * (0.7 + 0.5 * math.random()), size = preset.size * (0.6 + 0.6 * math.random()),
-                colour = {preset.colour[1] * shade, preset.colour[2] * shade, preset.colour[3] * shade},
-            })
-        end
-    end,
-
     --- Shows a screen flash that fades out.
     --
     -- @param colour Table with r, g, b.
@@ -152,43 +451,17 @@ UiContainer.BattleFx = {
         widget:set_visible(true)
     end,
 
-    --- Gets the effect preset of an attack, for the element it has or if it heals.
-    get_preset = function(self, attack, heals)
-        if heals then return self.HEAL end
-        if attack ~= nil and attack.elements ~= nil and #attack.elements > 0 then
-            return self.ELEMENTS[attack.elements[1]] or self.PHYSICAL
-        end
-        return self.PHYSICAL
-    end,
-
-    --- Effect of casting a spell: sparks around the caster.
-    cast = function(self, attack, side, index)
-        -- Only magic is cast. Other attacks just hit.
-        if attack == nil or attack.id == nil or attack.id < 0 or (attack.damage.formula or 0) ~= 2 then do return end end
-        local x, y = self:get_position(side, index, self.BODY_HEIGHT)
-        if x == nil then do return end end
-        local preset = self:get_preset(attack, (attack.damage.modifier or 0) == 5 and (attack.damage.formula or 0) == 2)
-        for _ = 1, 8 do
-            local angle = math.random() * 2 * math.pi
-            self:spawn({
-                x = x + math.cos(angle) * 30, y = y + math.sin(angle) * 30,
-                vx = -math.cos(angle) * 40, vy = -math.sin(angle) * 40 - 30, gravity = 0,
-                life = 0.8, size = 7, colour = preset.colour,
-            })
-        end
-    end,
-
-    --- Effect of an attack that hits a target: particles and, for spells, a screen flash.
+    --- Effect of an attack that hits a target: the effects made from the original assets.
     --
     -- @param attack Attack data.
     -- @param result The result of the attack, as in BattleUi.calculate_attack.
     hit = function(self, attack, side, index, result)
         local x, y = self:get_position(side, index, self.BODY_HEIGHT)
         if x == nil then do return end end
-        local preset = self:get_preset(attack, result.amount < 0)
-        self:burst(preset, x, y)
-        if preset.flash ~= nil and attack.id ~= nil and attack.id >= 0 then
-            self:flash({preset.flash[1], preset.flash[2], preset.flash[3]}, preset.flash[4], 0.4)
+        local heals = result.amount < 0
+        self:play_model_effect(attack, side, index, heals)
+        if heals and attack ~= nil and attack.id ~= nil and attack.id >= 0 then
+            self:heal_sparkles(x, y, self:get_power(attack))
         end
     end,
 
@@ -244,22 +517,8 @@ UiContainer.BattleFx = {
     update = function(self, now)
         local dt = math.max(0, math.min(0.1, now - self.last_time))
         self.last_time = now
-        for i, p in pairs(self.particles) do
-            p.age = p.age + dt
-            local widget = ui_manager:get_widget("BattleFx.P" .. tostring(i))
-            if p.age >= p.life then
-                widget:set_visible(false)
-                self.particles[i] = nil
-            else
-                p.x = p.x + p.vx * dt
-                p.y = p.y + p.vy * dt
-                p.vy = p.vy + p.gravity * dt
-                local fade = 1 - p.age / p.life
-                widget:set_alpha((p.alpha or 1) * fade)
-                widget:set_x(0, p.x - p.size / 2)
-                widget:set_y(0, p.y - p.size / 2)
-            end
-        end
+        self:update_pieces(dt)
+        self:update_sprites(dt)
         for i, n in pairs(self.numbers) do
             n.age = n.age + dt
             local widget = ui_manager:get_widget("BattleFx.N" .. tostring(i))

+ 49 - 69
data/data/screens/battle/battle_fx.xml

@@ -1,72 +1,52 @@
 <screen name="BattleFx" script="screens/battle/battle_fx.lua" visible="false">
-    <!-- Battle visual effects: a screen flash, a pool of particles and a pool of floating numbers. -->
+    <!-- Battle visual effects: a screen flash, a pool of animated sprites and a pool of floating numbers. -->
     <sprite name="Flash" x="0" y="0" width="100%" height="100%" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P1" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P2" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P3" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P4" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P5" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P6" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P7" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P8" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P9" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P10" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P11" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P12" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P13" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P14" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P15" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P16" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P17" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P18" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P19" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P20" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P21" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P22" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P23" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P24" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P25" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P26" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P27" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P28" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P29" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P30" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P31" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P32" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P33" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P34" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P35" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P36" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P37" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P38" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P39" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P40" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P41" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P42" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P43" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P44" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P45" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P46" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P47" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P48" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P49" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P50" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P51" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P52" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P53" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P54" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P55" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P56" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P57" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P58" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P59" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <sprite name="P60" x="0" y="0" width="8" height="8" colour="1 1 1" alpha="0" visible="false" />
-    <text_area name="N1" x="0" y="0" scale="1.4 1.4" font="FFVIIFont" visible="false" />
-    <text_area name="N2" x="0" y="0" scale="1.4 1.4" font="FFVIIFont" visible="false" />
-    <text_area name="N3" x="0" y="0" scale="1.4 1.4" font="FFVIIFont" visible="false" />
-    <text_area name="N4" x="0" y="0" scale="1.4 1.4" font="FFVIIFont" visible="false" />
-    <text_area name="N5" x="0" y="0" scale="1.4 1.4" font="FFVIIFont" visible="false" />
-    <text_area name="N6" x="0" y="0" scale="1.4 1.4" font="FFVIIFont" visible="false" />
-    <text_area name="N7" x="0" y="0" scale="1.4 1.4" font="FFVIIFont" visible="false" />
-    <text_area name="N8" x="0" y="0" scale="1.4 1.4" font="FFVIIFont" visible="false" />
+    <sprite name="S1" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S2" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S3" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S4" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S5" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S6" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S7" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S8" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S9" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S10" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S11" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S12" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S13" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S14" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S15" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S16" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S17" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S18" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S19" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S20" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S21" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S22" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S23" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S24" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S25" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S26" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S27" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S28" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S29" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S30" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S31" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S32" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S33" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S34" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S35" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S36" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S37" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S38" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S39" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <sprite name="S40" x="0" y="0" width="32" height="32" colour="1 1 1" alpha="0" visible="false" />
+    <text_area name="N1" x="0" y="0" width="400" height="60" scale="1.4 1.4" font="FFVIIFont" visible="false" />
+    <text_area name="N2" x="0" y="0" width="400" height="60" scale="1.4 1.4" font="FFVIIFont" visible="false" />
+    <text_area name="N3" x="0" y="0" width="400" height="60" scale="1.4 1.4" font="FFVIIFont" visible="false" />
+    <text_area name="N4" x="0" y="0" width="400" height="60" scale="1.4 1.4" font="FFVIIFont" visible="false" />
+    <text_area name="N5" x="0" y="0" width="400" height="60" scale="1.4 1.4" font="FFVIIFont" visible="false" />
+    <text_area name="N6" x="0" y="0" width="400" height="60" scale="1.4 1.4" font="FFVIIFont" visible="false" />
+    <text_area name="N7" x="0" y="0" width="400" height="60" scale="1.4 1.4" font="FFVIIFont" visible="false" />
+    <text_area name="N8" x="0" y="0" width="400" height="60" scale="1.4 1.4" font="FFVIIFont" visible="false" />
 </screen>

+ 298 - 0
data/data/screens/config_menu/config_menu.xml

@@ -0,0 +1,298 @@
+<screen name="ConfigMenu" script="scripts/menu/config_menu.lua" visible="false">
+    <widget name="Container" width="40%" height="40%" x="0" y="0" align="left" valign="top" scale="2.5 2.5" visible="true">
+        <widget name="Help" x="0" y="0" width="100%-97" height="26" visible="true">
+            <prototype name="Window" />
+            <text_area name="H1" text_name="ConfigMenuHelp1" x="16" y="7" font="FFVIIFont" visible="true" />
+            <text_area name="H2" text_name="ConfigMenuHelp2" x="16" y="7" font="FFVIIFont" visible="false" />
+            <text_area name="H3" text_name="ConfigMenuHelp3" x="16" y="7" font="FFVIIFont" visible="false" />
+            <text_area name="H4" text_name="ConfigMenuHelp4" x="16" y="7" font="FFVIIFont" visible="false" />
+            <text_area name="H5" text_name="ConfigMenuHelp5" x="16" y="7" font="FFVIIFont" visible="false" />
+            <text_area name="H6" text_name="ConfigMenuHelp6" x="16" y="7" font="FFVIIFont" visible="false" />
+            <text_area name="H7" text_name="ConfigMenuHelp7" x="16" y="7" font="FFVIIFont" visible="false" />
+            <text_area name="H8" text_name="ConfigMenuHelp8" x="16" y="7" font="FFVIIFont" visible="false" />
+            <text_area name="H9" text_name="ConfigMenuHelp9" x="16" y="7" font="FFVIIFont" visible="false" />
+            <text_area name="H10" text_name="ConfigMenuHelp10" x="16" y="7" font="FFVIIFont" visible="false" />
+            <text_area name="H11" text_name="ConfigMenuHelp11" x="16" y="7" font="FFVIIFont" visible="false" />
+            <text_area name="H12" text_name="ConfigMenuHelp12" x="16" y="7" font="FFVIIFont" visible="false" />
+        </widget>
+        <widget name="Title" x="100%-97" y="0" width="97" height="26" visible="true">
+            <prototype name="Window" />
+            <text_area name="Title" text_name="ConfigMenuTitle" x="16" y="7" font="FFVIIFont" visible="true" />
+        </widget>
+        <widget name="Body" x="0" y="26" width="100%" height="100%-26" visible="true">
+            <prototype name="Window" />
+            <widget name="WindowColor" x="0" y="18" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuLabelWindowColor" x="29" y="0" font="FFVIIFont" visible="true" />
+                <widget name="Preview" x="170" y="-12" width="67" height="26" visible="true">
+                    <prototype name="WindowPreview" />
+                </widget>
+            </widget>
+            <widget name="Sound" x="0" y="39" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuLabelSound" x="29" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="Controller" x="0" y="60" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuLabelController" x="29" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="CursorMode" x="0" y="81" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuLabelCursor" x="29" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Opt1On" text_name="ConfigMenuInitial" x="167" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Opt1Off" text_name="ConfigMenuInitialOff" x="167" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Opt2On" text_name="ConfigMenuMemory" x="240" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Opt2Off" text_name="ConfigMenuMemoryOff" x="240" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="Atb" x="0" y="102" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuLabelAtb" x="29" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Opt1On" text_name="ConfigMenuActive" x="167" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Opt1Off" text_name="ConfigMenuActiveOff" x="167" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Opt2On" text_name="ConfigMenuRecommended" x="220" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Opt2Off" text_name="ConfigMenuRecommendedOff" x="220" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Opt3On" text_name="ConfigMenuWait" x="326" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Opt3Off" text_name="ConfigMenuWaitOff" x="326" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="BattleSpeed" x="0" y="123" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuLabelBattleSpeed" x="29" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="FastLbl" text_name="ConfigMenuFast" x="158" y="0" font="FFVIIFont" visible="true" />
+                <sprite name="Bar" x="190" y="2" width="150" height="10" colour="0.12 0.12 0.3" visible="true" />
+                <sprite name="Handle" x="190" y="0" width="8" height="14" colour="0.75 0.75 0.75" visible="true">
+                    <animation name="Position0" length="0" x="0:190" />
+                    <animation name="Position1" length="0" x="0:204" />
+                    <animation name="Position2" length="0" x="0:218" />
+                    <animation name="Position3" length="0" x="0:233" />
+                    <animation name="Position4" length="0" x="0:247" />
+                    <animation name="Position5" length="0" x="0:261" />
+                    <animation name="Position6" length="0" x="0:275" />
+                    <animation name="Position7" length="0" x="0:289" />
+                    <animation name="Position8" length="0" x="0:304" />
+                    <animation name="Position9" length="0" x="0:318" />
+                    <animation name="Position10" length="0" x="0:332" />
+                </sprite>
+                <text_area name="SlowLbl" text_name="ConfigMenuSlow" x="343" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="BattleMessage" x="0" y="144" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuLabelBattleMessage" x="29" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="FastLbl" text_name="ConfigMenuFast" x="158" y="0" font="FFVIIFont" visible="true" />
+                <sprite name="Bar" x="190" y="2" width="150" height="10" colour="0.12 0.12 0.3" visible="true" />
+                <sprite name="Handle" x="190" y="0" width="8" height="14" colour="0.75 0.75 0.75" visible="true">
+                    <animation name="Position0" length="0" x="0:190" />
+                    <animation name="Position1" length="0" x="0:204" />
+                    <animation name="Position2" length="0" x="0:218" />
+                    <animation name="Position3" length="0" x="0:233" />
+                    <animation name="Position4" length="0" x="0:247" />
+                    <animation name="Position5" length="0" x="0:261" />
+                    <animation name="Position6" length="0" x="0:275" />
+                    <animation name="Position7" length="0" x="0:289" />
+                    <animation name="Position8" length="0" x="0:304" />
+                    <animation name="Position9" length="0" x="0:318" />
+                    <animation name="Position10" length="0" x="0:332" />
+                </sprite>
+                <text_area name="SlowLbl" text_name="ConfigMenuSlow" x="343" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="FieldMessage" x="0" y="165" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuLabelFieldMessage" x="29" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="FastLbl" text_name="ConfigMenuFast" x="158" y="0" font="FFVIIFont" visible="true" />
+                <sprite name="Bar" x="190" y="2" width="150" height="10" colour="0.12 0.12 0.3" visible="true" />
+                <sprite name="Handle" x="190" y="0" width="8" height="14" colour="0.75 0.75 0.75" visible="true">
+                    <animation name="Position0" length="0" x="0:190" />
+                    <animation name="Position1" length="0" x="0:204" />
+                    <animation name="Position2" length="0" x="0:218" />
+                    <animation name="Position3" length="0" x="0:233" />
+                    <animation name="Position4" length="0" x="0:247" />
+                    <animation name="Position5" length="0" x="0:261" />
+                    <animation name="Position6" length="0" x="0:275" />
+                    <animation name="Position7" length="0" x="0:289" />
+                    <animation name="Position8" length="0" x="0:304" />
+                    <animation name="Position9" length="0" x="0:318" />
+                    <animation name="Position10" length="0" x="0:332" />
+                </sprite>
+                <text_area name="SlowLbl" text_name="ConfigMenuSlow" x="343" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="Movement" x="0" y="186" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuLabelMovement" x="29" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Opt1On" text_name="ConfigMenuWalk" x="167" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Opt1Off" text_name="ConfigMenuWalkOff" x="167" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Opt2On" text_name="ConfigMenuRun" x="240" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Opt2Off" text_name="ConfigMenuRunOff" x="240" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="CameraAngle" x="0" y="207" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuLabelCameraAngle" x="29" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Opt1On" text_name="ConfigMenuAuto" x="167" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Opt1Off" text_name="ConfigMenuAutoOff" x="167" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Opt2On" text_name="ConfigMenuFix" x="240" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Opt2Off" text_name="ConfigMenuFixOff" x="240" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="MagicOrder" x="0" y="228" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuLabelMagicOrder" x="29" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="No" text_name="ConfigMenuMagicNo" x="150" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Number" x="180" y="2" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="Order1" text_name="ConfigMenuMagicOrder1" x="195" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Order2" text_name="ConfigMenuMagicOrder2" x="195" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Order3" text_name="ConfigMenuMagicOrder3" x="195" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Order4" text_name="ConfigMenuMagicOrder4" x="195" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Order5" text_name="ConfigMenuMagicOrder5" x="195" y="0" font="FFVIIFont" visible="false" />
+                <text_area name="Order6" text_name="ConfigMenuMagicOrder6" x="195" y="0" font="FFVIIFont" visible="false" />
+            </widget>
+        </widget>
+        <sprite name="CornerCursor" image="icons/cursor.png" x="146" y="28" width="24" height="17" visible="false">
+            <animation name="Position1" length="0" x="0:146" y="0:33" />
+            <animation name="Position2" length="0" x="0:210" y="0:33" />
+            <animation name="Position3" length="0" x="0:146" y="0:52" />
+            <animation name="Position4" length="0" x="0:210" y="0:52" />
+        </sprite>
+        <widget name="ColourWindow" x="130" y="68" width="210" height="88" visible="false">
+            <prototype name="Window" />
+            <widget name="Red" x="0" y="12" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuColourRed" x="26" y="0" font="FFVIIFont" visible="true" />
+                <sprite name="Bar" x="70" y="3" width="100" height="8" colour="0.55 0.12 0.12" visible="true" />
+                <sprite name="Handle" x="70" y="0" width="8" height="14" colour="0.85 0.85 0.85" visible="true" />
+                <text_area name="Value" x="178" y="2" font="FFVIIMenuDigits" visible="true" />
+            </widget>
+            <widget name="Green" x="0" y="36" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuColourGreen" x="26" y="0" font="FFVIIFont" visible="true" />
+                <sprite name="Bar" x="70" y="3" width="100" height="8" colour="0.12 0.45 0.12" visible="true" />
+                <sprite name="Handle" x="70" y="0" width="8" height="14" colour="0.85 0.85 0.85" visible="true" />
+                <text_area name="Value" x="178" y="2" font="FFVIIMenuDigits" visible="true" />
+            </widget>
+            <widget name="Blue" x="0" y="60" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuColourBlue" x="26" y="0" font="FFVIIFont" visible="true" />
+                <sprite name="Bar" x="70" y="3" width="100" height="8" colour="0.15 0.15 0.65" visible="true" />
+                <sprite name="Handle" x="70" y="0" width="8" height="14" colour="0.85 0.85 0.85" visible="true" />
+                <text_area name="Value" x="178" y="2" font="FFVIIMenuDigits" visible="true" />
+            </widget>
+            <sprite name="Cursor" image="icons/cursor.png" x="0" y="9" width="24" height="17" visible="true">
+                <animation name="Position1" length="0" y="0:9" />
+                <animation name="Position2" length="0" y="0:33" />
+                <animation name="Position3" length="0" y="0:57" />
+            </sprite>
+        </widget>
+        <widget name="SoundWindow" x="130" y="66" width="210" height="64" visible="false">
+            <prototype name="Window" />
+            <widget name="Music" x="0" y="12" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuSoundMusic" x="26" y="0" font="FFVIIFont" visible="true" />
+                <sprite name="Bar" x="80" y="3" width="100" height="8" colour="0.15 0.15 0.45" visible="true" />
+                <sprite name="Handle" x="80" y="0" width="8" height="14" colour="0.85 0.85 0.85" visible="true">
+                    <animation name="Position0" length="0" x="0:80" />
+                    <animation name="Position1" length="0" x="0:103" />
+                    <animation name="Position2" length="0" x="0:126" />
+                    <animation name="Position3" length="0" x="0:149" />
+                    <animation name="Position4" length="0" x="0:172" />
+                </sprite>
+            </widget>
+            <widget name="Effects" x="0" y="36" width="100%" height="16" visible="true">
+                <text_area name="Label" text_name="ConfigMenuSoundEffects" x="26" y="0" font="FFVIIFont" visible="true" />
+                <sprite name="Bar" x="80" y="3" width="100" height="8" colour="0.15 0.15 0.45" visible="true" />
+                <sprite name="Handle" x="80" y="0" width="8" height="14" colour="0.85 0.85 0.85" visible="true">
+                    <animation name="Position0" length="0" x="0:80" />
+                    <animation name="Position1" length="0" x="0:103" />
+                    <animation name="Position2" length="0" x="0:126" />
+                    <animation name="Position3" length="0" x="0:149" />
+                    <animation name="Position4" length="0" x="0:172" />
+                </sprite>
+            </widget>
+            <sprite name="Cursor" image="icons/cursor.png" x="0" y="9" width="24" height="17" visible="true">
+                <animation name="Position1" length="0" y="0:9" />
+                <animation name="Position2" length="0" y="0:33" />
+            </sprite>
+        </widget>
+        <widget name="ControllerWindow" x="60" y="36" width="290" height="250" visible="false">
+            <prototype name="Window" />
+            <widget name="RowDefault" x="0" y="10" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlDefault" x="26" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowUp" x="0" y="26" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlUp" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowDown" x="0" y="42" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlDown" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowLeft" x="0" y="58" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlLeft" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowRight" x="0" y="74" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlRight" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowOk" x="0" y="90" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlOk" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowCancel" x="0" y="106" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlCancel" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowMenu" x="0" y="122" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlMenu" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowExtra" x="0" y="138" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlExtra" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowSelect" x="0" y="154" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlSelect" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowStart" x="0" y="170" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlStart" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowL1" x="0" y="186" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlL1" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowR1" x="0" y="202" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlR1" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="RowRun" x="0" y="218" width="100%" height="14" visible="true">
+                <text_area name="Label" text_name="ConfigMenuControlRun" x="26" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Keys" x="150" y="0" font="FFVIIFont" visible="true" />
+            </widget>
+            <sprite name="Cursor" image="icons/cursor.png" x="0" y="7" width="24" height="17" visible="true">
+                <animation name="Position1" length="0" y="0:7" />
+                <animation name="Position2" length="0" y="0:23" />
+                <animation name="Position3" length="0" y="0:39" />
+                <animation name="Position4" length="0" y="0:55" />
+                <animation name="Position5" length="0" y="0:71" />
+                <animation name="Position6" length="0" y="0:87" />
+                <animation name="Position7" length="0" y="0:103" />
+                <animation name="Position8" length="0" y="0:119" />
+                <animation name="Position9" length="0" y="0:135" />
+                <animation name="Position10" length="0" y="0:151" />
+                <animation name="Position11" length="0" y="0:167" />
+                <animation name="Position12" length="0" y="0:183" />
+                <animation name="Position13" length="0" y="0:199" />
+                <animation name="Position14" length="0" y="0:215" />
+                <animation name="Blink1" length="0.1" y="0:7" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink2" length="0.1" y="0:23" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink3" length="0.1" y="0:39" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink4" length="0.1" y="0:55" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink5" length="0.1" y="0:71" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink6" length="0.1" y="0:87" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink7" length="0.1" y="0:103" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink8" length="0.1" y="0:119" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink9" length="0.1" y="0:135" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink10" length="0.1" y="0:151" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink11" length="0.1" y="0:167" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink12" length="0.1" y="0:183" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink13" length="0.1" y="0:199" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+                <animation name="Blink14" length="0.1" y="0:215" alpha="0:1, 0.04:1, 0.05:0, 0.09:0, 0.1:1" />
+            </sprite>
+        </widget>
+        <sprite name="Cursor" image="icons/cursor.png" x="-1" y="46" width="24" height="17" visible="true">
+            <animation name="Position1" length="0" y="0:46" />
+            <animation name="Position2" length="0" y="0:66" />
+            <animation name="Position3" length="0" y="0:88" />
+            <animation name="Position4" length="0" y="0:108" />
+            <animation name="Position5" length="0" y="0:130" />
+            <animation name="Position6" length="0" y="0:150" />
+            <animation name="Position7" length="0" y="0:172" />
+            <animation name="Position8" length="0" y="0:192" />
+            <animation name="Position9" length="0" y="0:214" />
+            <animation name="Position10" length="0" y="0:234" />
+            <animation name="Position11" length="0" y="0:256" />
+        </sprite>
+    </widget>
+</screen>

+ 0 - 22
data/data/screens/item_menu/prototypes.xml

@@ -1,22 +0,0 @@
-<prototypes>
-    <prototype name="MenuCharacterData">
-        <text_area name="Name" x="0" y="0" text_name="CharacterDataName" font="FFVIIFont" visible="true" />
-        <text_area name="Lv" x="0" y="5" text_name="CharacterDataLv" colour="0 0.9 0.9" font="FFVIIMenuFont" visible="true" />
-        <text_area name="LvNumber" x="18" y="13" text_name="CharacterDataLvNumber" font="FFVIIMenuDigits" visible="true" />
-        <text_area name="Hp" x="0" y="16" text_name="CharacterDataHp" colour="0 0.9 0.9" font="FFVIIMenuFont" visible="true" />
-        <text_area name="HpCurrent" x="18" y="23" text_name="CharacterDataHpCurrent" colour="0.9 0.9 0.9" font="FFVIIMenuDigits" visible="true" />
-        <sprite name="HpSlash" image="screens/images/slash.png" x="46" y="23" width="4" height="8" visible="true" />
-        <text_area name="HpMax" x="50" y="23" text_name="CharacterDataHpMax" font="FFVIIMenuDigits" visible="true" />
-        <sprite name="HpLine" x="18" y="31" width="60" height="1" colour="0.3 0 0" visible="true" />
-        <sprite name="HpLineCurrent" x="18" y="31" width="5" height="1" colours="0 0.5 1, 0.78 0.78 0.78, 0.78 0.78 0.78, 0 0.5 1" visible="true" />
-        <sprite name="HpLineShadow" x="18" y="32" width="60" height="1" colour="0 0 0" visible="true" />
-        <text_area name="Mp" x="0" y="26" text_name="CharacterDataMp" colour="0 0.9 0.9" font="FFVIIMenuFont" visible="true" />
-        <text_area name="MpCurrent" x="18" y="33" text_name="CharacterDataMpCurrent" font="FFVIIMenuDigits" visible="true" />
-        <sprite name="MpSlash" image="icons/slash.png" x="46" y="33" width="4" height="8" visible="true" />
-        <text_area name="MpMax" x="50" y="33" text_name="CharacterDataMpMax" font="FFVIIMenuDigits" visible="true" />
-        <sprite name="MpLine" x="18" y="41" width="60" height="1" colour="0.3 0 0" visible="true" />
-        <sprite name="MpLineCurrent" x="18" y="41" width="60" height="1" colours="0 1 0.5, 0.78 0.78 0.78, 0.78 0.78 0.78, 0 1 0.5" visible="true" />
-        <sprite name="MpLineShadow" x="18" y="42" width="60" height="1" colour="0 0 0" visible="true" />
-        <text_area name="Status" x="36" y="11" text_name="CharacterDataFury" colour="0.9 0 0.9" font="FFVIIFont" visible="true" />
-    </prototype>
-</prototypes>

+ 83 - 0
data/data/screens/limit_menu/limit_menu.xml

@@ -0,0 +1,83 @@
+<screen name="LimitMenu" script="scripts/menu/limit_menu.lua" visible="false">
+    <widget name="Container" width="40%" height="40%" x="0" y="0" align="left" valign="top" scale="2.5 2.5" visible="true">
+        <widget name="Character" width="100%" height="70" visible="true">
+            <prototype name="Window" />
+            <sprite name="Portrait" image="other/choco.png" x="10" y="12" width="42" height="48" visible="true" />
+            <widget name="Data" x="66" y="14" width="100%-66" height="100%-14" visible="true">
+                <prototype name="MenuCharacterData" />
+            </widget>
+            <text_area name="LimitLbl" text_name="LimitMenuLimitLevel" x="181" y="12" font="FFVIIFont" visible="true" />
+            <text_area name="LimitLevel" x="242" y="14" font="FFVIIMenuDigits" visible="true" />
+            <sprite name="LimitLevelBar" image="icons/menu_bar.png" x="196" y="26" width="56" height="8" visible="true" />
+            <sprite name="LimitLinePart1" x="197" y="27" width="25" height="3" colours="0.5 0.5 0.5, 0.5 0.5 0.5, 0.5 0.125 0.31, 0.5 0.125 0.31" alpha="0.8" visible="false" />
+            <sprite name="LimitLinePart2" x="197" y="30" width="25" height="3" colours="0.5 0.125 0.31, 0.5 0.125 0.31, 0 0 0, 0 0 0" alpha="0.8" visible="false" />
+            <widget name="Modes" x="0" y="44" width="100%" height="14" visible="true">
+                <text_area name="Set" text_name="LimitMenuSet" x="197" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Check" text_name="LimitMenuCheck" x="245" y="0" font="FFVIIFont" visible="true" />
+                <sprite name="Cursor" image="icons/cursor.png" x="170" y="1" width="24" height="17" visible="true">
+                    <animation name="Position1" length="0" x="0:170" />
+                    <animation name="Position2" length="0" x="0:218" />
+                </sprite>
+            </widget>
+        </widget>
+        <widget name="Title" x="100%-70" y="0" width="70" height="22" visible="true">
+            <prototype name="Window" />
+            <text_area name="Title" text_name="LimitMenuTitle" x="12" y="5" font="FFVIIFont" visible="true" />
+        </widget>
+        <widget name="Help" x="0" y="70" width="100%" height="24" visible="true">
+            <prototype name="Window" />
+            <text_area name="Help" x="16" y="6" font="FFVIIFont" visible="true" />
+        </widget>
+        <widget name="Body" x="0" y="94" width="100%" height="100%-94" visible="true">
+            <prototype name="Window" />
+            <widget name="Level1" x="28" y="30" width="120" height="40" visible="true">
+                <text_area name="Lbl" x="0" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Tech1" x="11" y="13" font="FFVIIFont" visible="true" />
+                <text_area name="Tech2" x="11" y="26" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="Level2" x="200" y="30" width="120" height="40" visible="true">
+                <text_area name="Lbl" x="0" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Tech1" x="11" y="13" font="FFVIIFont" visible="true" />
+                <text_area name="Tech2" x="11" y="26" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="Level3" x="28" y="100" width="120" height="40" visible="true">
+                <text_area name="Lbl" x="0" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Tech1" x="11" y="13" font="FFVIIFont" visible="true" />
+                <text_area name="Tech2" x="11" y="26" font="FFVIIFont" visible="true" />
+            </widget>
+            <widget name="Level4" x="200" y="100" width="120" height="40" visible="true">
+                <text_area name="Lbl" x="0" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Tech1" x="11" y="13" font="FFVIIFont" visible="true" />
+                <text_area name="Tech2" x="11" y="26" font="FFVIIFont" visible="true" />
+            </widget>
+            <sprite name="LevelCursor" image="icons/cursor.png" x="-2" y="28" width="24" height="17" visible="false">
+                <animation name="Position1" length="0" x="0:-2" y="0:28" />
+                <animation name="Position2" length="0" x="0:170" y="0:28" />
+                <animation name="Position3" length="0" x="0:-2" y="0:98" />
+                <animation name="Position4" length="0" x="0:170" y="0:98" />
+            </sprite>
+            <sprite name="TechCursor" image="icons/cursor.png" x="9" y="40" width="24" height="17" visible="false">
+                <animation name="Position1" length="0" x="0:9" y="0:40" />
+                <animation name="Position2" length="0" x="0:9" y="0:53" />
+                <animation name="Position3" length="0" x="0:181" y="0:40" />
+                <animation name="Position4" length="0" x="0:181" y="0:53" />
+                <animation name="Position5" length="0" x="0:9" y="0:110" />
+                <animation name="Position6" length="0" x="0:9" y="0:123" />
+                <animation name="Position7" length="0" x="0:181" y="0:110" />
+                <animation name="Position8" length="0" x="0:181" y="0:123" />
+            </sprite>
+        </widget>
+        <widget name="Confirm" x="78" y="46" width="164" height="82" visible="false">
+            <prototype name="Window" />
+            <text_area name="Line1" text_name="LimitMenuConfirm1" x="8" y="7" font="FFVIIFont" visible="true" />
+            <text_area name="Line2" text_name="LimitMenuConfirm2" x="8" y="21" font="FFVIIFont" visible="true" />
+            <text_area name="Line3" text_name="LimitMenuConfirm3" x="8" y="35" font="FFVIIFont" visible="true" />
+            <text_area name="Yes" text_name="Yes" x="70" y="54" font="FFVIIFont" visible="true" />
+            <text_area name="No" text_name="No" x="70" y="66" font="FFVIIFont" visible="true" />
+            <sprite name="Cursor" image="icons/cursor.png" x="44" y="55" width="24" height="17" visible="true">
+                <animation name="Position1" length="0" y="0:43" />
+                <animation name="Position2" length="0" y="0:55" />
+            </sprite>
+        </widget>
+    </widget>
+</screen>

+ 149 - 0
data/data/screens/magic_menu/magic_menu.xml

@@ -0,0 +1,149 @@
+<screen name="MagicMenu" script="scripts/menu/magic_menu.lua" visible="false">
+    <widget name="Container" width="40%" height="40%" x="0" y="0" align="left" valign="top" scale="2.5 2.5" visible="true">
+        <widget name="Character" x="0" y="0" width="100%-98" height="94" visible="true">
+            <prototype name="Window" />
+            <sprite name="Portrait" image="other/choco.png" x="11" y="20" width="53" height="58" visible="true" />
+            <widget name="Data" x="68" y="28" width="100%-68" height="100%-28" visible="true">
+                <prototype name="MenuCharacterData" />
+            </widget>
+            <widget name="Added" x="158" y="14" width="150" height="40" visible="false">
+                <text_area name="Label" text_name="MagicMenuAddedAbility" x="0" y="0" font="FFVIIFont" visible="true" />
+                <text_area name="Value" text_name="MagicMenuNothing" x="13" y="20" font="FFVIIFont" visible="true" />
+            </widget>
+        </widget>
+        <widget name="Title" x="100%-98" y="0" width="98" height="27" visible="true">
+            <prototype name="Window" />
+            <text_area name="Title" text_name="MagicMenuTitle" x="24" y="7" font="FFVIIFont" visible="true" />
+        </widget>
+        <widget name="Side" x="100%-98" y="27" width="98" height="67" visible="true">
+            <prototype name="Window" />
+            <widget name="Options" x="0" y="0" width="100%" height="100%" visible="true">
+                <text_area name="Magic" text_name="MagicMenuMagic" x="34" y="8" font="FFVIIFont" visible="true" />
+                <text_area name="Summon" text_name="MagicMenuSummon" x="34" y="25" font="FFVIIFont" visible="true" />
+                <text_area name="ESkill" text_name="MagicMenuESkill" x="34" y="42" font="FFVIIFont" visible="true" />
+                <sprite name="Cursor" image="icons/cursor.png" x="8" y="6" width="24" height="17" visible="true">
+                    <animation name="Position1" length="0" y="0:6" />
+                    <animation name="Position2" length="0" y="0:23" />
+                    <animation name="Position3" length="0" y="0:40" />
+                </sprite>
+            </widget>
+            <widget name="Cost" x="0" y="0" width="100%" height="100%" visible="false">
+                <text_area name="MpLabel" text_name="MagicMenuMpNeeded" x="14" y="6" font="FFVIIFont" visible="true" />
+                <text_area name="MpValue" x="40" y="20" font="FFVIIMenuDigits" visible="true" />
+                <widget name="Uses" x="0" y="0" width="100%" height="100%" visible="false">
+                    <text_area name="Label" text_name="MagicMenuUses" x="30" y="34" font="FFVIIFont" visible="true" />
+                    <text_area name="Value" x="40" y="48" font="FFVIIMenuDigits" visible="true" />
+                </widget>
+            </widget>
+        </widget>
+        <widget name="Help" x="0" y="94" width="100%" height="26" visible="true">
+            <prototype name="Window" />
+            <text_area name="Help" x="16" y="7" font="FFVIIFont" visible="true" />
+        </widget>
+        <widget name="List" x="0" y="120" width="100%" height="100%-120" visible="true">
+            <prototype name="Window" />
+            <sprite name="ScrollBg" x="100%-12" y="2" width="10" height="100%-6" colour="0.2 0.2 0.2" alpha="0.8" visible="true" />
+            <sprite name="Scroll" x="100%-11" y="3" width="8" height="18" colour="0.7 0.7 0.7" visible="true" />
+            <text_area name="R1C1" x="24" y="6" font="FFVIIFont" visible="true" />
+            <text_area name="R1C2" x="112" y="6" font="FFVIIFont" visible="true" />
+            <text_area name="R1C3" x="200" y="6" font="FFVIIFont" visible="true" />
+            <text_area name="R1C4" x="288" y="6" font="FFVIIFont" visible="true" />
+            <text_area name="R2C1" x="24" y="23" font="FFVIIFont" visible="true" />
+            <text_area name="R2C2" x="112" y="23" font="FFVIIFont" visible="true" />
+            <text_area name="R2C3" x="200" y="23" font="FFVIIFont" visible="true" />
+            <text_area name="R2C4" x="288" y="23" font="FFVIIFont" visible="true" />
+            <text_area name="R3C1" x="24" y="40" font="FFVIIFont" visible="true" />
+            <text_area name="R3C2" x="112" y="40" font="FFVIIFont" visible="true" />
+            <text_area name="R3C3" x="200" y="40" font="FFVIIFont" visible="true" />
+            <text_area name="R3C4" x="288" y="40" font="FFVIIFont" visible="true" />
+            <text_area name="R4C1" x="24" y="57" font="FFVIIFont" visible="true" />
+            <text_area name="R4C2" x="112" y="57" font="FFVIIFont" visible="true" />
+            <text_area name="R4C3" x="200" y="57" font="FFVIIFont" visible="true" />
+            <text_area name="R4C4" x="288" y="57" font="FFVIIFont" visible="true" />
+            <text_area name="R5C1" x="24" y="74" font="FFVIIFont" visible="true" />
+            <text_area name="R5C2" x="112" y="74" font="FFVIIFont" visible="true" />
+            <text_area name="R5C3" x="200" y="74" font="FFVIIFont" visible="true" />
+            <text_area name="R5C4" x="288" y="74" font="FFVIIFont" visible="true" />
+            <text_area name="R6C1" x="24" y="91" font="FFVIIFont" visible="true" />
+            <text_area name="R6C2" x="112" y="91" font="FFVIIFont" visible="true" />
+            <text_area name="R6C3" x="200" y="91" font="FFVIIFont" visible="true" />
+            <text_area name="R6C4" x="288" y="91" font="FFVIIFont" visible="true" />
+            <text_area name="R7C1" x="24" y="108" font="FFVIIFont" visible="true" />
+            <text_area name="R7C2" x="112" y="108" font="FFVIIFont" visible="true" />
+            <text_area name="R7C3" x="200" y="108" font="FFVIIFont" visible="true" />
+            <text_area name="R7C4" x="288" y="108" font="FFVIIFont" visible="true" />
+            <text_area name="R8C1" x="24" y="125" font="FFVIIFont" visible="true" />
+            <text_area name="R8C2" x="112" y="125" font="FFVIIFont" visible="true" />
+            <text_area name="R8C3" x="200" y="125" font="FFVIIFont" visible="true" />
+            <text_area name="R8C4" x="288" y="125" font="FFVIIFont" visible="true" />
+            <text_area name="R9C1" x="24" y="142" font="FFVIIFont" visible="true" />
+            <text_area name="R9C2" x="112" y="142" font="FFVIIFont" visible="true" />
+            <text_area name="R9C3" x="200" y="142" font="FFVIIFont" visible="true" />
+            <text_area name="R9C4" x="288" y="142" font="FFVIIFont" visible="true" />
+            <sprite name="Cursor" image="icons/cursor.png" x="-2" y="4" width="24" height="17" visible="false">
+                <animation name="Position1-1" length="0" x="0:-2" y="0:4" />
+                <animation name="Position1-2" length="0" x="0:86" y="0:4" />
+                <animation name="Position1-3" length="0" x="0:174" y="0:4" />
+                <animation name="Position1-4" length="0" x="0:262" y="0:4" />
+                <animation name="Position2-1" length="0" x="0:-2" y="0:21" />
+                <animation name="Position2-2" length="0" x="0:86" y="0:21" />
+                <animation name="Position2-3" length="0" x="0:174" y="0:21" />
+                <animation name="Position2-4" length="0" x="0:262" y="0:21" />
+                <animation name="Position3-1" length="0" x="0:-2" y="0:38" />
+                <animation name="Position3-2" length="0" x="0:86" y="0:38" />
+                <animation name="Position3-3" length="0" x="0:174" y="0:38" />
+                <animation name="Position3-4" length="0" x="0:262" y="0:38" />
+                <animation name="Position4-1" length="0" x="0:-2" y="0:55" />
+                <animation name="Position4-2" length="0" x="0:86" y="0:55" />
+                <animation name="Position4-3" length="0" x="0:174" y="0:55" />
+                <animation name="Position4-4" length="0" x="0:262" y="0:55" />
+                <animation name="Position5-1" length="0" x="0:-2" y="0:72" />
+                <animation name="Position5-2" length="0" x="0:86" y="0:72" />
+                <animation name="Position5-3" length="0" x="0:174" y="0:72" />
+                <animation name="Position5-4" length="0" x="0:262" y="0:72" />
+                <animation name="Position6-1" length="0" x="0:-2" y="0:89" />
+                <animation name="Position6-2" length="0" x="0:86" y="0:89" />
+                <animation name="Position6-3" length="0" x="0:174" y="0:89" />
+                <animation name="Position6-4" length="0" x="0:262" y="0:89" />
+                <animation name="Position7-1" length="0" x="0:-2" y="0:106" />
+                <animation name="Position7-2" length="0" x="0:86" y="0:106" />
+                <animation name="Position7-3" length="0" x="0:174" y="0:106" />
+                <animation name="Position7-4" length="0" x="0:262" y="0:106" />
+                <animation name="Position8-1" length="0" x="0:-2" y="0:123" />
+                <animation name="Position8-2" length="0" x="0:86" y="0:123" />
+                <animation name="Position8-3" length="0" x="0:174" y="0:123" />
+                <animation name="Position8-4" length="0" x="0:262" y="0:123" />
+                <animation name="Position9-1" length="0" x="0:-2" y="0:140" />
+                <animation name="Position9-2" length="0" x="0:86" y="0:140" />
+                <animation name="Position9-3" length="0" x="0:174" y="0:140" />
+                <animation name="Position9-4" length="0" x="0:262" y="0:140" />
+            </sprite>
+        </widget>
+        <widget name="Targets" x="0" y="0" width="50%" height="100%" visible="false">
+            <prototype name="Window" />
+            <widget name="Slot1" x="0" y="14" width="100%" height="64" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="20" y="0" width="42" height="48" visible="true" />
+                <widget name="Data" x="68" y="2" width="100%-68" height="100%-2" visible="true">
+                    <prototype name="MenuCharacterData" />
+                </widget>
+            </widget>
+            <widget name="Slot2" x="0" y="33%+14" width="100%" height="64" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="20" y="0" width="42" height="48" visible="true" />
+                <widget name="Data" x="68" y="2" width="100%-68" height="100%-2" visible="true">
+                    <prototype name="MenuCharacterData" />
+                </widget>
+            </widget>
+            <widget name="Slot3" x="0" y="66%+14" width="100%" height="64" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="20" y="0" width="42" height="48" visible="true" />
+                <widget name="Data" x="68" y="2" width="100%-68" height="100%-2" visible="true">
+                    <prototype name="MenuCharacterData" />
+                </widget>
+            </widget>
+            <sprite name="Cursor" image="icons/cursor.png" x="-2" y="26" width="24" height="17" visible="true">
+                <animation name="Position1" length="0" y="0:26" />
+                <animation name="Position2" length="0" y="0:33%+26" />
+                <animation name="Position3" length="0" y="0:66%+26" />
+            </sprite>
+        </widget>
+    </widget>
+</screen>

+ 2 - 2
data/data/screens/main_menu/main_menu.xml

@@ -23,7 +23,7 @@
                 <sprite name="LimitLinePart2" x="183" y="53" width="25" height="3" colours="0.5 0.125 0.31, 0.5 0.125 0.31, 0 0 0, 0 0 0" alpha="0.8" visible="false" />
             </widget>
             <widget name="Character2" x="2%" y="32%" width="100%" height="32%" visible="true">
-                <sprite name="Portrait" image="other/choco.png" x="5" y="12" width="56" height="56" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="5" y="12" width="49" height="56" visible="true">
                     <animation name="RowFront" length="0" x="0:5" />
                     <animation name="RowBack" length="0" x="0:25" />
                 </sprite>
@@ -41,7 +41,7 @@
                 <sprite name="LimitLinePart2" x="183" y="53" width="25" height="3" colours="0.5 0.125 0.31, 0.5 0.125 0.31, 0 0 0, 0 0 0" alpha="0.8" visible="false" />
             </widget>
             <widget name="Character3" x="2%" y="62%" width="100%" height="32%" visible="true">
-               <sprite name="Portrait" image="other/choco.png" x="5" y="12" width="56" height="56" visible="true">
+               <sprite name="Portrait" image="other/choco.png" x="5" y="12" width="49" height="56" visible="true">
                     <animation name="RowFront" length="0" x="0:5" />
                     <animation name="RowBack" length="0" x="0:25" />
                 </sprite>

+ 99 - 0
data/data/screens/phs_menu/phs_menu.xml

@@ -0,0 +1,99 @@
+<screen name="PhsMenu" script="scripts/menu/phs_menu.lua" visible="false">
+    <widget name="Container" width="40%" height="40%" x="0" y="0" align="left" valign="top" scale="2.5 2.5" visible="true">
+        <widget name="Help" x="0" y="0" width="100%-81" height="24" visible="true">
+            <prototype name="Window" />
+            <text_area name="Help" text_name="PhsMenuHelp" x="12" y="6" font="FFVIIFont" visible="true" />
+        </widget>
+        <widget name="Title" x="100%-81" y="0" width="81" height="24" visible="true">
+            <prototype name="Window" />
+            <text_area name="Title" text_name="PhsMenuTitle" x="24" y="6" font="FFVIIFont" visible="true" />
+        </widget>
+        <widget name="Party" x="0" y="24" width="180" height="100%-24" visible="true">
+            <prototype name="Window" />
+            <widget name="Slot1" x="0" y="20" width="100%" height="64" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="16" y="0" width="42" height="48" visible="true" />
+                <widget name="Data" x="64" y="2" width="100%-64" height="100%-2" visible="true">
+                    <prototype name="MenuCharacterData" />
+                </widget>
+            </widget>
+            <widget name="Slot2" x="0" y="33%+20" width="100%" height="64" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="16" y="0" width="42" height="48" visible="true" />
+                <widget name="Data" x="64" y="2" width="100%-64" height="100%-2" visible="true">
+                    <prototype name="MenuCharacterData" />
+                </widget>
+            </widget>
+            <widget name="Slot3" x="0" y="66%+20" width="100%" height="64" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="16" y="0" width="42" height="48" visible="true" />
+                <widget name="Data" x="64" y="2" width="100%-64" height="100%-2" visible="true">
+                    <prototype name="MenuCharacterData" />
+                </widget>
+            </widget>
+        </widget>
+        <widget name="Info" x="180" y="24" width="100%-180" height="56" visible="true">
+            <prototype name="Window" />
+            <widget name="Character" x="0" y="0" width="100%" height="100%" visible="false">
+                <widget name="Data" x="30" y="6" width="100%-30" height="100%-6" visible="true">
+                    <prototype name="MenuCharacterData" />
+                </widget>
+            </widget>
+        </widget>
+        <widget name="Reserve" x="180" y="80" width="100%-180" height="100%-80" visible="true">
+            <prototype name="Window" />
+            <widget name="Slot1" x="20" y="10" width="44" height="58" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="0" y="0" width="42" height="56" visible="true" />
+            </widget>
+            <widget name="Slot2" x="80" y="10" width="44" height="58" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="0" y="0" width="42" height="56" visible="true" />
+            </widget>
+            <widget name="Slot3" x="140" y="10" width="44" height="58" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="0" y="0" width="42" height="56" visible="true" />
+            </widget>
+            <widget name="Slot4" x="20" y="76" width="44" height="58" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="0" y="0" width="42" height="56" visible="true" />
+            </widget>
+            <widget name="Slot5" x="80" y="76" width="44" height="58" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="0" y="0" width="42" height="56" visible="true" />
+            </widget>
+            <widget name="Slot6" x="140" y="76" width="44" height="58" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="0" y="0" width="42" height="56" visible="true" />
+            </widget>
+            <widget name="Slot7" x="20" y="142" width="44" height="58" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="0" y="0" width="42" height="56" visible="true" />
+            </widget>
+            <widget name="Slot8" x="80" y="142" width="44" height="58" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="0" y="0" width="42" height="56" visible="true" />
+            </widget>
+            <widget name="Slot9" x="140" y="142" width="44" height="58" visible="true">
+                <sprite name="Portrait" image="other/choco.png" x="0" y="0" width="42" height="56" visible="true" />
+            </widget>
+        </widget>
+        <sprite name="Held" image="icons/cursor.png" x="-4" y="56" width="24" height="17" alpha="0.6" visible="false">
+            <animation name="Position1" length="0" x="0:-4" y="0:56" />
+            <animation name="Position2" length="0" x="0:-4" y="0:33%+48" />
+            <animation name="Position3" length="0" x="0:-4" y="0:66%+40" />
+            <animation name="Position4" length="0" x="0:178" y="0:106" />
+            <animation name="Position5" length="0" x="0:238" y="0:106" />
+            <animation name="Position6" length="0" x="0:298" y="0:106" />
+            <animation name="Position7" length="0" x="0:178" y="0:172" />
+            <animation name="Position8" length="0" x="0:238" y="0:172" />
+            <animation name="Position9" length="0" x="0:298" y="0:172" />
+            <animation name="Position10" length="0" x="0:178" y="0:238" />
+            <animation name="Position11" length="0" x="0:238" y="0:238" />
+            <animation name="Position12" length="0" x="0:298" y="0:238" />
+        </sprite>
+        <sprite name="Cursor" image="icons/cursor.png" x="-4" y="56" width="24" height="17" visible="true">
+            <animation name="Position1" length="0" x="0:-4" y="0:56" />
+            <animation name="Position2" length="0" x="0:-4" y="0:33%+48" />
+            <animation name="Position3" length="0" x="0:-4" y="0:66%+40" />
+            <animation name="Position4" length="0" x="0:178" y="0:106" />
+            <animation name="Position5" length="0" x="0:238" y="0:106" />
+            <animation name="Position6" length="0" x="0:298" y="0:106" />
+            <animation name="Position7" length="0" x="0:178" y="0:172" />
+            <animation name="Position8" length="0" x="0:238" y="0:172" />
+            <animation name="Position9" length="0" x="0:298" y="0:172" />
+            <animation name="Position10" length="0" x="0:178" y="0:238" />
+            <animation name="Position11" length="0" x="0:238" y="0:238" />
+            <animation name="Position12" length="0" x="0:298" y="0:238" />
+        </sprite>
+    </widget>
+</screen>

+ 11 - 0
data/data/screens/prototypes.xml

@@ -1,5 +1,16 @@
 <prototypes>
     <prototype name="Window">
+        <sprite name="Top" image="window/b_t.png" x="8" y="0" width="100%-16" height="8" visible="true" />
+        <sprite name="Bottom" image="window/b_b.png" x="8" y="100%-8" width="100%-16" height="8" visible="true" />
+        <sprite name="Left" image="window/b_l.png" x="0" y="8" width="8" height="100%-16" visible="true" />
+        <sprite name="Right" image="window/b_r.png" x="100%-8" y="8" width="8" height="100%-16" visible="true" />
+        <sprite name="TopLeft" image="window/c_tl.png" x="0" y="0" width="8" height="8" visible="true" />
+        <sprite name="TopRight" image="window/c_tr.png" x="100%-8" y="0" width="8" height="8" visible="true" />
+        <sprite name="BottomLeft" image="window/c_bl.png" x="0" y="100%-8" width="8" height="8" visible="true" />
+        <sprite name="BottomRight" image="window/c_br.png" x="100%-8" y="100%-8" width="8" height="8" visible="true" />
+        <sprite name="Center" x="3" y="2" width="100%-6" height="100%-5" colours="0 0 0.7,0 0 0.5,0 0 0.125,0 0 0.31" window_background="true" visible="true" />
+    </prototype>
+    <prototype name="WindowPreview">
         <sprite name="Top" image="window/b_t.png" x="8" y="0" width="100%-16" height="8" visible="true" />
         <sprite name="Bottom" image="window/b_b.png" x="8" y="100%-8" width="100%-16" height="8" visible="true" />
         <sprite name="Left" image="window/b_l.png" x="0" y="8" width="8" height="100%-16" visible="true" />

+ 15 - 0
data/data/screens/quit_menu/quit_menu.xml

@@ -0,0 +1,15 @@
+<screen name="QuitMenu" script="scripts/menu/quit_menu.lua" visible="false">
+    <widget name="Container" width="40%" height="40%" x="0" y="0" align="left" valign="top" scale="2.5 2.5" visible="true">
+        <widget name="Confirm" x="50" y="80" width="220" height="70" visible="true">
+            <prototype name="Window" />
+            <text_area name="Line1" text_name="QuitMenuLine1" x="12" y="8" font="FFVIIFont" visible="true" />
+            <text_area name="Line2" text_name="QuitMenuLine2" x="12" y="22" font="FFVIIFont" visible="true" />
+            <text_area name="Yes" text_name="Yes" x="95" y="38" font="FFVIIFont" visible="true" />
+            <text_area name="No" text_name="No" x="95" y="50" font="FFVIIFont" visible="true" />
+            <sprite name="Cursor" image="icons/cursor.png" x="68" y="36" width="24" height="17" visible="true">
+                <animation name="Position1" length="0" y="0:36" />
+                <animation name="Position2" length="0" y="0:48" />
+            </sprite>
+        </widget>
+    </widget>
+</screen>

+ 241 - 0
data/data/screens/status_menu/status_menu.xml

@@ -0,0 +1,241 @@
+<screen name="StatusMenu" script="scripts/menu/status_menu.lua" visible="false">
+    <widget name="Container" width="40%" height="40%" x="0" y="0" align="left" valign="top" scale="2.5 2.5" visible="true">
+        <widget name="Character" width="100%" height="70" visible="true">
+            <prototype name="Window" />
+            <sprite name="Portrait" image="other/choco.png" x="10" y="12" width="42" height="48" visible="true" />
+            <widget name="Data" x="66" y="14" width="100%-66" height="100%-14" visible="true">
+                <prototype name="MenuCharacterData" />
+            </widget>
+            <text_area name="ExpLbl" text_name="StatusMenuExp" x="170" y="6" font="FFVIIFont" visible="true" />
+            <text_area name="Exp" x="220" y="8" font="FFVIIMenuDigits" visible="true" />
+            <sprite name="NextLevelBar" image="icons/menu_bar.png" x="180" y="19" width="56" height="8" visible="true" />
+            <sprite name="NextLevelLinePart1" x="181" y="20" width="20" height="3" colours="0.5 0.5 0.5, 0.5 0.5 0.5, 0.5 0.125 0.125, 0.5 0.125 0.125" alpha="0.8" visible="false" />
+            <sprite name="NextLevelLinePart2" x="181" y="23" width="20" height="3" colours="0.5 0.125 0.125, 0.5 0.125 0.125, 0 0 0, 0 0 0" alpha="0.8" visible="false" />
+            <text_area name="NextLbl" text_name="StatusMenuNextLevel" x="170" y="32" font="FFVIIFont" visible="true" />
+            <text_area name="Next" x="220" y="34" font="FFVIIMenuDigits" visible="true" />
+            <text_area name="LimitLbl" text_name="StatusMenuLimitLevel" x="170" y="47" font="FFVIIFont" visible="true" />
+            <text_area name="LimitLevel" x="230" y="49" font="FFVIIMenuDigits" visible="true" />
+            <sprite name="LimitLevelBar" image="icons/menu_bar.png" x="259" y="48" width="56" height="8" visible="true" />
+            <sprite name="LimitLinePart1" x="260" y="49" width="25" height="3" colours="0.5 0.5 0.5, 0.5 0.5 0.5, 0.5 0.125 0.31, 0.5 0.125 0.31" alpha="0.8" visible="false" />
+            <sprite name="LimitLinePart2" x="260" y="52" width="25" height="3" colours="0.5 0.125 0.31, 0.5 0.125 0.31, 0 0 0, 0 0 0" alpha="0.8" visible="false" />
+        </widget>
+        <widget name="Title" x="100%-70" y="0" width="70" height="22" visible="true">
+            <prototype name="Window" />
+            <text_area name="Title" text_name="StatusMenuTitle" x="12" y="5" font="FFVIIFont" visible="true" />
+        </widget>
+        <widget name="Page1" x="0" y="70" width="100%" height="100%-70" visible="true">
+            <widget name="Body" x="0" y="0" width="100%" height="100%" visible="true">
+                <prototype name="Window" />
+            </widget>
+            <widget name="Attributes" x="0" y="0" width="150" height="100%" visible="true">
+                <text_area name="StrLbl" text_name="StatusMenuStr" x="28" y="8" font="FFVIIFont" visible="true" />
+                <text_area name="Str" x="100" y="9" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="DexLbl" text_name="StatusMenuDex" x="28" y="20" font="FFVIIFont" visible="true" />
+                <text_area name="Dex" x="100" y="21" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="VitLbl" text_name="StatusMenuVit" x="28" y="32" font="FFVIIFont" visible="true" />
+                <text_area name="Vit" x="100" y="33" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="MagLbl" text_name="StatusMenuMag" x="28" y="44" font="FFVIIFont" visible="true" />
+                <text_area name="Mag" x="100" y="45" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="SprLbl" text_name="StatusMenuSpr" x="28" y="56" font="FFVIIFont" visible="true" />
+                <text_area name="Spr" x="100" y="57" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="LckLbl" text_name="StatusMenuLck" x="28" y="68" font="FFVIIFont" visible="true" />
+                <text_area name="Lck" x="100" y="69" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="AtkLbl" text_name="StatusMenuAttack" x="28" y="85" font="FFVIIFont" visible="true" />
+                <text_area name="Atk" x="100" y="86" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="AtkPctLbl" text_name="StatusMenuAttackPct" x="28" y="97" font="FFVIIFont" visible="true" />
+                <text_area name="AtkPct" x="100" y="98" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="DefLbl" text_name="StatusMenuDefense" x="28" y="109" font="FFVIIFont" visible="true" />
+                <text_area name="Def" x="100" y="110" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="DefPctLbl" text_name="StatusMenuDefensePct" x="28" y="121" font="FFVIIFont" visible="true" />
+                <text_area name="DefPct" x="100" y="122" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="MAtkLbl" text_name="StatusMenuMagicAtk" x="28" y="133" font="FFVIIFont" visible="true" />
+                <text_area name="MAtk" x="100" y="134" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="MDefLbl" text_name="StatusMenuMagicDef" x="28" y="145" font="FFVIIFont" visible="true" />
+                <text_area name="MDef" x="100" y="146" font="FFVIIMenuDigits" visible="true" />
+                <text_area name="MDefPctLbl" text_name="StatusMenuMagicDefPct" x="28" y="157" font="FFVIIFont" visible="true" />
+                <text_area name="MDefPct" x="100" y="158" font="FFVIIMenuDigits" visible="true" />
+            </widget>
+            <widget name="Equipment" x="0" y="0" width="100%" height="100%" visible="true">
+                <widget name="Wpn" x="0" y="85" width="100%" height="32" visible="true">
+                    <sprite name="Icon" image="icons/item_weapon_0.png" x="162" y="-3" width="18" height="18" visible="true" />
+                    <text_area name="Lbl" x="196" y="0" font="FFVIIFont" visible="true" />
+                    <widget name="Slots" x="188" y="17" width="120" height="14" visible="true">
+                        <sprite name="Slot1" image="icons/materia_slot.png" x="0" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Slot2" image="icons/materia_slot.png" x="15" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Link1" image="icons/materia_slot_link.png" x="10" y="0" width="8" height="14" visible="true"/>
+                        <sprite name="Slot3" image="icons/materia_slot.png" x="30" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Slot4" image="icons/materia_slot.png" x="45" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Link2" image="icons/materia_slot_link.png" x="40" y="0" width="8" height="14" visible="true"/>
+                        <sprite name="Slot5" image="icons/materia_slot.png" x="60" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Slot6" image="icons/materia_slot.png" x="75" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Link3" image="icons/materia_slot_link.png" x="70" y="0" width="8" height="14" visible="true"/>
+                        <sprite name="Slot7" image="icons/materia_slot.png" x="90" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Slot8" image="icons/materia_slot.png" x="105" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Link4" image="icons/materia_slot_link.png" x="100" y="0" width="8" height="14" visible="true"/>
+                        <sprite name="Mat1" image="icons/materia_3.png" x="2" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat2" image="icons/materia_3.png" x="17" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat3" image="icons/materia_3.png" x="32" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat4" image="icons/materia_3.png" x="47" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat5" image="icons/materia_3.png" x="62" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat6" image="icons/materia_3.png" x="77" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat7" image="icons/materia_3.png" x="92" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat8" image="icons/materia_3.png" x="107" y="2" width="10" height="10" visible="true"/>
+                    </widget>
+                </widget>
+                <widget name="Arm" x="0" y="118" width="100%" height="32" visible="true">
+                    <sprite name="Icon" image="icons/item_armor.png" x="162" y="-3" width="18" height="18" visible="true" />
+                    <text_area name="Lbl" x="196" y="0" font="FFVIIFont" visible="true" />
+                    <widget name="Slots" x="188" y="17" width="120" height="14" visible="true">
+                        <sprite name="Slot1" image="icons/materia_slot.png" x="0" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Slot2" image="icons/materia_slot.png" x="15" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Link1" image="icons/materia_slot_link.png" x="10" y="0" width="8" height="14" visible="true"/>
+                        <sprite name="Slot3" image="icons/materia_slot.png" x="30" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Slot4" image="icons/materia_slot.png" x="45" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Link2" image="icons/materia_slot_link.png" x="40" y="0" width="8" height="14" visible="true"/>
+                        <sprite name="Slot5" image="icons/materia_slot.png" x="60" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Slot6" image="icons/materia_slot.png" x="75" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Link3" image="icons/materia_slot_link.png" x="70" y="0" width="8" height="14" visible="true"/>
+                        <sprite name="Slot7" image="icons/materia_slot.png" x="90" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Slot8" image="icons/materia_slot.png" x="105" y="0" width="14" height="14" visible="true"/>
+                        <sprite name="Link4" image="icons/materia_slot_link.png" x="100" y="0" width="8" height="14" visible="true"/>
+                        <sprite name="Mat1" image="icons/materia_3.png" x="2" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat2" image="icons/materia_3.png" x="17" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat3" image="icons/materia_3.png" x="32" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat4" image="icons/materia_3.png" x="47" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat5" image="icons/materia_3.png" x="62" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat6" image="icons/materia_3.png" x="77" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat7" image="icons/materia_3.png" x="92" y="2" width="10" height="10" visible="true"/>
+                        <sprite name="Mat8" image="icons/materia_3.png" x="107" y="2" width="10" height="10" visible="true"/>
+                    </widget>
+                </widget>
+                <widget name="Acc" x="0" y="150" width="100%" height="14" visible="true">
+                    <sprite name="Icon" image="icons/item_accessory.png" x="162" y="-3" width="18" height="18" visible="true" />
+                    <text_area name="Lbl" x="196" y="0" font="FFVIIFont" visible="true" />
+                </widget>
+            </widget>
+        </widget>
+        <widget name="Page2" x="0" y="70" width="100%" height="100%-70" visible="false">
+            <widget name="Body" x="0" y="0" width="100%" height="100%" visible="true">
+                <prototype name="Window" />
+            </widget>
+            <widget name="Table" x="0" y="0" width="100%" height="100%" visible="true">
+                <text_area name="Heading" text_name="StatusMenuElement" x="8" y="3" font="FFVIIFont" visible="true" />
+                <widget name="Attack" x="28" y="26" width="100%-28" height="42" visible="true">
+                    <text_area name="Lbl" text_name="StatusMenuAttackGroup" x="0" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name1" x="62" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name2" x="122" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name3" x="182" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name4" x="62" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name5" x="122" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name6" x="182" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name7" x="62" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name8" x="122" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name9" x="182" y="28" font="FFVIIFont" visible="true" />
+                </widget>
+                <widget name="Halve" x="28" y="74" width="100%-28" height="42" visible="true">
+                    <text_area name="Lbl" text_name="StatusMenuHalve" x="0" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name1" x="62" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name2" x="122" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name3" x="182" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name4" x="62" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name5" x="122" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name6" x="182" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name7" x="62" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name8" x="122" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name9" x="182" y="28" font="FFVIIFont" visible="true" />
+                </widget>
+                <widget name="Invalid" x="28" y="122" width="100%-28" height="42" visible="true">
+                    <text_area name="Lbl" text_name="StatusMenuInvalid" x="0" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name1" x="62" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name2" x="122" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name3" x="182" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name4" x="62" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name5" x="122" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name6" x="182" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name7" x="62" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name8" x="122" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name9" x="182" y="28" font="FFVIIFont" visible="true" />
+                </widget>
+                <widget name="Absorb" x="28" y="170" width="100%-28" height="42" visible="true">
+                    <text_area name="Lbl" text_name="StatusMenuAbsorb" x="0" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name1" x="62" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name2" x="122" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name3" x="182" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name4" x="62" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name5" x="122" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name6" x="182" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name7" x="62" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name8" x="122" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name9" x="182" y="28" font="FFVIIFont" visible="true" />
+                </widget>
+            </widget>
+        </widget>
+        <widget name="Page3" x="0" y="70" width="100%" height="100%-70" visible="false">
+            <widget name="Body" x="0" y="0" width="100%" height="100%" visible="true">
+                <prototype name="Window" />
+            </widget>
+            <widget name="Table" x="0" y="0" width="100%" height="100%" visible="true">
+                <widget name="Attack" x="16" y="4" width="100%-16" height="98" visible="true">
+                    <text_area name="Lbl" text_name="StatusMenuAttackGroup" x="0" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Effect" text_name="StatusMenuEffect" x="0" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name1" x="46" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name2" x="132" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name3" x="218" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name4" x="304" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name5" x="46" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name6" x="132" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name7" x="218" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name8" x="304" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name9" x="46" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name10" x="132" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name11" x="218" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name12" x="304" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name13" x="46" y="42" font="FFVIIFont" visible="true" />
+                    <text_area name="Name14" x="132" y="42" font="FFVIIFont" visible="true" />
+                    <text_area name="Name15" x="218" y="42" font="FFVIIFont" visible="true" />
+                    <text_area name="Name16" x="304" y="42" font="FFVIIFont" visible="true" />
+                    <text_area name="Name17" x="46" y="56" font="FFVIIFont" visible="true" />
+                    <text_area name="Name18" x="132" y="56" font="FFVIIFont" visible="true" />
+                    <text_area name="Name19" x="218" y="56" font="FFVIIFont" visible="true" />
+                    <text_area name="Name20" x="304" y="56" font="FFVIIFont" visible="true" />
+                    <text_area name="Name21" x="46" y="70" font="FFVIIFont" visible="true" />
+                    <text_area name="Name22" x="132" y="70" font="FFVIIFont" visible="true" />
+                    <text_area name="Name23" x="218" y="70" font="FFVIIFont" visible="true" />
+                    <text_area name="Name24" x="304" y="70" font="FFVIIFont" visible="true" />
+                    <text_area name="Name25" x="46" y="84" font="FFVIIFont" visible="true" />
+                    <text_area name="Name26" x="132" y="84" font="FFVIIFont" visible="true" />
+                </widget>
+                <widget name="Defend" x="16" y="110" width="100%-16" height="98" visible="true">
+                    <text_area name="Lbl" text_name="StatusMenuDefend" x="0" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Effect" text_name="StatusMenuEffect" x="0" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name1" x="46" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name2" x="132" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name3" x="218" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name4" x="304" y="0" font="FFVIIFont" visible="true" />
+                    <text_area name="Name5" x="46" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name6" x="132" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name7" x="218" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name8" x="304" y="14" font="FFVIIFont" visible="true" />
+                    <text_area name="Name9" x="46" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name10" x="132" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name11" x="218" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name12" x="304" y="28" font="FFVIIFont" visible="true" />
+                    <text_area name="Name13" x="46" y="42" font="FFVIIFont" visible="true" />
+                    <text_area name="Name14" x="132" y="42" font="FFVIIFont" visible="true" />
+                    <text_area name="Name15" x="218" y="42" font="FFVIIFont" visible="true" />
+                    <text_area name="Name16" x="304" y="42" font="FFVIIFont" visible="true" />
+                    <text_area name="Name17" x="46" y="56" font="FFVIIFont" visible="true" />
+                    <text_area name="Name18" x="132" y="56" font="FFVIIFont" visible="true" />
+                    <text_area name="Name19" x="218" y="56" font="FFVIIFont" visible="true" />
+                    <text_area name="Name20" x="304" y="56" font="FFVIIFont" visible="true" />
+                    <text_area name="Name21" x="46" y="70" font="FFVIIFont" visible="true" />
+                    <text_area name="Name22" x="132" y="70" font="FFVIIFont" visible="true" />
+                    <text_area name="Name23" x="218" y="70" font="FFVIIFont" visible="true" />
+                    <text_area name="Name24" x="304" y="70" font="FFVIIFont" visible="true" />
+                    <text_area name="Name25" x="46" y="84" font="FFVIIFont" visible="true" />
+                    <text_area name="Name26" x="132" y="84" font="FFVIIFont" visible="true" />
+                </widget>
+            </widget>
+        </widget>
+    </widget>
+</screen>

+ 4 - 0
data/data/scripts/_scripts.xml

@@ -7,6 +7,7 @@
     <script file="gamedata/attacks.lua" />
     <script file="gamedata/characters.lua" />
     <script file="gamedata/commands.lua" />
+    <script file="gamedata/effect_sprites.lua" />
     <script file="gamedata/growth.lua" />
     <script file="gamedata/items.lua" />
     <script file="gamedata/key_items.lua" />
@@ -15,6 +16,8 @@
     <script file="gamedata/weapons.lua" />
 
     <script file="scripts/system.lua" />
+    <script file="scripts/character_effects.lua" />
+    <script file="scripts/character_abilities.lua" />
     <script file="scripts/save.lua" />
 
     <!-- TODO: Don't always parse initial savemap when loading is implemented. -->
@@ -26,6 +29,7 @@
     <script file="scripts/battle_ai.lua" />
     <script file="scripts/battle/battle_ai_vm.lua" />
     <script file="scripts/menu/menu_helpers.lua" />
+    <script file="scripts/menu/menu_lib.lua" />
 
     <!-- TODO: Debugging pourposes only -->
     <script file="scripts/debug_data.lua" />

+ 25 - 0
data/data/scripts/battle.lua

@@ -103,6 +103,29 @@ Battle.Damage = {
 
 
 
+--- Is the ATB time stopped by the menus the player has open?
+--
+-- Depends on the ATB setting of the config menu (MenuSettings.config.atb):
+--  * 1, active: time never stops.
+--  * 2, recommended: time stops while a list (magic, summon, item) is open.
+--  * 3, wait: time stops while a list is open and while a target is being chosen.
+Battle.is_time_stopped = function()
+    local mode = (MenuSettings.config ~= nil) and MenuSettings.config.atb or 2
+    local ui = UiContainer.BattleUi
+    if mode == 1 or ui == nil then return false end
+    local state = ui.window_state
+    if mode == 2 then return state == "list" end
+    return state == "list" or state == "target"
+end
+
+--- Can the battle camera move by itself? (Camera angle: Auto in the config menu.)
+--
+-- TODO: The battle camera doesn't move during the battle yet. When it does, the movements of the
+-- camera (attacks, magic, summons) must only happen if this returns true.
+Battle.is_camera_auto = function()
+    return MenuSettings.config == nil or MenuSettings.config.camera ~= 2
+end
+
 Battle.logic_script = {
     unit_type = Battle.Type.LOGIC,
 
@@ -133,6 +156,8 @@ Battle.logic_script = {
     on_update = function( self )
         local timer = timer:get_game_time_total()
         local delta = timer - self.game_timer
+        -- The timers don't advance while the menus stop the ATB time.
+        if Battle.is_time_stopped() then delta = 0 end
 
         -- update timers
         for key, value in pairs( EntityContainer ) do

+ 44 - 12
data/data/scripts/battle/battle_ui.lua

@@ -262,6 +262,14 @@ UiContainer.BattleUi = {
     --- Current position for the cursor in the command window.
     cursor_command = 1,
 
+    --- Last command chosen by each party member (position in the command window), by party slot.
+    -- Used when the cursor memory is on.
+    memory_command = {},
+
+    --- Last position chosen by each party member in the lists (magic, summon, item), by party slot
+    -- and list kind: {first = first visible entry, cursor = row of the cursor}.
+    memory_list = {},
+
     --- Current position for the cursor in the list window.
     cursor_list = 1,
 
@@ -302,7 +310,8 @@ UiContainer.BattleUi = {
     --- Handles button events.
     --
     -- For the current submenu, handles directional keys, enter and escape events.
-    -- @param button Pressed button string key. "Up", "Left", "Enter" and "Escape" are handled.
+    -- @param button Game event (the directions, OK, cancel...) or, for the debug keys, the name of
+    -- the key.
     -- @param event Trigger event. Normally, "Press".
     on_button = function(self, button, event)
         if self.active == false then do return 0 end end
@@ -336,7 +345,7 @@ UiContainer.BattleUi = {
         if self.window_state == "command" then
             local command_count = #self.party[self.current_character_window].command
             local cursor = ui_manager:get_widget("BattleUi.Container.Bottom.Commands.Cursor")
-            if button == "Down" then
+            if button == Config.CONTROLS.DOWN then
                 if self.cursor_command < 4 then
                     -- If on the first three ones, always alow to go down to reach "Item"
                     self.cursor_command = self.cursor_command + 1
@@ -358,7 +367,7 @@ UiContainer.BattleUi = {
                     cursor:set_default_animation("Position" .. tostring(self.cursor_command))
                     audio_manager:play_sound("Cursor")
                 end
-            elseif button == "Up" then
+            elseif button == Config.CONTROLS.UP then
                 if self.cursor_command == 1 then
                     -- If on "Attack, always go to item
                     self.cursor_command = 4
@@ -382,7 +391,7 @@ UiContainer.BattleUi = {
                     cursor:set_default_animation("Position" .. tostring(self.cursor_command))
                     audio_manager:play_sound("Cursor")
                 end
-            elseif button == "Left" then
+            elseif button == Config.CONTROLS.LEFT then
                 if command_count > 4 then
                     if self.cursor_command > 4 then
                         self.cursor_command = self.cursor_command - 4
@@ -396,7 +405,7 @@ UiContainer.BattleUi = {
                         audio_manager:play_sound("Cursor")
                     end
                 end
-            elseif button == "Right" then
+            elseif button == Config.CONTROLS.RIGHT then
                 if command_count > 4 then
                     self.cursor_command = self.cursor_command + 4
                     if self.cursor_command > command_count then
@@ -417,6 +426,7 @@ UiContainer.BattleUi = {
                 audio_manager:play_sound("Cursor")
                 self:show_commands(self.command_queue[1])
             elseif button == Config.ACTIONS.CONFIRM then
+                self.memory_command[self.current_character_window] = self.cursor_command
                 -- Check if a menu must be shown
                 local cmd = Game.Commands[self.party[self.current_character_window].command[self.cursor_command]]
                 if cmd.menu == Battle.Menu.NONE then print("DIRECT COMMAND")
@@ -440,7 +450,7 @@ UiContainer.BattleUi = {
             if button == Config.ACTIONS.CANCEL then
                 audio_manager:play_sound("Cursor")
                 self:close_list()
-            elseif button == "Down" then
+            elseif button == Config.CONTROLS.DOWN then
                 if total <=  self.cursor_list + self.first_list - 1 then do return 0 end end
                 if self.cursor_list < 4 then
                     -- Move cursor down
@@ -453,7 +463,7 @@ UiContainer.BattleUi = {
                     self.first_list = self.first_list + 1
                     self:repopulate_list()
                 end
-            elseif button == "Up" then
+            elseif button == Config.CONTROLS.UP then
                 if self.cursor_list == 1 and self.first_list == 1 then do return 0 end end
                 if self.cursor_list > 1 then
                     -- Move cursor up
@@ -474,7 +484,7 @@ UiContainer.BattleUi = {
             if button == Config.ACTIONS.CANCEL then
                 audio_manager:play_sound("Cursor")
                 self:close_target()
-            elseif button == "Up" then
+            elseif button == Config.CONTROLS.UP then
                 if self.cursor_target_h == 2 then
                     -- Allies cursor, never scroll. Go un up or to the bottom
                     self.cursor_target_v = self.cursor_target_v - 1
@@ -517,7 +527,7 @@ UiContainer.BattleUi = {
                         end
                     end
                 end
-            elseif button == "Down" then
+            elseif button == Config.CONTROLS.DOWN then
                 if self.cursor_target_h == 2 then
                     -- Allies cursor, never scroll. Go un up or to the bottom
                     self.cursor_target_v = self.cursor_target_v + 1
@@ -560,7 +570,7 @@ UiContainer.BattleUi = {
                         end
                     end
                 end
-            elseif button == "Left" then
+            elseif button == Config.CONTROLS.LEFT then
                 if self.target_options.fixed_row == 0 and self.target_options.toggle_multiple == 0 then
                     -- If both rows are present and multiple toggling is not available...
                     if self.cursor_target_h == 2 then
@@ -600,7 +610,7 @@ UiContainer.BattleUi = {
                     elseif prev_mode == Battle.TargetType.SINGLE then self.target_type = Battle.TargetType.GROUP
                     end
                 end
-            elseif button == "Right" then
+            elseif button == Config.CONTROLS.RIGHT then
                 if self.target_options.fixed_row == 0 and self.target_options.toggle_multiple == 0 then
                     -- If both rows are present and multiple toggling is not available...
                     if self.cursor_target_h == 1 then
@@ -2438,7 +2448,6 @@ UiContainer.BattleUi = {
         -- Spells, summons, items and enemy attacks show their names.
         if action.attack_id ~= nil or action.item_id ~= nil then
             ui_manager:get_widget("BattleUi.Container.Message.Name"):set_text(attack.name)
-            UiContainer.BattleFx:cast(attack, actor_side, action.actor)
         end
         current.pending = 1
         local played = self:play_script(
@@ -2671,6 +2680,14 @@ UiContainer.BattleUi = {
             self.first_list = 1
             self.current_list = kind
             self.list_entries = self:build_list_entries(kind)
+            -- Start where the list was left, if the cursor memory is on.
+            if UiContainer.cursor_memory() then
+                local remembered = (self.memory_list[self.current_character_window] or {})[kind]
+                if remembered ~= nil and remembered.first + remembered.cursor - 1 <= #self.list_entries then
+                    self.first_list = remembered.first
+                    self.cursor_list = remembered.cursor
+                end
+            end
             self:populate_list()
             self.window_state = "list"
             ui_manager:get_widget("BattleUi.Container.Bottom.List"):set_visible(true)
@@ -2693,8 +2710,17 @@ UiContainer.BattleUi = {
         end
     end,
 
+    --- Remembers the position of the cursor in the current list, for the cursor memory.
+    remember_list = function(self)
+        local slot = self.current_character_window
+        if self.current_list == "none" or slot == nil then return end
+        self.memory_list[slot] = self.memory_list[slot] or {}
+        self.memory_list[slot][self.current_list] = { first = self.first_list, cursor = self.cursor_list }
+    end,
+
     --- Closes the currently opened list
     close_list = function(self)
+        self:remember_list()
         self.current_list = "none"
         self.window_state = "command"
         ui_manager:get_widget("BattleUi.Container.Bottom.List"):set_visible(false)
@@ -2708,6 +2734,7 @@ UiContainer.BattleUi = {
         local entry = nil
         if self.window_state == "list" then entry = self.list_entries[self.cursor_list + self.first_list - 1] end
         if entry == nil then do return end end
+        self:remember_list()
         -- Spells, summons and items that can't be used (not enough MP, no summons left...).
         if not entry.enabled then
             audio_manager:play_sound("Error")
@@ -3168,6 +3195,11 @@ UiContainer.BattleUi = {
         ui_manager:get_widget("BattleUi.Container.Bottom.Commands"):set_visible(false)
         ui_manager:get_widget("BattleUi.Container.Bottom.Commands"):set_width(60 * (1 + math.floor(total_commands / 4)))
         ui_manager:get_widget("BattleUi.Container.Bottom.Commands"):set_visible(true)
+        -- Start at the command chosen last time, if the cursor memory is on.
+        if UiContainer.cursor_memory() then
+            local remembered = self.memory_command[party_id]
+            if remembered ~= nil and remembered <= total_commands then self.cursor_command = remembered end
+        end
         -- Set and show cursor
         ui_manager:get_widget("BattleUi.Container.Bottom.Commands.Cursor"):set_default_animation("Position" .. tostring(self.cursor_command))
         ui_manager:get_widget("BattleUi.Container.Bottom.Commands.Cursor"):set_visible(true)

+ 159 - 0
data/data/scripts/character_abilities.lua

@@ -0,0 +1,159 @@
+--- Abilities of the characters that come from their equipped materia.
+
+--- Command of the Enemy Skill materia (see Game.Commands).
+Characters.E_SKILL_COMMAND = 13
+
+--- ID of the attack of the first enemy skill. There are 24, in the order of the Enemy Skill materia.
+Characters.FIRST_E_SKILL_ATTACK = 72
+
+--- Gets the level of a materia given its AP.
+--
+-- @param materia Definition of the materia (an entry of Game.Materia).
+-- @param ap AP of the materia.
+-- @return The level (1 or more), or 0 if the AP are less than needed for the first level.
+local function get_materia_level(materia, ap)
+    local level = 0
+    for l = 1, #materia.levels_ap do
+        if materia.levels_ap[l] <= ap then level = l end
+    end
+    return level
+end
+
+--- Gets the magic, summons and enemy skills a character can use with the materia that is equipped.
+--
+-- Every ability is listed once, even if more than one materia gives it.
+--
+-- @param char_id ID of the character.
+-- @return A table with these lists, sorted by attack ID:
+--  * magic: Attack IDs of the spells.
+--  * magic_groups: The same spells, in lists with the ones that come from the same materia. The
+--    groups are sorted by their first spell.
+--  * summon: Tables {id = attack ID, times = times it can be used per battle (255 is no limit)}.
+--  * e_skill: Attack IDs of the enemy skills that were learned.
+Characters.get_abilities = function(char_id)
+    local result = { magic = {}, magic_groups = {}, summon = {}, e_skill = {} }
+    local character = Characters[char_id]
+    if character == nil then return result end
+
+    local magic_seen = {}
+    local e_skill_seen = {}
+    local summon_by_id = {}
+
+    for _, part in ipairs({ "weapon", "armor" }) do
+        local equipment = character[part]
+        -- Slots are 0-7.
+        for slot = 0, 7 do
+            local equipped = equipment.materia[slot]
+            local materia = (equipped ~= nil) and Game.Materia[equipped.id] or nil
+            if materia ~= nil then
+                local level = get_materia_level(materia, equipped.ap or 0)
+                if materia.type == Materia.TYPE.MAGIC and materia.magic ~= nil then
+                    local group = {}
+                    for spell_level, spell in ipairs(materia.magic) do
+                        if level >= spell_level and not magic_seen[spell] then
+                            magic_seen[spell] = true
+                            result.magic[#result.magic + 1] = spell
+                            group[#group + 1] = spell
+                        end
+                    end
+                    if #group > 0 then result.magic_groups[#result.magic_groups + 1] = group end
+                elseif materia.type == Materia.TYPE.SUMMON and materia.summon ~= nil and level > 0 then
+                    local times = materia.times[level] or 1
+                    local existing = summon_by_id[materia.summon]
+                    if existing == nil then
+                        existing = { id = materia.summon, times = times }
+                        summon_by_id[materia.summon] = existing
+                        result.summon[#result.summon + 1] = existing
+                    elseif times > existing.times then
+                        existing.times = times
+                    end
+                elseif materia.type == Materia.TYPE.COMMAND and materia.command ~= nil then
+                    for _, command in ipairs(materia.command) do
+                        if command == Characters.E_SKILL_COMMAND and equipped.skills ~= nil then
+                            for skill = 1, 24 do
+                                local attack = Characters.FIRST_E_SKILL_ATTACK + skill - 1
+                                if equipped.skills[skill] == true and not e_skill_seen[attack] then
+                                    e_skill_seen[attack] = true
+                                    result.e_skill[#result.e_skill + 1] = attack
+                                end
+                            end
+                        end
+                    end
+                end
+            end
+        end
+    end
+
+    table.sort(result.magic)
+    table.sort(result.magic_groups, function(a, b) return a[1] < b[1] end)
+    table.sort(result.e_skill)
+    table.sort(result.summon, function(a, b) return a.id < b.id end)
+    return result
+end
+
+--- Status of death (see BattleStatus). The characters with no HP are dead.
+local STATUS_DEATH = 0
+
+--- Checks if an attack revives (cures the death status).
+local function revives(attack)
+    for _, effect in ipairs(attack.status or {}) do
+        -- Mode 1 is cure.
+        if effect.status == STATUS_DEATH and effect.mode == 1 then return true end
+    end
+    return false
+end
+
+--- Gets the maximum of a stat (HP or MP) of a character.
+-- @param stat The stat, with the fields max (if it was calculated) and base.
+local function get_stat_max(stat)
+    return stat.max or stat.base
+end
+
+--- Casts a spell from the menu, out of battle: the cure and life families.
+--
+-- The spell heals (or revives) the target and the caster pays the MP. If the spell can't be cast
+-- (not enough MP, the target is dead for a cure, alive for a life, or already has all its HP), it
+-- does nothing and costs nothing.
+--
+-- The amounts are the ones of the battle: cures heal the magic base times the power / 16, and life
+-- spells a fraction of the maximum HP (power / 32).
+--
+-- @param caster_id ID of the character that casts.
+-- @param attack_id ID of the attack (spell).
+-- @param target_id ID of the character that receives it.
+-- @return True if it was cast, and the HP that were restored.
+Characters.cast_menu_spell = function(caster_id, attack_id, target_id)
+    local attack = Game.Attacks[attack_id]
+    local caster = Characters[caster_id]
+    local target = Characters[target_id]
+    if attack == nil or caster == nil or target == nil then return false end
+
+    local cost = attack.mp or 0
+    if caster.stats.mp.current < cost then return false end
+
+    local hp = target.stats.hp
+    local hp_max = get_stat_max(hp)
+    local dead = hp.current <= 0
+    local power = attack.power or 0
+    local amount = 0
+
+    if revives(attack) then
+        -- Life: only works on dead characters. A quarter of the HP if the power is unknown.
+        if not dead then return false end
+        amount = hp_max * ((power > 0) and power or 8) / 32
+    elseif attack.dmg_formula == 2 and attack.dmg_modifier == 5 then
+        -- Cure: doesn't work on dead characters, or on the ones with all their HP.
+        if dead or hp.current >= hp_max then return false end
+        local mag = caster.stats.mag.total or caster.stats.mag.base or 1
+        local level = caster.level or 1
+        local base = 6 * (mag + ((mag + level) / 32) * ((mag * level) / 32))
+        amount = base * power / 16 * (15 + math.random()) / 16
+    else
+        return false
+    end
+
+    amount = math.max(1, math.min(math.floor(amount), hp_max - hp.current))
+    hp.current = hp.current + amount
+    caster.stats.mp.current = caster.stats.mp.current - cost
+    return true, amount
+end

+ 165 - 0
data/data/scripts/character_effects.lua

@@ -0,0 +1,165 @@
+--- Elemental and status effects a character gets from the equipment and the equipped materia.
+--
+-- Used by the status menu, and usable by anything that needs to know what a character resists or
+-- inflicts.
+
+--- Element IDs, as stored in the item and materia data.
+Characters.ELEMENT = {
+    FIRE = 0, ICE = 1, BOLT = 2, EARTH = 3, POISON = 4, GRAVITY = 5, WATER = 6, WIND = 7,
+    HOLY = 8, RESTORATIVE = 9, CUT = 10, HIT = 11, PUNCH = 12, SHOOT = 13, SHOUT = 14, HIDDEN = 15,
+}
+
+--- How an element affects a character. Lower is stronger.
+Characters.ELEMENT_MODE = {
+    ABSORB = 0,
+    NULLIFY = 1,
+    HALF = 2,
+}
+
+--- Status IDs, as stored in the item and materia data (bit position in the kernel).
+Characters.STATUS = {
+    DEATH = 0, NEAR_DEATH = 1, SLEEP = 2, POISON = 3, SADNESS = 4, FURY = 5, CONFUSION = 6,
+    SILENCE = 7, HASTE = 8, SLOW = 9, STOP = 10, FROG = 11, SMALL = 12, SLOW_NUMB = 13,
+    PETRIFY = 14, REGEN = 15, BARRIER = 16, MBARRIER = 17, REFLECT = 18, DUAL = 19, SHIELD = 20,
+    D_SENTENCE = 21, MANIPULATE = 22, BERSERK = 23, PEERLESS = 24, PARALYZED = 25, DARKNESS = 26,
+}
+
+--- Element used in the materia data for "no element".
+local NO_ELEMENT = Characters.ELEMENT.HIDDEN
+
+--- Materia level (1-based) for an amount of AP.
+local function materia_level(definition, ap)
+    local level = 0
+    for _, level_ap in ipairs(definition.levels_ap) do
+        if level_ap > ap then break end
+        level = level + 1
+    end
+    return math.max(level, 1)
+end
+
+--- Mode an Elemental materia gives to armor, by materia level.
+local function elemental_armor_mode(level)
+    if level >= 3 then return Characters.ELEMENT_MODE.ABSORB end
+    if level == 2 then return Characters.ELEMENT_MODE.NULLIFY end
+    return Characters.ELEMENT_MODE.HALF
+end
+
+--- Adds an element resistance, keeping the strongest one.
+local function add_resistance(defense, element, mode)
+    if mode == nil or mode > Characters.ELEMENT_MODE.HALF then return end
+    if defense[element] == nil or mode < defense[element] then
+        defense[element] = mode
+    end
+end
+
+--- Gets the materia linked to a slot.
+--
+-- @param item The weapon or armor definition.
+-- @param slot Slot of the item, 1-8.
+-- @return List of slots (1-8) linked to it.
+local function linked_slots(item, slot)
+    local linked = {}
+    -- slots[n] == 1 means that slot n is linked to slot n + 1.
+    if item.slots[slot] == 1 and #(item.slots) > slot then table.insert(linked, slot + 1) end
+    if slot > 1 and item.slots[slot - 1] == 1 then table.insert(linked, slot - 1) end
+    return linked
+end
+
+--- Applies the link materia (Elemental, Added Effect) of a weapon or armor.
+local function apply_link_materia(char_id, part, result)
+    local equipment = Characters[char_id][part]
+    local item = Game.Items[equipment.id]
+    if item == nil or item.slots == nil then return end
+    for slot = 1, #(item.slots) do
+        local equipped = equipment.materia[slot - 1]
+        local definition = equipped ~= nil and Game.Materia[equipped.id] or nil
+        if definition ~= nil and definition.link ~= nil then
+            local link = definition.link.id
+            for _, partner_slot in ipairs(linked_slots(item, slot)) do
+                local partner = equipment.materia[partner_slot - 1]
+                local partner_def = partner ~= nil and Game.Materia[partner.id] or nil
+                if partner_def ~= nil then
+                    if link == Materia.LINK.ELEMENTAL then
+                        for _, element in ipairs(partner_def.elements or {}) do
+                            if element ~= NO_ELEMENT then
+                                if part == "weapon" then
+                                    result.elements.attack[element] = true
+                                else
+                                    add_resistance(
+                                        result.elements.defense, element,
+                                        elemental_armor_mode(materia_level(definition, equipped.ap))
+                                    )
+                                end
+                            end
+                        end
+                    elseif link == Materia.LINK.ADDED_EFFECT then
+                        local target = (part == "weapon") and result.status.attack or result.status.defense
+                        for _, status in ipairs(partner_def.status or {}) do
+                            target[status] = true
+                        end
+                    end
+                end
+            end
+        end
+    end
+end
+
+--- Calculates the elemental and status effects of a character.
+--
+-- Takes into account the weapon, the armor, the accessory and the linked materia:
+--  * Weapon elements, and Elemental materia linked in the weapon: elements the attacks have.
+--  * Armor and accessory elements: resistances. Elemental materia linked in the armor gives half
+--    damage (level 1), nullifies (level 2) or absorbs (level 3 or higher).
+--  * Added Effect materia: weapon statuses are inflicted, armor statuses are protected against.
+--  * Accessory statuses: protected against.
+--
+-- @param char_id ID of the character.
+-- @return A table:
+--     elements = {attack = {[element] = true}, defense = {[element] = mode}}
+--     status = {attack = {[status] = true}, defense = {[status] = true}}
+Characters.get_effects = function(char_id)
+    local result = {
+        elements = {attack = {}, defense = {}},
+        status = {attack = {}, defense = {}},
+    }
+    local character = Characters[char_id]
+    if character == nil then return result end
+
+    -- Weapon
+    local weapon = Game.Items[character.weapon.id]
+    if weapon ~= nil then
+        for _, element in ipairs(weapon.elements or {}) do
+            result.elements.attack[element] = true
+        end
+    end
+
+    -- Armor
+    local armor = Game.Items[character.armor.id]
+    if armor ~= nil then
+        for _, element in ipairs(armor.elements or {}) do
+            add_resistance(result.elements.defense, element.id, element.mode)
+        end
+    end
+
+    -- Accessory
+    local accessory = character.accessory ~= nil and Game.Items[character.accessory] or nil
+    if accessory ~= nil then
+        for _, element in ipairs(accessory.elements or {}) do
+            if type(element) == "table" then
+                add_resistance(result.elements.defense, element.id, element.mode)
+            else
+                -- Older data without a mode.
+                add_resistance(result.elements.defense, element, Characters.ELEMENT_MODE.NULLIFY)
+            end
+        end
+        for _, status in ipairs(accessory.status or {}) do
+            result.status.defense[status] = true
+        end
+    end
+
+    -- Materia
+    apply_link_materia(char_id, "weapon", result)
+    apply_link_materia(char_id, "armor", result)
+
+    return result
+end

+ 2 - 33
data/data/scripts/config.lua

@@ -16,36 +16,5 @@ Config = {
         R2 = "R2",
         L1 = "L1",
         L2 = "L2",
-    },
-    WINDOW = {
-        TOP_LEFT = {
-            r = 0,
-            g = 0,
-            b = 178,
-        },
-        TOP_RIGHT = {
-            r = 0,
-            g = 0,
-            b = 127,
-        },
-        BOTTOM_RIGHT = {
-            r = 0,
-            g = 0,
-            b = 32,
-        },
-        BOTTOM_LEFT = {
-            r = 0,
-            g = 0,
-            b = 79,
-        },
-    },
-    CURSOR = "MEMORY",
-    ATB = "ACTIVE",
-    SPEED = {
-        BATTLE = 127,
-        BATTLE_MESSAGE = 127,
-        FIELD_MESSAGE = 127,
-    },
-    CAMERA_ANGLE = "AUTO",
-    MAGIC_ORDER = 1
-} 
+    }
+}

+ 2 - 1
data/data/scripts/debug_data.lua

@@ -44,8 +44,9 @@ Inventory.add_key_item(38)
 end]]
 Inventory.money = 1234567
 Inventory.sort(Inventory.ORDER.NAME)
-Characters[0].stats.hp.current = 12345
+--[[Characters[0].stats.hp.current = 12345
 Characters[0].stats.hp.base = 54321
+]]
 Characters[0].stats.dex.base = 11
 Characters[0].stats.dex.total = 11
 Characters[0].armor.id = 268

+ 5 - 0
data/data/scripts/field.lua

@@ -242,6 +242,11 @@ name_character = function(id)
         print("Tried to rename invalid character with ID " .. tostring(id))
         return 0
     end
+    -- While the name menu is not enabled, the character just gets its default name.
+    if not UiContainer.NameMenu.enabled then
+        UiContainer.NameMenu:apply_default_name(id)
+        return 0
+    end
     UiContainer.NameMenu.id = id
     UiContainer.NameMenu.chocobo = false
     script:request(Script.UI, "NameMenu", "show", 0)

+ 136 - 125
data/data/scripts/menu/begin_menu.lua

@@ -1,141 +1,152 @@
 if UiContainer == nil then UiContainer = {} end
 
-    --- The begin menu
-    UiContainer.BeginMenu = {
+--- The begin menu
+UiContainer.BeginMenu = UiContainer.Menu.new{
 
-        --- Current cursor position
-        position = 1,
+    id = "begin",
 
-        -- Max cursor position
-        position_total = 7,
+    --- Cursor over the entries.
+    cursor = UiContainer.Cursor.new{
+        widget = "BeginMenu.Container.Cursor",
+        total = 7,
+    },
 
-        --- Run when the menu is created.
-        --
-        -- Sets this menu as the one beind displayed.
-        on_start = function(self)
-        UiContainer.current_menu = "begin"
-        local cursor = ui_manager:get_widget("BeginMenu.Container.Cursor")
-        cursor:set_default_animation("Position" .. self.position)
+    --- Run when the menu is created.
+    --
+    -- Sets this menu as the one being displayed.
+    on_start = function(self)
+        self:activate()
+        self.cursor:refresh()
         return 0
+    end,
+
+    --- What each entry does. Indexed by cursor position.
+    actions = {
+        -- 1: New game
+        function(self)
+            audio_manager:play_sound("Window")
+            generate_control_key()
+            load_field_map_request("md1stin", "")
+            console("camera_free false")
+            --console("debug_walkmesh true")
+            script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
+            MenuSettings.pause_available = true
+        end,
+
+        -- 2: Load, show save menu.
+        function(self)
+            audio_manager:play_sound("Cursor")
+            UiContainer.SaveMenu.operation = "load"
+            script:request_end_sync(Script.UI, "SaveMenu", "show", 0)
+        end,
+
+        -- 3: NMKIN_3 test.
+        function(self)
+            Data.progress_game = 27
+            set_party(0, 1, -1)
+            load_field_map_request("nmkin_3", "")
+            console("camera_free false")
+            console("debug_walkmesh true")
+            script:wait(1)
+            script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
+            entity_manager:get_entity("Cloud"):set_position(-1.090750, 15.310307, 17.281244)
+            entity_manager:set_player_entity("Cloud")
+            script:wait(1)
+            entity_manager:get_player_entity():set_position(-1.093750, 15.320307, 17.281244)
+            entity_manager:get_entity("Cloud"):jump_to_position(0.46875, 14.9922, -1, 1, 42) -- Triangle 42, 2 steps.
+            entity_manager:get_entity("Cloud"):jump_sync()
+            entity_manager:player_lock(false)
+            MenuSettings.pause_available = true
+            -- NMKIN_4 test:
+            --[[Data.progress_game = 14
+            --load_field_map_request("nmkin_4", "")
+            map("nmkin_4")
+            console("camera_free false")
+            console("debug_walkmesh true")
+            script:wait(1.5)
+            script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
+            entity_manager:get_entity("Cloud"):set_position(-0.820312, 0.500000, 17.281244)
+            entity_manager:set_player_entity("Cloud")
+            set_party(0, 1, 4)
+            script:wait(0.5)
+            entity_manager:get_player_entity():set_position(-0.820312, 0.500000, 17.281244)
+            entity_manager:get_entity("Cloud"):jump_to_position(-0.800312, 0.500000, -1, 1) -- Triangle 42, 2 steps.
+            entity_manager:get_entity("Cloud"):jump_sync()
+            entity_manager:player_lock(false)
+            print("TRIANGLE " .. entity_manager:get_entity("Cloud"):get_move_triangle_id())
+            MenuSettings.pause_available = true]]
+        end,
+
+        -- 4: Walkmesh test map.
+        function(self)
+            script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
+            console("camera_free true")
+            console("debug_walkmesh true")
+            map("test_2")
+            MenuSettings.pause_available = true
+        end,
+
+        -- 5: World map.
+        function(self)
+            set_party(0, 2, 3)
+            script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
+            console("camera_free true")
+            console("debug_entity 2")
+            MenuSettings.pause_available = true
+            world_map_module:init()
+        end,
+
+        -- 6: Start map.
+        function(self)
+            load_field_map_request("startmap", "")
+            console("camera_free false")
+            console("debug_walkmesh false")
+            script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
+            MenuSettings.pause_available = true
         end,
 
+        -- 7: Test battle.
+        function(self)
+            --load_field_map_request("startmap", "")
+            console("camera_free false")
+            console("debug_walkmesh false")
+            script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
+            set_party(0, 2, 3)
+            Battle.start(372) -- Single level 2 MP, easy test battle
+            MenuSettings.pause_available = true
+        end,
+    },
 
-        --- Handles button events.
-        --
-        -- Handles directional Up, Down and Enter keys.
-        -- @param button Pressed direction or action name.
-        -- @param event Trigger event. Normally, "Press".
-        on_button = function(self, button, event)
-        if UiContainer.current_menu == "begin" then
-            local cursor = ui_manager:get_widget("BeginMenu.Container.Cursor")
+    --- Handles button events.
+    --
+    -- Handles directional Up, Down and Enter keys.
+    -- @param button Pressed direction or action name.
+    -- @param event Trigger event. Normally, "Press".
+    on_button = function(self, button, event)
+        if self:is_active() then
             if button == Config.ACTIONS.CONFIRM and event == "Press" then
-                if self.position == 1 then -- New game
-                    audio_manager:play_sound("Window")
-                    generate_control_key()
-                    load_field_map_request("md1stin", "")
-                    console("camera_free false")
-                    --console("debug_walkmesh true")
-                    script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
-                    MenuSettings.pause_available = true
-                    elseif self.position == 2 then -- Load, show save menu.
-                        audio_manager:play_sound("Cursor")
-                        UiContainer.SaveMenu.operation = "load"
-                        script:request_end_sync(Script.UI, "SaveMenu", "show", 0)
-                        elseif self.position == 3 then
-                            -- NMKIN_3 START
-                            Data.progress_game = 27
-                            load_field_map_request("nmkin_3", "")
-                            console("camera_free false")
-                            console("debug_walkmesh true")
-                            script:wait(1)
-                            script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
-                            entity_manager:get_entity("Cloud"):set_position(-1.090750, 15.310307, 17.281244)
-                            entity_manager:set_player_entity("Cloud")
-                            script:wait(1)
-                            entity_manager:get_player_entity():set_position(-1.093750, 15.320307, 17.281244)
-                            entity_manager:get_entity("Cloud"):jump_to_position(0.46875, 14.9922, -1, 1, 42) -- Triangle 42, 2 steps.
-                            entity_manager:get_entity("Cloud"):jump_sync()
-                            entity_manager:player_lock(false)
-                            MenuSettings.pause_available = true
-                            -- NMKIN_3 END
-                            -- NMKIN_4 START
-                            --[[Data.progress_game = 14
-                            --load_field_map_request("nmkin_4", "")
-                            map("nmkin_4")
-                            console("camera_free false")
-                            console("debug_walkmesh true")
-                            script:wait(1.5)
-                            script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
-                            entity_manager:get_entity("Cloud"):set_position(-0.820312, 0.500000, 17.281244)
-                            entity_manager:set_player_entity("Cloud")
-                            set_party(0, 1, 4)
-                            script:wait(0.5)
-                            entity_manager:get_player_entity():set_position(-0.820312, 0.500000, 17.281244)
-                            entity_manager:get_entity("Cloud"):jump_to_position(-0.800312, 0.500000, -1, 1) -- Triangle 42, 2 steps.
-                            entity_manager:get_entity("Cloud"):jump_sync()
-                            entity_manager:player_lock(false)
-                            print("TRIANGLE " .. entity_manager:get_entity("Cloud"):get_move_triangle_id())
-                            MenuSettings.pause_available = true]]
-                            -- NMKIN_4 END
-                            elseif self.position == 4 then
-                                script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
-                                console("camera_free true")
-                                console("debug_walkmesh true")
-                                map("test_2")
-                                MenuSettings.pause_available = true
-                                elseif self.position == 5 then
-                                    script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
-                                    console("camera_free true")
-                                    console("debug_entity 2")
-                                    MenuSettings.pause_available = true
-                                    world_map_module:init()
-                                    elseif self.position == 6 then
-                                        load_field_map_request("startmap", "")
-                                        console("camera_free false")
-                                        console("debug_walkmesh false")
-                                        script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
-                                        MenuSettings.pause_available = true
-                                        elseif self.position == 7 then
-                                            --load_field_map_request("startmap", "")
-                                            console("camera_free false")
-                                            console("debug_walkmesh false")
-                                            script:request_end_sync(Script.UI, "BeginMenu", "hide", 0)
-                                            set_party(0, 2, 3)
-                                            Battle.start(372) -- Single level 2 MP, easy test battle
-                                            MenuSettings.pause_available = true
-                                            end
-                                            elseif button == Config.CONTROLS.DOWN then
-                                                audio_manager:play_sound("Cursor")
-                                                self.position = self.position + 1
-                                                if self.position > self.position_total then
-                                                    self.position = 1;
-        end
-        cursor:set_default_animation("Position" .. self.position)
-        elseif button == Config.CONTROLS.UP then
-            audio_manager:play_sound("Cursor")
-            self.position = self.position - 1
-            if self.position <= 0 then
-                self.position = self.position_total;
-        end
-        cursor:set_default_animation("Position" .. self.position)
-        end
+                local action = self.actions[self.cursor:index()]
+                if action ~= nil then action(self) end
+            else
+                self.cursor:handle(button, "vertical")
+            end
         end
         return 0
-        end,
+    end,
 
-        --- Opens the save menu.
-        show = function(self)
-        UiContainer.current_menu = "begin"
+    --- Opens the begin menu.
+    show = function(self)
+        self:activate()
         ui_manager:get_widget("BeginMenu"):set_visible(true)
-        return 0;
-        end,
+        return 0
+    end,
 
-        --- Hiddens the begin menu.
-        --
-        -- It doesn't do anything else.
-        hide = function(self)
-        UiContainer.current_menu = ""
+    --- Hides the begin menu.
+    --
+    -- It doesn't do anything else.
+    hide = function(self)
+        self:deactivate("")
         ui_manager:get_widget("BeginMenu"):set_visible(false)
-        return 0;
-        end,
-    }
+        return 0
+    end,
+}

+ 687 - 0
data/data/scripts/menu/config_menu.lua

@@ -0,0 +1,687 @@
+if UiContainer == nil then UiContainer = {} end
+
+local ROOT = "ConfigMenu.Container."
+
+--- Range of the values of the red, green and blue sliders of the window colour.
+local COLOUR_MIN = 0
+local COLOUR_MAX = 255
+
+--- Step of the colour sliders: left and right, and L1 and R1 (big step).
+local COLOUR_STEP = 1
+local COLOUR_BIG_STEP = 16
+
+--- Position and width in pixels of the colour sliders, relative to the colour window.
+local SLIDER_X = 70
+local SLIDER_WIDTH = 92
+
+--- Corners of the window, in the order they are shown (2x2 grid), and the corner of the colours
+-- table (top-left, top-right, bottom-right, bottom-left) each one is.
+local CORNER_COLOUR = { 1, 2, 4, 3 }
+
+--- Actions the player can choose the keys of. The first entry is the button that restores the
+-- default keys.
+--
+--  * name: Name of the row widget (with the prefix "Row", because the window prototype has children
+--    called Left and Right) and of the texts.
+--  * events: Game events (see bind_game_event in config.cfg) that use the keys of the action. The
+--    default keys are the ones config.cfg gives to the first one (see get_default_keys).
+local CONTROLS = {
+    { name = "Default" },
+    { name = "Up", events = { "Up", "walk_up", "message_up" } },
+    { name = "Down", events = { "Down", "walk_down", "message_down" } },
+    { name = "Left", events = { "Left", "walk_left" } },
+    { name = "Right", events = { "Right", "walk_right" } },
+    { name = "Ok", events = { "CONFIRM" } },
+    { name = "Cancel", events = { "CANCEL" } },
+    { name = "Menu", events = { "MENU" } },
+    { name = "Extra", events = { "EXTRA" } },
+    { name = "Select", events = { "SELECT" } },
+    { name = "Start", events = { "START" } },
+    { name = "L1", events = { "L1" } },
+    { name = "R1", events = { "R1" } },
+    { name = "Run", events = { "run" } },
+}
+
+--- File where the keys are saved. The game runs it after config.cfg when it starts.
+local CONTROLS_FILE = "./controls.cfg"
+
+--- Key that cancels the choice of a key. It can't be assigned.
+local CAPTURE_CANCEL_KEY = "Escape"
+
+--- Maximum value of the volume sliders (0 is muted).
+local VOLUME_MAX = 4
+
+--- Range of the sliders. 0 is the left end (fast), SLIDER_MAX the right end (slow).
+local SLIDER_MAX = 10
+
+--- Types of row.
+local KIND = {
+    ACTION = "action",   -- Opens something when OK is pressed.
+    CHOICE = "choice",   -- Left/right choose one of several options.
+    SLIDER = "slider",   -- Left/right move a slider.
+    ORDER = "order",     -- Left/right choose one of the orders of the magic menu.
+}
+
+--- The rows of the menu, in the order they are shown.
+--
+--  * name: Name of the row widget.
+--  * kind: Type of row (KIND). Sound and controller open their own window, like window color.
+--  * key: Field of MenuSettings.config where the value is stored.
+--  * count: Number of options (choices and orders).
+local ROWS = {
+    { name = "WindowColor", kind = KIND.ACTION },
+    { name = "Sound", kind = KIND.ACTION },
+    { name = "Controller", kind = KIND.ACTION },
+    { name = "CursorMode", kind = KIND.CHOICE, key = "cursor", count = 2 },
+    { name = "Atb", kind = KIND.CHOICE, key = "atb", count = 3 },
+    { name = "BattleSpeed", kind = KIND.SLIDER, key = "battle_speed" },
+    { name = "BattleMessage", kind = KIND.SLIDER, key = "battle_message" },
+    { name = "FieldMessage", kind = KIND.SLIDER, key = "field_message" },
+    { name = "Movement", kind = KIND.CHOICE, key = "movement", count = 2 },
+    { name = "CameraAngle", kind = KIND.CHOICE, key = "camera", count = 2 },
+    { name = "MagicOrder", kind = KIND.ORDER, key = "magic_order", count = 6 },
+}
+
+--- Values of the options. Choices and orders start at 1, sliders at 0.
+--
+-- TODO: These are placeholders. They don't change the game yet. Each option must be connected to
+-- its setting as it is implemented.
+-- The settings are saved with the game (save_settings) and loaded with it (load_settings). The keys of
+-- the controller are not: they are saved in controls.cfg.
+MenuSettings.config = MenuSettings.config or {
+    cursor = 1,         -- 1: Initial, 2: Memory.
+    atb = 2,            -- 1: Active, 2: Recommended, 3: Wait.
+    battle_speed = 5,
+    battle_message = 5,
+    field_message = 5,
+    movement = 1,       -- 1: Walk, 2: Run.
+    camera = 1,         -- 1: Auto, 2: Fix.
+    magic_order = 1,    -- Permutations of restore, attack and indirect.
+
+    music_volume = 4,   -- 0-4.
+    sound_volume = 4,   -- 0-4. Sound effects.
+
+    --- Colours of the windows (red, green, blue, 0-255) at the top-left, top-right, bottom-right
+    -- and bottom-left corners. They are the ones the windows have in the XML.
+    window_colours = {
+        { 0, 0, 178 },
+        { 0, 0, 127 },
+        { 0, 0, 32 },
+        { 0, 0, 79 },
+    },
+}
+
+--- Gets the colours of the windows as the 12 numbers (0-1) the engine takes.
+local function window_colours_as_floats()
+    local values = {}
+    for _, colour in ipairs(MenuSettings.config.window_colours) do
+        for channel = 1, 3 do
+            values[#values + 1] = colour[channel] / 255
+        end
+    end
+    return values
+end
+
+--- Splits a list of keys separated by commas.
+local function split_keys(text)
+    local keys = {}
+    for key in string.gmatch(text, "[^,]+") do keys[#keys + 1] = key end
+    return keys
+end
+
+--- Gets the keys of an action of CONTROLS.
+local function get_keys(action)
+    return split_keys(input_manager:get_game_event_keys(action.events[1]))
+end
+
+--- Gets the default keys of an action of CONTROLS: the ones of config.cfg.
+local function get_default_keys(action)
+    return split_keys(input_manager:get_default_game_event_keys(action.events[1]))
+end
+
+--- Sets the keys of an action of CONTROLS, in all its game events.
+local function set_keys(action, keys)
+    local text = table.concat(keys, ",")
+    for _, event in ipairs(action.events) do
+        input_manager:set_game_event_keys(event, text)
+    end
+end
+
+--- Settings (fields of MenuSettings.config) saved with the game, apart from the window colours.
+-- The values are numbers: choices start at 1, and sliders and volumes at 0.
+local SAVED_SETTINGS = {
+    "music_volume", "sound_volume", "cursor", "atb", "battle_speed", "battle_message",
+    "field_message", "movement", "camera", "magic_order",
+}
+
+--- Handlers of the buttons that change the value of the colour under the cursor.
+local function colour_buttons()
+    local buttons = {}
+    buttons[Config.CONTROLS.LEFT] = function(self, event) self:change_colour(-COLOUR_STEP) end
+    buttons[Config.CONTROLS.RIGHT] = function(self, event) self:change_colour(COLOUR_STEP) end
+    buttons[Config.ACTIONS.L1] = function(self, event) self:change_colour(-COLOUR_BIG_STEP) end
+    buttons[Config.ACTIONS.R1] = function(self, event) self:change_colour(COLOUR_BIG_STEP) end
+    return buttons
+end
+
+--- Handlers of Left and Right, which change the value of the row under the cursor.
+local function value_buttons()
+    local buttons = {}
+    buttons[Config.CONTROLS.LEFT] = function(self, event) self:change_value(-1) end
+    buttons[Config.CONTROLS.RIGHT] = function(self, event) self:change_value(1) end
+    return buttons
+end
+
+--- The config menu.
+--
+-- Up and down choose a row, and left and right change its value right away. Window color, sound
+-- and controller open their own window with OK.
+--
+-- Sound: opens a window with two sliders, one for the volume of the music and another for the
+-- sound effects (0-4). The volume changes as soon as the slider moves.
+--
+-- Controller: opens a window with the keys of every action (the directions, OK, cancel...). OK on
+-- an action asks for the key to assign to it. If the key was used by another action, they swap
+-- their keys. The first entry restores the default keys. The changes are kept apart and are only
+-- applied (and saved in controls.cfg, which the game reads when it starts) when the window is
+-- closed, if they are valid (see controls_are_valid), so the player can't be left without a way to
+-- control the game.
+--
+-- Window color: the cursor goes to the preview of the window to the right, where one of the four
+-- corners is chosen. Each corner opens a window with three sliders (red, green and blue). The
+-- preview shows the changes, and the colours are applied to all the windows when the cursor goes
+-- back to the list.
+--
+-- TODO: All the options are placeholders for now.
+UiContainer.ConfigMenu = UiContainer.Menu.new{
+
+    id = "config",
+    parent = "main",
+
+    --- Cursor over the rows.
+    cursor = UiContainer.Cursor.new{
+        widget = ROOT .. "Cursor",
+        total = #ROWS,
+        on_move = function(position)
+            UiContainer.ConfigMenu:update_help(position)
+        end,
+    },
+
+    --- Cursor over the corners of the window preview (2x2 grid).
+    corner_cursor = UiContainer.Cursor.new{
+        widget = ROOT .. "CornerCursor",
+        total = 4,
+        sound = false,
+    },
+
+    --- Cursor over the actions of the controller window.
+    controller_cursor = UiContainer.Cursor.new{
+        widget = ROOT .. "ControllerWindow.Cursor",
+        total = #CONTROLS,
+    },
+
+    --- Action (entry of CONTROLS) that is waiting for a key.
+    assigning = nil,
+
+    --- Keys the player has chosen in the controller window, not applied yet. Lists of key names by
+    -- position in CONTROLS.
+    pending_keys = {},
+
+    --- Cursor over the music and sound effects sliders.
+    sound_cursor = UiContainer.Cursor.new{
+        widget = ROOT .. "SoundWindow.Cursor",
+        total = 2,
+    },
+
+    --- Cursor over the red, green and blue sliders.
+    channel_cursor = UiContainer.Cursor.new{
+        widget = ROOT .. "ColourWindow.Cursor",
+        total = 3,
+    },
+
+    states = {
+        [""] = {
+            cursor = function(self) return self.cursor end,
+            axis = "vertical",
+            buttons = value_buttons(),
+            confirm = function(self, cursor)
+                local row = ROWS[cursor:index()]
+                if row.name == "WindowColor" then
+                    self.corner_cursor:set(1)
+                    ui_manager:get_widget(ROOT .. "CornerCursor"):set_visible(true)
+                    self:set_state("corner")
+                    return
+                elseif row.name == "Sound" then
+                    self.sound_cursor:set(1)
+                    self:refresh_sound_window()
+                    ui_manager:get_widget(ROOT .. "SoundWindow"):set_visible(true)
+                    self:set_state("sound")
+                    return
+                elseif row.name == "Controller" then
+                    self.controller_cursor:set(1)
+                    self:load_pending_keys()
+                    self:refresh_controller_window()
+                    ui_manager:get_widget(ROOT .. "ControllerWindow"):set_visible(true)
+                    self:set_state("controller")
+                    return
+                elseif row.kind == KIND.ACTION then
+                    print("TODO: Open the window of " .. row.name)
+                end
+                -- The other rows change with left and right.
+                return false
+            end,
+            cancel = function(self)
+                script:request_end_sync(Script.UI, "ConfigMenu", "hide", 0)
+            end,
+        },
+
+        --- Choosing the action to change the key of.
+        controller = {
+            cursor = function(self) return self.controller_cursor end,
+            axis = "vertical",
+            confirm = function(self, cursor)
+                local index = cursor:index()
+                if index == 1 then
+                    self:reset_controls()
+                    self:refresh_controller_window()
+                    return
+                end
+                -- The next key pressed is the new key of the action.
+                self.assigning = index
+                input_manager:capture_next_key()
+                self:set_cursor_blinking(true)
+                self:update_help(#ROWS + 1)
+                self:set_state("assign")
+            end,
+            cancel = function(self)
+                -- The keys are only applied if they are valid. If not, the window stays open.
+                if not self:controls_are_valid() then return false end
+                ui_manager:get_widget(ROOT .. "ControllerWindow"):set_visible(false)
+                self:apply_controls()
+                self:set_state("")
+            end,
+        },
+
+        --- Waiting for the key to assign to an action.
+        assign = {
+            capture = function(self, key)
+                if key == "UNASSIGNED" then
+                    -- A key the game doesn't know: ask again.
+                    UiContainer.play_sound("Error")
+                    input_manager:capture_next_key()
+                    return
+                elseif key == CAPTURE_CANCEL_KEY then
+                    UiContainer.play_sound("Back")
+                else
+                    self:assign_key(self.assigning, key)
+                    UiContainer.play_sound("Cursor")
+                    self:refresh_controller_window()
+                end
+                self:set_cursor_blinking(false)
+                self:update_help(self.cursor:index())
+                self:set_state("controller")
+            end,
+        },
+
+        --- Changing the volume of the music and of the sound effects.
+        sound = {
+            cursor = function(self) return self.sound_cursor end,
+            axis = "vertical",
+            buttons = {
+                [Config.CONTROLS.LEFT] = function(self, event) self:change_volume(-1) end,
+                [Config.CONTROLS.RIGHT] = function(self, event) self:change_volume(1) end,
+            },
+            confirm = function(self)
+                ui_manager:get_widget(ROOT .. "SoundWindow"):set_visible(false)
+                self:set_state("")
+            end,
+            cancel = function(self)
+                ui_manager:get_widget(ROOT .. "SoundWindow"):set_visible(false)
+                self:set_state("")
+            end,
+        },
+
+        --- Choosing the corner of the window to change.
+        corner = {
+            buttons = UiContainer.grid_buttons("corner_cursor", 2),
+            confirm = function(self)
+                self.channel_cursor:set(1)
+                self:refresh_colour_window()
+                ui_manager:get_widget(ROOT .. "ColourWindow"):set_visible(true)
+                self:set_state("colour")
+            end,
+            cancel = function(self)
+                -- Back to the list: the colours are applied now.
+                ui_manager:get_widget(ROOT .. "CornerCursor"):set_visible(false)
+                self:apply_window_colours()
+                self:set_state("")
+            end,
+        },
+
+        --- Changing the red, green and blue of a corner.
+        colour = {
+            cursor = function(self) return self.channel_cursor end,
+            axis = "vertical",
+            buttons = colour_buttons(),
+            confirm = function(self)
+                ui_manager:get_widget(ROOT .. "ColourWindow"):set_visible(false)
+                self:set_state("corner")
+            end,
+            cancel = function(self)
+                ui_manager:get_widget(ROOT .. "ColourWindow"):set_visible(false)
+                self:set_state("corner")
+            end,
+        },
+    },
+
+    --- Makes the cursor of the controller window blink (while a key is asked for), or stop.
+    --
+    -- @param blinking True to blink, false to go back to the fixed cursor.
+    set_cursor_blinking = function(self, blinking)
+        local cursor = ui_manager:get_widget(ROOT .. "ControllerWindow.Cursor")
+        local animation = (blinking and "Blink" or "Position") .. tostring(self.controller_cursor:index())
+        cursor:set_default_animation(animation)
+        cursor:play_animation(animation)
+        -- The blink leaves it transparent when it stops at the wrong moment.
+        if not blinking then cursor:set_alpha(1.0) end
+    end,
+
+    --- Shows the keys of every action in the controller window.
+    refresh_controller_window = function(self)
+        for index = 2, #CONTROLS do
+            local widget = ui_manager:get_widget(ROOT .. "ControllerWindow.Row" .. CONTROLS[index].name .. ".Keys")
+            widget:set_colour(1.0, 1.0, 1.0)
+            widget:set_text(table.concat(self.pending_keys[index], ", "))
+        end
+    end,
+
+    --- Copies the keys the actions have now to the pending keys.
+    load_pending_keys = function(self)
+        self.pending_keys = {}
+        for index = 2, #CONTROLS do
+            self.pending_keys[index] = get_keys(CONTROLS[index])
+        end
+    end,
+
+    --- Checks that the pending keys leave the game controllable.
+    --
+    -- Every action must have at least one key, and no key can be used by two actions.
+    --
+    -- @return True if they can be applied.
+    controls_are_valid = function(self)
+        local used = {}
+        for index = 2, #CONTROLS do
+            local keys = self.pending_keys[index]
+            if keys == nil or #keys == 0 then return false end
+            for _, key in ipairs(keys) do
+                if used[key] then return false end
+                used[key] = true
+            end
+        end
+        return true
+    end,
+
+    --- Applies the pending keys to the game and saves them.
+    apply_controls = function(self)
+        for index = 2, #CONTROLS do
+            set_keys(CONTROLS[index], self.pending_keys[index])
+        end
+        self:save_controls()
+    end,
+
+    --- Gives a key to an action, in the pending keys.
+    --
+    -- The action keeps only that key. If another action had it, it loses it and, if it was its
+    -- only key, it takes the keys the action had before (they swap).
+    --
+    -- @param index Position of the action in CONTROLS.
+    -- @param key Name of the key.
+    assign_key = function(self, index, key)
+        local previous = self.pending_keys[index]
+        for other_index = 2, #CONTROLS do
+            if other_index ~= index then
+                local keys = self.pending_keys[other_index]
+                for position, existing in ipairs(keys) do
+                    if existing == key then
+                        table.remove(keys, position)
+                        if #keys == 0 then
+                            keys = (#previous > 0) and previous or get_default_keys(CONTROLS[other_index])
+                        end
+                        self.pending_keys[other_index] = keys
+                        break
+                    end
+                end
+            end
+        end
+        self.pending_keys[index] = { key }
+    end,
+
+    --- Puts the default keys of all the actions in the pending keys.
+    reset_controls = function(self)
+        for index = 2, #CONTROLS do
+            self.pending_keys[index] = get_default_keys(CONTROLS[index])
+        end
+    end,
+
+    --- Saves the keys in the controls file, which the game runs when it starts.
+    save_controls = function(self)
+        local file = io.open(CONTROLS_FILE, "w")
+        if file == nil then
+            print("Can't write the controller settings in " .. CONTROLS_FILE)
+            return
+        end
+        for index = 2, #CONTROLS do
+            local action = CONTROLS[index]
+            local keys = table.concat(self.pending_keys[index], ",")
+            for _, event in ipairs(action.events) do
+                file:write('set_game_event_keys "' .. event .. '" "' .. keys .. '"\n')
+            end
+        end
+        file:close()
+    end,
+
+    --- Changes the volume (music or sound effects, the one under the cursor).
+    --
+    -- @param delta -1 for left, 1 for right.
+    change_volume = function(self, delta)
+        local key = (self.sound_cursor:index() == 1) and "music_volume" or "sound_volume"
+        local values = MenuSettings.config
+        local value = math.max(0, math.min(VOLUME_MAX, values[key] + delta))
+        if value == values[key] then return end
+        values[key] = value
+        self:apply_volumes()
+        self:refresh_sound_window()
+        -- Played after applying the volume, so the sound effect is heard with the new volume.
+        UiContainer.play_sound("Cursor")
+    end,
+
+    --- Gives the settings to the savemap, to be saved with the game. See save_game in save.lua.
+    save_settings = function(self)
+        local config = MenuSettings.config
+        for _, key in ipairs(SAVED_SETTINGS) do
+            savemap_manager:set_setting(key, config[key])
+        end
+        local c = config.window_colours
+        savemap_manager:set_window_colours(
+            c[1][1], c[1][2], c[1][3], c[2][1], c[2][2], c[2][3],
+            c[3][1], c[3][2], c[3][3], c[4][1], c[4][2], c[4][3]
+        )
+    end,
+
+    --- Loads the settings of a saved game and applies them. See load_game in save.lua.
+    --
+    -- Games saved before the settings were saved don't have them, and don't change the ones the
+    -- player has. Values that are out of range (a damaged file) are brought inside it.
+    --
+    -- @param slot Slot of the saved game (0-15).
+    load_settings = function(self, slot)
+        if not savemap_manager:is_slot_setting_set(slot, SAVED_SETTINGS[1]) then return end
+        local config = MenuSettings.config
+        -- Range of the values of each setting.
+        local ranges = {
+            music_volume = { 0, VOLUME_MAX }, sound_volume = { 0, VOLUME_MAX },
+        }
+        for _, row in ipairs(ROWS) do
+            if row.kind == KIND.SLIDER then ranges[row.key] = { 0, SLIDER_MAX }
+            elseif row.key ~= nil then ranges[row.key] = { 1, row.count } end
+        end
+        for _, key in ipairs(SAVED_SETTINGS) do
+            if savemap_manager:is_slot_setting_set(slot, key) then
+                local range = ranges[key]
+                config[key] = math.max(range[1], math.min(range[2], savemap_manager:get_slot_setting(slot, key)))
+            end
+        end
+        -- Corners: top-left, top-right, bottom-right and bottom-left, as in the savemap.
+        for corner = 1, 4 do
+            for channel = 1, 3 do
+                config.window_colours[corner][channel] = math.max(COLOUR_MIN, math.min(COLOUR_MAX,
+                    savemap_manager:get_slot_window_colour(slot, corner - 1, channel - 1)))
+            end
+        end
+        self:apply_all()
+    end,
+
+    --- Applies all the settings that act on the game right away: volumes, movement and the
+    -- colours of the windows. The rest are read by the game when it needs them.
+    apply_all = function(self)
+        self:apply_volumes()
+        self:apply_movement()
+        self:apply_window_colours()
+    end,
+
+    --- Applies the movement setting (walk or run by default) to the player.
+    apply_movement = function(self)
+        entity_manager:set_player_default_run(MenuSettings.config.movement == 2)
+    end,
+
+    --- Applies the volumes to the audio.
+    apply_volumes = function(self)
+        audio_manager:set_music_volume(MenuSettings.config.music_volume / VOLUME_MAX)
+        audio_manager:set_sound_volume(MenuSettings.config.sound_volume / VOLUME_MAX)
+    end,
+
+    --- Shows the position of the volume sliders.
+    refresh_sound_window = function(self)
+        local values = MenuSettings.config
+        ui_manager:get_widget(ROOT .. "SoundWindow.Music.Handle")
+            :set_default_animation("Position" .. tostring(values.music_volume))
+        ui_manager:get_widget(ROOT .. "SoundWindow.Effects.Handle")
+            :set_default_animation("Position" .. tostring(values.sound_volume))
+    end,
+
+    --- Colour (entry of MenuSettings.config.window_colours) of the corner under the cursor.
+    current_colour = function(self)
+        return MenuSettings.config.window_colours[CORNER_COLOUR[self.corner_cursor:index()]]
+    end,
+
+    --- Changes the red, green or blue (the one under the cursor) of the corner.
+    --
+    -- @param delta Amount added to the value.
+    change_colour = function(self, delta)
+        local colour = self:current_colour()
+        local channel = self.channel_cursor:index()
+        local value = math.max(COLOUR_MIN, math.min(COLOUR_MAX, colour[channel] + delta))
+        if value == colour[channel] then return end
+        colour[channel] = value
+        self:refresh_colour_window()
+        self:refresh_preview()
+    end,
+
+    --- Shows the sliders of the corner under the cursor.
+    refresh_colour_window = function(self)
+        local colour = self:current_colour()
+        local names = { "Red", "Green", "Blue" }
+        for channel = 1, 3 do
+            local path = ROOT .. "ColourWindow." .. names[channel]
+            local value = colour[channel]
+            ui_manager:get_widget(path .. ".Value"):set_text(UiContainer.pad_value(value, 3))
+            local offset = math.floor((value - COLOUR_MIN) / (COLOUR_MAX - COLOUR_MIN) * SLIDER_WIDTH)
+            ui_manager:get_widget(path .. ".Handle"):set_x(0, SLIDER_X + offset)
+        end
+    end,
+
+    --- Shows the chosen colours in the preview of the window.
+    refresh_preview = function(self)
+        local v = window_colours_as_floats()
+        ui_manager:get_widget(ROOT .. "Body.WindowColor.Preview.Center"):set_colours(
+            v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11], v[12])
+    end,
+
+    --- Applies the chosen colours to all the windows.
+    apply_window_colours = function(self)
+        local v = window_colours_as_floats()
+        ui_manager:set_window_colours(
+            v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11], v[12])
+    end,
+
+    --- Shows only the help text of a row.
+    update_help = function(self, position)
+        -- There is one more help text than rows: the one shown while a key is asked for.
+        for index = 1, #ROWS + 1 do
+            ui_manager:get_widget(ROOT .. "Help.H" .. tostring(index)):set_visible(index == position)
+        end
+    end,
+
+    --- Changes the value of the row under the cursor.
+    --
+    -- @param delta -1 for left, 1 for right.
+    change_value = function(self, delta)
+        local row = ROWS[self.cursor:index()]
+        if row.kind == KIND.ACTION then return end
+        local values = MenuSettings.config
+        local min = (row.kind == KIND.SLIDER) and 0 or 1
+        local max = (row.kind == KIND.SLIDER) and SLIDER_MAX or row.count
+        local value = values[row.key] + delta
+        if value < min or value > max then return end
+        values[row.key] = value
+        if row.key == "movement" then self:apply_movement() end
+        UiContainer.play_sound("Cursor")
+        self:refresh_row(row)
+    end,
+
+    --- Shows the value of a row.
+    refresh_row = function(self, row)
+        local path = ROOT .. "Body." .. row.name
+        local value = MenuSettings.config[row.key]
+        if row.kind == KIND.CHOICE then
+            -- Every option has a white copy (shown when selected) and a gray one (shown otherwise).
+            -- The gray one uses its own text ("...Off") in the texts file, because the colour
+            -- attribute of the XML is not applied to texts that come from the texts file.
+            for option = 1, row.count do
+                local selected = (option == value)
+                ui_manager:get_widget(path .. ".Opt" .. tostring(option) .. "On"):set_visible(selected)
+                ui_manager:get_widget(path .. ".Opt" .. tostring(option) .. "Off"):set_visible(not selected)
+            end
+        elseif row.kind == KIND.SLIDER then
+            ui_manager:get_widget(path .. ".Handle"):set_default_animation("Position" .. tostring(value))
+        elseif row.kind == KIND.ORDER then
+            ui_manager:get_widget(path .. ".Number"):set_text(tostring(value))
+            for order = 1, row.count do
+                ui_manager:get_widget(path .. ".Order" .. tostring(order)):set_visible(order == value)
+            end
+        end
+    end,
+
+    --- Opens the config menu.
+    show = function(self)
+        self:activate()
+        for _, row in ipairs(ROWS) do
+            if row.kind ~= KIND.ACTION then self:refresh_row(row) end
+        end
+        self.cursor:set(1)
+        ui_manager:get_widget(ROOT .. "CornerCursor"):set_visible(false)
+        ui_manager:get_widget(ROOT .. "ColourWindow"):set_visible(false)
+        ui_manager:get_widget(ROOT .. "SoundWindow"):set_visible(false)
+        ui_manager:get_widget(ROOT .. "ControllerWindow"):set_visible(false)
+        self:refresh_preview()
+        ui_manager:get_widget("ConfigMenu"):set_visible(true)
+        return 0
+    end,
+
+    --- Closes the config menu and goes back to the main menu.
+    hide = function(self)
+        ui_manager:get_widget("ConfigMenu"):set_visible(false)
+        self:deactivate()
+        return 0
+    end,
+}

+ 20 - 13
data/data/scripts/menu/equip_menu.lua

@@ -111,7 +111,7 @@ UiContainer.EquipMenu = {
                 elseif button == Config.CONTROLS.DOWN then
                     audio_manager:play_sound("Cursor")
                     -- Move one position down only if there is a next item
-                    if #(list) <= self.list_item_selected + 1 then
+                    if #(list) <= self.list_item_selected then
                         return 0
                     end
                     self.list_item_selected = self.list_item_selected + 1
@@ -150,11 +150,11 @@ UiContainer.EquipMenu = {
                     -- Equip the seleced item
                     local item_id = nil
                     if self.selecting_slot == Inventory.ITEM_TYPE.WEAPON then
-                        item_id = self.avail_weapons[self.list_item_selected + 1]
+                        item_id = self.avail_weapons[self.list_item_selected]
                     elseif self.selecting_slot == Inventory.ITEM_TYPE.ARMOR then
-                        item_id = self.avail_armors[self.list_item_selected + 1]
+                        item_id = self.avail_armors[self.list_item_selected]
                     elseif self.selecting_slot == Inventory.ITEM_TYPE.ACCESSORY then
-                        item_id = self.avail_accessories[self.list_item_selected + 1]
+                        item_id = self.avail_accessories[self.list_item_selected]
                     end
                     if Characters.equip(self.char_id, item_id) == false then
                         audio_manager:play_sound("Error")
@@ -258,8 +258,8 @@ UiContainer.EquipMenu = {
             return false
         end
         for i = 1, self.list_position_total do
-            if #(list) >= i + self.list_first_visible then
-                local name = Game.Items[list[i + self.list_first_visible]].name
+            if #(list) >= i + self.list_first_visible - 1 then
+                local name = Game.Items[list[i + self.list_first_visible - 1]].name
                 ui_manager:get_widget("EquipMenu.Container.List.Item" .. tostring(i)):set_text(name)
             else
                 ui_manager:get_widget("EquipMenu.Container.List.Item" .. tostring(i)):set_text("")
@@ -274,8 +274,10 @@ UiContainer.EquipMenu = {
         self.avail_weapons = {}
         self.avail_armors = {}
         self.avail_accessories = {}
-        for _, item in ipairs(Inventory) do
-            if item.item ~= nil then
+        -- The inventory can have empty slots in between, so ipairs() can't be used.
+        for slot = 1, MAX_INVENTORY_SLOTS do
+            local item = Inventory[slot]
+            if item ~= nil and item.item ~= nil then
                 local item_type = Game.Items[item.item].type
                 if item_type == Inventory.ITEM_TYPE.WEAPON or item_type == Inventory.ITEM_TYPE.ARMOR or item_type == Inventory.ITEM_TYPE.ACCESSORY then
                     local equip = Game.Items[item.item].users
@@ -338,13 +340,13 @@ UiContainer.EquipMenu = {
         else
             -- Selecting from the list
             if self.selecting_slot == Inventory.ITEM_TYPE.WEAPON then
-                item = Game.Items[self.avail_weapons[self.list_item_selected + 1]]
+                item = Game.Items[self.avail_weapons[self.list_item_selected]]
                 type = Inventory.ITEM_TYPE.WEAPON
             elseif self.selecting_slot == Inventory.ITEM_TYPE.ARMOR then
-                item = Game.Items[self.avail_armors[self.list_item_selected + 1]]
+                item = Game.Items[self.avail_armors[self.list_item_selected]]
                 type = Inventory.ITEM_TYPE.ARMOR
             elseif self.selecting_slot == Inventory.ITEM_TYPE.ACCESSORY then
-                item = Game.Items[self.avail_accessories[self.list_item_selected + 1]]
+                item = Game.Items[self.avail_accessories[self.list_item_selected]]
                 type = Inventory.ITEM_TYPE.ACCESSORY
             end
         end
@@ -360,6 +362,10 @@ UiContainer.EquipMenu = {
             ui_manager:get_widget("EquipMenu.Container.SlotsEmpty"):set_visible(true)
             return 0
         end
+        if item == nil then
+            -- Nothing to show details for (e.g. empty slot).
+            return 0
+        end
         ui_manager:get_widget("EquipMenu.Container.SlotsEmpty"):set_visible(false)
         ui_manager:get_widget("EquipMenu.Container.Slots.Growth0"):set_visible(false)
         ui_manager:get_widget("EquipMenu.Container.Slots.Growth1"):set_visible(false)
@@ -397,10 +403,10 @@ UiContainer.EquipMenu = {
         local item
         local cur_item
         if self.selecting_slot == Inventory.ITEM_TYPE.WEAPON then
-            item = Game.Items[self.avail_weapons[self.list_item_selected + 1]]
+            item = Game.Items[self.avail_weapons[self.list_item_selected]]
             cur_item = Game.Items[Characters[self.char_id].weapon.id]
         elseif self.selecting_slot == Inventory.ITEM_TYPE.ARMOR then
-            item = Game.Items[self.avail_armors[self.list_item_selected + 1]]
+            item = Game.Items[self.avail_armors[self.list_item_selected]]
             cur_item = Game.Items[Characters[self.char_id].armor.id]
         end
         ui_manager:get_widget("EquipMenu.Container.Stats.AtkDif"):set_visible(false)
@@ -508,6 +514,7 @@ UiContainer.EquipMenu = {
         ui_manager:get_widget("EquipMenu"):set_visible(false)
         UiContainer.current_menu = "main"
         UiContainer.current_submenu = ""
+        UiContainer.resume_menu("main")
         return 0;
     end,
 }

+ 24 - 31
data/data/scripts/menu/item_menu.lua

@@ -191,13 +191,9 @@ UiContainer.ItemMenu = {
                     end
                     cursor:set_default_animation("Position" .. self.item_cursor_position)
                     -- If an item is selected, show description
-                    if (Inventory[self.item_cursor_item_selected] ~= nil) then
-                        desc_label:set_text(Game.Items[Inventory[self.item_cursor_item_selected + 1].item].description)
-                    else
-                        desc_label:set_text("")
-                    end
+                    self.show_item_description(self)
                 elseif button == Config.CONTROLS.UP then
-                    if self.item_cursor_item_selected == 0 then
+                    if self.item_cursor_item_selected <= 1 then
                         -- At the very begining, do nothing
                         audio_manager:play_sound("Error")
                         return 0
@@ -214,36 +210,24 @@ UiContainer.ItemMenu = {
                     end
                     cursor:set_default_animation("Position" .. self.item_cursor_position)
                     -- If an item is selected, show description
-                    if (Inventory[self.item_cursor_item_selected] ~= nil) then
-                        desc_label:set_text(Game.Items[Inventory[self.item_cursor_item_selected + 1].item].description)
-                    else
-                        desc_label:set_text("")
-                    end
+                    self.show_item_description(self)
                 elseif button == Config.ACTIONS.R1 then
                     if self.first_item_in_window + self.item_cursor_position_total < self.inventory_size - self.item_cursor_position_total then
                         audio_manager:play_sound("Cursor")
                         self.item_cursor_item_selected = self.item_cursor_item_selected + self.item_cursor_position_total
                         self.first_item_in_window = self.first_item_in_window + self.item_cursor_position_total
                         self.populate_items(self)
-                        if (Inventory[self.item_cursor_item_selected] ~= nil) then
-                            desc_label:set_text(Game.Items[Inventory[self.item_cursor_item_selected + 1].item].description)
-                        else
-                            desc_label:set_text("")
-                        end
+                        self.show_item_description(self)
                     else
                         audio_manager:play_sound("Error")
                     end
                 elseif button == Config.ACTIONS.L1 then
-                    if self.first_item_in_window - self.item_cursor_position_total > 1 then
+                    if self.first_item_in_window - self.item_cursor_position_total >= 0 then
                         audio_manager:play_sound("Cursor")
                         self.item_cursor_item_selected = self.item_cursor_item_selected - self.item_cursor_position_total
                         self.first_item_in_window = self.first_item_in_window - self.item_cursor_position_total
                         self.populate_items(self)
-                        if (Inventory[self.item_cursor_item_selected] ~= nil) then
-                            desc_label:set_text(Game.Items[Inventory[self.item_cursor_item_selected + 1].item].description)
-                        else
-                            desc_label:set_text("")
-                        end
+                        self.show_item_description(self)
                     else
                         audio_manager:play_sound("Error")
                     end
@@ -253,10 +237,11 @@ UiContainer.ItemMenu = {
                     UiContainer.current_submenu = "bar_selection"
                     ui_manager:get_widget("ItemMenu.Container.ItemList.Cursor"):set_visible(false)
                 elseif button == Config.ACTIONS.CONFIRM then
-                    if Game.Items[Inventory[self.item_cursor_item_selected].item].menu == 1 then
+                    local selected = Inventory[self.item_cursor_item_selected]
+                    if selected ~= nil and Game.Items[selected.item].menu == 1 then
                         audio_manager:play_sound("Cursor")
-                        self.item_in_use = Inventory[self.item_cursor_item_selected].item
-                        if (Game.Items[Inventory[self.item_cursor_item_selected - 1].item].target.default_multiple) == 1 then
+                        self.item_in_use = selected.item
+                        if (Game.Items[selected.item].target.default_multiple) == 1 then
                             self.item_in_use_party = true
                             self.submenu_chars(self, true)
                         else
@@ -506,6 +491,17 @@ UiContainer.ItemMenu = {
         ui_manager:get_widget("ItemMenu.Container.ItemOrder.Cursor"):set_default_animation("Position" .. self.order_cursor_position)
     end,
 
+    --- Shows the description of the highlighted inventory item, or clears it if the slot is empty.
+    show_item_description = function(self)
+        local label = ui_manager:get_widget("ItemMenu.Container.ItemMenuSecondBar.ItemText")
+        local entry = Inventory[self.item_cursor_item_selected]
+        if entry ~= nil then
+            label:set_text(Game.Items[entry.item].description)
+        else
+            label:set_text("")
+        end
+    end,
+
     --- Initializes the item selection selection mode.
     submenu_items = function(self)
         UiContainer.current_submenu = "item_selection"
@@ -527,11 +523,7 @@ UiContainer.ItemMenu = {
         end
 
 
-        if (Inventory[0] ~= nil) then
-            ui_manager:get_widget("ItemMenu.Container.ItemMenuSecondBar.ItemText"):set_text(Game.Items[Inventory[0].item].description)
-        else
-            ui_manager:get_widget("ItemMenu.Container.ItemMenuSecondBar.ItemText"):set_text("")
-        end
+        self.show_item_description(self)
     end,
 
     --- Initializes the character selection mode.
@@ -966,7 +958,7 @@ UiContainer.ItemMenu = {
             local w_name = ui_manager:get_widget("ItemMenu.Container.ItemList.Name" .. tostring(i))
             local w_colon = ui_manager:get_widget("ItemMenu.Container.ItemList.Colon" .. tostring(i))
             local w_qty = ui_manager:get_widget("ItemMenu.Container.ItemList.Qty" .. tostring(i))
-            local inventory_index = i + first_item_in_window + 1
+            local inventory_index = i + first_item_in_window
             if Inventory[inventory_index] == nil then
                 w_icon:set_visible(false)
                 w_name:set_visible(false)
@@ -1059,6 +1051,7 @@ UiContainer.ItemMenu = {
         ui_manager:get_widget("ItemMenu"):set_visible(false)
         UiContainer.current_menu = "main"
         UiContainer.current_submenu = ""
+        UiContainer.resume_menu("main")
         return 0;
     end,
 }

+ 235 - 0
data/data/scripts/menu/limit_menu.lua

@@ -0,0 +1,235 @@
+if UiContainer == nil then UiContainer = {} end
+
+local ROOT = "LimitMenu.Container."
+
+--- Inside width of the limit bar.
+local BAR_WIDTH = 54
+
+--- Number of limit levels, and of techniques of each level.
+local LEVELS = 4
+local TECHNIQUES = 2
+
+--- What the player is doing with the levels.
+local MODE = { SET = 1, CHECK = 2 }
+
+--- Colour of the levels with learned techniques, and of the others.
+local COLOUR_ACTIVE = { 1.0, 1.0, 1.0 }
+local COLOUR_INACTIVE = { 0.45, 0.45, 0.35 }
+
+local function widget(path)
+    return ui_manager:get_widget(ROOT .. path)
+end
+
+--- Is a technique of the character learned?
+local function is_learned(character, level, technique)
+    local learned = character.limit.learned[level]
+    return learned ~= nil and learned[technique] == true
+end
+
+--- Gets the definition (name and description) of a technique, or nil if it isn't known.
+local function get_technique(char_id, level, technique)
+    local definition = Game.Characters[char_id]
+    if definition == nil or definition.limits == nil or definition.limits[level] == nil then
+        return nil
+    end
+    local entry = definition.limits[level].techniques[technique]
+    if entry == nil then return nil end
+    return Game.Attacks[entry.command]
+end
+
+--- The limit menu.
+--
+-- On top there are two choices: Set, to choose the limit level of the character, and Check, to see
+-- the techniques learned at each level. Both let the player move over the four levels.
+--  * Set: choosing a level with learned techniques asks for confirmation. Confirming assigns it
+--    and empties the limit bar.
+--  * Check: choosing a level with learned techniques lets the player move between its techniques,
+--    showing their description.
+-- Choosing a level without learned techniques plays the error sound.
+UiContainer.LimitMenu = UiContainer.Menu.new{
+
+    id = "limit",
+    parent = "main",
+
+    --- ID of the character being shown.
+    char_id = -1,
+
+    --- Set or check (MODE).
+    mode = MODE.SET,
+
+    --- Cursor over Set and Check.
+    mode_cursor = UiContainer.Cursor.new{
+        widget = ROOT .. "Character.Modes.Cursor",
+        total = 2,
+    },
+
+    --- Cursor over the four levels, in a 2x2 grid.
+    level_cursor = UiContainer.Cursor.new{
+        widget = ROOT .. "Body.LevelCursor",
+        total = 4,
+        sound = false,
+    },
+
+    --- Cursor over the techniques of the chosen level.
+    tech_cursor = UiContainer.Cursor.new{
+        total = 2,
+        on_move = function(position)
+            UiContainer.LimitMenu:update_technique(position)
+        end,
+    },
+
+    --- Cursor over Yes and No in the confirmation window.
+    confirm_cursor = UiContainer.Cursor.new{
+        widget = ROOT .. "Confirm.Cursor",
+        total = 2,
+    },
+
+    states = {
+        --- Choosing Set or Check.
+        [""] = {
+            cursor = function(self) return self.mode_cursor end,
+            axis = "horizontal",
+            confirm = function(self, cursor)
+                self.mode = cursor:index()
+                self.level_cursor:set(1)
+                widget("Body.LevelCursor"):set_visible(true)
+                self:set_state("level")
+            end,
+            cancel = function(self)
+                script:request_end_sync(Script.UI, "LimitMenu", "hide", 0)
+            end,
+        },
+
+        --- Choosing a level.
+        level = {
+            buttons = UiContainer.grid_buttons("level_cursor", 2),
+            confirm = function(self)
+                local level = self.level_cursor:index()
+                if not self:has_learned(level) then return false end
+                if self.mode == MODE.SET then
+                    self.confirm_cursor:set(2) -- No.
+                    widget("Confirm"):set_visible(true)
+                    self:set_state("confirm")
+                else
+                    widget("Body.LevelCursor"):set_visible(false)
+                    widget("Body.TechCursor"):set_visible(true)
+                    self.tech_cursor:set(1)
+                    self:set_state("technique")
+                end
+            end,
+            cancel = function(self)
+                widget("Body.LevelCursor"):set_visible(false)
+                self:set_state("")
+            end,
+        },
+
+        --- Moving between the techniques of a level.
+        technique = {
+            cursor = function(self) return self.tech_cursor end,
+            axis = "vertical",
+            cancel = function(self)
+                widget("Body.TechCursor"):set_visible(false)
+                widget("Help.Help"):set_text("")
+                widget("Body.LevelCursor"):set_visible(true)
+                self:set_state("level")
+            end,
+        },
+
+        --- Confirming the change of level.
+        confirm = {
+            cursor = function(self) return self.confirm_cursor end,
+            axis = "vertical",
+            confirm = function(self, cursor)
+                if cursor:index() == 1 then
+                    self:set_level(self.level_cursor:index())
+                end
+                widget("Confirm"):set_visible(false)
+                self:set_state("level")
+            end,
+            cancel = function(self)
+                widget("Confirm"):set_visible(false)
+                self:set_state("level")
+            end,
+        },
+    },
+
+    --- Does the character know any technique of a level?
+    has_learned = function(self, level)
+        local character = Characters[self.char_id]
+        for technique = 1, TECHNIQUES do
+            if is_learned(character, level, technique) then return true end
+        end
+        return false
+    end,
+
+    --- Assigns a limit level to the character and empties its limit bar.
+    set_level = function(self, level)
+        local character = Characters[self.char_id]
+        character.limit.current = level
+        character.limit.bar = 0
+        self:populate_limit(character)
+    end,
+
+    --- Moves the technique cursor and shows the description of the technique.
+    update_technique = function(self, position)
+        local level = self.level_cursor:index()
+        widget("Body.TechCursor"):set_default_animation("Position" .. tostring((level - 1) * 2 + position))
+        local description = ""
+        if is_learned(Characters[self.char_id], level, position) then
+            local attack = get_technique(self.char_id, level, position)
+            if attack ~= nil then description = attack.description end
+        end
+        widget("Help.Help"):set_text(description)
+    end,
+
+    --- Shows the limit level and bar of the character.
+    populate_limit = function(self, character)
+        widget("Character.LimitLevel"):set_text(tostring(character.limit.current))
+        UiContainer.set_limit_bar(ROOT .. "Character.LimitLinePart", character, BAR_WIDTH)
+    end,
+
+    --- Shows the names of the levels and of their learned techniques.
+    populate = function(self)
+        local character = Characters[self.char_id]
+        UiContainer.populate_character_data(ROOT .. "Character", character)
+        widget("Character.Portrait"):set_image("characters/" .. tostring(self.char_id) .. ".png")
+        self:populate_limit(character)
+
+        for level = 1, LEVELS do
+            local learned = self:has_learned(level)
+            local colour = learned and COLOUR_ACTIVE or COLOUR_INACTIVE
+            local label = widget("Body.Level" .. tostring(level) .. ".Lbl")
+            label:set_colour(colour[1], colour[2], colour[3])
+            label:set_text("LEVEL " .. tostring(level))
+            for technique = 1, TECHNIQUES do
+                local name = ""
+                if is_learned(character, level, technique) then
+                    local attack = get_technique(self.char_id, level, technique)
+                    if attack ~= nil then name = attack.name end
+                end
+                widget("Body.Level" .. tostring(level) .. ".Tech" .. tostring(technique)):set_text(name)
+            end
+        end
+        widget("Help.Help"):set_text("")
+    end,
+
+    --- Opens the limit menu for UiContainer.current_character_id.
+    show = function(self)
+        self.char_id = UiContainer.current_character_id
+        self:activate()
+        self:populate()
+        self.mode_cursor:set(1)
+        widget("Body.LevelCursor"):set_visible(false)
+        widget("Body.TechCursor"):set_visible(false)
+        widget("Confirm"):set_visible(false)
+        ui_manager:get_widget("LimitMenu"):set_visible(true)
+        return 0
+    end,
+
+    --- Hides the limit menu and goes back to the main menu.
+    hide = function(self)
+        ui_manager:get_widget("LimitMenu"):set_visible(false)
+        self:deactivate()
+        return 0
+    end,
+}

+ 393 - 0
data/data/scripts/menu/magic_menu.lua

@@ -0,0 +1,393 @@
+if UiContainer == nil then UiContainer = {} end
+
+local ROOT = "MagicMenu.Container."
+
+--- Rows visible in the list, and abilities in each row.
+local ROWS = 9
+local COLUMNS = 4
+
+--- Categories of the abilities, in the order of the options of the menu.
+local KINDS = { "magic", "summon", "e_skill" }
+
+--- Colour of the abilities that can be used from the menu, and of the rest.
+local COLOUR_USABLE = { 1.0, 1.0, 1.0 }
+local COLOUR_UNUSABLE = { 0.45, 0.45, 0.35 }
+
+--- Number of uses that means a summon can be used without limit.
+local UNLIMITED = 255
+
+--- Attacks (by ID) that can be used from the menu, out of battle. The rest are shown in gray.
+--
+-- The data of the attacks has a condition for the menu, but it is not reliable (Life has none, and
+-- some spells that can't be cast out of battle have one), so the list is explicit.
+local MENU_ATTACKS = {
+    [0] = true,  -- Cure
+    [1] = true,  -- Cure2
+    [2] = true,  -- Cure3
+    [7] = true,  -- Life
+    [8] = true,  -- Life2
+}
+
+--- Creates the entry of the list for an attack.
+--
+-- @param id ID of the attack.
+-- @param uses Times it can be used (summons), or nil.
+-- @param mp MP the character has now. The attacks that cost more are not usable.
+local function create_entry(id, uses, mp)
+    local attack = Game.Attacks[id]
+    if attack == nil then return nil end
+    return {
+        id = id,
+        name = attack.name,
+        description = attack.description or "",
+        mp = attack.mp or 0,
+        uses = uses,
+        usable = MENU_ATTACKS[id] == true and (attack.mp or 0) <= mp,
+    }
+end
+
+--- Splits a list in rows of at most COLUMNS elements.
+local function split_in_rows(list)
+    local rows = {}
+    for index, item in ipairs(list) do
+        local row = math.floor((index - 1) / COLUMNS) + 1
+        rows[row] = rows[row] or {}
+        rows[row][#rows[row] + 1] = item
+    end
+    return rows
+end
+
+--- The magic menu.
+--
+-- At first the player chooses what to see (magic, summons or enemy skills) in the box at the right,
+-- and the abilities of that kind are shown below in a grid of four columns. Magic has a row for each
+-- materia, with the spells it gives. Summons and enemy skills fill the rows in order. OK goes into
+-- the grid, where the cursor moves over the abilities. The description of the one that is highlighted is shown in the help
+-- window, and the MP it needs (and the times it can be used, for summons) in the box at the right.
+--
+-- The abilities that can be cast from the menu (MENU_ATTACKS) are white, as long as the character
+-- has the MP they need. The rest are gray.
+--
+-- OK on one of them opens a window with the party to choose who receives it. The spell heals or
+-- revives and the MP are spent, or it fails (Error sound) and nothing is spent.
+-- TODO: Added-Ability is always "Nothing." The ability linked to the materia isn't calculated.
+UiContainer.MagicMenu = UiContainer.Menu.new{
+
+    id = "magic",
+    parent = "main",
+
+    --- ID of the character being shown.
+    char_id = -1,
+
+    --- Rows of the grid of the character, by kind (magic, summon, e_skill). Each row is a list of
+    -- up to COLUMNS entries.
+    rows = { magic = {}, summon = {}, e_skill = {} },
+
+    --- Kind of abilities whose grid is shown.
+    kind = "magic",
+
+    --- Position of the cursor in the grid: row and column of the highlighted ability, and the first
+    -- row that is visible.
+    row = 1,
+    column = 1,
+    first_row = 1,
+
+    --- Cursor over the options: magic, summon and enemy skills.
+    category_cursor = UiContainer.Cursor.new{
+        widget = ROOT .. "Side.Options.Cursor",
+        total = #KINDS,
+        on_move = function(position)
+            UiContainer.MagicMenu:show_category(position)
+        end,
+    },
+
+    --- Party slots shown in the window to choose the target of a spell, by position of the cursor.
+    target_slots = {},
+
+    --- Cursor over the characters of the party, to choose who receives the spell.
+    target_cursor = UiContainer.Cursor.new{
+        total = 1,
+        on_move = function(position)
+            UiContainer.MagicMenu:update_target_cursor(position)
+        end,
+    },
+
+    states = {
+        --- Choosing what to see.
+        [""] = {
+            cursor = function(self) return self.category_cursor end,
+            axis = "vertical",
+            confirm = function(self)
+                if #self.rows[self.kind] == 0 then return false end
+                self:set_browsing(true)
+                self.row, self.column, self.first_row = 1, 1, 1
+                self:update_cursor()
+                self:set_state("list")
+            end,
+            cancel = function(self)
+                script:request_end_sync(Script.UI, "MagicMenu", "hide", 0)
+            end,
+        },
+
+        --- Moving over the abilities of the list.
+        list = {
+            confirm = function(self)
+                local entry = self.rows[self.kind][self.row][self.column]
+                if not entry.usable then return false end
+                self:open_targets()
+                self:set_state("target")
+            end,
+            buttons = {
+                [Config.CONTROLS.UP] = function(self, event) self:move_cursor(-1, 0) end,
+                [Config.CONTROLS.DOWN] = function(self, event) self:move_cursor(1, 0) end,
+                [Config.CONTROLS.LEFT] = function(self, event) self:move_cursor(0, -1) end,
+                [Config.CONTROLS.RIGHT] = function(self, event) self:move_cursor(0, 1) end,
+            },
+            cancel = function(self)
+                self:set_browsing(false)
+                self:set_state("")
+            end,
+        },
+
+        --- Choosing the character that receives the spell.
+        target = {
+            cursor = function(self) return self.target_cursor end,
+            axis = "vertical",
+            confirm = function(self, cursor)
+                return self:cast_spell(cursor:index())
+            end,
+            cancel = function(self)
+                self:close_targets()
+                self:set_state("list")
+            end,
+        },
+    },
+
+    --- Opens the window to choose the character that receives the spell.
+    open_targets = function(self)
+        self.target_slots = {}
+        for slot = 1, Party.MAX do
+            if Party[slot] ~= nil and Characters[Party[slot]] ~= nil then
+                self.target_slots[#self.target_slots + 1] = slot
+            end
+        end
+        self.target_cursor.total = #self.target_slots
+        self:populate_targets()
+        ui_manager:get_widget(ROOT .. "Targets"):set_visible(true)
+        self.target_cursor:set(1)
+    end,
+
+    --- Closes the window to choose the target.
+    close_targets = function(self)
+        ui_manager:get_widget(ROOT .. "Targets"):set_visible(false)
+    end,
+
+    --- Shows the data of the party in the window to choose the target.
+    populate_targets = function(self)
+        for slot = 1, Party.MAX do
+            local path = ROOT .. "Targets.Slot" .. tostring(slot)
+            local id = Party[slot]
+            if id ~= nil and Characters[id] ~= nil then
+                UiContainer.populate_character_data(path, Characters[id])
+                ui_manager:get_widget(path .. ".Portrait"):set_image("characters/" .. tostring(id) .. ".png")
+            else
+                UiContainer.populate_character_data(path, nil)
+            end
+        end
+    end,
+
+    --- Puts the cursor of the targets on a character.
+    -- @param position Position in target_slots.
+    update_target_cursor = function(self, position)
+        local slot = self.target_slots[position]
+        if slot ~= nil then
+            ui_manager:get_widget(ROOT .. "Targets.Cursor"):set_default_animation("Position" .. tostring(slot))
+        end
+    end,
+
+    --- Casts the highlighted spell on a character of the party.
+    --
+    -- @param position Position of the target in target_slots.
+    -- @return False if the spell can't be cast (Error sound).
+    cast_spell = function(self, position)
+        local entry = self.rows[self.kind][self.row][self.column]
+        local target = Party[self.target_slots[position]]
+        if not Characters.cast_menu_spell(self.char_id, entry.id, target) then return false end
+
+        -- HP and MP changed.
+        self:populate_targets()
+        UiContainer.populate_character_data(ROOT .. "Character", Characters[self.char_id])
+        self:refresh_usable()
+        -- Without MP for another one, go back to the list.
+        if not entry.usable then
+            self:close_targets()
+            self:set_state("list")
+        end
+    end,
+
+    --- Updates which spells can be cast, as the MP of the character changed.
+    refresh_usable = function(self)
+        local mp = Characters[self.char_id].stats.mp.current
+        for _, rows in pairs(self.rows) do
+            for _, row in ipairs(rows) do
+                for _, entry in ipairs(row) do
+                    entry.usable = MENU_ATTACKS[entry.id] == true and entry.mp <= mp
+                end
+            end
+        end
+        self:populate_list()
+    end,
+
+    --- Builds the grids of abilities of the character.
+    build_rows = function(self)
+        local abilities = Characters.get_abilities(self.char_id)
+        local mp = Characters[self.char_id].stats.mp.current
+        local rows = { magic = {}, summon = {}, e_skill = {} }
+        -- Magic: one row for each materia (more rows if it gives more than COLUMNS spells).
+        for _, group in ipairs(abilities.magic_groups) do
+            local entries = {}
+            for _, id in ipairs(group) do entries[#entries + 1] = create_entry(id, nil, mp) end
+            for _, row in ipairs(split_in_rows(entries)) do rows.magic[#rows.magic + 1] = row end
+        end
+        local summons = {}
+        for _, summon in ipairs(abilities.summon) do
+            summons[#summons + 1] = create_entry(summon.id, summon.times, mp)
+        end
+        rows.summon = split_in_rows(summons)
+        local e_skills = {}
+        for _, id in ipairs(abilities.e_skill) do e_skills[#e_skills + 1] = create_entry(id, nil, mp) end
+        rows.e_skill = split_in_rows(e_skills)
+        self.rows = rows
+    end,
+
+    --- Shows the grid of a kind of abilities, with the cursor out of it.
+    --
+    -- @param position Position of the option (1 magic, 2 summon, 3 enemy skill).
+    show_category = function(self, position)
+        self.kind = KINDS[position]
+        self.row, self.column, self.first_row = 1, 1, 1
+        self:populate_list()
+        self:update_scroll()
+        ui_manager:get_widget(ROOT .. "Help.Help"):set_text("")
+    end,
+
+    --- Shows the abilities of the rows that are visible.
+    populate_list = function(self)
+        local rows = self.rows[self.kind]
+        for visible_row = 1, ROWS do
+            local row = rows[self.first_row + visible_row - 1]
+            for column = 1, COLUMNS do
+                local widget = ui_manager:get_widget(
+                    ROOT .. "List.R" .. tostring(visible_row) .. "C" .. tostring(column))
+                local entry = row ~= nil and row[column] or nil
+                if entry == nil then
+                    widget:set_visible(false)
+                else
+                    -- The colour has to be set before the text, as it applies to it when it's set.
+                    local colour = entry.usable and COLOUR_USABLE or COLOUR_UNUSABLE
+                    widget:set_colour(colour[1], colour[2], colour[3])
+                    widget:set_text(entry.name)
+                    widget:set_visible(true)
+                end
+            end
+        end
+    end,
+
+    --- Moves the scroll bar of the list. It is only shown if the grid doesn't fit.
+    update_scroll = function(self)
+        local count = #self.rows[self.kind]
+        local visible = count > ROWS
+        ui_manager:get_widget(ROOT .. "List.ScrollBg"):set_visible(visible)
+        local scroll = ui_manager:get_widget(ROOT .. "List.Scroll")
+        scroll:set_visible(visible)
+        if visible then
+            local fraction = (self.first_row - 1) / (count - ROWS)
+            -- Between 3 pixels from the top and 21 from the bottom of the list.
+            scroll:set_y(fraction * 100, 3 - fraction * 24)
+        end
+    end,
+
+    --- Moves the cursor over the grid.
+    --
+    -- Up and down keep the column, or go to the last ability if the row has fewer.
+    --
+    -- @param delta_row -1 to go up, 1 to go down.
+    -- @param delta_column -1 to go left, 1 to go right.
+    move_cursor = function(self, delta_row, delta_column)
+        local rows = self.rows[self.kind]
+        local row, column = self.row, self.column + delta_column
+        if delta_column ~= 0 then
+            -- Left and right stay in the row.
+            if column < 1 or column > #rows[row] then return end
+        else
+            row = row + delta_row
+            if row < 1 or row > #rows then
+                UiContainer.play_sound("Error")
+                return
+            end
+            column = math.min(column, #rows[row])
+        end
+        self.row, self.column = row, column
+        UiContainer.play_sound("Cursor")
+        -- Scroll if the cursor leaves the visible rows.
+        local first_row = self.first_row
+        if row < first_row then first_row = row
+        elseif row > first_row + ROWS - 1 then first_row = row - ROWS + 1 end
+        if first_row ~= self.first_row then
+            self.first_row = first_row
+            self:populate_list()
+            self:update_scroll()
+        end
+        self:update_cursor()
+    end,
+
+    --- Puts the cursor on the highlighted ability and shows its description and cost.
+    update_cursor = function(self)
+        local visible_row = self.row - self.first_row + 1
+        ui_manager:get_widget(ROOT .. "List.Cursor"):set_default_animation(
+            "Position" .. tostring(visible_row) .. "-" .. tostring(self.column))
+        local entry = self.rows[self.kind][self.row][self.column]
+        ui_manager:get_widget(ROOT .. "Help.Help"):set_text(entry.description)
+        ui_manager:get_widget(ROOT .. "Side.Cost.MpValue"):set_text(UiContainer.zero_pad_value(entry.mp, 3))
+        local uses = ui_manager:get_widget(ROOT .. "Side.Cost.Uses")
+        uses:set_visible(entry.uses ~= nil)
+        if entry.uses ~= nil then
+            local text = (entry.uses >= UNLIMITED) and "---" or UiContainer.zero_pad_value(entry.uses, 3)
+            ui_manager:get_widget(ROOT .. "Side.Cost.Uses.Value"):set_text(text)
+        end
+    end,
+
+    --- Switches between choosing what to see and moving over the grid.
+    --
+    -- @param browsing True when the cursor is in the grid.
+    set_browsing = function(self, browsing)
+        ui_manager:get_widget(ROOT .. "Side.Options"):set_visible(not browsing)
+        ui_manager:get_widget(ROOT .. "Side.Cost"):set_visible(browsing)
+        ui_manager:get_widget(ROOT .. "Character.Added"):set_visible(browsing)
+        ui_manager:get_widget(ROOT .. "List.Cursor"):set_visible(browsing)
+        if not browsing then
+            ui_manager:get_widget(ROOT .. "Help.Help"):set_text("")
+        end
+    end,
+
+    --- Opens the magic menu for UiContainer.current_character_id.
+    show = function(self)
+        self.char_id = UiContainer.current_character_id
+        self:activate()
+        self:build_rows()
+        local character = Characters[self.char_id]
+        UiContainer.populate_character_data(ROOT .. "Character", character)
+        ui_manager:get_widget(ROOT .. "Character.Portrait"):set_image("characters/" .. tostring(self.char_id) .. ".png")
+        self:set_browsing(false)
+        self.category_cursor:set(1)
+        ui_manager:get_widget("MagicMenu"):set_visible(true)
+        return 0
+    end,
+
+    --- Closes the magic menu and goes back to the main menu.
+    hide = function(self)
+        ui_manager:get_widget("MagicMenu"):set_visible(false)
+        self:deactivate()
+        return 0
+    end,
+}

+ 182 - 161
data/data/scripts/menu/main_menu.lua

@@ -1,205 +1,226 @@
 if UiContainer == nil then UiContainer = {} end
 
+--- Entries of the main menu, by cursor position.
+local ENTRY = {
+    ITEM = 1, MAGIC = 2, MATERIA = 3, EQUIP = 4, STATUS = 5, ORDER = 6, LIMIT = 7,
+    CONFIG = 8, PHS = 9, SAVE = 10, QUIT = 11,
+}
+
+--- Width in pixels of the inside of the level and limit bars.
+local BAR_WIDTH = 62
+
+--- Returned by a character action to keep the character selection open.
+local STAY = "stay"
+
+local CHARACTERS_CURSOR = "MainMenu.Container.Characters.Cursor"
+
 --- The main menu.
-UiContainer.MainMenu = {
+UiContainer.MainMenu = UiContainer.Menu.new{
+
+    id = "main",
 
-    --- Current cursor position in the main menu (1-10).
-    position = 1,
+    --- Cursor over the menu entries (1-11).
+    cursor = UiContainer.Cursor.new{
+        widget = "MainMenu.Container.Menu.Cursor",
+        total = 11,
+    },
 
-    -- Max cursor position in the main menu.
-    position_total = 11,
+    --- Cursor over the party slots (1-3).
+    -- TODO: Skip empty character slots.
+    character_cursor = UiContainer.Cursor.new{
+        widget = CHARACTERS_CURSOR,
+        total = 3,
+    },
 
-    --- When selecting a character, the menu in this position will be opened.
+    --- When selecting a character, the menu entry that will be opened.
     --
     -- If 0, not selecting character.
     select_character_for_menu = 0,
 
-    --- Current cursorposition in the charaster list (1-3).
-    character_position = 1,
+    --- Entries that don't need a character. Return false to signal they can't be opened.
+    entry_actions = {
+        [ENTRY.ITEM] = function(self)
+            script:request_end_sync(Script.UI, "ItemMenu", "show", 0)
+        end,
+        [ENTRY.CONFIG] = function(self)
+            script:request_end_sync(Script.UI, "ConfigMenu", "show", 0)
+        end,
+        [ENTRY.PHS] = function(self)
+            script:request_end_sync(Script.UI, "PhsMenu", "show", 0)
+        end,
+        [ENTRY.SAVE] = function(self)
+            UiContainer.SaveMenu.operation = "save"
+            script:request_end_sync(Script.UI, "SaveMenu", "show", 0)
+        end,
+        [ENTRY.QUIT] = function(self)
+            script:request_end_sync(Script.UI, "QuitMenu", "show", 0)
+        end,
+    },
+
+    --- Entries that need a character. Receive the party slot (1-3).
+    --
+    -- Return false if the entry can't be opened (error sound), or STAY to keep the character
+    -- selection open instead of handing the control to another menu.
+    character_actions = {
+        [ENTRY.MAGIC] = function(self, slot)
+            UiContainer.current_character_id = Party[slot]
+            script:request_end_sync(Script.UI, "MagicMenu", "show", 0)
+        end,
+        [ENTRY.MATERIA] = function(self, slot)
+            UiContainer.current_character_id = Party[slot]
+            script:request_end_sync(Script.UI, "MateriaMenu", "show", 0)
+        end,
+        [ENTRY.EQUIP] = function(self, slot)
+            UiContainer.current_character_id = Party[slot]
+            script:request_end_sync(Script.UI, "EquipMenu", "show", 0)
+        end,
+        [ENTRY.STATUS] = function(self, slot)
+            UiContainer.current_character_id = Party[slot]
+            script:request_end_sync(Script.UI, "StatusMenu", "show", 0)
+        end,
+        --- Toggles the row (front/back) of the character. Stays in the character selection.
+        [ENTRY.ORDER] = function(self, slot)
+            local character = Characters[Party[slot]]
+            character.back_row = not character.back_row
+            self:update_portrait(slot)
+            return STAY
+        end,
+        [ENTRY.LIMIT] = function(self, slot)
+            UiContainer.current_character_id = Party[slot]
+            script:request_end_sync(Script.UI, "LimitMenu", "show", 0)
+        end,
+    },
+
+    states = {
+        --- Choosing an entry of the menu.
+        [""] = {
+            cursor = function(self) return self.cursor end,
+            axis = "vertical",
+            cancel = function(self)
+                script:request_end_sync(Script.UI, "MainMenu", "hide", 0)
+            end,
+            confirm = function(self, cursor)
+                local position = cursor:index()
+                local action = self.entry_actions[position]
+                if action ~= nil then
+                    return action(self)
+                end
+                -- Any other entry needs a character.
+                self.select_character_for_menu = position
+                self:set_state("main_character")
+                ui_manager:get_widget(CHARACTERS_CURSOR):set_visible(true)
+            end,
+        },
+
+        --- Choosing the character to open the entry for.
+        main_character = {
+            cursor = function(self) return self.character_cursor end,
+            axis = "vertical",
+            cancel = function(self)
+                self:set_state("")
+                ui_manager:get_widget(CHARACTERS_CURSOR):set_visible(false)
+            end,
+            confirm = function(self, cursor)
+                local slot = cursor:index()
+                local action = self.character_actions[self.select_character_for_menu]
+                if action == nil or Party[slot] == nil or Characters[Party[slot]] == nil then
+                    return false
+                end
+                local result = action(self, slot)
+                if result == false or result == STAY then
+                    return result
+                end
+                -- The chosen menu takes over. When it closes, the main menu is back at the entry
+                -- list, so the character cursor must not stay behind.
+                self.select_character_for_menu = 0
+                self:set_state("")
+                ui_manager:get_widget(CHARACTERS_CURSOR):set_visible(false)
+            end,
+        },
+    },
+
+    --- Handles the menu button while the main menu is closed.
+    on_inactive_button = function(self, button, event)
+        if ui_manager:get_widget("MainMenu"):is_visible() == false
+            and MenuSettings.available == true
+            and button == Config.ACTIONS.MENU and event == "Press"
+        then
+            UiContainer.play_sound("Back")
+            script:request_end_sync(Script.UI, "MainMenu", "show", 0)
+        end
+    end,
 
-    --- Max cursor position in the characetr list.
-    character_position_total = 3,
+    --- Moves the portrait of a party slot according to the row: back row is 20 px to the right.
+    update_portrait = function(self, slot)
+        local character = Characters[Party[slot]]
+        local portrait = ui_manager:get_widget("MainMenu.Container.Characters.Character" .. tostring(slot) .. ".Portrait")
+        portrait:set_default_animation(character.back_row and "RowBack" or "RowFront")
+    end,
 
-    --- Run when the menu is creaded.
-    --
-    -- It does nothing.
-    on_start = function(self)
-        return 0
+    --- Shows the data of one party slot.
+    populate_character = function(self, slot)
+        local path = "MainMenu.Container.Characters.Character" .. tostring(slot)
+        local character = Party[slot] ~= nil and Characters[Party[slot]] or nil
+        UiContainer.populate_character_data(path, character)
+        if character == nil then return end
+        ui_manager:get_widget(path .. ".Portrait"):set_image("characters/" .. tostring(Party[slot]) .. ".png")
+        self:update_portrait(slot)
+        ui_manager:get_widget(path .. ".LimitLevel"):set_text(tostring(character.limit.current))
+        UiContainer.set_limit_bar(path .. ".LimitLinePart", character, BAR_WIDTH)
+        UiContainer.set_bar(path .. ".NextLevelLinePart", UiContainer.exp_progress(character), BAR_WIDTH)
     end,
 
-    --- Handles button events.
+    --- Refreshes the data a submenu may have changed (level, HP/MP, limit, row, gil...).
     --
-    -- For the current submenu, handles directional keys, enter and escape events.
-    -- @param button Pressed direction or action name.
-    -- @param event Trigger event. Normally, "Press".
-    on_button = function(self, button, event)
-        if UiContainer.current_menu == "main" then
-            local characters  = ui_manager:get_widget("MainMenu.Container.Characters")
-            local menu  = ui_manager:get_widget("MainMenu.Container.Menu")
-            local menu_cursor = ui_manager:get_widget("MainMenu.Container.Menu.Cursor")
-            local timegil = ui_manager:get_widget("MainMenu.Container.TimeGil")
-            local location = ui_manager:get_widget("MainMenu.Container.Location")
-            if UiContainer.current_submenu == "" then
-                if button == Config.ACTIONS.CANCEL and event == "Press" then
-                    audio_manager:play_sound("Back")
-                    script:request_end_sync(Script.UI, "MainMenu", "hide", 0)
-                elseif button == Config.ACTIONS.CONFIRM and event == "Press" then
-                    if self.position == 1 then -- Item menu
-                        audio_manager:play_sound("Cursor")
-                        script:request_end_sync(Script.UI, "ItemMenu", "show", 0)
-                    elseif self.position == 8 then -- Config menu
-                        audio_manager:play_sound("Error")
-                        print("TODO: Open config menu")
-                    elseif self.position == 9 then -- PHS menu
-                        audio_manager:play_sound("Error")
-                        print("TODO: Open PHS menu")
-                    elseif self.position == 10 then -- Save menu
-                        audio_manager:play_sound("Cursor")
-                        UiContainer.SaveMenu.operation = "save"
-                        script:request_end_sync(Script.UI, "SaveMenu", "show", 0)
-                    else -- Any other menu that needs a character
-                        audio_manager:play_sound("Cursor")
-                        self.select_character_for_menu = self.position
-                        UiContainer.current_submenu = "main_character"
-                        ui_manager:get_widget("MainMenu.Container.Characters.Cursor"):set_visible(true)
-                    end
-                elseif button == Config.CONTROLS.DOWN then
-                    audio_manager:play_sound("Cursor")
-                    self.position = self.position + 1
-                    if self.position > self.position_total then
-                        self.position = 1;
-                    end
-                    menu_cursor:set_default_animation("Position" .. self.position)
-                elseif button == Config.CONTROLS.UP then
-                    audio_manager:play_sound("Cursor")
-                    self.position = self.position - 1
-                    if self.position <= 0 then
-                        self.position = self.position_total;
-                    end
-                    menu_cursor:set_default_animation("Position" .. self.position)
-                end
-            elseif UiContainer.current_submenu == "main_character" then
-                if button == Config.ACTIONS.CANCEL and event == "Press" then
-                    audio_manager:play_sound("Back")
-                    UiContainer.current_submenu = ""
-                    ui_manager:get_widget("MainMenu.Container.Characters.Cursor"):set_visible(false)
-                elseif button == Config.CONTROLS.DOWN then
-                    audio_manager:play_sound("Cursor")
-                    -- TODO: Skip empty character slots
-                    self.character_position = self.character_position + 1
-                    if self.character_position > self.character_position_total then
-                        self.character_position = 1
-                    end
-                    ui_manager:get_widget("MainMenu.Container.Characters.Cursor"):set_default_animation("Position" .. self.character_position)
-                elseif button == Config.CONTROLS.UP then
-                    audio_manager:play_sound("Cursor")
-                    -- TODO: Skip empty character slots
-                    self.character_position = self.character_position - 1
-                    if self.character_position < 1 then
-                        self.character_position = self.character_position_total
-                    end
-                    ui_manager:get_widget("MainMenu.Container.Characters.Cursor"):set_default_animation("Position" .. self.character_position)
-                elseif button == Config.ACTIONS.CONFIRM then
-                    -- TODO: Check for empty character.
-                    audio_manager:play_sound("Cursor")
-                    if self.select_character_for_menu == 2 then -- Magic menu
-                        print("Open magic menu for char in slot " .. self.character_position)
-                    elseif self.select_character_for_menu == 3 then -- Materia menu
-                        UiContainer.current_character_id = Party[self.character_position]
-                        script:request_end_sync(Script.UI, "MateriaMenu", "show", 0)
-                    elseif self.select_character_for_menu == 4 then -- Equip menu
-                        UiContainer.current_character_id = Party[self.character_position]
-                        script:request_end_sync(Script.UI, "EquipMenu", "show", 0)
-                    elseif self.select_character_for_menu == 5 then -- Status menu
-                        print("Open status menu for char in slot " .. self.character_position)
-                    elseif self.select_character_for_menu == 6 then -- Order menu
-                        print("Open order menu for char in slot " .. self.character_position)
-                    elseif self.select_character_for_menu == 7 then -- Limit menu
-                        print("Open limit menu for char in slot " .. self.character_position)
-                    end
-                end
-            end
-        elseif ui_manager:get_widget("MainMenu"):is_visible() == false and MenuSettings.available == true then
-            if button == Config.ACTIONS.MENU and event == "Press" then
-                audio_manager:play_sound("Back")
-                script:request_end_sync(Script.UI, "MainMenu", "show", 0)
-            end
-        else
-            return 0
+    -- Runs when a submenu closes and gives the control back to the main menu.
+    on_resume = function(self)
+        for slot = 1, 3 do
+            self:populate_character(slot)
         end
-
-        return 0
+        ui_manager:get_widget("MainMenu.Container.TimeGil.Gil"):set_text(UiContainer.format_money(Inventory.money))
     end,
 
     --- Opens the main menu.
     --
     -- Populates and updates displayed data before opening. It also pauses the game.
     show = function(self)
-        UiContainer.current_menu = "main"
-        UiContainer.current_submenu = ""
+        self:activate()
         entity_manager:set_paused(true)
         MenuSettings.pause_available = false
         ui_manager:get_widget("MainMenu"):set_visible(true)
-        local characters  = ui_manager:get_widget("MainMenu.Container.Characters")
-        local menu = ui_manager:get_widget("MainMenu.Container.Menu")
-        local menu_cursor = ui_manager:get_widget("MainMenu.Container.Menu.Cursor")
-        local timegil = ui_manager:get_widget("MainMenu.Container.TimeGil")
-        local location = ui_manager:get_widget("MainMenu.Container.Location")
-
 
-        for c = 1, 3 do
-            if Party[c] ~= nil then
-                UiContainer.populate_character_data("MainMenu.Container.Characters.Character" .. tostring(c), Characters[Party[c]])
-                ui_manager:get_widget("MainMenu.Container.Characters.Character" .. tostring(c) .. ".Portrait"):set_image("characters/" .. tostring(Party[c]) .. ".png")
-                if Characters[Party[c]].back_row == 1 then
-                    ui_manager:get_widget("MainMenu.Container.Characters.Character" .. tostring(c) .. ".Portrait"):set_default_animation("RowBack")
-                else
-                    ui_manager:get_widget("MainMenu.Container.Characters.Character" .. tostring(c) .. ".Portrait"):set_default_animation("RowFront")
-                end
-                ui_manager:get_widget("MainMenu.Container.Characters.Character" .. tostring(c) .. ".LimitLevel"):set_text(tostring(Characters[Party[c]].limit.current))
-            else
-                UiContainer.populate_character_data("MainMenu.Container.Characters.Character" .. tostring(c), nil)
-            end
+        for slot = 1, 3 do
+            self:populate_character(slot)
         end
 
         -- TODO: Set portraits of the correct char
 
-        --ui_manager:get_widget("MainMenu.Container.Menu.PHSText"):set_visible(false)
         ui_manager:get_widget("MainMenu.Container.Menu.SaveText"):set_colour(0.4, 0.4, 0.4)
-
-        -- Set location
         ui_manager:get_widget("MainMenu.Container.Location.Text"):set_text(System["MapChanger"].location_name)
+        ui_manager:get_widget("MainMenu.Container.TimeGil.Gil"):set_text(UiContainer.format_money(Inventory.money))
 
-        -- Set money
-        local money_str = tostring(Inventory.money)
-        while #(money_str) < 8 do
-            money_str = " " .. money_str
-        end
-        ui_manager:get_widget("MainMenu.Container.TimeGil.Gil"):set_text(money_str)
-
-        characters:play_animation_stop("Appear")
-        menu:play_animation_stop("Appear")
-        menu_cursor:set_default_animation("Position" .. self.position)
-        timegil:play_animation_stop("Appear")
+        local location = ui_manager:get_widget("MainMenu.Container.Location")
+        ui_manager:get_widget("MainMenu.Container.Characters"):play_animation_stop("Appear")
+        ui_manager:get_widget("MainMenu.Container.Menu"):play_animation_stop("Appear")
+        ui_manager:get_widget("MainMenu.Container.TimeGil"):play_animation_stop("Appear")
         location:play_animation_stop("Appear")
+        -- The cursor starts at the first entry, or where it was left if the cursor memory is on.
+        if not UiContainer.cursor_memory() then self.cursor:set(1) end
+        self.cursor:refresh()
         location:animation_sync()
-        return 0;
+        return 0
     end,
 
-    --- Hides the item menu and goes back to the game.
+    --- Hides the main menu and goes back to the game.
     --
     -- It also unpauses the game.
     hide = function(self)
-        UiContainer.current_menu = ""
-        UiContainer.current_submenu = ""
-        local characters  = ui_manager:get_widget("MainMenu.Container.Characters")
-        local menu = ui_manager:get_widget("MainMenu.Container.Menu")
-        local timegil = ui_manager:get_widget("MainMenu.Container.TimeGil")
+        self:deactivate("")
         local location = ui_manager:get_widget("MainMenu.Container.Location")
 
-        characters:play_animation_stop("Disappear")
-        menu:play_animation_stop("Disappear")
-        timegil:play_animation_stop("Disappear")
+        ui_manager:get_widget("MainMenu.Container.Characters"):play_animation_stop("Disappear")
+        ui_manager:get_widget("MainMenu.Container.Menu"):play_animation_stop("Disappear")
+        ui_manager:get_widget("MainMenu.Container.TimeGil"):play_animation_stop("Disappear")
         location:play_animation_stop("Disappear")
         location:animation_sync()
 
@@ -208,6 +229,6 @@ UiContainer.MainMenu = {
         MenuSettings.pause_available = true
         entity_manager:set_paused(false)
 
-        return 0;
+        return 0
     end,
 }

+ 1 - 0
data/data/scripts/menu/materia_menu.lua

@@ -714,6 +714,7 @@ UiContainer.MateriaMenu = {
         ui_manager:get_widget("MateriaMenu"):set_visible(false)
         UiContainer.current_menu = "main"
         UiContainer.current_submenu = ""
+        UiContainer.resume_menu("main")
         return 0;
     end,
 }

+ 20 - 33
data/data/scripts/menu/menu_helpers.lua

@@ -23,20 +23,15 @@ end
 -- hidden.
 UiContainer.populate_character_data = function(widget, character)
 
-    space_pad = function(val, length)
-        str = tostring(val)
-        while #(str) < length do
-            str = " " .. str
-        end
-        return str
-    end
-
     if character == nil then
         ui_manager:get_widget(widget):set_visible(false)
         return 0
     end
 
-    -- TODO: When MAX HP and MP values are fixed, use them instead of base.
+    -- Maximum HP and MP include the bonuses of equipment and materia. Before the stats are first
+    -- calculated they are not set, so fall back to the base values.
+    local hp_max = character.stats.hp.max or character.stats.hp.base
+    local mp_max = character.stats.mp.max or character.stats.mp.base
 
     local char_id = character.char_id
     ui_manager:get_widget(widget):set_visible(true)
@@ -44,7 +39,7 @@ UiContainer.populate_character_data = function(widget, character)
     -- Critical?
     if character.stats.hp.current == 0 then
         ui_manager:get_widget(widget .. ".Data.HpCurrent"):set_colour(1.0, 0, 0)
-    elseif character.stats.hp.current <= character.stats.hp.base / 4 then
+    elseif character.stats.hp.current <= hp_max / 4 then
         ui_manager:get_widget(widget .. ".Data.HpCurrent"):set_colour(1.0, 1.0, 0)
     else
         ui_manager:get_widget(widget .. ".Data.HpCurrent"):set_colour(0.9, 0.9, 0.9)
@@ -52,42 +47,34 @@ UiContainer.populate_character_data = function(widget, character)
 
     -- Text data
     ui_manager:get_widget(widget .. ".Data.Name"):set_text(character.name)
-    ui_manager:get_widget(widget .. ".Data.LvNumber"):set_text(space_pad(character.level, 3))
-    ui_manager:get_widget(widget .. ".Data.HpCurrent"):set_text(space_pad(character.stats.hp.current, 5))
-    ui_manager:get_widget(widget .. ".Data.HpMax"):set_text(space_pad(character.stats.hp.base, 5))
-    ui_manager:get_widget(widget .. ".Data.MpCurrent"):set_text(space_pad(character.stats.mp.current, 5))
-    ui_manager:get_widget(widget .. ".Data.MpMax"):set_text(space_pad(character.stats.mp.base, 5))
+    ui_manager:get_widget(widget .. ".Data.LvNumber"):set_text(UiContainer.pad_value(character.level, 3))
+    ui_manager:get_widget(widget .. ".Data.HpCurrent"):set_text(UiContainer.pad_value(character.stats.hp.current, 5))
+    ui_manager:get_widget(widget .. ".Data.HpMax"):set_text(UiContainer.pad_value(hp_max, 5))
+    ui_manager:get_widget(widget .. ".Data.MpCurrent"):set_text(UiContainer.pad_value(character.stats.mp.current, 5))
+    ui_manager:get_widget(widget .. ".Data.MpMax"):set_text(UiContainer.pad_value(mp_max, 5))
 
     -- Calculate the HP and MP bar.
     local max_hp_width= ui_manager:get_widget(widget .. ".Data.HpLine"):get_width()
-    local hp_width = math.floor(character.stats.hp.current * max_hp_width / character.stats.hp.base)
+    local hp_width = math.floor(character.stats.hp.current * max_hp_width / hp_max)
     ui_manager:get_widget(widget .. ".Data.HpLineCurrent"):set_width(hp_width)
     local max_mp_width= ui_manager:get_widget(widget .. ".Data.MpLine"):get_width()
-    local mp_width = character.stats.mp.current * max_mp_width / character.stats.mp.base
+    local mp_width = character.stats.mp.current * max_mp_width / mp_max
     ui_manager:get_widget(widget .. ".Data.MpLineCurrent"):set_width(mp_width)
 
     -- Status (Fury/Sadness)
-    local fury = false
-    local sadness = false
+    local status_text = ""
     for _, st in ipairs(character.status) do
-        if st == 4 then
-            sadness = true
+        if st == UiContainer.Status.SADNESS then
+            status_text = "Sadness"
             break
-        elseif st == 5 then
-            fury = true
+        elseif st == UiContainer.Status.FURY then
+            status_text = "Fury"
             break
         end
     end
-    if fury == true then
-        ui_manager:get_widget(widget .. ".Data.Status"):set_colour(0.8, 0, 1)
-        ui_manager:get_widget(widget .. ".Data.Status"):set_text("Fury")
-    elseif sadness == true then
-        ui_manager:get_widget(widget .. ".Data.Status"):set_colour(0.8, 0, 1)
-        ui_manager:get_widget(widget .. ".Data.Status"):set_text("Sadness")
-    elseif sadness == false and fury == false then
-        ui_manager:get_widget(widget .. ".Data.Status"):set_colour(0.8, 0, 1)
-        ui_manager:get_widget(widget .. ".Data.Status"):set_text("")
-    end
+    local status_widget = ui_manager:get_widget(widget .. ".Data.Status")
+    status_widget:set_colour(0.8, 0, 1)
+    status_widget:set_text(status_text)
 
     return 0
 end

+ 467 - 0
data/data/scripts/menu/menu_lib.lua

@@ -0,0 +1,467 @@
+--- Shared building blocks for the menu scripts.
+--
+-- Provides:
+--  * UiContainer.Cursor: a cursor over a fixed list of positions.
+--  * UiContainer.ListCursor: a cursor over a scrollable list.
+--  * UiContainer.Menu: a small state machine for menus (replaces the nested
+--    "if current_menu == ... if current_submenu == ..." ladders).
+--  * Formatting helpers and constants.
+--
+-- Menus still publish UiContainer.current_menu / UiContainer.current_submenu, so menus that haven't
+-- been migrated yet keep working next to the migrated ones.
+
+if UiContainer == nil then UiContainer = {} end
+
+---------------------------------------------------------------------------------------------------
+-- Constants and formatting
+---------------------------------------------------------------------------------------------------
+
+--- Status effect ids shown in menus.
+UiContainer.Status = {
+    SADNESS = 4,
+    FURY = 5,
+}
+
+--- Formats a money amount, right aligned.
+-- @param value The amount of gil.
+-- @param length Width of the field. Defaults to 8.
+UiContainer.format_money = function(value, length)
+    return UiContainer.pad_value(value, length or 8)
+end
+
+--- Formats a number of seconds as HHH:MM.
+UiContainer.format_time = function(seconds)
+    return UiContainer.pad_value(math.floor(seconds / 3600), 3) .. ":"
+        .. UiContainer.zero_pad_value(math.floor((seconds % 3600) / 60), 2)
+end
+
+--- Calculates how much of the way to the next level a character has done.
+--
+-- Uses the EXP curve of the character (see Game.Growth.curves): the EXP needed to attain a level
+-- L is Mod * (L - 1)^2 / 10, where Mod is the gradient of the curve segment for L. The character
+-- stores the EXP still missing for the next level (exp_to_next).
+--
+-- @param character Entry of the Characters table.
+-- @return A number from 0 to 1. 0 if it can't be calculated (e.g. level 99).
+UiContainer.exp_progress = function(character)
+    local definition = Game.Characters[character.char_id]
+    if definition == nil or definition.curves == nil or definition.curves.exp == nil then
+        return 0
+    end
+    local curve = Game.Growth.curves[definition.curves.exp]
+    local level = character.level
+    local target = level + 1
+    if curve == nil or target > 99 then return 0 end
+    local segment
+    if target <= 11 then segment = 0
+    elseif target <= 21 then segment = 1
+    elseif target <= 31 then segment = 2
+    elseif target <= 41 then segment = 3
+    elseif target <= 51 then segment = 4
+    elseif target <= 61 then segment = 5
+    elseif target <= 81 then segment = 6
+    else segment = 7 end
+    local span = math.floor(curve[segment].gradient * level * level / 10)
+    if span <= 0 or character.exp_to_next == nil then return 0 end
+    return math.max(0, math.min(1, 1 - character.exp_to_next / span))
+end
+
+--- Fills a two-line bar (widgets "<prefix>1" and "<prefix>2").
+--
+-- @param prefix Path of the bar widgets, without the final 1/2.
+-- @param fraction Fill amount, 0 to 1.
+-- @param width Width in pixels of the full bar.
+UiContainer.set_bar = function(prefix, fraction, width)
+    fraction = math.max(0, math.min(1, fraction))
+    local pixels = math.floor(fraction * width)
+    for line = 1, 2 do
+        local widget = ui_manager:get_widget(prefix .. tostring(line))
+        widget:set_visible(pixels > 0)
+        widget:set_width(pixels)
+    end
+end
+
+--- Is the cursor memory on? (Cursor: Memory in the config menu.)
+--
+-- If it is, the cursors of the menus and of the battle commands start where they were left. If not
+-- (Initial), they start at the first position.
+UiContainer.cursor_memory = function()
+    return MenuSettings ~= nil and MenuSettings.config ~= nil and MenuSettings.config.cursor == 2
+end
+
+--- Maximum value of the limit bar of a character.
+UiContainer.LIMIT_BAR_MAX = 255
+
+--- Fills the limit bar of a character (see set_bar).
+--
+-- @param prefix Path of the bar widgets, without the final 1/2.
+-- @param character Entry of the Characters table.
+-- @param width Width in pixels of the full bar.
+UiContainer.set_limit_bar = function(prefix, character, width)
+    UiContainer.set_bar(prefix, character.limit.bar / UiContainer.LIMIT_BAR_MAX, width)
+end
+
+--- Plays a menu sound.
+-- @param name One of "Cursor", "Back", "Error", "Window"...
+UiContainer.play_sound = function(name)
+    audio_manager:play_sound(name)
+end
+
+--- Resolves a widget given its full path, or returns the argument if it is already a widget.
+local function resolve_widget(widget)
+    if type(widget) == "string" then
+        return ui_manager:get_widget(widget)
+    end
+    return widget
+end
+
+---------------------------------------------------------------------------------------------------
+-- Cursor
+---------------------------------------------------------------------------------------------------
+
+--- A cursor over a fixed list of positions (1..total).
+--
+-- The cursor widget must define animations "Position1".."PositionN".
+--
+--     self.cursor = UiContainer.Cursor.new{
+--         widget = "ItemMenu.Container.ItemOrder.Cursor",
+--         total = 8,
+--         on_move = function(position) ... end,  -- optional
+--     }
+--
+-- Options:
+--  * widget: Path of the cursor widget (resolved lazily), or a widget. Optional.
+--  * total: Number of positions.
+--  * wrap: Wrap around the ends. Defaults to true.
+--  * sound: Sound played on move. Defaults to "Cursor". false for none.
+--  * on_move: Called with the new position after every move.
+UiContainer.Cursor = {}
+UiContainer.Cursor.__index = UiContainer.Cursor
+
+UiContainer.Cursor.new = function(options)
+    local cursor = setmetatable({}, UiContainer.Cursor)
+    cursor.widget = options.widget
+    cursor.total = options.total or 1
+    cursor.wrap = options.wrap ~= false
+    cursor.sound = options.sound
+    if cursor.sound == nil then cursor.sound = "Cursor" end
+    cursor.on_move = options.on_move
+    cursor.position = options.position or 1
+    return cursor
+end
+
+--- Moves the cursor to a position and updates the widget. Plays no sound.
+function UiContainer.Cursor:set(position)
+    self.position = position
+    if self.widget ~= nil then
+        resolve_widget(self.widget):set_default_animation("Position" .. self.position)
+    end
+    if self.on_move ~= nil then self.on_move(self.position) end
+end
+
+--- Refreshes the widget with the current position.
+function UiContainer.Cursor:refresh()
+    self:set(self.position)
+end
+
+--- Moves by a delta. Returns true if the cursor actually moved.
+function UiContainer.Cursor:move(delta)
+    local target = self.position + delta
+    if target > self.total then
+        if not self.wrap then
+            if self.sound then UiContainer.play_sound("Error") end
+            return false
+        end
+        target = 1
+    elseif target < 1 then
+        if not self.wrap then
+            if self.sound then UiContainer.play_sound("Error") end
+            return false
+        end
+        target = self.total
+    end
+    if self.sound then UiContainer.play_sound(self.sound) end
+    self:set(target)
+    return true
+end
+
+function UiContainer.Cursor:next() return self:move(1) end
+function UiContainer.Cursor:prev() return self:move(-1) end
+
+--- Currently highlighted position.
+function UiContainer.Cursor:index() return self.position end
+
+--- Handles a directional button.
+--
+-- @param button Button name.
+-- @param axis "vertical" (Up/Down) or "horizontal" (Left/Right).
+-- @return true if the button was a direction of the axis (even if the cursor didn't move).
+function UiContainer.Cursor:handle(button, axis)
+    local forward, backward
+    if axis == "horizontal" then
+        forward, backward = Config.CONTROLS.RIGHT, Config.CONTROLS.LEFT
+    else
+        forward, backward = Config.CONTROLS.DOWN, Config.CONTROLS.UP
+    end
+    if button == forward then
+        self:next()
+        return true
+    elseif button == backward then
+        self:prev()
+        return true
+    end
+    return false
+end
+
+--- Handlers for the direction buttons that move a cursor over a grid.
+--
+-- The cursor goes through the cells row by row, and wraps around at the ends of rows and columns.
+-- Up and down keep the column, or go to the last cell of a shorter row. Use them as the `buttons` of
+-- a state of a menu.
+--
+--     buttons = UiContainer.grid_buttons("level_cursor", 2),
+--
+-- @param cursor_name Name of the field of the menu that holds the cursor (a Cursor).
+-- @param columns Number of columns of the grid.
+UiContainer.grid_buttons = function(cursor_name, columns)
+    local buttons = {}
+    for _, button in ipairs({
+        Config.CONTROLS.UP, Config.CONTROLS.DOWN, Config.CONTROLS.LEFT, Config.CONTROLS.RIGHT
+    }) do
+        buttons[button] = function(self, event)
+            local cursor = self[cursor_name]
+            local index = cursor:index() - 1
+            local column = index % columns
+            local row = math.floor(index / columns)
+            local rows = math.ceil(cursor.total / columns)
+            local function row_length(r) return math.min(columns, cursor.total - r * columns) end
+            if button == Config.CONTROLS.LEFT then
+                column = (column - 1) % row_length(row)
+            elseif button == Config.CONTROLS.RIGHT then
+                column = (column + 1) % row_length(row)
+            else
+                row = (row + ((button == Config.CONTROLS.DOWN) and 1 or -1)) % rows
+                column = math.min(column, row_length(row) - 1)
+            end
+            UiContainer.play_sound("Cursor")
+            cursor:set(row * columns + column + 1)
+        end
+    end
+    return buttons
+end
+
+---------------------------------------------------------------------------------------------------
+-- ListCursor
+---------------------------------------------------------------------------------------------------
+
+--- A cursor over a scrollable list: `rows` visible rows over `count` items.
+--
+-- The cursor moves inside the window until it hits an edge, then the window scrolls. It never
+-- wraps.
+--
+-- Options:
+--  * widget: Cursor widget, with animations "Position1".."PositionRows".
+--  * rows: Visible rows.
+--  * count: Total items in the list. Can be changed later through the `count` field.
+--  * on_scroll: Called when the window scrolls (to repopulate the rows).
+--  * on_move: Called after every successful move.
+UiContainer.ListCursor = setmetatable({}, { __index = UiContainer.Cursor })
+UiContainer.ListCursor.__index = UiContainer.ListCursor
+
+UiContainer.ListCursor.new = function(options)
+    local cursor = setmetatable({}, UiContainer.ListCursor)
+    cursor.widget = options.widget
+    cursor.rows = options.rows
+    cursor.total = options.rows
+    cursor.count = options.count or options.rows
+    cursor.sound = options.sound
+    if cursor.sound == nil then cursor.sound = "Cursor" end
+    cursor.on_scroll = options.on_scroll
+    cursor.on_move = options.on_move
+    cursor.position = 1 -- Row inside the window.
+    cursor.first = 1    -- First item shown in the window.
+    return cursor
+end
+
+--- Index of the highlighted item in the whole list.
+function UiContainer.ListCursor:index()
+    return self.first + self.position - 1
+end
+
+--- Resets to the top of the list.
+function UiContainer.ListCursor:reset()
+    self.first = 1
+    self:set(1)
+end
+
+function UiContainer.ListCursor:move(delta)
+    local index = self:index() + delta
+    if index < 1 or index > self.count then
+        if self.sound then UiContainer.play_sound("Error") end
+        return false
+    end
+    if self.sound then UiContainer.play_sound(self.sound) end
+    local row = self.position + delta
+    if row < 1 or row > self.rows then
+        -- Scroll instead of moving the cursor.
+        self.first = self.first + delta
+        if self.on_scroll ~= nil then self.on_scroll(self.first) end
+        if self.on_move ~= nil then self.on_move(self.position) end
+    else
+        self:set(row)
+    end
+    return true
+end
+
+---------------------------------------------------------------------------------------------------
+-- Menu
+---------------------------------------------------------------------------------------------------
+
+--- Menu state machine.
+--
+-- A menu is declared as a table of states. Each state describes how it reacts to the buttons, and
+-- UiContainer.Menu.new() adds the on_button / set_state plumbing:
+--
+--     UiContainer.PauseMenu = UiContainer.Menu.new{
+--         id = "pause",              -- Value of UiContainer.current_menu while open.
+--         initial_state = "",        -- State entered by open(). Defaults to "".
+--         states = {
+--             [""] = {
+--                 cursor = function(self) return self.cursor end,
+--                 axis = "vertical",          -- Directional axis handled by the cursor.
+--                 confirm = function(self, cursor) ... end,
+--                 cancel = function(self) ... end,
+--                 buttons = {                 -- Any other button.
+--                     [Config.ACTIONS.L1] = function(self, event) ... end,
+--                 },
+--             },
+--         },
+--         show = ..., hide = ...,
+--     }
+--
+-- Rules:
+--  * When a menu gives the control back (deactivate), the receiving menu's optional `on_resume`
+--    hook runs, so it can refresh data the submenu may have changed.
+--  * confirm and cancel only fire on "Press" (not on key repeat).
+--  * Directions fire on "Press" and "Repeat".
+--  * cancel is followed by the "Back" sound. confirm is followed by "Cursor". If either handler
+--    returns false, "Error" is played instead.
+--  * A state may set `confirm_sound = false` (or `cancel_sound = false`) when its handler plays its
+--    own sound.
+--  * A state may set `blocked = true` to swallow all input.
+--  * A state may define `capture = function(self, key)`, called with the name of the key when the
+--    player presses the key requested with input_manager:capture_next_key().
+UiContainer.Menu = {}
+
+--- Menus created with UiContainer.Menu.new(), by id.
+UiContainer.menus = {}
+
+--- Tells a menu that it has the control again after a submenu closed.
+--
+-- The menu runs its `on_resume` hook (if it has one), which is the place to refresh whatever the
+-- submenu could have changed (stats, equipment, limit level...). Menus not migrated to
+-- UiContainer.Menu should call this after giving the control back.
+--
+-- @param id Id of the menu receiving the control, e.g. "main".
+UiContainer.resume_menu = function(id)
+    local menu = UiContainer.menus[id]
+    if menu ~= nil and menu.on_resume ~= nil then
+        menu:on_resume()
+    end
+end
+
+local menu_methods = {}
+
+--- Switches to another state of the menu.
+function menu_methods.set_state(self, state)
+    UiContainer.current_submenu = state
+end
+
+--- Gets the current state name.
+function menu_methods.get_state(self)
+    return UiContainer.current_submenu
+end
+
+--- Marks this menu as the active one and enters its initial state.
+function menu_methods.activate(self)
+    UiContainer.current_menu = self.id
+    UiContainer.current_submenu = self.initial_state or ""
+end
+
+--- Gives the control back to another menu.
+--
+-- @param id Id of the menu receiving the control (e.g. "main"). Defaults to `self.parent`.
+function menu_methods.deactivate(self, id)
+    UiContainer.current_menu = id or self.parent or ""
+    UiContainer.current_submenu = ""
+    UiContainer.resume_menu(UiContainer.current_menu)
+end
+
+--- Is this menu the one currently handling the input?
+function menu_methods.is_active(self)
+    return UiContainer.current_menu == self.id
+end
+
+--- Dispatches a button to the current state.
+function menu_methods.on_button(self, button, event, key)
+    if UiContainer.current_menu ~= self.id then
+        if self.on_inactive_button ~= nil then
+            return self:on_inactive_button(button, event) or 0
+        end
+        return 0
+    end
+    local state = self.states[UiContainer.current_submenu]
+    if state == nil or state.blocked then return 0 end
+
+    -- A key asked for with input_manager:capture_next_key() was pressed. The name of the key is the
+    -- third argument.
+    if button == "KEY_CAPTURE" then
+        if state.capture ~= nil then state.capture(self, key) end
+        return 0
+    end
+
+    local pressed = (event == "Press")
+    local cursor = nil
+    if state.cursor ~= nil then cursor = state.cursor(self) end
+
+    if button == Config.ACTIONS.CANCEL then
+        if pressed and state.cancel ~= nil then
+            local result = state.cancel(self)
+            if result == false then
+                UiContainer.play_sound("Error")
+            elseif state.cancel_sound ~= false then
+                UiContainer.play_sound("Back")
+            end
+        end
+    elseif button == Config.ACTIONS.CONFIRM then
+        if pressed and state.confirm ~= nil then
+            local result = state.confirm(self, cursor)
+            if result == false then
+                UiContainer.play_sound("Error")
+            elseif state.confirm_sound ~= false then
+                UiContainer.play_sound("Cursor")
+            end
+        end
+    elseif cursor ~= nil and cursor:handle(button, state.axis) then
+        return 0
+    elseif state.buttons ~= nil and state.buttons[button] ~= nil then
+        state.buttons[button](self, event)
+    end
+    return 0
+end
+
+--- Creates a menu from its definition.
+--
+-- Methods not defined by the menu are filled in with the defaults above (set_state, activate,
+-- deactivate, is_active, on_button).
+UiContainer.Menu.new = function(definition)
+    for name, method in pairs(menu_methods) do
+        if definition[name] == nil then definition[name] = method end
+    end
+    if definition.on_start == nil then
+        definition.on_start = function(self) return 0 end
+    end
+    if definition.states == nil then definition.states = {} end
+    if definition.id ~= nil then UiContainer.menus[definition.id] = definition end
+    return definition
+end

+ 47 - 18
data/data/scripts/menu/name_menu.lua

@@ -1,6 +1,12 @@
 if UiContainer == nil then UiContainer = {} end
 
 --- The name menu.
+--- Is a key one that types a character in the name menu? The letters (their name is one character)
+-- and the space bar.
+local function is_typing_key(key)
+    return key ~= nil and (key == "Space" or #key == 1)
+end
+
 UiContainer.NameMenu = {
 
     --- Indicates if a chocobo is being renamed instead of a character.
@@ -12,6 +18,12 @@ UiContainer.NameMenu = {
     --- Default name.
     default_name = "",
 
+    --- Is the name menu shown when the game asks to name a character?
+    --
+    -- TODO: Turn it on when the menu works with the controller settings (see is_typing_key). While
+    -- it is off, the characters keep their default names and the game goes on.
+    enabled = false,
+
     --- Currently input-ed name.
     name = "",
 
@@ -45,23 +57,26 @@ UiContainer.NameMenu = {
 
     --- Handles button events.
     --
-    -- For the current submenu, handles directional keys, enter and escape events.
-    -- @param button Pressed button string key. "Up", "Left", "Enter" and "Escape" are handled.
+    -- For the current submenu, handles the directions, OK, and the keys that type letters.
+    -- @param button Game event (the directions, OK...) or, for the letters and the space bar,
+    -- the name of the key.
     -- @param event Trigger event. Normally, "Press".
+    -- @param source_key Name of the key that produced the event.
     on_button = function(self, button, event, source_key)
         if UiContainer.current_menu == "name" then
-            if button == Config.ACTIONS.CONFIRM and source_key == "Space" and UiContainer.current_submenu == "" then
+            -- A letter or the space bar that is also the OK key types, it doesn't confirm.
+            if button == Config.ACTIONS.CONFIRM and is_typing_key(source_key) and UiContainer.current_submenu == "" then
                 return 0
             end
             if UiContainer.current_submenu == "confirm" then
-                if button == "Right" then
+                if button == Config.CONTROLS.RIGHT then
                     audio_manager:play_sound("Cursor")
                     self.confirm_position = self.confirm_position + 1
                     if self.confirm_position > self.confirm_position_total then
                         self.confirm_position = 1
                     end
                     ui_manager:get_widget("NameMenu.Container.Confirm.Cursor"):set_default_animation("Position" .. self.confirm_position)
-                elseif button == "Left" then
+                elseif button == Config.CONTROLS.LEFT then
                     audio_manager:play_sound("Cursor")
                     self.confirm_position = self.confirm_position - 1
                     if self.confirm_position < 1 then
@@ -83,26 +98,26 @@ UiContainer.NameMenu = {
                 local character_index
                 if button == "Space" then
                     character_index = string.find(self.letters, " ")
-                else
+                elseif is_typing_key(button) then
                     character_index = string.find(self.letters, button)
                 end
 
                 -- Handle buttons.
-                if button == "Down" then
+                if button == Config.CONTROLS.DOWN then
                     audio_manager:play_sound("Cursor")
                     self.options_position = self.options_position + 1
                     if self.options_position > self.options_position_total then
                         self.options_position = 1
                     end
                     ui_manager:get_widget("NameMenu.Container.Options.Cursor"):set_default_animation("Position" .. self.options_position)
-                elseif button == "Up" then
+                elseif button == Config.CONTROLS.UP then
                     audio_manager:play_sound("Cursor")
                     self.options_position = self.options_position - 1
                     if self.options_position < 1 then
                         self.options_position = self.options_position_total
                     end
                     ui_manager:get_widget("NameMenu.Container.Options.Cursor"):set_default_animation("Position" .. self.options_position)
-                elseif button == "Left" then
+                elseif button == Config.CONTROLS.LEFT then
                     if self.text_cursor > 1 then
                         self.text_cursor = self.text_cursor - 1
                         self.draw_cursor(self)
@@ -110,7 +125,7 @@ UiContainer.NameMenu = {
                     else
                         audio_manager:play_sound("Error")
                     end
-                elseif button == "Right" then
+                elseif button == Config.CONTROLS.RIGHT then
                     if self.text_cursor < self.max_characters then
                         audio_manager:play_sound("Cursor")
                         self.text_cursor = self.text_cursor + 1
@@ -164,6 +179,26 @@ UiContainer.NameMenu = {
         return 0
     end,
 
+    --- Gets the default name of a character.
+    --
+    -- @param id ID of the character.
+    get_default_name = function(self, id)
+        local name = Characters[id].name
+        -- HACK: Hack for Ex-Soldier default. Can this be extracted from menu.lgp?
+        if string.lower(name) == "ex-soldier" then return "Cloud" end
+        return name
+    end,
+
+    --- Gives a character its default name, without showing the menu.
+    --
+    -- Used when the menu is not enabled.
+    --
+    -- @param id ID of the character.
+    apply_default_name = function(self, id)
+        Characters[id].name = self:get_default_name(id)
+        export_character_names()
+    end,
+
     --- Opens the name menu.
     --
     -- Populates and updates displayed data before opening. It also pauses the game. Shows a fade
@@ -180,14 +215,8 @@ UiContainer.NameMenu = {
         ui_manager:get_widget("NameMenu.Container.Confirm.Cursor"):set_default_animation("Position" .. self.confirm_position)
         ui_manager:get_widget("NameMenu.Container.Options.Cursor"):set_default_animation("Position" .. self.options_position)
         ui_manager:get_widget("NameMenu.Container.Character.Portrait"):set_image("characters/" .. tostring(self.id) .. ".png")
-        if (string.lower(Characters[self.id].name) == "ex-soldier") then
-            -- HACK: Hack for Ex-Soldier default. Can this be extracted from menu.lgp?
-            self.default_name = "Cloud"
-            self.name = "Cloud"
-        else
-            self.default_name = Characters[self.id].name
-            self.name = Characters[self.id].name
-        end
+        self.default_name = self:get_default_name(self.id)
+        self.name = self.default_name
         self.text_cursor = math.min(#(self.name) + 1, self.max_characters)
         self.populate_name(self)
         self.draw_cursor(self)

+ 31 - 44
data/data/scripts/menu/pause_menu.lua

@@ -1,64 +1,51 @@
 if UiContainer == nil then UiContainer = {} end
 
---- The pause menu
-UiContainer.PauseMenu = {
+--- The pause menu.
+--
+-- Unlike the other menus it isn't driven by UiContainer.current_menu: it reacts to the START
+-- button while the game is paused.
+UiContainer.PauseMenu = UiContainer.Menu.new{
 
-    --- Cursor position.
-    position = 1,
+    id = "pause",
 
-    --- Maximum cursor position
-    position_total = 2,
-
-    --- Run on creation, does nothing.
-    on_start = function( self )
-        return 0
-    end,
+    --- Cursor over the pause options. 1: Continue, 2: Exit to the begin menu.
+    cursor = UiContainer.Cursor.new{
+        widget = "PauseMenu.Menu.Cursor",
+        total = 2,
+    },
 
     --- Handles button events.
     --
     -- If the game is paused, it handles the navigation in the pause menu. If the game is not
-    -- paused, it only handles the pause event
-    on_button = function( self, button, event )
-        if System:is_paused() == true then
-            local menu_cursor = ui_manager:get_widget( "PauseMenu.Menu.Cursor" )
-            if button == Config.ACTIONS.START and event == "Press" then
-                System:unpause()
-            elseif button == Config.ACTIONS.CONFIRM and event == "Press" then
-                audio_manager:play_sound("Cursor")
-                script:request_end_sync( Script.UI, "PauseMenu", "hide", 0 )
-                if self.position == 2 then
-                    script:request_end_sync( Script.UI, "BeginMenu", "show", 0 )
-                    map("empty")
-                    MenuSettings.pause_available = false
-                else
-                    MenuSettings.available = true
-                end
-            elseif button == "Down" then
-                audio_manager:play_sound("Cursor")
-                self.position = self.position + 1
-                if self.position > self.position_total then
-                    self.position = 1;
-                end
-                menu_cursor:set_default_animation( "Position" .. self.position )
-            elseif button == "Up" then
-                audio_manager:play_sound("Cursor")
-                self.position = self.position - 1
-                if self.position <= 0 then
-                    self.position = self.position_total;
-                end
-                menu_cursor:set_default_animation( "Position" .. self.position )
-            end
-        elseif System:is_paused() == false then
+    -- paused, it only handles the pause event.
+    on_button = function(self, button, event)
+        if System:is_paused() == false then
             if button == Config.ACTIONS.START and event == "Press" then
                 System:pause()
             end
+            return 0
         end
 
+        if button == Config.ACTIONS.START and event == "Press" then
+            System:unpause()
+        elseif button == Config.ACTIONS.CONFIRM and event == "Press" then
+            UiContainer.play_sound("Cursor")
+            script:request_end_sync(Script.UI, "PauseMenu", "hide", 0)
+            if self.cursor:index() == 2 then
+                script:request_end_sync(Script.UI, "BeginMenu", "show", 0)
+                map("empty")
+                MenuSettings.pause_available = false
+            else
+                MenuSettings.available = true
+            end
+        else
+            self.cursor:handle(button, "vertical")
+        end
         return 0
     end,
 
     --- Shows the pause menu.
-    show = function( self )
+    show = function(self)
         ui_manager:get_widget("PauseMenu"):set_visible(true)
     end,
 

+ 313 - 0
data/data/scripts/menu/phs_menu.lua

@@ -0,0 +1,313 @@
+if UiContainer == nil then UiContainer = {} end
+
+local ROOT = "PhsMenu.Container."
+
+--- Slots of the party (left) and of the reserve (right, a grid of 3x3).
+local PARTY_SLOTS = 3
+local RESERVE_COLUMNS = 3
+local RESERVE_ROWS = 3
+local RESERVE_SLOTS = RESERVE_COLUMNS * RESERVE_ROWS
+
+--- The cursor goes over "cells": 1-3 are the party slots, 4-12 the reserve slots.
+local TOTAL_CELLS = PARTY_SLOTS + RESERVE_SLOTS
+
+--- Highest character id the PHS shows (Cid). Young Cloud and Sephiroth can't be chosen, and the
+-- nine characters fit in the party (3) and the reserve (9).
+local MAX_CHARACTER_ID = 8
+
+local function is_party_cell(cell)
+    return cell <= PARTY_SLOTS
+end
+
+--- Gets the cell the cursor goes to when a direction is pressed.
+--
+-- @param cell Current cell.
+-- @param button A direction button.
+-- @return The new cell, or nil if the cursor can't move that way.
+local function neighbour(cell, button)
+    local UP, DOWN = Config.CONTROLS.UP, Config.CONTROLS.DOWN
+    local LEFT, RIGHT = Config.CONTROLS.LEFT, Config.CONTROLS.RIGHT
+    if is_party_cell(cell) then
+        if button == UP then
+            return (cell - 2) % PARTY_SLOTS + 1
+        elseif button == DOWN then
+            return cell % PARTY_SLOTS + 1
+        elseif button == RIGHT then
+            -- First column of the reserve, in the same row.
+            return PARTY_SLOTS + (cell - 1) * RESERVE_COLUMNS + 1
+        end
+        return nil
+    end
+
+    local index = cell - PARTY_SLOTS - 1
+    local column = index % RESERVE_COLUMNS
+    local row = math.floor(index / RESERVE_COLUMNS)
+    if button == LEFT then
+        if column > 0 then return cell - 1 end
+        return math.min(row + 1, PARTY_SLOTS)
+    elseif button == RIGHT then
+        if column < RESERVE_COLUMNS - 1 then return cell + 1 end
+    elseif button == UP then
+        return PARTY_SLOTS + ((row - 1) % RESERVE_ROWS) * RESERVE_COLUMNS + column + 1
+    elseif button == DOWN then
+        return PARTY_SLOTS + ((row + 1) % RESERVE_ROWS) * RESERVE_COLUMNS + column + 1
+    end
+    return nil
+end
+
+--- Is a character locked in its place? Locked characters can't be moved or replaced.
+local function is_locked(char_id)
+    return char_id ~= nil and Characters[char_id].locked == true
+end
+
+--- Number of characters in the party.
+local function party_count()
+    local count = 0
+    for slot = 1, PARTY_SLOTS do
+        if Party[slot] ~= nil then count = count + 1 end
+    end
+    return count
+end
+
+--- Handlers of the directional buttons.
+local function direction_buttons()
+    local buttons = {}
+    for _, button in ipairs({
+        Config.CONTROLS.UP, Config.CONTROLS.DOWN, Config.CONTROLS.LEFT, Config.CONTROLS.RIGHT
+    }) do
+        buttons[button] = function(self, event)
+            local target = neighbour(self.cursor:index(), button)
+            if target ~= nil then
+                UiContainer.play_sound("Cursor")
+                self.cursor:set(target)
+            end
+        end
+    end
+    return buttons
+end
+
+--- The PHS menu: changes the characters of the party.
+--
+-- The party is on the left and the rest of the available characters on the right. A character is
+-- picked with OK, and then OK on another place swaps both:
+--  * party <-> party: changes the order (or moves the character to an empty slot).
+--  * party <-> reserve: the character leaves the party and the other one (if any) takes its place.
+--  * reserve <-> reserve: not allowed.
+-- Locked characters can't be moved or replaced. The party can only be left once it has three
+-- characters, or all the available ones if there are fewer.
+UiContainer.PhsMenu = UiContainer.Menu.new{
+
+    id = "phs",
+    parent = "main",
+
+    --- Cell the cursor is on (see TOTAL_CELLS).
+    cursor = UiContainer.Cursor.new{
+        widget = ROOT .. "Cursor",
+        total = TOTAL_CELLS,
+        sound = false,
+        on_move = function(cell)
+            UiContainer.PhsMenu:update_info(cell)
+        end,
+    },
+
+    --- Cell of the picked character, or nil.
+    held = nil,
+
+    --- IDs of the available characters that aren't in the party, in the order they are shown.
+    reserve = {},
+
+    states = {
+        [""] = {
+            buttons = direction_buttons(),
+            confirm = function(self)
+                return self:on_confirm(self.cursor:index())
+            end,
+            cancel = function(self)
+                if self.held ~= nil then
+                    self:set_held(nil)
+                    return
+                end
+                if not self:is_party_complete() then return false end
+                script:request_end_sync(Script.UI, "PhsMenu", "hide", 0)
+            end,
+        },
+    },
+
+    --- Gets the character in a cell.
+    -- @return Character ID, or nil if the cell is empty.
+    get_character = function(self, cell)
+        if is_party_cell(cell) then return Party[cell] end
+        return self.reserve[cell - PARTY_SLOTS]
+    end,
+
+    --- Puts a character in a cell, or empties it.
+    set_character = function(self, cell, char_id)
+        if is_party_cell(cell) then
+            Party[cell] = char_id
+        else
+            self.reserve[cell - PARTY_SLOTS] = char_id
+        end
+    end,
+
+    --- Number of characters in the reserve. The reserve can have empty cells between them.
+    reserve_count = function(self)
+        local count = 0
+        for slot = 1, RESERVE_SLOTS do
+            if self.reserve[slot] ~= nil then count = count + 1 end
+        end
+        return count
+    end,
+
+    --- Fills the reserve with the available characters that aren't in the party.
+    --
+    -- Characters are packed from the first cell, in order. From then on they keep their cell
+    -- while the party is being formed (see swap).
+    refresh_reserve = function(self)
+        self.reserve = {}
+        for id = 0, MAX_CHARACTER_ID do
+            local character = Characters[id]
+            if character ~= nil and character.enabled == true then
+                local in_party = false
+                for slot = 1, PARTY_SLOTS do
+                    if Party[slot] == id then in_party = true end
+                end
+                if not in_party then
+                    self.reserve[#self.reserve + 1] = id
+                end
+            end
+        end
+    end,
+
+    --- Has the party all the characters it can have?
+    --
+    -- Three are needed, unless there are fewer characters available.
+    is_party_complete = function(self)
+        local available = party_count() + self:reserve_count()
+        return party_count() >= math.min(PARTY_SLOTS, available)
+    end,
+
+    --- Picks a character, or drops the one that was picked.
+    -- @param cell Cell that was selected. nil to drop.
+    set_held = function(self, cell)
+        self.held = cell
+        local held = ui_manager:get_widget(ROOT .. "Held")
+        if cell == nil then
+            held:set_visible(false)
+        else
+            held:set_default_animation("Position" .. tostring(cell))
+            held:set_visible(true)
+        end
+    end,
+
+    --- OK was pressed on a cell.
+    -- @return false if the action isn't allowed.
+    on_confirm = function(self, cell)
+        local character = self:get_character(cell)
+        if self.held == nil then
+            -- Pick a character.
+            if character == nil or is_locked(character) then return false end
+            self:set_held(cell)
+            return
+        end
+
+        -- Picking the same one again drops it.
+        if self.held == cell then
+            self:set_held(nil)
+            return
+        end
+
+        if not self:swap(self.held, cell) then return false end
+        self:set_held(nil)
+        self:populate()
+    end,
+
+    --- Swaps the characters of two cells.
+    -- @return false if it isn't allowed.
+    swap = function(self, from, to)
+        if not is_party_cell(from) and not is_party_cell(to) then return false end
+        local a = self:get_character(from)
+        local b = self:get_character(to)
+        if is_locked(a) or is_locked(b) then return false end
+
+        -- Both cells exchange their contents, so nobody in the reserve changes place.
+        self:set_character(from, b)
+        self:set_character(to, a)
+        set_party(Party[1], Party[2], Party[3])
+        -- TODO: If the leader (first slot) changed, change the player entity of the field too.
+        return true
+    end,
+
+    --- Moves the characters of the party to the first slots, with no gaps between them.
+    compact_party = function(self)
+        local members = {}
+        for slot = 1, PARTY_SLOTS do
+            if Party[slot] ~= nil then members[#members + 1] = Party[slot] end
+        end
+        set_party(members[1], members[2], members[3])
+    end,
+
+    --- Shows the characters of the party and of the reserve.
+    populate = function(self)
+        self:update_info(self.cursor:index())
+        for slot = 1, PARTY_SLOTS do
+            local path = ROOT .. "Party.Slot" .. tostring(slot)
+            local id = Party[slot]
+            if id ~= nil then
+                UiContainer.populate_character_data(path, Characters[id])
+                ui_manager:get_widget(path .. ".Portrait"):set_image("characters/" .. tostring(id) .. ".png")
+            else
+                UiContainer.populate_character_data(path, nil)
+            end
+        end
+        for slot = 1, RESERVE_SLOTS do
+            local path = ROOT .. "Reserve.Slot" .. tostring(slot)
+            local id = self.reserve[slot]
+            ui_manager:get_widget(path):set_visible(id ~= nil)
+            if id ~= nil then
+                ui_manager:get_widget(path .. ".Portrait"):set_image("characters/" .. tostring(id) .. ".png")
+            end
+        end
+    end,
+
+    --- Shows the details of the character under the cursor in the top right window.
+    --
+    -- Only for the reserve: the characters of the party already show them next to their portrait.
+    update_info = function(self, cell)
+        local path = ROOT .. "Info.Character"
+        local id = nil
+        if not is_party_cell(cell) then id = self:get_character(cell) end
+        if id ~= nil then
+            UiContainer.populate_character_data(path, Characters[id])
+        else
+            UiContainer.populate_character_data(path, nil)
+        end
+    end,
+
+    --- Opens the PHS menu.
+    show = function(self)
+        self:activate()
+        -- Empty slots may hold an invalid ID (e.g. -1). Leave them as nil.
+        for slot = 1, PARTY_SLOTS do
+            if Party[slot] ~= nil and Characters[Party[slot]] == nil then Party[slot] = nil end
+        end
+        set_party(Party[1], Party[2], Party[3])
+        -- Whoever is in the party is available, even if no script enabled them.
+        for slot = 1, PARTY_SLOTS do
+            if Party[slot] ~= nil then Characters[Party[slot]].enabled = true end
+        end
+        self:refresh_reserve()
+        self:set_held(nil)
+        self:populate()
+        self.cursor:set(1)
+        ui_manager:get_widget("PhsMenu"):set_visible(true)
+        return 0
+    end,
+
+    --- Closes the PHS menu and goes back to the main menu.
+    hide = function(self)
+        self:compact_party()
+        ui_manager:get_widget("PhsMenu"):set_visible(false)
+        self:deactivate()
+        return 0
+    end,
+}

+ 49 - 0
data/data/scripts/menu/quit_menu.lua

@@ -0,0 +1,49 @@
+if UiContainer == nil then UiContainer = {} end
+
+--- The quit menu.
+--
+-- Only shows a window warning that the unsaved progress will be lost. Confirming quits the game,
+-- declining (or cancelling) goes back to the main menu.
+UiContainer.QuitMenu = UiContainer.Menu.new{
+
+    id = "quit",
+    parent = "main",
+
+    --- Cursor over Yes and No.
+    cursor = UiContainer.Cursor.new{
+        widget = "QuitMenu.Container.Confirm.Cursor",
+        total = 2,
+    },
+
+    states = {
+        [""] = {
+            cursor = function(self) return self.cursor end,
+            axis = "vertical",
+            confirm = function(self, cursor)
+                if cursor:index() == 1 then
+                    console("quit")
+                else
+                    script:request_end_sync(Script.UI, "QuitMenu", "hide", 0)
+                end
+            end,
+            cancel = function(self)
+                script:request_end_sync(Script.UI, "QuitMenu", "hide", 0)
+            end,
+        },
+    },
+
+    --- Opens the window. The cursor starts on No.
+    show = function(self)
+        self:activate()
+        self.cursor:set(2)
+        ui_manager:get_widget("QuitMenu"):set_visible(true)
+        return 0
+    end,
+
+    --- Closes the window and goes back to the main menu.
+    hide = function(self)
+        ui_manager:get_widget("QuitMenu"):set_visible(false)
+        self:deactivate()
+        return 0
+    end,
+}

+ 43 - 97
data/data/scripts/menu/save_menu.lua

@@ -1,75 +1,38 @@
 if UiContainer == nil then UiContainer = {} end
 
---- The item menu.
-UiContainer.SaveMenu = {
+--- The save/load menu.
+UiContainer.SaveMenu = UiContainer.Menu.new{
+
+    id = "save",
 
     --- Current operation ("save/load")
     operation = "save",
 
-    --- Current cursor position in the top bar (1-3).
-    position = 1,
-
-    --- Max cursor position in the top bar.
-    position_total = 3,
-
-    --- Maximum scroll position.
+    --- Maximum number of save slots.
     max_slots = 15,
 
-    --- First visible save slot.
-    first_visible = 1,
+    --- Cursor over the three visible slots, scrolling over all of them.
+    cursor = UiContainer.ListCursor.new{
+        widget = "SaveMenu.Container.Cursor",
+        rows = 3,
+        count = 15,
+        on_scroll = function() UiContainer.SaveMenu:populate_slots() end,
+    },
 
-    --- Run when the menu is creaded.
-    --
-    -- It does nothing.
-    on_start = function(self)
-        return 0
-    end,
-
-    --- Handles button events.
-    --
-    -- For the current submenu, handles directional keys, enter and escape events.
-    -- @param button Pressed direction or action name.
-    -- @param event Trigger event. Normally, "Press".
-    on_button = function(self, button, event)
-        if UiContainer.current_menu == "save" then
-            if UiContainer.current_submenu == "" then
-                if button == Config.ACTIONS.CANCEL and event == "Press" then
-                    audio_manager:play_sound("Back")
-                    script:request_end_sync(Script.UI, "SaveMenu", "hide", 0)
-                elseif button == Config.ACTIONS.CONFIRM then
-                    self.action(self, self.first_visible - 1 + self.position)
-                elseif button == Config.CONTROLS.DOWN then
-                    if self.position < self.position_total and self.first_visible + self.position < self.max_slots then
-                        -- Move cursor down
-                        self.position = self.position + 1
-                        ui_manager:get_widget("SaveMenu.Container.Cursor"):set_default_animation("Position" .. self.position)
-                        audio_manager:play_sound("Cursor")
-                    elseif self.position == self.position_total and self.first_visible + self.position < self.max_slots then
-                        self.first_visible = self.first_visible + 1
-                        self.populate_slots(self)
-                        audio_manager:play_sound("Cursor")
-                    else
-                        audio_manager:play_sound("Error")
-                    end
-                elseif button == Config.CONTROLS.UP then
-                    if self.position > 1 then
-                        -- Move cursor up
-                        self.position = self.position - 1
-                        ui_manager:get_widget("SaveMenu.Container.Cursor"):set_default_animation("Position" .. self.position)
-                        audio_manager:play_sound("Cursor")
-                    elseif self.position == 1 and self.first_visible > 1 then
-                        self.first_visible = self.first_visible - 1
-                        self.populate_slots(self)
-                        audio_manager:play_sound("Cursor")
-                    else
-                        audio_manager:play_sound("Error")
-                    end
-                end
-            elseif UiContainer.current_submenu == "confirm" then
-            end
-        end
-        return 0
-    end,
+    states = {
+        [""] = {
+            cursor = function(self) return self.cursor end,
+            axis = "vertical",
+            cancel = function(self)
+                script:request_end_sync(Script.UI, "SaveMenu", "hide", 0)
+            end,
+            -- The action plays its own sound.
+            confirm_sound = false,
+            confirm = function(self, cursor)
+                self:action(cursor:index())
+            end,
+        },
+    },
 
     --- Opens the save menu.
     --
@@ -84,39 +47,31 @@ UiContainer.SaveMenu = {
             print("before calling show()")
             return 0
         end
-        UiContainer.current_menu = "save"
-        UiContainer.current_submenu = ""
-        if self.operation == "load" then
-            ui_manager:get_widget("SaveMenu.Container.TopBar.DescSave"):set_visible(false)
-            ui_manager:get_widget("SaveMenu.Container.TopBar.DescLoad"):set_visible(true)
-            ui_manager:get_widget("SaveMenu.Container.Title.TitleSave"):set_visible(false)
-            ui_manager:get_widget("SaveMenu.Container.Title.TitleLoad"):set_visible(true)
-        else
-            ui_manager:get_widget("SaveMenu.Container.TopBar.DescSave"):set_visible(true)
-            ui_manager:get_widget("SaveMenu.Container.TopBar.DescLoad"):set_visible(false)
-            ui_manager:get_widget("SaveMenu.Container.Title.TitleSave"):set_visible(true)
-            ui_manager:get_widget("SaveMenu.Container.Title.TitleLoad"):set_visible(false)
-        end
+        self:activate()
+        local loading = (self.operation == "load")
+        local container = "SaveMenu.Container."
+        ui_manager:get_widget(container .. "TopBar.DescSave"):set_visible(not loading)
+        ui_manager:get_widget(container .. "TopBar.DescLoad"):set_visible(loading)
+        ui_manager:get_widget(container .. "Title.TitleSave"):set_visible(not loading)
+        ui_manager:get_widget(container .. "Title.TitleLoad"):set_visible(loading)
         ui_manager:get_widget("SaveMenu"):set_visible(true)
-        UiContainer.current_menu = "save"
-        self.position = 1
-        self.first_visible = 1
-        ui_manager:get_widget("SaveMenu.Container.Cursor"):set_default_animation("Position" .. self.position)
-        self.populate_slots(self)
+        self.cursor.count = self.max_slots
+        self.cursor:reset()
+        self:populate_slots()
         return 0;
     end,
 
     populate_slots = function(self)
         for s = 1, 3 do
-            local slot = self.first_visible + s - 2 -- 0-index
+            local slot = self.cursor.first + s - 2 -- 0-index
             if savemap_manager:is_slot_empty(slot) == true then
                 ui_manager:get_widget("SaveMenu.Container.Slot" .. tostring(s) .. ".Slot"):set_visible(false)
                 ui_manager:get_widget("SaveMenu.Container.Slot" .. tostring(s) .. ".Empty"):set_visible(true)
             else
                 local slot_id = "SaveMenu.Container.Slot" .. tostring(s) .. ".Slot."
-                ui_manager:get_widget(slot_id .. "TimeMoney.Money"):set_text(UiContainer.pad_value(savemap_manager:get_slot_money(slot), 7))
+                ui_manager:get_widget(slot_id .. "TimeMoney.Money"):set_text(UiContainer.format_money(savemap_manager:get_slot_money(slot), 7))
                 local seconds = savemap_manager:get_slot_game_time(slot)
-                local time = UiContainer.pad_value(math.floor(seconds / 3600), 3) .. ":" .. UiContainer.zero_pad_value(math.floor((seconds % 3600) / 60), 2)
+                local time = UiContainer.format_time(seconds)
                 ui_manager:get_widget(slot_id .. "TimeMoney.Time"):set_text(time)
                 ui_manager:get_widget(slot_id .. "Location.Name"):set_text(savemap_manager:get_slot_location_name(slot))
                 local save_party = {
@@ -145,14 +100,10 @@ UiContainer.SaveMenu = {
             if savemap_manager:is_slot_empty(slot - 1) == false and ControlKey ~= savemap_manager:get_slot_control_key(slot - 1) then
                 -- TODO: Confirmation dialog
                 print("OVERRIDE ANOTHER SAVE")
-                save_game(slot - 1, true)
-                audio_manager:play_sound("Cursor")
-                self.populate_slots(self)
-            else
-                save_game(slot - 1, true)
-                audio_manager:play_sound("Cursor")
-                self.populate_slots(self)
             end
+            save_game(slot - 1, true)
+            audio_manager:play_sound("Cursor")
+            self:populate_slots()
         elseif self.operation == "load" then
             audio_manager:play_sound("Window")
             script:request_end_sync(Script.UI, "SaveMenu", "hide", 0)
@@ -164,12 +115,7 @@ UiContainer.SaveMenu = {
     --- Hides the item menu and goes back to the main menu.
     hide = function(self)
         ui_manager:get_widget("SaveMenu"):set_visible(false)
-        if self.operation == "load" then
-            UiContainer.current_menu = "begin"
-        else
-            UiContainer.current_menu = "main"
-        end
-        UiContainer.current_submenu = ""
+        self:deactivate(self.operation == "load" and "begin" or "main")
         savemap_manager:release()
         return 0;
     end,

+ 215 - 0
data/data/scripts/menu/status_menu.lua

@@ -0,0 +1,215 @@
+if UiContainer == nil then UiContainer = {} end
+
+local ROOT = "StatusMenu.Container."
+
+--- Inside width of the bars of the header.
+local BAR_WIDTH = 54
+
+--- Colour of the names of the elements and statuses that apply, and of those that don't.
+local COLOUR_ACTIVE = { 1.0, 1.0, 1.0 }
+local COLOUR_INACTIVE = { 0.45, 0.45, 0.35 }
+
+--- Widgets of the first page with a number, and where its value comes from.
+local NUMBERS = {
+    { "Page1.Attributes.Str", function(c) return c.stats.str.total end },
+    { "Page1.Attributes.Dex", function(c) return c.stats.dex.total end },
+    { "Page1.Attributes.Vit", function(c) return c.stats.vit.total end },
+    { "Page1.Attributes.Mag", function(c) return c.stats.mag.total end },
+    { "Page1.Attributes.Spr", function(c) return c.stats.spr.total end },
+    { "Page1.Attributes.Lck", function(c) return c.stats.lck.total end },
+    { "Page1.Attributes.Atk", function(c) return c.stats.atk end },
+    { "Page1.Attributes.AtkPct", function(c) return c.stats.acc end },
+    { "Page1.Attributes.Def", function(c) return c.stats.def end },
+    { "Page1.Attributes.DefPct", function(c) return c.stats.eva end },
+    { "Page1.Attributes.MAtk", function(c) return c.stats.matk end },
+    { "Page1.Attributes.MDef", function(c) return c.stats.mdef end },
+    { "Page1.Attributes.MDefPct", function(c) return c.stats.meva end },
+    { "Character.Exp", function(c) return c.exp end, 8 },
+    { "Character.Next", function(c) return c.exp_to_next end, 8 },
+    { "Character.LimitLevel", function(c) return c.limit.current end, 2 },
+}
+
+--- Elements of the second page, in the order of the widgets.
+local ELEMENTS = {
+    { Characters.ELEMENT.FIRE, "Fire" }, { Characters.ELEMENT.ICE, "Ice" },
+    { Characters.ELEMENT.BOLT, "Lightning" }, { Characters.ELEMENT.EARTH, "Earth" },
+    { Characters.ELEMENT.POISON, "Poison" }, { Characters.ELEMENT.GRAVITY, "Gravity" },
+    { Characters.ELEMENT.WATER, "Water" }, { Characters.ELEMENT.WIND, "Wind" },
+    { Characters.ELEMENT.HOLY, "Holy" },
+}
+
+--- Statuses of the third page, in the order of the widgets.
+local STATUSES = {
+    { Characters.STATUS.DEATH, "Death" }, { Characters.STATUS.NEAR_DEATH, "Near-death" },
+    { Characters.STATUS.SLEEP, "Sleep" }, { Characters.STATUS.POISON, "Poison" },
+    { Characters.STATUS.SADNESS, "Sadness" }, { Characters.STATUS.FURY, "Fury" },
+    { Characters.STATUS.CONFUSION, "Confusion" }, { Characters.STATUS.SILENCE, "Silence" },
+    { Characters.STATUS.HASTE, "Haste" }, { Characters.STATUS.SLOW, "Slow" },
+    { Characters.STATUS.STOP, "Stop" }, { Characters.STATUS.FROG, "Frog" },
+    { Characters.STATUS.SMALL, "Small" }, { Characters.STATUS.SLOW_NUMB, "Slow-numb" },
+    { Characters.STATUS.PETRIFY, "Petrify" }, { Characters.STATUS.REGEN, "Regen" },
+    { Characters.STATUS.BARRIER, "Barrier" }, { Characters.STATUS.MBARRIER, "MBarrier" },
+    { Characters.STATUS.REFLECT, "Reflect" }, { Characters.STATUS.SHIELD, "Shield" },
+    { Characters.STATUS.D_SENTENCE, "Death-sentence" }, { Characters.STATUS.MANIPULATE, "Manipulate" },
+    { Characters.STATUS.BERSERK, "Berserk" }, { Characters.STATUS.PEERLESS, "Peerless" },
+    { Characters.STATUS.PARALYZED, "Paralyzed" }, { Characters.STATUS.DARKNESS, "Darkness" },
+}
+
+--- Sets a name, white if it applies, gray otherwise.
+local function set_name(path, name, active)
+    local widget = ui_manager:get_widget(path)
+    local colour = active and COLOUR_ACTIVE or COLOUR_INACTIVE
+    -- The text is set after the colour to apply it.
+    widget:set_colour(colour[1], colour[2], colour[3])
+    widget:set_text(name)
+end
+
+--- The status menu. Purely informative.
+--
+-- It has three pages that share the character data: attributes and equipment, elemental effects,
+-- and status effects. OK goes to the next page, looping, and Cancel closes the menu.
+UiContainer.StatusMenu = UiContainer.Menu.new{
+
+    id = "status",
+    parent = "main",
+
+    --- ID of the character being shown.
+    char_id = -1,
+
+    --- Page being shown (1-3).
+    page = 1,
+
+    --- Total number of pages.
+    page_total = 3,
+
+    states = {
+        [""] = {
+            confirm = function(self)
+                self:show_page(self.page % self.page_total + 1)
+            end,
+            cancel = function(self)
+                script:request_end_sync(Script.UI, "StatusMenu", "hide", 0)
+            end,
+        },
+    },
+
+    --- Shows a page and hides the others.
+    show_page = function(self, page)
+        self.page = page
+        for p = 1, self.page_total do
+            ui_manager:get_widget(ROOT .. "Page" .. tostring(p)):set_visible(p == page)
+        end
+    end,
+
+    --- Shows the data shared by every page.
+    populate_header = function(self, character)
+        UiContainer.populate_character_data(ROOT .. "Character", character)
+        ui_manager:get_widget(ROOT .. "Character.Portrait"):set_image("characters/" .. tostring(self.char_id) .. ".png")
+        UiContainer.set_bar(ROOT .. "Character.NextLevelLinePart", UiContainer.exp_progress(character), BAR_WIDTH)
+        UiContainer.set_limit_bar(ROOT .. "Character.LimitLinePart", character, BAR_WIDTH)
+    end,
+
+    --- Shows the slots and materia of a weapon or armor.
+    --
+    -- @param prefix Prefix of the widgets: "Wpn" or "Arm".
+    -- @param item Definition of the weapon or armor.
+    -- @param materia Materia equipped on it. Slots 0-7.
+    populate_slots = function(self, prefix, item, materia)
+        local path = ROOT .. "Page1.Equipment." .. prefix .. ".Slots."
+        for slot = 1, 8 do
+            ui_manager:get_widget(path .. "Slot" .. tostring(slot)):set_visible(#(item.slots) >= slot)
+            local equipped = materia[slot - 1]
+            local widget = ui_manager:get_widget(path .. "Mat" .. tostring(slot))
+            if equipped ~= nil and Game.Materia[equipped.id] ~= nil then
+                widget:set_image("icons/materia_" .. tostring(Game.Materia[equipped.id].type) .. ".png")
+                widget:set_visible(true)
+            else
+                widget:set_visible(false)
+            end
+            -- At every odd slot, if there is a next slot and it's linked, show the linker.
+            if slot % 2 ~= 0 then
+                local linked = #(item.slots) > slot and item.slots[slot] == 1
+                ui_manager:get_widget(path .. "Link" .. tostring(math.floor(slot / 2) + 1)):set_visible(linked)
+            end
+        end
+    end,
+
+    --- Shows the attributes and the equipment.
+    populate_page_1 = function(self, character)
+        for _, entry in ipairs(NUMBERS) do
+            local value = entry[2](character)
+            ui_manager:get_widget(ROOT .. entry[1]):set_text(UiContainer.pad_value(value or 0, entry[3] or 4))
+        end
+
+        local equipment = ROOT .. "Page1.Equipment."
+        local weapon = Game.Items[character.weapon.id]
+        local armor = Game.Items[character.armor.id]
+        ui_manager:get_widget(equipment .. "Wpn.Lbl"):set_text(weapon.name)
+        ui_manager:get_widget(equipment .. "Arm.Lbl"):set_text(armor.name)
+        if character.accessory ~= nil then
+            ui_manager:get_widget(equipment .. "Acc.Lbl"):set_text(Game.Items[character.accessory].name)
+        else
+            ui_manager:get_widget(equipment .. "Acc.Lbl"):set_text("")
+        end
+        ui_manager:get_widget(equipment .. "Wpn.Icon"):set_image("icons/item_weapon_" .. tostring(self.char_id) .. ".png")
+        self:populate_slots("Wpn", weapon, character.weapon.materia)
+        self:populate_slots("Arm", armor, character.armor.materia)
+    end,
+
+    --- Shows the elements the attacks have, and the elements that are halved, nullified or
+    -- absorbed.
+    populate_page_2 = function(self, effects)
+        local mode = Characters.ELEMENT_MODE
+        local groups = {
+            { "Attack", function(element) return effects.elements.attack[element] == true end },
+            { "Halve", function(element) return effects.elements.defense[element] == mode.HALF end },
+            { "Invalid", function(element) return effects.elements.defense[element] == mode.NULLIFY end },
+            { "Absorb", function(element) return effects.elements.defense[element] == mode.ABSORB end },
+        }
+        for _, group in ipairs(groups) do
+            for index, element in ipairs(ELEMENTS) do
+                set_name(ROOT .. "Page2.Table." .. group[1] .. ".Name" .. tostring(index), element[2], group[2](element[1]))
+            end
+        end
+    end,
+
+    --- Shows the statuses the attacks inflict, and the ones the character is protected from.
+    populate_page_3 = function(self, effects)
+        local groups = {
+            { "Attack", effects.status.attack },
+            { "Defend", effects.status.defense },
+        }
+        for _, group in ipairs(groups) do
+            for index, status in ipairs(STATUSES) do
+                set_name(ROOT .. "Page3.Table." .. group[1] .. ".Name" .. tostring(index), status[2], group[2][status[1]] == true)
+            end
+        end
+    end,
+
+    --- Shows the data of the current character in every page.
+    populate = function(self)
+        local character = Characters[self.char_id]
+        local effects = Characters.get_effects(self.char_id)
+        self:populate_header(character)
+        self:populate_page_1(character)
+        self:populate_page_2(effects)
+        self:populate_page_3(effects)
+    end,
+
+    --- Opens the status menu for UiContainer.current_character_id.
+    show = function(self)
+        self.char_id = UiContainer.current_character_id
+        self:activate()
+        self:populate()
+        self:show_page(1)
+        ui_manager:get_widget("StatusMenu"):set_visible(true)
+        return 0
+    end,
+
+    --- Hides the status menu and goes back to the main menu.
+    hide = function(self)
+        ui_manager:get_widget("StatusMenu"):set_visible(false)
+        self:deactivate()
+        return 0
+    end,
+}

+ 126 - 148
data/data/scripts/save.lua

@@ -14,7 +14,8 @@ end
 -- @param force If false, exisiting game data will not be overwritten if control keys are different.
 save_game = function(slot, force)
     savemap_manager:set_control_key(ControlKey)
-    savemap_manager:set_window_colours(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) -- TODO Actual data
+    -- Settings of the config menu, and the colours of the windows.
+    UiContainer.ConfigMenu:save_settings()
     savemap_manager:set_money(Inventory.money)
     savemap_manager:set_game_time(123456) -- TODO Actual data
     savemap_manager:set_countdown_time(0) -- TODO Actual data
@@ -35,7 +36,7 @@ save_game = function(slot, force)
             if Materia[i].id == Materia.ENEMY_SKILL_ID then
                 for s = 1, 24 do
                     if Materia[i].skills[s] == true then
-                        savemap_manager:set_e_skill_materia(i - 1, s, true)
+                        savemap_manager:set_e_skill_materia(i - 1, s - 1, true)
                     end
                 end
             end
@@ -46,7 +47,6 @@ save_game = function(slot, force)
     local x, y, z = player:get_position()
 
     savemap_manager:set_location(x, y, z, player:get_move_triangle_id(), player:get_rotation(), System["MapChanger"].current_map_name, System["MapChanger"].location_name) -- TODO Actual data
-    -- TODO Settings, when implemented
     for i = 0, 11 do
         if Characters[i] ~= nil and Characters[i].char_id ~= nil then
             local c = Characters[i]
@@ -63,23 +63,17 @@ save_game = function(slot, force)
                 savemap_manager:set_character_limit_learned(i, l - 1, 0, c.limit.learned[l][1], c.limit.uses[l])
                 savemap_manager:set_character_limit_learned(i, l - 1, 1, c.limit.learned[l][2], c.limit.uses[l])
             end
-            for m = 0, 8 do
-                if c.weapon.materia[m] ~= nil then
-                    savemap_manager:set_character_materia(i, true, m, c.weapon.materia[m].id, c.weapon.materia[m].ap)
-                    if c.weapon.materia[m].id == Materia.ENEMY_SKILL_ID then
-                        for s = 1, 24 do
-                            if c.weapon.materia[m].skills[s] == true then
-                                savemap_manager:set_character_e_skill_materie(i, true, m, s, true)
-                            end
-                        end
-                    end
-                end
-                if c.armor[m] ~= nil then
-                    savemap_manager:set_character_materia(i, false, m, c.armor.materia[m].id, c.weapon.materia[m].ap)
-                    if c.weapon.materia[m].id == Materia.ENEMY_SKILL_ID then
-                        for s = 1, 24 do
-                            if c.armor.materia[m].skills[s] == true then
-                                savemap_manager:set_character_e_skill_materie(i, true, m, s, true)
+            for m = 0, 7 do
+                for _, part in ipairs({"weapon", "armor"}) do
+                    local is_weapon = (part == "weapon")
+                    local materia = c[part].materia[m]
+                    if materia ~= nil then
+                        savemap_manager:set_character_materia(i, is_weapon, m, materia.id, materia.ap)
+                        if materia.id == Materia.ENEMY_SKILL_ID then
+                            for s = 1, 24 do
+                                if materia.skills[s] == true then
+                                    savemap_manager:set_character_e_skill_materia(i, is_weapon, m, s - 1, true)
+                                end
                             end
                         end
                     end
@@ -99,7 +93,97 @@ save_game = function(slot, force)
     savemap_manager:save(slot, force or true)
 end
 
---- Laods a game.
+--- Loads the characters from a saved game. Everything save_game saves of them.
+--
+-- The characters that already exist are updated in place, so the data that isn't saved stays.
+--
+-- @param slot The slot to load (0-15).
+local function load_characters(slot)
+    -- The savemap has room for 11 characters.
+    for c = 0, 10 do
+        if savemap_manager:get_slot_character_char_id(slot, c) ~= -1 then
+            local character = Characters[c] or {}
+            Characters[c] = character
+            character.char_id = savemap_manager:get_slot_character_char_id(slot, c)
+            character.name = savemap_manager:get_slot_character_name(slot, c)
+            character.level = savemap_manager:get_slot_character_level(slot, c)
+            character.enabled = savemap_manager:is_slot_character_enabled(slot, c)
+            character.locked = savemap_manager:is_slot_character_locked(slot, c)
+            character.kills = savemap_manager:get_slot_character_kills(slot, c)
+            character.back_row = savemap_manager:is_slot_character_back_row(slot, c)
+            character.exp = savemap_manager:get_slot_character_exp(slot, c)
+            character.exp_to_next = savemap_manager:get_slot_character_exp_to_next(slot, c)
+
+            -- Stats: strength, vitality, magic, spirit, dexterity and luck, then HP and MP.
+            character.stats = character.stats or {}
+            local stat_names = { "str", "vit", "mag", "spr", "dex", "lck" }
+            for index, name in ipairs(stat_names) do
+                character.stats[name] = {
+                    base = savemap_manager:get_slot_character_stat_base(slot, c, index - 1),
+                    bonus = savemap_manager:get_slot_character_stat_extra(slot, c, index - 1),
+                    total = 0,
+                }
+            end
+            -- For HP and MP the "extra" value is the current one. The maximum is calculated.
+            character.stats.hp = {
+                current = savemap_manager:get_slot_character_stat_extra(slot, c, 6),
+                base = savemap_manager:get_slot_character_stat_base(slot, c, 6),
+                max = 65535,
+            }
+            character.stats.mp = {
+                current = savemap_manager:get_slot_character_stat_extra(slot, c, 7),
+                base = savemap_manager:get_slot_character_stat_base(slot, c, 7),
+                max = 65535,
+            }
+
+            -- Limit breaks. Levels are 0-3 in the savemap, and 1-4 in the game.
+            character.limit = {
+                current = savemap_manager:get_slot_character_limit_level(slot, c),
+                bar = savemap_manager:get_slot_character_limit_bar(slot, c),
+                learned = {},
+                uses = {},
+            }
+            for l = 1, 4 do
+                character.limit.learned[l] = {
+                    [1] = savemap_manager:is_slot_character_limit_learned(slot, c, l - 1, 0),
+                    [2] = savemap_manager:is_slot_character_limit_learned(slot, c, l - 1, 1),
+                }
+                character.limit.uses[l] = savemap_manager:get_slot_character_limit_uses(slot, c, l - 1)
+            end
+
+            -- Equipment and its materia (slots 0-7).
+            local accessory = savemap_manager:get_slot_character_accessory_id(slot, c)
+            character.accessory = (accessory >= 0) and accessory or nil
+            character.weapon = { id = savemap_manager:get_slot_character_weapon_id(slot, c), materia = {} }
+            character.armor = { id = savemap_manager:get_slot_character_armor_id(slot, c), materia = {} }
+            for _, part in ipairs({ "weapon", "armor" }) do
+                local is_weapon = (part == "weapon")
+                for m = 0, 7 do
+                    local id = savemap_manager:get_slot_character_materia_id(slot, c, is_weapon, m)
+                    if id >= 0 then
+                        local materia = { id = id, ap = savemap_manager:get_slot_character_materia_ap(slot, c, is_weapon, m) }
+                        if id == Materia.ENEMY_SKILL_ID then
+                            materia.ap = 0
+                            materia.skills = {}
+                            for s = 1, 24 do
+                                materia.skills[s] = savemap_manager:get_slot_character_materia_e_skill_learned(slot, c, is_weapon, m, s - 1)
+                            end
+                        end
+                        character[part].materia[m] = materia
+                    end
+                end
+            end
+
+            -- TODO: Status
+            character.status = {}
+            Characters.recalculate_stats(c)
+        end
+    end
+end
+
+--- Loads a game.
+--
+-- It loads everything save_game saves, and then goes to the field where the game was saved.
 --
 -- @param slot The slot to load (0-15).
 load_game = function(slot)
@@ -109,7 +193,8 @@ load_game = function(slot)
             Banks[b][a] = savemap_manager:get_slot_data(slot, b, a)
         end
     end
-    -- TODO: Window colours
+    -- Settings of the config menu, and the colours of the windows.
+    UiContainer.ConfigMenu:load_settings(slot)
     Inventory.money = savemap_manager:get_slot_money(slot)
     -- TODO: Game time
     -- TODO: Countdown
@@ -119,25 +204,29 @@ load_game = function(slot)
             Party[p] = nil
         end
     end
-    for i = 0, MAX_INVENTORY_SLOTS do
-        Inventory[i] = {item = 0, quantity = 0}
-        Inventory[i].quantity = savemap_manager:get_slot_item_at_pos_qty(slot, i)
-        if Inventory[i].quantity > 0 then
-            Inventory[i].id = savemap_manager:get_slot_item_at_pos_id(slot, i)
+    -- The battles and dialogs read the party from the text manager.
+    export_party_members()
+    -- The inventory and the materia are 1-based in the game, and 0-based in the savemap.
+    for i = 1, MAX_INVENTORY_SLOTS do
+        local quantity = savemap_manager:get_slot_item_at_pos_qty(slot, i - 1)
+        if quantity > 0 then
+            Inventory[i] = {
+                item = savemap_manager:get_slot_item_at_pos_id(slot, i - 1),
+                quantity = quantity,
+            }
         else
             Inventory[i] = nil
         end
     end
-    for i = 0, MAX_MATERIA_SLOTS do
-        Materia[i] = {id = -1, ap = 0}
-        Materia[i].id = savemap_manager:get_slot_materia_at_pos_id(slot, i)
-        if Materia[i].id >= 0 then
-            Materia[i].ap = savemap_manager:get_slot_materia_at_pos_ap(slot, i)
-            if Materia[i].id == Materia.ENEMY_SKILL_ID then
+    for i = 1, MAX_MATERIA_SLOTS do
+        local id = savemap_manager:get_slot_materia_at_pos_id(slot, i - 1)
+        if id >= 0 then
+            Materia[i] = { id = id, ap = savemap_manager:get_slot_materia_at_pos_ap(slot, i - 1) }
+            if id == Materia.ENEMY_SKILL_ID then
                 Materia[i].ap = 0
                 Materia[i].skills = {}
                 for s = 1, 24 do
-                    Materia[i].skills[s] = savemap_manager:is_slot_materia_at_pos_e_skill_learned(slot, i, s - 1)
+                    Materia[i].skills[s] = savemap_manager:is_slot_materia_at_pos_e_skill_learned(slot, i - 1, s - 1)
                 end
             end
         else
@@ -145,122 +234,11 @@ load_game = function(slot)
         end
     end
     -- TODO: Stash (same as materia)
-    for i = 0, MAX_KEY_SLOTS do
-        Inventory.key[i] = savemap_manager:get_slot_key_item(slot, i)
-    end
-    -- Characters
-    for c = 0, 11 do
-        Characters[c].char_id = savemap_manager:get_slot_character_char_id(slot, c)
-        if Characters[c].char_id == -1 then
-            Characters[c] = nil
-        else
-            Characters[c] = {}
-            Characters[c].name = savemap_manager:get_slot_character_name(slot, c)
-            Characters[c].level = savemap_manager:get_slot_character_level(slot, c)
-            Characters[c].enabled = savemap_manager:is_slot_character_enabled(slot, c)
-            Characters[c].locked = savemap_manager:is_slot_character_locked(slot, c)
-            Characters[c].kills = savemap_manager:get_slot_character_kills(slot, c)
-            Characters[c].back_row = savemap_manager:is_slot_character_back_row(slot, c)
-            Characters[c].exp = savemap_manager:get_slot_character_exp(slot, c)
-            Characters[c].exp_to_next = savemap_manager:get_slot_character_exp_to_next(slot, c)
-            Characters[c].stats = {}
-            Characters[c].stats.str = {
-                base = savemap_manager:get_slot_character_stat_base(slot, c, 0),
-                bonus = savemap_manager:get_slot_character_stat_extra(slot, c, 0),
-                total = 0
-            }
-            Characters[c].stats.vit = {
-                base = savemap_manager:get_slot_character_stat_base(slot, c, 1),
-                bonus = savemap_manager:get_slot_character_stat_extra(slot, c, 1),
-                total = 0
-            }
-            Characters[c].stats.mag = {
-                base = savemap_manager:get_slot_character_stat_base(slot, c, 2),
-                bonus = savemap_manager:get_slot_character_stat_extra(slot, c, 2),
-                total = 0
-            }
-            Characters[c].stats.spr = {
-                base = savemap_manager:get_slot_character_stat_base(slot, c, 3),
-                bonus = savemap_manager:get_slot_character_stat_extra(slot, c, 3),
-                total = 0
-            }
-            Characters[c].stats.dex = {
-                base = savemap_manager:get_slot_character_stat_base(slot, c, 4),
-                bonus = savemap_manager:get_slot_character_stat_extra(slot, c, 4),
-                total = 0
-            }
-            Characters[c].stats.lck = {
-                base = savemap_manager:get_slot_character_stat_base(slot, c, 5),
-                bonus = savemap_manager:get_slot_character_stat_extra(slot, c, 5),
-                total = 0
-            }
-            Characters[c].stats.hp = {
-                current = savemap_manager:get_slot_character_stat_extra(slot, c, 6),
-                base = savemap_manager:get_slot_character_stat_base(slot, c, 6),
-                max = 65535
-            }
-            Characters[c].stats.mp = {
-                current = savemap_manager:get_slot_character_stat_extra(slot, c, 7),
-                base = savemap_manager:get_slot_character_stat_base(slot, c, 7),
-                max = 65535
-            }
-            Characters[c].limit = {}
-            Characters[c].limit.current = savemap_manager:get_slot_character_limit_level(slot, c)
-            Characters[c].limit.bar = savemap_manager:get_slot_character_limit_bar(slot, c)
-            Characters[c].limit.learned = {}
-            Characters[c].limit.uses = {}
-            for l = 1, 4 do
-                Characters[c].limit.learned[l] = {
-                    [1] = savemap_manager:is_slot_character_limit_learned(slot, c, l, 0),
-                    [2] = savemap_manager:is_slot_character_limit_learned(slot, c, l, 1)
-                }
-                Characters[c].limit.uses[l] = savemap_manager:get_slot_character_limit_uses(slot, c, l)
-            end
-            Characters[c].accessory = savemap_manager:get_slot_character_accessory_id(slot, c)
-            Characters[c].weapon = {}
-            Characters[c].armor = {}
-            Characters[c].weapon.id = savemap_manager:get_slot_character_weapon_id(slot, c)
-            Characters[c].armor.id = savemap_manager:get_slot_character_armor_id(slot, c)
-            Characters[c].weapon.materia = {}
-            Characters[c].armor.materia = {}
-            for i = 0, 8 do
-                Characters[c].weapon.materia[i] = {id = -1, ap = 0}
-                Characters[c].armor.materia[i] = {id = -1, ap = 0}
-                Characters[c].weapon.materia[i].id = savemap_manager:get_slot_character_materia_id(slot, c, true, i)
-                Characters[c].armor.materia[i].id = savemap_manager:get_slot_character_materia_id(slot, c, false, i)
-                if Characters[c].weapon.materia[i].id >= 0 then
-                    Characters[c].weapon.materia[i].ap = savemap_manager:get_slot_character_materia_ap(slot, i, true, i)
-                    if Characters[c].weapon.materia[i].id == Materia.ENEMY_SKILL_ID then
-                        Characters[c].weapon.materia[i].ap = 0
-                        Characters[c].weapon.materia[i].skills = {}
-                        for s = 1, 24 do
-                            Characters[c].weapon.materia[i].skills[s] = savemap_manager:is_slot_character_materia_e_skill_learned(slot, i, c, true, s - 1)
-                        end
-                    end
-                else
-                    Characters[c].weapon.materia[i] = nil
-                end
-                if Characters[c].armor.materia[i].id >= 0 then
-                    Characters[c].armor.materia[i].ap = savemap_manager:get_slot_character_materia_ap(slot, i, false, i)
-                    if Characters[c].armor.materia[i].id == Materia.ENEMY_SKILL_ID then
-                        Characters[c].armor.materia[i].ap = 0
-                        Characters[c].armor.materia[i].skills = {}
-                        for s = 1, 24 do
-                            Characters[c].armor.materia[i].skills[s] = savemap_manager:is_slot_character_materia_e_skill_learned(slot, i, c, false, s - 1)
-                        end
-                    end
-                else
-                    Characters[c].armor.materia[i] = nil
-                end
-            end
-            -- TODO: Status
-            Characters[c].status = {}
-            Characters.recalculate_stats(c)
-        end
-
+    for i = 1, MAX_KEY_SLOTS do
+        Inventory.key[i] = savemap_manager:get_slot_key_item(slot, i - 1)
     end
+    load_characters(slot)
     export_character_names()
-    -- TODO: Settings
     -- TODO: Location
     -- TODO: Update character stats
     -- TODO: Handle world map when implemented

+ 38 - 37
data/data/scripts/system.lua

@@ -117,9 +117,6 @@ end
 -- @param character2 The second member character ID, can be nil.
 -- @param character3 The third member character ID, can be nil.
 set_party = function(character1, character2, character3)
-    Party[1] = character1
-    Party[2] = character2
-    Party[3] = character3
     Party[1] = character1
     Party[2] = character2
     Party[3] = character3
@@ -145,19 +142,19 @@ add_party_character = function(character)
         print("Tried add to party invalid character \"" .. character .. "\".");
         return
     end
-    if Characters[character].party_lock == true then
+    if Characters[character].locked == true then
         print("Tried add to party locked character \"" .. character .. "\".");
         return
     end
     -- Check if character already in party
-    for x = 1, 3 do
+    for i = 1, 3 do
         if Party[i] == character then
             print("Character \"" .. character .. "\" already in party.");
             return
         end
     end
     -- Try to add in first free slot.
-    for x = 1, 3 do
+    for i = 1, 3 do
         if Party[i] == nil then
             Party[i] = character
             export_party_members()
@@ -174,11 +171,11 @@ remove_party_character = function(character)
         print("Tried remove from party invalid character \"" .. character .. "\".");
         return
     end
-    if Characters[character].party_lock == true then
+    if Characters[character].locked == true then
         print("Tried remove from party locked character \"" .. character .. "\".");
         return
     end
-    for x = 1, 3 do
+    for i = 1, 3 do
         if Party[i] == character then
             Party[i] = nil
             export_party_members()
@@ -197,7 +194,7 @@ set_party_lock = function(character, lock)
         print("Tried set/unset party lock to invalid character \"" .. character .. "\".");
         return
     end
-    Characters[character].party_lock = lock;
+    Characters[character].locked = lock;
 end
 
 --- Checks if a character is locked in the party.
@@ -210,7 +207,7 @@ get_party_lock = function(character)
         print("Tried get party lock from invalid character \"" .. character .. "\".");
         return false
     end
-    return Characters[character].party_lock
+    return Characters[character].locked
 end
 
 --- Enables or disables a character is the PHS.
@@ -224,7 +221,7 @@ set_character_enable = function(character, enable)
         print("Tried enable/disable invalid character \"" .. character .. "\".");
         return
     end
-    Characters[character].enable = enable;
+    Characters[character].enabled = enable;
 end
 
 --- Checks if a character is enabled in the PHS.
@@ -238,7 +235,7 @@ get_character_enable = function(character)
         print("Tried get enabled from invalid character \"" .. character .. "\".");
         return false
     end
-    return Characters[character].enable;
+    return Characters[character].enabled;
 end
 
 --- Adds items to the inventory.
@@ -447,9 +444,10 @@ Characters.recalculate_stats = function(id)
     stats.lck.total = stats.lck.base
     stats.hp.max = stats.hp.base
     stats.mp.max = stats.mp.base
-    -- Weapon bonus. HP and MP are percents of base, the rest absolutes.
+    -- Weapon bonus. HP and MP are percents of base, the rest absolutes. The stats are keyed by name,
+    -- so they are walked with pairs (ipairs only walks the array part and found nothing).
     local weapon = Game.Items[Characters[id].weapon.id]
-    for stat, value in ipairs(weapon.stats) do
+    for stat, value in pairs(weapon.stats or {}) do
         if stat == "str" then
             stats.str.total = stats.str.total + value
         elseif stat == "vit" then
@@ -470,7 +468,7 @@ Characters.recalculate_stats = function(id)
     end
     -- Armor bonus. HP and MP are percents of bsae, the rest absolutes.
     local armor = Game.Items[Characters[id].armor.id]
-    for stat, value in ipairs(armor.stats) do
+    for stat, value in pairs(armor.stats or {}) do
         if stat == "str" then
             stats.str.total = stats.str.total + value
         elseif stat == "vit" then
@@ -492,7 +490,7 @@ Characters.recalculate_stats = function(id)
     -- Accesory bonus
     local accessory = Game.Items[Characters[id].accessory]
     if (accessory ~= nil) then
-        for stat, value in ipairs(armor.stats) do
+        for stat, value in pairs(accessory.stats or {}) do
             if stat == "str" then
                 stats.str.total = stats.str.total + value
             elseif stat == "vit" then
@@ -519,12 +517,12 @@ Characters.recalculate_stats = function(id)
             table.insert(materias, Game.Materia[Characters[id].weapon.materia[m].id])
         end
         if Characters[id].armor.materia[m] ~= nil then
-            table.insert(materias, Game.Materia[Characters[id].weapon.materia[m].id])
+            table.insert(materias, Game.Materia[Characters[id].armor.materia[m].id])
         end
     end
     -- Get materia bonuses. All are percents of base.
     for _, materia in ipairs(materias) do
-        for stat, value in ipairs(materia.stats) do
+        for stat, value in pairs(materia.stats or {}) do
             if stat == "str" then
                 stats.str.total = stats.str.total + (value * stats.str.base / 100)
             elseif stat == "vit" then
@@ -625,6 +623,21 @@ Characters.recalculate_stats = function(id)
     Characters[id].stats = stats
 end
 
+--- Sends the materia that doesn't fit in a piece of equipment back to the materia inventory.
+--
+-- @param char_id ID of the character.
+-- @param part "weapon" or "armor".
+-- @param slots Number of materia slots the equipment will have. Slots are 0-7 in the character.
+Characters.remove_excess_materia = function(char_id, part, slots)
+    local materia = Characters[char_id][part].materia
+    for m = slots, 7 do
+        if materia[m] ~= nil then
+            Materia.add(materia[m].id, materia[m].ap)
+            materia[m] = nil
+        end
+    end
+end
+
 --- Equips an item on a character.
 --
 -- Works for weapons, armors and accessories. If there is a previously equiped item, it
@@ -644,15 +657,15 @@ Characters.equip = function(char_id, item_id)
         print("Tried to equip item on wrong character: " .. tostring(char_id))
         return false
     elseif Game.Items[item_id] == nil then
-        print("Tried to equip invalid item: " .. tostring(character))
+        print("Tried to equip invalid item: " .. tostring(item_id))
         return false
     elseif Inventory.get_item_quantity(item_id) < 1 then
-        print("Tried to equip item not in inventory: " .. item_id .. " (" .. Game.Items[item.id].name .. ")")
+        print("Tried to equip item not in inventory: " .. item_id .. " (" .. Game.Items[item_id].name .. ")")
         return false
     end
     local type = Game.Items[item_id].type
     if type ~= Inventory.ITEM_TYPE.WEAPON and type ~= Inventory.ITEM_TYPE.ARMOR and type ~= Inventory.ITEM_TYPE.ACCESSORY then
-        print("Tried to equip non-equipable item: " .. item_id .. " (" .. Game.Items[item.id].name .. ")")
+        print("Tried to equip non-equipable item: " .. item_id .. " (" .. Game.Items[item_id].name .. ")")
         return false
     end
     local equipable = false
@@ -663,7 +676,7 @@ Characters.equip = function(char_id, item_id)
         end
     end
     if equipable == false then
-        print("Tried to equip item " .. tostring(item_id) .. " (" .. Game.Items[item.id].name .. ") on a non valid character: " .. tostring(char_id) .. " (" .. Characters[char_id].name .. ")")
+        print("Tried to equip item " .. tostring(item_id) .. " (" .. Game.Items[item_id].name .. ") on a non valid character: " .. tostring(char_id) .. " (" .. Characters[char_id].name .. ")")
         return false
     end
 
@@ -677,25 +690,13 @@ Characters.equip = function(char_id, item_id)
         Inventory.remove_item(item_id, 1)
     elseif type == Inventory.ITEM_TYPE.WEAPON then
         -- Weapon. Remove excess materia, send previuos one to the inventory.
-        for m = 0, 7 do
-            if Characters[char_id].weapon.materia[i] ~= nil and #(Game.Items[item_id].slots) < m then
-                -- Materia to inventory
-                Materia.add(Characters[char_id].weapon.materia[i].id, Characters[char_id].weapon.materia[i].ap)
-                Characters[char_id].weapon.materia[i] = nil
-            end
-        end
+        Characters.remove_excess_materia(char_id, "weapon", #(Game.Items[item_id].slots))
         Inventory.add_item(Characters[char_id].weapon.id, 1)
         Characters[char_id].weapon.id = item_id
         Inventory.remove_item(item_id, 1)
     elseif type == Inventory.ITEM_TYPE.ARMOR then
         -- Armor. Remove excess materia, send previuos one to the inventory.
-        for m = 0, 7 do
-            if Characters[char_id].armor.materia[i] ~= nil and #(Game.Items[item_id].slots) < m then
-                -- Materia to inventory
-                Materia.add(Characters[char_id].armor.materia[i].id, Characters[char_id].armor.materia[i].ap)
-                Characters[char_id].armor.materia[i] = nil
-            end
-        end
+        Characters.remove_excess_materia(char_id, "armor", #(Game.Items[item_id].slots))
         Inventory.add_item(Characters[char_id].armor.id, 1)
         Characters[char_id].armor.id = item_id
         Inventory.remove_item(item_id, 1)
@@ -723,7 +724,7 @@ end
 Materia.add = function(id, ap)
     local ap = ap or 0
     if Game.Materia[id] == nil then
-        print("Tried to add invalid materia \"" .. item .. "\".");
+        print("Tried to add invalid materia \"" .. tostring(id) .. "\".");
         return
     end
     -- TODO Cap AP?

+ 2 - 2
data/data/system/system.fontdef

@@ -1,7 +1,7 @@
 font FFVIIFont
 {
     type image
-    source system/courier_new_9_75.png
+    source courier_new_9_75.png
 
     glyph u32 0.015625 0.125 0.046875 0.1875
     glyph u33 0.078125 0.125 0.109375 0.1875
@@ -233,7 +233,7 @@ font FFVIIFont
 font CourierNew
 {
     type image
-    source system/courier_new_9_75.png
+    source courier_new_9_75.png
 
     glyph u32 0.015625 0.125 0.046875 0.1875
     glyph u33 0.078125 0.125 0.109375 0.1875

+ 114 - 2
data/data/texts/english/menu.xml

@@ -60,11 +60,9 @@
     <text name="ItemMenuOrder">Order</text>
     <text name="ItemMenuKey">Key Items</text>
     <text name="ItemMenuStatus">Status</text>
-    <text name="ItemMenuOrder">Order</text>
     <text name="ItemMenuLimit">Limit</text>
     <text name="ItemMenuConfig">Config</text>
     <text name="ItemMenuPHS">PHS</text>
-    <text name="MainMenuSave">Save</text>
     <text name="ItemMenuOrder1">Custom</text>
     <text name="ItemMenuOrder2">Field</text>
     <text name="ItemMenuOrder3">Battle</text>
@@ -137,4 +135,118 @@
     <text name="BattleUiTime">TIME</text>
     <text name="GameOverText">GAME OVER</text>
     <text name="BattleUiDebugExit">Debug: Press 'K' to end battle.</text>
+
+    <text name="StatusMenuStr"><colour value="0 0.8 1">Strength</colour></text>
+    <text name="StatusMenuDex"><colour value="0 0.8 1">Dexterity</colour></text>
+    <text name="StatusMenuVit"><colour value="0 0.8 1">Vitality</colour></text>
+    <text name="StatusMenuMag"><colour value="0 0.8 1">Magic</colour></text>
+    <text name="StatusMenuSpr"><colour value="0 0.8 1">Spirit</colour></text>
+    <text name="StatusMenuLck"><colour value="0 0.8 1">Luck</colour></text>
+    <text name="StatusMenuAttack"><colour value="0 0.8 1">Attack</colour></text>
+    <text name="StatusMenuAttackPct"><colour value="0 0.8 1">Attack%</colour></text>
+    <text name="StatusMenuDefense"><colour value="0 0.8 1">Defense</colour></text>
+    <text name="StatusMenuDefensePct"><colour value="0 0.8 1">Defense%</colour></text>
+    <text name="StatusMenuMagicAtk"><colour value="0 0.8 1">Magic atk</colour></text>
+    <text name="StatusMenuMagicDef"><colour value="0 0.8 1">Magic def</colour></text>
+    <text name="StatusMenuMagicDefPct"><colour value="0 0.8 1">Magic def%</colour></text>
+    <text name="StatusMenuExp"><colour value="0 0.8 1">EXP:</colour></text>
+    <text name="StatusMenuNextLevel"><colour value="0 0.8 1">next level:</colour></text>
+    <text name="StatusMenuLimitLevel"><colour value="0 0.8 1">Limit level:</colour></text>
+    <text name="StatusMenuElement"><colour value="0 0.8 1">Element</colour></text>
+    <text name="StatusMenuEffect"><colour value="0 0.8 1">Effect</colour></text>
+    <text name="StatusMenuAttackGroup"><colour value="0 0.8 1">Attack</colour></text>
+    <text name="StatusMenuHalve"><colour value="0 0.8 1">Halve</colour></text>
+    <text name="StatusMenuInvalid"><colour value="0 0.8 1">Invalid</colour></text>
+    <text name="StatusMenuAbsorb"><colour value="0 0.8 1">Absorb</colour></text>
+    <text name="StatusMenuDefend"><colour value="0 0.8 1">Defend</colour></text>
+    <text name="LimitMenuTitle">Limit</text>
+    <text name="LimitMenuSet">Set</text>
+    <text name="LimitMenuCheck">Check</text>
+    <text name="LimitMenuLimitLevel">Limit level</text>
+    <text name="LimitMenuConfirm1">To change BREAK LEVEL,</text>
+    <text name="LimitMenuConfirm2">it will begin from Limit Point 0.</text>
+    <text name="LimitMenuConfirm3">Change BREAK LEVEL?</text>
+    <text name="QuitMenuLine1">Any unsaved progress will be lost.</text>
+    <text name="QuitMenuLine2">Do you really want to quit the game?</text>
+    <text name="PhsMenuTitle">PHS</text>
+    <text name="PhsMenuHelp">Please make a party of three.</text>
+    <text name="ConfigMenuTitle">Config</text>
+    <text name="ConfigMenuLabelWindowColor"><colour value="0 0.8 1">Window color</colour></text>
+    <text name="ConfigMenuLabelSound"><colour value="0 0.8 1">Sound</colour></text>
+    <text name="ConfigMenuLabelController"><colour value="0 0.8 1">Controller</colour></text>
+    <text name="ConfigMenuLabelCursor"><colour value="0 0.8 1">Cursor</colour></text>
+    <text name="ConfigMenuLabelAtb"><colour value="0 0.8 1">ATB</colour></text>
+    <text name="ConfigMenuLabelBattleSpeed"><colour value="0 0.8 1">Battle speed</colour></text>
+    <text name="ConfigMenuLabelBattleMessage"><colour value="0 0.8 1">Battle message</colour></text>
+    <text name="ConfigMenuLabelFieldMessage"><colour value="0 0.8 1">Field message</colour></text>
+    <text name="ConfigMenuLabelMovement"><colour value="0 0.8 1">Movement</colour></text>
+    <text name="ConfigMenuLabelCameraAngle"><colour value="0 0.8 1">Camera angle</colour></text>
+    <text name="ConfigMenuLabelMagicOrder"><colour value="0 0.8 1">Magic order</colour></text>
+    <text name="ConfigMenuInitial">Initial</text>
+    <text name="ConfigMenuMemory">Memory</text>
+    <text name="ConfigMenuActive">Active</text>
+    <text name="ConfigMenuRecommended">Recommended</text>
+    <text name="ConfigMenuWait">Wait</text>
+    <text name="ConfigMenuFast">Fast</text>
+    <text name="ConfigMenuSlow">Slow</text>
+    <text name="ConfigMenuAuto">Auto</text>
+    <text name="ConfigMenuFix">Fix</text>
+    <text name="ConfigMenuWalk">Walk</text>
+    <text name="ConfigMenuRun">Run</text>
+    <text name="ConfigMenuMagicNo">No.</text>
+    <text name="ConfigMenuMagicOrder1">restore attack indirect</text>
+    <text name="ConfigMenuMagicOrder2">restore indirect attack</text>
+    <text name="ConfigMenuMagicOrder3">attack restore indirect</text>
+    <text name="ConfigMenuMagicOrder4">attack indirect restore</text>
+    <text name="ConfigMenuMagicOrder5">indirect restore attack</text>
+    <text name="ConfigMenuMagicOrder6">indirect attack restore</text>
+    <text name="ConfigMenuHelp1">Select colors for each corner of the window</text>
+    <text name="ConfigMenuHelp2">Choose the sound settings</text>
+    <text name="ConfigMenuHelp3">Choose the controller settings</text>
+    <text name="ConfigMenuHelp4">Choose whether the cursor starts at the top or remembers its last position</text>
+    <text name="ConfigMenuHelp5">Choose how time flows in battle: Active, Recommended or Wait</text>
+    <text name="ConfigMenuHelp6">Set the speed of the battles</text>
+    <text name="ConfigMenuHelp7">Set how fast battle messages are displayed</text>
+    <text name="ConfigMenuHelp8">Set how fast field messages are displayed</text>
+    <text name="ConfigMenuHelp9">Choose whether the character walks or runs</text>
+    <text name="ConfigMenuHelp10">Choose automatic or fixed camera angle in battle</text>
+    <text name="ConfigMenuHelp11">Set the order of the magic menu</text>
+    <text name="ConfigMenuColourRed"><colour value="0 0.8 1">Red</colour></text>
+    <text name="ConfigMenuColourGreen"><colour value="0 0.8 1">Green</colour></text>
+    <text name="ConfigMenuColourBlue"><colour value="0 0.8 1">Blue</colour></text>
+    <text name="ConfigMenuSoundMusic"><colour value="0 0.8 1">Music</colour></text>
+    <text name="ConfigMenuSoundEffects"><colour value="0 0.8 1">Effects</colour></text>
+    <text name="ConfigMenuActiveOff"><colour value="0.45 0.45 0.35">Active</colour></text>
+    <text name="ConfigMenuAutoOff"><colour value="0.45 0.45 0.35">Auto</colour></text>
+    <text name="ConfigMenuFixOff"><colour value="0.45 0.45 0.35">Fix</colour></text>
+    <text name="ConfigMenuInitialOff"><colour value="0.45 0.45 0.35">Initial</colour></text>
+    <text name="ConfigMenuMemoryOff"><colour value="0.45 0.45 0.35">Memory</colour></text>
+    <text name="ConfigMenuRecommendedOff"><colour value="0.45 0.45 0.35">Recommended</colour></text>
+    <text name="ConfigMenuRunOff"><colour value="0.45 0.45 0.35">Run</colour></text>
+    <text name="ConfigMenuWaitOff"><colour value="0.45 0.45 0.35">Wait</colour></text>
+    <text name="ConfigMenuWalkOff"><colour value="0.45 0.45 0.35">Walk</colour></text>
+    <text name="ConfigMenuControlDefault"><colour value="0 0.8 1">Default settings</colour></text>
+    <text name="ConfigMenuControlUp"><colour value="0 0.8 1">Up</colour></text>
+    <text name="ConfigMenuControlDown"><colour value="0 0.8 1">Down</colour></text>
+    <text name="ConfigMenuControlLeft"><colour value="0 0.8 1">Left</colour></text>
+    <text name="ConfigMenuControlRight"><colour value="0 0.8 1">Right</colour></text>
+    <text name="ConfigMenuControlOk"><colour value="0 0.8 1">OK</colour></text>
+    <text name="ConfigMenuControlCancel"><colour value="0 0.8 1">Cancel</colour></text>
+    <text name="ConfigMenuControlMenu"><colour value="0 0.8 1">Menu</colour></text>
+    <text name="ConfigMenuControlExtra"><colour value="0 0.8 1">Extra</colour></text>
+    <text name="ConfigMenuControlSelect"><colour value="0 0.8 1">Select</colour></text>
+    <text name="ConfigMenuControlStart"><colour value="0 0.8 1">Start</colour></text>
+    <text name="ConfigMenuControlL1"><colour value="0 0.8 1">L1</colour></text>
+    <text name="ConfigMenuControlR1"><colour value="0 0.8 1">R1</colour></text>
+    <text name="ConfigMenuControlRun"><colour value="0 0.8 1">Run</colour></text>
+    <text name="ConfigMenuHelp12">Press the new key for this action. Esc cancels</text>
+    <text name="MagicMenuTitle">Magic</text>
+    <text name="MagicMenuMagic">Magic</text>
+    <text name="MagicMenuSummon">Summon</text>
+    <text name="MagicMenuESkill">E.Skill</text>
+    <text name="MagicMenuAddedAbility"><colour value="0 0.8 1">Added-Ability</colour></text>
+    <text name="MagicMenuNothing">Nothing.</text>
+    <text name="MagicMenuMpNeeded"><colour value="0 0.8 1">MP needed</colour></text>
+    <text name="MagicMenuUses"><colour value="0 0.8 1">Uses</colour></text>
+    <text name="StatusMenuTitle">Status</text>
 </texts>

+ 0 - 18
data/plugins.cfg_win32

@@ -1,18 +0,0 @@
-# Defines plugins to load
-
-# Define plugin folder
-PluginFolder=.
-
-# Define plugins
-# Plugin=RenderSystem_Direct3D9
-# Plugin=RenderSystem_Direct3D11
- Plugin=RenderSystem_GL
-# Plugin=RenderSystem_GLES
-# Plugin=RenderSystem_GLES2
-# Plugin=Plugin_ParticleFX
-# Plugin=Plugin_BSPSceneManager
-# Plugin=Plugin_CgProgramManager
-# Plugin=Plugin_PCZSceneManager
-# Plugin=Plugin_OctreeZone
-# Plugin=Plugin_OctreeSceneManager
-# Plugin=Plugin_Data

+ 6 - 6
data/resources.cfg

@@ -1,6 +1,6 @@
 # alternative ressources directly from the game files
 [Game]
-FileSystem=data
+#FileSystem=data
 
 [FFVII_World]
 #LGP=~/.wine/drive_c/FF7/data/wm/world_us.lgp
@@ -8,13 +8,13 @@ FileSystem=data
 [FFVIIFields]
 
 [FFVIITextures]
-FileSystem=data/models/field/entities/
-FileSystem=data/models/battle/enemy/
-FileSystem=data/models/battle/char/
-FileSystem=data/models/battle/scene/
+#FileSystem=data/models/field/entities/
+#FileSystem=data/models/battle/enemy/
+#FileSystem=data/models/battle/char/
+#FileSystem=data/models/battle/scene/
 
 [Images]
-FileSystem=data/images/
+#FileSystem=data/images/
 
 [General]
 #FileSystem=/home/ivalentin/.q-gears/

+ 0 - 6
data/resources.cfg_linux

@@ -1,6 +0,0 @@
-# alternative ressources directly from the game files
-[Game]
-#FileSystem=data
-
-[FFVII_World]
-#LGP=~/.wine/drive_c/FF7/data/wm/world_us.lgp

+ 0 - 461
data/system.fontdef

@@ -1,461 +0,0 @@
-font FFVIIFont
-{
-    type image
-    source system/courier_new_9_75.png
-
-    glyph u32 0.015625 0.125 0.046875 0.1875
-    glyph u33 0.078125 0.125 0.109375 0.1875
-    glyph u34 0.140625 0.125 0.171875 0.1875
-    glyph u35 0.203125 0.125 0.234375 0.1875
-    glyph u36 0.265625 0.125 0.296875 0.1875
-    glyph u37 0.328125 0.125 0.359375 0.1875
-    glyph u38 0.390625 0.125 0.421875 0.1875
-    glyph u39 0.453125 0.125 0.484375 0.1875
-    glyph u40 0.515625 0.125 0.546875 0.1875
-    glyph u41 0.578125 0.125 0.609375 0.1875
-    glyph u42 0.640625 0.125 0.671875 0.1875
-    glyph u43 0.703125 0.125 0.734375 0.1875
-    glyph u44 0.765625 0.125 0.796875 0.1875
-    glyph u45 0.828125 0.125 0.859375 0.1875
-    glyph u46 0.890625 0.125 0.921875 0.1875
-    glyph u47 0.953125 0.125 0.984375 0.1875
-    glyph u48 0.015625 0.1875 0.046875 0.25
-    glyph u49 0.078125 0.1875 0.109375 0.25
-    glyph u50 0.140625 0.1875 0.171875 0.25
-    glyph u51 0.203125 0.1875 0.234375 0.25
-    glyph u52 0.265625 0.1875 0.296875 0.25
-    glyph u53 0.328125 0.1875 0.359375 0.25
-    glyph u54 0.390625 0.1875 0.421875 0.25
-    glyph u55 0.453125 0.1875 0.484375 0.25
-    glyph u56 0.515625 0.1875 0.546875 0.25
-    glyph u57 0.578125 0.1875 0.609375 0.25
-    glyph u58 0.640625 0.1875 0.671875 0.25
-    glyph u59 0.703125 0.1875 0.734375 0.25
-    glyph u60 0.765625 0.1875 0.796875 0.25
-    glyph u61 0.828125 0.1875 0.859375 0.25
-    glyph u62 0.890625 0.1875 0.921875 0.25
-    glyph u63 0.953125 0.1875 0.984375 0.25
-    glyph u64 0.015625 0.25 0.046875 0.3125
-    glyph u65 0.078125 0.25 0.109375 0.3125
-    glyph u66 0.140625 0.25 0.171875 0.3125
-    glyph u67 0.203125 0.25 0.234375 0.3125
-    glyph u68 0.265625 0.25 0.296875 0.3125
-    glyph u69 0.328125 0.25 0.359375 0.3125
-    glyph u70 0.390625 0.25 0.421875 0.3125
-    glyph u71 0.453125 0.25 0.484375 0.3125
-    glyph u72 0.515625 0.25 0.546875 0.3125
-    glyph u73 0.578125 0.25 0.609375 0.3125
-    glyph u74 0.640625 0.25 0.671875 0.3125
-    glyph u75 0.703125 0.25 0.734375 0.3125
-    glyph u76 0.765625 0.25 0.796875 0.3125
-    glyph u77 0.828125 0.25 0.859375 0.3125
-    glyph u78 0.890625 0.25 0.921875 0.3125
-    glyph u79 0.953125 0.25 0.984375 0.3125
-    glyph u80 0.015625 0.3125 0.046875 0.375
-    glyph u81 0.078125 0.3125 0.109375 0.375
-    glyph u82 0.140625 0.3125 0.171875 0.375
-    glyph u83 0.203125 0.3125 0.234375 0.375
-    glyph u84 0.265625 0.3125 0.296875 0.375
-    glyph u85 0.328125 0.3125 0.359375 0.375
-    glyph u86 0.390625 0.3125 0.421875 0.375
-    glyph u87 0.453125 0.3125 0.484375 0.375
-    glyph u88 0.515625 0.3125 0.546875 0.375
-    glyph u89 0.578125 0.3125 0.609375 0.375
-    glyph u90 0.640625 0.3125 0.671875 0.375
-    glyph u91 0.703125 0.3125 0.734375 0.375
-    glyph u92 0.765625 0.3125 0.796875 0.375
-    glyph u93 0.828125 0.3125 0.859375 0.375
-    glyph u94 0.890625 0.3125 0.921875 0.375
-    glyph u95 0.953125 0.3125 0.984375 0.375
-    glyph u96 0.015625 0.375 0.046875 0.4375
-    glyph u97 0.078125 0.375 0.109375 0.4375
-    glyph u98 0.140625 0.375 0.171875 0.4375
-    glyph u99 0.203125 0.375 0.234375 0.4375
-    glyph u100 0.265625 0.375 0.296875 0.4375
-    glyph u101 0.328125 0.375 0.359375 0.4375
-    glyph u102 0.390625 0.375 0.421875 0.4375
-    glyph u103 0.453125 0.375 0.484375 0.4375
-    glyph u104 0.515625 0.375 0.546875 0.4375
-    glyph u105 0.578125 0.375 0.609375 0.4375
-    glyph u106 0.640625 0.375 0.671875 0.4375
-    glyph u107 0.703125 0.375 0.734375 0.4375
-    glyph u108 0.765625 0.375 0.796875 0.4375
-    glyph u109 0.828125 0.375 0.859375 0.4375
-    glyph u110 0.890625 0.375 0.921875 0.4375
-    glyph u111 0.953125 0.375 0.984375 0.4375
-    glyph u112 0.015625 0.4375 0.046875 0.5
-    glyph u113 0.078125 0.4375 0.109375 0.5
-    glyph u114 0.140625 0.4375 0.171875 0.5
-    glyph u115 0.203125 0.4375 0.234375 0.5
-    glyph u116 0.265625 0.4375 0.296875 0.5
-    glyph u117 0.328125 0.4375 0.359375 0.5
-    glyph u118 0.390625 0.4375 0.421875 0.5
-    glyph u119 0.453125 0.4375 0.484375 0.5
-    glyph u120 0.515625 0.4375 0.546875 0.5
-    glyph u121 0.578125 0.4375 0.609375 0.5
-    glyph u122 0.640625 0.4375 0.671875 0.5
-    glyph u123 0.703125 0.4375 0.734375 0.5
-    glyph u124 0.765625 0.4375 0.796875 0.5
-    glyph u125 0.828125 0.4375 0.859375 0.5
-    glyph u126 0.890625 0.4375 0.921875 0.5
-    glyph u127 0.953125 0.4375 0.984375 0.5
-    glyph u128 0.015625 0.5 0.046875 0.5625
-    glyph u129 0.078125 0.5 0.109375 0.5625
-    glyph u130 0.140625 0.5 0.171875 0.5625
-    glyph u131 0.203125 0.5 0.234375 0.5625
-    glyph u132 0.265625 0.5 0.296875 0.5625
-    glyph u133 0.328125 0.5 0.359375 0.5625
-    glyph u134 0.390625 0.5 0.421875 0.5625
-    glyph u135 0.453125 0.5 0.484375 0.5625
-    glyph u136 0.515625 0.5 0.546875 0.5625
-    glyph u137 0.578125 0.5 0.609375 0.5625
-    glyph u138 0.640625 0.5 0.671875 0.5625
-    glyph u139 0.703125 0.5 0.734375 0.5625
-    glyph u140 0.765625 0.5 0.796875 0.5625
-    glyph u141 0.828125 0.5 0.859375 0.5625
-    glyph u142 0.890625 0.5 0.921875 0.5625
-    glyph u143 0.953125 0.5 0.984375 0.5625
-    glyph u144 0.015625 0.5625 0.046875 0.625
-    glyph u145 0.078125 0.5625 0.109375 0.625
-    glyph u146 0.140625 0.5625 0.171875 0.625
-    glyph u147 0.203125 0.5625 0.234375 0.625
-    glyph u148 0.265625 0.5625 0.296875 0.625
-    glyph u149 0.328125 0.5625 0.359375 0.625
-    glyph u150 0.390625 0.5625 0.421875 0.625
-    glyph u151 0.453125 0.5625 0.484375 0.625
-    glyph u152 0.515625 0.5625 0.546875 0.625
-    glyph u153 0.578125 0.5625 0.609375 0.625
-    glyph u154 0.640625 0.5625 0.671875 0.625
-    glyph u155 0.703125 0.5625 0.734375 0.625
-    glyph u156 0.765625 0.5625 0.796875 0.625
-    glyph u157 0.828125 0.5625 0.859375 0.625
-    glyph u158 0.890625 0.5625 0.921875 0.625
-    glyph u159 0.953125 0.5625 0.984375 0.625
-    glyph u160 0.015625 0.625 0.046875 0.6875
-    glyph u161 0.078125 0.625 0.109375 0.6875
-    glyph u162 0.140625 0.625 0.171875 0.6875
-    glyph u163 0.203125 0.625 0.234375 0.6875
-    glyph u164 0.265625 0.625 0.296875 0.6875
-    glyph u165 0.328125 0.625 0.359375 0.6875
-    glyph u166 0.390625 0.625 0.421875 0.6875
-    glyph u167 0.453125 0.625 0.484375 0.6875
-    glyph u168 0.515625 0.625 0.546875 0.6875
-    glyph u169 0.578125 0.625 0.609375 0.6875
-    glyph u170 0.640625 0.625 0.671875 0.6875
-    glyph u171 0.703125 0.625 0.734375 0.6875
-    glyph u172 0.765625 0.625 0.796875 0.6875
-    glyph u173 0.828125 0.625 0.859375 0.6875
-    glyph u174 0.890625 0.625 0.921875 0.6875
-    glyph u175 0.953125 0.625 0.984375 0.6875
-    glyph u176 0.015625 0.6875 0.046875 0.75
-    glyph u177 0.078125 0.6875 0.109375 0.75
-    glyph u178 0.140625 0.6875 0.171875 0.75
-    glyph u179 0.203125 0.6875 0.234375 0.75
-    glyph u180 0.265625 0.6875 0.296875 0.75
-    glyph u181 0.328125 0.6875 0.359375 0.75
-    glyph u182 0.390625 0.6875 0.421875 0.75
-    glyph u183 0.453125 0.6875 0.484375 0.75
-    glyph u184 0.515625 0.6875 0.546875 0.75
-    glyph u185 0.578125 0.6875 0.609375 0.75
-    glyph u186 0.640625 0.6875 0.671875 0.75
-    glyph u187 0.703125 0.6875 0.734375 0.75
-    glyph u188 0.765625 0.6875 0.796875 0.75
-    glyph u189 0.828125 0.6875 0.859375 0.75
-    glyph u190 0.890625 0.6875 0.921875 0.75
-    glyph u191 0.953125 0.6875 0.984375 0.75
-    glyph u192 0.015625 0.75 0.046875 0.8125
-    glyph u193 0.078125 0.75 0.109375 0.8125
-    glyph u194 0.140625 0.75 0.171875 0.8125
-    glyph u195 0.203125 0.75 0.234375 0.8125
-    glyph u196 0.265625 0.75 0.296875 0.8125
-    glyph u197 0.328125 0.75 0.359375 0.8125
-    glyph u198 0.390625 0.75 0.421875 0.8125
-    glyph u199 0.453125 0.75 0.484375 0.8125
-    glyph u200 0.515625 0.75 0.546875 0.8125
-    glyph u201 0.578125 0.75 0.609375 0.8125
-    glyph u202 0.640625 0.75 0.671875 0.8125
-    glyph u203 0.703125 0.75 0.734375 0.8125
-    glyph u204 0.765625 0.75 0.796875 0.8125
-    glyph u205 0.828125 0.75 0.859375 0.8125
-    glyph u206 0.890625 0.75 0.921875 0.8125
-    glyph u207 0.953125 0.75 0.984375 0.8125
-    glyph u208 0.015625 0.8125 0.046875 0.875
-    glyph u209 0.078125 0.8125 0.109375 0.875
-    glyph u210 0.140625 0.8125 0.171875 0.875
-    glyph u211 0.203125 0.8125 0.234375 0.875
-    glyph u212 0.265625 0.8125 0.296875 0.875
-    glyph u213 0.328125 0.8125 0.359375 0.875
-    glyph u214 0.390625 0.8125 0.421875 0.875
-    glyph u215 0.453125 0.8125 0.484375 0.875
-    glyph u216 0.515625 0.8125 0.546875 0.875
-    glyph u217 0.578125 0.8125 0.609375 0.875
-    glyph u218 0.640625 0.8125 0.671875 0.875
-    glyph u219 0.703125 0.8125 0.734375 0.875
-    glyph u220 0.765625 0.8125 0.796875 0.875
-    glyph u221 0.828125 0.8125 0.859375 0.875
-    glyph u222 0.890625 0.8125 0.921875 0.875
-    glyph u223 0.953125 0.8125 0.984375 0.875
-    glyph u224 0.015625 0.875 0.046875 0.9375
-    glyph u225 0.078125 0.875 0.109375 0.9375
-    glyph u226 0.140625 0.875 0.171875 0.9375
-    glyph u227 0.203125 0.875 0.234375 0.9375
-    glyph u228 0.265625 0.875 0.296875 0.9375
-    glyph u229 0.328125 0.875 0.359375 0.9375
-    glyph u230 0.390625 0.875 0.421875 0.9375
-    glyph u231 0.453125 0.875 0.484375 0.9375
-    glyph u232 0.515625 0.875 0.546875 0.9375
-    glyph u233 0.578125 0.875 0.609375 0.9375
-    glyph u234 0.640625 0.875 0.671875 0.9375
-    glyph u235 0.703125 0.875 0.734375 0.9375
-    glyph u236 0.765625 0.875 0.796875 0.9375
-    glyph u237 0.828125 0.875 0.859375 0.9375
-    glyph u238 0.890625 0.875 0.921875 0.9375
-    glyph u239 0.953125 0.875 0.984375 0.9375
-    glyph u240 0.015625 0.9375 0.046875 1
-    glyph u241 0.078125 0.9375 0.109375 1
-    glyph u242 0.140625 0.9375 0.171875 1
-    glyph u243 0.203125 0.9375 0.234375 1
-    glyph u244 0.265625 0.9375 0.296875 1
-    glyph u245 0.328125 0.9375 0.359375 1
-    glyph u246 0.390625 0.9375 0.421875 1
-    glyph u247 0.453125 0.9375 0.484375 1
-    glyph u248 0.515625 0.9375 0.546875 1
-    glyph u249 0.578125 0.9375 0.609375 1
-    glyph u250 0.640625 0.9375 0.671875 1
-    glyph u251 0.703125 0.9375 0.734375 1
-    glyph u252 0.765625 0.9375 0.796875 1
-    glyph u253 0.828125 0.9375 0.859375 1
-    glyph u254 0.890625 0.9375 0.921875 1
-    glyph u255 0.953125 0.9375 0.984375 1
-}
-
-font CourierNew
-{
-    type image
-    source system/courier_new_9_75.png
-
-    glyph u32 0.015625 0.125 0.046875 0.1875
-    glyph u33 0.078125 0.125 0.109375 0.1875
-    glyph u34 0.140625 0.125 0.171875 0.1875
-    glyph u35 0.203125 0.125 0.234375 0.1875
-    glyph u36 0.265625 0.125 0.296875 0.1875
-    glyph u37 0.328125 0.125 0.359375 0.1875
-    glyph u38 0.390625 0.125 0.421875 0.1875
-    glyph u39 0.453125 0.125 0.484375 0.1875
-    glyph u40 0.515625 0.125 0.546875 0.1875
-    glyph u41 0.578125 0.125 0.609375 0.1875
-    glyph u42 0.640625 0.125 0.671875 0.1875
-    glyph u43 0.703125 0.125 0.734375 0.1875
-    glyph u44 0.765625 0.125 0.796875 0.1875
-    glyph u45 0.828125 0.125 0.859375 0.1875
-    glyph u46 0.890625 0.125 0.921875 0.1875
-    glyph u47 0.953125 0.125 0.984375 0.1875
-    glyph u48 0.015625 0.1875 0.046875 0.25
-    glyph u49 0.078125 0.1875 0.109375 0.25
-    glyph u50 0.140625 0.1875 0.171875 0.25
-    glyph u51 0.203125 0.1875 0.234375 0.25
-    glyph u52 0.265625 0.1875 0.296875 0.25
-    glyph u53 0.328125 0.1875 0.359375 0.25
-    glyph u54 0.390625 0.1875 0.421875 0.25
-    glyph u55 0.453125 0.1875 0.484375 0.25
-    glyph u56 0.515625 0.1875 0.546875 0.25
-    glyph u57 0.578125 0.1875 0.609375 0.25
-    glyph u58 0.640625 0.1875 0.671875 0.25
-    glyph u59 0.703125 0.1875 0.734375 0.25
-    glyph u60 0.765625 0.1875 0.796875 0.25
-    glyph u61 0.828125 0.1875 0.859375 0.25
-    glyph u62 0.890625 0.1875 0.921875 0.25
-    glyph u63 0.953125 0.1875 0.984375 0.25
-    glyph u64 0.015625 0.25 0.046875 0.3125
-    glyph u65 0.078125 0.25 0.109375 0.3125
-    glyph u66 0.140625 0.25 0.171875 0.3125
-    glyph u67 0.203125 0.25 0.234375 0.3125
-    glyph u68 0.265625 0.25 0.296875 0.3125
-    glyph u69 0.328125 0.25 0.359375 0.3125
-    glyph u70 0.390625 0.25 0.421875 0.3125
-    glyph u71 0.453125 0.25 0.484375 0.3125
-    glyph u72 0.515625 0.25 0.546875 0.3125
-    glyph u73 0.578125 0.25 0.609375 0.3125
-    glyph u74 0.640625 0.25 0.671875 0.3125
-    glyph u75 0.703125 0.25 0.734375 0.3125
-    glyph u76 0.765625 0.25 0.796875 0.3125
-    glyph u77 0.828125 0.25 0.859375 0.3125
-    glyph u78 0.890625 0.25 0.921875 0.3125
-    glyph u79 0.953125 0.25 0.984375 0.3125
-    glyph u80 0.015625 0.3125 0.046875 0.375
-    glyph u81 0.078125 0.3125 0.109375 0.375
-    glyph u82 0.140625 0.3125 0.171875 0.375
-    glyph u83 0.203125 0.3125 0.234375 0.375
-    glyph u84 0.265625 0.3125 0.296875 0.375
-    glyph u85 0.328125 0.3125 0.359375 0.375
-    glyph u86 0.390625 0.3125 0.421875 0.375
-    glyph u87 0.453125 0.3125 0.484375 0.375
-    glyph u88 0.515625 0.3125 0.546875 0.375
-    glyph u89 0.578125 0.3125 0.609375 0.375
-    glyph u90 0.640625 0.3125 0.671875 0.375
-    glyph u91 0.703125 0.3125 0.734375 0.375
-    glyph u92 0.765625 0.3125 0.796875 0.375
-    glyph u93 0.828125 0.3125 0.859375 0.375
-    glyph u94 0.890625 0.3125 0.921875 0.375
-    glyph u95 0.953125 0.3125 0.984375 0.375
-    glyph u96 0.015625 0.375 0.046875 0.4375
-    glyph u97 0.078125 0.375 0.109375 0.4375
-    glyph u98 0.140625 0.375 0.171875 0.4375
-    glyph u99 0.203125 0.375 0.234375 0.4375
-    glyph u100 0.265625 0.375 0.296875 0.4375
-    glyph u101 0.328125 0.375 0.359375 0.4375
-    glyph u102 0.390625 0.375 0.421875 0.4375
-    glyph u103 0.453125 0.375 0.484375 0.4375
-    glyph u104 0.515625 0.375 0.546875 0.4375
-    glyph u105 0.578125 0.375 0.609375 0.4375
-    glyph u106 0.640625 0.375 0.671875 0.4375
-    glyph u107 0.703125 0.375 0.734375 0.4375
-    glyph u108 0.765625 0.375 0.796875 0.4375
-    glyph u109 0.828125 0.375 0.859375 0.4375
-    glyph u110 0.890625 0.375 0.921875 0.4375
-    glyph u111 0.953125 0.375 0.984375 0.4375
-    glyph u112 0.015625 0.4375 0.046875 0.5
-    glyph u113 0.078125 0.4375 0.109375 0.5
-    glyph u114 0.140625 0.4375 0.171875 0.5
-    glyph u115 0.203125 0.4375 0.234375 0.5
-    glyph u116 0.265625 0.4375 0.296875 0.5
-    glyph u117 0.328125 0.4375 0.359375 0.5
-    glyph u118 0.390625 0.4375 0.421875 0.5
-    glyph u119 0.453125 0.4375 0.484375 0.5
-    glyph u120 0.515625 0.4375 0.546875 0.5
-    glyph u121 0.578125 0.4375 0.609375 0.5
-    glyph u122 0.640625 0.4375 0.671875 0.5
-    glyph u123 0.703125 0.4375 0.734375 0.5
-    glyph u124 0.765625 0.4375 0.796875 0.5
-    glyph u125 0.828125 0.4375 0.859375 0.5
-    glyph u126 0.890625 0.4375 0.921875 0.5
-    glyph u127 0.953125 0.4375 0.984375 0.5
-    glyph u128 0.015625 0.5 0.046875 0.5625
-    glyph u129 0.078125 0.5 0.109375 0.5625
-    glyph u130 0.140625 0.5 0.171875 0.5625
-    glyph u131 0.203125 0.5 0.234375 0.5625
-    glyph u132 0.265625 0.5 0.296875 0.5625
-    glyph u133 0.328125 0.5 0.359375 0.5625
-    glyph u134 0.390625 0.5 0.421875 0.5625
-    glyph u135 0.453125 0.5 0.484375 0.5625
-    glyph u136 0.515625 0.5 0.546875 0.5625
-    glyph u137 0.578125 0.5 0.609375 0.5625
-    glyph u138 0.640625 0.5 0.671875 0.5625
-    glyph u139 0.703125 0.5 0.734375 0.5625
-    glyph u140 0.765625 0.5 0.796875 0.5625
-    glyph u141 0.828125 0.5 0.859375 0.5625
-    glyph u142 0.890625 0.5 0.921875 0.5625
-    glyph u143 0.953125 0.5 0.984375 0.5625
-    glyph u144 0.015625 0.5625 0.046875 0.625
-    glyph u145 0.078125 0.5625 0.109375 0.625
-    glyph u146 0.140625 0.5625 0.171875 0.625
-    glyph u147 0.203125 0.5625 0.234375 0.625
-    glyph u148 0.265625 0.5625 0.296875 0.625
-    glyph u149 0.328125 0.5625 0.359375 0.625
-    glyph u150 0.390625 0.5625 0.421875 0.625
-    glyph u151 0.453125 0.5625 0.484375 0.625
-    glyph u152 0.515625 0.5625 0.546875 0.625
-    glyph u153 0.578125 0.5625 0.609375 0.625
-    glyph u154 0.640625 0.5625 0.671875 0.625
-    glyph u155 0.703125 0.5625 0.734375 0.625
-    glyph u156 0.765625 0.5625 0.796875 0.625
-    glyph u157 0.828125 0.5625 0.859375 0.625
-    glyph u158 0.890625 0.5625 0.921875 0.625
-    glyph u159 0.953125 0.5625 0.984375 0.625
-    glyph u160 0.015625 0.625 0.046875 0.6875
-    glyph u161 0.078125 0.625 0.109375 0.6875
-    glyph u162 0.140625 0.625 0.171875 0.6875
-    glyph u163 0.203125 0.625 0.234375 0.6875
-    glyph u164 0.265625 0.625 0.296875 0.6875
-    glyph u165 0.328125 0.625 0.359375 0.6875
-    glyph u166 0.390625 0.625 0.421875 0.6875
-    glyph u167 0.453125 0.625 0.484375 0.6875
-    glyph u168 0.515625 0.625 0.546875 0.6875
-    glyph u169 0.578125 0.625 0.609375 0.6875
-    glyph u170 0.640625 0.625 0.671875 0.6875
-    glyph u171 0.703125 0.625 0.734375 0.6875
-    glyph u172 0.765625 0.625 0.796875 0.6875
-    glyph u173 0.828125 0.625 0.859375 0.6875
-    glyph u174 0.890625 0.625 0.921875 0.6875
-    glyph u175 0.953125 0.625 0.984375 0.6875
-    glyph u176 0.015625 0.6875 0.046875 0.75
-    glyph u177 0.078125 0.6875 0.109375 0.75
-    glyph u178 0.140625 0.6875 0.171875 0.75
-    glyph u179 0.203125 0.6875 0.234375 0.75
-    glyph u180 0.265625 0.6875 0.296875 0.75
-    glyph u181 0.328125 0.6875 0.359375 0.75
-    glyph u182 0.390625 0.6875 0.421875 0.75
-    glyph u183 0.453125 0.6875 0.484375 0.75
-    glyph u184 0.515625 0.6875 0.546875 0.75
-    glyph u185 0.578125 0.6875 0.609375 0.75
-    glyph u186 0.640625 0.6875 0.671875 0.75
-    glyph u187 0.703125 0.6875 0.734375 0.75
-    glyph u188 0.765625 0.6875 0.796875 0.75
-    glyph u189 0.828125 0.6875 0.859375 0.75
-    glyph u190 0.890625 0.6875 0.921875 0.75
-    glyph u191 0.953125 0.6875 0.984375 0.75
-    glyph u192 0.015625 0.75 0.046875 0.8125
-    glyph u193 0.078125 0.75 0.109375 0.8125
-    glyph u194 0.140625 0.75 0.171875 0.8125
-    glyph u195 0.203125 0.75 0.234375 0.8125
-    glyph u196 0.265625 0.75 0.296875 0.8125
-    glyph u197 0.328125 0.75 0.359375 0.8125
-    glyph u198 0.390625 0.75 0.421875 0.8125
-    glyph u199 0.453125 0.75 0.484375 0.8125
-    glyph u200 0.515625 0.75 0.546875 0.8125
-    glyph u201 0.578125 0.75 0.609375 0.8125
-    glyph u202 0.640625 0.75 0.671875 0.8125
-    glyph u203 0.703125 0.75 0.734375 0.8125
-    glyph u204 0.765625 0.75 0.796875 0.8125
-    glyph u205 0.828125 0.75 0.859375 0.8125
-    glyph u206 0.890625 0.75 0.921875 0.8125
-    glyph u207 0.953125 0.75 0.984375 0.8125
-    glyph u208 0.015625 0.8125 0.046875 0.875
-    glyph u209 0.078125 0.8125 0.109375 0.875
-    glyph u210 0.140625 0.8125 0.171875 0.875
-    glyph u211 0.203125 0.8125 0.234375 0.875
-    glyph u212 0.265625 0.8125 0.296875 0.875
-    glyph u213 0.328125 0.8125 0.359375 0.875
-    glyph u214 0.390625 0.8125 0.421875 0.875
-    glyph u215 0.453125 0.8125 0.484375 0.875
-    glyph u216 0.515625 0.8125 0.546875 0.875
-    glyph u217 0.578125 0.8125 0.609375 0.875
-    glyph u218 0.640625 0.8125 0.671875 0.875
-    glyph u219 0.703125 0.8125 0.734375 0.875
-    glyph u220 0.765625 0.8125 0.796875 0.875
-    glyph u221 0.828125 0.8125 0.859375 0.875
-    glyph u222 0.890625 0.8125 0.921875 0.875
-    glyph u223 0.953125 0.8125 0.984375 0.875
-    glyph u224 0.015625 0.875 0.046875 0.9375
-    glyph u225 0.078125 0.875 0.109375 0.9375
-    glyph u226 0.140625 0.875 0.171875 0.9375
-    glyph u227 0.203125 0.875 0.234375 0.9375
-    glyph u228 0.265625 0.875 0.296875 0.9375
-    glyph u229 0.328125 0.875 0.359375 0.9375
-    glyph u230 0.390625 0.875 0.421875 0.9375
-    glyph u231 0.453125 0.875 0.484375 0.9375
-    glyph u232 0.515625 0.875 0.546875 0.9375
-    glyph u233 0.578125 0.875 0.609375 0.9375
-    glyph u234 0.640625 0.875 0.671875 0.9375
-    glyph u235 0.703125 0.875 0.734375 0.9375
-    glyph u236 0.765625 0.875 0.796875 0.9375
-    glyph u237 0.828125 0.875 0.859375 0.9375
-    glyph u238 0.890625 0.875 0.921875 0.9375
-    glyph u239 0.953125 0.875 0.984375 0.9375
-    glyph u240 0.015625 0.9375 0.046875 1
-    glyph u241 0.078125 0.9375 0.109375 1
-    glyph u242 0.140625 0.9375 0.171875 1
-    glyph u243 0.203125 0.9375 0.234375 1
-    glyph u244 0.265625 0.9375 0.296875 1
-    glyph u245 0.328125 0.9375 0.359375 1
-    glyph u246 0.390625 0.9375 0.421875 1
-    glyph u247 0.453125 0.9375 0.484375 1
-    glyph u248 0.515625 0.9375 0.546875 1
-    glyph u249 0.578125 0.9375 0.609375 1
-    glyph u250 0.640625 0.9375 0.671875 1
-    glyph u251 0.703125 0.9375 0.734375 1
-    glyph u252 0.765625 0.9375 0.796875 1
-    glyph u253 0.828125 0.9375 0.859375 1
-    glyph u254 0.890625 0.9375 0.921875 1
-    glyph u255 0.953125 0.9375 0.984375 1
-}

+ 0 - 0
data/v-gears.cfg_linux


+ 1 - 1
debian/changelog

@@ -2,4 +2,4 @@ v-gears (0.0.1) bullseye; urgency=low
 
   First VGears Package
 
- -- Iñigo Valentin <i@inigovalentin.com>  Tue, 26 Jul 2022 22:07:00 -0100
+ -- Iñigo Valentin <i@inigovalentin.com>  Tue, 26 Jul 2022 22:07:00 -0100

+ 3 - 3
debian/control

@@ -1,11 +1,11 @@
 Source: v-gears
 Section: games
 Priority: extra
-Maintainer: I�igo Valentin <i@inigovalentin.com>
-Build-Depends: debhelper(>= 7.0.50~),libogre-1.12-dev, libtiyxml-dev, cmake, cdbs, libois-dev, libboost-program-options-dev, libboost-filesystem-dev, libvorbis-dev, libopenal-dev, libboost-test-dev, zlib1g-dev, libqt5core5a, libqt5widgets5, libluajit-5.1-dev
+Maintainer: Iñigo Valentin <i@inigovalentin.com>
+Build-Depends: debhelper(>= 7.0.50~),libogre-1.12-dev, libtinyxml-dev, cmake, cdbs, libois-dev, libboost-program-options-dev, libboost-filesystem-dev, libvorbis-dev, libopenal-dev, libboost-test-dev, zlib1g-dev, libarchive-dev, libqt5core5a, libqt5widgets5, libluajit-5.1-dev
 Standards-Version: 3.9.5
 Homepage: https://v-gears.inigovalentin.com
-Vcs-Git: git://https://github.com/v-gears/v-gears.git
+Vcs-Git: https://github.com/InigoValentin/V-Gears.git
 #Vcs-Browser: http://git.debian.org/?p=collab-maint/vgears.git;a=summary
 
 Package: v-gears

+ 1 - 2
debian/install

@@ -2,5 +2,4 @@ v-gears.desktop /usr/share/applications
 v-gears.png /usr/share/pixmaps
 v-gears-installer.desktop /usr/share/applications
 v-gears-installer.png /usr/share/pixmaps
-v-gears-launcher /usr/bin
-output/resources.cfg /usr/share/v-gears
+data/* /usr/share/v-gears

+ 1 - 1
debian/menu

@@ -1,3 +1,3 @@
 ?package(v-gears):needs="x11" section="Games/Toys"\
-  title="v-gears" command="/usr/bin/v-gears"
+  title="v-gears" command="sh -c 'cd ~/.v-gears && exec /usr/games/v-gears'"
   

+ 19 - 3
doc/BUILD.md

@@ -4,7 +4,7 @@
 If you are runind Debian (or derivates), you can install the required dependencies by running as root
 
 ```bash
-apt install g++ cmake libqt5widgets5 qtbase5-dev zlib1g-dev libogre-1.12-dev libois-dev libvorbis-dev libboost-dev libboost-program-options-dev libboost-test-dev libboost-filesystem-dev libboost-thread-dev libopenal-dev libtinyxml-dev libluajit-5.1-dev
+apt install g++ cmake libqt5widgets5 qtbase5-dev zlib1g-dev libarchive-dev libogre-1.12-dev libois-dev libvorbis-dev libboost-dev libboost-program-options-dev libboost-test-dev libboost-filesystem-dev libboost-thread-dev libopenal-dev libtinyxml-dev libluajit-5.1-dev
 ```
 
 ## Deterministic fallbacks
@@ -43,9 +43,25 @@ If you want to speed it up a bit (it can take several minutes, depending on your
 Once the build process finishes, you u sholh have the following files (from the project directory):
 
 - `build/bin/v-gears`, the engine executable.
-- `build/bin/v-gears-launcher`, the data installer.
+- `build/bin/v-gears-installer`, the data installer.
 
-Both the engine and the installer are a little pesky about from where they are launched, so before trying to run them, keep reading.
+The engine must be launched from the installation directory (the installer starts it from there for you), so before trying to run it, keep reading.
+
+## Run the tests (optional)
+
+The tests are not built by default. To build and run the tests of the installer logic (the option rules, the installation layout, the verifier, the log and the tool finder), which don't need the rest of the engine:
+
+```bash
+mkdir build-tests;
+cd build-tests;
+cmake ../ -DBUILD_TESTS=ON;
+make v-gears-installer-tests;
+./bin/v-gears-installer-tests
+```
+
+## Optional programs
+
+To convert the sound effects and the music, the installer uses FFmpeg and TiMidity. They are not needed to build V-Gears, only to install the data. See the [data installation guide](INSTALL_DATA.md "Install Data").
 
 ## Next steps
 

+ 3 - 7
doc/INSTALL.md

@@ -2,23 +2,19 @@
 
 ## Manual installation
 
-Currently, there is no easy way to install V-Gears automatically. Until it's implemented, this is the easiest method.
+The data installer can do this for you: enable "Copy V-Gears data files" in its advanced options and it copies the `data/` folder of the repository to the installation directory. To do it by hand:
 
 1. Create a folder for V-Gears data, outside the project. In Linux-based systems, it's recommended to use `~/.v-gears/`.
-2. Copy all the contents of the `output/` folder to the newly created directory. It should end up like this:
+2. Copy all the contents of the `data/` folder of the repository to the newly created directory. It should end up like this:
 ```
 ~/.v-gears/
   ├── data
   ├── field_models_and_animation_metadata.xml
   ├── plugins.cfg
-  ├── plugins.cfg_win32
   ├── plugins_d.cfg
   ├── RenderSystem_GL.so.1.12.10
   ├── resources.cfg
-  ├── resources.cfg_linux
-  ├── resources_d.cfg
-  ├── system.fontdef
-  └── v-gears.cfg_linux
+  └── resources_d.cfg
 ```
 3. Whenever you want to launch V-Gears or the V-Gears data installer, it must be done from this directory.
 

+ 39 - 51
doc/INSTALL_DATA.md

@@ -1,87 +1,75 @@
 # <img src="../v-gears.png" alt="V-Gears" width="50"/> V-Gears Data Installation Guide
 
-## Extract game data.
+## What you need
 
-To install Final Fantasy VII game data, you'll nedd a PC version copy of if. Extract the contents of the installation dist to a folder anywehre you like. For this guide, we'll be calling that folder `FFVIIPC/`. Once extracted, verify that the directory structure contains the following subdirectories:
-```
-FFVIIPC/
-├── battle
-├── cd
-├── field
-├── kernel
-├── menu
-├── midi
-├── minigame
-├── music
-├── sound
-└── wm
-```
+To install the Final Fantasy VII game data you need your own copy of the PC version of the game. The installer reads the **ISO image of the install disk** directly, so there is nothing to extract by hand. It extracts what it needs to `<installation directory>/original_data/` and converts it from there.
 
-## Install FFmpeg
+If you also want the videos, you need the ISO images of the PC game disks 1, 2 and 3 (see [Extract the videos](#extract-the-videos)). The disks of the PlayStation versions are not supported.
 
-The installer uses [FFmpeg](https://ffmpeg.org "FFmpeg") to convert sound effect files. To do this, it uses the ffmpeg executable in the system, and the sounds files will not be properly installed if there is such executable. In most GNU/Linux distros, ffmpeg is available as a package, so you can install it with your package manager. For the rest of cases, see [the downloads page](https://ffmpeg.org/download.html "FFmpeg download page").
+## FFmpeg and TiMidity
 
-It's important that the ffmpeg executable is located in a globally accessed location. To verify this, try to run this command from a console: `ffmpeg -version`. If FFmpeg version is show, you are all set. If not, you must include the path to the ffmpeg executable to your system PATH environment variable.
+The installer converts the sound effects and the music to `.ogg` files with two external programs:
 
-If you can't (or don't want to) install FFmpeg in your system, a workaround is explained later.
+- [FFmpeg](https://ffmpeg.org/download.html "FFmpeg") converts the sound effects and encodes the music.
+- [TiMidity](https://timidity.sourceforge.net/#download "TiMidity") plays the original MIDI music so FFmpeg can encode it. Only the music needs it.
 
-## Install TiMidity
+You don't have to configure anything if they are installed. The installer looks for them, in this order:
 
-The installer uses [TiMidity](http://timidity.sourceforge.net/ "TiMidity") to convert the original music tracks. To do this, it uses the timidity executable in the system, and the tracks files will not be properly installed if there is such executable. In most GNU/Linux distros, timidity is available as a package, so you can install it with your package manager. For the rest of cases, see [the downloads page](https://timidity.sourceforge.net/#download "TiMidity download page").
+1. The program you chose with **Locate…**. The choice is remembered.
+2. A `tools` folder inside the installation directory, or next to the installer. On Windows, the easiest way is to copy `ffmpeg.exe` there.
+3. The system `PATH`. In most GNU/Linux distributions both are available as packages (for example, `sudo apt install ffmpeg timidity`).
 
-It's important that the timidity executable is located in a globally accessed location. To verify this, try to run this command from a console: `timidity -v`. If TiMidity version is show, you are all set. If not, you must include the path to the timidity executable to your system PATH environment variable.
+The window shows what the installer found (`Audio tools: FFmpeg ✓ … · TiMidity ✓ …`). Every program is run once to check that it works, and FFmpeg must be able to write ogg files (it needs the Vorbis encoder). On Windows, TiMidity also needs its `timidity.cfg` and the instrument files next to `timidity.exe`.
 
-If you can't (or don't want to) install TiMidity in your system, a workaround is explained later.
+If a program that the installation needs is missing, the installer tells you before it starts. The message says what you would lose, and you can:
 
-## Launch the Installer
+- **Locate…** the program in your system.
+- **Get it…**: open its download page.
+- **Check again**, for when you have just installed it.
+- **Continue without** it. The rest is installed normally, and the sound effects, the music, or both are left out. The extracted `.wav` and `.mid` files stay in `~/.v-gears/data/audio/`, and the indexes only list the files that exist.
+- **Cancel** the installation.
 
-If you have followed the [build](BUILD.md "Building guide") and [installaton](INSTALL.md "Installation guide") guides, you'll have the game data copied to `~/.v-gears/`, and the binaries compilled somewhere (we'll assum they are in `~/V-Gears/build/bin`, change the directories according to your system).
+To add the missing audio later, install the programs and run the installer again with only the sound effects and the music selected (the *Media only* option, or *Custom*).
 
+## Launch the Installer
 
-On a terminal, do into the game data folder, and from there, run the installer
+If you have followed the [build](BUILD.md "Building guide") and [installaton](INSTALL.md "Installation guide") guides, you'll have the game data copied to `~/.v-gears/` (or you can let the installer copy it, see below), and the binaries compiled somewhere (we'll assume they are in `~/V-Gears/build/bin`, change the directories according to your system). Just run the installer:
 
 ```Bash
-cd ~/.v-gears/;
-~/V-Gears/build/bin/v-gears-launcher;
-
+~/V-Gears/build/bin/v-gears-installer
 ```
 
+It can be started from any directory. When the installation starts, it works from the installation directory.
+
 ## Run the Installer
 
-In the 'Data Installer' tab, in the 'Import data source' field, select the folder where you extracted the game data (in this guide `FFVIIPC/`), and in the 'VGears Data Path', select the folder whith the game data (in this guide `~/.v-gears/`).
+1. In **Import data source**, select the ISO image of the install disk. The installer tells you if the image is valid.
+2. In **VGears installation directory**, select where to install, for example `~/.v-gears/`. It will be created if it doesn't exist. It holds the configuration and the converted data, and it is where the game runs.
+3. (Optional) Select **Show advanced options** to choose what to install (see below). The defaults install everything. If the directory doesn't have the V-Gears files yet (configuration, scripts, screens, fonts...), leave **Copy V-Gears data files** checked, and the installer copies them there from the `data/` folder of the repository.
+4. Click **Install data**.
 
-Then just click 'Install Data'.
+Before extracting anything, the installer checks that everything is in place: that the ISO is valid, that the directory is writable and has enough free space (about 3 GB plus the size of the ISO), and that the FFmpeg and TiMidity programs it needs can be used (see below). If something is wrong it tells you and doesn't start.
 
-The process shouldn't take too long. Dont' be discouraged by the huge amount of errors you might see, if at the end you see 'Installation finished', the data is succesfully installed.
+The log shows the progress. Problems are shown in color (red for errors, orange for warnings), and the full log is written to `installer.log` in the installation directory. Don't be discouraged by some warnings about skipped fields or models, they are known limitations of the converter. The installation is finished when the progress bar reaches 100% and the window can be closed again.
 
-### Workaround: FFmpeg is installed, but TiMidity is not.
+An installation can only be done once per installer window. To install again, close the installer and open it again. The estimate under the options shows about how much disk space it will use.
 
-If your system has FFmpeg installed, but not Timidity, you will stil get the sound effects, but V-Gears will not be able to play background music unless you find a way to convert mid files to ogg. If you know of one, here is wht you can do:
+When it finishes:
 
-- In the installer, open the advanced options.
-- Check the options 'Prevent TiMidity calls' and 'Keep originals'.
-- Proceed with the installation normally.
-- Once is done, use your own method to convert the `.mid` files in `~/.v-gears/data/audio/music/` to `.ogg` files, while keeping the same name.
-- (Optional) When done, mid files can be safely deleted.
+- **Verify install** checks that the installation directory has everything the game needs, and lists what is missing, grouped by part. If you skipped a part on purpose, its files are expected to be missing.
+- **Launch game** starts V-Gears from the installation directory.
 
-### Workaround: FFmpeg is not installed.
+## Extract the videos
 
-If your system doesn't have ffmpeg installed, the instaler will not be able to convert sound effects or background music. If you have a way to convert `.mid` and `.wav` files to `.ogg`, there is still something you can do:
+The videos are not on the install disk, but on the PC game disks 1, 2 and 3. To get them, run the installer once for each disk: select the disk's ISO as the data source and the same installation directory, and click 'Install data'. The installer detects a game disk, extracts only its videos to `<installation directory>/data/video/` and doesn't touch anything else. Several disks have the same videos, so those already installed are skipped. Unlike the full installation, it can be repeated with the same installer window.
 
-- In the installer, open the advanced options.
-- Check the options 'Prevent FFmpeg calls' and 'Keep originals'.
-- Proceed with the installation normally.
-- Once is done, use your own method to convert the `.mid` files in `~/.v-gears/data/audio/music/` to `.ogg` files, while keeping the same name.
-- Use your own method to convert the `.wav` files in `~/.v-gears/data/audio/sound/` to `.ogg` files, while keeping the same name.
-- The installation disk has 4 `.wav` files that you will also need to convert to `.ogg`. They are located in `data/music/`. For each one, you can find the corespondant name in the file `~/.v-gears/data/musics.xml` (it wil be one or two digits). Convert the tracks to `ogg`, with that name, and place them in `~/.v-gears/data/audio/music/`.
-- (Optional) When done, mid and wav files can be safely deleted.
+The videos are copied as they are (AVI files with the Duck TrueMotion 2 codec, which FFmpeg can decode). V-Gears doesn't play them yet.
 
 ## Advanced options
 
-The installer has a list of advanced options. They have tooltips with short explanations of what they do. For a fresh installation, there is ususlly no nned to check any advanced options (unless you are using one of the workarounds described below).
-
-For subsequent installations, it's generally usefull ti check the options 'Don't extract sounds' and 'Don't extract music'. By doing so, music and sounds will not be reinstalled, and the installation wil shorten by afew minutes.
+The installer has a list of advanced options, under **Show advanced options**. They have tooltips with short explanations of what they do. For a fresh installation there is usually no need to change them: the *Everything* option installs it all.
 
+*What to install* has ready-made sets of options. Choose *Custom* to pick them one by one. For later installations it's useful to choose only what you need (for example, only the fields), which shortens the installation by a few minutes.
 
 ## Next steps
 

+ 2 - 0
doc/RUN.md

@@ -12,6 +12,8 @@ cd ~/.v-gears/;
 
 ```
 
+The data installer also has a **Launch game** button, which starts the engine from the installation directory.
+
 ## Key Mappings
 
 Here is a list of key mappings, adapted from the PSX version of the game:

+ 3 - 0
src/CMakeLists.txt

@@ -601,6 +601,9 @@ set(VGEARS_SOURCE_FILES
     core/Background2DAnimation.cpp
     core/Background2D.cpp
     core/ScreenFade.cpp
+    core/MovieCameraFrame.cpp
+    core/MoviePlayer.cpp
+    core/MovieState.cpp
     core/ScreenFadeState.cpp
     core/BattleManager.cpp
     core/CameraManager.cpp

+ 4 - 4
src/Version.h

@@ -22,8 +22,8 @@
 /**
  * Application version.
  */
-#define VGEARS_VERSION "0.1.18"
+#define VGEARS_VERSION "0.3.0"
 #define VGEARS_VERSION_MAJOR "0"
-#define VGEARS_VERSION_MINOR "1"
-#define VGEARS_VERSION_PATCH "18"
-#define VGEARS_VERSION_SIGNATURE "V-Gears v0.1.18"
+#define VGEARS_VERSION_MINOR "3"
+#define VGEARS_VERSION_PATCH "0"
+#define VGEARS_VERSION_SIGNATURE "V-Gears v0.3.0"

+ 47 - 2
src/core/AudioManager.cpp

@@ -13,6 +13,7 @@
  * GNU General Public License for more details.
  */
 
+#include <algorithm>
 #include <iostream>
 #include <list>
 #include <boost/thread.hpp>
@@ -34,7 +35,7 @@ int AudioManager::channel_buffer_size_ = 96 * 1024;
 
 AudioManager::AudioManager():
   initialized_(false), thread_continue_(true), update_mutex_(), music_(&update_mutex_),
-  battle_music_(&update_mutex_), fx_(&update_mutex_)
+  battle_music_(&update_mutex_), fx_(&update_mutex_), movie_(&update_mutex_)
 {
     al_device_ = alcOpenDevice(nullptr);
     if (al_device_ != nullptr){
@@ -143,6 +144,7 @@ void AudioManager::MusicPlay(const Ogre::String& name){
         }
         else{
             battle_music_.Stop();
+            LOG_TRIVIAL("Music '" + name + "' plays '" + music->file + "'.");
             music_.SetLoop(music->loop);
             music_.Play(music->file);
         }
@@ -190,6 +192,19 @@ void AudioManager::SoundPlay(const Ogre::String& name){
     }
 }
 
+void AudioManager::MoviePlay(const Ogre::String& file){
+    if (!initialized_) return;
+    boost::recursive_mutex::scoped_lock lock(update_mutex_);
+    movie_.SetLoop(-1);
+    movie_.Play(file);
+}
+
+void AudioManager::MovieStop(){
+    if (!initialized_) return;
+    boost::recursive_mutex::scoped_lock lock(update_mutex_);
+    movie_.Stop();
+}
+
 void AudioManager::ScriptPlaySound(const char* name){
     const Ogre::String name_str = Ogre::String(name);
     SoundPlay(name_str);
@@ -204,6 +219,21 @@ void AudioManager::ScriptPlaySounds(
     ScriptPlaySound(name1);
 }
 
+void AudioManager::ScriptSetMusicVolume(const float volume){
+    if (!initialized_) return;
+    boost::recursive_mutex::scoped_lock lock(update_mutex_);
+    music_.SetGain(volume);
+    battle_music_.SetGain(volume);
+    // The audio of the movies follows the music volume.
+    movie_.SetGain(volume);
+}
+
+void AudioManager::ScriptSetSoundVolume(const float volume){
+    if (!initialized_) return;
+    boost::recursive_mutex::scoped_lock lock(update_mutex_);
+    fx_.SetGain(volume);
+}
+
 void AudioManager::MusicStop(){
     boost::recursive_mutex::scoped_lock lock(update_mutex_);
     music_.Stop();
@@ -282,12 +312,14 @@ void AudioManager::UpdateField(){
     boost::recursive_mutex::scoped_lock lock(update_mutex_);
     music_.Update();
     fx_.Update();
+    movie_.Update();
 }
 
 void AudioManager::UpdateBattle(){
     boost::recursive_mutex::scoped_lock lock(update_mutex_);
     battle_music_.Update();
     fx_.Update();
+    movie_.Update();
 }
 
 void AudioManager::UpdateWorld(){UpdateField();}
@@ -355,6 +387,7 @@ void AudioManager::Player::Play(const Ogre::String &file){
     alGenBuffers(channel_buffer_number_, buffers);
     // Set source parameters
     alSourcei(source_, AL_LOOPING, AL_FALSE);
+    alSourcef(source_, AL_GAIN, gain_);
     // Fill buffers
     for (int i = 0; i < channel_buffer_number_; ++ i){
         ALsizei buffer_size = FillBuffer();
@@ -376,9 +409,18 @@ void AudioManager::Player::Play(const Ogre::String &file){
     alSourcePlay(source_);
 }
 
+void AudioManager::Player::SetGain(const float gain){
+    boost::recursive_mutex::scoped_lock lock(*update_mutex_);
+    gain_ = std::max(0.0f, std::min(1.0f, gain));
+    if (!file_.empty() && alIsSource(source_)) alSourcef(source_, AL_GAIN, gain_);
+}
+
 void AudioManager::Player::Stop(){
     boost::recursive_mutex::scoped_lock lock(*update_mutex_);
-    if (!alIsSource(source_)) return;
+    // Nothing to stop if there is no track. The id of a source that was deleted can be given to
+    // another player, so checking if it is a source is not enough: it would delete the source of
+    // the other player and free the vorbis file a second time.
+    if (file_.empty() || !alIsSource(source_)) return;
     // Stop source
     alSourceStop(source_);
     // Get source buffers
@@ -396,6 +438,7 @@ void AudioManager::Player::Stop(){
     ov_clear(&vorbis_file_);
     stream_finished_ = false;
     file_ = "";
+    source_ = 0;
 }
 
 void AudioManager::Player::SetLoop(const float loop){
@@ -404,6 +447,8 @@ void AudioManager::Player::SetLoop(const float loop){
 }
 
 void AudioManager::Player::Update(){
+    // A player with no track has no source. Its old id may belong to another player by now.
+    if (file_.empty()) return;
     // Try to fill processed buffers
     int processed = 0;
     alGetSourcei(source_, AL_BUFFERS_PROCESSED, &processed);

+ 52 - 0
src/core/AudioManager.h

@@ -212,6 +212,18 @@ class AudioManager : public Manager, public Ogre::Singleton<AudioManager>{
          */
         void SoundPlay(const Ogre::String& name);
 
+        /**
+         * Plays the sound of a movie, in its own channel.
+         *
+         * @param[in] file Path to the ogg file.
+         */
+        void MoviePlay(const Ogre::String& file);
+
+        /**
+         * Stops the sound of the movie.
+         */
+        void MovieStop();
+
         /**
          * Stops the currently playing music.
          *
@@ -292,6 +304,27 @@ class AudioManager : public Manager, public Ogre::Singleton<AudioManager>{
          */
         void ScriptPlayMusicOnce(const char* name);
 
+        /**
+         * Sets the volume of the music (field, battle and world tracks) and of the audio of the
+         * movies.
+         *
+         * It applies right away to the track that is playing and to the ones that start later. To
+         * be called from Lua scripts.
+         *
+         * @param[in] volume Volume, from 0 (muted) to 1 (maximum).
+         */
+        void ScriptSetMusicVolume(const float volume);
+
+        /**
+         * Sets the volume of the sound effects.
+         *
+         * It applies right away to the sound that is playing and to the ones that start later. To
+         * be called from Lua scripts.
+         *
+         * @param[in] volume Volume, from 0 (muted) to 1 (maximum).
+         */
+        void ScriptSetSoundVolume(const float volume);
+
         /**
          * Stops the battle music and lets the field or world map music continue.
          *
@@ -350,6 +383,15 @@ class AudioManager : public Manager, public Ogre::Singleton<AudioManager>{
                  */
                 void SetLoop(const float loop);
 
+                /**
+                 * Sets the volume of the player.
+                 *
+                 * Applies to the track that is playing, and is kept for the ones that start later.
+                 *
+                 * @param[in] gain Volume, from 0 (muted) to 1 (maximum).
+                 */
+                void SetGain(const float gain);
+
                 /**
                  * Updates the audio player.
                  *
@@ -412,6 +454,11 @@ class AudioManager : public Manager, public Ogre::Singleton<AudioManager>{
                  */
                 ALuint source_;
 
+                /**
+                 * Volume of the player, from 0 to 1.
+                 */
+                float gain_ = 1.0f;
+
                 /**
                  * Fills the audio buffer.
                  *
@@ -510,6 +557,11 @@ class AudioManager : public Manager, public Ogre::Singleton<AudioManager>{
          */
         AudioManager::Player fx_;
 
+        /**
+         * Player of the sound of movies.
+         */
+        AudioManager::Player movie_;
+
         /**
          * List of music.
          */

+ 3 - 9
src/core/Background2D.cpp

@@ -494,15 +494,7 @@ void Background2D::ScriptScrollToPosition(
 ){
     LOG_TRIVIAL(
       "Background2d set scroll to position '"
-      + Ogre::StringConverter::toString(Ogre::Vector2(x, y)) + "', current '"
-      + Ogre::StringConverter::toString(position_) + "', player would be at '"
-      + (EntityManager::getSingleton().ScriptGetPlayerEntity() != nullptr
-        ? Ogre::StringConverter::toString(
-            GetEntityScroll(EntityManager::getSingleton().ScriptGetPlayerEntity())
-          )
-        : Ogre::String("none"))
-      + "', range " + Ogre::StringConverter::toString(range_.getMinimum()) + " "
-      + Ogre::StringConverter::toString(range_.getMaximum()) + "."
+      + Ogre::StringConverter::toString(Ogre::Vector2(x, y)) + "'."
     );
     scroll_entity_ = nullptr;
     if (type == Background2D::NONE || seconds <= 0){
@@ -888,6 +880,8 @@ void Background2D::renderQueueEnded(
   Ogre::uint8 queue_group_id, const Ogre::String& invocation, bool& repeat_this_invocation
 ){
     if (cv_show_background2d.GetB() == false) return;
+    // The movie takes the place of the background, and the models are drawn over it.
+    if (EntityManager::getSingleton().GetMoviePlayer()->IsDrawnBehind()) return;
     if (queue_group_id == Ogre::RENDER_QUEUE_MAIN){
         Ogre::GpuProgramParametersPtr rs_params = render_system_->getFixedFunctionParams(
           Ogre::TVC_NONE, Ogre::FOG_NONE

+ 29 - 0
src/core/BattleManager.cpp

@@ -19,6 +19,7 @@
 #include <cmath>
 #include <OgreEntity.h>
 #include <OgreRoot.h>
+#include <OgreResourceGroupManager.h>
 #include <OgreViewport.h>
 #include "FF7Character.h"
 #include "core/AudioManager.h"
@@ -291,6 +292,34 @@ void BattleManager::ScriptAddAttackModel(
     );
 }
 
+std::string BattleManager::ScriptAddEffectModel(
+  const char* model, const float x, const float y, const float z, const float scale
+){
+    if (module_ != Module::BATTLE){
+        LOG_ERROR("Tried to add an effect model, but the BattleManager is not in battle mode.");
+        return "";
+    }
+    const std::string mesh = "effects/" + std::string(model) + ".mesh";
+    if (!Ogre::ResourceGroupManager::getSingleton().resourceExists("BATTLE_MODELS", mesh)){
+        LOG_WARNING("Effect model '" + mesh + "' doesn't exist, reinstall the battle data.");
+        return "";
+    }
+    const std::string name = "fx_" + std::string(model) + "_" + std::to_string(next_effect_id_ ++);
+    const float s = ATTACK_SCALE * scale;
+    EntityManager::getSingleton().AddEntity(
+      name, mesh, Ogre::Vector3(x, y, z), Ogre::Degree(0), Ogre::Vector3(s, s, s),
+      Ogre::Quaternion(1, 1, 0, 0), -1
+    );
+    // Effect meshes have no animations, and battle entities look for "xx_00" by default.
+    Entity* entity = EntityManager::getSingleton().GetEntity(name);
+    if (entity != nullptr) entity->ScriptSetDefaultAnimation("Idle");
+    return name;
+}
+
+void BattleManager::ScriptRemoveEffectModel(const char* name){
+    EntityManager::getSingleton().RemoveBattleEntity(name);
+}
+
 void BattleManager::AddCamera(
   const unsigned int id, const Ogre::Vector3 pos, const Ogre::Vector3 dir
 ){

+ 32 - 0
src/core/BattleManager.h

@@ -286,6 +286,32 @@ class BattleManager : public Manager, public Ogre::Singleton<BattleManager>{
         );
 
 
+        /**
+         * Adds a mesh of a visual effect to the battle scene.
+         *
+         * The meshes come from the installer, in models/battle/effects. The entity can be moved,
+         * scaled and blended from the scripts, and should be removed with
+         * {@see ScriptRemoveEffectModel} when the effect ends.
+         *
+         * @param[in] model Model name, without path or extension, like "magic_faira_1".
+         * @param[in] x Position, X coordinate.
+         * @param[in] y Position, Y coordinate.
+         * @param[in] z Position, Z coordinate.
+         * @param[in] scale Scale, relative to the size of attack models. 1 is the original size.
+         * @return Name of the new entity, empty if it couldn't be created.
+         */
+        std::string ScriptAddEffectModel(
+          const char* model, const float x, const float y, const float z, const float scale
+        );
+
+        /**
+         * Removes an effect mesh added with {@see ScriptAddEffectModel}.
+         *
+         * @param[in] name Name of the entity.
+         */
+        void ScriptRemoveEffectModel(const char* name);
+
+
         /**
          * Sets the battle layout.
          *
@@ -536,6 +562,12 @@ class BattleManager : public Manager, public Ogre::Singleton<BattleManager>{
 
     private:
 
+        /**
+         * Counter to give names to effect entities.
+         */
+        unsigned int next_effect_id_ = 0;
+
+
         /**
          * Loads the party members into the battle.
          */

+ 21 - 0
src/core/CameraManager.cpp

@@ -249,8 +249,29 @@ void CameraManager::EndBattleCamera(){
       << camera_->getOrientation().y << ", " << camera_->getOrientation().z << std::endl;*/
 }
 
+void CameraManager::SetMovieCamera(
+  const Ogre::Vector3& position, const Ogre::Quaternion& orientation, const Ogre::Radian& fov
+){
+    movie_camera_ = true;
+    camera_->setPosition(position);
+    camera_->setOrientation(orientation);
+    camera_->setFOVy(fov);
+    // The movie fills the screen: it doesn't scroll.
+    camera_->resetFrustumExtents();
+}
+
+void CameraManager::EndMovieCamera(){
+    if (!movie_camera_) return;
+    movie_camera_ = false;
+    camera_->setPosition(d2_position_);
+    camera_->setOrientation(d2_orientation_);
+    camera_->setFOVy(d2_fov_);
+    Set2DScroll(d2_scroll_);
+}
+
 void CameraManager::Set2DScroll(const Ogre::Vector2& position){
     d2_scroll_ = position;
+    if (movie_camera_) return;
     if(camera_free_ == true) return;
     float width = static_cast<float>(viewport_->getActualWidth());
     float height = static_cast<float>(viewport_->getActualHeight());

+ 23 - 0
src/core/CameraManager.h

@@ -108,6 +108,24 @@ class CameraManager : public Manager, public Ogre::Singleton<CameraManager>{
           const Ogre::Radian fov
         );
 
+        /**
+         * Places the camera where the camera of a movie is, to draw the models over it. The field
+         * camera is kept, and the scroll of the background doesn't move the camera until
+         * {@see EndMovieCamera}.
+         *
+         * @param[in] position Camera coordinates.
+         * @param[in] orientation Camera rotation.
+         * @param[in] fov Vertical field of view.
+         */
+        void SetMovieCamera(
+          const Ogre::Vector3& position, const Ogre::Quaternion& orientation, const Ogre::Radian& fov
+        );
+
+        /**
+         * Puts the camera of the field back, with its scroll.
+         */
+        void EndMovieCamera();
+
         /**
          * Starts the battle camera
          *
@@ -311,5 +329,10 @@ class CameraManager : public Manager, public Ogre::Singleton<CameraManager>{
          * The camera scroll.
          */
         Ogre::Vector2 d2_scroll_;
+
+        /**
+         * Whether the camera is the one of a movie.
+         */
+        bool movie_camera_ = false;
 };
 

+ 13 - 0
src/core/Entity.cpp

@@ -300,6 +300,19 @@ void Entity::setScale(const Ogre::Vector3 &scale) {
     model_root_node_->setScale(scale);
 }
 
+void Entity::ScriptSetScale(const float x, const float y, const float z){
+    setScale(Ogre::Vector3(x, y, z));
+}
+
+void Entity::ScriptSetModelRotation(const float x, const float y, const float z){
+    assert(model_node_);
+    model_node_->setOrientation(
+      Ogre::Quaternion(Ogre::Degree(z), Ogre::Vector3::UNIT_Z)
+      * Ogre::Quaternion(Ogre::Degree(y), Ogre::Vector3::UNIT_Y)
+      * Ogre::Quaternion(Ogre::Degree(x), Ogre::Vector3::UNIT_X)
+    );
+}
+
 void Entity::SetIndex(const int index){
     assert(model_root_node_);
     index_ = index;

+ 51 - 0
src/core/Entity.h

@@ -299,6 +299,57 @@ class Entity{
          */
         virtual void setScale(const Ogre::Vector3 &scale);
 
+        /**
+         * Sets the entity scale from a script.
+         *
+         * @param[in] x Scale on the X axis.
+         * @param[in] y Scale on the Y axis.
+         * @param[in] z Scale on the Z axis.
+         */
+        void ScriptSetScale(const float x, const float y, const float z);
+
+        /**
+         * Rotates the model of the entity relative to the entity itself, for example to tilt the
+         * mesh of a visual effect. It doesn't change the direction the entity faces.
+         *
+         * @param[in] x Rotation around the X axis of the model, in degrees.
+         * @param[in] y Rotation around the Y axis of the model, in degrees.
+         * @param[in] z Rotation around the Z axis of the model, in degrees.
+         */
+        void ScriptSetModelRotation(const float x, const float y, const float z);
+
+        /**
+         * Blend modes for entities used as visual effects.
+         */
+        enum EffectBlend{
+            /** Normal drawing. */
+            BLEND_NORMAL = 0,
+            /** Colours are added to the ones behind, for fire, light and so on. */
+            BLEND_ADDITIVE = 1,
+            /** Standard transparency. */
+            BLEND_ALPHA = 2,
+        };
+
+        /**
+         * Sets how the entity is blended with the scene. Meant for visual effects.
+         *
+         * @param[in] blend The blend mode, one of {@see EffectBlend}.
+         */
+        virtual void ScriptSetBlend(const int blend){}
+
+        /**
+         * Sets the entity opacity. Needs a blend mode other than normal. For additive blend it
+         * scales the brightness.
+         *
+         * @param[in] alpha Opacity between 0 and 1.
+         */
+        virtual void ScriptSetAlpha(const float alpha){}
+
+        /**
+         * Frees the resources that were created for the entity, before it is removed.
+         */
+        virtual void ReleaseResources(){}
+
         /**
          * Sets the entity index in the field.
          *

+ 34 - 8
src/core/EntityManager.cpp

@@ -259,7 +259,8 @@ void EntityManager::UpdateField(){
                             speed = entity_[i]->GetMoveWalkSpeed();
                             // TODO: Set as a constant.
                             // Or even better, as a configurable variable.
-                            if (player_run_ == true) speed *= 4;
+                            // The run key inverts the default movement.
+                            if (player_run_ != player_default_run_) speed *= 4;
                         }
                         else speed = entity_[i]->GetMoveAutoSpeed();
                         float remaining_time = Timer::getSingleton().GetGameTimeDelta();
@@ -323,6 +324,9 @@ void EntityManager::UpdateField(){
     background_2d_.Update();
     // This can start a battle and change the module, so it's the last thing to do.
     UpdateEncounters(walked);
+    // The movie follows the speed of the game, so the scenes that are timed with it stay in sync.
+    // Its sound doesn't, so at any other speed they drift apart.
+    movie_player_.Update(Timer::getSingleton().GetGameTimeDelta());
     screen_fade_.Update(Timer::getSingleton().GetGameTimeDelta());
 }
 
@@ -441,6 +445,22 @@ void EntityManager::AddBattleEntity(
 
 }
 
+void EntityManager::RemoveBattleEntity(const Ogre::String& name){
+    if (module_ != Module::BATTLE) return;
+    for (auto it = battle_entity_.begin(); it != battle_entity_.end(); ++ it){
+        Entity* entity = *it;
+        if (entity->GetName() != name) continue;
+        ScriptManager::getSingleton().RemoveEntity(ScriptManager::BATTLE, name);
+        battle_entity_.erase(it);
+        // The entity is not deleted, as its destructor would destroy the model again, like in
+        // ClearBattle.
+        Ogre::Root::getSingleton().getSceneManager("Scene")->destroyEntity(name);
+        scene_node_->removeAndDestroyChild("Battle_" + name);
+        entity->ReleaseResources();
+        return;
+    }
+}
+
 Entity* EntityManager::GetBackground3D() const{
     if (IsFieldModule()){
         LOG_WARNING("Tried to retrieve a 3D background while the EntityManager is in field mode.");
@@ -598,6 +618,8 @@ Background2D* EntityManager::GetBackground2D(){return &background_2d_;}
 
 ScreenFade* EntityManager::GetScreenFade(){return &screen_fade_;}
 
+MoviePlayer* EntityManager::GetMoviePlayer(){return &movie_player_;}
+
 void EntityManager::AddEntity(
   const Ogre::String& name, const Ogre::String& file_name,
   const Ogre::Vector3& position, const Ogre::Degree& rotation, int index
@@ -773,6 +795,8 @@ void EntityManager::ScriptPlayerLock(const bool lock){
 
 bool EntityManager::IsPlayerLocked() const{return player_lock_;}
 
+void EntityManager::SetPlayerDefaultRun(const bool run){player_default_run_ = run;}
+
 void EntityManager::SetPlayerMoveRotation(const Ogre::Radian rotation){
     if (IsBattleModule()){
         LOG_WARNING("Tried to set player move rotation the EntityManager is in battle mode.");
@@ -915,14 +939,16 @@ bool EntityManager::IsKeyOn(unsigned int key_code){
     // Some scene where the player can't move but must press a button?
     if (player_lock_) return false;
     // The key code of the scripts is a mask of the pad buttons, and the check is true if any of
-    // them is pressed. Translate each to its key. For example 32 is "Circle/Action", the space bar.
-    // TODO: Get values from config.lua
-    static const struct{unsigned int mask; int key;} KEYS[] = {
-      {4, OIS::KC_PGUP}, {8, OIS::KC_PGDOWN}, {32, OIS::KC_SPACE}, {64, OIS::KC_ESCAPE},
-      {4096, OIS::KC_UP}, {8192, OIS::KC_RIGHT}, {16384, OIS::KC_DOWN}, {32768, OIS::KC_LEFT}
+    // them is pressed. Translate each to its game event (see bind_game_event in config.cfg), so
+    // the keys chosen in the controller settings are the ones that count. For example 32 is
+    // "Circle/Action", the OK button.
+    static const struct{unsigned int mask; const char* event;} BUTTONS[] = {
+      {1, "L2"}, {2, "R2"}, {4, "L1"}, {8, "R1"}, {16, "MENU"}, {32, "CONFIRM"}, {64, "CANCEL"},
+      {128, "EXTRA"}, {256, "SELECT"}, {2048, "START"},
+      {4096, "Up"}, {8192, "Right"}, {16384, "Down"}, {32768, "Left"}
     };
-    for (const auto& entry : KEYS)
-        if ((key_code & entry.mask) && InputManager::getSingleton().IsButtonPressed(entry.key))
+    for (const auto& entry : BUTTONS)
+        if ((key_code & entry.mask) && InputManager::getSingleton().IsGameEventPressed(entry.event))
             return true;
     return false;
 }

+ 39 - 0
src/core/EntityManager.h

@@ -25,6 +25,7 @@
 #include "EntityTrigger.h"
 #include "Event.h"
 #include "Manager.h"
+#include "MoviePlayer.h"
 #include "ScreenFade.h"
 #include "Walkmesh.h"
 
@@ -129,6 +130,16 @@ class EntityManager : public Manager, public Ogre::Singleton<EntityManager>{
          */
         void ClearBattle() override;
 
+        /**
+         * Removes a battle entity, for example one made for a visual effect.
+         *
+         * If the manager is not in the battle module, or the entity doesn't exist, it will do
+         * nothing.
+         *
+         * @param[in] name Entity name.
+         */
+        void RemoveBattleEntity(const Ogre::String& name);
+
         /**
          * Clears all world map information in the entity manager.
          *
@@ -168,6 +179,13 @@ class EntityManager : public Manager, public Ogre::Singleton<EntityManager>{
          */
         ScreenFade* GetScreenFade();
 
+        /**
+         * Retrieves the movie player.
+         *
+         * @return The movie player.
+         */
+        MoviePlayer* GetMoviePlayer();
+
         /**
          * Adds an entity to the manager.
          *
@@ -369,6 +387,17 @@ class EntityManager : public Manager, public Ogre::Singleton<EntityManager>{
          */
         void ScriptPlayerLock(const bool lock);
 
+        /**
+         * Sets if the player moves running by default.
+         *
+         * If true, the player runs unless the run key is held, which makes it walk. If false (the
+         * default), the player walks unless the run key is held. To be called from Lua scripts
+         * (movement setting of the config menu).
+         *
+         * @param[in] run True to run by default, false to walk by default.
+         */
+        void SetPlayerDefaultRun(const bool run);
+
         /**
          * Checks if the player movement is locked.
          *
@@ -770,6 +799,11 @@ class EntityManager : public Manager, public Ogre::Singleton<EntityManager>{
          */
         Background2D background_2d_;
 
+        /**
+         * The movie player. It has to be created before the screen fade, which is drawn over it.
+         */
+        MoviePlayer movie_player_;
+
         /**
          * The coloured fade of the screen. It has to be created after the background, because the
          * fade must be drawn over it.
@@ -827,6 +861,11 @@ class EntityManager : public Manager, public Ogre::Singleton<EntityManager>{
          */
         bool player_run_;
 
+        /**
+         * Indicates if the player runs by default, and the run key makes it walk instead.
+         */
+        bool player_default_run_ = false;
+
         /**
          * List of triggers.
          */

+ 87 - 0
src/core/EntityModel.cpp

@@ -14,7 +14,13 @@
  */
 
 #include <iostream>
+#include <algorithm>
 #include <OgreEntity.h>
+#include <OgreSubEntity.h>
+#include <OgreMaterialManager.h>
+#include <OgreResourceGroupManager.h>
+#include <OgreTechnique.h>
+#include <OgrePass.h>
 #include <OgreRoot.h>
 #include "core/EntityModel.h"
 #include "core/EntityManager.h"
@@ -75,6 +81,87 @@ unsigned int EntityModel::GetAnimationCount() const{
 
 Ogre::Entity* EntityModel::GetModel(){return model_;}
 
+void EntityModel::ScriptSetBlend(const int blend){
+    if (blend == blend_) return;
+    blend_ = blend;
+    for (unsigned int s = 0; s < model_->getNumSubEntities(); ++ s){
+        Ogre::SubEntity* sub_entity = model_->getSubEntity(s);
+        // Materials are shared among all the entities of a mesh, so use one of its own.
+        Ogre::MaterialPtr material = sub_entity->getMaterial();
+        if (std::find(own_materials_.begin(), own_materials_.end(), material->getName()) == own_materials_.end()){
+            const Ogre::String own_name = material->getName() + "/" + name_;
+            Ogre::MaterialPtr own = Ogre::MaterialManager::getSingleton().getByName(own_name, material->getGroup());
+            if (own == nullptr) own = material->clone(own_name);
+            own_materials_.push_back(own_name);
+            sub_entity->setMaterial(own);
+            material = own;
+        }
+        for (unsigned short t = 0; t < material->getNumTechniques(); ++ t){
+            Ogre::Technique* technique = material->getTechnique(t);
+            for (unsigned short p = 0; p < technique->getNumPasses(); ++ p){
+                Ogre::Pass* pass = technique->getPass(p);
+                switch (blend){
+                    case BLEND_ADDITIVE:
+                        pass->setSceneBlending(Ogre::SBT_ADD);
+                        pass->setDepthWriteEnabled(false);
+                        pass->setLightingEnabled(false);
+                        break;
+                    case BLEND_ALPHA:
+                        pass->setSceneBlending(Ogre::SBT_TRANSPARENT_ALPHA);
+                        pass->setDepthWriteEnabled(false);
+                        pass->setLightingEnabled(false);
+                        break;
+                    default:
+                        pass->setSceneBlending(Ogre::SBT_REPLACE);
+                        pass->setDepthWriteEnabled(true);
+                }
+            }
+        }
+    }
+}
+
+void EntityModel::ScriptSetAlpha(const float alpha){
+    if (blend_ == BLEND_NORMAL) return;
+    const float a = std::max(0.0f, std::min(1.0f, alpha));
+    for (unsigned int s = 0; s < model_->getNumSubEntities(); ++ s){
+        Ogre::MaterialPtr material = model_->getSubEntity(s)->getMaterial();
+        for (unsigned short t = 0; t < material->getNumTechniques(); ++ t){
+            Ogre::Technique* technique = material->getTechnique(t);
+            for (unsigned short p = 0; p < technique->getNumPasses(); ++ p){
+                Ogre::Pass* pass = technique->getPass(p);
+                for (unsigned short u = 0; u < pass->getNumTextureUnitStates(); ++ u){
+                    Ogre::TextureUnitState* unit = pass->getTextureUnitState(u);
+                    if (blend_ == BLEND_ADDITIVE){
+                        // The colour is added, so less brightness is less opacity.
+                        unit->setColourOperationEx(
+                          Ogre::LBX_MODULATE, Ogre::LBS_TEXTURE, Ogre::LBS_MANUAL,
+                          Ogre::ColourValue::White, Ogre::ColourValue(a, a, a)
+                        );
+                    }
+                    else{
+                        unit->setAlphaOperation(
+                          Ogre::LBX_MODULATE, Ogre::LBS_TEXTURE, Ogre::LBS_MANUAL, 1.0f, a
+                        );
+                    }
+                }
+            }
+        }
+    }
+}
+
+void EntityModel::ReleaseResources(){
+    // The materials are used by the model, so make it use none before removing them.
+    for (const Ogre::String& name : own_materials_){
+        // The group is not known here (the material has the one of the original), so search all.
+        Ogre::MaterialManager& manager = Ogre::MaterialManager::getSingleton();
+        Ogre::MaterialPtr material = manager.getByName(
+          name, Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME
+        );
+        if (material != nullptr) manager.remove(material);
+    }
+    own_materials_.clear();
+}
+
 void EntityModel::PlayAnimation(
   const Ogre::String& animation, Entity::AnimationState state,
   Entity::AnimationPlayType play_type, const float start, const float end

+ 16 - 0
src/core/EntityModel.h

@@ -66,6 +66,12 @@ class EntityModel : public Entity{
 
         Ogre::Entity* GetModel();
 
+        virtual void ScriptSetBlend(const int blend);
+
+        virtual void ScriptSetAlpha(const float alpha);
+
+        virtual void ReleaseResources();
+
         /**
          * Plays an animation of the model.
          *
@@ -110,5 +116,15 @@ class EntityModel : public Entity{
          * The current animation state.
          */
         Ogre::AnimationState* animation_current_;
+
+        /**
+         * Blend mode of the model, one of {@see Entity::EffectBlend}.
+         */
+        int blend_ = Entity::BLEND_NORMAL;
+
+        /**
+         * Materials created to give this model its own blending, so they are removed with it.
+         */
+        std::vector<Ogre::String> own_materials_;
 };
 

+ 88 - 0
src/core/InputManager.cpp

@@ -13,6 +13,7 @@
  * GNU General Public License for more details.
  */
 
+#include <algorithm>
 #include "core/Console.h"
 #include "core/InputManager.h"
 #include "core/InputManagerCommands.h"
@@ -51,6 +52,18 @@ void InputManager::Reset(){
 }
 
 void InputManager::ButtonPressed(int button, char text, bool down){
+    // A script asked for the next key (to let the player choose one): report it as an event and do
+    // nothing else with it.
+    if (capture_next_key_ == true && down == true){
+        capture_next_key_ = false;
+        VGears::Event capture_event;
+        capture_event.type = VGears::ET_KEY_PRESS;
+        capture_event.param1 = button;
+        capture_event.param2 = text;
+        capture_event.event = "KEY_CAPTURE";
+        event_queue_.push_back(capture_event);
+        return;
+    }
     if (button_state_[button] != down){
         button_state_[button] = down;
         button_text_[button] = text;
@@ -130,6 +143,22 @@ void InputManager::Update(){
 
 bool InputManager::IsButtonPressed(int button) const{return button_state_[button];}
 
+bool InputManager::IsGameEventPressed(const Ogre::String& event) const{
+    if (Console::getSingleton().IsVisible() == true) return false;
+    for (const BindGameEventInfo& bind : bind_game_events_){
+        if (bind.event != event || bind.buttons.empty()) continue;
+        bool all_pressed = true;
+        for (const int button : bind.buttons){
+            if (IsButtonPressed(button) == false){
+                all_pressed = false;
+                break;
+            }
+        }
+        if (all_pressed == true) return true;
+    }
+    return false;
+}
+
 void InputManager::GetInputEvents(InputEventArray& input_events){
     input_events = event_queue_;
     event_queue_.clear();
@@ -152,6 +181,65 @@ void InputManager::BindGameEvent(const Ogre::String& event, const ButtonList& bu
     bind_game_events_.push_back(info);
 }
 
+void InputManager::ScriptSetGameEventKeys(const char* event, const char* keys){
+    const Ogre::String event_name(event);
+    bind_game_events_.erase(
+      std::remove_if(
+        bind_game_events_.begin(), bind_game_events_.end(),
+        [&event_name](const BindGameEventInfo& bind){return bind.event == event_name;}
+      ),
+      bind_game_events_.end()
+    );
+    Ogre::StringVector combinations = Ogre::StringUtil::split(Ogre::String(keys), ",");
+    for (Ogre::String& combination : combinations){
+        Ogre::StringUtil::trim(combination);
+        if (combination.empty()) continue;
+        ButtonList key_codes;
+        if (ParseKeys(combination, key_codes) == false) BindGameEvent(event_name, key_codes);
+    }
+}
+
+std::string InputManager::ScriptGetGameEventKeys(const char* event) const{
+    const Ogre::String event_name(event);
+    std::string keys;
+    for (const BindGameEventInfo& bind : bind_game_events_){
+        if (bind.event != event_name) continue;
+        if (!keys.empty()) keys += ",";
+        for (unsigned int i = 0; i < bind.buttons.size(); ++ i){
+            if (i > 0) keys += "+";
+            keys += KeyToString(static_cast<OIS::KeyCode>(bind.buttons[i]));
+        }
+    }
+    return keys;
+}
+
+void InputManager::ScriptCaptureNextKey(){capture_next_key_ = true;}
+
+void InputManager::SnapshotDefaultGameEvents(){
+    default_game_event_keys_.clear();
+    for (const BindGameEventInfo& bind : bind_game_events_){
+        if (default_game_event_keys_.count(bind.event) == 0){
+            default_game_event_keys_[bind.event] = ScriptGetGameEventKeys(bind.event.c_str());
+        }
+    }
+}
+
+std::string InputManager::ScriptGetDefaultGameEventKeys(const char* event) const{
+    const auto it = default_game_event_keys_.find(Ogre::String(event));
+    return (it == default_game_event_keys_.end()) ? std::string() : it->second;
+}
+
+void InputManager::EnsureDefaultGameEvents(){
+    const struct{const char* event; OIS::KeyCode key;} defaults[] = {
+      {"Up", OIS::KC_UP}, {"Down", OIS::KC_DOWN}, {"Left", OIS::KC_LEFT}, {"Right", OIS::KC_RIGHT}
+    };
+    for (const auto& entry : defaults){
+        if (ScriptGetGameEventKeys(entry.event).empty()){
+            BindGameEvent(entry.event, ButtonList(1, static_cast<int>(entry.key)));
+        }
+    }
+}
+
 void InputManager::ActivateBinds(const int button) {
     std::vector< int > binds_indexes;
     for (unsigned int i = 0; i < binds_.size(); ++ i){

+ 76 - 0
src/core/InputManager.h

@@ -15,6 +15,7 @@
 
 #pragma once
 
+#include <map>
 #include <vector>
 #include <OgreSingleton.h>
 #include <OgreString.h>
@@ -151,6 +152,17 @@ class InputManager : public Manager, public Ogre::Singleton<InputManager>{
          */
         bool IsButtonPressed(int button) const;
 
+        /**
+         * Checks if a game event is active: all the keys of any of its bindings are being pressed.
+         *
+         * Use it, instead of checking keys, to poll the controls of the game, so the keys chosen
+         * in the controller settings are used. It is false while the console is open.
+         *
+         * @param[in] event The game event, for example "CONFIRM" or "walk_left".
+         * @return true if the event is active, false otherwise.
+         */
+        bool IsGameEventPressed(const Ogre::String& event) const;
+
         /**
          * Retrieves the current input events.
          *
@@ -184,6 +196,60 @@ class InputManager : public Manager, public Ogre::Singleton<InputManager>{
          */
         void BindGameEvent(const Ogre::String& event, const ButtonList& buttons);
 
+        /**
+         * Replaces all the keys bound to a game event.
+         *
+         * To be called from Lua scripts (controller setting of the config menu) and from the
+         * console command "set_game_event_keys".
+         *
+         * @param[in] event The game event.
+         * @param[in] keys Key names separated by commas (for example "Enter,Space"). Each one can
+         * be a combination of keys joined with '+' (for example "LCtrl+X"). If empty, the event is
+         * left with no keys.
+         */
+        void ScriptSetGameEventKeys(const char* event, const char* keys);
+
+        /**
+         * Gets the keys bound to a game event.
+         *
+         * @param[in] event The game event.
+         * @return Key names separated by commas, like the ones taken by ScriptSetGameEventKeys.
+         * Empty if the event has no keys.
+         */
+        std::string ScriptGetGameEventKeys(const char* event) const;
+
+        /**
+         * Makes the next key that is pressed be reported to the scripts as the game event
+         * "KEY_CAPTURE" (the name of the key is the third argument of on_button), instead of
+         * doing what it is bound to. To be called from Lua scripts, to let the player choose a
+         * key.
+         */
+        void ScriptCaptureNextKey();
+
+        /**
+         * Binds the directions of the menus (game events "Up", "Down", "Left" and "Right") to the
+         * arrow keys, if the configuration doesn't bind them.
+         */
+        void EnsureDefaultGameEvents();
+
+        /**
+         * Remembers the keys that every game event has now as its default keys.
+         *
+         * To be called once the configuration file was read, and before the keys chosen by the
+         * player are applied. See ScriptGetDefaultGameEventKeys.
+         */
+        void SnapshotDefaultGameEvents();
+
+        /**
+         * Gets the default keys of a game event: the ones it had when SnapshotDefaultGameEvents was
+         * called. To be called from Lua scripts, to restore the default keys.
+         *
+         * @param[in] event The game event.
+         * @return Key names separated by commas, like the ones of ScriptGetGameEventKeys. Empty if
+         * the event had no keys.
+         */
+        std::string ScriptGetDefaultGameEventKeys(const char* event) const;
+
         /**
          * Activates all bindings for a button.
          *
@@ -302,5 +368,15 @@ class InputManager : public Manager, public Ogre::Singleton<InputManager>{
          * List of game event bindings.
          */
         std::vector<BindGameEventInfo> bind_game_events_;
+
+        /**
+         * Indicates if the next key pressed must be captured for the scripts.
+         */
+        bool capture_next_key_ = false;
+
+        /**
+         * Default keys of the game events (see SnapshotDefaultGameEvents), by event.
+         */
+        std::map<Ogre::String, std::string> default_game_event_keys_;
 };
 

+ 20 - 0
src/core/InputManagerCommands.h

@@ -117,8 +117,28 @@ void CmdBindGameEvent(const Ogre::StringVector& params){
     }
 }
 
+/**
+ * Replaces all the keys bound to a game event.
+ *
+ * @param[in] params Parameters. Exactly three are required. The first one is the command name and
+ * it's not evaluated here. The second one is the game event, and the third one the keys separated
+ * by commas (a combination of keys is joined with '+'). Used by the controller settings file.
+ */
+void CmdSetGameEventKeys(const Ogre::StringVector& params){
+    if (params.size() != 3){
+        Console::getSingleton().AddTextToOutput(
+          "Usage: /set_game_event_keys \"<game event>\" \"<key1>[,<key2>...]\""
+        );
+        return;
+    }
+    InputManager::getSingleton().ScriptSetGameEventKeys(params[1].c_str(), params[2].c_str());
+}
+
 // TODO: Move this to InpuManager.cpp?
 void InputManager::InitCmd(){
+    ConfigCmdHandler::getSingleton().AddCommand(
+      "set_game_event_keys", "Replace the keys of a game event", "", CmdSetGameEventKeys, NULL
+    );
     ConfigCmdHandler::getSingleton().AddCommand(
       "bind", "Bind command to keys", "", CmdBind, NULL
     );

+ 65 - 0
src/core/MovieCameraFrame.cpp

@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <algorithm>
+#include <cstring>
+#include <OgreMath.h>
+#include <OgreMatrix3.h>
+#include "core/MovieCameraFrame.h"
+
+namespace{
+
+    // Height of the screen the focal length refers to.
+    const float SCREEN_HEIGHT = 240.0f;
+
+    int16_t ReadS16(const uint8_t* data, const size_t offset){
+        int16_t value;
+        std::memcpy(&value, data + offset, sizeof(value));
+        return value;
+    }
+
+    int32_t ReadS32(const uint8_t* data, const size_t offset){
+        int32_t value;
+        std::memcpy(&value, data + offset, sizeof(value));
+        return value;
+    }
+}
+
+MovieCameraFrame MovieCameraFrame::Parse(const uint8_t* data, const float field_scale){
+    MovieCameraFrame frame;
+    // The projection (the last 8 bytes) can be set even if there isn't a camera.
+    if (std::all_of(data, data + 32, [](uint8_t byte){return byte == 0;})) return frame;
+    // Same steps as the installer takes with the camera of a field.
+    Ogre::Matrix3 matrix;
+    for (int column = 0; column < 3; ++ column)
+        for (int row = 0; row < 3; ++ row)
+            matrix[row][column] = static_cast<float>(ReadS16(data, (column * 3 + row) * 2)) / 4096.0f;
+    Ogre::Vector3 position;
+    for (int i = 0; i < 3; ++ i) position[i] = -static_cast<float>(ReadS32(data, 20 + i * 4));
+    Ogre::Quaternion orientation;
+    orientation.FromRotationMatrix(matrix);
+    position = orientation * (position / 128.0f);
+    for (int row = 0; row < 3; ++ row){
+        matrix[row][1] = -matrix[row][1];
+        matrix[row][2] = -matrix[row][2];
+    }
+    const float focal_length = static_cast<float>(static_cast<uint16_t>(ReadS16(data, 36)));
+    if (focal_length <= 0) return frame;
+    frame.position = position / (field_scale > 0 ? field_scale : 1.0f);
+    frame.orientation = Ogre::Quaternion(matrix);
+    frame.fov = Ogre::Radian(2 * Ogre::Math::ATan(SCREEN_HEIGHT / (2 * focal_length)));
+    frame.valid = true;
+    return frame;
+}

+ 65 - 0
src/core/MovieCameraFrame.h

@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <OgreQuaternion.h>
+#include <OgreMath.h>
+#include <OgreVector3.h>
+
+/**
+ * The camera of a frame of a movie, from the camera file the installer extracts from
+ * moviecam.lgp. It has the same layout as the camera of a field, and the same conversion applies.
+ */
+struct MovieCameraFrame{
+
+    /**
+     * Size in bytes of the data of a frame.
+     */
+    static const size_t SIZE = 40;
+
+    /**
+     * Whether the frame has a camera. The frames without one are all zero, and no model can be
+     * drawn over them.
+     */
+    bool valid = false;
+
+    /**
+     * Position of the camera, in the units of the field.
+     */
+    Ogre::Vector3 position = Ogre::Vector3::ZERO;
+
+    /**
+     * Orientation of the camera.
+     */
+    Ogre::Quaternion orientation = Ogre::Quaternion::IDENTITY;
+
+    /**
+     * Vertical field of view.
+     */
+    Ogre::Radian fov;
+
+    /**
+     * Reads the camera of a frame.
+     *
+     * @param[in] data The SIZE bytes of the frame.
+     * @param[in] field_scale Scale factor of the field. The camera is in the units of the
+     * original game and the field divides them by it.
+     * @return The camera. If there isn't one, it's not valid.
+     */
+    static MovieCameraFrame Parse(const uint8_t* data, const float field_scale);
+};

+ 316 - 0
src/core/MoviePlayer.cpp

@@ -0,0 +1,316 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <cstdio>
+#include <fstream>
+#include <iterator>
+#include <vector>
+#include <tinyxml.h>
+#include <OgreHardwareBufferManager.h>
+#include <OgreHardwarePixelBuffer.h>
+#include <OgreImage.h>
+#include <OgreMaterialManager.h>
+#include <OgreRoot.h>
+#include <OgreTechnique.h>
+#include <OgreTextureManager.h>
+#include "core/AudioManager.h"
+#include "core/CameraManager.h"
+#include "core/EntityManager.h"
+#include "core/Logger.h"
+#include "core/MoviePlayer.h"
+
+namespace{
+
+    // Size of the frames of the original movies.
+    const int FRAME_WIDTH = 320;
+    const int FRAME_HEIGHT = 224;
+
+    // The texture is the next power of two of the frame.
+    const int TEXTURE_WIDTH = 512;
+    const int TEXTURE_HEIGHT = 256;
+
+    // The field is drawn as 320x240. The movie is centered, with bars above and below.
+    const float FIELD_HEIGHT = 240.0f;
+
+    const unsigned int QUAD_VERTEX_COUNT = 6;
+
+    // Position (3), colour (4) and texture coordinates (2).
+    const unsigned int QUAD_FLOATS_PER_VERTEX = 9;
+
+    const char* const RESOURCE_GROUP = "FIELDS";
+
+    std::string MovieDirectory(const std::string& name){return "./data/video/" + name + "/";}
+
+    std::string FramePath(const std::string& name, const int frame){
+        char number[16];
+        snprintf(number, sizeof(number), "%05d", frame);
+        return MovieDirectory(name) + number + ".jpg";
+    }
+}
+
+MoviePlayer::MoviePlayer(){
+    scene_manager_ = Ogre::Root::getSingleton().getSceneManager("Scene");
+    render_system_ = Ogre::Root::getSingletonPtr()->getRenderSystem();
+
+    texture_ = Ogre::TextureManager::getSingleton().createManual(
+      "MovieFrame", RESOURCE_GROUP, Ogre::TEX_TYPE_2D, TEXTURE_WIDTH, TEXTURE_HEIGHT, 0,
+      Ogre::PF_BYTE_RGBA, Ogre::TU_DYNAMIC_WRITE_ONLY_DISCARDABLE
+    );
+    material_ = Ogre::MaterialManager::getSingleton().create("MovieFrame", RESOURCE_GROUP);
+    Ogre::Pass* pass = material_->getTechnique(0)->getPass(0);
+    pass->setCullingMode(Ogre::CULL_NONE);
+    pass->setDepthCheckEnabled(false);
+    pass->setDepthWriteEnabled(false);
+    pass->setLightingEnabled(false);
+    Ogre::TextureUnitState* unit = pass->createTextureUnitState();
+    unit->setTexture(texture_);
+    unit->setTextureAddressingMode(Ogre::TextureUnitState::TAM_CLAMP);
+    unit->setTextureFiltering(Ogre::FO_LINEAR, Ogre::FO_LINEAR, Ogre::FO_NONE);
+
+    render_op_.vertexData = new Ogre::VertexData;
+    render_op_.vertexData->vertexStart = 0;
+    render_op_.vertexData->vertexCount = QUAD_VERTEX_COUNT;
+    Ogre::VertexDeclaration* declaration = render_op_.vertexData->vertexDeclaration;
+    size_t offset = 0;
+    declaration->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION);
+    offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3);
+    declaration->addElement(0, offset, Ogre::VET_FLOAT4, Ogre::VES_DIFFUSE);
+    offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT4);
+    declaration->addElement(0, offset, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES);
+    vertex_buffer_ = Ogre::HardwareBufferManager::getSingletonPtr()->createVertexBuffer(
+      declaration->getVertexSize(0), QUAD_VERTEX_COUNT, Ogre::HardwareBuffer::HBU_DYNAMIC_WRITE_ONLY,
+      false
+    );
+    render_op_.vertexData->vertexBufferBinding->setBinding(0, vertex_buffer_);
+    render_op_.operationType = Ogre::RenderOperation::OT_TRIANGLE_LIST;
+    render_op_.useIndexes = false;
+    WriteQuad();
+
+    scene_manager_->addRenderQueueListener(this);
+}
+
+MoviePlayer::~MoviePlayer(){
+    scene_manager_->removeRenderQueueListener(this);
+    delete render_op_.vertexData;
+}
+
+bool MoviePlayer::ReadInfo(
+  int& frame_count, float& frames_per_second, int& camera_frame, int& release_frame
+) const{
+    TiXmlDocument document(MovieDirectory(name_) + "movie.xml");
+    if (!document.LoadFile()) return false;
+    const TiXmlElement* root = document.RootElement();
+    if (root == nullptr) return false;
+    double fps = 15;
+    camera_frame = -1;
+    root->QueryIntAttribute("frames", &frame_count);
+    root->QueryIntAttribute("camera_frame", &camera_frame);
+    // The frame at which the script that plays the movie goes on. By default, the end of the movie,
+    // which is what looks right in the opening.
+    release_frame = -1;
+    root->QueryIntAttribute("release_frame", &release_frame);
+    root->QueryDoubleAttribute("fps", &fps);
+    frames_per_second = static_cast<float>(fps);
+    return frame_count > 0;
+}
+
+void MoviePlayer::ScriptSet(const char* name, const float field_scale){
+    name_ = name;
+    field_scale_ = field_scale;
+    // A movie that is playing is not interrupted by choosing the next one.
+    if (!state_.IsPlaying()) state_.Prepare();
+    LOG_TRIVIAL("Movie '" + name_ + "' chosen.");
+}
+
+void MoviePlayer::ReleaseWaiting(){
+    for (const ScriptId& script : waiting_) ScriptManager::getSingleton().ContinueScriptExecution(script);
+    waiting_.clear();
+}
+
+int MoviePlayer::ScriptPlay(){
+    int frames = 0;
+    float fps = 15;
+    loaded_frame_ = -1;
+    int camera_frame = -1;
+    int release_frame = -1;
+    if (name_.empty() || !ReadInfo(frames, fps, camera_frame, release_frame)){
+        LOG_WARNING("Movie '" + name_ + "' is not installed, so it can't be played.");
+        state_.Start(0, fps);
+        return 1;
+    }
+    LOG_TRIVIAL("Movie '" + name_ + "' starts, " + std::to_string(frames) + " frames.");
+    state_.Start(frames, fps);
+    // The scenes of the fields that play over a movie are timed to start when the models can be
+    // drawn with the camera of the movie, so the script that plays it goes on from there.
+    release_frame_ = release_frame;
+    camera_data_.clear();
+    camera_valid_ = false;
+    std::ifstream camera_file(MovieDirectory(name_) + "camera.cam", std::ios::binary);
+    if (camera_file){
+        camera_data_.assign(
+          (std::istreambuf_iterator<char>(camera_file)), std::istreambuf_iterator<char>()
+        );
+    }
+    AudioManager::getSingleton().MoviePlay(MovieDirectory(name_) + name_ + ".ogg");
+    waiting_.push_back(ScriptManager::getSingleton().GetCurrentScriptId());
+    return -1;
+}
+
+bool MoviePlayer::IsDrawnBehind() const{
+    return state_.IsPlaying() && !camera_data_.empty();
+}
+
+void MoviePlayer::SetModelsHidden(const bool hidden){
+    if (hidden == models_hidden_) return;
+    models_hidden_ = hidden;
+    CameraManager::getSingleton().getViewport()->setVisibilityMask(hidden ? 0 : 0xFFFFFFFF);
+}
+
+void MoviePlayer::UpdateCamera(){
+    if (camera_data_.empty()) return;
+    if (!state_.IsPlaying()){
+        camera_valid_ = false;
+        SetModelsHidden(false);
+        if (camera_in_use_){
+            camera_in_use_ = false;
+            CameraManager::getSingleton().EndMovieCamera();
+        }
+        return;
+    }
+    const size_t offset = static_cast<size_t>(state_.GetFrame()) * MovieCameraFrame::SIZE;
+    camera_valid_ = false;
+    if (offset + MovieCameraFrame::SIZE <= camera_data_.size()){
+        const MovieCameraFrame frame = MovieCameraFrame::Parse(
+          camera_data_.data() + offset, field_scale_
+        );
+        camera_valid_ = frame.valid;
+        if (frame.valid){
+            camera_in_use_ = true;
+            CameraManager::getSingleton().SetMovieCamera(frame.position, frame.orientation, frame.fov);
+        }
+    }
+    SetModelsHidden(!camera_valid_);
+}
+
+void MoviePlayer::ScriptStop(){
+    state_.Stop();
+    UpdateCamera();
+    AudioManager::getSingleton().MovieStop();
+    ReleaseWaiting();
+}
+
+int MoviePlayer::ScriptGetFrame() const{return state_.GetFrame();}
+
+bool MoviePlayer::ScriptIsPlaying() const{return state_.IsPlaying();}
+
+void MoviePlayer::Update(const float delta){
+    const bool ended = state_.Advance(delta);
+    if (state_.IsPlaying() && release_frame_ >= 0 && state_.GetFrame() >= release_frame_){
+        LOG_TRIVIAL("Movie '" + name_ + "' reached frame " + std::to_string(state_.GetFrame()) + ".");
+        release_frame_ = -1;
+        ReleaseWaiting();
+    }
+    UpdateCamera();
+    if (ended){
+        LOG_TRIVIAL("Movie '" + name_ + "' ended.");
+        AudioManager::getSingleton().MovieStop();
+        ReleaseWaiting();
+    }
+}
+
+void MoviePlayer::LoadFrame(const int frame){
+    loaded_frame_ = frame;
+    std::ifstream file(FramePath(name_, frame), std::ios::binary);
+    if (!file) return;
+    std::vector<unsigned char> bytes(
+      (std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>()
+    );
+    Ogre::DataStreamPtr stream(new Ogre::MemoryDataStream(bytes.data(), bytes.size(), false));
+    Ogre::Image image;
+    try{
+        image.load(stream, "jpg");
+    }
+    catch (const Ogre::Exception& e){
+        LOG_WARNING("Frame " + std::to_string(frame) + " of the movie can't be read: " + e.what());
+        return;
+    }
+    const Ogre::Box area(0, 0, image.getWidth(), image.getHeight());
+    texture_->getBuffer()->blitFromMemory(image.getPixelBox(), area);
+}
+
+void MoviePlayer::WriteQuad(){
+    const float half_height = FRAME_HEIGHT / FIELD_HEIGHT;
+    const float u = static_cast<float>(FRAME_WIDTH) / TEXTURE_WIDTH;
+    const float v = static_cast<float>(FRAME_HEIGHT) / TEXTURE_HEIGHT;
+    const float corners[QUAD_VERTEX_COUNT][4] = {
+      {-1, half_height, 0, 0}, {1, half_height, u, 0}, {1, -half_height, u, v},
+      {-1, half_height, 0, 0}, {1, -half_height, u, v}, {-1, -half_height, 0, v}
+    };
+    float* write_iterator
+      = static_cast<float*>(vertex_buffer_->lock(Ogre::HardwareBuffer::HBL_DISCARD));
+    for (unsigned int i = 0; i < QUAD_VERTEX_COUNT; ++ i){
+        *write_iterator ++ = corners[i][0];
+        *write_iterator ++ = corners[i][1];
+        *write_iterator ++ = 0;
+        *write_iterator ++ = 1;
+        *write_iterator ++ = 1;
+        *write_iterator ++ = 1;
+        *write_iterator ++ = 1;
+        *write_iterator ++ = corners[i][2];
+        *write_iterator ++ = corners[i][3];
+    }
+    vertex_buffer_->unlock();
+}
+
+void MoviePlayer::DrawQuad(){
+    const int frame = state_.GetFrame();
+    if (frame != loaded_frame_) LoadFrame(frame);
+
+    // Same setup the background and the fades use: the quad is given in screen coordinates.
+    Ogre::GpuProgramParametersPtr rs_params = render_system_->getFixedFunctionParams(
+      Ogre::TVC_NONE, Ogre::FOG_NONE
+    );
+    rs_params->setConstant(Ogre::GpuProgramParameters::ACT_WORLD_MATRIX, Ogre::Matrix4::IDENTITY);
+    rs_params->setConstant(
+      Ogre::GpuProgramParameters::ACT_PROJECTION_MATRIX, Ogre::Matrix4::IDENTITY
+    );
+    render_system_->_setViewMatrix(Ogre::Matrix4::IDENTITY);
+    rs_params->setConstant(Ogre::GpuProgramParameters::ACT_VIEW_MATRIX, Ogre::Matrix4::IDENTITY);
+    render_system_->applyFixedFunctionParams(rs_params, Ogre::GPV_GLOBAL);
+    scene_manager_->_setPass(material_->getTechnique(0)->getPass(0), true, false);
+    render_system_->_render(render_op_);
+}
+
+void MoviePlayer::renderQueueStarted(
+  Ogre::uint8 queue_group_id, const Ogre::String& invocation, bool& skip_this_invocation
+){
+    if (!state_.IsPlaying()) return;
+    if (!EntityManager::getSingleton().IsFieldModule()) return;
+    if (!camera_data_.empty()){
+        // Behind the models, which come in the main queue. They are only drawn if the movie has a
+        // camera in the frame, or they would be in the wrong place. They are hidden with the
+        // visibility mask of the viewport: skipping the queue would also skip the listeners that
+        // are called when it ends, and the debug lines would pile up.
+        if (queue_group_id != Ogre::RENDER_QUEUE_MAIN) return;
+        DrawQuad();
+        // The queue sets its own matrices, but not before it starts.
+        Ogre::Camera* camera = CameraManager::getSingleton().GetCurrentCamera();
+        render_system_->_setProjectionMatrix(camera->getProjectionMatrixRS());
+        render_system_->_setViewMatrix(camera->getViewMatrix(true));
+        return;
+    }
+    if (queue_group_id != Ogre::RENDER_QUEUE_OVERLAY) return;
+    DrawQuad();
+}

+ 240 - 0
src/core/MoviePlayer.h

@@ -0,0 +1,240 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+#include <OgreHardwareVertexBuffer.h>
+#include <OgreMaterial.h>
+#include <OgreRenderOperation.h>
+#include <OgreRenderQueueListener.h>
+#include <OgreRenderSystem.h>
+#include <OgreSceneManager.h>
+#include <OgreTexture.h>
+#include "MovieCameraFrame.h"
+#include "MovieState.h"
+#include "ScriptManager.h"
+
+/**
+ * Plays the movies that the installer converted: a folder of JPEG frames and an ogg file with the
+ * sound, in data/video/NAME/.
+ *
+ * The movie is drawn over the field and below the interface and the screen fades.
+ */
+class MoviePlayer : public Ogre::RenderQueueListener{
+
+    public:
+
+        /**
+         * Constructor.
+         */
+        MoviePlayer();
+
+        /**
+         * Destructor.
+         */
+        virtual ~MoviePlayer();
+
+        /**
+         * Run each frame. Moves the movie forward.
+         *
+         * @param[in] delta Seconds since the last frame.
+         */
+        void Update(const float delta);
+
+        /**
+         * Chooses the movie to play next, like the PMVIE opcode.
+         *
+         * @param[in] name Name of the movie, without extension.
+         * @param[in] field_scale Scale factor of the field, to place the models over the movie.
+         */
+        void ScriptSet(const char* name, const float field_scale);
+
+        /**
+         * Starts the chosen movie, like the MOVIE opcode. The script that starts it waits until
+         * the movie ends. The other scripts go on while it's played.
+         *
+         * @return 1 if the script doesn't have to wait, and -1 if it does.
+         */
+        int ScriptPlay();
+
+        /**
+         * Stops the movie.
+         */
+        void ScriptStop();
+
+        /**
+         * Retrieves the frame being shown, like the MVIEF opcode.
+         *
+         * @return The frame. If the chosen movie isn't installed, a number high enough to be
+         * after any frame the scripts wait for.
+         */
+        int ScriptGetFrame() const;
+
+        /**
+         * @return True while a movie is being played.
+         */
+        bool ScriptIsPlaying() const;
+
+        /**
+         * Checks if the movie is drawn behind the field, which is drawn over it with the camera of
+         * the movie. If not, the movie covers the field.
+         *
+         * @return True if a movie with a camera is being played.
+         */
+        bool IsDrawnBehind() const;
+
+        /**
+         * Draws the movie. If it has a camera it's drawn behind the models, and the models are
+         * only drawn in the frames that have a camera. If not, it's drawn over the field and below
+         * the interface.
+         */
+        void renderQueueStarted(
+          Ogre::uint8 queue_group_id, const Ogre::String& invocation, bool& skip_this_invocation
+        ) override;
+
+    private:
+
+        /**
+         * Reads the information of the chosen movie.
+         *
+         * @param[out] frame_count Number of frames.
+         * @param[out] frames_per_second Frames per second.
+         * @param[out] camera_frame First frame with a camera for the models, or -1.
+         * @param[out] release_frame Frame at which the script that plays the movie goes on, or -1
+         * for the end.
+         * @return True if the movie is installed.
+         */
+        bool ReadInfo(
+          int& frame_count, float& frames_per_second, int& camera_frame, int& release_frame
+        ) const;
+
+        /**
+         * Content of the camera file of the movie. Empty if it hasn't got one.
+         */
+        std::vector<uint8_t> camera_data_;
+
+        /**
+         * Scale factor of the field.
+         */
+        float field_scale_ = 1.0f;
+
+        /**
+         * Whether the current frame of the movie has a camera.
+         */
+        bool camera_valid_ = false;
+
+        /**
+         * Whether the movie camera is in use.
+         */
+        bool camera_in_use_ = false;
+
+        /**
+         * Frame at which the script that played the movie goes on, or -1 for the end of the movie.
+         */
+        int release_frame_ = -1;
+
+        /**
+         * Places the camera of the field in the one of the current frame of the movie, or puts it
+         * back if the movie has ended.
+         */
+        void UpdateCamera();
+
+        /**
+         * Hides or shows everything the viewport draws as an object of the scene, the models.
+         *
+         * @param[in] hidden True to hide them.
+         */
+        void SetModelsHidden(const bool hidden);
+
+        /**
+         * Whether the models are hidden because the frame has no camera.
+         */
+        bool models_hidden_ = false;
+
+        /**
+         * Draws the quad with the current frame.
+         */
+        void DrawQuad();
+
+        /**
+         * Loads a frame in the texture.
+         *
+         * @param[in] frame The frame to load.
+         */
+        void LoadFrame(const int frame);
+
+        /**
+         * Writes the corners of the screen quad.
+         */
+        void WriteQuad();
+
+        /**
+         * The scripts that wait for the end of the movie.
+         */
+        std::vector<ScriptId> waiting_;
+
+        /**
+         * Resumes the scripts that wait for the end of the movie.
+         */
+        void ReleaseWaiting();
+
+        /**
+         * The clock of the movie.
+         */
+        MovieState state_;
+
+        /**
+         * Name of the chosen movie.
+         */
+        std::string name_;
+
+        /**
+         * Frame in the texture, or -1.
+         */
+        int loaded_frame_ = -1;
+
+        /**
+         * Scene manager.
+         */
+        Ogre::SceneManager* scene_manager_;
+
+        /**
+         * Render system.
+         */
+        Ogre::RenderSystem* render_system_;
+
+        /**
+         * Texture the frames are copied to.
+         */
+        Ogre::TexturePtr texture_;
+
+        /**
+         * Material of the quad.
+         */
+        Ogre::MaterialPtr material_;
+
+        /**
+         * Render operation of the quad.
+         */
+        Ogre::RenderOperation render_op_;
+
+        /**
+         * Vertex buffer of the quad.
+         */
+        Ogre::HardwareVertexBufferSharedPtr vertex_buffer_;
+};

+ 55 - 0
src/core/MovieState.cpp

@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <algorithm>
+#include <cmath>
+#include "core/MovieState.h"
+
+// Defined here too, as the tests and the scripts take it by reference.
+const int MovieState::NO_MOVIE_FRAME;
+
+void MovieState::Start(const int frame_count, const float frames_per_second){
+    frame_count_ = std::max(frame_count, 0);
+    // A movie without frames is one that isn't there.
+    started_ = frame_count_ > 0;
+    frames_per_second_ = frames_per_second > 0 ? frames_per_second : 15.0f;
+    elapsed_ = 0;
+    playing_ = frame_count_ > 0;
+}
+
+void MovieState::Prepare(){
+    started_ = true;
+    playing_ = false;
+    frame_count_ = 0;
+    elapsed_ = 0;
+}
+
+void MovieState::Stop(){playing_ = false;}
+
+bool MovieState::Advance(const float seconds){
+    if (!playing_) return false;
+    elapsed_ += seconds;
+    if (GetFrame() < frame_count_) return false;
+    playing_ = false;
+    return true;
+}
+
+bool MovieState::IsPlaying() const{return playing_;}
+
+int MovieState::GetFrame() const{
+    if (!started_) return NO_MOVIE_FRAME;
+    const int frame = static_cast<int>(std::floor(elapsed_ * frames_per_second_));
+    return std::min(frame, frame_count_);
+}

+ 100 - 0
src/core/MovieState.h

@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+/**
+ * The clock of a movie: which frame is shown at each moment, and when it ends.
+ *
+ * It has no knowledge of the video or the screen. It's separated from {@see MoviePlayer} so its
+ * rules can be tested.
+ */
+class MovieState{
+
+    public:
+
+        /**
+         * Frame reported when there is no movie to play. It's high so the scripts that wait for a
+         * frame of the movie to continue don't wait for ever.
+         */
+        static const int NO_MOVIE_FRAME = 32767;
+
+        /**
+         * Starts the movie from its first frame.
+         *
+         * @param[in] frame_count Number of frames of the movie.
+         * @param[in] frames_per_second Frames shown per second.
+         */
+        void Start(const int frame_count, const float frames_per_second);
+
+        /**
+         * Chooses a movie without playing it. Until it starts the frame is 0, which is what the
+         * scripts that wait for a frame of the movie expect before it starts.
+         */
+        void Prepare();
+
+        /**
+         * Stops the movie. The frame it reaches is kept.
+         */
+        void Stop();
+
+        /**
+         * Moves the clock forward.
+         *
+         * @param[in] seconds Seconds since the last update.
+         * @return True if the movie ended in this update.
+         */
+        bool Advance(const float seconds);
+
+        /**
+         * @return True while the movie is being played.
+         */
+        bool IsPlaying() const;
+
+        /**
+         * Retrieves the frame that must be shown.
+         *
+         * @return The frame, starting at 0. When the movie ended, the number of frames. If there
+         * was no movie, {@see NO_MOVIE_FRAME}.
+         */
+        int GetFrame() const;
+
+    private:
+
+        /**
+         * Whether the movie is being played.
+         */
+        bool playing_ = false;
+
+        /**
+         * Whether a movie has been started.
+         */
+        bool started_ = false;
+
+        /**
+         * Number of frames of the movie.
+         */
+        int frame_count_ = 0;
+
+        /**
+         * Frames per second.
+         */
+        float frames_per_second_ = 15.0f;
+
+        /**
+         * Seconds played.
+         */
+        float elapsed_ = 0;
+};

+ 85 - 48
src/core/Savemap.cpp

@@ -13,6 +13,7 @@
  * GNU General Public License for more details.
  */
 
+#include <boost/filesystem.hpp>
 #include <string>
 #include <iostream>
 #include <algorithm>
@@ -139,11 +140,12 @@ void Savemap::operator = (const Savemap &to_copy){
     control_ = to_copy.control_;
     for (int i = 0; i < 4; i ++){
         window_colours_[i][0] = to_copy.window_colours_[i][0];
-        window_colours_[i][1] = to_copy.window_colours_[i][0];
-        window_colours_[i][2] = to_copy.window_colours_[i][0];
+        window_colours_[i][1] = to_copy.window_colours_[i][1];
+        window_colours_[i][2] = to_copy.window_colours_[i][2];
     }
     for (int i = 0; i < 11; i ++) characters_[i] = to_copy.characters_[i];
     for (int i = 0; i < 3; i ++) party_[i] = to_copy.party_[i];
+    settings_ = to_copy.settings_;
 }
 
 void Savemap::SetData(const unsigned int bank, const unsigned int address, const int value){
@@ -253,8 +255,13 @@ void Savemap::SetLocation(
     location_.name = name;
 }
 
-void Savemap::SetSetting(const unsigned int key, const unsigned int value){
-    // TODO : Implements when settings are working.
+void Savemap::SetSetting(const std::string& key, const int value){settings_[key] = value;}
+
+bool Savemap::HasSetting(const std::string& key) const{return settings_.count(key) > 0;}
+
+int Savemap::GetSetting(const std::string& key) const{
+    const auto it = settings_.find(key);
+    return (it == settings_.end()) ? 0 : it->second;
 }
 
 void Savemap::SetCharacterInfo(
@@ -339,14 +346,14 @@ void Savemap::SetCharacterMateria(
 ){
     if (id < MAX_CHARACTERS && slot < MAX_EQUIP_SLOTS){
         if (weapon){
-            characters_[id].weapon.materia[slot].id = id;
+            characters_[id].weapon.materia[slot].id = materia;
             characters_[id].weapon.materia[slot].ap = ap;
             characters_[id].weapon.materia[slot].enemy_skill = false;
             for (int i = 0; i < MAX_ENEMY_SKILLS; i ++)
                 characters_[id].weapon.materia[slot].enemy_skill_learned[i] = false;
         }
         else{
-            characters_[id].armor.materia[slot].id = id;
+            characters_[id].armor.materia[slot].id = materia;
             characters_[id].armor.materia[slot].ap = ap;
             characters_[id].armor.materia[slot].enemy_skill = false;
             for (int i = 0; i < MAX_ENEMY_SKILLS; i ++)
@@ -485,11 +492,6 @@ std::string Savemap::GetLocationField(){return location_.field;}
 
 std::string Savemap::GetLocationName(){return location_.name;}
 
-int Savemap::GetSetting(const unsigned int key){
-    // TODO: Implement.
-    return 0;
-}
-
 int Savemap::GetCharacterCharId(const unsigned int id){
     if (id >= MAX_CHARACTERS) return -1;
     else return characters_[id].char_id;
@@ -790,8 +792,8 @@ void Savemap::Write(int slot, std::string file_name){
                         enemy_skill->SetAttribute("learned", true);
                         enemy_skills->LinkEndChild(enemy_skill.release());
                     }
-                    materia->LinkEndChild(enemy_skills.release());
                 }
+                materia->LinkEndChild(enemy_skills.release());
             }
             materias->LinkEndChild(materia.release());
         }
@@ -804,8 +806,8 @@ void Savemap::Write(int slot, std::string file_name){
         if (materia_stash_[i].id >= 0){
             std::unique_ptr<TiXmlElement> materia(new TiXmlElement("Materia"));
             materia->SetAttribute("slot", i);
-            materia->SetAttribute("id", materia_[i].id);
-            materia->SetAttribute("ap", materia_[i].ap);
+            materia->SetAttribute("id", materia_stash_[i].id);
+            materia->SetAttribute("ap", materia_stash_[i].ap);
             if (materia_stash_[i].enemy_skill){
                 materia->SetAttribute("enemy_skill", true);
                 std::unique_ptr<TiXmlElement> enemy_skills(new TiXmlElement("EnemySkill"));
@@ -816,15 +818,25 @@ void Savemap::Write(int slot, std::string file_name){
                         enemy_skill->SetAttribute("learned", true);
                         enemy_skills->LinkEndChild(enemy_skill.release());
                     }
-                    materia->LinkEndChild(enemy_skills.release());
                 }
+                materia->LinkEndChild(enemy_skills.release());
             }
             stash->LinkEndChild(materia.release());
         }
     }
     container->LinkEndChild(stash.release());
 
-    // TODO: Settings
+    // Settings (config menu). The window colours are saved apart.
+    if (!settings_.empty()){
+        std::unique_ptr<TiXmlElement> settings(new TiXmlElement("Settings"));
+        for (const auto& setting : settings_){
+            std::unique_ptr<TiXmlElement> element(new TiXmlElement("Setting"));
+            element->SetAttribute("name", setting.first);
+            element->SetAttribute("value", setting.second);
+            settings->LinkEndChild(element.release());
+        }
+        container->LinkEndChild(settings.release());
+    }
 
     // Characters
     std::unique_ptr<TiXmlElement> characters(new TiXmlElement("Characters"));
@@ -935,8 +947,8 @@ void Savemap::Write(int slot, std::string file_name){
                             enemy_skill->SetAttribute("learned", true);
                             character_weapon_enemy_skills->LinkEndChild(enemy_skill.release());
                         }
-                        materia->LinkEndChild(character_weapon_enemy_skills.release());
                     }
+                    materia->LinkEndChild(character_weapon_enemy_skills.release());
                 }
                 character_weapon_materia->LinkEndChild(materia.release());
             }
@@ -966,8 +978,8 @@ void Savemap::Write(int slot, std::string file_name){
                             enemy_skill->SetAttribute("learned", true);
                             character_armor_enemy_skills->LinkEndChild(enemy_skill.release());
                         }
-                        materia->LinkEndChild(character_armor_enemy_skills.release());
                     }
+                    materia->LinkEndChild(character_armor_enemy_skills.release());
                 }
                 character_armor_materia->LinkEndChild(materia.release());
             }
@@ -1006,7 +1018,14 @@ void Savemap::Write(int slot, std::string file_name){
 
     // Save
     xml.LinkEndChild(container.release());
-    xml.SaveFile(file_name);
+    // The folder of the saved games may not exist yet (a fresh installation): create it, or the
+    // game would not be saved.
+    const boost::filesystem::path path(file_name);
+    if (path.has_parent_path()){
+        boost::system::error_code error;
+        boost::filesystem::create_directories(path.parent_path(), error);
+    }
+    if (!xml.SaveFile(file_name)) LOG_ERROR("Can't write the saved game \"" + file_name + "\".");
     slot_ = slot;
     return;
 
@@ -1052,51 +1071,69 @@ void Savemap::Read(std::string file_name){
                     if (position == "top_left"){
                         colour = -1;
                         colour_elm->QueryIntAttribute("red", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[0][0] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[0][0] = colour;
                         colour = -1;
                         colour_elm->QueryIntAttribute("green", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[0][1] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[0][1] = colour;
                         colour = -1;
                         colour_elm->QueryIntAttribute("blue", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[0][2] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[0][2] = colour;
                     }
                     else if (position == "top_right"){
                         colour = -1;
                         colour_elm->QueryIntAttribute("red", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[1][0] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[1][0] = colour;
                         colour = -1;
                         colour_elm->QueryIntAttribute("green", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[1][1] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[1][1] = colour;
                         colour = -1;
                         colour_elm->QueryIntAttribute("blue", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[1][2] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[1][2] = colour;
                     }
                     else if (position == "bottom_right"){
                         colour = -1;
                         colour_elm->QueryIntAttribute("red", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[2][0] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[2][0] = colour;
                         colour = -1;
                         colour_elm->QueryIntAttribute("green", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[2][1] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[2][1] = colour;
                         colour = -1;
                         colour_elm->QueryIntAttribute("blue", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[2][2] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[2][2] = colour;
                     }
-                    else if (position == "bottol_left"){
+                    else if (position == "bottom_left"){
                         colour = -1;
                         colour_elm->QueryIntAttribute("red", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[3][0] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[3][0] = colour;
                         colour = -1;
                         colour_elm->QueryIntAttribute("green", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[3][1] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[3][1] = colour;
                         colour = -1;
                         colour_elm->QueryIntAttribute("blue", &colour);
-                        if (colour >= 0 && colour < MAX_COLOUR) window_colours_[3][2] = colour;
+                        if (colour >= 0 && colour <= MAX_COLOUR) window_colours_[3][2] = colour;
                     }
                 }
                 colour_node = colour_node->NextSibling();
             }
         }
+        else if (node->Type() == TiXmlNode::TINYXML_ELEMENT && node->ValueStr() == "Settings"){
+            TiXmlNode* setting_node = node->FirstChild();
+            while (setting_node != nullptr){
+                if (
+                  setting_node->Type() == TiXmlNode::TINYXML_ELEMENT
+                  && setting_node->ValueStr() == "Setting"
+                ){
+                    std::string name;
+                    int value = 0;
+                    TiXmlElement* setting_elm = setting_node->ToElement();
+                    if (
+                      setting_elm->QueryStringAttribute("name", &name) == TIXML_SUCCESS
+                      && setting_elm->QueryIntAttribute("value", &value) == TIXML_SUCCESS
+                    ) settings_[name] = value;
+                }
+                setting_node = setting_node->NextSibling();
+            }
+        }
         else if (node->Type() == TiXmlNode::TINYXML_ELEMENT && node->ValueStr() == "Money"){
             int money = -1;
             node->ToElement()->QueryIntAttribute("quantity", &money);
@@ -1201,10 +1238,10 @@ void Savemap::Read(std::string file_name){
                     materia_elm->QueryIntAttribute("slot", &slot);
                     materia_elm->QueryIntAttribute("id", &id);
                     materia_elm->QueryIntAttribute("ap", &ap);
-                    materia_elm->QueryIntAttribute("enemy_skill", &ap);
+                    materia_elm->QueryIntAttribute("enemy_skill", &eskill);
                     if (
                       slot >= 0 && slot < MAX_MATERIA_SLOTS && id >= -1
-                      && ap >= 0 && (eskill == 0 | eskill == 1)
+                      && ap >= 0 && (eskill == 0 || eskill == 1)
                     ){
                         materia_[slot].id = id;
                         materia_[slot].ap = ap;
@@ -1230,10 +1267,10 @@ void Savemap::Read(std::string file_name){
                     materia_elm->QueryIntAttribute("slot", &slot);
                     materia_elm->QueryIntAttribute("id", &id);
                     materia_elm->QueryIntAttribute("ap", &ap);
-                    materia_elm->QueryIntAttribute("enemy_skill", &ap);
+                    materia_elm->QueryIntAttribute("enemy_skill", &eskill);
                     if (
                       slot >= 0 && slot < MAX_STASH_SLOTS && id >= -1
-                      && ap >= 0 && (eskill == 0 | eskill == 1)
+                      && ap >= 0 && (eskill == 0 || eskill == 1)
                     ){
                         materia_stash_[slot].id = id;
                         materia_stash_[slot].ap = ap;
@@ -1441,19 +1478,19 @@ void Savemap::Read(std::string file_name){
                                           && mat_node->ValueStr() == "Materia"
                                         ){
                                             int slot = -1;
-                                            int id = -2;
+                                            int materia_id = -2;
                                             int ap = -1;
                                             int eskill = 0;
                                             TiXmlElement* mat_elm = mat_node->ToElement();
                                             mat_elm->QueryIntAttribute("slot", &slot);
-                                            mat_elm->QueryIntAttribute("id", &id);
+                                            mat_elm->QueryIntAttribute("id", &materia_id);
                                             mat_elm->QueryIntAttribute("ap", &ap);
-                                            mat_elm->QueryIntAttribute("enemy_skill", &ap);
+                                            mat_elm->QueryIntAttribute("enemy_skill", &eskill);
                                             if (
-                                              slot >= 0 && slot < MAX_EQUIP_SLOTS && id >= -1
-                                              && ap >= 0 && (eskill == 0 | eskill == 1)
+                                              slot >= 0 && slot < MAX_EQUIP_SLOTS && materia_id >= -1
+                                              && ap >= 0 && (eskill == 0 || eskill == 1)
                                             ){
-                                                characters_[id].weapon.materia[slot].id = id;
+                                                characters_[id].weapon.materia[slot].id = materia_id;
                                                 characters_[id].weapon.materia[slot].ap = ap;
                                                 characters_[id].weapon.materia[slot].enemy_skill
                                                   = (eskill == 1);
@@ -1483,19 +1520,19 @@ void Savemap::Read(std::string file_name){
                                           && mat_node->ValueStr() == "Materia"
                                         ){
                                             int slot = -1;
-                                            int id = -2;
+                                            int materia_id = -2;
                                             int ap = -1;
                                             int eskill = 0;
                                             TiXmlElement* mat_elm = mat_node->ToElement();
                                             mat_elm->QueryIntAttribute("slot", &slot);
-                                            mat_elm->QueryIntAttribute("id", &id);
+                                            mat_elm->QueryIntAttribute("id", &materia_id);
                                             mat_elm->QueryIntAttribute("ap", &ap);
-                                            mat_elm->QueryIntAttribute("enemy_skill", &ap);
+                                            mat_elm->QueryIntAttribute("enemy_skill", &eskill);
                                             if (
-                                              slot >= 0 && slot < MAX_EQUIP_SLOTS && id >= -1
-                                              && ap >= 0 && (eskill == 0 | eskill == 1)
+                                              slot >= 0 && slot < MAX_EQUIP_SLOTS && materia_id >= -1
+                                              && ap >= 0 && (eskill == 0 || eskill == 1)
                                             ){
-                                                characters_[id].armor.materia[slot].id = id;
+                                                characters_[id].armor.materia[slot].id = materia_id;
                                                 characters_[id].armor.materia[slot].ap = ap;
                                                 characters_[id].armor.materia[slot].enemy_skill
                                                   = (eskill == 1);

+ 23 - 112
src/core/Savemap.h

@@ -15,6 +15,7 @@
 
 #pragma once
 
+#include <map>
 #include <string>
 #include "common/TypeDefine.h"
 
@@ -211,11 +212,26 @@ class Savemap{
         /**
          * Sets a setting value in the savemap.
          *
-         * @param[in] key Setting key.
+         * @param[in] key Name of the setting.
          * @param[in] value Setting value.
-         * @todo Does nothing, implement when settings are working.
          */
-        void SetSetting(const unsigned int key, const unsigned int value);
+        void SetSetting(const std::string& key, const int value);
+
+        /**
+         * Checks if the savemap has a setting.
+         *
+         * @param[in] key Name of the setting.
+         * @return True if the setting was saved. Savemaps from older versions have none.
+         */
+        bool HasSetting(const std::string& key) const;
+
+        /**
+         * Gets the value of a setting.
+         *
+         * @param[in] key Name of the setting.
+         * @return The value of the setting, or 0 if the savemap doesn't have it.
+         */
+        int GetSetting(const std::string& key) const;
 
         /**
          * Sets a character basic information in the savemap.
@@ -537,15 +553,6 @@ class Savemap{
          */
         std::string GetLocationName();
 
-        /**
-         * Retrieves a setting.
-         *
-         * @param[in] key Setting key.
-         * @return The setting value.
-         * @todo Implement and document properly.
-         */
-        int GetSetting(const unsigned int key);
-
         /**
          * Retrieves the char ID of a character.
          *
@@ -937,105 +944,9 @@ class Savemap{
         static constexpr unsigned int BANK_ADDRESS_COUNT = 256;
 
         /**
-         * MAx colour component value.
+         * Max colour component value (they are stored in a byte).
          */
-        static constexpr unsigned int MAX_COLOUR = 254;
-
-
-        /**
-         * Game settings.
-         */
-        struct Settings{
-
-            /**
-             * Magic sorting methods.
-             */
-            enum class MagicOrder{
-
-                /**
-                 * Restore-Attack-Indirect.
-                 */
-                RAI,
-
-                /**
-                 * Restore-Indirect-Attack.
-                 */
-                RIA,
-
-                /**
-                 * Attack-Indirect-Restore.
-                 */
-                AIR,
-
-                /**
-                 * Attack-Restore-Indirect.
-                 */
-                ARI,
-
-                /**
-                 * Indirect-Restore-Attack.
-                 */
-                IRA,
-
-                /**
-                 * Indirect-Attack-Restore.
-                 */
-                IAR,
-            };
-
-            /**
-             * ATB modes.
-             */
-            enum class AtbMode{
-
-                /**
-                 * Active battle.
-                 */
-                ACTIVE,
-
-                /**
-                 * Mixed mode.
-                 */
-                RECOMMENDED,
-
-                /**
-                 * Paused mode.
-                 */
-                WAIT
-            };
-
-            /**
-             * Battle speed.
-             */
-            unsigned int battle_speed;
-
-            /**
-             * Battle message speed.
-             */
-            unsigned int battle_msg_speed;
-
-            /**
-             * Field message speed
-             */
-            unsigned int msg_speed;
-
-            /**
-             * Magic order.
-             */
-            MagicOrder order;
-
-            /**
-             * ATB mode.
-             */
-            AtbMode atb_mode;
-
-            /**
-             * Indicates if the battle help window is to be shown.
-             */
-            bool batle_help;
-
-            // TODO: Key mappings, when implemented.
-        };
+        static constexpr unsigned int MAX_COLOUR = 255;
 
         /**
          * Location information.
@@ -1373,9 +1284,9 @@ class Savemap{
         Location location_;
 
         /**
-         * Game settings.
+         * Settings of the game (config menu), by name. The scripts decide which ones there are.
          */
-        Settings settings_;
+        std::map<std::string, int> settings_;
 
         /**
          * Memory banks.

+ 14 - 8
src/core/SavemapHandler.cpp

@@ -179,9 +179,21 @@ void SavemapHandler::SetLocation(
     current_savemap_->SetLocation(x, y, z, triangle, angle, std::string(field), std::string(name));
 }
 
-void SavemapHandler::SetSetting(const unsigned int key, const unsigned int value){
+void SavemapHandler::SetSetting(const char* key, const int value){
     if (current_savemap_ == nullptr) current_savemap_ = new Savemap();
-    current_savemap_->SetSetting(key, value);
+    current_savemap_->SetSetting(std::string(key), value);
+}
+
+bool SavemapHandler::IsSlotSettingSet(const unsigned int slot, const char* key){
+    if (slot >= MAX_SAVE_SLOTS) return false;
+    if (savemaps_read_ == false) ReadSavemaps();
+    return saved_savemaps_[slot]->HasSetting(std::string(key));
+}
+
+int SavemapHandler::GetSlotSetting(const unsigned int slot, const char* key){
+    if (slot >= MAX_SAVE_SLOTS) return 0;
+    if (savemaps_read_ == false) ReadSavemaps();
+    return saved_savemaps_[slot]->GetSetting(std::string(key));
 }
 
 void SavemapHandler::SetCharacterInfo(
@@ -393,12 +405,6 @@ std::string SavemapHandler::GetSlotLocationName(const unsigned int slot){
     return saved_savemaps_[slot]->GetLocationName();
 }
 
-int SavemapHandler::GetSlotSetting(const unsigned int slot, const unsigned int key){
-    if (slot >= MAX_SAVE_SLOTS) return 0;
-    if (savemaps_read_ == false) ReadSavemaps();
-    return saved_savemaps_[slot]->GetSetting(key);
-}
-
 int SavemapHandler::GetSlotCharacterCharId(const unsigned int slot, const unsigned int id){
     if (slot >= MAX_SAVE_SLOTS) return 0;
     if (savemaps_read_ == false) ReadSavemaps();

+ 19 - 11
src/core/SavemapHandler.h

@@ -252,7 +252,25 @@ class SavemapHandler : public Ogre::Singleton<SavemapHandler>{
          * @param[in] value Setting value.
          * @todo Does nothing, implement when settings are working.
          */
-        void SetSetting(const unsigned int key, const unsigned int value);
+        void SetSetting(const char* key, const int value);
+
+        /**
+         * Checks if a saved savemap has a setting.
+         *
+         * @param[in] slot The slot to read from.
+         * @param[in] key Name of the setting.
+         * @return True if the slot has the setting. False if it doesn't, or the slot is invalid.
+         */
+        bool IsSlotSettingSet(const unsigned int slot, const char* key);
+
+        /**
+         * Gets a setting from a saved savemap.
+         *
+         * @param[in] slot The slot to read from.
+         * @param[in] key Name of the setting.
+         * @return The value of the setting, or 0 if the slot doesn't have it.
+         */
+        int GetSlotSetting(const unsigned int slot, const char* key);
 
         /**
          * Sets a character basic information in the current savemap.
@@ -607,16 +625,6 @@ class SavemapHandler : public Ogre::Singleton<SavemapHandler>{
          */
         std::string GetSlotLocationName(const unsigned int slot);
 
-        /**
-         * Retrieves a setting from a saved savemap.
-         *
-         * @param[in] slot The slot to read from.
-         * @param[in] key Setting key.
-         * @return The setting value.
-         * @todo Implement and document properly.
-         */
-        int GetSlotSetting(const unsigned int slot, const unsigned int key);
-
         /**
          * Retrieves the char ID of a character from a saved savemap.
          *

+ 4 - 11
src/core/ScriptManager.cpp

@@ -87,18 +87,11 @@ void ScriptManager::Input(const VGears::Event& event){
     if (
       (event.type == VGears::ET_KEY_PRESS || event.type == VGears::ET_KEY_REPEAT_WAIT)
       && (
+        // Only the keys that scripts read by themselves reach them as raw keys: the letters and the
+        // space bar, to type in the name menu and for the debug keys of the battle. Everything else
+        // goes through the game events of config.cfg, so it can be remapped.
         // TODO: Do this the other way: block keys NOT TO pass to scripts.
-        event.param1 == OIS::KC_RETURN
-        || event.param1 == OIS::KC_ESCAPE
-        || event.param1 == OIS::KC_SPACE
-        || event.param1 == OIS::KC_LCONTROL
-        || event.param1 == OIS::KC_LEFT
-        || event.param1 == OIS::KC_RIGHT
-        || event.param1 == OIS::KC_DOWN
-        || event.param1 == OIS::KC_UP
-        || event.param1 == OIS::KC_PGUP
-        || event.param1 == OIS::KC_PGDOWN
-        || event.param1 == OIS::KC_BACK
+        event.param1 == OIS::KC_SPACE
         || event.param1 == OIS::KC_A || event.param1 == OIS::KC_B || event.param1 == OIS::KC_C
         || event.param1 == OIS::KC_D || event.param1 == OIS::KC_E || event.param1 == OIS::KC_F
         || event.param1 == OIS::KC_G || event.param1 == OIS::KC_H || event.param1 == OIS::KC_I

+ 44 - 4
src/core/ScriptManagerBinds.h

@@ -24,6 +24,7 @@
 #include "EntityManager.h"
 #include "BattleManager.h"
 #include "AudioManager.h"
+#include "InputManager.h"
 #include "SavemapHandler.h"
 #include "Timer.h"
 #include "UiManager.h"
@@ -131,6 +132,10 @@ void ScriptManager::InitBinds(){
       "set_talkable", &Entity::SetTalkable,
       "is_talkable", &Entity::IsTalkable,
       "set_visible", &Entity::SetVisible,
+      "set_scale", &Entity::ScriptSetScale,
+      "set_model_rotation", &Entity::ScriptSetModelRotation,
+      "set_blend", &Entity::ScriptSetBlend,
+      "set_alpha", &Entity::ScriptSetAlpha,
       "is_visible", &Entity::IsVisible,
       "set_move_auto_speed", &Entity::SetMoveAutoSpeed,
       "get_move_auto_speed", &Entity::GetMoveAutoSpeed,
@@ -169,6 +174,9 @@ void ScriptManager::InitBinds(){
     BindYielding(entity_type, "offset_sync", &Entity::ScriptOffsetSync);
     BindYielding(entity_type, "turn_sync", &Entity::ScriptTurnSync);
     BindYielding(entity_type, "animation_sync", &Entity::ScriptAnimationSync);
+    entity_type["BLEND_NORMAL"] = Entity::BLEND_NORMAL;
+    entity_type["BLEND_ADDITIVE"] = Entity::BLEND_ADDITIVE;
+    entity_type["BLEND_ALPHA"] = Entity::BLEND_ALPHA;
     entity_type["NONE"] = AT_NONE;
     entity_type["LINEAR"] = AT_LINEAR;
     entity_type["SMOOTH"] = AT_SMOOTH;
@@ -203,6 +211,7 @@ void ScriptManager::InitBinds(){
       "unset_player_entity", &EntityManager::ScriptUnsetPlayerEntity,
       "player_lock", &EntityManager::ScriptPlayerLock,
       "is_player_locked", &EntityManager::IsPlayerLocked,
+      "set_player_default_run", &EntityManager::SetPlayerDefaultRun,
       "random_encounters_on", &EntityManager::SetRandomEncounters,
       "set_encounter_table", &EntityManager::ScriptSetEncounterTable,
       "roll_encounter_battle", &EntityManager::ScriptRollEncounterBattle,
@@ -219,6 +228,8 @@ void ScriptManager::InitBinds(){
       "get_enemy_count", &BattleManager::ScriptGetEnemyCount,
       "get_enemy", &BattleManager::ScriptGetEnemy,
       "add_attack_model", &BattleManager::ScriptAddAttackModel,
+      "add_effect_model", &BattleManager::ScriptAddEffectModel,
+      "remove_effect_model", &BattleManager::ScriptRemoveEffectModel,
       "has_attack", &BattleManager::HasAttackData,
       "set_camera", &BattleManager::ScriptSetCamera,
       "get_layout", &BattleManager::ScriptGetLayout,
@@ -335,7 +346,18 @@ void ScriptManager::InitBinds(){
       "get_track_id", &AudioManager::ScriptGetTrack,
       "get_battle_track_id", &AudioManager::ScriptGetBattleTrack,
       "set_battle_track_id", &AudioManager::ScriptSetBattleTrack,
-      "play_music_once", &AudioManager::ScriptPlayMusicOnce
+      "play_music_once", &AudioManager::ScriptPlayMusicOnce,
+      "set_music_volume", &AudioManager::ScriptSetMusicVolume,
+      "set_sound_volume", &AudioManager::ScriptSetSoundVolume
+    );
+
+    // Commands for the input manager (controller settings).
+    lua.new_usertype<InputManager>(
+      "InputManager",
+      "set_game_event_keys", &InputManager::ScriptSetGameEventKeys,
+      "get_game_event_keys", &InputManager::ScriptGetGameEventKeys,
+      "get_default_game_event_keys", &InputManager::ScriptGetDefaultGameEventKeys,
+      "capture_next_key", &InputManager::ScriptCaptureNextKey
     );
 
     // Commands for the camera manager.
@@ -379,6 +401,9 @@ void ScriptManager::InitBinds(){
         // Registered under the same name in the original bindings.
         &SavemapHandler::GetSlotWindowCornerColourComponent
       ),
+      "get_slot_window_colour", &SavemapHandler::GetSlotWindowCornerColourComponent,
+      "is_slot_setting_set", &SavemapHandler::IsSlotSettingSet,
+      "get_slot_setting", &SavemapHandler::GetSlotSetting,
       "get_slot_money", &SavemapHandler::GetSlotMoney,
       "get_slot_game_time", &SavemapHandler::GetSlotGameTime,
       "get_slot_countdown_time", &SavemapHandler::GetSlotCountdownTime,
@@ -403,7 +428,6 @@ void ScriptManager::InitBinds(){
       "get_slot_location_angle", &SavemapHandler::GetSlotLocationAngle,
       "get_slot_location_field", &SavemapHandler::GetSlotLocationField,
       "get_slot_location_name", &SavemapHandler::GetSlotLocationName,
-      "get_slot_setting", &SavemapHandler::GetSlotSetting,
       "get_slot_character_char_id", &SavemapHandler::GetSlotCharacterCharId,
       "get_slot_character_name", &SavemapHandler::GetSlotCharacterName,
       "get_slot_character_level", &SavemapHandler::GetSlotCharacterLevel,
@@ -451,6 +475,16 @@ void ScriptManager::InitBinds(){
       "fade", &ScreenFade::ScriptFade,
       "nfade", &ScreenFade::ScriptNFade
     );
+    // Movies
+    lua.new_usertype<MoviePlayer>(
+      "MoviePlayer",
+      "set", &MoviePlayer::ScriptSet,
+      "stop", &MoviePlayer::ScriptStop,
+      "get_frame", &MoviePlayer::ScriptGetFrame,
+      "is_playing", &MoviePlayer::ScriptIsPlaying
+    );
+    sol::table movie_player_type = lua["MoviePlayer"];
+    BindYielding(movie_player_type, "play", &MoviePlayer::ScriptPlay);
     sol::table screen_fade_type = lua["ScreenFade"];
     BindYielding(screen_fade_type, "wait", &ScreenFade::ScriptWait);
 
@@ -517,6 +551,7 @@ void ScriptManager::InitBinds(){
       ),
       "set_default_animation", &UiWidget::ScriptSetDefaultAnimation,
       "set_colour", &UiWidget::SetColour,
+      "set_colours", &UiWidget::SetColours,
       "set_alpha", &UiWidget::SetAlpha,
       "set_x", &UiWidget::SetX,
       "set_y", &UiWidget::SetY,
@@ -526,7 +561,9 @@ void ScriptManager::InitBinds(){
       "get_height", &UiWidget::ScriptGetHeight,
       "set_height", sol::overload(&UiWidget::ScriptSetHeight, &UiWidget::SetHeight),
       "set_text", &UiWidget::SetText,
-      "set_image", &UiWidget::SetImage
+      "set_image", &UiWidget::SetImage,
+      "set_image_region", &UiWidget::SetImageRegion,
+      "set_additive", &UiWidget::SetAdditive
     );
     sol::table ui_widget_type = lua["UiWidget"];
     BindYielding(ui_widget_type, "animation_sync", &UiWidget::ScriptAnimationSync);
@@ -534,7 +571,8 @@ void ScriptManager::InitBinds(){
     // UI manager commands. Use to get a specific widget.
     lua.new_usertype<UiManager>(
       "UiManager",
-      "get_widget", &UiManager::ScriptGetWidget
+      "get_widget", &UiManager::ScriptGetWidget,
+      "set_window_colours", &UiManager::SetWindowColours
     );
 
     // Timer command. To show a in-game timer.
@@ -574,6 +612,7 @@ void ScriptManager::InitBinds(){
     );
 
     // Register all command handlers
+    lua["input_manager"] = InputManager::getSingletonPtr();
     lua["camera_manager"] = CameraManager::getSingletonPtr();
     lua["entity_manager"] = EntityManager::getSingletonPtr();
     lua["battle_manager"] = BattleManager::getSingletonPtr();
@@ -581,6 +620,7 @@ void ScriptManager::InitBinds(){
     lua["savemap_manager"] = SavemapHandler::getSingletonPtr();
     lua["background2d"] = EntityManager::getSingletonPtr()->GetBackground2D();
     lua["screen_fade"] = EntityManager::getSingletonPtr()->GetScreenFade();
+    lua["movie_player"] = EntityManager::getSingletonPtr()->GetMoviePlayer();
     lua["walkmesh"] = EntityManager::getSingletonPtr()->GetWalkmesh();
     lua["text_manager"] = TextHandler::getSingletonPtr();
     lua["dialog"] = DialogsManager::getSingletonPtr();

+ 25 - 0
src/core/UiManager.cpp

@@ -123,6 +123,31 @@ UiWidget* UiManager::GetWidget(const Ogre::String& name){
 
 UiWidget* UiManager::ScriptGetWidget(const char* name){return GetWidget(Ogre::String(name));}
 
+void UiManager::SetWindowColours(
+  const float r1, const float g1, const float b1, const float r2, const float g2, const float b2,
+  const float r3, const float g3, const float b3, const float r4, const float g4, const float b4
+){
+    const float colours[12] = {r1, g1, b1, r2, g2, b2, r3, g3, b3, r4, g4, b4};
+    for (int i = 0; i < 12; ++ i) window_colours_[i] = colours[i];
+    window_colours_set_ = true;
+    for (unsigned int i = 0; i < widgets_.size(); ++ i) ApplyWindowColours(widgets_[i]);
+}
+
+void UiManager::ApplyWindowColours(UiWidget* widget){
+    if (widget == nullptr || window_colours_set_ == false) return;
+    if (widget->IsWindowBackground()){
+        widget->SetColours(
+          window_colours_[0], window_colours_[1], window_colours_[2],
+          window_colours_[3], window_colours_[4], window_colours_[5],
+          window_colours_[6], window_colours_[7], window_colours_[8],
+          window_colours_[9], window_colours_[10], window_colours_[11]
+        );
+    }
+    for (unsigned int i = 0; i < widget->GetNumberOfChildren(); ++ i){
+        ApplyWindowColours(widget->GetChild(i));
+    }
+}
+
 void UiManager::renderQueueStarted(
   Ogre::uint8 queueGroupId, const Ogre::String& invocation, bool& skipThisInvocation
 ){

+ 45 - 0
src/core/UiManager.h

@@ -139,6 +139,40 @@ class UiManager :
          */
         UiWidget* ScriptGetWidget(const char* name);
 
+        /**
+         * Sets the colours of the background of every window.
+         *
+         * They are applied to all the existing window backgrounds (widgets marked with
+         * UiWidget::SetWindowBackground), and to the ones created later.
+         *
+         * @param[in] r1 Red component for the top-left corner (0-1).
+         * @param[in] g1 Green component for the top-left corner (0-1).
+         * @param[in] b1 Blue component for the top-left corner (0-1).
+         * @param[in] r2 Red component for the top-right corner (0-1).
+         * @param[in] g2 Green component for the top-right corner (0-1).
+         * @param[in] b2 Blue component for the top-right corner (0-1).
+         * @param[in] r3 Red component for the bottom-right corner (0-1).
+         * @param[in] g3 Green component for the bottom-right corner (0-1).
+         * @param[in] b3 Blue component for the bottom-right corner (0-1).
+         * @param[in] r4 Red component for the bottom-left corner (0-1).
+         * @param[in] g4 Green component for the bottom-left corner (0-1).
+         * @param[in] b4 Blue component for the bottom-left corner (0-1).
+         */
+        void SetWindowColours(
+          const float r1, const float g1, const float b1,
+          const float r2, const float g2, const float b2,
+          const float r3, const float g3, const float b3,
+          const float r4, const float g4, const float b4
+        );
+
+        /**
+         * Applies the window colours to a widget if it is a window background, and to its
+         * children. Does nothing until the colours have been set.
+         *
+         * @param[in] widget The widget.
+         */
+        void ApplyWindowColours(UiWidget* widget);
+
         /**
          * Updates the render queue.
          *
@@ -200,4 +234,15 @@ class UiManager :
          * List of widgets.
          */
         std::vector<UiWidget*> widgets_;
+
+        /**
+         * Colours of the windows (RGB of the top-left, top-right, bottom-right and bottom-left
+         * corners). Only used if window_colours_set_.
+         */
+        float window_colours_[12];
+
+        /**
+         * Indicates if the window colours were set.
+         */
+        bool window_colours_set_ = false;
 };

+ 25 - 12
src/core/UiSprite.cpp

@@ -82,6 +82,19 @@ void UiSprite::SetImage(const Ogre::String& image){
 
 void UiSprite::SetImage(const char* image){SetImage(Ogre::String(image));}
 
+void UiSprite::SetImageRegion(const float u1, const float v1, const float u2, const float v2){
+    image_region_[0] = u1;
+    image_region_[1] = v1;
+    image_region_[2] = u2;
+    image_region_[3] = v2;
+    UpdateGeometry();
+}
+
+void UiSprite::SetAdditive(const bool additive){
+    Ogre::Pass* pass = material_->getTechnique(0)->getPass(0);
+    pass->setSceneBlending(additive ? Ogre::SBT_ADD : Ogre::SBT_TRANSPARENT_ALPHA);
+}
+
 void UiSprite::SetVertexShader(const Ogre::String& shader){
     Ogre::Pass* pass = material_->getTechnique(0)->getPass(0);
     pass->setVertexProgram(shader, true);
@@ -142,8 +155,8 @@ void UiSprite::UpdateGeometry(){
     *write_iterator ++ = colour_1_.g;
     *write_iterator ++ = colour_1_.b;
     *write_iterator ++ = colour_1_.a;
-    *write_iterator ++ = 0;
-    *write_iterator ++ = 0;
+    *write_iterator ++ = image_region_[0];
+    *write_iterator ++ = image_region_[1];
 
     *write_iterator ++ = new_x2;
     *write_iterator ++ = new_y2;
@@ -152,8 +165,8 @@ void UiSprite::UpdateGeometry(){
     *write_iterator ++ = colour_2_.g;
     *write_iterator ++ = colour_2_.b;
     *write_iterator ++ = colour_2_.a;
-    *write_iterator ++ = 1;
-    *write_iterator ++ = 0;
+    *write_iterator ++ = image_region_[2];
+    *write_iterator ++ = image_region_[1];
 
     *write_iterator ++ = new_x3;
     *write_iterator ++ = new_y3;
@@ -162,8 +175,8 @@ void UiSprite::UpdateGeometry(){
     *write_iterator ++ = colour_3_.g;
     *write_iterator ++ = colour_3_.b;
     *write_iterator ++ = colour_3_.a;
-    *write_iterator ++ = 1;
-    *write_iterator ++ = 1;
+    *write_iterator ++ = image_region_[2];
+    *write_iterator ++ = image_region_[3];
 
     *write_iterator ++ = new_x1;
     *write_iterator ++ = new_y1;
@@ -172,8 +185,8 @@ void UiSprite::UpdateGeometry(){
     *write_iterator ++ = colour_1_.g;
     *write_iterator ++ = colour_1_.b;
     *write_iterator ++ = colour_1_.a;
-    *write_iterator ++ = 0;
-    *write_iterator ++ = 0;
+    *write_iterator ++ = image_region_[0];
+    *write_iterator ++ = image_region_[1];
 
     *write_iterator ++ = new_x3;
     *write_iterator ++ = new_y3;
@@ -182,8 +195,8 @@ void UiSprite::UpdateGeometry(){
     *write_iterator ++ = colour_3_.g;
     *write_iterator ++ = colour_3_.b;
     *write_iterator ++ = colour_3_.a;
-    *write_iterator ++ = 1;
-    *write_iterator ++ = 1;
+    *write_iterator ++ = image_region_[2];
+    *write_iterator ++ = image_region_[3];
 
     *write_iterator ++ = new_x4;
     *write_iterator ++ = new_y4;
@@ -192,8 +205,8 @@ void UiSprite::UpdateGeometry(){
     *write_iterator ++ = colour_4_.g;
     *write_iterator ++ = colour_4_.b;
     *write_iterator ++ = colour_4_.a;
-    *write_iterator ++ = 0;
-    *write_iterator ++ = 1;
+    *write_iterator ++ = image_region_[0];
+    *write_iterator ++ = image_region_[3];
 
     render_operation_.vertexData->vertexCount = 6;
     vertex_buffer_->unlock();

+ 9 - 0
src/core/UiSprite.h

@@ -86,6 +86,10 @@ class UiSprite : public UiWidget{
          */
         void SetImage(const char* image) override;
 
+        void SetImageRegion(const float u1, const float v1, const float u2, const float v2) override;
+
+        void SetAdditive(const bool additive) override;
+
         /**
          * Sets the vertex shader for the sprite.
          *
@@ -142,6 +146,11 @@ class UiSprite : public UiWidget{
          */
         Ogre::RenderOperation render_operation_;
 
+        /**
+         * Part of the image that is drawn: left, top, right and bottom, from 0 to 1.
+         */
+        float image_region_[4] = {0.0f, 0.0f, 1.0f, 1.0f};
+
         /**
          * The vertex buffer.
          */

+ 3 - 0
src/core/UiTextArea.cpp

@@ -158,6 +158,9 @@ void UiTextArea::Update(){
 }
 
 void UiTextArea::Render(){
+    // Scripts that run after the widgets update (like the battle) can change the text or the
+    // position of a widget in the same frame it is drawn, so the geometry is refreshed here.
+    if (update_transformation_ == true && visible_ == true) UpdateTransformation();
     if (update_transformation_ == false && visible_ == true){
         if (render_operation_.vertexData->vertexCount != 0){
             auto render_parameters = render_system_->getFixedFunctionParams(

+ 8 - 0
src/core/UiWidget.cpp

@@ -567,6 +567,10 @@ void UiWidget::SetColours(
     SetUpdateTransformation();
 }
 
+void UiWidget::SetWindowBackground(const bool background){window_background_ = background;}
+
+bool UiWidget::IsWindowBackground() const{return window_background_;}
+
 void UiWidget::SetAlpha(const float a){
     colour_1_.a = a;
     colour_2_.a = a;
@@ -579,6 +583,10 @@ void UiWidget::SetText(const char* text){}
 
 void UiWidget::SetImage(const char* image){}
 
+void UiWidget::SetImageRegion(const float u1, const float v1, const float u2, const float v2){}
+
+void UiWidget::SetAdditive(const bool additive){}
+
 float UiWidget::ScriptGetWidth(){return width_;}
 
 void UiWidget::ScriptSetWidth(float width){

+ 43 - 0
src/core/UiWidget.h

@@ -502,6 +502,23 @@ class UiWidget{
           const float r4, const float g4, const float b4
         );
 
+        /**
+         * Marks the widget as the background of a window.
+         *
+         * Window backgrounds all share the colours the player chooses in the config menu, see
+         * UiManager::SetWindowColours.
+         *
+         * @param[in] background True to mark it as a window background.
+         */
+        void SetWindowBackground(const bool background);
+
+        /**
+         * Checks if the widget is the background of a window.
+         *
+         * @return True if the widget is a window background.
+         */
+        bool IsWindowBackground() const;
+
         /**
          * Sets the widget transparency.
          *
@@ -527,6 +544,27 @@ class UiWidget{
          */
         virtual void SetImage(const char* image);
 
+        /**
+         * Sets which part of the image of the widget is drawn.
+         *
+         * Only sprites do something. Used to draw frames of a sprite sheet.
+         *
+         * @param[in] u1 Left side, from 0 to 1.
+         * @param[in] v1 Top side, from 0 to 1.
+         * @param[in] u2 Right side, from 0 to 1.
+         * @param[in] v2 Bottom side, from 0 to 1.
+         */
+        virtual void SetImageRegion(const float u1, const float v1, const float u2, const float v2);
+
+        /**
+         * Sets if the widget is drawn adding its colours to the ones behind, for light effects.
+         *
+         * Only sprites do something.
+         *
+         * @param[in] additive True for additive blending, false for the normal transparency.
+         */
+        virtual void SetAdditive(const bool additive);
+
         /**
          * Retrieves the absolute width of the widget.
          *
@@ -604,6 +642,11 @@ class UiWidget{
          */
         bool visible_;
 
+        /**
+         * Indicates if the widget is the background of a window, which uses the window colours.
+         */
+        bool window_background_ = false;
+
         /**
          * Top-left corner colour.
          */

+ 4 - 0
src/core/XmlScreenFile.cpp

@@ -131,6 +131,10 @@ void XmlScreenFile::LoadScreenRecursive(
                         Ogre::Vector3 colour = GetVector3(node, "colour", Ogre::Vector3(1, 1, 1));
                         widget2->SetColour(colour.x, colour.y, colour.z);
                     }
+                    if (GetBool(node, "window_background", false) == true){
+                        widget2->SetWindowBackground(true);
+                        UiManager::getSingleton().ApplyWindowColours(widget2);
+                    }
                     float alpha = GetFloat(node, "alpha", 1);
                     widget2->SetAlpha(alpha);
                     Ogre::String align = GetString(node, "align");

+ 9 - 0
src/data/VGearsLGPArchive.h

@@ -170,6 +170,15 @@ namespace VGears{
                  */
                 String file_name;
 
+                /**
+                 * Folder the file is in, empty if it's at the root of the archive.
+                 *
+                 * Several files can have the same name if they're in different folders. The
+                 * folder comes from the conflict tables of the archive, and has the form
+                 * "ff7/data/battle/magic/fire".
+                 */
+                String folder;
+
                 /**
                  * The file offset from the beginning of the archive.
                  */

+ 25 - 0
src/data/VGearsLGPArchiveSerializer.cpp

@@ -31,6 +31,7 @@ namespace VGears{
         ReadUInt32(stream, file_count);
         FileList& files(dest->GetFiles());
         ReadVector(stream, files, file_count);
+        ReadFolders(stream, files);
         FileList::iterator it(files.begin());
         FileList::const_iterator it_end(files.end());
         while (it != it_end){
@@ -42,6 +43,30 @@ namespace VGears{
         }
     }
 
+    void LGPArchiveSerializer::ReadFolders(Ogre::DataStreamPtr &stream, FileList& files){
+        // After the file table there's a lookup table of 30 * 30 entries, then the conflict tables.
+        // Each conflict table lists the folders where files of the same name are, with the index
+        // of the file in the file table.
+        static const size_t LOOKUP_TABLE_SIZE = 30 * 30 * 4;
+        static const size_t FOLDER_NAME_LENGTH = 128;
+        if (stream->size() < stream->tell() + LOOKUP_TABLE_SIZE + 2) return;
+        stream->skip(LOOKUP_TABLE_SIZE);
+        uint16 table_count(0);
+        ReadUInt16(stream, table_count);
+        for (uint16 t = 0; t < table_count; ++ t){
+            if (stream->eof()) return;
+            uint16 entry_count(0);
+            ReadUInt16(stream, entry_count);
+            for (uint16 e = 0; e < entry_count; ++ e){
+                if (stream->size() < stream->tell() + FOLDER_NAME_LENGTH + 2) return;
+                String folder(readString(stream, FOLDER_NAME_LENGTH));
+                uint16 index(0);
+                ReadUInt16(stream, index);
+                if (index < files.size()) files[index].folder = folder;
+            }
+        }
+    }
+
     void LGPArchiveSerializer::ReadFileHeader(Ogre::DataStreamPtr &stream){
         stream->skip(2); // unused
         String magic_string(readString(stream, MAGIC_STRING_LENGTH));

+ 8 - 0
src/data/VGearsLGPArchiveSerializer.h

@@ -65,6 +65,14 @@ namespace VGears{
 
         protected:
 
+            /**
+             * Reads the folders of the files, from the conflict tables after the file table.
+             *
+             * @param[in] stream The contents of the LGP archive, positioned after the file table.
+             * @param[in,out] files The files of the archive. Their folder is set.
+             */
+            void ReadFolders(Ogre::DataStreamPtr &stream, FileList& files);
+
             /**
              * Reads an archive header and sets the instance data.
              *

+ 277 - 17
src/installer/BattleDataInstaller.cpp

@@ -18,12 +18,17 @@
 
 #include <iostream>
 #include <fstream>
+#include <sstream>
+#include <map>
+#include <set>
+#include <cctype>
 #include <algorithm>
 #include <zlib.h>
 #include <tinyxml.h>
 #include <boost/filesystem.hpp>
 #include <OgreMesh.h>
 #include "BattleDataInstaller.h"
+#include "ModelMaterials.h"
 #include "TexFile.h"
 #include "data/VGearsHRCFileManager.h"
 #include "data/VGearsAFileManager.h"
@@ -36,7 +41,7 @@
 BattleDataInstaller::BattleDataInstaller(
   const DiskImage& disk_image, const std::string output_dir, Ogre::ResourceGroupManager* res_mgr
 ):
-  disk_image_(disk_image), output_dir_(output_dir),
+  disk_image_(disk_image), output_dir_(output_dir), data_dir_(output_dir + "data/"),
   scene_bin_(*const_cast<DiskImage&>(disk_image).filePath("data/battle/scene.bin")), total_scenes_(0), next_scene_(0),
   res_mgr_(res_mgr)
 {}
@@ -110,7 +115,10 @@ unsigned int BattleDataInstaller::InitializeSpellModels(){
     battle_lgp_files_.clear();
     battle_lgp_file_names_.clear();
     battle_models_.clear();
-    // Open battle.lgp
+    magic_lgp_files_.clear();
+    magic_lgp_file_names_.clear();
+    magic_lgp_file_folders_.clear();
+    // Open magic.lgp
     File magic_lgp_file(*disk_image_.filePath("data/battle/magic.lgp"));
     // Also, open it as a LGP archive.
     VGears::LGPArchive magic_lgp(*disk_image_.filePath("data/battle/magic.lgp"), "LGP");
@@ -123,6 +131,7 @@ unsigned int BattleDataInstaller::InitializeSpellModels(){
             File m_lgp_file(&magic_lgp_file, f.data_offset, f.data_size);
             magic_lgp_files_.push_back(m_lgp_file);
             magic_lgp_file_names_.push_back(f.file_name);
+            magic_lgp_file_folders_.push_back(f.folder);
         }
     }
     return magic_lgp_files_.size();
@@ -257,7 +266,7 @@ unsigned int BattleDataInstaller::ProcessBattleModel(){
           output_dir_ + "temp/battle_models/" + battle_lgp_file_names_[next_model_to_process_] + ".tex"
         );
         TexFile tex(battle_lgp_files_[next_model_to_process_]);
-        std::string path = output_dir_ + "models/battle/";
+        std::string path = data_dir_ + "models/battle/";
         if (info.is_player){
             path += "characters/";
             // Add or update on character_data_
@@ -418,13 +427,13 @@ unsigned int BattleDataInstaller::ProcessSpellModel(){
         // add them to a particular model.
         magic_lgp_files_[next_model_to_process_].WriteFile(output_dir_ + "temp/spell_models/" + fname);
         TexFile tex(output_dir_ + "temp/spell_models/" + fname);
-        std::string path = output_dir_ + "models/battle/attacks/" + id + ".png";
+        std::string path = data_dir_ + "models/battle/attacks/" + id + ".png";
         // Many of these tex files are wrong and dummied out in the OG, so try/catch.
         try{
             tex.SavePng(path);
         }
         catch(const std::exception& e){
-            std::cerr << "Error converting texture " << (output_dir_ + "temp/spell_models/" + fname)
+            std::cerr << "[WARNING] Error converting texture " << (output_dir_ + "temp/spell_models/" + fname)
               << " to png: " << e.what() << std::endl;
         }
     }
@@ -494,7 +503,7 @@ unsigned int BattleDataInstaller::ConvertBattleModel(){
 
         if ("" != model.script){ // Skip non scripted models
             AbFile ab(File(output_dir_ + "temp/battle_models/" + model.script), info.is_enemy);
-            ab.GenerateScripts(model.id + "_" + info.name_normal, output_dir_ + "scripts/battle_models/");
+            ab.GenerateScripts(model.id + "_" + info.name_normal, data_dir_ + "scripts/battle_models/");
         }
 
         // Reload the resources for the newly created hrc and rsd.
@@ -593,6 +602,258 @@ unsigned int BattleDataInstaller::ConvertSpellModel(){
     return next_model_to_convert_;
 }
 
+namespace{
+
+/**
+ * Makes a name that is safe to use in files and Ogre resources: lower case letters, numbers and
+ * underscores.
+ */
+std::string EffectSafeName(std::string name){
+    for (char& c : name){
+        c = std::tolower(static_cast<unsigned char>(c));
+        if (!std::isalnum(static_cast<unsigned char>(c)) && c != '_') c = '_';
+    }
+    return name;
+}
+
+/**
+ * Gets the name without the extension, in lower case.
+ */
+std::string EffectStem(const std::string& file_name){
+    std::string stem = file_name.substr(0, file_name.find_last_of('.'));
+    std::transform(stem.begin(), stem.end(), stem.begin(), [](unsigned char c){return std::tolower(c);});
+    return stem;
+}
+
+/**
+ * Gets the name of a texture in a rsd file, without the extension, in lower case.
+ */
+std::string EffectRsdValue(const std::string& line){
+    size_t equal = line.find('=');
+    if (equal == std::string::npos) return "";
+    std::string value = line.substr(equal + 1);
+    while (!value.empty() && std::isspace(static_cast<unsigned char>(value.back()))) value.pop_back();
+    while (!value.empty() && std::isspace(static_cast<unsigned char>(value.front()))) value.erase(0, 1);
+    return EffectStem(value);
+}
+
+}
+
+unsigned int BattleDataInstaller::InitializeEffectModels(){
+    effect_models_.clear();
+    next_effect_to_convert_ = 0;
+    const std::string dir = output_dir_ + "temp/effects/";
+    // Index the files by folder and name.
+    std::map<std::pair<std::string, std::string>, size_t> index;
+    for (size_t f = 0; f < magic_lgp_files_.size(); f ++){
+        std::string name = magic_lgp_file_names_[f];
+        std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c){return std::tolower(c);});
+        index[{magic_lgp_file_folders_[f], name}] = f;
+    }
+    // Folder key: the effect folder, "common" for the root.
+    auto key_of = [](const std::string& folder){
+        if (folder.empty()) return std::string("common");
+        std::string key = folder;
+        const std::string prefix = "ff7/data/battle/";
+        if (key.compare(0, prefix.size(), prefix) == 0) key = key.substr(prefix.size());
+        return EffectSafeName(key);
+    };
+    // Finds a file in the folder of the model, or in the archive root.
+    auto find = [&](const std::string& folder, const std::string& name, std::string& found_folder){
+        auto it = index.find({folder, name});
+        if (it != index.end()){found_folder = folder; return it->second;}
+        it = index.find({"", name});
+        if (it != index.end()){found_folder = ""; return it->second;}
+        return static_cast<size_t>(-1);
+    };
+    std::set<std::string> written;
+    auto write = [&](size_t f, const std::string& name){
+        if (written.insert(name).second) magic_lgp_files_[f].WriteFile(dir + name);
+    };
+    for (size_t f = 0; f < magic_lgp_files_.size(); f ++){
+        std::string rsd_name = magic_lgp_file_names_[f];
+        if (rsd_name.size() < 4 || EffectSafeName(rsd_name.substr(rsd_name.size() - 4)) != "_rsd") continue;
+        const std::string& folder = magic_lgp_file_folders_[f];
+        // Parse the rsd.
+        File& rsd_file = magic_lgp_files_[f];
+        rsd_file.SetOffset(0);
+        std::string content;
+        for (size_t b = 0; b < rsd_file.GetFileSize(); b ++) content += static_cast<char>(rsd_file.readU8());
+        std::string ply;
+        std::vector<std::string> tex;
+        std::istringstream lines(content);
+        std::string line;
+        while (std::getline(lines, line)){
+            std::string upper = line;
+            std::transform(upper.begin(), upper.end(), upper.begin(), [](unsigned char c){return std::toupper(c);});
+            if (upper.compare(0, 4, "PLY=") == 0) ply = EffectRsdValue(line);
+            else if (upper.compare(0, 4, "TEX[") == 0) tex.push_back(EffectRsdValue(line));
+        }
+        if (ply.empty()) continue;
+        std::string p_folder;
+        size_t p_index = find(folder, ply + ".p", p_folder);
+        if (p_index == static_cast<size_t>(-1)) continue;
+        const std::string key = key_of(folder);
+        EffectModel model;
+        model.name = key + "_" + EffectSafeName(EffectStem(rsd_name));
+        const std::string p_name = key_of(p_folder) + "_" + EffectSafeName(ply) + ".p";
+        write(p_index, p_name);
+        std::string rsd = "@RSD940102\nPLY=" + p_name.substr(0, p_name.size() - 2) + ".PLY\n";
+        rsd += "MAT=" + model.name + ".MAT\nGRP=" + model.name + ".GRP\n";
+        std::vector<std::string> tex_names;
+        for (const std::string& t : tex){
+            std::string t_folder;
+            size_t t_index = find(folder, t + ".tex", t_folder);
+            if (t_index == static_cast<size_t>(-1)) continue;
+            const std::string t_name = key_of(t_folder) + "_" + EffectSafeName(t) + ".tex";
+            write(t_index, t_name);
+            tex_names.push_back(t_name);
+        }
+        rsd += "NTEX=" + std::to_string(tex_names.size()) + "\n";
+        for (size_t t = 0; t < tex_names.size(); t ++){
+            rsd += "TEX[" + std::to_string(t) + "]=" + tex_names[t].substr(0, tex_names[t].size() - 4) + ".TIM\n";
+        }
+        model.textures = tex_names;
+        std::ofstream(dir + model.name + ".rsd") << rsd;
+        // A model with a single bone, with the mesh.
+        std::ofstream(dir + model.name + ".hrc")
+          << ":HEADER_BLOCK 2\n:SKELETON " << model.name << "\n:BONES 1\n\nbone0\nroot\n0.000000\n1 "
+          << model.name << "\n\n";
+        effect_models_.push_back(model);
+    }
+    res_mgr_->removeResourceLocation(dir, "FFVII");
+    res_mgr_->addResourceLocation(dir, "FileSystem", "FFVII", true, true);
+    return effect_models_.size();
+}
+
+unsigned int BattleDataInstaller::ConvertEffectModel(){
+    if (next_effect_to_convert_ >= effect_models_.size()) return effect_models_.size();
+    EffectModel& model = effect_models_[next_effect_to_convert_];
+    try{
+        VGears::HRCFileManager::GetSingleton().load(model.name + ".hrc", "FFVII");
+        Ogre::MeshPtr mesh(Ogre::MeshManager::getSingleton().load(model.name + ".mesh", "FFVII"));
+        ExportMesh("effects/", mesh);
+        for (const std::string& texture : model.textures){
+            const std::string path = data_dir_ + "models/battle/effects/"
+              + texture.substr(0, texture.size() - 4) + ".png";
+            if (boost::filesystem::exists(path)) continue;
+            try{
+                TexFile tex(output_dir_ + "temp/effects/" + texture);
+                tex.SavePng(path);
+            }
+            catch (const std::exception& ex){
+                // Many of the original textures are dummies.
+                std::cerr << "[WARNING] Error converting effect texture " << texture << ": " << ex.what() << std::endl;
+            }
+        }
+        model.converted = true;
+    }
+    catch (const Ogre::Exception& ex){
+        std::cerr << "[WARNING] Could not convert effect model " << model.name << ": " << ex.what() << std::endl;
+    }
+    catch (const std::exception& ex){
+        std::cerr << "[WARNING] Could not convert effect model " << model.name << ": " << ex.what() << std::endl;
+    }
+    next_effect_to_convert_ ++;
+    return next_effect_to_convert_;
+}
+
+void BattleDataInstaller::WriteEffectModels(){
+    std::ofstream file(data_dir_ + "gamedata/effects.lua");
+    file << "-- Meshes of the battle effects, extracted from magic.lgp. Names are the folder of the effect\n"
+         << "-- and the original file, for example magic_faira_1. Meshes are in models/battle/effects.\n"
+         << "Game.EffectModels = {\n";
+    for (const EffectModel& model : effect_models_){
+        if (model.converted) file << "    \"" << model.name << "\",\n";
+    }
+    file << "}\n";
+    WriteEffectSprites();
+}
+
+void BattleDataInstaller::WriteEffectSprites(){
+    // The sprite files (.s) are lists of quads of a sprite sheet, after an 8 byte header ("# " and
+    // the number of frames). Layout found by comparing files, not documented. A quad has:
+    //   flags (4 bytes, plus 4 if the second 16 bit value is not 0),
+    //   draw offset X and Y, signed 16 bit, normally half of the size (centered),
+    //   position of the quad in the texture, U and V, 16 bit,
+    //   4 unknown bytes,
+    //   size of the quad in the texture, width and height in bytes (twice).
+    // Textures are converted too, with the name of the effect folder as a prefix (common_ for the
+    // archive root), to images/battle_fx, so they can be used as images of sprites.
+    const std::string key_prefix = "ff7/data/battle/";
+    auto key_of = [&](std::string folder){
+        if (folder.empty()) return std::string("common");
+        if (folder.compare(0, key_prefix.size(), key_prefix) == 0) folder = folder.substr(key_prefix.size());
+        return EffectSafeName(folder);
+    };
+    std::ofstream file(data_dir_ + "gamedata/effect_sprites.lua");
+    file << "-- Frames of the sprite files of magic.lgp. Game.EffectSprites[name] is a list of frames with\n"
+         << "-- the position (u, v) and size (w, h) in the texture, in pixels, the offset to draw it\n"
+         << "-- (ox, oy) and the flags. The names are the folder and the file, like common_iyashi.\n"
+         << "-- Textures are in images/battle_fx, like common_iyashi00.png.\n"
+         << "Game.EffectSprites = Game.EffectSprites or {}\n";
+    const std::string dir = output_dir_ + "temp/effects/";
+    boost::filesystem::create_directories(data_dir_ + "images/battle_fx/");
+    for (size_t f = 0; f < magic_lgp_files_.size(); f ++){
+        const std::string& name = magic_lgp_file_names_[f];
+        if (name.size() < 3) continue;
+        std::string ext = name.substr(name.find_last_of('.') + 1);
+        std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c){return std::tolower(c);});
+        const std::string key = key_of(magic_lgp_file_folders_[f]);
+        if (ext == "tex"){
+            const std::string stem = EffectSafeName(EffectStem(name));
+            const std::string path = data_dir_ + "images/battle_fx/" + key + "_" + stem + ".png";
+            if (boost::filesystem::exists(path)) continue;
+            try{
+                magic_lgp_files_[f].WriteFile(dir + key + "_" + stem + ".tex");
+                TexFile tex(dir + key + "_" + stem + ".tex");
+                tex.SavePng(path);
+            }
+            catch (const std::exception& ex){
+                // Many of the original textures are dummies.
+            }
+        }
+        else if (ext == "s"){
+            File& sprite_file = magic_lgp_files_[f];
+            const size_t size = sprite_file.GetFileSize();
+            if (size < 8) continue;
+            sprite_file.SetOffset(0);
+            std::vector<unsigned char> bytes(size);
+            for (size_t b = 0; b < size; b ++) bytes[b] = sprite_file.readU8();
+            // Only the files with the header of sprite lists.
+            if (bytes[0] != 0x23 || bytes[1] != 0x20) continue;
+            // Each quad has 4 bytes of flags, 4 more if the second 16 bit value is not 0, and then 16
+            // bytes of data. The header count is of frames, not quads, so it's not used.
+            struct Quad{int ox, oy, u, v, w, h;};
+            std::vector<Quad> quads;
+            size_t p = 8;
+            bool valid = true;
+            while (p < size){
+                if (p + 4 > size){valid = false; break;}
+                const bool extra = (bytes[p + 2] | (bytes[p + 3] << 8)) != 0;
+                const size_t q = p + 4 + (extra ? 4 : 0);
+                if (q + 16 > size){valid = false; break;}
+                Quad quad;
+                quad.ox = static_cast<int16_t>(bytes[q] | (bytes[q + 1] << 8));
+                quad.oy = static_cast<int16_t>(bytes[q + 2] | (bytes[q + 3] << 8));
+                quad.u = bytes[q + 4] | (bytes[q + 5] << 8);
+                quad.v = bytes[q + 6] | (bytes[q + 7] << 8);
+                quad.w = bytes[q + 12];
+                quad.h = bytes[q + 13];
+                quads.push_back(quad);
+                p = q + 16;
+            }
+            if (!valid || quads.empty()) continue;
+            file << "Game.EffectSprites[\"" << key << "_" << EffectSafeName(EffectStem(name)) << "\"] = {\n";
+            for (const Quad& quad : quads){
+                file << "    {u = " << quad.u << ", v = " << quad.v << ", w = " << quad.w << ", h = " << quad.h
+                     << ", ox = " << quad.ox << ", oy = " << quad.oy << "},\n";
+            }
+            file << "}\n";
+        }
+    }
+}
+
 void BattleDataInstaller::WriteEnemies(){
     for (Enemy enemy : enemies_){
         TiXmlDocument xml;
@@ -718,7 +979,7 @@ void BattleDataInstaller::WriteEnemies(){
         }
         xml.LinkEndChild(container.release());
         std::string file_name = BuildEnemyFileName(enemy);
-        xml.SaveFile(output_dir_ + file_name);
+        xml.SaveFile(data_dir_ + file_name);
         enemy_map_[enemy.id] = file_name;
 
     }
@@ -865,7 +1126,7 @@ void BattleDataInstaller::WriteAttacks(){
         container->LinkEndChild(anim.release());
         xml.LinkEndChild(container.release());
         std::string file_name = BuildAttackFileName(attack);
-        xml.SaveFile(output_dir_ + file_name);
+        xml.SaveFile(data_dir_ + file_name);
         attack_map_[attack.id] = file_name;
     }
     // Index of the attack files by ID, as file names include the attack name.
@@ -879,7 +1140,7 @@ void BattleDataInstaller::WriteAttacks(){
         attacks->LinkEndChild(xml_attack.release());
     }
     index.LinkEndChild(attacks.release());
-    index.SaveFile(output_dir_ + "gamedata/attack/_index.xml");
+    index.SaveFile(data_dir_ + "gamedata/attack/_index.xml");
 }
 
 void BattleDataInstaller::WriteFormations(){
@@ -945,7 +1206,7 @@ void BattleDataInstaller::WriteFormations(){
         std::string file_name = std::to_string(formation.id);
         while (file_name.length() < 4) file_name = "0" + file_name;
         file_name = "gamedata/formation/" + file_name + ".xml";
-        xml.SaveFile(output_dir_ + file_name);
+        xml.SaveFile(data_dir_ + file_name);
         formation_map_[formation.id] = file_name;
     }
 }
@@ -965,7 +1226,7 @@ void BattleDataInstaller::WriteCharacterData(){
         container->LinkEndChild(xml_character.release());
     }
     xml.LinkEndChild(container.release());
-    xml.SaveFile(output_dir_ + "models/battle/characters.xml");
+    xml.SaveFile(data_dir_ + "models/battle/characters.xml");
 }
 
 void BattleDataInstaller::WriteSceneData(){
@@ -979,7 +1240,7 @@ void BattleDataInstaller::WriteSceneData(){
         container->LinkEndChild(xml_scene.release());
     }
     xml.LinkEndChild(container.release());
-    xml.SaveFile(output_dir_ + "models/battle/scenes.xml");
+    xml.SaveFile(data_dir_ + "models/battle/scenes.xml");
 }
 
 std::string BattleDataInstaller::BuildEnemyFileName(Enemy enemy){
@@ -1086,20 +1347,19 @@ File* BattleDataInstaller::ExtractGZipScene(File file){
 void BattleDataInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPtr &mesh){
     VGears::String base_mesh_name;
     VGears::StringUtil::splitFull(mesh->getName(), base_mesh_name);
+    MakeMaterialsUnique(mesh, outdir + base_mesh_name);
     Ogre::MeshSerializer mesh_serializer;
     Ogre::SkeletonPtr skeleton(mesh->getSkeleton());
     Ogre::SkeletonSerializer skeleton_serializer;
     skeleton_serializer.exportSkeleton(
-      skeleton.get(), output_dir_ + "models/battle/" + outdir + base_mesh_name + ".skeleton"
+      skeleton.get(), data_dir_ + "models/battle/" + outdir + base_mesh_name + ".skeleton"
     );
     mesh->setSkeletonName(outdir + base_mesh_name + ".skeleton");
-    mesh_serializer.exportMesh(mesh.get(), output_dir_ + "models/battle/" + outdir + mesh->getName());
+    mesh_serializer.exportMesh(mesh.get(), data_dir_ + "models/battle/" + outdir + mesh->getName());
     Ogre::MaterialSerializer mat_ser;
     std::set<std::string> textures;
     Ogre::Mesh::SubMeshList sub_meshes = mesh->getSubMeshes();
     for (Ogre::SubMesh* sub_mesh : sub_meshes){
-        // Change material name to avoid conflicts with field model materials.
-        sub_mesh->setMaterialName(sub_mesh->getMaterialName());
         Ogre::MaterialPtr mat(Ogre::MaterialManager::getSingleton().getByName(sub_mesh->getMaterialName()));
         if (mat == nullptr) continue;
         for (size_t techs = 0; techs < mat->getNumTechniques(); techs ++){
@@ -1127,7 +1387,7 @@ void BattleDataInstaller::ExportMesh(const std::string outdir, const Ogre::MeshP
             battle_materials_.push_back(sub_mesh->getMaterialName());
         }
     }
-    mat_ser.exportQueued(output_dir_ + "models/battle/" + outdir + base_mesh_name + VGears::EXT_MATERIAL);
+    mat_ser.exportQueued(data_dir_ + "models/battle/" + outdir + base_mesh_name + VGears::EXT_MATERIAL);
 }
 
 void BattleDataInstaller::GenerateRsdFiles(BattleModel model, std::string path){

+ 71 - 0
src/installer/BattleDataInstaller.h

@@ -128,6 +128,36 @@ class BattleDataInstaller{
          */
         unsigned int ConvertSpellModel();
 
+        /**
+         * Extracts the assets of the battle effects of magic.lgp: meshes, textures and sprite files.
+         *
+         * Effects are in folders of the archive, and files of different folders can have the same
+         * name. Each file is saved with the folder as a prefix, so the names are unique, and an
+         * effect model list is made with the meshes that can be converted.
+         * {@see InitializeSpellModels} must be called before.
+         *
+         * @return The number of effect models to convert.
+         */
+        unsigned int InitializeEffectModels();
+
+        /**
+         * Converts an effect model to an Ogre mesh and its textures to PNG.
+         *
+         * @return The total number of converted effect models.
+         */
+        unsigned int ConvertEffectModel();
+
+        /**
+         * Writes the list of the converted effect models to gamedata/effects.lua.
+         */
+        void WriteEffectModels();
+
+        /**
+         * Converts the textures of the effects and writes the sprite frame lists to
+         * gamedata/effect_sprites.lua. Called by {@see WriteEffectModels}.
+         */
+        void WriteEffectSprites();
+
         /**
          * Writes enemy data to files.
          */
@@ -426,6 +456,11 @@ class BattleDataInstaller{
          */
         std::string output_dir_;
 
+        /**
+         * Directory where the game data is installed (<install dir>/data/).
+         */
+        std::string data_dir_;
+
         /**
          * Pointer to the application resource manager.
          */
@@ -521,6 +556,42 @@ class BattleDataInstaller{
          */
         std::vector<std::string> magic_lgp_file_names_;
 
+        /**
+         * Folders for the files in {@see magic_lgp_files_}, empty for files in the archive root.
+         */
+        std::vector<std::string> magic_lgp_file_folders_;
+
+        /**
+         * A static mesh of a battle effect.
+         */
+        struct EffectModel{
+
+            /**
+             * Name of the model, unique: folder and name of the original rsd file.
+             */
+            std::string name;
+
+            /**
+             * Names of the textures, with the same prefix as the model.
+             */
+            std::vector<std::string> textures;
+
+            /**
+             * True if the model was converted.
+             */
+            bool converted = false;
+        };
+
+        /**
+         * The effect models.
+         */
+        std::vector<EffectModel> effect_models_;
+
+        /**
+         * The next effect model to convert.
+         */
+        size_t next_effect_to_convert_ = 0;
+
         /**
          * Next model to process;
          */

+ 8 - 0
src/installer/CMakeLists.txt

@@ -12,6 +12,13 @@ set(INSTALLER_SOURCE_FILES
     DiskImage.cpp
     FieldDataInstaller.cpp
     FieldTextWriter.cpp
+    InstallLayout.cpp
+    InstallLog.cpp
+    InstallOptions.cpp
+    ExternalTools.cpp
+    InstallVerifier.cpp
+    VideoFiles.cpp
+    MovieCamera.cpp
     KernelDataInstaller.cpp
     MainWindow.cpp
     MediaDataInstaller.cpp
@@ -123,6 +130,7 @@ endif()
 
 # Generate v-gears-installer executable.
 add_executable (v-gears-installer ${INSTALLER_SOURCE_FILES} ${UIS} ${MOCS} ${RCC_SOURCES})
+target_compile_definitions(v-gears-installer PRIVATE VGEARS_INSTALLER_VERSION="${PROJECT_VERSION}")
 SET_PROPERTY(TARGET v-gears-installer PROPERTY FOLDER "build/v-gears-installer")
 if (APPLE)
     target_link_libraries(v-gears-installer "-framework CoreFoundation -framework Cocoa -framework IOKit")

Vissa filer visades inte eftersom för många filer har ändrats