Have a 2nd hero move, attack and be attacked

Started by gzuk, September 08, 2022, 10:04:02 AM

Previous topic - Next topic
Hi, I'm trying to implement a 2nd hero for co-op gaming. It's just for laughs, and I know the engine isn't really made for that. So far, I only managed to have a custom entity walk around with the WASD keys, and spawn projectiles with the Ctrl key. However, I can only seem to make the projectiles traverse enemies, or disappear when they hit one.

How can I make the spawned projectiles hurt the enemies, as projectiles from the hero would?

Is there a way to have the enemies and enemy projectiles hurt the custom entity, like they would with the hero?

Since the 2nd hero should behave pretty much like the 1st hero, only with other keys, is there any Lua script for the hero I could copy from? Or is it all in the C++ files?

Many thanks for some hints!  :)

This tutorial covers attacking enemies with a custom entity (some fire in this example, but for any projectile this can apply): https://www.youtube.com/watch?v=rFDILBbVHHQ
The next version of Solarus will support multiple heroes, so maybe you can consider switching to the development version. We'd be happy to hear your feedback by the way because this is still experimental!

Thanks for the link to the video, I got the projectile attack working with the sample code. (I had failed before with passing a reference to an external function as a callback, but only the inline function from the video worked. I probably need to read up on general Lua syntax too.)

I am actually working with a Quest Editor compiled from the current "dev" branch. Does that one already contain the support for multiple heroes somewhere? If so, what files should I look at to understand the syntax? Or is it in some special feature branch?

Sounds exciting in any case. I'm not even aware of any commercial Zelda-type ARPG that has this feature.

Oooh, I found it: The maps with multiple heroes are in tests/testing_quest/data/maps/multiplayer. Even with splitscreen. Truly impressive!  :D

September 12, 2022, 09:51:37 AM #4 Last Edit: September 12, 2022, 10:31:00 AM by gzuk
I had implemented the 2nd hero as a custom entity, and now changed this to an actual hero entity, created with map:create_hero. However, this seems to break the key binding of "pause" to "p", with game:set_command_keyboard_binding("pause", "p"), which did not break with the custom entity. This means that the "d" key is now mapped to pause, as well as to the hero movement.

I'll post my script here in case anyone knows how to debug this. The code probably has plenty of other problems, since I've never programmed Lua before. The WASD control is now implemented like the mouse_control script. But perhaps it would be better to have it run as an entity script, as with enemies or NPCs? Only there's no GUI button to link it yet.

To make this script work, you have to include it in "features.lua", and then place a custom entity on the map and name it "wasd_hero". To see it work as a custom entity without the bug, you have to comment out lines 43-48.


require("scripts/multi_events")

-- This replaces an entity named "wasd_hero" on the map
-- with a 2nd hero using a goblin sprite, who can be
-- steered with the WASD keys and throw axes with left Ctrl.
--
-- FIXME: When pressing the WASD keys to steer the 2nd hero,
-- the "d" key is again mapped to "pause", which did not happen
-- when the hero is a custom entity.
local function initialize_wasd_control_features(game)

  game:set_command_keyboard_binding("pause", "p")

  local hero2
  local wasd_control = {}
  local movement = sol.movement.create("straight")
  local current_direction = 3
  local current_angle = 3 * math.pi / 2
  local is_key_pushed = false
  print("WASD control startup")

  -- Movement of hero 2.
  local directions = {
      right = false,
      up = false,
      left = false,
      down = false,
      attack = false
  }
 
  -- Replace entity "wasd_hero" with a 2nd hero
  -- using the goblin green sprite.
  -- FIXME: should be called once on map creation
  function wasd_control:set_hero2()
    if hero2 ~= nil then
      return
    end
    map = game:get_map()
    if map:has_entity("wasd_hero") then
      hero2 = map:get_entity("wasd_hero")
      -- FIXME: Comment out these 6 lines to play as custom entity. This fixes
      -- the "pause" bug, but it's not a hero anymore.
      local x, y, z = hero2:get_position()
      hero2:remove()
      hero2 = map:create_hero {x = x, y = y, layer = z}
      hero2:set_name("wasd_hero")
      hero2:remove_sprite(hero2:get_sprite("tunic"))
      hero2:create_sprite("enemies/goblin_green", "tunic")
    end
  end

  -- detect pressed WASD keys
  function wasd_control:on_key_pressed(key, modifiers)
    if key == "w" then
      directions.up = true
    end
    if key == "a" then
      directions.left = true
    end
    if key == "s" then
      directions.down = true
    end
    if key == "d" then
      directions.right = true
    end
    if key == "left control" then
      directions.attack = true
    end
    self:update_movement()
  end

  -- detect released WASD keys
  function wasd_control:on_key_released(key)
    if key == "w" then
      directions.up = false
    end
    if key == "a" then
      directions.left = false
    end
    if key == "s" then
      directions.down = false
    end
    if key == "d" then
      directions.right = false
    end
    if key == "left control" then
      directions.attack = false
    end
    self:update_movement()
  end

  -- calc direction for sprite:
  -- right = 0, up = 1, left = 2, down = 3
  function wasd_control:calc_direction()
    if directions.right then
      current_direction = 0
    elseif directions.left then
      current_direction = 2
    elseif directions.up then
      current_direction = 1
    elseif directions.down then
      current_direction = 3
    else
      -- direction stays as it is
    end
    return current_direction
  end

  -- cals angle
  function wasd_control:calc_angle()
    if directions.right and directions.up then
      current_angle = 1 * math.pi / 4
    elseif directions.right and directions.down then
      current_angle = 7 * math.pi / 4
    elseif directions.left and directions.up then
      current_angle = 3 * math.pi / 4
    elseif directions.left and directions.down then
      current_angle = 5 * math.pi / 4
    elseif directions.right then
      current_angle = 0 * math.pi / 2
    elseif directions.up then
      current_angle = 1 * math.pi / 2
    elseif directions.left then
      current_angle = 2 * math.pi / 2
    elseif directions.down then
      current_angle = 3 * math.pi / 2
    else
      -- angle stays as it is
    end
    return current_angle
  end

  -- loop over hero2 sprite and set animation
  function wasd_control:set_hero2_anim(anim_name)
    for _, s in hero2:get_sprites() do
      if s:has_animation(anim_name) then s:set_animation(anim_name) end
    end
  end

  -- update movement for the hero, and spawn axes when attacking
  function wasd_control:update_movement()
    local map = game:get_map()
    self:set_hero2()
    if (map == nil or movement == nil or hero2 == nil) then
      return
    end
   
    hero2:set_direction(self:calc_direction())
    if (hero2:get_sprite("tunic") ~= nil) then
      hero2:get_sprite("tunic"):set_direction(self:calc_direction())
    end
   
    -- when attacking, throw goblin axes
    if directions.attack then
      local proj_sprite_id = "enemies/goblin_axe"
      local x, y, layer = hero2:get_position()
      local prop = {x=x, y=y, layer=layer, direction=self:calc_direction(), width=16, height=16}
      local projectile = map:create_custom_entity(prop)
      local proj_sprite = projectile:create_sprite(proj_sprite_id)
      proj_sprite:set_animation("thrown")
      projectile:stop_movement()
      local m = sol.movement.create("straight")
      m:set_angle(self:calc_angle())
      m:set_speed(100)
      m:set_max_distance(300)
      m:start(projectile)
      projectile:set_can_traverse("enemy", false)
      function projectile:on_obstacle_reached() projectile:remove() end
      function projectile:on_movement_finished() projectile:remove() end
      -- FIXME: collision not always detected, perhaps due to on_obstacle_reached
      projectile:add_collision_test("overlapping", function(projectile, other)
        if other:get_type() == "enemy" then
          other:hurt(1)
          projectile:remove()
        end
      end)
    end

    if not directions.right and not directions.left and not directions.up and not directions.down then
      movement:stop()
      hero2:stop_movement()
      self:set_hero2_anim("stopped")
      return
    end
   
    self:set_hero2_anim("walking")
    movement:set_speed(90)
    movement:set_angle(self:calc_angle())
    movement:start(hero2)   
  end
 
  sol.menu.start(game, wasd_control)
end

local game_meta = sol.main.get_metatable("game")
game_meta:register_event("on_started", initialize_wasd_control_features)
return true

September 12, 2022, 11:51:15 AM #5 Last Edit: September 12, 2022, 11:57:38 AM by stdgregwar
Hi !

The multiple heroes stuff comes with multiple control sets features as well.
This means that  the game:set_command_keyboard_binding only affects the default key bindings but there are now more of them. When you create the second hero it comes with his own set of controls that you can get and modify with hero:get_controls() as you can see here : https://gitlab.com/solarus-games/solarus/-/blob/dev/tests/testing_quest/data/maps/multiplayer/heroes.lua#L87

I guess that what is happening is that the D key is default mapping for the fresh controls of the second hero. So you must get them and change them.

Of course once your second hero is a real hero. Most of the control script you have here is kind of obsolete. Just settings the key bindings for the second hero controls will grant you the exact same moving scheme without having to manually do the direction / animations. On top of that the second hero will be able to take teletransporter if he has a camera attached.

Feel free to ask more questions. It is a shame that documentation can't be published (because it was written) but we have a doc rewrite pending for to many years now...

Have fun with your multiplayer tests !

Greg

EDIT:
You can find the name of the methods of the "controls" objects here :
https://gitlab.com/solarus-games/solarus/-/blob/dev/src/lua/ControlsApi.cpp#L29

They work similar to the legacy methods of the game object.

Ah, thanks. hero2:get_controls():set_keyboard_binding("pause", "") (to empty string) did the trick to remove the "d" binding also for the 2nd hero. Interestingly, if you instead reassign "pause" to "p" for both heroes, then it breaks the pause, i.e. the game doesn't pause anymore. I'll have a look at the rest of the new controls API and try to remove all of the now obsolete entity code...

I'd be grateful if you could give me a suggestion as to where custom hero code belongs, if an entire game was to be written for 2 heroes coop. In the testing_quest, it's in the map code. Where should it go if it's used on all maps?

September 12, 2022, 02:53:17 PM #7 Last Edit: September 12, 2022, 03:01:57 PM by stdgregwar
I think that in the case where the whole game is coop, you want the code spawning and setting up the new hero in something like game:on_started or another function that gets called early when game is reloaded.

Pay attention to the fact that other heroes than the default one do not get their inventory/equipement saved automatically by the engine. It is up to you to decide how additional heroes equipement is setted up from custom variables or replication of the default hero stuff. Up to you. It is also true for getting items from chest. The items giving  abilities to heroes should check which hero opened them and/or give ability to everyone. It is up to the quest maker to make this work, engine cannot assume a default behaviour here I guess.

EDIT :

You also need to handle map transition kind of yourself in this case. If you have a single camera and multiple heroes you need to teleport the heroes without cam when changing map. If you were to do map transitions like in four swords, for example, you would need to have a custom entity for side transition that waits for all hero to push on the edge of the map before teletransporting everyone. Engines only gives building blocks for multiplayer and the rest is still up to you.

September 12, 2022, 05:41:07 PM #8 Last Edit: September 14, 2022, 10:45:32 PM by gzuk
Thanks for the tips. But so far I'm still having trouble to even get the single-map usecase to work.

How or where would I override or append to game:on_started? Is there an example? I also found I need special behavior when starting each map or multimap area, for spawning the 2nd hero with the map functions. Is there maybe a way to override map:on_started for all maps?

When I tried to register "on_started" on sol.main.get_metatable("map"), it was never called. When I tried register "on_started" on sol.main.get_metatable("game"), the returned hero from create_hero was nil. That using the map metatable with "on_started" doesn't work has apparently been reported by other users. The reason may be that the map already has the function, and the docs say that the instance field always has priority. However, I haven't found any other hook in the map startup that I could use.


local game_meta = sol.main.get_metatable("game")
game_meta:register_event("on_started", function(game)
  local x, y, z = game:get_hero():get_position()
  local hero2 = game:get_map():create_hero {x = x, y = y, layer = z}
  print("This is called without error, but hero2 is nil here, and doesn't show: ", hero2)
end)

local map_meta = sol.main.get_metatable("map")
map_meta:register_event("on_started", function()
  print("This is never called.")
end)

Ah, I got it working by registering "on_map_changed" for the game metatable. If added to main.lua on the current develop branch, the following script will on every entered map spawn a 2nd hero in blue next to the 1st one, controlled with the WASD keys. They both hurt and are hurt by enemies, and can coop-kill them together. Neat. There's still plenty of stuff to add, but I'll ask that in other posts.

A big thanks to all devs for the 2nd hero addition, and everything else!  :)


local game_meta = sol.main.get_metatable("game")
game_meta:register_event("on_map_changed", function()
  local map = sol.main.get_game():get_map()
  local hero1 = map:get_hero()
  -- Adjust controls for hero 1.
  hero1:get_controls():set_keyboard_binding("pause", "p")
  hero1:get_controls():set_keyboard_binding("attack", "right control")
  local x, y, layer = hero1:get_position()
  hero1:set_position(x + 8, y, layer)
  -- Create hero 2 next to hero 1, with WASD controls.
  local hero2 = map:create_hero({x = x - 8, y = y, layer = layer})
  hero2:set_name("hero2")
  hero2:get_controls():set_keyboard_binding("pause", "")
  hero2:get_controls():set_keyboard_binding("up", "w")
  hero2:get_controls():set_keyboard_binding("left", "a")
  hero2:get_controls():set_keyboard_binding("down", "s")
  hero2:get_controls():set_keyboard_binding("right", "d")
  hero2:get_controls():set_keyboard_binding("attack", "left alt")
  hero2:set_tunic_sprite_id("hero/tunic2")
  -- Give them swords and extra life for testing.
  hero1:set_ability("sword", 1)
  hero2:set_ability("sword", 1)
  hero1:set_max_life(16)
  hero1:set_life(16)
  hero2:set_max_life(16)
  hero2:set_life(16)
end)