瀏覽代碼

Battle spell effects from the original assets, floating numbers

Spell effects now come from the data in magic.lgp, extracted by the
installer and recreated in Lua, instead of generic particles.

Installer
- LGP archives read the folder table, so files with the same name in
  different folders (fire_1.p, thund_3.p, ice_1.p...) are no longer
  overwritten. FileEntry has a folder field.
- New install steps EFFECT_MODELS_INIT/CONVERT export effect meshes to
  models/battle/effects/<folder>_<name>.mesh, convert their textures,
  and write gamedata/effects.lua.
- Sprite sheets (.s files) are parsed into gamedata/effect_sprites.lua
  and their textures go to images/battle_fx.

Engine
- BattleManager: add_effect_model / remove_effect_model.
- Entity: set_scale, set_model_rotation, set_blend (normal, additive,
  alpha), set_alpha. EntityModel clones materials per entity and
  releases them on removal.
- UiWidget/UiSprite: image sub-regions and additive blending.
- UiTextArea::Render refreshes the layout when the widget changed
  after the UI update. Battle scripts run after it, so text that was
  moved or changed every frame was never drawn.

Battle (Lua)
- Effects by attack id (Regen, Barrier, MBarrier, Reflect, Wall, Slow,
  Stop, DeBarrier, Death) and by element (Fire, Ice, Bolt), plus Cure
  with the original sprites. The generic particles, flashes and caster
  sparks are removed; only the battle-start flash stays.
- Floating damage, heal, MP and status numbers over the targets.
- Fix: the party models were missing in battle after loading a save,
  because load_game did not export the party. It now calls
  export_party_members().

Sizes, timing and orientation of the effects are first guesses and
have been tested in game only for Fire, Ice, Bolt, Cure and the
numbers. Haste, summons and most status spells have no effect yet.
Iñigo Valentin 4 小時之前
父節點
當前提交
adc1e71f56

+ 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>

+ 1 - 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" />

+ 0 - 1
data/data/scripts/battle/battle_ui.lua

@@ -2448,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(

+ 2 - 0
data/data/scripts/save.lua

@@ -204,6 +204,8 @@ load_game = function(slot)
             Party[p] = nil
         end
     end
+    -- 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)

+ 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.
          */

+ 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.
          *

+ 16 - 0
src/core/EntityManager.cpp

@@ -445,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.");

+ 10 - 0
src/core/EntityManager.h

@@ -130,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.
          *

+ 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_;
 };
 

+ 12 - 1
src/core/ScriptManagerBinds.h

@@ -132,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,
@@ -170,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;
@@ -221,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,
@@ -552,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);

+ 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(

+ 4 - 0
src/core/UiWidget.cpp

@@ -583,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){

+ 21 - 0
src/core/UiWidget.h

@@ -544,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.
          *

+ 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.
              *

+ 261 - 1
src/installer/BattleDataInstaller.cpp

@@ -18,6 +18,10 @@
 
 #include <iostream>
 #include <fstream>
+#include <sstream>
+#include <map>
+#include <set>
+#include <cctype>
 #include <algorithm>
 #include <zlib.h>
 #include <tinyxml.h>
@@ -111,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");
@@ -124,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();
@@ -594,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;

+ 66 - 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.
          */
@@ -526,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;
          */

+ 15 - 1
src/installer/DataInstaller.cpp

@@ -44,6 +44,7 @@ DataInstaller::DataInstaller(
     step_weight_[IDLE] = 0;
     step_weight_[BATTLE_MODELS_CONVERT] = 2;
     step_weight_[SPELL_MODELS_CONVERT] = 2;
+    step_weight_[EFFECT_MODELS_CONVERT] = 2;
     step_weight_[MEDIA_IMAGES] = 3;
     step_weight_[MEDIA_SOUNDS] = 8;
     step_weight_[MEDIA_MUSICS] = 9;
@@ -204,7 +205,20 @@ float DataInstaller::Progress(){
             return CalcProgress();
         case SPELL_MODELS_CONVERT:
             cur_substep_ = battle_installer_->ConvertSpellModel();
-            if (cur_substep_ >= substeps_) installation_state_ = KERNEL_PRICES;
+            if (cur_substep_ >= substeps_) installation_state_ = EFFECT_MODELS_INIT;
+            return CalcProgress();
+        case EFFECT_MODELS_INIT:
+            write_output_line_("Extracting battle effects...", 2, true);
+            substeps_ = battle_installer_->InitializeEffectModels();
+            cur_substep_ = 0;
+            installation_state_ = EFFECT_MODELS_CONVERT;
+            return CalcProgress();
+        case EFFECT_MODELS_CONVERT:
+            cur_substep_ = battle_installer_->ConvertEffectModel();
+            if (cur_substep_ >= substeps_){
+                battle_installer_->WriteEffectModels();
+                installation_state_ = KERNEL_PRICES;
+            }
             return CalcProgress();
         case KERNEL_PRICES:
             // Skip kernel data if option is set.

+ 10 - 0
src/installer/DataInstaller.h

@@ -264,6 +264,16 @@ class DataInstaller{
              */
             SPELL_MODELS_CONVERT,
 
+            /**
+             * Extracts the meshes and textures of the battle effects.
+             */
+            EFFECT_MODELS_INIT,
+
+            /**
+             * Converts the battle effect meshes.
+             */
+            EFFECT_MODELS_CONVERT,
+
             /**
              * Parses item and materia prices from ff7.exe.
              */

+ 2 - 2
src/installer/InstallLayout.cpp

@@ -26,7 +26,7 @@ namespace InstallLayout{
           "audio/musics", "audio/sounds", "fields", "fonts", "gamedata", "gamedata/enemy", "gamedata/attack",
           "gamedata/formation", "images/icons", "images/other", "images/characters", "images/fonts", "images/reels",
           "images/window", "screens", "scripts", "scripts/battle_models", "system", "texts", "models/fields",
-          "models/battle/characters", "models/battle/scenes", "models/battle/enemies", "models/battle/attacks",
+          "models/battle/characters", "models/battle/scenes", "models/battle/enemies", "models/battle/attacks", "models/battle/effects",
           "models/world/terrain/0", "models/world/terrain/1", "models/world/terrain/2", "models/world/elements",
           "world/0", "world/1", "world/2", "wm"
         };
@@ -34,7 +34,7 @@ namespace InstallLayout{
     }
 
     const std::vector<std::string>& TempSubdirs(){
-        static const std::vector<std::string> dirs = {"char", "battle_models", "world_models", "spell_models", "wm"};
+        static const std::vector<std::string> dirs = {"char", "battle_models", "world_models", "spell_models", "effects", "wm"};
         return dirs;
     }