~fgaz/minetest-hide_and_seek

60b92762d5e0700ffe9b4297c9fc80c42054805d — Francesco Gazzetta 1 year, 1 month ago master
🌅
A  => .cdb.json +17 -0
@@ 1,17 @@
{
  "type": "MOD",
  "name": "hide_and_seek",
  "title": "Hide and Seek",
  "dev_state": "WIP",
  "tags": [
    "mini-game",
    "multiplayer"
  ],
  "license": "AGPL-3.0-or-later",
  "media_license": "CC-BY-SA-4.0",
  "repo": "https://git.sr.ht/~fgaz/minetest-hide_and_seek",
  "website": "https://sr.ht/~fgaz/minetest-hide_and_seek/",
  "issue_tracker": "https://todo.sr.ht/~fgaz/minetest-mods",
  "short_description": "Hide and Seek minigame using arena_lib",
  "long_description": "# Minetest Hide and Seek\n\nHide and Seek minigame in [Minetest](https://minetest.net)\nusing [arena_lib](https://content.minetest.net/packages/Zughy/arena_lib/)\n\n## Rules\n\nThe game is fairly simple: seekers (marked with a red skin) have to punch hiders\nwith their stick, and hiders have to avoid getting punched.\nThe game ends when all hiders are found, or after a timeout.\n\n## Scoring\n\n* Each player starts with 1 point\n* Seeker(s) gain 1 point for every hider they find\n  * Initial seekers always get a point even if the hider was found by someone else\n  * Infected seekers only get a point for hiders they personally found\n* Hiders gain a point every time another hider gets found\n  == hiders get increasing points depending on the order they get caught\n* Initial (not infected) seeker(s) gain 3 points if they catch every hider\n* If a hider makes it to the end, they gain 3 bonus points\n\n## Play styles\n\nDepending on how the arena is built, different play styles can emerge:\n\n* Big arena with hiding places, no nametags: hiders should try to find a good\n  hiding place, there will be more seeking, the game will be slower-paced.\n* Small parkourish arena, nametags: hiders should keep moving, there will be\n  more chasing, the game will be faster and dynamic.\n\n## License\n\n* Code is `AGPL-3.0-or-later`\n* Assets are `CC-BY-SA-4.0`\n\n## Credits\n\n* [arena_lib](https://content.minetest.net/packages/Zughy/arena_lib/)\n* [panel_lib](https://content.minetest.net/packages/Zughy/panel_lib/)\n* [MKWii HideNSeek](https://wiki.tockdom.com/wiki/Hide_and_Seek) for the point system.\n"
}

A  => .luacheckrc +70 -0
@@ 1,70 @@
local used_minetest_fields = {
  get_modpath = {},
  get_translator = {},
  register_tool = {},
  register_privilege = {},
  get_player_by_name = {},
  chat_send_player = {},
}

local used_arena_lib_fields = {
  register_minigame = {},
  create_arena = {},
  print_arena_info = {},
  print_arenas = {},
  rename_arena = {},
  enter_editor = {},
  set_sign = {},
  set_timer = {},
  change_arena_property = {},
  get_arena_by_player = {},
  remove_player_from_arena = {},
  HUD_send_msg = {},
  HUD_send_msg_all = {},
  on_load = {},
  on_start = {},
  on_timeout = {},
  on_celebration = {},
  on_end = {},
  load_celebration = {},
}

stds.minetest = {
  read_globals = {
    minetest = {
      fields = used_minetest_fields,
    },
    ItemStack = {},
	vector = {
	  fields = {
		zero = {},
	  },
	},
    declarative_chatcmd = {
      fields = {
        register_chatcommand = {},
      },
    },
    arena_lib = {
      fields = used_arena_lib_fields,
    },
    Panel = {
      fields = {
        new = {},
      },
    },
    enderpearl = {
      fields = {
        block_teleport = {},
      },
    },
  },
}

std = "luajit+minetest"

globals = {
  hide_and_seek = {
    other_fields = true,
  },
}

A  => Makefile +15 -0
@@ 1,15 @@
MODNAME=hide_and_seek

.PHONY: dist
dist: check
	git archive --format=zip --prefix=$(MODNAME)/ --output=minetest-$(MODNAME).zip HEAD

# We also check that the cdb file is up to date.
# It needs to be committed so that CDB can auto-import/update the mod
.PHONY: check
check:
	luacheck .
	jq --rawfile readme README.md '.long_description=$$readme' cdb-template.json | diff .cdb.json -

.cdb.json: cdb-template.json README.md
	jq --rawfile readme README.md '.long_description=$$readme' cdb-template.json > .cdb.json

A  => README.md +41 -0
@@ 1,41 @@
# Minetest Hide and Seek

Hide and Seek minigame in [Minetest](https://minetest.net)
using [arena_lib](https://content.minetest.net/packages/Zughy/arena_lib/)

## Rules

The game is fairly simple: seekers (marked with a red skin) have to punch hiders
with their stick, and hiders have to avoid getting punched.
The game ends when all hiders are found, or after a timeout.

## Scoring

* Each player starts with 1 point
* Seeker(s) gain 1 point for every hider they find
  * Initial seekers always get a point even if the hider was found by someone else
  * Infected seekers only get a point for hiders they personally found
* Hiders gain a point every time another hider gets found
  == hiders get increasing points depending on the order they get caught
* Initial (not infected) seeker(s) gain 3 points if they catch every hider
* If a hider makes it to the end, they gain 3 bonus points

## Play styles

Depending on how the arena is built, different play styles can emerge:

* Big arena with hiding places, no nametags: hiders should try to find a good
  hiding place, there will be more seeking, the game will be slower-paced.
* Small parkourish arena, nametags: hiders should keep moving, there will be
  more chasing, the game will be faster and dynamic.

## License

* Code is `AGPL-3.0-or-later`
* Assets are `CC-BY-SA-4.0`

## Credits

* [arena_lib](https://content.minetest.net/packages/Zughy/arena_lib/)
* [panel_lib](https://content.minetest.net/packages/Zughy/panel_lib/)
* [MKWii HideNSeek](https://wiki.tockdom.com/wiki/Hide_and_Seek) for the point system.

A  => cdb-template.json +12 -0
@@ 1,12 @@
{ "type": "MOD"
, "name": "hide_and_seek"
, "title": "Hide and Seek"
, "dev_state": "WIP"
, "tags": ["mini-game", "multiplayer"]
, "license": "AGPL-3.0-or-later"
, "media_license": "CC-BY-SA-4.0"
, "repo": "https://git.sr.ht/~fgaz/minetest-hide_and_seek"
, "website": "https://sr.ht/~fgaz/minetest-hide_and_seek/"
, "issue_tracker": "https://todo.sr.ht/~fgaz/minetest-mods"
, "short_description": "Hide and Seek minigame using arena_lib"
}

A  => commands.lua +79 -0
@@ 1,79 @@
local modname = "hide_and_seek"

local arg_arena = {
  name = "arena",
  description = "The name of the arena to affect",
}

local simple_arena_action = function (description, func) return {
  description = description,
  args = { arg_arena, },
  func = function(sender, arena_name)
    func(sender, modname, arena_name)
  end,
} end

declarative_chatcmd.register_chatcommand("hide_and_seek_admin", {
  description = "Manage Hide and Seek arenas",
  privs = { hide_and_seek_admin = true },
  subcommands = {
    create = simple_arena_action("Create a new arena", arena_lib.create_arena),
    remove = simple_arena_action("Remove an existing arena", arena_lib.print_arena_info),
    rename = {
      description = "Rename an existing arena",
      args = {
        arg_arena,
        {
          name = "new_name",
          description = "The new name to give the arena";
        },
      },
      func = function(sender, arena_name, new_name)
        arena_lib.rename_arena(sender, modname, arena_name, new_name)
      end
    },
    list = {
      description = "List existing arenas",
      func = function(sender)
        arena_lib.print_arenas(sender, modname)
      end,
    },
    info = simple_arena_action("Print information about an existing arena", arena_lib.print_arena_info),
    edit = simple_arena_action("Modify an existing arena", arena_lib.enter_editor),
    -- TODO pos is now mandatory
    setsign = simple_arena_action("Link a sign to an existing arena", arena_lib.set_sign),
    infection = {
      description = [[
        Enable or disable infection mode. When infection mode is enabled,
        found players become seekers instead of being eliminated.
      ]],
      args = {
        arg_arena,
        {
          name = "enabled",
          type = "boolean",
          description = "Whether to enable infection mode",
        },
      },
      func = function (sender, arena, enable)
        arena_lib.change_arena_property(sender, modname, arena, "infection", enable)
      end,
    },
    timer = {
      description = [[
        Change how long a match should last
      ]],
      args = {
        arg_arena,
        {
          name = "time",
          type = "number",
          description = "The duration in seconds",
        },
      },
      func = function (sender, arena, time)
        arena_lib.set_timer(sender, modname, arena, time)
      end,
    },
  },
})

A  => init.lua +150 -0
@@ 1,150 @@
local modname = "hide_and_seek"

dofile(minetest.get_modpath(modname) .. "/commands.lua")
local lib = dofile(minetest.get_modpath(modname) .. "/lib.lua")
local items = dofile(minetest.get_modpath(modname) .. "/items.lua")

minetest.register_privilege("hide_and_seek_admin")


arena_lib.register_minigame(modname, {
  name = "Hide and Seek",
  prefix = "[H&S] ",
  hotbar = { slots = 2 }, -- stick/arm and end pearl
  show_nametags = true, -- TODO depends on the arena shape
  time_mode = "decremental",
  -- MAYBE make these two configurable
  load_time = 10, -- Amount of time the seeker is locked in place
  celebration_time = 10,
  disabled_damage_types = {
    "set_hp", -- avoid damage from enderpearls
    "punch",
    "fall",
    "node_damage",
    "drown",
    "respawn",
  },
  disable_inventory = true,
  properties = {
    infection = false,
    seeker_pearls = 5,
    hider_pearls = 3,
    -- TODO:
    -- seeker location?
    -- setting to give speed to seekers?
    -- setting to make the first player that clicked the sign the seeker?
    -- initial seeker amount?
  },
  temp_properties = {
    started = false,
    -- [pl_name] = bool
    initial_seekers = {},
    -- [n] = {pl_name,score}
    final_scores = {},
  },
  player_properties = {
    seeker = false,
    initial_seeker = false,
    points = 1,
  },
})

arena_lib.on_load(modname, function(arena)
  local player_names = lib.table_keys(arena.players)
  local initial_seeker = player_names[math.random(1, #player_names)]
  for pl_name, pl_props in pairs(arena.players) do
    local player = minetest.get_player_by_name(pl_name)
    if pl_name == initial_seeker then
      pl_props.seeker = true
      pl_props.initial_seeker = true
      arena.initial_seekers[pl_name] = true
      lib.set_seeker_skin(player)
      pl_props.physics = player:get_physics_override()
      -- Lock the seeker in place
      player:set_physics_override()
      player:set_velocity(vector.zero())
      arena_lib.HUD_send_msg("title", pl_name, "You are a Seeker! Wait a bit...", 5, nil, 0xFF2222)
    else
      items.set_hider_inventory(player, arena)
      arena_lib.HUD_send_msg("title", pl_name, "You are a Hider! Run!", 5, nil, 0x22FF22)
    end
  end
end)

arena_lib.on_start(modname, function(arena)
  arena.started = true
  for pl_name, _ in pairs(arena.initial_seekers) do
    local player = minetest.get_player_by_name(pl_name)
    items.set_seeker_inventory(player, arena)
    player:set_physics_override(arena.players[pl_name].physics)
  end
  arena_lib.HUD_send_msg_all("title", arena, "Let the hunt begin!", 3, nil, 0xFF2222)
end)

arena_lib.on_timeout(modname, function(arena)
  -- Add final bonus
  for pl_name, pl_props in pairs(arena.players) do
    if not pl_props.seeker then
      pl_props.points = pl_props.points + 3
    end
  end
  arena_lib.load_celebration(modname, arena, nil)
end)

arena_lib.on_celebration(modname, function(arena)
  -- Add remaining players to final_scores
  for pl_name, pl_props in pairs(arena.players) do
    table.insert(arena.final_scores, {
      pl_name = pl_name,
      score = pl_props.points,
    })
  end

  table.sort(arena.final_scores, function(a, b) return a.score > b.score end)

  -- Create scoreboard
  local scores_panel_elems = {}
  local offset = 0
  for _, score_pair in pairs(arena.final_scores) do
    offset = offset + 16
    table.insert(scores_panel_elems, {
      text = score_pair.pl_name,
      alignment = {x = 0, y = 0},
      offset = {x = -64, y = offset},
    })
    table.insert(scores_panel_elems, {
      text = tostring(score_pair.score),
      alignment = {x = 0, y = 0},
      offset = {x = 64, y = offset},
    })
  end
  -- TODO send scoreboard to spectators too
  for pl_name, pl_props in pairs(arena.players) do
    local scoreboard = Panel:new(modname .. ":scoreboard", {
      player = pl_name,
      title = "Hide and Seek results",
      title_offset = {x = 0, y = -128},
      position = {x = 0.5, y  = 0.5},
      alignment = {x = 0, y = 0},
      bg_scale = {x = 24, y = 24},
      sub_txt_elems = scores_panel_elems,
    })
    pl_props.scoreboard = scoreboard
  end

  -- TODO sound
end)

arena_lib.on_end(modname, function(arena, players, winners, spectators, is_forced)
  -- TODO do most of this stuff in on_quit/on_eliminate too
  for pl_name, pl_props in pairs(players) do
    local player = minetest.get_player_by_name(pl_name)
    -- TODO remove scoreboard from spectators too
    if pl_props.scoreboard then pl_props.scoreboard:remove() end
    lib.reset_seeker_skin(minetest.get_player_by_name(pl_name))
    -- Prevent pearl shenanigans
    enderpearl.block_teleport(player, 10)
  end
end)

-- TODO on_quit, terminate the game if there are no more seekers/hiders (or assign a new seeker)

A  => items.lua +97 -0
@@ 1,97 @@
local modname = "hide_and_seek"
local items = {}

local lib = dofile(minetest.get_modpath(modname) .. "/lib.lua")

function items.set_seeker_inventory(player, arena)
    local sword = ItemStack(modname .. ":stick")
    local enderpearls = ItemStack("enderpearl:ender_pearl " .. arena.seeker_pearls)
    player:get_inventory():set_stack("main", 1, sword)
    player:get_inventory():set_stack("main", 2, enderpearls)
end
function items.set_hider_inventory(player, arena)
    local enderpearls = ItemStack("enderpearl:ender_pearl " .. arena.hider_pearls)
    player:get_inventory():set_stack("main", 2, enderpearls)
end

local function punch(itemstack, puncher, pointed_thing)
    if not puncher:is_player() then return end
    if pointed_thing.type ~= "object" then return end
    if not pointed_thing.ref:is_player() then return end
    local pointed_player = pointed_thing.ref
    local puncher_name = puncher:get_player_name()
    local pointed_name = pointed_player:get_player_name()
    local arena = arena_lib.get_arena_by_player(puncher_name)
    if not arena.started then return end
    -- Check that everything exists, in case players inside and outside manage to interact
    if not arena then return end
    if not arena.players[puncher_name] then return end
    if not arena.players[pointed_name] then return end
    -- Just to be sure (only seekers can have this tool anyway):
    if not arena.players[puncher_name].seeker then return end
    if arena.players[pointed_name].seeker then return end

    -- Now we know that an actual seeker-to-hider punch happened
    minetest.chat_send_player(puncher_name, "Found " .. pointed_name .. "!")
    minetest.chat_send_player(pointed_name, "Found by " .. puncher_name .. "!")

    -- TODO sound
    -- TODO chat announcement
    -- TODO hud with remaining hider number and timer

    -- Infect or eliminate
    if arena.infection then
        arena.players[pointed_name].seeker = true
        items.set_seeker_inventory(pointed_player, arena)
        lib.set_seeker_skin(pointed_player)
        arena_lib.HUD_send_msg("title", pointed_name, "You got infected!", 5, nil, 0xFF2222)
    else
        -- MAYBE move some of this to on_eliminate
        arena_lib.HUD_send_msg("title", pointed_name, "You got found!", 10, nil, 0xFF2222)
        table.insert(arena.final_scores, {
          pl_name = pointed_name,
          score = arena.players[pointed_name].points,
        })
        -- Prevent pearl shenanigans
        enderpearl.block_teleport(pointed_player, 10)
        arena_lib.remove_player_from_arena(pointed_player:get_player_name(), 1, puncher:get_player_name())
    end

    -- Give points.
    -- If the player was infected (not initial seeker), they will not receive
    -- the initial seeker point, so give the point now:
    if not arena.players[puncher_name].initial_seeker then
      arena.players[puncher_name].points = arena.players[puncher_name].points + 1
    end
    -- Give point to every initial seeker and to every not yet found hider:
    for _, pl_props in pairs(arena.players) do
      if pl_props.initial_seeker or not pl_props.seeker then
        pl_props.points = pl_props.points + 1
      end
    end

    -- Check for game end, give points and terminate
    local end_game = true
    for _, pl_props in pairs(arena.players) do
      if not pl_props.seeker then end_game = false; break end
    end
    if end_game then
      for pl_name, _ in pairs(arena.initial_seekers) do
        arena.players[pl_name].points = arena.players[pl_name].points + 3
      end
      arena_lib.load_celebration(modname, arena, nil)
    end
end

-- TODO better name and texture
minetest.register_tool(modname .. ":stick", {
    description = "Seeker's Stick\nHit players to eliminate or infect them",
    inventory_image = "hide_and_seek_stick.png",
    -- TODO 0 damage (even though it's already disabled in the arena props)
    on_use = punch,
    on_drop = function(itemstack, dropper, pos)
      -- Make it undroppable
    end,
})

return items

A  => lib.lua +29 -0
@@ 1,29 @@
local lib = {}

function lib.table_keys(t)
  local keys = {}
  for k,_ in pairs(t) do
    table.insert(keys, k)
  end
  return keys
end


function lib.set_seeker_skin(player)
  player:set_properties({
    textures = {
      player:get_properties().textures[1] ..
        "^[colorize:red:85",
    },
  })
end

function lib.reset_seeker_skin(player)
  player:set_properties({
    textures = {
      string.gsub(player:get_properties().textures[1], "%^%[colorize:red:85", ""),
    },
  })
end

return lib

A  => mod.conf +4 -0
@@ 1,4 @@
name = hide_and_seek
title = Hide and Seek
description = Hide and Seek minigame using arena_lib
depends = arena_lib, panel_lib, enderpearl, declarative_chatcmd

A  => shell.nix +10 -0
@@ 1,10 @@
{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  nativeBuildInputs = [
    pkgs.lua
    pkgs.luajit
    pkgs.luaPackages.luacheck
    pkgs.jq
  ];
}

A  => textures/hide_and_seek_stick.png +0 -0