-- This feature adds an experience leveling system based on ForceControl.lua -- made by Linaori & SimonFlapse -- modified by Valansch, grilledham, RedRafe -- ======================================================= -- local config = require 'config'.experience local Color = require 'resources.color_presets' local Event = require 'utils.event' local ForceControl = require 'features.force_control' local Game = require 'utils.game' local Global = require 'utils.global' local Gui = require 'utils.gui' local math = require 'utils.math' local Retailer = require 'features.retailer' local RS = require 'map_gen.shared.redmew_surface' local ScoreTracker = require 'utils.score_tracker' local Settings = require 'utils.redmew_settings' local Toast = require 'features.gui.toast' local Utils = require 'utils.core' local Public = {} local add_experience = ForceControl.add_experience local add_experience_percentage = ForceControl.add_experience_percentage local disable_item = Retailer.disable_item local enable_item = Retailer.enable_item local get_force_data = ForceControl.get_force_data local remove_experience_percentage = ForceControl.remove_experience_percentage local set_item = Retailer.set_item local floor = math.floor local insert = table.insert local max = math.max local min = math.min local round_sig = math.round_sig local gain_xp_color = Color.light_sky_blue local lose_xp_color = Color.red local notify_name = 'notify_experience_level_up' local experience_lost_name = 'experience-lost' local on_bonuses, off_bonuses = '▼ Bonuses', '▲ Bonuses' local on_rewards, off_rewards = '▼ Rewards', '▲ Rewards' local force_sounds = {} local level_table = {} local gui_toggled = {} local mining_efficiency = { active_modifier = 0, research_modifier = 0, level_modifier = 0 } local inventory_slots = { active_modifier = 0, research_modifier = 0, level_modifier = 0 } local health_bonus = { active_modifier = 0, research_modifier = 0, level_modifier = 0 } local character_reach = { active_modifier = 0, research_modifier = 0, level_modifier = 0 } local crafting_speed = { active_modifier = 0, research_modifier = 0, level_modifier = 0 } local running_speed = { active_modifier = 0, research_modifier = 0, level_modifier = 0 } -- Prevents table lookup thousands of times local rock_big_xp = config.XP['big-rock'] local rock_huge_xp = config.XP['huge-rock'] Global.register( { mining_efficiency = mining_efficiency, inventory_slots = inventory_slots, health_bonus = health_bonus, force_sounds = force_sounds, gui_toggled = gui_toggled, level_table = level_table, character_reach = character_reach, crafting_speed = crafting_speed, running_speed = running_speed, }, function(tbl) mining_efficiency = tbl.mining_efficiency inventory_slots = tbl.inventory_slots health_bonus = tbl.health_bonus force_sounds = tbl.force_sounds gui_toggled = tbl.gui_toggled level_table = tbl.level_table character_reach = tbl.character_reach crafting_speed = tbl.crafting_speed running_speed = tbl.running_speed end ) Settings.register(notify_name, Settings.types.boolean, true, 'experience.notify_caption_short') ScoreTracker.register(experience_lost_name, { 'experience.score_experience_lost' }, '[img=item.artillery-targeting-remote]') local global_to_show = storage.config.score.global_to_show global_to_show[#global_to_show + 1] = experience_lost_name -- == HELPERS ============================================================== --[[ Given the parameters a: difficulty_scale b: xp_fine_tune c: first_lvl_xp The Level Up formula is defined as: Experience(L) = a•L^3 + b•L^2 + (c-a-b)•L + 1.15^(0.1•L) ]] ---A function to calculate level caps (When to level up) local level_up_formula = function(level_reached) local difficulty_scale = floor(config.difficulty_scale) local fine_tune = floor(config.xp_fine_tune) local start_value = floor(config.first_lvl_xp) local precision = floor(config.cost_precision) local function formula(L) local L2 = L * L local L3 = L * L2 return floor(difficulty_scale * L3 + fine_tune * L2 + (start_value - difficulty_scale - fine_tune) * L + 1.15 ^ (0.1 * L)) end local value = formula(level_reached + 1) local lower_value = formula(level_reached) return round_sig(value - lower_value, precision) end ---Get experience requirement for a given level ---Primarily used for the Experience GUI to display total experience required to unlock a specific item ---@param level number a number specifying the level ---@return number required total experience to reach supplied level local function calculate_level_xp(level) if level_table[level] == nil then local value if level == 1 then value = level_up_formula(level - 1) else value = level_up_formula(level - 1) + calculate_level_xp(level - 1) end insert(level_table, level, value) end return level_table[level] end ---Get a percentage of required experience between a level and the next level ---@param level number a number specifying the current level ---@return number a percentage of the required experience to level up from one level to the other local function percentage_of_level_req(level, percentage) return level_up_formula(level) * percentage end ---@param force LuaForce local function play_level_up_sound(force) if not config.sound then return end if not helpers.is_valid_sound_path(config.sound.path or '') then return end if force_sounds[force.index] and game.tick < force_sounds[force.index] then return end force_sounds[force.index] = game.tick + (config.sound.duration or 20 * 60) for _, player in pairs(force.connected_players) do if Settings.get(player.index, notify_name) then player.play_sound(config.sound) end end end ---@param parent LuaGuiElement ---@param caption string ---@param element string|table local function label_pair(parent, caption, element) local flow = parent.add { type = 'flow', direction = 'horizontal' } Gui.set_style(flow, { vertical_align = 'center' }) flow.add { type = 'label', style = 'semibold_caption_label', caption = caption .. ':' } return (type(element) == 'table') and flow.add(element) or flow.add { type = 'label', caption = element } end ---@param item table item data local function get_item_tooltip(item) return { '', '[font=var]Item: [/font]', {'?', {'entity-name.'..item.name}, {'item-name.'..item.name}, {'equipment-name.'..item.name} }, '\n[font=var]Price: [/font]'..item.price..' [img=item/coin]', '\n[font=var]Unlocked at: [/font]'..Utils.comma_value(calculate_level_xp(item.level))..' XP' } end ---@field buff table of buff config ---@field force LuaForce ---@field formula? function ---@field level_up? number ---@field modifier table of modifiers ---@field property string LuaForce::property to update local function update_modifier(params) local buff = params.buff local force = params.force local formula = params.formula or function(p) return p.buff.value end local level_up = params.level_up or 0 local modifier = params.modifier local property = params.property if not buff or force[property] >= (buff.max or math.huge) then return end if buff.level_interval and (level_up % buff.level_interval ~= 0) then return end if level_up > 0 then local value = formula(params) if buff.double_level and (level_up % buff.double_level) == 0 then value = 2 * value end modifier.level_modifier = modifier.level_modifier + value end -- remove the current buff local old_modifier = force[property] - modifier.active_modifier old_modifier = old_modifier >= 0 and old_modifier or 0 -- update the active modifier modifier.active_modifier = modifier.research_modifier + modifier.level_modifier -- add the new active modifier to the non-buffed modifier force[property] = old_modifier + modifier.active_modifier if buff.max then force[property] = min(buff.max, force[property]) end end -- == GUI ===================================================================== local main_frame_name = Gui.uid_name() local main_button_name = Gui.uid_name() local bonuses_button_name = Gui.uid_name() local rewards_list_button_name = Gui.uid_name() Public.update_main_frame = function(player) local frame = Gui.get_left_element(player, main_frame_name) if not frame or not frame.valid then return end local force_data = get_force_data('player') local data = Gui.get_data(frame) local current_level = force_data.current_level data.level.caption = current_level data.label.caption = Utils.comma_value(force_data.total_experience) data.label.tooltip = { 'experience.gui_total_xp', Utils.comma_value(force_data.total_experience) } data.progress.value = force_data.experience_percentage * 0.01 data.progress.tooltip = { 'experience.gui_progress_bar', floor(force_data.experience_percentage * 100) * 0.01 } data.bonus_mining_speed.caption = '+ '..(player.force.manual_mining_speed_modifier * 100)..'%' data.bonus_inventory_slot.caption = '+ '..player.force.character_inventory_slots_bonus data.bonus_health_bonus.caption = '+ '..player.force.character_health_bonus..'%' data.bonus_character_reach.caption = '+ '..(player.force.character_reach_distance_bonus) data.bonus_crafting_speed.caption = '+ '..(player.force.manual_crafting_speed_modifier * 100)..'%' data.bonus_running_speed.caption = '+ '..(player.force.character_running_speed_modifier * 100)..'%' for _, row in pairs(data.reward_list.children) do for _, item in pairs(row.children) do if item.tags and item.tags.level then item.style = (current_level >= item.tags.level) and 'green_slot' or 'yellow_slot_button' item.style.size = 32 end end end end Public.get_main_frame = function(player) local frame = Gui.get_left_element(player, main_frame_name) if frame and frame.valid then return Public.update_main_frame(player) end local data = {} frame = Gui.add_left_element(player, { name = main_frame_name, type = 'frame', direction = 'vertical' }) Gui.set_style(frame, { maximal_width = 360 }) local canvas = frame .add { type = 'flow', direction = 'vertical', style = 'vertical_flow' } .add { type = 'frame', direction = 'vertical', style = 'inside_shallow_frame_packed' } do -- Title local subheader = canvas.add { type = 'frame', style = 'subheader_frame' } Gui.set_style(subheader, { horizontally_stretchable = true }) local flow = subheader.add { type = 'flow', direction = 'horizontal' } local label = flow.add({ type = 'label', caption = 'Experience', style = 'frame_title' }) Gui.set_style(label, { left_margin = 4 }) end local sp = canvas.add { type = 'scroll-pane', vertical_scroll_policy = 'auto-and-reserve-space' } Gui.set_style(sp, { padding = 12, maximal_height = 600 }) do -- Level local content = sp.add { type = 'frame', direction = 'vertical', style = 'deep_frame_in_shallow_frame_for_description' } content.add { type = 'label', style = 'tooltip_heading_label_category', caption = '★ XP' } content.add { type = 'line', style = 'tooltip_category_line' } data.level = label_pair(content, 'Level', '---') data.label = label_pair(content, 'Total', '---') data.progress = label_pair(content, 'Progress', { type = 'progressbar' }) end do -- Bonuses local toggled = gui_toggled[player.index].bonuses local label = sp.add { type = 'label', style = 'bold_label', caption = toggled and on_bonuses or off_bonuses, name = bonuses_button_name, tooltip = 'Hide/Show bonuses' } local deep = sp.add { type = 'frame', direction = 'vertical', style = 'deep_frame_in_shallow_frame_for_description' } Gui.set_style(deep, { padding = 0, minimal_height = 4 }) local content = deep.add { type = 'flow', direction = 'vertical' } Gui.set_style(content, { padding = 8 }) content.visible = toggled Gui.set_data(label, { list = content }) local buffs = config.buffs content.add { type = 'label', style = 'tooltip_heading_label_category', caption = '✔ Bonuses' } content.add { type = 'line', style = 'tooltip_category_line' } data.bonus_mining_speed = label_pair(content, 'Manual mining speed', '---') data.bonus_inventory_slot = label_pair(content, 'Inventory slots', '---') data.bonus_health_bonus = label_pair(content, 'Max health', '---') data.bonus_character_reach = label_pair(content, 'Character reach', '---') data.bonus_crafting_speed = label_pair(content, 'Crafting speed', '---') data.bonus_running_speed = label_pair(content, 'Running speed', '---') data.bonus_mining_speed.tooltip = { 'experience.gui_buff_mining', buffs.mining_speed.value, buffs.mining_speed.max * 100 } data.bonus_inventory_slot.tooltip = { 'experience.gui_buff_inv', buffs.inventory_slot.value, buffs.inventory_slot.max } data.bonus_health_bonus.tooltip = { 'experience.gui_buff_health', buffs.health_bonus.value, buffs.health_bonus.max } data.bonus_character_reach.tooltip = { 'experience.gui_buff_character_reach', buffs.character_reach.value, buffs.character_reach.max } data.bonus_crafting_speed.tooltip = { 'experience.gui_buff_crafting_speed', buffs.crafting_speed.value * 100, buffs.crafting_speed.max * 100 } data.bonus_running_speed.tooltip = { 'experience.gui_buff_running_speed', buffs.running_speed.value * 100, buffs.running_speed.max * 100 } end do -- Rewards local toggled = gui_toggled[player.index].rewards local label = sp.add { type = 'label', style = 'bold_label', caption = toggled and on_rewards or off_rewards, name = rewards_list_button_name, tooltip = 'Hide/Show rewards' } local deep = sp.add { type = 'frame', style = 'deep_frame_in_shallow_frame_for_description', direction = 'vertical' } Gui.set_style(deep, { padding = 0, minimal_height = 4 }) local last = {} local list = deep .add { type = 'scroll-pane', vertical_scroll_policy = 'dont-show-but-allow-scrolling' } .add { type = 'table', style = 'table_with_selection', column_count = 2 } list.visible = toggled Gui.set_data(label, { list = list }) for _, item in pairs(config.unlockables) do if item.level ~= last.level then local row = list.add { type = 'flow', direction = 'horizontal' } Gui.set_style(row, { vertical_align = 'center' }) local level = row.add { type = 'sprite-button', caption = item.level, style = 'transparent_slot', ignored_by_interaction = true } Gui.set_style(level, { size = 24, font_color = { 255, 255, 255 } }) Gui.add_pusher(row) last.row = row end local button = last.row.add { type = 'sprite-button', sprite = 'item.'..item.name, number = item.price, style = 'slot_button', tooltip = get_item_tooltip(item), tags = { level = item.level } } Gui.set_style(button, { size = 32 }) last.level = item.level end data.reward_list = list end Gui.set_data(frame, data) Public.update_main_frame(player) end Public.toggle_main_button = function(player) local frame = Gui.get_left_element(player, main_frame_name) local button = Gui.get_top_element(player, main_button_name) if frame then button.toggled = false Gui.destroy(frame) else button.toggled = true Public.get_main_frame(player) end end ---Toggle the player's experience main window, required for Cutscene controller module Public.toggle = function(event) Public.toggle_main_button(event.player) end Gui.allow_player_to_toggle_top_element_visibility(main_button_name) Gui.on_click(main_button_name, Public.toggle) Gui.on_custom_close(main_frame_name, Public.toggle) Gui.on_click(bonuses_button_name, function(event) local list = Gui.get_data(event.element).list list.visible = not list.visible event.element.caption = list.visible and on_bonuses or off_bonuses gui_toggled[event.player_index].bonuses = list.visible end) Gui.on_click(rewards_list_button_name, function(event) local list = Gui.get_data(event.element).list list.visible = not list.visible event.element.caption = list.visible and on_rewards or off_rewards gui_toggled[event.player_index].rewards = list.visible end) -- == EVENTS ================================================================== ---Awards experience when a rock has been mined (increases by 1 XP every 5th level) local function on_player_mined_entity(event) local entity = event.entity local name = entity.name local player_index = event.player_index local force = game.get_player(player_index).force local level = get_force_data(force).current_level local exp = 0 if name == 'big-rock' then exp = rock_big_xp + floor(level / 5) elseif name == 'huge-rock' then exp = rock_huge_xp + floor(level / 5) end if exp == 0 then return end local text = { '', '[img=entity/' .. name .. '] ', { 'experience.float_xp_gained_mine', exp } } local player = game.get_player(player_index) player.create_local_flying_text { text = text, color = gain_xp_color, position = player.position } add_experience(force, exp) end ---Awards experience when a research has finished, based on ingredient cost of research local function on_research_finished(event) local research = event.research local force = research.force local exp if research.research_unit_count_formula ~= nil then local force_data = get_force_data(force) exp = percentage_of_level_req(force_data.current_level, config.XP['infinity-research']) else local award_xp = 0 for _, ingredient in pairs(research.research_unit_ingredients) do local name = ingredient.name local reward = config.XP[name] award_xp = award_xp + reward end exp = award_xp * research.research_unit_count end local text = { '', '[img=item/automation-science-pack] ', { 'experience.float_xp_gained_research', exp } } Game.create_local_flying_text { surface = RS.get_surface_name(), text = text, color = gain_xp_color, create_at_cursor = true } add_experience(force, exp) local current_modifier = mining_efficiency.research_modifier local new_modifier = force.mining_drill_productivity_bonus * config.mining_speed_productivity_multiplier * 0.5 if (current_modifier == new_modifier) then -- something else was researched return end mining_efficiency.research_modifier = new_modifier inventory_slots.research_modifier = force.mining_drill_productivity_bonus * 50 -- 1 per level Public.update_inventory_slots(force, 0) Public.update_mining_speed(force, 0) Public.update_health_bonus(force, 0) Public.update_character_reach_bonus(force, 0) Public.update_crafting_speed_bonus(force, 0) Public.update_running_speed_bonus(force, 0) end ---Awards experience when a rocket has been launched based on percentage of total experience local function on_rocket_launched(event) local force = event.rocket.force local silo_surface = event.rocket_silo and event.rocket_silo.surface local exp = add_experience_percentage(force, config.XP['rocket_launch'], nil, config.XP['rocket_launch_max']) local text = { '', '[img=item/satellite] ', { 'experience.float_xp_gained_rocket', exp } } Game.create_local_flying_text { surface = silo_surface and silo_surface.index, text = text, color = gain_xp_color, create_at_cursor = true } end ---Awards experience when a player kills an enemy, based on type of enemy local function on_entity_died(event) local entity = event.entity local force = event.force local cause = event.cause local entity_name = entity.name -- For bot mining and turrets if not cause or not cause.valid or cause.type ~= 'character' then local exp = 0 local floating_text_position -- stuff killed by the player force, but not the player if force and force.name == 'player' then if cause and (cause.name == 'artillery-turret' or cause.name == 'gun-turret' or cause.name == 'laser-turret' or cause.name == 'flamethrower-turret') then exp = config.XP['enemy_killed'] * (config.alien_experience_modifiers[entity_name] or 1) floating_text_position = cause.position else local level = get_force_data(force).current_level if entity_name == 'big-rock' then exp = floor((rock_big_xp + level * 0.2) * 0.5) elseif entity_name == 'huge-rock' then exp = floor((rock_huge_xp + level * 0.2) * 0.5) end floating_text_position = entity.position end end if exp > 0 then Game.create_local_flying_text({ surface = entity.surface, position = floating_text_position, text = { '', '[img=entity/' .. entity_name .. '] ', { 'experience.float_xp_gained_kill', exp } }, color = gain_xp_color, }) add_experience(force, exp) end return end if entity.force.name ~= 'enemy' then return end local exp = config.XP['enemy_killed'] * (config.alien_experience_modifiers[entity.name] or 1) cause.player.create_local_flying_text { position = cause.player.position, text = { '', '[img=entity/' .. entity_name .. '] ', { 'experience.float_xp_gained_kill', exp } }, color = gain_xp_color, } add_experience(force, exp) end ---Deducts experience when a player respawns, based on a percentage of total experience local function on_player_respawned(event) local player = game.get_player(event.player_index) local exp = remove_experience_percentage(player.force, config.XP['death-penalty'], 50) local text = { '', '[img=entity.character]', { 'experience.float_xp_drain', exp } } game.print({ 'experience.player_drained_xp', player.name, exp }, { color = lose_xp_color }) Game.create_local_flying_text { surface = player.surface, text = text, color = lose_xp_color, create_at_cursor = true } ScoreTracker.change_for_global(experience_lost_name, exp) end local function on_player_created(event) local player = game.get_player(event.player_index) if not (player and player.valid) then return end Gui.add_top_element(player, { name = main_button_name, type = 'sprite-button', sprite = 'entity/market', tooltip = { 'experience.gui_experience_button_tip' }, }) gui_toggled[player.index] = { bonuses = true, rewards = false, } end ---Updates the experience progress gui for every player that has it open local function on_nth_tick() for _, player in pairs(game.connected_players) do Public.update_main_frame(player) end -- Resets buffs if they have been set to 0 local force = game.forces.player Public.update_inventory_slots(force, 0) Public.update_mining_speed(force, 0) Public.update_health_bonus(force, 0) Public.update_character_reach_bonus(force, 0) Public.update_crafting_speed_bonus(force, 0) Public.update_running_speed_bonus(force, 0) end local function on_init() -- Adds the 'player' force to participate in the force control system. local force = game.forces.player ForceControl.register_force(force) table.sort(config.unlockables, function(a, b) return a.level < b.level end) local force_name = force.name for _, prototype in pairs(config.unlockables) do set_item(force_name, prototype) end Public.update_market_contents(force) end ---A function that will be executed at every level up local function on_level_reached(level_reached, force) Toast.toast_force(force, 10, { 'experience.toast_new_level', level_reached }) Public.update_inventory_slots(force, level_reached) Public.update_mining_speed(force, level_reached) Public.update_health_bonus(force, level_reached) Public.update_character_reach_bonus(force, level_reached) Public.update_crafting_speed_bonus(force, level_reached) Public.update_running_speed_bonus(force, level_reached) Public.update_market_contents(force) play_level_up_sound(force) end -- == EXPERIENCE ============================================================== ---Updates the market contents based on the current level. ---@param force LuaForce the force which the unlocking requirement should be based of Public.update_market_contents = function(force) local current_level = get_force_data(force).current_level local force_name = force.name for _, prototype in pairs(config.unlockables) do local prototype_level = prototype.level if current_level < prototype_level then disable_item(force_name, prototype.name, { 'experience.market_disabled', prototype_level }) else enable_item(force_name, prototype.name) end end end ---Updates a forces manual mining speed modifier. By removing active modifiers and re-adding ---@param force LuaForce the force of which will be updated ---@param level_up? number a level if updating as part of a level up Public.update_mining_speed = function(force, level_up) update_modifier{ buff = config.buffs['mining_speed'], force = force, formula = function(params) local level = get_force_data(params.force).current_level return 0.01 * floor(max(params.buff.value, 24 * 0.9 ^ (level ^ 0.5))) end, level_up = level_up, modifier = mining_efficiency, property = 'manual_mining_speed_modifier', } end ---Updates a forces inventory slots. By removing active modifiers and re-adding ---@param force LuaForce the force of which will be updated ---@param level_up? number a level if updating as part of a level up Public.update_inventory_slots = function(force, level_up) update_modifier{ buff = config.buffs['inventory_slot'], force = force, level_up = level_up, modifier = inventory_slots, property = 'character_inventory_slots_bonus', } end ---Updates a forces health bonus. By removing active modifiers and re-adding ---@param force LuaForce the force of which will be updated ---@param level_up? number a level if updating as part of a level up Public.update_health_bonus = function(force, level_up) update_modifier{ buff = config.buffs['health_bonus'], force = force, level_up = level_up, modifier = health_bonus, property = 'character_health_bonus', } end ---Updates a forces reach bonus. By removing active modifiers and re-adding ---@param force LuaForce the force of which will be updated ---@param level_up? number a level if updating as part of a level up Public.update_character_reach_bonus = function(force, level_up) update_modifier{ buff = config.buffs['character_reach'], force = force, level_up = level_up, modifier = character_reach, property = 'character_reach_distance_bonus', } for _, reach_property in pairs({ 'character_build_distance_bonus', 'character_item_drop_distance_bonus', 'character_reach_distance_bonus', 'character_resource_reach_distance_bonus', 'character_item_pickup_distance_bonus' }) do force[reach_property] = force.character_reach_distance_bonus end end ---Updates a forces crafting speed bonus. By removing active modifiers and re-adding ---@param force LuaForce the force of which will be updated ---@param level_up? number a level if updating as part of a level up Public.update_crafting_speed_bonus = function(force, level_up) update_modifier{ buff = config.buffs['crafting_speed'], force = force, level_up = level_up, modifier = crafting_speed, property = 'manual_crafting_speed_modifier', } end ---Updates a forces running speed bonus. By removing active modifiers and re-adding ---@param force LuaForce the force of which will be updated ---@param level_up? number a level if updating as part of a level up Public.update_running_speed_bonus = function(force, level_up) update_modifier{ buff = config.buffs['running_speed'], force = force, level_up = level_up, modifier = running_speed, property = 'character_running_speed_modifier', } end -- ============================================================================ Event.on_init(on_init) Event.add(defines.events.on_player_mined_entity, on_player_mined_entity) Event.add(defines.events.on_research_finished, on_research_finished) Event.add(defines.events.on_rocket_launched, on_rocket_launched) Event.add(defines.events.on_player_respawned, on_player_respawned) Event.add(defines.events.on_entity_died, on_entity_died) Event.add(defines.events.on_player_created, on_player_created) Event.on_nth_tick(61, on_nth_tick) local ForceControlBuilder = ForceControl.register(level_up_formula) ForceControlBuilder.register_on_every_level(on_level_reached) return Public