diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8c6d6f8..e259703 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,21 @@ jobs: pxluanova/pxluanova-test/build/reports/tests/ pxluanova/pxluanova-test/build/test-results/ + tutorial: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install Lua 5.2 and lua-language-server + run: | + sudo apt-get update && sudo apt-get install -y lua5.2 + mkdir -p "$RUNNER_TEMP/luals" + curl -sSL https://github.com/LuaLS/lua-language-server/releases/download/3.13.9/lua-language-server-3.13.9-linux-x64.tar.gz \ + | tar xz -C "$RUNNER_TEMP/luals" + + - name: Check the arena tutorial + run: LUALS="$RUNNER_TEMP/luals/bin/lua-language-server" site/tutorial/test.sh + build: runs-on: ubuntu-latest strategy: diff --git a/AGENTS.md b/AGENTS.md index a20401c..161b936 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,12 @@ JUnit 5 via `kotlin-test-junit5`. Pure logic, no MC runtime. Two tests have quir Loaded libs, `package.path`, globals, lambda syntax, scheduler tick, built-in `require` libs (`format`, `simple`, `chestgui`). +## Tutorial + +`site/src/content/docs/tutorial/` is a step-by-step arena minigame; its code is in `site/tutorial/arena/` +(one checkpoint file per chapter) and is checked by `site/tutorial/test.sh` (snippets match checkpoints, +LuaLS against `lua-types/`, a fake-API game run). API changes that touch it must keep that script green. + ## Design docs → see `docs/` Changelog (`site/src/content/docs/changelog.md`) says WHAT changed; `docs/` (e.g. `async-suspend-bridge.md`) documents diff --git a/lua-types/01-vec.lua b/lua-types/01-vec.lua index 926517d..d401a85 100644 --- a/lua-types/01-vec.lua +++ b/lua-types/01-vec.lua @@ -21,10 +21,7 @@ local Vec = {} ---@operator div(number): Vec ---Negation. ---@operator unm: Vec ----Component-wise equality. ----@operator eq(Vec|Vec3Like): boolean ----Renders as `"(x, y, z)"`. ----@operator tostring: string +-- `==` compares components; `tostring(v)` renders as `"(x, y, z)"`. ---Returns sqrt(x² + y² + z²). ---@return number diff --git a/lua-types/11-region.lua b/lua-types/11-region.lua index 5249348..c67fc19 100644 --- a/lua-types/11-region.lua +++ b/lua-types/11-region.lua @@ -27,7 +27,8 @@ Region.entities = nil ---@param callback fun(e: RegionEvent) ---@param opts? EventOptions ---@return integer ----@overload fun(self: Region, event: '"enter"'|'"leave"', callback: fun(e: RegionEntityEvent), opts?: EventOptions): integer +---@overload fun(self: Region, event: '"enter"', callback: fun(e: RegionEntityEvent), opts?: EventOptions): integer +---@overload fun(self: Region, event: '"leave"', callback: fun(e: RegionEntityEvent), opts?: EventOptions): integer ---@overload fun(self: Region, event: '"move"', callback: fun(e: RegionMoveEvent), opts?: EventOptions): integer ---@overload fun(self: Region, event: '"death"', callback: fun(e: RegionDeathEvent), opts?: EventOptions): integer function Region:on(event, callback, opts) end diff --git a/lua-types/14-mc.lua b/lua-types/14-mc.lua index 9fc2104..cc19402 100644 --- a/lua-types/14-mc.lua +++ b/lua-types/14-mc.lua @@ -5,6 +5,16 @@ ---@class mcApi mc = {} +---@class ItemOpts +---@field count? integer +---@field name? string +---@field lore? string[] +---@field unbreakable? boolean +---@field custom_model_data? integer + +---@class ItemSpec : ItemOpts +---@field id string + -- === Functions ============================================================= ---@param text string @@ -116,7 +126,7 @@ function mc.mapped(className) end ---Registers a custom mob behaviour handler. See /reference/mob-ai. ---@param id string ----@param fn fun(entity. Mob, ctx. table) +---@param fn fun(entity: Mob, ctx: table) function mc.registerBehaviour(id, fn) end ---@param title string @@ -131,13 +141,14 @@ function mc.createBossBar(title, color, style) end ---@return boolean, integer|string function mc.execute(command, opts) end ----Creates an ItemStack. Two overloads. ---- createItem("minecraft.stone", 64) ---- createItem({ id = "minecraft.diamond_sword", count = 1, name = "Excalibur", lore = {...}, unbreakable = true, custom_model_data = 1 }) ----@param idOrSpec string|table ----@param count? integer +---Creates an ItemStack. Three forms: +--- createItem("minecraft:stone", 64) +--- createItem("minecraft:diamond_sword", { name = "Excalibur", lore = {...}, unbreakable = true }) +--- createItem({ id = "minecraft:diamond_sword", count = 1, name = "Excalibur", custom_model_data = 1 }) +---@param idOrSpec string|ItemSpec +---@param countOrOpts? integer|ItemOpts ---@return Item -function mc.createItem(idOrSpec, count) end +function mc.createItem(idOrSpec, countOrOpts) end -- === Event methods ========================================================= diff --git a/lua-types/17-modules.lua b/lua-types/17-modules.lua index 3d0c252..907513c 100644 --- a/lua-types/17-modules.lua +++ b/lua-types/17-modules.lua @@ -18,9 +18,8 @@ function Format.format(template, args) end function Format.broadcastFormat(template, args) end ---@class SimpleLib -local Simple = {} - ---@field defaultOverlay integer -- ticks; default 140 (7s) +local Simple = {} ---Concise command registration. The template is rendered with `args.ctx` set ---to the command context (so `{ctx.player.name}` works), and `args.x` etc. @@ -31,44 +30,64 @@ local Simple = {} ---@param overlay? boolean|integer -- true = default overlay; integer = ticks function Simple.register(syntax, template, range, overlay) end +---`require "core:chestgui"`. ---@class ChestGui local ChestGui = {} ----Creates a new chest GUI. Returns an object with a grid API. +---Click callback. Return `false` to cancel the click (items in an open GUI are locked +---either way, but a cancelled click is also not shown to the player as a pickup). +---@alias ChestGuiCallback fun(player: Player, slot: integer, clickType: string, slotItem: Item|nil, cursorItem: Item|nil): boolean|nil + +---Creates a new chest GUI. ---@param rows integer -- 1..6 ---@param title? string ---@return ChestGuiInstance function ChestGui.create(rows, title) end ---@class ChestGuiInstance +---@field inventory Inventory +---@field title string ---@field rows integer ----@field cols integer -- always 9 ----@field onClick? fun(player: Player, slot: integer, clickType: string, slotItem: Item|nil, cursorItem: Item|nil):boolean|nil +---@field slots integer -- rows * 9 local ChestGuiInstance = {} ----Opens the GUI for the given player. ----@param player Player ----@return Container -function ChestGuiInstance:open(player) end - ----@param player Player -function ChestGuiInstance:close(player) end - ----Sets the item in a slot. +---Puts an item at a grid position (1-based) with a click callback. ---@param row integer ---@param col integer ----@param item? Item -function ChestGuiInstance:setItem(row, col, item) end +---@param item Item +---@param callback? ChestGuiCallback +function ChestGuiInstance:set(row, col, item, callback) end + +---Puts an item at a raw slot (1-based) with a click callback. +---@param slot integer +---@param item Item +---@param callback? ChestGuiCallback +function ChestGuiInstance:button(slot, item, callback) end +---Puts an item without a callback; clicks on it are cancelled. ---@param row integer ---@param col integer ----@return Item|nil -function ChestGuiInstance:getItem(row, col) end +---@param item Item +function ChestGuiInstance:decorate(row, col, item) end +---Puts `item` into every slot (callbacks stay; call it before `set`/`button`). +---@param item? Item function ChestGuiInstance:fill(item) end function ChestGuiInstance:clear() end +---Opens the GUI for the player. +---@param player Player +---@return Container|nil +function ChestGuiInstance:open(player) end + +---@param player Player +function ChestGuiInstance:close(player) end + +---Changes the title used by the next `open`. +---@param title string +function ChestGuiInstance:setTitle(title) end + ---`require "async"`. Only `sleep` and `fetch` are typed here; see ---https://ignis.pyxiion.ru/reference/async-api for tasks, promises and mutexes. ---@class AsyncLib diff --git a/lua-types/19-events.lua b/lua-types/19-events.lua index cc2f457..1180eb1 100644 --- a/lua-types/19-events.lua +++ b/lua-types/19-events.lua @@ -99,8 +99,14 @@ function Event:cancel(reason) end ---@param handler fun(...) ---@param opts? EventOptions ---@return integer ----@overload fun(event: '"init"'|'"uninit"'|'"server_start"'|'"server_stop"'|'"tick"', handler: fun(e: Event), opts?: EventOptions): integer ----@overload fun(event: '"player.login"'|'"player.join"'|'"player.leave"', handler: fun(e: PlayerEvent), opts?: EventOptions): integer +---@overload fun(event: '"init"', handler: fun(e: Event), opts?: EventOptions): integer +---@overload fun(event: '"uninit"', handler: fun(e: Event), opts?: EventOptions): integer +---@overload fun(event: '"server_start"', handler: fun(e: Event), opts?: EventOptions): integer +---@overload fun(event: '"server_stop"', handler: fun(e: Event), opts?: EventOptions): integer +---@overload fun(event: '"tick"', handler: fun(e: Event), opts?: EventOptions): integer +---@overload fun(event: '"player.login"', handler: fun(e: PlayerEvent), opts?: EventOptions): integer +---@overload fun(event: '"player.join"', handler: fun(e: PlayerEvent), opts?: EventOptions): integer +---@overload fun(event: '"player.leave"', handler: fun(e: PlayerEvent), opts?: EventOptions): integer ---@overload fun(event: '"player.respawn"', handler: fun(e: PlayerRespawnEvent), opts?: EventOptions): integer ---@overload fun(event: '"player.chat"', handler: fun(e: PlayerChatEvent), opts?: EventOptions): integer ---@overload fun(event: '"player.move"', handler: fun(e: PlayerMoveEvent), opts?: EventOptions): integer @@ -109,10 +115,14 @@ function Event:cancel(reason) end ---@overload fun(event: '"player.interact"', handler: fun(e: PlayerInteractEvent), opts?: EventOptions): integer ---@overload fun(event: '"player.kill"', handler: fun(e: PlayerKillEvent), opts?: EventOptions): integer ---@overload fun(event: '"player.consume"', handler: fun(e: PlayerItemEvent), opts?: EventOptions): integer ----@overload fun(event: '"player.pickup"'|'"player.drop"', handler: fun(e: PlayerItemCountEvent), opts?: EventOptions): integer ----@overload fun(event: '"block.break"'|'"block.place"', handler: fun(e: BlockEvent), opts?: EventOptions): integer ----@overload fun(event: '"entity.spawn"'|'"entity.despawn"', handler: fun(e: EntityEvent), opts?: EventOptions): integer ----@overload fun(event: '"entity.hurt"'|'"entity.death"', handler: fun(e: EntityDamageEvent), opts?: EventOptions): integer +---@overload fun(event: '"player.pickup"', handler: fun(e: PlayerItemCountEvent), opts?: EventOptions): integer +---@overload fun(event: '"player.drop"', handler: fun(e: PlayerItemCountEvent), opts?: EventOptions): integer +---@overload fun(event: '"block.break"', handler: fun(e: BlockEvent), opts?: EventOptions): integer +---@overload fun(event: '"block.place"', handler: fun(e: BlockEvent), opts?: EventOptions): integer +---@overload fun(event: '"entity.spawn"', handler: fun(e: EntityEvent), opts?: EventOptions): integer +---@overload fun(event: '"entity.despawn"', handler: fun(e: EntityEvent), opts?: EventOptions): integer +---@overload fun(event: '"entity.hurt"', handler: fun(e: EntityDamageEvent), opts?: EventOptions): integer +---@overload fun(event: '"entity.death"', handler: fun(e: EntityDamageEvent), opts?: EventOptions): integer ---@overload fun(event: '"entity.damaged"', handler: fun(e: EntityDamagedEvent), opts?: EventOptions): integer ---@overload fun(event: PxIgnisEventName|string, opts: EventOptions, handler: fun(e: Event)): integer -- Nova trailing block order function mc.on(event, handler, opts) end diff --git a/site/astro.config.mjs b/site/astro.config.mjs index efe1e6b..c33a32b 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -25,6 +25,21 @@ export default defineConfig({ { label: "2. Events and storage", slug: "guide/02-events-and-storage" }, ], }, + { + label: "Tutorial: Arena", + items: [ + { label: "Overview", slug: "tutorial" }, + { label: "1. Commands and settings", slug: "tutorial/01-commands" }, + { label: "2. The arena region", slug: "tutorial/02-region" }, + { label: "3. Rounds", slug: "tutorial/03-rounds" }, + { label: "4. Fights", slug: "tutorial/04-fights" }, + { label: "5. Boss bar and scoreboard", slug: "tutorial/05-interface" }, + { label: "6. Statistics and leaderboard", slug: "tutorial/06-stats" }, + { label: "7. Kits and inventories", slug: "tutorial/07-kits" }, + { label: "8. Events for other scripts", slug: "tutorial/08-events" }, + { label: "9. Reloads and crashes", slug: "tutorial/09-reliability" }, + ], + }, { label: "Core API", items: [ diff --git a/site/src/content/docs/examples/whitelist.md b/site/src/content/docs/examples/whitelist.md index e15ba84..818dbde 100644 --- a/site/src/content/docs/examples/whitelist.md +++ b/site/src/content/docs/examples/whitelist.md @@ -10,8 +10,8 @@ and the cancellable `player.login` event. Save this as `config/ignis/whitelist.lua`: ```lua -local whitelist = mc.data.whitelist or {} -mc.data.whitelist = whitelist +mc.data.whitelist = mc.data.whitelist or {} +local whitelist = mc.data.whitelist mc.on("player.login", function(e) -- Keep operators able to join while setting up the whitelist. diff --git a/site/src/content/docs/guide/02-events-and-storage.md b/site/src/content/docs/guide/02-events-and-storage.md index f3a6a8f..9006afd 100644 --- a/site/src/content/docs/guide/02-events-and-storage.md +++ b/site/src/content/docs/guide/02-events-and-storage.md @@ -208,6 +208,11 @@ mc.on("player.join") \{ e -> Keep going with the topics that match what you want to build: +**Build a whole game** + +- [Tutorial: an arena minigame](/tutorial) — commands, regions, rounds, kits, statistics and more, in one + script that grows chapter by chapter + **More about events and data** - [Events reference](/reference/events) — complete event list and handler signatures diff --git a/site/src/content/docs/guide/getting-started.md b/site/src/content/docs/guide/getting-started.md index b9b3e25..0b7d64f 100644 --- a/site/src/content/docs/guide/getting-started.md +++ b/site/src/content/docs/guide/getting-started.md @@ -77,3 +77,9 @@ Errors in event handlers, commands and scheduled tasks, and warnings such as an the server log and also sent to online players with the `px.ignis.prompt_errors` permission (operators level 4 by default), in the form `event 'block.break': shop.lua:12: attempt to index a nil value`. The same error is repeated in chat at most once every 10 seconds, so a handler failing every tick does not flood it. + +## Next steps + +- [1. Your first command](/guide/01-your-first-command) and [2. Events and storage](/guide/02-events-and-storage): + short recipes for the basics. +- [Tutorial: an arena minigame](/tutorial): build a complete minigame step by step. diff --git a/site/src/content/docs/reference/commands-api.md b/site/src/content/docs/reference/commands-api.md index 97b48cb..8153e7f 100644 --- a/site/src/content/docs/reference/commands-api.md +++ b/site/src/content/docs/reference/commands-api.md @@ -74,6 +74,8 @@ register("warn ") \{ctx, target, reason -> ## Permissions Pass a permission node as the third argument. Players won't even see the command. +Without a permissions mod, operators (op level 4) have every permission; `player:hasPermission(node)` +follows the same rule. If omitted, all players can use the command. ```lua diff --git a/site/src/content/docs/reference/storage.md b/site/src/content/docs/reference/storage.md index d374c78..6075bc9 100644 --- a/site/src/content/docs/reference/storage.md +++ b/site/src/content/docs/reference/storage.md @@ -39,6 +39,25 @@ data.stats.kills = 5 data.stats.name = "player_stats" ``` +## Assigning a Table Stores a Copy + +Assigning a table to `mc.data` or `player.data` stores a copy of it. Changes to the original table after the +assignment are not saved: + +```lua +-- Wrong: `homes` is the local table, the saved copy stays empty +local homes = {} +player.data.homes = homes +homes.base = { x = 0, y = 64, z = 0 } + +-- Right: assign first, then work with the saved table +player.data.homes = player.data.homes or {} +local homes = player.data.homes +homes.base = { x = 0, y = 64, z = 0 } +``` + +The table you read back from storage is the saved one, so changes to it (at any depth) are saved. + ## Data Validation The following types are **not** allowed in storage and will raise an error: diff --git a/site/src/content/docs/tutorial/01-commands.mdx b/site/src/content/docs/tutorial/01-commands.mdx new file mode 100644 index 0000000..d3fef72 --- /dev/null +++ b/site/src/content/docs/tutorial/01-commands.mdx @@ -0,0 +1,167 @@ +--- +title: 1. Commands and saved settings +description: "Arena tutorial, chapter 1: admin commands, permissions and settings that survive restarts." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/01-commands.lua?raw'; + +In this chapter the arena learns where its lobby and spawn points are, and players can join and leave a +queue. Nothing fights yet, but the base everything else builds on is here. + +Create `config/ignis/arena.lua`. With `/ignis watch on` each save reloads it. + +## Settings that survive restarts + +An admin sets the arena up once, so the positions have to survive a restart. `mc.data` is a table that +PxIgnis saves to `config/ignis/storage/global.json` by itself: + +```lua +local ADMIN = "arena.admin" + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} +``` + +`mc.data.arena or {}` means "the saved table, or a new empty one the first time". + +:::caution[Assigning a table stores a copy] +The order of those two lines matters. Assigning a table to `mc.data` (or `player.data`) stores a **copy** +of it, so this does not work: + +```lua nocheck +local config = {} +mc.data.arena = config -- saves a copy of the empty table +config.lobby = ... -- changes the local table, not the saved copy +``` + +Assign first, then read the saved table back, as above. Only numbers, strings, booleans and tables of them +can be stored: no players, functions or vectors. +::: + +A position is stored as a plain table with the world name. Two helpers convert between it and a player: + +```lua +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end +``` + +## Admin commands + +`register(syntax, handler, permission)` adds a command. Words in the syntax are literal parts of the +command, so `"arena setlobby"` is `/arena setlobby`. The handler gets `ctx`, and `ctx.player` is the player +who ran it: + +```lua +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) +``` + +The third argument is a permission. Players without `arena.admin` do not see the command at all. Without a +permissions mod, operators (op level 4) have every script permission. Spawn points are a list, and a new one +goes after the last one: + +```lua +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) +``` + +## The game state + +Who is in the arena right now is not worth saving: after a restart nobody is. That state lives in an +ordinary local table, keyed by the player's UUID: + +```lua +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + players = {}, -- uuid -> Player: everyone who joined +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end +``` + +`tell` sends a message to everyone in the arena. `count(t)` (in the checkpoint) counts the keys of a table, +since `#` only works for lists. + +## Joining and leaving + +```lua +local function join(player) + if not config.lobby then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + else + game.players[player.uuid] = player + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + game.players[uuid] = nil + player:sendMessage("§7You left the arena.") +end +``` + +A player who disconnects must leave the arena too, or a round could wait for them forever. The +`player.leave` event fires when a player leaves the server; handlers get an event table `e` with the +player in `e.player`: + +```lua +mc.on("player.leave", function(e) + leave(e.player) +end) +``` + +Finally the player commands. They have no permission, so everyone can use them: + +```lua +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) +``` + +## Try it + +1. Stand where the lobby should be and run `/arena setlobby`. +2. Walk into your arena and run `/arena addspawn` in two or more places. +3. Run `/ignis eval mc.data.arena` to see what was saved. +4. `/arena join` teleports you to the lobby; `/arena leave` takes you out of the queue. +5. Restart the server or run `/ignis reload`: the settings are still there, the queue is empty. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 1 + + + +
+ +**Exercise:** add `/arena where`, which tells the player how many spawn points are set and whether the lobby +is set. diff --git a/site/src/content/docs/tutorial/02-region.mdx b/site/src/content/docs/tutorial/02-region.mdx new file mode 100644 index 0000000..0cc8c72 --- /dev/null +++ b/site/src/content/docs/tutorial/02-region.mdx @@ -0,0 +1,129 @@ +--- +title: 2. The arena region +description: "Arena tutorial, chapter 2: a region for the arena and block protection with cancellable events." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/02-region.lua?raw'; + +The arena needs borders: to protect its blocks now, and later to notice who walks in or out. A +[region](/reference/region-api) is a box in a world that knows which entities are inside it. + +## Two corners + +The admin stands in one corner and runs `/arena pos1`, then in the opposite corner `/arena pos2`. Both +commands do the same thing with a different key, so they share a function: + +```lua +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) +``` + +`math.floor` turns the player's position (say `12.7, 64, -3.2`) into the block they stand in +(`12, 64, -4`). + +## Building the region + +Regions, like everything else a script creates, are removed on `/ignis reload`. So the region is not +saved; the corners are, and the script builds the region from them every time it loads: + +```lua +local region -- Region|nil, created by buildRegion() below +``` + +```lua +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) +end + +buildRegion() +``` + +The corners can be given in any order, hence `min` and `max`. The `+ 1` matters: the region box ends +*before* its second corner, and a block at `x = 20` spans from 20 to 21. + +`region` is declared near the top of the file because functions above `buildRegion` need it. The arena is +ready once it has a region, a lobby and a spawn point, and `join` now checks exactly that: + +```lua +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end +``` + +```lua + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") +``` + +## Protecting the blocks + +`block.break` and `block.place` are **cancellable** events: a handler that calls `e:cancel()` stops the +action. `e.pos` is the block and `e.player` the player: + +```lua +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) +``` + +Admins keep building rights, so they can still fix the arena. + +`priority = "high"` makes these handlers run before handlers with the default priority. Once an event is +cancelled, later handlers are not called at all, so a script elsewhere that pays players for mined blocks +never pays for a block the arena refused. See [Events](/reference/events#cancelling) for the details. + +:::tip +If a handler of a cancellable event throws an error, the event is cancelled too. A bug in protection code +blocks too much instead of letting everything through. +::: + +## Try it + +1. Stand in one corner of your arena (on the floor), run `/arena pos1`; in the opposite corner, one block + above the highest point players should reach, run `/arena pos2`. +2. Ask a player who is not an operator to break a block inside the arena and one outside it. (Alone? Run + `/deop ` from the server console and `/op ` afterwards.) +3. `/ignis eval #me.world.regions` shows how many regions the world has. It stays at 1 however often you + reload. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 2 + + + +
+ +**Exercise:** show the arena borders: after `/arena pos2`, spawn `minecraft:end_rod` particles at the four +bottom corners with `world:particle`. diff --git a/site/src/content/docs/tutorial/03-rounds.mdx b/site/src/content/docs/tutorial/03-rounds.mdx new file mode 100644 index 0000000..4cb558c --- /dev/null +++ b/site/src/content/docs/tutorial/03-rounds.mdx @@ -0,0 +1,177 @@ +--- +title: 3. Rounds +description: "Arena tutorial, chapter 3: a game loop with phases, the scheduler and async.sleep." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/03-rounds.lua?raw'; + +Now the arena gets a game loop: when enough players have joined, a countdown starts, everyone is +teleported to a spawn point, and after a while the round ends. Fights come in the next chapter; here the +round simply ends in a draw when time runs out. + +## Phases + +A round goes through four phases: + +- **waiting**: players join, until there are enough of them; +- **countdown**: a few seconds to get ready; +- **playing**: the fight; +- **ending**: the winner celebrates, then everyone returns to the lobby and it is `waiting` again. + +The current phase and a timer go into the game state, together with the set of players still in the fight: + +```lua +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw +``` + +```lua +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + timer = 0, -- seconds left in the current phase +} +``` + +## A tick every second + +`mc.scheduleRepeating(delay, interval, fn)` calls a function every `interval` ticks (20 ticks = 1 second). +One function that runs once a second and moves the game along is the whole game loop: + +```lua +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end +end + +mc.scheduleRepeating(20, 20, tick) +``` + +Scheduled tasks are removed on reload, and the file schedules a new one when it loads, so there is always +exactly one. + +:::note[Why not a loop with `async.sleep`?] +A `while true do ... async.sleep(20) end` would work too. A tick function is easier to follow when several +things can happen in any order (players leave during the countdown, someone dies, time runs out): each +second it looks at the state and decides, instead of being somewhere in the middle of a long function. +::: + +## Starting a round + +```lua +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + end + tell("§aFight!") +end +``` + +`(i - 1) % #config.spawns + 1` goes through the spawn points one by one and starts over when there are more +players than points: with 3 points, players 1, 2, 3, 4, 5 get points 1, 2, 3, 1, 2. + +While a round is running nobody can join: + +```lua + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") +``` + +## Ending a round + +`finish(winner)` shows the winner (none yet, that is the next chapter) and brings everyone back after a +five-second celebration: + +```lua +local function finish(winner) + game.phase = "ending" + + if winner then + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end +``` + +`async.sleep(ticks)` (from `local async = require "async"` at the top of the file) pauses the function +without pausing the server. It only works inside a coroutine; scheduled callbacks, event handlers and +command handlers all run as one, so it works there. `mc.schedule(0, ...)` starts the celebration in its +own coroutine, and `finish` returns right away. + +`winner.pos + vec(0, 1, 0)` is one block above the winner's feet: vectors support `+`, `-` and `*`. + +## Try it + +1. Set `ROUND_TIME` to `30` while testing. +2. Join with two players (a second account, or a friend). After ten seconds both are teleported to the + spawn points; after 30 more the round ends in a draw and you are back in the lobby. +3. Leave during the countdown: it stops. + +Dying does nothing special yet: a dead player respawns as usual and is still "alive" in the round. That is +fixed in the next chapter. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 3 + + + +
+ +**Exercise:** start the round early when the arena is full: if `count(game.players)` reaches +`#config.spawns` during the countdown, skip to 3 seconds. diff --git a/site/src/content/docs/tutorial/04-fights.mdx b/site/src/content/docs/tutorial/04-fights.mdx new file mode 100644 index 0000000..0e6bf43 --- /dev/null +++ b/site/src/content/docs/tutorial/04-fights.mdx @@ -0,0 +1,145 @@ +--- +title: 4. Fights +description: "Arena tutorial, chapter 4: knockouts instead of deaths, a winner, and region enter/leave events." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/04-fights.lua?raw'; + +Time to fight. A player who would die is knocked out instead: they stay alive, are healed and go back to the +lobby. When one player is left, they win. + +## Knockouts instead of deaths + +`entity.death` fires when an entity takes fatal damage, before it dies, and it is cancellable. For players, +`e.player` is set (for other entities it is `nil`), and `e.attacker` is whoever dealt the damage, if +anyone: + +```lua +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) +``` + +Cancelling the death keeps the player alive, but their health is still zero, so the handler must heal them +right away. No death screen, no dropped items, no respawn point to worry about. + +The winner is still in `game.alive` during the celebration. If they fall off something then, the handler +heals them but does not eliminate them: that is what the `game.phase == "playing"` check is for. + +`e.attacker and game.players[e.attacker.uuid]` is the attacker if they are an arena player, otherwise +`nil` (a zombie, fall damage, a player from outside). + +## Knocking a player out + +```lua +local function eliminate(player, killer) + game.alive[player.uuid] = nil + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end +``` + +Knocked-out players stay in `game.players`: they watch from the lobby and play in the next round. + +## Who won? + +```lua +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end +``` + +`next(t)` returns some key of a table; with one entry left, it is the last player standing. + +## Leaving + +A player who leaves the arena (with `/arena leave` or by disconnecting) during a round is knocked out +first, so the round can still end: + +```lua + if game.alive[uuid] then + eliminate(player, nil) + end +``` + +## Region events + +Regions have their own events, subscribed with `region:on`. `leave` and `enter` fire when an entity leaves +or enters the box; like other entity events, `e.player` is set for players. The handlers are added in +`buildRegion`, right after the region is created: + +```lua + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +``` + +`eliminate` teleports the player to the lobby, which is outside the arena, so it fires `leave` again. That +second call does nothing because the player is no longer in `game.alive`. Handlers that react to their own +side effects are common; checking the state first keeps them harmless. + +## No hits from the lobby + +Players waiting in the lobby, and knocked-out ones, should not hit anybody: + +```lua +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) +``` + +## Try it + +With two players: fight until one of them would die. They land in the lobby with full health, the other +one gets the title and the particles, and after five seconds both are in the lobby and the next countdown +starts. Try walking out of the arena and back in during a round, too. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 4 + + + +
+ +**Exercise:** make the final duel visible: when only two players are left, set `player.glowing = true` on +both, and turn it off again when the round ends. diff --git a/site/src/content/docs/tutorial/05-interface.mdx b/site/src/content/docs/tutorial/05-interface.mdx new file mode 100644 index 0000000..5b8dd94 --- /dev/null +++ b/site/src/content/docs/tutorial/05-interface.mdx @@ -0,0 +1,108 @@ +--- +title: 5. Boss bar and scoreboard +description: "Arena tutorial, chapter 5: a boss bar with the countdown and a scoreboard sidebar for each player." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/05-interface.lua?raw'; + +The game works, but players see little of it. This chapter adds a boss bar at the top of the screen with +the phase and the time, and a scoreboard on the right. + +## The boss bar + +One [boss bar](/reference/bossbar-api) is shared by all arena players. It is created when the script loads +(and, like regions, removed on reload): + +```lua +local bar = mc.createBossBar("Arena", "yellow", "progress") +``` + +Players are added when they join and removed when they leave: + +```lua + bar:addPlayer(player) +``` + +```lua +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end +``` + +```lua + hideUI(player) +``` + +## Updating it + +`updateUI` sets the bar's text and fill from the phase. `bar.progress` goes from 0 to 1: + +```lua +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status +``` + +`string.format` fills in `%d` (a whole number) and `%02d` (at least two digits, so `4:05` and not `4:5`). + +It is called at the end of `tick`, so it refreshes once a second, and after each join so the new player sees +it at once: + +```lua + updateUI() +``` + +## The scoreboard + +Every player has their own [sidebar](/reference/sidebar-api). Assigning a table to `player.sidebar` creates +it or updates it; assigning `nil` removes it (see `hideUI` above): + +```lua + for uuid, player in pairs(game.players) do + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + }, + } + end +end +``` + +`a and b or c` is Lua's short form of "if a then b else c" (as long as `b` is not `false` or `nil`). + +The sidebar has only two lines for now; the next chapters add kills, wins and the kit. + +## Try it + +Join and watch the bar fill up as players join, count down, then show the time left. Leave the arena: the +bar and the sidebar disappear. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 5 + + + +
+ +**Exercise:** change the bar color with the phase: `bar.color = "green"` while waiting, `"yellow"` during the +countdown, `"red"` during the fight. diff --git a/site/src/content/docs/tutorial/06-stats.mdx b/site/src/content/docs/tutorial/06-stats.mdx new file mode 100644 index 0000000..6777468 --- /dev/null +++ b/site/src/content/docs/tutorial/06-stats.mdx @@ -0,0 +1,153 @@ +--- +title: 6. Statistics and leaderboard +description: "Arena tutorial, chapter 6: per-player statistics in player.data and a leaderboard hologram." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/06-stats.lua?raw'; + +Wins and kills should count for more than one round. This chapter keeps statistics for every player, shows +them on the scoreboard and with `/arena stats`, and puts a top-5 leaderboard in the lobby. + +## Per-player data + +`player.data` works like `mc.data`, but belongs to one player. It is saved to +`config/ignis/storage/players/.json`, loaded when the player joins and written when they leave. + +The statistics are a table inside it. The same rule as in chapter 1 applies: assign the table, then read it +back from `player.data`: + +```lua +local function stats(player) + if not player.data.arenaStats then + player.data.arenaStats = { wins = 0, kills = 0, deaths = 0, games = 0 } + end + return player.data.arenaStats +end +``` + +What `stats` returns is the saved table, so changing a field is enough, no assignment back needed: + +```lua + stats(player).games = stats(player).games + 1 +``` + +```lua + stats(player).deaths = stats(player).deaths + 1 +``` + +Kills also count per round, for the scoreboard. That list is part of the game state, reset in `startRound` +and cleaned up in `leave`: + +```lua + kills = {}, -- uuid -> kills this round +``` + +```lua + game.kills[killer.uuid] = (game.kills[killer.uuid] or 0) + 1 + stats(killer).kills = stats(killer).kills + 1 +``` + +The scoreboard gets three more lines: + +```lua + local s = stats(player) +``` + +```lua + "", + "Kills this round: §e" .. (game.kills[uuid] or 0), + "Wins: §e" .. s.wins, +``` + +## `/arena stats` + +An optional argument in `[...]` is `nil` when it is left out, so `/arena stats` shows your own numbers and +`/arena stats Steve` someone else's: + +```lua +register("arena stats []", function(ctx, target) + local player = target or ctx.player + local s = stats(player) + ctx.player:sendMessage(string.format( + "§6%s§7: %d wins, %d kills, %d deaths in %d games", + player.name, s.wins, s.kills, s.deaths, s.games)) +end) +``` + +The `player` argument type only accepts players who are online, which is fine here: `player.data` is only +available for them. + +## A leaderboard + +The leaderboard must include players who are offline, and their `player.data` is not loaded. So the wins +are copied into `mc.data` too, when a round is won: + +```lua +-- Wins of every player who ever won, for the leaderboard (offline players too). +mc.data.arenaWins = mc.data.arenaWins or {} +local wins = mc.data.arenaWins +``` + +```lua + stats(winner).wins = stats(winner).wins + 1 + wins[winner.uuid] = { name = winner.name, wins = stats(winner).wins } + updateLeaderboard() +``` + +It is shown by a [hologram](/reference/hologram-api), floating text in the world. `/arena setleaderboard` +saves its position, and the hologram is created from it when the script loads (holograms are removed on +reload, like regions and boss bars): + +```lua +local function spawnLeaderboard() + if leaderboard then leaderboard:destroy() end + leaderboard = nil + local point = config.leaderboard + if not point then return end + leaderboard = mc.world(point.world):spawnHologram(vec(point.x, point.y, point.z), "Arena") + updateLeaderboard() +end +``` + +```lua +spawnLeaderboard() +``` + +`updateLeaderboard` sorts the entries and writes the top five into `leaderboard.lines`: + +```lua + local rows = {} + for _, entry in pairs(wins) do + rows[#rows + 1] = entry + end + table.sort(rows, function(a, b) return a.wins > b.wins end) + + local lines = { "§6§lArena: top players" } + for i = 1, math.min(5, #rows) do + lines[#lines + 1] = string.format("§e%d. §f%s §7- %d", i, rows[i].name, rows[i].wins) + end + if #rows == 0 then + lines[#lines + 1] = "§7no winners yet" + end + leaderboard.lines = lines +``` + +`table.sort` takes a function that says whether `a` goes before `b`. + +## Try it + +1. Stand in the lobby and run `/arena setleaderboard`. +2. Play a round. The winner appears on the leaderboard; `/arena stats` shows the numbers. +3. Restart the server: statistics and leaderboard are still there. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 6 + + + +
+ +**Exercise:** add a kill/death ratio to `/arena stats`. Mind players with zero deaths. diff --git a/site/src/content/docs/tutorial/07-kits.mdx b/site/src/content/docs/tutorial/07-kits.mdx new file mode 100644 index 0000000..7f78aa0 --- /dev/null +++ b/site/src/content/docs/tutorial/07-kits.mdx @@ -0,0 +1,148 @@ +--- +title: 7. Kits and inventories +description: "Arena tutorial, chapter 7: saving the player's items, giving kits and a chest menu to choose one." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/07-kits.lua?raw'; + +So far players fight with whatever they carry, and a diamond sword wins every round. Now everyone gets a kit +of their choice, and their own items wait in storage until the round is over. + +## Putting items away + +A player's inventory is numbered: slots 0 to 35 are the main inventory and the hotbar, 36 to 39 the armor, +40 the offhand. `item:serialise()` turns an item into a string with all its enchantments and names, and +`mc.deserialise("item", text)` turns it back. Strings can be stored, so the items go into `player.data`: + +```lua +-- Player inventory slots: 0-35 main, 36-39 armor, 40 offhand. +local LAST_SLOT = 40 + +-- Saves the player's items in player.data and empties the inventory. +local function stash(player) + local saved = {} + for slot = 0, LAST_SLOT do + local item = player:getItem(slot) + if item then + saved[tostring(slot)] = item:serialise() + end + end + player.data.arenaStash = saved + player:clear() +end + +local function restore(player) + local saved = player.data.arenaStash + if not saved then return end + player:clear() + for slot, json in pairs(saved) do + player:setItem(tonumber(slot), mc.deserialise("item", json)) + end + player.data.arenaStash = nil +end +``` + +The slot numbers are stored as strings (`"0"`, `"38"`). Storage is JSON, and a table with gaps in its +numbers comes back from JSON with string keys anyway; converting on both sides keeps it predictable. + +Why `player.data` and not a local table? If the server crashes in the middle of a round, a local table is +gone, and so are the player's items. `player.data` is on disk. Chapter 9 uses that to give the items back. + +Items go away when a round starts, and come back when a player is knocked out and at the end of the round: + +```lua + stash(player) + giveKit(player) +``` + +```lua + restore(player) +``` + +```lua + restore(player) +``` + +## Kits + +A kit is plain data: a name, an icon, items with counts, and a chestplate. + +```lua +local KITS = { + { + id = "warrior", name = "§cWarrior", icon = "minecraft:iron_sword", + items = { + { "minecraft:iron_sword", 1 }, { "minecraft:shield", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:iron_chestplate", + }, +``` + +The player's choice is saved in `player.data.arenaKit`; `findKit` falls back to the first kit: + +```lua +local function giveKit(player) + local kit = findKit(player.data.arenaKit) + for _, entry in ipairs(kit.items) do + player:give(entry[1], entry[2]) + end + player.chest = mc.createItem(kit.chest) +end +``` + +`player.chest` is the chestplate slot; `head`, `legs`, `feet`, `mainhand` and `offhand` work the same way. + +## A chest menu + +The built-in `chestgui` library makes a menu out of a chest window. Load it at the top of the file: + +```lua +local chestgui = require "core:chestgui" +``` + +One menu serves all players. `kitMenu:set(row, column, item, callback)` puts a button in it: + +```lua +local kitMenu = chestgui.create(1, "Choose a kit") +for i, kit in ipairs(KITS) do + local icon = mc.createItem(kit.icon, { name = kit.name, lore = { "§7Click to choose" } }) + kitMenu:set(1, i * 2 + 1, icon, function(player) + player.data.arenaKit = kit.id + player:sendMessage("Kit: " .. kit.name) + player:playSound("minecraft:ui.button.click") + return false + end) +end +``` + +`return false` cancels the click, so the item does not end up on the cursor. The buttons go in columns 3, 5 +and 7 of the single row. + +```lua +register("arena kit", function(ctx) kitMenu:open(ctx.player) end) +``` + +And the scoreboard shows the chosen kit: + +```lua + "Kit: " .. findKit(player.data.arenaKit).name, +``` + +## Try it + +1. Put something recognizable in your inventory, e.g. a named item and some armor. +2. `/arena kit`, choose the archer, join and wait for the round. Your inventory is replaced by the kit. +3. After the round your own items are back, in the same slots. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 7 + + + +
+ +**Exercise:** give each kit an `effect` field (e.g. `"minecraft:speed"` for the archer) and apply it in +`giveKit` with `player:addEffect(kit.effect, 20 * ROUND_TIME)`. diff --git a/site/src/content/docs/tutorial/08-events.mdx b/site/src/content/docs/tutorial/08-events.mdx new file mode 100644 index 0000000..d7f1859 --- /dev/null +++ b/site/src/content/docs/tutorial/08-events.mdx @@ -0,0 +1,94 @@ +--- +title: 8. Events for other scripts +description: "Arena tutorial, chapter 8: a custom event with mc.emit and a second script that rewards winners." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/08-events.lua?raw'; +import rewards from '../../../../tutorial/arena/08-rewards.lua?raw'; + +The arena should not have to know about the server's economy, Discord or quests. Instead it announces what +happened, and other scripts decide what to do about it. This chapter adds an `arena:win` event and a second +script that rewards the winner. + +## Firing a custom event + +`mc.emit(name, ...)` calls every handler of the event with the arguments you pass. Custom event names +contain a `:`; a prefix like `arena:` keeps them apart from other scripts' events. + +The event fires at the end of the celebration, when the winner is back in the lobby: + +```lua + -- Tell other scripts, now that the winner has their own items back. + if winner and game.players[winner.uuid] then + mc.emit("arena:win", winner, game.kills[winner.uuid] or 0) + end +``` + +Why not right away in `finish`? At that moment the winner still carries the kit, and `restore` clears the +inventory before giving their own items back: a reward handed out earlier would vanish with the kit. +`game.players[winner.uuid]` skips the event if the winner left the server during the celebration. + +## A rewards script + +Create a second file, `config/ignis/arena_rewards.lua`. Scripts in `config/ignis/` share the event bus, so +it can listen to the arena's event. Handlers of custom events get the emitted arguments as they are: + +```lua +mc.on("arena:win", function(winner, kills) + winner:give("minecraft:diamond", 1 + kills) + mc.broadcast("§6" .. winner.name .. " §ewon an arena round with " .. kills .. " kills!") +``` + +The arena works without this file, and this file can be replaced by another one (coins, a quest step, a +title) without touching the arena. + +## Posting to Discord + +Event handlers run as coroutines, so they can wait for an HTTP request with `async.fetch` without +stopping the server. With a Discord webhook URL in `WEBHOOK`, winners are announced on Discord too: + +```lua +-- A Discord webhook URL to announce winners there, or nil to skip it. +local WEBHOOK = nil +``` + +```lua + if WEBHOOK then + local response = async.fetch({ + url = WEBHOOK, + method = "POST", + json = { content = winner.name .. " won an arena round with " .. kills .. " kills" }, + }) + if not response.ok then + print("arena webhook failed: " .. tostring(response.error or response.status)) + end + end +``` + +`mc.emit` returns as soon as the handler starts waiting, so the arena does not wait for Discord. +`print` writes to the server console. See the [Async API](/reference/async-api) for more. + +## Try it + +Win a round: diamonds (one plus one per kill) land in your inventory next to your own items, and everyone +reads the announcement. + +## Checkpoint + +
+config/ignis/arena.lua after chapter 8 + + + +
+ +
+config/ignis/arena_rewards.lua + + + +
+ +**Exercise:** add an `arena:eliminated` event, fired in `eliminate` with the player and the killer, and use +it in `arena_rewards.lua` to give the killer an arrow for every knockout. diff --git a/site/src/content/docs/tutorial/09-reliability.mdx b/site/src/content/docs/tutorial/09-reliability.mdx new file mode 100644 index 0000000..4078e4f --- /dev/null +++ b/site/src/content/docs/tutorial/09-reliability.mdx @@ -0,0 +1,127 @@ +--- +title: 9. Reloads and crashes +description: "Arena tutorial, chapter 9: what a reload keeps and throws away, cleaning up in uninit, and debugging." +--- + +import { Code } from '@astrojs/starlight/components'; +import checkpoint from '../../../../tutorial/arena/09-final.lua?raw'; + +The arena works as long as nothing unexpected happens. This last chapter is about the unexpected: a +`/ignis reload` in the middle of a round, a server crash, a broken setup. It also collects the debugging +tricks that help while writing scripts like this one. + +## What a reload keeps + +`/ignis reload` (and every save with `/ignis watch on`) throws away everything the scripts created and runs +the files again: + +| Removed on reload | Kept | +|---|---| +| local variables, e.g. `game` | `mc.data` and `player.data` | +| event handlers and commands | the world, and what scripts did to it | +| scheduled tasks and sleeping coroutines | players, their positions and inventories | +| regions, holograms, boss bars, sidebars, open menus | | + +The arena's own objects (region, boss bar, leaderboard) are created again when the file loads, from the +settings in `mc.data`. But the round in progress is gone, and its players are left standing in the arena +with a kit instead of their items. + +## Cleaning up in `uninit` + +The `uninit` event fires just before a reload, while the old handlers still work. It is the place to +undo what only the script knows about: + +```lua +-- Before a reload the game state is thrown away: put everyone back first. +mc.on("uninit", function() + for uuid, player in pairs(game.players) do + if game.alive[uuid] then + restore(player) + teleport(player, config.lobby) + end + player.sidebar = nil + player:sendMessage("§eThe arena was reloaded, join again with /arena join.") + end +end) +``` + +A normal server stop needs nothing extra: every player leaves the server, `player.leave` fires, and `leave` +knocks them out and gives their items back. + +## After a crash + +A crash skips all of that. What is left is whatever was written to disk. The stash is in `player.data`, so +the next time the player joins, the script can finish the job: + +```lua +-- Came back after a crash in the middle of a round: return their items. +mc.on("player.join", function(e) + if e.player.data.arenaStash then + restore(e.player) + if config.lobby then teleport(e.player, config.lobby) end + e.player:sendMessage("§eYour items from the last arena round were returned.") + end +end) +``` + +:::caution +PxIgnis writes `player.data` to disk on `/ignis reload`, when the player leaves and when the server stops, +not on a timer. If the server crashes during a round, the stash is on disk only if one of those happened +since the round started. Keep that in mind for anything valuable you keep in storage. +::: + +## Guarding against a broken setup + +An admin can run `/arena clearspawns` while players wait for the countdown. `startRound` would then pick a +spawn point from an empty list and fail. A check at the start turns that into a clear message: + +```lua + if not isReady() then + game.phase = "waiting" + tell("§cThe arena setup is incomplete, the round cannot start.") + return + end +``` + +A rule of thumb: every function that runs later (on a timer, on an event) checks that what it needs still +exists, because a command or a reload may have changed it in between. + +## Debugging + +- **Errors in chat.** When a handler fails, operators see where and why in chat, e.g. + `event 'entity.death': arena.lua:22: attempt to index ? (a nil value) with key 'x'`: line 22 is + `teleport`, and the point it got was `nil` (no lobby set?). The same error is shown at most once every 10 + seconds, so a failing tick does not flood the chat. +- **Syntax errors** stop the reload: the old scripts keep running and the error is reported. +- **`/ignis status`** lists the scripts, event handlers per event, commands, tasks and regions. Two + `tick` tasks where you expect one means something is scheduled twice. +- **`/ignis eval`** shows what the scripts see: `/ignis eval mc.data.arena.lobby`, + `/ignis eval me.data.arenaStats`. Local variables are invisible to it; to look at the game state, add + `arenaDebug = game` at the end of the file for a while and run `/ignis eval arenaDebug.phase`. +- **Your editor** catches most typos before you save: a misspelled method or event field is underlined. + +## Checkpoint + +This is the finished arena. + +
+config/ignis/arena.lua, complete + + + +
+ +## Where to go from here + +Ideas to take it further, each a good exercise on its own: + +- **Several arenas.** Turn `config` and `game` into tables keyed by an arena name, and add a name argument to + the commands: `register("arena join ", ...)`. +- **Teams.** Two teams with colored names, friendly fire off (`player.attack` knows both players), and + a team win instead of a last player standing. +- **Spectators.** Instead of the lobby, knocked-out players become spectators (`player.gamemode = + "spectator"`) until the round ends. +- **Split it up.** Move the kits and the statistics into their own files and load them with `require`; see + [Language](/reference/language) and the [libraries overview](/libraries/overview). + +The [reference](/reference/mc-api) describes everything used here in full, and more. diff --git a/site/src/content/docs/tutorial/index.mdx b/site/src/content/docs/tutorial/index.mdx new file mode 100644 index 0000000..5cc9766 --- /dev/null +++ b/site/src/content/docs/tutorial/index.mdx @@ -0,0 +1,73 @@ +--- +title: "Tutorial: an arena minigame" +description: Build a complete PvP arena minigame with PxIgnis, one feature per chapter. +--- + +In this tutorial you build a small but complete minigame: a PvP arena where the last player standing wins. +Each chapter adds one feature and one part of the PxIgnis API, so by the end you have used most of what a +real server script needs. + +The finished arena has: + +- setup commands for admins: arena corners, spawn points, lobby, leaderboard; +- `/arena join`, `/arena leave`: a queue, a countdown and rounds that start by themselves; +- knockouts instead of deaths, so nobody loses their items or sees a death screen; +- a boss bar with the countdown and time left, and a scoreboard for every player; +- kits chosen from a chest menu, while the player's own items wait safely in storage; +- statistics per player and a leaderboard hologram; +- an `arena:win` event that other scripts use for rewards; +- clean behaviour on `/ignis reload` and after a server crash. + +## Before you start + +You need a server with PxIgnis and operator rights (or the `px.ignis` permission). Three things make the +work much faster; set them up first: + +1. **Editor.** Open `config/ignis/` in VS Code with the Lua extension, as described in + [Editor setup](/guide/getting-started#editor-setup). You get completion for the whole API and your + typos are underlined before you even reload. +2. **Auto-reload.** Run `/ignis watch on`. From now on every save of a `.lua` file reloads the scripts and + the result shows up in chat. +3. **Errors in chat.** Script errors are sent to operators in chat + ([details](/guide/getting-started#script-errors-in-chat)), with the file and line. + +`/ignis eval ` runs a line of Lua in the scripts' environment, handy for quick checks: +`/ignis eval me.pos` prints where you stand, `/ignis eval mc.data.arena` shows the saved arena settings. + +## How the tutorial works + +Everything lives in one file, `config/ignis/arena.lua`, which grows from chapter to chapter. The code +snippets in the text are pieces of it. Each chapter ends with a **checkpoint**: the complete file as it +should look at that point. If something does not work, compare with it; to start from any chapter, copy +the checkpoint of the previous one. + +The arena expects all its points (corners, spawns, lobby) in one area of one world. Build it somewhere +flat first: a fenced 20×20 field is enough, and a lobby a little outside it. + +## Chapters + +1. [Commands and saved settings](/tutorial/01-commands): `register`, permissions, `mc.data`. +2. [The arena region](/tutorial/02-region): regions and cancelling block events. +3. [Rounds](/tutorial/03-rounds): a game loop with the scheduler and `async.sleep`. +4. [Fights](/tutorial/04-fights): knockouts with `entity.death`, region enter and leave. +5. [Boss bar and scoreboard](/tutorial/05-interface): boss bars, sidebars and titles. +6. [Statistics and leaderboard](/tutorial/06-stats): `player.data` and holograms. +7. [Kits and inventories](/tutorial/07-kits): saving items and a chest menu. +8. [Events for other scripts](/tutorial/08-events): `mc.emit` and a second script. +9. [Reloads and crashes](/tutorial/09-reliability): what survives a reload and how to clean up. + +## The Lua you need + +If you have written a bit of JavaScript or Python, Lua reads easily. The differences that matter here: + +- `local` makes a variable local to the file or the block. Use it for everything; globals are shared by all + scripts. +- Tables (`{}`) are both arrays and dictionaries. Arrays start at index **1**; `#t` is the length of an + array; `pairs(t)` iterates over all keys, `ipairs(t)` over array items in order. +- `nil` means "no value". Setting a table key to `nil` removes it. +- `..` joins strings: `"Hello, " .. player.name`. +- `obj:method(x)` is a method call and is the same as `obj.method(obj, x)`. PxIgnis objects use `:` for + methods and `.` for properties: `player:sendMessage("hi")`, `player.health = 20`. +- `§` codes color text in Minecraft: `§a` green, `§c` red, `§e` yellow, `§7` gray, `§6` gold, `§l` bold. + +More in [Language](/reference/language). diff --git a/site/tutorial/README.md b/site/tutorial/README.md new file mode 100644 index 0000000..f4c7f23 --- /dev/null +++ b/site/tutorial/README.md @@ -0,0 +1,14 @@ +# Arena tutorial code + +The pages are in `site/src/content/docs/tutorial/`; the code they show lives here. + +- `arena/NN-*.lua`: the complete `arena.lua` after chapter NN (the "checkpoint" at the end of each page, + embedded with a `?raw` import). `08-rewards.lua` is the second script from chapter 8. +- `check-snippets.py`: every line of a ```` ```lua ```` block in page NN must appear in `arena/NN-*.lua`, so + the text cannot drift from the checkpoints. Mark an intentionally different block ```` ```lua nocheck ````. +- `test/`: a fake PxIgnis API (`mock.lua`) and scripts that play the arena through with it + (`smoke.lua` for each checkpoint, `scenario.lua` for the finished game). +- `test.sh`: runs all of the above plus `lua-language-server --check` against `lua-types/`. CI runs it in + the `tutorial` job. + +When you change a chapter, change its checkpoint and every later one the same way, then run `test.sh`. diff --git a/site/tutorial/arena/01-commands.lua b/site/tutorial/arena/01-commands.lua new file mode 100644 index 0000000..e368287 --- /dev/null +++ b/site/tutorial/arena/01-commands.lua @@ -0,0 +1,81 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. + +local ADMIN = "arena.admin" + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + players = {}, -- uuid -> Player: everyone who joined +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not config.lobby then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + else + game.players[player.uuid] = player + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + game.players[uuid] = nil + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Commands ─────────────────────────────────────────────────────────────── + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) diff --git a/site/tutorial/arena/02-region.lua b/site/tutorial/arena/02-region.lua new file mode 100644 index 0000000..0f1e1bc --- /dev/null +++ b/site/tutorial/arena/02-region.lua @@ -0,0 +1,131 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. + +local ADMIN = "arena.admin" + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + players = {}, -- uuid -> Player: everyone who joined +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + else + game.players[player.uuid] = player + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + game.players[uuid] = nil + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) +end + +buildRegion() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) diff --git a/site/tutorial/arena/03-rounds.lua b/site/tutorial/arena/03-rounds.lua new file mode 100644 index 0000000..7861c54 --- /dev/null +++ b/site/tutorial/arena/03-rounds.lua @@ -0,0 +1,216 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + game.players[uuid] = nil + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) +end + +buildRegion() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) diff --git a/site/tutorial/arena/04-fights.lua b/site/tutorial/arena/04-fights.lua new file mode 100644 index 0000000..e28b6d5 --- /dev/null +++ b/site/tutorial/arena/04-fights.lua @@ -0,0 +1,280 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) diff --git a/site/tutorial/arena/05-interface.lua b/site/tutorial/arena/05-interface.lua new file mode 100644 index 0000000..e0755cc --- /dev/null +++ b/site/tutorial/arena/05-interface.lua @@ -0,0 +1,322 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Scoreboard and boss bar ──────────────────────────────────────────────── + +local bar = mc.createBossBar("Arena", "yellow", "progress") + +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status + + for uuid, player in pairs(game.players) do + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + }, + } + end +end + +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end + updateUI() +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + bar:addPlayer(player) + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + updateUI() + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + hideUI(player) + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) diff --git a/site/tutorial/arena/06-stats.lua b/site/tutorial/arena/06-stats.lua new file mode 100644 index 0000000..1af62f7 --- /dev/null +++ b/site/tutorial/arena/06-stats.lua @@ -0,0 +1,393 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + kills = {}, -- uuid -> kills this round + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Statistics ───────────────────────────────────────────────────────────── + +local function stats(player) + if not player.data.arenaStats then + player.data.arenaStats = { wins = 0, kills = 0, deaths = 0, games = 0 } + end + return player.data.arenaStats +end + +-- Wins of every player who ever won, for the leaderboard (offline players too). +mc.data.arenaWins = mc.data.arenaWins or {} +local wins = mc.data.arenaWins + +local leaderboard -- Hologram|nil + +local function updateLeaderboard() + if not leaderboard then return end + local rows = {} + for _, entry in pairs(wins) do + rows[#rows + 1] = entry + end + table.sort(rows, function(a, b) return a.wins > b.wins end) + + local lines = { "§6§lArena: top players" } + for i = 1, math.min(5, #rows) do + lines[#lines + 1] = string.format("§e%d. §f%s §7- %d", i, rows[i].name, rows[i].wins) + end + if #rows == 0 then + lines[#lines + 1] = "§7no winners yet" + end + leaderboard.lines = lines +end + +local function spawnLeaderboard() + if leaderboard then leaderboard:destroy() end + leaderboard = nil + local point = config.leaderboard + if not point then return end + leaderboard = mc.world(point.world):spawnHologram(vec(point.x, point.y, point.z), "Arena") + updateLeaderboard() +end + +-- ── Scoreboard and boss bar ──────────────────────────────────────────────── + +local bar = mc.createBossBar("Arena", "yellow", "progress") + +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status + + for uuid, player in pairs(game.players) do + local s = stats(player) + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + "", + "Kills this round: §e" .. (game.kills[uuid] or 0), + "Wins: §e" .. s.wins, + }, + } + end +end + +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + stats(winner).wins = stats(winner).wins + 1 + wins[winner.uuid] = { name = winner.name, wins = stats(winner).wins } + updateLeaderboard() + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + stats(player).deaths = stats(player).deaths + 1 + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + game.kills[killer.uuid] = (game.kills[killer.uuid] or 0) + 1 + stats(killer).kills = stats(killer).kills + 1 + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + game.kills = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + stats(player).games = stats(player).games + 1 + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end + updateUI() +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + bar:addPlayer(player) + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + updateUI() + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + game.kills[uuid] = nil + hideUI(player) + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() +spawnLeaderboard() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena setleaderboard", function(ctx) + config.leaderboard = here(ctx.player) + spawnLeaderboard() + ctx.player:sendMessage("Leaderboard placed.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) + +register("arena stats []", function(ctx, target) + local player = target or ctx.player + local s = stats(player) + ctx.player:sendMessage(string.format( + "§6%s§7: %d wins, %d kills, %d deaths in %d games", + player.name, s.wins, s.kills, s.deaths, s.games)) +end) diff --git a/site/tutorial/arena/07-kits.lua b/site/tutorial/arena/07-kits.lua new file mode 100644 index 0000000..ec84947 --- /dev/null +++ b/site/tutorial/arena/07-kits.lua @@ -0,0 +1,481 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" +local chestgui = require "core:chestgui" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + kills = {}, -- uuid -> kills this round + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Statistics ───────────────────────────────────────────────────────────── + +local function stats(player) + if not player.data.arenaStats then + player.data.arenaStats = { wins = 0, kills = 0, deaths = 0, games = 0 } + end + return player.data.arenaStats +end + +-- Wins of every player who ever won, for the leaderboard (offline players too). +mc.data.arenaWins = mc.data.arenaWins or {} +local wins = mc.data.arenaWins + +local leaderboard -- Hologram|nil + +local function updateLeaderboard() + if not leaderboard then return end + local rows = {} + for _, entry in pairs(wins) do + rows[#rows + 1] = entry + end + table.sort(rows, function(a, b) return a.wins > b.wins end) + + local lines = { "§6§lArena: top players" } + for i = 1, math.min(5, #rows) do + lines[#lines + 1] = string.format("§e%d. §f%s §7- %d", i, rows[i].name, rows[i].wins) + end + if #rows == 0 then + lines[#lines + 1] = "§7no winners yet" + end + leaderboard.lines = lines +end + +local function spawnLeaderboard() + if leaderboard then leaderboard:destroy() end + leaderboard = nil + local point = config.leaderboard + if not point then return end + leaderboard = mc.world(point.world):spawnHologram(vec(point.x, point.y, point.z), "Arena") + updateLeaderboard() +end + +-- ── Inventory ────────────────────────────────────────────────────────────── + +-- Player inventory slots: 0-35 main, 36-39 armor, 40 offhand. +local LAST_SLOT = 40 + +-- Saves the player's items in player.data and empties the inventory. +local function stash(player) + local saved = {} + for slot = 0, LAST_SLOT do + local item = player:getItem(slot) + if item then + saved[tostring(slot)] = item:serialise() + end + end + player.data.arenaStash = saved + player:clear() +end + +local function restore(player) + local saved = player.data.arenaStash + if not saved then return end + player:clear() + for slot, json in pairs(saved) do + player:setItem(tonumber(slot), mc.deserialise("item", json)) + end + player.data.arenaStash = nil +end + +-- ── Kits ─────────────────────────────────────────────────────────────────── + +local KITS = { + { + id = "warrior", name = "§cWarrior", icon = "minecraft:iron_sword", + items = { + { "minecraft:iron_sword", 1 }, { "minecraft:shield", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:iron_chestplate", + }, + { + id = "archer", name = "§aArcher", icon = "minecraft:bow", + items = { + { "minecraft:bow", 1 }, { "minecraft:arrow", 32 }, + { "minecraft:stone_sword", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:leather_chestplate", + }, + { + id = "tank", name = "§9Tank", icon = "minecraft:diamond_chestplate", + items = { + { "minecraft:wooden_sword", 1 }, { "minecraft:golden_apple", 2 }, + }, + chest = "minecraft:diamond_chestplate", + }, +} + +local function findKit(id) + for _, kit in ipairs(KITS) do + if kit.id == id then return kit end + end + return KITS[1] +end + +local function giveKit(player) + local kit = findKit(player.data.arenaKit) + for _, entry in ipairs(kit.items) do + player:give(entry[1], entry[2]) + end + player.chest = mc.createItem(kit.chest) +end + +local kitMenu = chestgui.create(1, "Choose a kit") +for i, kit in ipairs(KITS) do + local icon = mc.createItem(kit.icon, { name = kit.name, lore = { "§7Click to choose" } }) + kitMenu:set(1, i * 2 + 1, icon, function(player) + player.data.arenaKit = kit.id + player:sendMessage("Kit: " .. kit.name) + player:playSound("minecraft:ui.button.click") + return false + end) +end + +-- ── Scoreboard and boss bar ──────────────────────────────────────────────── + +local bar = mc.createBossBar("Arena", "yellow", "progress") + +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status + + for uuid, player in pairs(game.players) do + local s = stats(player) + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + "", + "Kills this round: §e" .. (game.kills[uuid] or 0), + "Wins: §e" .. s.wins, + "Kit: " .. findKit(player.data.arenaKit).name, + }, + } + end +end + +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + stats(winner).wins = stats(winner).wins + 1 + wins[winner.uuid] = { name = winner.name, wins = stats(winner).wins } + updateLeaderboard() + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + restore(player) + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + stats(player).deaths = stats(player).deaths + 1 + restore(player) + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + game.kills[killer.uuid] = (game.kills[killer.uuid] or 0) + 1 + stats(killer).kills = stats(killer).kills + 1 + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + game.kills = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + stash(player) + giveKit(player) + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + stats(player).games = stats(player).games + 1 + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end + updateUI() +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + bar:addPlayer(player) + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + updateUI() + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + game.kills[uuid] = nil + hideUI(player) + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() +spawnLeaderboard() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena setleaderboard", function(ctx) + config.leaderboard = here(ctx.player) + spawnLeaderboard() + ctx.player:sendMessage("Leaderboard placed.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) +register("arena kit", function(ctx) kitMenu:open(ctx.player) end) + +register("arena stats []", function(ctx, target) + local player = target or ctx.player + local s = stats(player) + ctx.player:sendMessage(string.format( + "§6%s§7: %d wins, %d kills, %d deaths in %d games", + player.name, s.wins, s.kills, s.deaths, s.games)) +end) diff --git a/site/tutorial/arena/08-events.lua b/site/tutorial/arena/08-events.lua new file mode 100644 index 0000000..882e5eb --- /dev/null +++ b/site/tutorial/arena/08-events.lua @@ -0,0 +1,486 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" +local chestgui = require "core:chestgui" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + kills = {}, -- uuid -> kills this round + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Statistics ───────────────────────────────────────────────────────────── + +local function stats(player) + if not player.data.arenaStats then + player.data.arenaStats = { wins = 0, kills = 0, deaths = 0, games = 0 } + end + return player.data.arenaStats +end + +-- Wins of every player who ever won, for the leaderboard (offline players too). +mc.data.arenaWins = mc.data.arenaWins or {} +local wins = mc.data.arenaWins + +local leaderboard -- Hologram|nil + +local function updateLeaderboard() + if not leaderboard then return end + local rows = {} + for _, entry in pairs(wins) do + rows[#rows + 1] = entry + end + table.sort(rows, function(a, b) return a.wins > b.wins end) + + local lines = { "§6§lArena: top players" } + for i = 1, math.min(5, #rows) do + lines[#lines + 1] = string.format("§e%d. §f%s §7- %d", i, rows[i].name, rows[i].wins) + end + if #rows == 0 then + lines[#lines + 1] = "§7no winners yet" + end + leaderboard.lines = lines +end + +local function spawnLeaderboard() + if leaderboard then leaderboard:destroy() end + leaderboard = nil + local point = config.leaderboard + if not point then return end + leaderboard = mc.world(point.world):spawnHologram(vec(point.x, point.y, point.z), "Arena") + updateLeaderboard() +end + +-- ── Inventory ────────────────────────────────────────────────────────────── + +-- Player inventory slots: 0-35 main, 36-39 armor, 40 offhand. +local LAST_SLOT = 40 + +-- Saves the player's items in player.data and empties the inventory. +local function stash(player) + local saved = {} + for slot = 0, LAST_SLOT do + local item = player:getItem(slot) + if item then + saved[tostring(slot)] = item:serialise() + end + end + player.data.arenaStash = saved + player:clear() +end + +local function restore(player) + local saved = player.data.arenaStash + if not saved then return end + player:clear() + for slot, json in pairs(saved) do + player:setItem(tonumber(slot), mc.deserialise("item", json)) + end + player.data.arenaStash = nil +end + +-- ── Kits ─────────────────────────────────────────────────────────────────── + +local KITS = { + { + id = "warrior", name = "§cWarrior", icon = "minecraft:iron_sword", + items = { + { "minecraft:iron_sword", 1 }, { "minecraft:shield", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:iron_chestplate", + }, + { + id = "archer", name = "§aArcher", icon = "minecraft:bow", + items = { + { "minecraft:bow", 1 }, { "minecraft:arrow", 32 }, + { "minecraft:stone_sword", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:leather_chestplate", + }, + { + id = "tank", name = "§9Tank", icon = "minecraft:diamond_chestplate", + items = { + { "minecraft:wooden_sword", 1 }, { "minecraft:golden_apple", 2 }, + }, + chest = "minecraft:diamond_chestplate", + }, +} + +local function findKit(id) + for _, kit in ipairs(KITS) do + if kit.id == id then return kit end + end + return KITS[1] +end + +local function giveKit(player) + local kit = findKit(player.data.arenaKit) + for _, entry in ipairs(kit.items) do + player:give(entry[1], entry[2]) + end + player.chest = mc.createItem(kit.chest) +end + +local kitMenu = chestgui.create(1, "Choose a kit") +for i, kit in ipairs(KITS) do + local icon = mc.createItem(kit.icon, { name = kit.name, lore = { "§7Click to choose" } }) + kitMenu:set(1, i * 2 + 1, icon, function(player) + player.data.arenaKit = kit.id + player:sendMessage("Kit: " .. kit.name) + player:playSound("minecraft:ui.button.click") + return false + end) +end + +-- ── Scoreboard and boss bar ──────────────────────────────────────────────── + +local bar = mc.createBossBar("Arena", "yellow", "progress") + +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status + + for uuid, player in pairs(game.players) do + local s = stats(player) + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + "", + "Kills this round: §e" .. (game.kills[uuid] or 0), + "Wins: §e" .. s.wins, + "Kit: " .. findKit(player.data.arenaKit).name, + }, + } + end +end + +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + stats(winner).wins = stats(winner).wins + 1 + wins[winner.uuid] = { name = winner.name, wins = stats(winner).wins } + updateLeaderboard() + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + restore(player) + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + + -- Tell other scripts, now that the winner has their own items back. + if winner and game.players[winner.uuid] then + mc.emit("arena:win", winner, game.kills[winner.uuid] or 0) + end + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + stats(player).deaths = stats(player).deaths + 1 + restore(player) + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + game.kills[killer.uuid] = (game.kills[killer.uuid] or 0) + 1 + stats(killer).kills = stats(killer).kills + 1 + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + game.kills = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + stash(player) + giveKit(player) + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + stats(player).games = stats(player).games + 1 + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end + updateUI() +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + bar:addPlayer(player) + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + updateUI() + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + game.kills[uuid] = nil + hideUI(player) + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() +spawnLeaderboard() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena setleaderboard", function(ctx) + config.leaderboard = here(ctx.player) + spawnLeaderboard() + ctx.player:sendMessage("Leaderboard placed.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) +register("arena kit", function(ctx) kitMenu:open(ctx.player) end) + +register("arena stats []", function(ctx, target) + local player = target or ctx.player + local s = stats(player) + ctx.player:sendMessage(string.format( + "§6%s§7: %d wins, %d kills, %d deaths in %d games", + player.name, s.wins, s.kills, s.deaths, s.games)) +end) diff --git a/site/tutorial/arena/08-rewards.lua b/site/tutorial/arena/08-rewards.lua new file mode 100644 index 0000000..b985f11 --- /dev/null +++ b/site/tutorial/arena/08-rewards.lua @@ -0,0 +1,21 @@ +-- config/ignis/arena_rewards.lua: rewards for arena winners. +local async = require "async" + +-- A Discord webhook URL to announce winners there, or nil to skip it. +local WEBHOOK = nil + +mc.on("arena:win", function(winner, kills) + winner:give("minecraft:diamond", 1 + kills) + mc.broadcast("§6" .. winner.name .. " §ewon an arena round with " .. kills .. " kills!") + + if WEBHOOK then + local response = async.fetch({ + url = WEBHOOK, + method = "POST", + json = { content = winner.name .. " won an arena round with " .. kills .. " kills" }, + }) + if not response.ok then + print("arena webhook failed: " .. tostring(response.error or response.status)) + end + end +end) diff --git a/site/tutorial/arena/09-final.lua b/site/tutorial/arena/09-final.lua new file mode 100644 index 0000000..cedb916 --- /dev/null +++ b/site/tutorial/arena/09-final.lua @@ -0,0 +1,512 @@ +-- config/ignis/arena.lua: a last-player-standing PvP arena. +local async = require "async" +local chestgui = require "core:chestgui" + +local ADMIN = "arena.admin" +local MIN_PLAYERS = 2 +local COUNTDOWN = 10 -- seconds before a round starts +local ROUND_TIME = 300 -- seconds before a round ends in a draw + +-- Corners, spawn points and lobby, kept across restarts by mc.data. +mc.data.arena = mc.data.arena or {} +local config = mc.data.arena +config.spawns = config.spawns or {} + +-- Where a player stands, as a plain table that can be saved. +local function here(player) + local p = player.pos + return { x = p.x, y = p.y, z = p.z, world = player.world.name } +end + +local function teleport(player, point) + player:teleport(point.x, point.y, point.z, point.world) +end + +local region -- Region|nil, created by buildRegion() below + +local function isReady() + return region ~= nil and config.lobby ~= nil and #config.spawns > 0 +end + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +-- Game state. It lives only in memory: a reload starts from scratch. +local game = { + phase = "waiting", -- waiting -> countdown -> playing -> ending + players = {}, -- uuid -> Player: everyone who joined + alive = {}, -- uuid -> true: still fighting (playing phase only) + kills = {}, -- uuid -> kills this round + timer = 0, -- seconds left in the current phase +} + +local function tell(text) + for _, player in pairs(game.players) do + player:sendMessage(text) + end +end + +-- ── Statistics ───────────────────────────────────────────────────────────── + +local function stats(player) + if not player.data.arenaStats then + player.data.arenaStats = { wins = 0, kills = 0, deaths = 0, games = 0 } + end + return player.data.arenaStats +end + +-- Wins of every player who ever won, for the leaderboard (offline players too). +mc.data.arenaWins = mc.data.arenaWins or {} +local wins = mc.data.arenaWins + +local leaderboard -- Hologram|nil + +local function updateLeaderboard() + if not leaderboard then return end + local rows = {} + for _, entry in pairs(wins) do + rows[#rows + 1] = entry + end + table.sort(rows, function(a, b) return a.wins > b.wins end) + + local lines = { "§6§lArena: top players" } + for i = 1, math.min(5, #rows) do + lines[#lines + 1] = string.format("§e%d. §f%s §7- %d", i, rows[i].name, rows[i].wins) + end + if #rows == 0 then + lines[#lines + 1] = "§7no winners yet" + end + leaderboard.lines = lines +end + +local function spawnLeaderboard() + if leaderboard then leaderboard:destroy() end + leaderboard = nil + local point = config.leaderboard + if not point then return end + leaderboard = mc.world(point.world):spawnHologram(vec(point.x, point.y, point.z), "Arena") + updateLeaderboard() +end + +-- ── Inventory ────────────────────────────────────────────────────────────── + +-- Player inventory slots: 0-35 main, 36-39 armor, 40 offhand. +local LAST_SLOT = 40 + +-- Saves the player's items in player.data and empties the inventory. +local function stash(player) + local saved = {} + for slot = 0, LAST_SLOT do + local item = player:getItem(slot) + if item then + saved[tostring(slot)] = item:serialise() + end + end + player.data.arenaStash = saved + player:clear() +end + +local function restore(player) + local saved = player.data.arenaStash + if not saved then return end + player:clear() + for slot, json in pairs(saved) do + player:setItem(tonumber(slot), mc.deserialise("item", json)) + end + player.data.arenaStash = nil +end + +-- ── Kits ─────────────────────────────────────────────────────────────────── + +local KITS = { + { + id = "warrior", name = "§cWarrior", icon = "minecraft:iron_sword", + items = { + { "minecraft:iron_sword", 1 }, { "minecraft:shield", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:iron_chestplate", + }, + { + id = "archer", name = "§aArcher", icon = "minecraft:bow", + items = { + { "minecraft:bow", 1 }, { "minecraft:arrow", 32 }, + { "minecraft:stone_sword", 1 }, { "minecraft:cooked_beef", 8 }, + }, + chest = "minecraft:leather_chestplate", + }, + { + id = "tank", name = "§9Tank", icon = "minecraft:diamond_chestplate", + items = { + { "minecraft:wooden_sword", 1 }, { "minecraft:golden_apple", 2 }, + }, + chest = "minecraft:diamond_chestplate", + }, +} + +local function findKit(id) + for _, kit in ipairs(KITS) do + if kit.id == id then return kit end + end + return KITS[1] +end + +local function giveKit(player) + local kit = findKit(player.data.arenaKit) + for _, entry in ipairs(kit.items) do + player:give(entry[1], entry[2]) + end + player.chest = mc.createItem(kit.chest) +end + +local kitMenu = chestgui.create(1, "Choose a kit") +for i, kit in ipairs(KITS) do + local icon = mc.createItem(kit.icon, { name = kit.name, lore = { "§7Click to choose" } }) + kitMenu:set(1, i * 2 + 1, icon, function(player) + player.data.arenaKit = kit.id + player:sendMessage("Kit: " .. kit.name) + player:playSound("minecraft:ui.button.click") + return false + end) +end + +-- ── Scoreboard and boss bar ──────────────────────────────────────────────── + +local bar = mc.createBossBar("Arena", "yellow", "progress") + +local function updateUI() + local status + if game.phase == "waiting" then + status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) + bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) + elseif game.phase == "countdown" then + status = string.format("Starting in %d s", game.timer) + bar.progress = game.timer / COUNTDOWN + elseif game.phase == "playing" then + local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 + status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) + bar.progress = game.timer / ROUND_TIME + else + status = "Round over" + bar.progress = 1 + end + bar.title = status + + for uuid, player in pairs(game.players) do + local s = stats(player) + player.sidebar = { + title = "§6§lArena", + lines = { + game.alive[uuid] and "§aYou are fighting" or "§7You are watching", + "Players: §e" .. count(game.players), + "", + "Kills this round: §e" .. (game.kills[uuid] or 0), + "Wins: §e" .. s.wins, + "Kit: " .. findKit(player.data.arenaKit).name, + }, + } + end +end + +local function hideUI(player) + bar:removePlayer(player) + player.sidebar = nil +end + +-- ── Rounds ───────────────────────────────────────────────────────────────── + +local function finish(winner) + game.phase = "ending" + + if winner then + stats(winner).wins = stats(winner).wins + 1 + wins[winner.uuid] = { name = winner.name, wins = stats(winner).wins } + updateLeaderboard() + for _, player in pairs(game.players) do + player:sendTitle("§6" .. winner.name, "wins the round!") + end + else + tell("§eThe round ended in a draw.") + end + + -- Celebrate for five seconds, then send everyone back to the lobby. + mc.schedule(0, function() + for _ = 1, 5 do + if winner and game.alive[winner.uuid] then + local pos = winner.pos + vec(0, 1, 0) + winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) + winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) + end + async.sleep(20) + end + for uuid in pairs(game.alive) do + local player = game.players[uuid] + restore(player) + teleport(player, config.lobby) + end + game.alive = {} + game.phase = "waiting" + + -- Tell other scripts, now that the winner has their own items back. + if winner and game.players[winner.uuid] then + mc.emit("arena:win", winner, game.kills[winner.uuid] or 0) + end + end) +end + +local function checkWinner() + if game.phase ~= "playing" then return end + local left = count(game.alive) + if left == 1 then + finish(game.players[next(game.alive)]) + elseif left == 0 then + finish(nil) + end +end + +local function eliminate(player, killer) + game.alive[player.uuid] = nil + stats(player).deaths = stats(player).deaths + 1 + restore(player) + teleport(player, config.lobby) + + if killer and game.alive[killer.uuid] then + game.kills[killer.uuid] = (game.kills[killer.uuid] or 0) + 1 + stats(killer).kills = stats(killer).kills + 1 + tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) + else + tell("§c" .. player.name .. " §7was eliminated") + end + checkWinner() +end + +local function startRound() + if not isReady() then + game.phase = "waiting" + tell("§cThe arena setup is incomplete, the round cannot start.") + return + end + game.phase = "playing" + game.timer = ROUND_TIME + game.alive = {} + game.kills = {} + + local i = 0 + for uuid, player in pairs(game.players) do + i = i + 1 + local spawn = config.spawns[(i - 1) % #config.spawns + 1] + game.alive[uuid] = true + stash(player) + giveKit(player) + player.health = player.maxHealth + player.food = 20 + teleport(player, spawn) + stats(player).games = stats(player).games + 1 + end + tell("§aFight!") +end + +-- Runs once per second and moves the game from phase to phase. +local function tick() + if game.phase == "waiting" then + if count(game.players) >= MIN_PLAYERS then + game.phase = "countdown" + game.timer = COUNTDOWN + end + elseif game.phase == "countdown" then + if count(game.players) < MIN_PLAYERS then + game.phase = "waiting" + tell("§eNot enough players, the countdown stopped.") + else + game.timer = game.timer - 1 + if game.timer <= 0 then + startRound() + elseif game.timer <= 3 then + tell("§e" .. game.timer .. "...") + end + end + elseif game.phase == "playing" then + game.timer = game.timer - 1 + if game.timer <= 0 then finish(nil) end + end + updateUI() +end + +mc.scheduleRepeating(20, 20, tick) + +-- ── Joining and leaving ──────────────────────────────────────────────────── + +local function join(player) + if not isReady() then + player:sendMessage("§cThe arena is not set up yet.") + elseif game.players[player.uuid] then + player:sendMessage("§eYou have already joined.") + elseif game.phase == "playing" or game.phase == "ending" then + player:sendMessage("§eA round is in progress, try again in a moment.") + else + game.players[player.uuid] = player + bar:addPlayer(player) + teleport(player, config.lobby) + tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") + updateUI() + end +end + +local function leave(player) + local uuid = player.uuid + if not game.players[uuid] then return end + if game.alive[uuid] then + eliminate(player, nil) + end + game.players[uuid] = nil + game.kills[uuid] = nil + hideUI(player) + player:sendMessage("§7You left the arena.") +end + +mc.on("player.leave", function(e) + leave(e.player) +end) + +-- Came back after a crash in the middle of a round: return their items. +mc.on("player.join", function(e) + if e.player.data.arenaStash then + restore(e.player) + if config.lobby then teleport(e.player, config.lobby) end + e.player:sendMessage("§eYour items from the last arena round were returned.") + end +end) + +-- Before a reload the game state is thrown away: put everyone back first. +mc.on("uninit", function() + for uuid, player in pairs(game.players) do + if game.alive[uuid] then + restore(player) + teleport(player, config.lobby) + end + player.sidebar = nil + player:sendMessage("§eThe arena was reloaded, join again with /arena join.") + end +end) + +-- ── Fights ───────────────────────────────────────────────────────────────── + +-- A fatal hit knocks the player out of the round instead of killing them. +mc.on("entity.death", function(e) + local player = e.player + if not player or not game.alive[player.uuid] then return end + e:cancel() + player.health = player.maxHealth + player.fireTicks = 0 + if game.phase == "playing" then + eliminate(player, e.attacker and game.players[e.attacker.uuid]) + end +end, { priority = "high" }) + +-- No friendly hits before the round starts. +mc.on("player.attack", function(e) + if game.players[e.player.uuid] and not game.alive[e.player.uuid] then + e:cancel() + end +end) + +-- ── The arena region ─────────────────────────────────────────────────────── + +local function isProtected(player, pos) + return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN) +end + +mc.on("block.break", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +mc.on("block.place", function(e) + if isProtected(e.player, e.pos) then e:cancel() end +end, { priority = "high" }) + +local function buildRegion() + if region then region:destroy() end + region = nil + local a, b = config.pos1, config.pos2 + if not (a and b) then return end + + -- A region is a box from its min corner up to (but not including) its max + -- corner, so +1 makes the max block part of it. + region = mc.world(a.world):createRegion( + vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), + vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) + ) + + -- Walking (or being knocked) out of the arena counts as losing. + region:on("leave", function(e) + if e.player and game.phase == "playing" and game.alive[e.player.uuid] then + e.player:sendMessage("§cYou left the arena!") + eliminate(e.player, nil) + end + end) + + -- Nobody else walks in during a round. + region:on("enter", function(e) + local player = e.player + if player and game.phase == "playing" and not game.alive[player.uuid] + and not player:hasPermission(ADMIN) then + teleport(player, config.lobby) + player:sendMessage("§eA round is in progress.") + end + end) +end + +buildRegion() +spawnLeaderboard() + +-- ── Commands ─────────────────────────────────────────────────────────────── + +local function corner(ctx, key) + local pos = ctx.player.pos + config[key] = { + x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), + world = ctx.player.world.name, + } + buildRegion() + local ready = region and ", the arena region is ready." or "." + ctx.player:sendMessage("Corner " .. key .. " set" .. ready) +end + +register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN) +register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN) + +register("arena addspawn", function(ctx) + local spawns = config.spawns + spawns[#spawns + 1] = here(ctx.player) + ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.") +end, ADMIN) + +register("arena clearspawns", function(ctx) + config.spawns = {} + ctx.player:sendMessage("All spawn points removed.") +end, ADMIN) + +register("arena setlobby", function(ctx) + config.lobby = here(ctx.player) + ctx.player:sendMessage("Lobby set.") +end, ADMIN) + +register("arena setleaderboard", function(ctx) + config.leaderboard = here(ctx.player) + spawnLeaderboard() + ctx.player:sendMessage("Leaderboard placed.") +end, ADMIN) + +register("arena join", function(ctx) join(ctx.player) end) +register("arena leave", function(ctx) leave(ctx.player) end) +register("arena kit", function(ctx) kitMenu:open(ctx.player) end) + +register("arena stats []", function(ctx, target) + local player = target or ctx.player + local s = stats(player) + ctx.player:sendMessage(string.format( + "§6%s§7: %d wins, %d kills, %d deaths in %d games", + player.name, s.wins, s.kills, s.deaths, s.games)) +end) diff --git a/site/tutorial/check-snippets.py b/site/tutorial/check-snippets.py new file mode 100644 index 0000000..6a4d4fd --- /dev/null +++ b/site/tutorial/check-snippets.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Checks that the Lua snippets in the tutorial pages are taken from the chapter's checkpoint files. + +Every ```lua block in site/src/content/docs/tutorial/NN-*.mdx must consist of lines that appear in one of +site/tutorial/arena/NN-*.lua (indentation included). Blocks marked ```lua nocheck are skipped. +""" +import pathlib +import re +import sys + +root = pathlib.Path(__file__).resolve().parent +pages = root.parent / "src" / "content" / "docs" / "tutorial" +code = root / "arena" + +errors = 0 +for page in sorted(pages.glob("[0-9][0-9]-*.mdx")): + chapter = page.name[:2] + files = sorted(code.glob(chapter + "-*.lua")) + if not files: + print(f"{page.name}: no checkpoint file {chapter}-*.lua") + errors += 1 + continue + known = set() + for f in files: + known.update(f.read_text(encoding="utf-8").splitlines()) + text = page.read_text(encoding="utf-8") + for block in re.finditer(r"^```lua([^\n]*)\n(.*?)^```", text, re.M | re.S): + if "nocheck" in block.group(1): + continue + first = text[: block.start()].count("\n") + 2 + for n, line in enumerate(block.group(2).splitlines()): + if line.strip() and line not in known: + print(f"{page.name}:{first + n}: not in {', '.join(f.name for f in files)}: {line.strip()}") + errors += 1 + +if errors: + sys.exit(1) +print("tutorial snippets match their checkpoints") diff --git a/site/tutorial/test.sh b/site/tutorial/test.sh new file mode 100755 index 0000000..2abbc5b --- /dev/null +++ b/site/tutorial/test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Checks the arena tutorial: the snippets in the pages match the checkpoint files, LuaLS finds no problems +# in them, and the game logic runs against a fake API (test/mock.lua). +# Needs python3, lua5.2 and lua-language-server (set LUALS to its path if it is not on PATH). +set -euo pipefail +cd "$(dirname "$0")" +LUALS=${LUALS:-lua-language-server} + +python3 check-snippets.py + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +failed=0 +for file in arena/*.lua; do + ws="$work/$(basename "$file" .lua)" + mkdir -p "$ws" + cp -r ../../lua-types "$ws/.types" + cp "$file" "$ws/" + cat > "$ws/.luarc.json" <<'JSON' +{ + "runtime.version": "Lua 5.2", + "runtime.builtin": { "io": "disable", "os": "disable", "debug": "disable" }, + "workspace.library": [".types"] +} +JSON + "$LUALS" --check="$ws" --checklevel=Hint --check_format=json --check_out_path="$ws/check.json" \ + --logpath="$ws/log" >/dev/null 2>&1 || true + if [ ! -f "$ws/check.json" ]; then + echo "$file: LuaLS produced no report"; failed=1 + elif [ "$(tr -d '[:space:]' < "$ws/check.json")" != "[]" ]; then + echo "$file: LuaLS problems:"; cat "$ws/check.json"; failed=1 + fi +done +[ "$failed" = 0 ] && echo "LuaLS: no problems" + +for n in 1 2 3 4 5 6 7 8 9; do + file=$(ls arena/0$n-*.lua | grep -v rewards) + lua5.2 test/smoke.lua test "$file" "$n" +done +lua5.2 test/scenario.lua test arena/09-final.lua +lua5.2 test/scenario.lua test arena/09-final.lua arena/08-rewards.lua + +exit "$failed" diff --git a/site/tutorial/test/mock.lua b/site/tutorial/test/mock.lua new file mode 100644 index 0000000..adb96e9 --- /dev/null +++ b/site/tutorial/test/mock.lua @@ -0,0 +1,270 @@ +-- A small fake of the PxIgnis API, just enough to run the tutorial scripts in plain Lua 5.2 and check +-- the game logic. It is not a model of the real server: behaviour the tutorial relies on is imitated +-- (storage copies tables on assignment, cancelled events stop later handlers, regions fire enter/leave). +local M = { log = {}, emitted = {}, tickNo = 0 } + +local function log(s) M.log[#M.log + 1] = s end + +-- ── persistent data: assignment copies tables (like DataTable) ───────────── +local function persist(src) + local store = {} + local proxy = setmetatable({}, { + __index = store, + __newindex = function(_, k, v) + assert(type(k) == "string" or type(k) == "number", "bad key " .. tostring(k)) + local t = type(v) + assert(t == "nil" or t == "string" or t == "number" or t == "boolean" or t == "table", + "Cannot store " .. t .. " values in persistent data") + if t == "table" then + if getmetatable(v) and getmetatable(v).__persist then + store[k] = v + else + assert(not getmetatable(v), "cannot store table with metatable (userdata-like)") + store[k] = M.persist(v) + end + else + store[k] = v + end + end, + __len = function() return #store end, + __pairs = function() return next, store, nil end, + __persist = true, + }) + for k, v in pairs(src or {}) do proxy[k] = v end + return proxy +end +M.persist = persist + +-- JSON-like round trip: sequences stay sequences, other keys become strings. +function M.roundtrip(p) + local function copy(t) + local out, n = {}, #t + local isList = n > 0 + for k in pairs(t) do + if type(k) ~= "number" or k < 1 or k > n then isList = false end + end + for k, v in pairs(t) do + local key = isList and k or tostring(k) + out[key] = type(v) == "table" and copy(v) or v + end + return out + end + return persist(copy(p)) +end + +-- ── vec ─────────────────────────────────────────────────────────────────── +local V = {} +V.__index = V +local function vec(x, y, z) return setmetatable({ x = x, y = y, z = z }, V) end +V.__add = function(a, b) + if type(b) == "number" then return vec(a.x + b, a.y + b, a.z + b) end + return vec(a.x + b.x, a.y + b.y, a.z + b.z) +end +V.__tostring = function(a) return string.format("(%g, %g, %g)", a.x, a.y, a.z) end +_G.vec = vec + +-- ── world / regions / holograms ─────────────────────────────────────────── +local worlds = {} +M.regions = {} +local function world(name) + if worlds[name] then return worlds[name] end + local w = { name = name } + function w:particle(id, pos, opts) assert(getmetatable(pos) == V) log("particle " .. id) end + function w:playSound(id, pos) log("sound " .. id) end + function w:spawnHologram(pos, text) + local h = { text = text, lines = { text }, pos = pos, destroyed = false } + function h:destroy() self.destroyed = true end + M.hologram = h + return h + end + function w:createRegion(a, b) + local r = { world = w, a = a, b = b, handlers = {}, inside = {}, destroyed = false } + function r:on(ev, fn) self.handlers[ev] = self.handlers[ev] or {}; table.insert(self.handlers[ev], fn); return 1 end + function r:contains(p) + return p.x >= a.x and p.x < b.x and p.y >= a.y and p.y < b.y and p.z >= a.z and p.z < b.z + end + function r:destroy() self.destroyed = true end + table.insert(M.regions, r) + return r + end + worlds[name] = w + return w +end + +-- ── players ─────────────────────────────────────────────────────────────── +M.players = {} +function M.player(name, opts) + local p = { + uuid = "uuid-" .. name, name = name, pos = vec(0, 64, 0), world = world("overworld"), + health = 20, maxHealth = 20, food = 20, fireTicks = 0, inv = {}, msgs = {}, titles = {}, + perms = opts and opts.perms or {}, online = true, + } + p.data = persist({}) + function p:sendMessage(t) table.insert(self.msgs, t); log(self.name .. " <- " .. t) end + function p:sendTitle(a, b) table.insert(self.titles, a .. " " .. (b or "")) end + function p:playSound() end + function p:hasPermission(perm) return self.perms[perm] == true end + function p:teleport(x, y, z, w) + assert(type(x) == "number" and type(y) == "number" and type(z) == "number", "teleport: numbers expected") + assert(w == nil or type(w) == "string", "teleport: world name") + self.pos = vec(x, y, z); self.world = world(w or self.world.name) + end + function p:give(id, count) + assert(type(id) == "string") + for slot = 0, 35 do + if not self.inv[slot] then self.inv[slot] = { id = id, count = count or 1 }; return end + end + end + function p:has(id) + for _, it in pairs(self.inv) do if it.id == id then return it end end + end + function p:getItem(slot) + assert(slot >= 0 and slot <= 40) + return self.inv[slot] and M.item(self.inv[slot].id) or nil + end + function p:setItem(slot, item) assert(type(slot) == "number"); self.inv[slot] = item and { id = item.id } or nil end + function p:clear() self.inv = {}; self.chestItem = nil end + setmetatable(p, { + __newindex = function(t, k, v) + if k == "chest" then rawset(t, "chestItem", v); return end + if k == "sidebar" then + assert(v == nil or type(v) == "table") + rawset(t, "sidebarValue", v); return + end + rawset(t, k, v) + end, + }) + table.insert(M.players, p) + return p +end + +function M.item(id) + local it = { id = id } + function it:serialise() return "json:" .. self.id end + return it +end + +-- ── events ──────────────────────────────────────────────────────────────── +local handlers = {} +local PRI = { lowest = -200, low = -100, normal = 0, high = 100, highest = 200 } +local mc = {} +function mc.on(name, fn, opts) + local pr = opts and opts.priority or 0 + if type(pr) == "string" then pr = assert(PRI[pr]) end + handlers[name] = handlers[name] or {} + table.insert(handlers[name], { fn = fn, pr = pr }) + table.sort(handlers[name], function(a, b) return a.pr > b.pr end) +end +function M.fire(name, fields, cancellable) + local e = fields or {} + e.name = name; e.cancelled = false + function e:cancel() assert(cancellable, "not cancellable " .. name); self.cancelled = true end + for _, h in ipairs(handlers[name] or {}) do + if e.cancelled then break end + local co = coroutine.create(h.fn) + local ok, err = coroutine.resume(co, e) + if not ok then error("handler of " .. name .. ": " .. tostring(err)) end + end + return e +end +function mc.emit(name, ...) + table.insert(M.emitted, { name, ... }) + for _, h in ipairs(handlers[name] or {}) do + local co = coroutine.create(h.fn) + local ok, err = coroutine.resume(co, ...) + if not ok then error("handler of " .. name .. ": " .. tostring(err)) end + end + return true +end +function mc.broadcast(t) log("broadcast " .. t) end + +-- ── scheduler + async ───────────────────────────────────────────────────── +local tasks = {} +function mc.schedule(delay, fn) table.insert(tasks, { at = M.tickNo + math.max(delay, 0), fn = fn }) end +function mc.scheduleRepeating(delay, interval, fn) table.insert(tasks, { at = M.tickNo + delay, every = interval, fn = fn }) end +local async = {} +function async.sleep(ticks) + local co = assert(coroutine.running(), "async.sleep outside coroutine") + table.insert(tasks, { at = M.tickNo + ticks, co = co }) + coroutine.yield() +end +local function runTask(t) + local co = t.co or coroutine.create(t.fn) + local ok, err = coroutine.resume(co) + if not ok then error("task: " .. tostring(err)) end +end +function M.regionTick() + for _, r in ipairs(M.regions) do + if not r.destroyed then + for _, p in ipairs(M.players) do + if p.online then + local now = p.world == r.world and r:contains(p.pos) + local was = r.inside[p.uuid] or false + if now ~= was then + r.inside[p.uuid] = now + for _, fn in ipairs(r.handlers[now and "enter" or "leave"] or {}) do + local co = coroutine.create(fn) + assert(coroutine.resume(co, { entity = p, player = p, region = r })) + end + end + end + end + end + end +end +function M.tick(n) + for _ = 1, n or 1 do + M.tickNo = M.tickNo + 1 + local due = {} + for i = #tasks, 1, -1 do + if tasks[i].at <= M.tickNo then table.insert(due, 1, table.remove(tasks, i)) end + end + for _, t in ipairs(due) do + if t.every then t.at = M.tickNo + t.every; table.insert(tasks, t) end + runTask(t) + end + M.regionTick() + end +end +function M.seconds(n) M.tick(n * 20) end + +-- ── misc API ────────────────────────────────────────────────────────────── +mc.data = persist({}) +function mc.world(name) return world(name) end +function mc.createItem(id, opts) local it = M.item(id); it.opts = opts; return it end +function mc.deserialise(kind, json) assert(kind == "item"); return M.item(json:sub(6)) end +function mc.createBossBar(title) + local b = { title = title, progress = 0, players = {} } + function b:addPlayer(p) self.players[p.uuid] = true end + function b:removePlayer(p) self.players[p.uuid] = nil end + M.bar = b + return b +end +M.commands = {} +function _G.register(syntax, fn, perm) M.commands[syntax] = { fn = fn, perm = perm } end +function M.run(player, syntax, ...) + local c = assert(M.commands[syntax], "no command " .. syntax) + if c.perm then assert(player:hasPermission(c.perm), "no permission") end + local co = coroutine.create(c.fn) + local ok, err = coroutine.resume(co, { player = player }, ...) + if not ok then error(syntax .. ": " .. tostring(err)) end +end + +local chestgui = {} +function chestgui.create(rows, title) + local g = { rows = rows, title = title, cbs = {} } + function g:set(r, c, item, cb) assert(c >= 1 and c <= 9); self.cbs[(r - 1) * 9 + c] = { item = item, cb = cb } end + function g:open(p) M.openGui = self end + function g:close(p) end + function g:click(p, slot) local s = self.cbs[slot]; return s and s.cb and s.cb(p, slot, "pickup", s.item, nil) end + return g +end + +package.preload["async"] = function() return async end +package.preload["core:chestgui"] = function() return chestgui end +_G.mc = mc +M.mc = mc +function M.reset() + handlers = {}; tasks = {}; M.regions = {}; M.commands = {} +end +return M diff --git a/site/tutorial/test/scenario.lua b/site/tutorial/test/scenario.lua new file mode 100644 index 0000000..458ce78 --- /dev/null +++ b/site/tutorial/test/scenario.lua @@ -0,0 +1,128 @@ +-- Plays the finished arena through: setup, kits, rounds, knockouts, leaving, reloads, a restart. +-- lua5.2 scenario.lua [arena_rewards.lua] +package.path = arg[1] .. "/?.lua;" .. package.path +local M = require "mock" +local script = arg[2] + +local admin = M.player("Admin", { perms = { ["arena.admin"] = true } }) +local alice = M.player("Alice") +local bob = M.player("Bob") +for _, p in ipairs({ alice, bob }) do p.pos = vec(100, 64, 100) end + +assert(loadfile(script))() +if arg[3] then assert(loadfile(arg[3]))() end + +-- setup +admin.pos = vec(0.5, 60, 0.5); M.run(admin, "arena pos1") +admin.pos = vec(20.2, 70, 20.9); M.run(admin, "arena pos2") +assert(#M.regions == 1, "region after both corners") +local r = M.regions[1] +assert(r:contains(vec(20.5, 70.5, 20.5)) and not r:contains(vec(21, 70, 20)), "max block included") +M.run(alice, "arena join") +assert(alice.msgs[#alice.msgs]:find("not set up"), "join before setup refused") +admin.pos = vec(2, 61, 2); M.run(admin, "arena addspawn") +admin.pos = vec(18, 61, 18); M.run(admin, "arena addspawn") +admin.pos = vec(50, 64, 50); M.run(admin, "arena setlobby") +admin.pos = vec(52, 64, 50); M.run(admin, "arena setleaderboard") +assert(M.hologram and M.hologram.lines[2]:find("no winners"), "empty leaderboard") +assert(#mc.data.arena.spawns == 2) + +-- kits +M.run(alice, "arena kit") +M.openGui:click(alice, 5) -- second kit (col 5) +assert(alice.data.arenaKit == "archer", tostring(alice.data.arenaKit)) + +-- inventories to stash +alice.inv = { [0] = { id = "minecraft:diamond" }, [38] = { id = "minecraft:elytra" } } +bob.inv = { [5] = { id = "minecraft:stone" } } + +M.run(alice, "arena join") +assert(alice.pos.x == 50, "teleported to lobby") +M.seconds(2) +assert(M.bar.title:find("Waiting"), M.bar.title) +M.run(bob, "arena join") +M.seconds(1) +assert(M.bar.title:find("Starting in"), M.bar.title) +M.seconds(10) +assert(M.bar.title:find("Alive: 2"), M.bar.title) +assert(alice.data.arenaStash["0"] == "json:minecraft:diamond" and alice.data.arenaStash["38"] == "json:minecraft:elytra") +assert(alice:has("minecraft:bow") and not alice:has("minecraft:diamond"), "archer kit, own items stashed") +assert(bob:has("minecraft:iron_sword"), "warrior kit default") +assert(alice.chestItem.id == "minecraft:leather_chestplate") +assert(r:contains(alice.pos) and r:contains(bob.pos), "players in arena") +assert(alice.sidebarValue.lines[1]:find("fighting")) + +-- block protection +local e = M.fire("block.break", { player = alice, pos = vec(5, 61, 5) }, true) +assert(e.cancelled, "arena protected") +e = M.fire("block.break", { player = admin, pos = vec(5, 61, 5) }, true) +assert(not e.cancelled, "admin can build") +e = M.fire("block.break", { player = alice, pos = vec(500, 61, 5) }, true) +assert(not e.cancelled, "outside not protected") + +-- alice kills bob +bob.health = 0 +e = M.fire("entity.death", { entity = bob, player = bob, attacker = alice, source = "player_attack", amount = 5 }, true) +assert(e.cancelled and bob.health == 20, "death cancelled") +assert(bob.pos.x == 50, "bob back in lobby") +assert(bob.inv[5].id == "minecraft:stone" and bob.data.arenaStash == nil, "bob items back") +assert(bob.data.arenaStats.deaths == 1 and alice.data.arenaStats.kills == 1) +assert(#M.emitted == 0, "win announced after the celebration") +assert(alice.titles[1]:find("Alice")) +assert(M.hologram.lines[2]:find("Alice") and M.hologram.lines[2]:find("1"), M.hologram.lines[2]) +-- winner leaves region during celebration: no elimination +alice.pos = vec(100, 64, 100); M.tick(1) +alice.pos = vec(5, 61, 5) +M.seconds(6) +assert(alice.pos.x == 50 and alice.inv[38].id == "minecraft:elytra" and not alice:has("minecraft:bow"), "alice restored") +assert(#M.emitted == 1 and M.emitted[1][1] == "arena:win" and M.emitted[1][2] == alice and M.emitted[1][3] == 1) +if arg[3] then + local d = alice.inv[1] + assert(d and d.id == "minecraft:diamond" and d.count == 2, "reward kept after restore") +end +assert(alice.data.arenaStats.deaths == 0 and alice.data.arenaStats.wins == 1) + +-- next round starts automatically, bob walks out of the arena +M.seconds(11) +assert(M.bar.title:find("Alive: 2"), M.bar.title) +bob.pos = vec(100, 64, 100); M.tick(1) +assert(bob.msgs[#bob.msgs - 1]:find("left the arena") or bob.msgs[#bob.msgs]:find("eliminated"), bob.msgs[#bob.msgs]) +assert(bob.pos.x == 50 and alice.data.arenaStats.wins == 2 or alice.data.arenaStats.wins == 2) +M.seconds(6) + +-- third round: bob disconnects mid-round +M.seconds(11) +assert(M.bar.title:find("Alive: 2"), M.bar.title) +M.fire("player.leave", { player = bob }) +bob.online = false +assert(alice.data.arenaStats.wins == 3) +M.seconds(6) + +-- a stranger walks into a running round +local carol = M.player("Carol"); carol.pos = vec(100, 64, 100) +M.run(carol, "arena join") +M.seconds(11) +assert(M.bar.title:find("Alive: 2"), M.bar.title) +local dave = M.player("Dave"); dave.pos = vec(100, 64, 100); M.tick(1) +dave.pos = vec(3, 61, 3); M.tick(1) +assert(dave.pos.x == 50, "stranger teleported out") + +-- crash: carol has a stash; simulate a lost state +local stash = carol.data.arenaStash +assert(stash, "carol stashed") +M.fire("uninit", {}) +assert(carol.data.arenaStash == nil and carol.pos.x == 50, "uninit restores") + +-- persisted data survives a JSON round trip (restart) +mc.data = M.roundtrip(mc.data) +M.reset() +assert(loadfile(script))() +assert(#M.regions == 1 and M.regions[1]:contains(vec(20.5, 70.5, 20.5)), "region rebuilt after restart") +local eve = M.player("Eve"); eve.pos = vec(100, 64, 100) +eve.data = M.roundtrip(M.persist({ arenaStash = { ["3"] = "json:minecraft:apple" } })) +M.fire("player.join", { player = eve }) +assert(eve.inv[3].id == "minecraft:apple" and eve.pos.x == 50, "crash recovery") +M.run(eve, "arena stats []") +assert(eve.msgs[#eve.msgs]:find("0 wins"), eve.msgs[#eve.msgs]) +assert(M.hologram.lines[2]:find("Alice"), "leaderboard after restart") +print("OK: " .. #M.log .. " log lines") diff --git a/site/tutorial/test/smoke.lua b/site/tutorial/test/smoke.lua new file mode 100644 index 0000000..4fbe8dc --- /dev/null +++ b/site/tutorial/test/smoke.lua @@ -0,0 +1,60 @@ +-- Smoke test for a tutorial checkpoint: lua5.2 smoke.lua +package.path = arg[1] .. "/?.lua;" .. package.path +local M = require "mock" +local ch = tonumber(arg[3]) + +local admin = M.player("Admin", { perms = { ["arena.admin"] = true } }) +local alice = M.player("Alice") +local bob = M.player("Bob") +alice.pos = vec(100, 64, 100); bob.pos = vec(100, 64, 100) +alice.inv = { [0] = { id = "minecraft:diamond" } } + +assert(loadfile(arg[2]))() + +if ch >= 2 then + admin.pos = vec(0, 60, 0); M.run(admin, "arena pos1") + admin.pos = vec(20, 70, 20); M.run(admin, "arena pos2") + assert(#M.regions == 1) + local e = M.fire("block.place", { player = alice, pos = vec(1, 61, 1) }, true) + assert(e.cancelled, "protected") +end +admin.pos = vec(2, 61, 2); M.run(admin, "arena addspawn") +admin.pos = vec(18, 61, 18); M.run(admin, "arena addspawn") +admin.pos = vec(50, 64, 50); M.run(admin, "arena setlobby") + +M.run(alice, "arena join"); M.run(bob, "arena join") +assert(alice.pos.x == 50 and bob.pos.x == 50, "in lobby") + +if ch == 1 then + M.run(bob, "arena leave") + assert(bob.msgs[#bob.msgs]:find("left")) + print("OK ch1"); return +end +if ch == 2 then print("OK ch2"); return end + +M.seconds(12) +assert(alice.pos.x ~= 50 and bob.pos.x ~= 50, "round started, players at spawns") +if ch >= 7 then assert(alice.data.arenaStash and not alice:has("minecraft:diamond"), "stashed") end +if ch >= 5 then assert(M.bar.title:find("Alive"), M.bar.title); assert(alice.sidebarValue) end + +if ch == 3 then + M.seconds(300) + assert(alice.msgs[#alice.msgs]:find("draw"), alice.msgs[#alice.msgs]) + M.seconds(6) + assert(alice.pos.x == 50 and bob.pos.x == 50, "back in lobby after draw") + print("OK ch3"); return +end + +bob.health = 0 +local e = M.fire("entity.death", { entity = bob, player = bob, attacker = alice, source = "player_attack", amount = 5 }, true) +assert(e.cancelled and bob.pos.x == 50) +assert(alice.titles[1]:find("Alice")) +M.seconds(6) +assert(alice.pos.x == 50, "winner back in lobby") +if ch >= 6 then + M.run(alice, "arena stats []") + assert(alice.msgs[#alice.msgs]:find("1 wins, 1 kills"), alice.msgs[#alice.msgs]) +end +if ch >= 7 then assert(alice:has("minecraft:diamond") and not alice.data.arenaStash, "restored") end +if ch >= 8 then assert(#M.emitted == 1) else assert(#M.emitted == 0) end +print("OK ch" .. ch) diff --git a/src/main/java/ru/pyxiion/ignis/Utils.kt b/src/main/java/ru/pyxiion/ignis/Utils.kt index 6071e5f..964142f 100644 --- a/src/main/java/ru/pyxiion/ignis/Utils.kt +++ b/src/main/java/ru/pyxiion/ignis/Utils.kt @@ -18,7 +18,11 @@ import java.util.concurrent.CancellationException import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException -fun CommandSource.checkPermission(permission: String): Boolean = Permissions.check(this, permission) +/** Op level that has a script permission when no permissions mod decides it (same as `/ignis`). */ +const val SCRIPT_PERMISSION_LEVEL = 4 + +fun CommandSource.checkPermission(permission: String): Boolean = + Permissions.check(this, permission, SCRIPT_PERMISSION_LEVEL) fun luaTableOf(vararg items: Pair): LuaTable { return LuaTable().apply { diff --git a/src/main/java/ru/pyxiion/ignis/api/wrapper/PlayerWrap.kt b/src/main/java/ru/pyxiion/ignis/api/wrapper/PlayerWrap.kt index 1417be5..644364f 100644 --- a/src/main/java/ru/pyxiion/ignis/api/wrapper/PlayerWrap.kt +++ b/src/main/java/ru/pyxiion/ignis/api/wrapper/PlayerWrap.kt @@ -28,6 +28,7 @@ import ru.pyxiion.ignis.PxIgnis import ru.pyxiion.ignis.api.MetaTableRegistry import ru.pyxiion.ignis.api.manager.SidebarManager import ru.pyxiion.ignis.api.util.metaTable +import ru.pyxiion.ignis.SCRIPT_PERMISSION_LEVEL import ru.pyxiion.ignis.unwrap object PlayerWrap { @@ -145,7 +146,8 @@ object PlayerWrap { method("hasPermission") { args -> val self = args.arg(1).checktable() - LuaValue.valueOf(Permissions.check(self.unwrap(), args.arg(2).checkjstring())) + val player = self.unwrap() + LuaValue.valueOf(Permissions.check(player, args.arg(2).checkjstring(), SCRIPT_PERMISSION_LEVEL)) } method("sendMessage") { args ->