commit d28dfcae3df533e1a1ffd8e53dbee427b74faea6 Author: Lars Mueller Date: Wed Jul 13 14:21:03 2022 +0200 A game of Go diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..aac1d04 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,11 @@ +globals = { + "go"; + "visible_wielditem"; + -- HACK item entity override + minetest = {fields = {"registered_entities"}}; +} +read_globals = { + "minetest", "vector", "ItemStack"; + table = {fields = {"copy"}}; + "modlib", "fslib"; +} \ No newline at end of file diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..3ebd287 --- /dev/null +++ b/Readme.md @@ -0,0 +1,123 @@ +# Go + +A game of Go for Minetest. + +![Screenshot](screenshot.png) + +## Instructions + +How to play a game of Go: + +1. Craft Go boards & (infinite) stones +2. Place a Go board +3. Make a move or pass (either right-click the board to open the formspec, then click on an intersection or right-click with infinite Go stones of the appropriate color) +4. Wait for your opponent to make a move (or pass) +5. Go back to step 3 +6. After both you and your opponent have passed consecutively the marking phase begins +7. Mark captured groups (only if any groups remain that don't have two eyes; otherwise the board is scored immediately) +8. Wait for your opponent to mark captured groups +9. After both you and your opponent have accepted which groups were marked as captured, proceed to scoring +10. Reset the board + +Using a bare hand, the board can be picked up at any time (only the protection owner can pick it up if the location where it resides is protected). The full game state will be kept by the item - simply place the board again to resume the game. + +### Rules + +Standard [Rules of Go](https://en.wikipedia.org/wiki/Rules_of_Go), where the following choices have been made: + +* Basic ("japanese") ko rule, but no super ko rules +* Area ("chinese") scoring; games should ideally be played out (no neutral area left at the end of the game, all groups have two eyes) - otherwise groups need to be marked as captured manually + +## Dependencies + +Required ("hard") dependencies: + +* [`modlib`](https://github.com/appgurueu/modlib) +* [`fslib`](https://github.com/appgurueu/fslib) + +Optional development dependencies: + +* [`strictest`](https://github.com/appgurueu/strictest) +* [`dbg`](https://github.com/appgurueu/dbg) + +Optional "hinted" dependencies that may provide items for crafts (various alternatives exist): + +* [`default`](https://github.com/minetest/minetest_game/tree/master/mods/default) +* [`dye`](https://github.com/minetest/minetest_game/tree/master/mods/dye) + +both are part of [Minetest Game](https://github.com/minetest/minetest_game) and most derivatives. + +Note that technically these mods don't have to load first for Go to work, hence they are only "hinted". + +Optional, supported dependencies: + +* [`visible_wielditem`](https://github.com/appgurueu/visible_wielditem) + +Other recommendations: + +* [`craftguide`](https://content.minetest.net/packages/jp/craftguide/) for viewing the recipes +* [`i3`](https://content.minetest.net/packages/jp/i3/) an aesthetic inventory mod containing a crafting guide + +### Crafting + +For the craft recipes, items with the following groups are required: + +* `group:wood` and `group:dye,color_black` for boards; +* `group:stone` and and `group:dye,color_black` for black stones; +* `group:stone` and and `group:dye,color_white` for white stones + +if no items of a group are available, items that use the group in their craft won't be craftable. + +## Engine Limitations + +The options for implementing in-game Go boards are the following: + +* Use entities for stones. Not feasible at all due to entities being very heavy. +* Change the texture of the board as stones are placed; mix & match texture modifiers and dynamic media. Fills up the client cache; textures would get very large if stone textures are more than just a single color. +* Have two stones for every position hidden inside the board, move one up using a bone offset. Puts some strain on the network for sending the update bone position commands but is rather fine other than that & doesn't leak memory. Many boards will eventually lead to FPS drops as skeletal animation is implemented rather inefficiently, running on the CPU. **This is what this mod uses.** + +In the formspec stones would flicker the moment they are placed if styles were used for immediate feedback. This is because as soon as you stop pressing, the hovered state will be shown again until the new formspec reaches you. Elements that stay pressed (i.e. checkboxes) don't support the necessary styling yet. Thus placing stones has a certain "lag" to it. + +Another limitation is that no settings (or defaults) can be forced by a mod or game (apart from editing the global settings, which is extremely dirty and error-prone). Thus decent settings have to be recommended: + +## Recommended Settings + +* Enabling *shaders* (`enable_shaders = true`) is highly recommended to enhance the 3D look & feel of the board +* *Entity selectionboxes should be hidden* (`show_entity_selectionbox = false`); if entity highlighting is used, it should *not be set to halo*, but rather to *outline* (`node_highlighting = box`) as to not obstruct the view on the board +* *Smooth GUI image scaling* should be enabled (`gui_scaling_filter = true`) +* Enable *trilinear texture filtering* (`trilinear_filter = true`), *mipmapping* (`mip_map = true`) & *anisotropic filtering* (`anisotropic_filter = true`) for smoother board appearance +* **Consider** enabling *multi-sample anti-aliasing* (`msaa = 2`) for smooth board edges **if it doesn't [trigger a nasty rendering bug](https://github.com/minetest/minetest/issues/9072) on your setup** + +You can set these settings by adding the following lines at the end of your configuration file (`minetest.conf`): + + enable_shaders = true + show_entity_selectionbox = false + gui_scaling_filter = true + trilinear_filter = true + mip_map = true + anisotropic_filter = true + +(MSAA not included due to the aforementioned bug) + +Additionally, when playing using the formspec, first hiding the HUD (usually by pressing F1) is recommended. + +--- + +Language Support: + +* English: 100% +* German: 100% + +--- + +Links: [GitHub](https://github.com/appgurueu/go), [ContentDB](https://content.minetest.net/packages/LMD/go), [Minetest Forums](https://forum.minetest.net/viewtopic.php?t=28401) + +License: + +* Code: Written by Lars Müller and licensed under the **MIT** license +* Media: **CC0 & CC-BY 3.0** as follows: + * Models, textures & locales: Own work, all licensed CC0 + * Sounds: + * `go_board_place.ogg`: OGG version of a [`put_item.wav`](https://freesound.org/people/j1987/sounds/335751/) by [`j1987`](https://freesound.org/people/j1987), licensed under the CC0 license + * `go_stone_place.1.ogg`, `go_stone_place.2.ogg`: Derivatives of [`Glass Place 2.wav`](https://freesound.org/people/kelsey_w/sounds/467057/) by [`kelsey_w`](https://freesound.org/people/kelsey_w/), licensed under CC-BY 3.0 + * `go_stone_place.3.ogg`: Derivative of [`Glass Pick Up & Place.wav`](https://freesound.org/people/kelsey_w/sounds/467043/) by [`kelsey_w`](https://freesound.org/people/kelsey_w/), licensed under CC-BY 3.0 diff --git a/board_entity.lua b/board_entity.lua new file mode 100644 index 0000000..82fcb85 --- /dev/null +++ b/board_entity.lua @@ -0,0 +1,498 @@ +local Game = modlib.mod.include"game.lua" + +local T, models, textures, conf = go.T, go.models, go.textures, go.conf + +local board_thickness, stone_width, stone_height = conf.board_thickness, conf.stone_width, conf.stone_height + +local function _set_stone_bone(object, board_size, player, x, y, show) + object:set_bone_position(player .. ("%02d%02d"):format(x, y), + vector.new(0, show and ((board_thickness + stone_height/board_size) / 2) or 0, 0)) +end + +-- HACK hook into item entity to set Go board appearance, including placed stones +do + local item_ent_def = minetest.registered_entities["__builtin:item"] + local item_ent_def_set_item = item_ent_def.set_item + function item_ent_def:set_item(item, ...) + item_ent_def_set_item(self, item, ...) + local itemstack = ItemStack(item or self.itemstring) + local staticdata = itemstack:get_meta():get"go_staticdata" + if go.board_itemnames[itemstack:get_name()] and staticdata then + local game = Game.deserialize(staticdata) + self.object:set_properties{ + visual = "mesh", + mesh = models.boards[game.board_size], + textures = { + textures.boards[game.board_size], + "go_board_background.png", + "go_board_background.png", + "go_stone_W.png", + "go_stone_B.png" + }, + visual_size = 10 * self.object:get_properties().visual_size + } + for x, y, stone in game:xy_stones() do + _set_stone_bone(self.object, game.board_size, stone, x, y, true) + end + end + end +end + +local board = {} + +board.initial_properties = { + visual = "mesh", + mesh = models.boards[19], + textures = { + textures.boards[19], + "go_board_background.png", + "go_board_background.png", + "go_stone_W.png", + "go_stone_B.png" + }, + shaded = true, + backface_culling = true, + physical = true, + collisionbox = {-0.5, -0.05, -0.5, 0.5, 0.05, 0.5}, + visual_size = vector.new(10, 10, 10) -- blocksize +} + +function board:_set_stone_bone(...) + _set_stone_bone(self.object, self._game.board_size, ...) +end + +local gravity = vector.new(0, -9.81, 0) +function board:on_activate(staticdata) + local object = self.object + + if staticdata == "" then + minetest.log("warning", + ("[go] Board entity at %s has invalid staticdata, removing") + :format(minetest.pos_to_string(self.object:get_pos()))) + object:remove() + return + end + + local board_size = tonumber(staticdata) + local game + if board_size then + game = Game.new(board_size) + else + game = Game.deserialize(staticdata) + end + self._game = game + self._fs_viewers = {} + + object:set_acceleration(gravity) + object:set_armor_groups{punch_operable = 1} + object:set_properties{ + textures = {textures.boards[game.board_size], unpack(board.initial_properties.textures, 2)}, + mesh = models.boards[game.board_size] + } + for x, y, stone in game:xy_stones() do + self:_set_stone_bone(stone, x, y, true) + end +end + +function board:get_staticdata() + return self._game:serialize() +end + +function board:on_punch(puncher, _, tool_capabilities) + local game = self._game + local board_size = game.board_size + + -- Board pickup + if + puncher:get_wielded_item():is_empty() + and not minetest.is_protected(self.object:get_pos(), puncher:get_player_name()) + then + local item = ItemStack(("go:board_%dx%d"):format(board_size, board_size)) + local meta = item:get_meta() + meta:set_string("go_staticdata", self:get_staticdata()) + meta:set_string("description", + ("%s - %s vs %s"):format(item:get_description(), game.players.B or "?", game.players.W or "?")) + self:_close_formspecs() + self.object:remove() + puncher:set_wielded_item(item) + return + end + + if game:state() ~= "in_game" then + return -- can't place pieces + end + + if not (tool_capabilities.groupcaps["go:stones_" .. game.turn]) then + return -- not the right Go stone + end + + -- Determine eye position + local eye_pos = puncher:get_pos() + eye_pos.y = eye_pos.y + puncher:get_properties().eye_height + local first, third = puncher:get_eye_offset() + if not vector.equals(first, third) then + minetest.log("warning", "[go] First & third person eye offsets don't match, assuming first person") + end + eye_pos = vector.add(eye_pos, vector.divide(first, 10)) + -- Look dir + local dir = puncher:get_look_dir() + if dir.y >= 0 then + return -- looking up + end + -- Tool range + local range = puncher:get_wielded_item():get_definition().range + if (range or -1) < 0 then + local inv = puncher:get_inventory() + local hand = (inv and inv:get_size"hand" > 0) and inv:get_stack("hand", 1) or ItemStack() + range = hand:get_definition().range + if (range or -1) < 0 then + range = 4 + end + end + + -- Calculate world pos of intersection + local board_pos = self.object:get_pos() + local board_top_y = board_pos.y + board_thickness / 2 + local y_diff = board_top_y - eye_pos.y + local pos_on_ray = y_diff / dir.y + if pos_on_ray > range then + return -- out of range + end + local world_pos = eye_pos + vector.multiply(dir, pos_on_ray) + -- Relative position on the board, translated by 0.5 for convenience + local board_x, board_z = world_pos.x - board_pos.x + 0.5, world_pos.z - board_pos.z + 0.5 + if board_x < 0 or board_x > 1 or board_z < 0 or board_z > 1 then + return -- out of board bounds + end + self:_place(puncher, math.ceil(board_x * game.board_size), math.ceil(board_z * game.board_size)) +end + +function board:on_rightclick(clicker) + self:_show_formspec(clicker) +end + +function board:_place(placer, x, y) + local game = self._game + local color = game.turn + local captures = game:place(placer:get_player_name(), x, y) + if not captures then + return -- invalid move + end + self:_set_stone_bone(color, x, y, true) + for ci, stone in pairs(captures) do + self:_set_stone_bone(stone, game:get_xy(ci)) + end + do + local stone_center = vector.offset(self.object:get_pos(), + (x - 0.5) / game.board_size - 0.5, + (stone_height/game.board_size + board_thickness) / 2, + (y - 0.5) / game.board_size - 0.5) + -- Particle effect + local stone_extent = vector.divide(vector.new(stone_width, stone_height, stone_width), 2 * game.board_size) + minetest.add_particlespawner{ + time = 0.5, + amount = 10, + minpos = vector.subtract(stone_center, stone_extent), + maxpos = vector.add(stone_center, stone_extent), + minvel = vector.new(-0.5, 0, -0.5), + maxvel = vector.new(0.5, 0.5, 0.5), + minacc = vector.new(0, -0.981, 0), + maxacc = vector.new(0, -0.981, 0), + minsize = 0.2, + maxsize = 0.4, + minexptime = 0.2, + maxexptime = 0.4, + node = {name = "go:stones_" .. color}, + glow = 7, + } + -- Sound effect + minetest.sound_play("go_stone_place", { + pos = stone_center, + gain = 0.75 + 0.5 * math.random(), + pitch = 0.75 + 0.5 * math.random(), + max_hear_distance = 5, + }, true) + end + self:_update_formspecs() +end + +function board:_approve_scoring(viewer) + local game = self._game + local success, captures = game:approve(viewer:get_player_name()) + if not success then + return -- no formspec update necessary + end + if captures then + for i, stone in pairs(captures) do + self:_set_stone_bone(stone, game:get_xy(i)) + end + end + self:_update_formspecs() +end + +-- Formspec stuff + +function board:_reset() + local game = self._game + + -- Clear all stones + for x, y, stone in game:xy_stones() do + self:_set_stone_bone(stone, x, y) + end + -- Reset game + self._game = Game.new(game.board_size) + + self:_update_formspecs() +end + +local fs_size = 16 -- force 16x16 formspec dimensions +function board:_build_formspec(viewer) + local game = self._game + local state = game:state() + local board_size = game.board_size + local board_unit = fs_size / board_size + local viewers_turn, enter_game = game.players[game.turn] == viewer:get_player_name(), not game.players[game.turn] + + local function get_highlight(player) + if state == "scored" and player == game.winner then + return "winner_highlight" + end + if state == "in_game" and player == game.turn then + return "highlight" + end + return "plain" + end + + local text + if state == "in_game" then + if not game.players[game.turn] then + text = T"Make a move to enter the game" + elseif game.turn == "B" then + text = T"Black to play" + else assert(game.turn == "W") + text = T"White to play" + end + elseif state == "scoring" then + text = T"Mark captured groups" + else assert(state == "scored") + local scores, winner = game.scores, game.winner + if scores then + if winner == "B" then + text = T("Black wins @1 to @2", scores.B, scores.W) + elseif winner == "W" then + text = T("White wins @1 to @2", scores.W, scores.B) + else assert(scores.W == scores.B) + text = T("Draw (@1 each)", scores.B) + end + else + if winner == "B" then + text = T"Black wins (White resigned)" + else assert(winner == "W") + text = T"White wins (Black resigned)" + end + end + end + + -- Two bars with height 1 with 0.5 spacing + local form = { + -- Header + {"formspec_version", 2}; + {"size", {fs_size, fs_size + 3, false}}; + {"no_prepend"}, + + -- Transparent background + {"bgcolor", "#0000"}, + + -- Top bar + {"background", {0, 0}; {fs_size, 1}, "go_board_background.png"}; + -- Black player + {"image", {0, 0}; {1, 1}; textures.stones.B[get_highlight"B"]}; + {"label", {1 + fs_size / 100 --[[HACK: small offset to match the hypertext margin]], 0.5}; game.players.B or ""}; + -- White player + -- HACK use hypertext for right-aligned text + {"hypertext", {fs_size/2, 0}; {fs_size/2 - 1.25, 1}; ""; fslib.hypertext_root{ + fslib.hypertext_tags.global{color = "white", valign = "middle", halign = "right", margin = 0}, + game.players.W or "" + }}, + {"image", {fs_size - 1, 0}; {1, 1}; textures.stones.W[get_highlight"W"]}; + + -- Background + {"background", {0, 1.5}; {fs_size, fs_size}, textures.boards[board_size]}; + + -- Bottom bar + {"background", {0, fs_size + 2}; {fs_size, 1}, "go_board_background.png"}; + {"label", {0.25, fs_size + 2.5}; text}; + + -- Button styles + {"style_type", "button"; {bgcolor = "#EDD68E"}}; + {"style_type", "button:hovered"; {bgcolor = "#FDE69E"}}; + {"style_type", "button:pressed"; {bgcolor = "#DDC67E"}}; + } + + -- Add buttons to the bottom bar, right-to-left + local btn_wh, btn_count = {4, 1}, 0 + local function add_button(name, label) + btn_count = btn_count + 1 + table.insert(form, {"button", {fs_size - btn_wh[1] * btn_count, fs_size + 2}, btn_wh; name; label}) + end + + local stone_hover + if state == "in_game" then + stone_hover = textures.stones[game.turn].hover + table.insert(form, {"style_type", "image_button:hovered"; {fgimg = textures.stones[game.turn].hover}}) + -- Deliberately omitted to avoid flickering: + -- {"style_type", "image_button:pressed"; {fgimg = textures.stones[game.turn].plain}} + -- to be re-added when stylable image "checkbuttons" exist + if viewers_turn then + add_button("resign", T"Resign") + add_button("pass", T"Pass") + end + elseif state == "scoring" then + add_button("resume", T"Resume") + if not game.scoring.approvals[viewer:get_player_name()] then + add_button("score", T"Score") + end + elseif state == "scored" then + add_button("reset", T"Reset") + end + + -- Transform formspec coordinates to absolute board coordinates relative to viewer look dir + local look_dir = viewer:get_look_dir() + local function fs_to_board_coords(x, y) + local x_flipped, y_flipped = board_size - x + 1, board_size - y + 1 + if math.abs(look_dir.z) > math.abs(look_dir.x) then -- Z is the closest cardinal direction + if look_dir.z < 0 then -- -Z + return x_flipped, y + else -- +Z + return x, y_flipped + end + else -- X is the closest cardinal direction + if look_dir.x < 0 then -- -X + return y, x + else -- +X + return y_flipped, x_flipped + end + end + end + + for fs_x = 1, board_size do + for fs_y = 1, board_size do + local x, y = fs_to_board_coords(fs_x, fs_y) + local i = game:get_index(x, y) + local stone = game.stones[i] + + local xy, wh = {(fs_x - 1) * board_unit, 1.5 + (fs_y - 1) * board_unit}, {board_unit, board_unit} + local element + if stone then + if state == "scoring" and not game.groups[i].invincible then + local captured = game.scoring.captures[i] + local name = ("P%02d%02d"):format(x, y) + -- Marked as capture: Hover -> plain + local image = textures.stones[stone][captured and "hover" or "plain"] + -- Not marked as capture (yet): Plain -> hover + local hover = textures.stones[stone][captured and "plain" or "hover"] + table.insert(form, {"style", name .. ":hovered"; {fgimg = hover}}) + element = {"image_button", xy; wh; image; name; ""; true; hover} + else + local highlight + if state == "scored" or i ~= (game.last_action or {}).i then + highlight = "plain" + else + highlight = "highlight" + end + element = {"image", xy; wh; textures.stones[stone][highlight]} + end + elseif state == "in_game" and (viewers_turn or enter_game) and game.possible_moves[i] then + element = {"image_button", xy; wh; "blank.png"; ("P%02d%02d"):format(x, y); ""; true; stone_hover} + end + table.insert(form, element) -- inserting nil is a no-op + end + end + + table.insert(form, {"no_prepend"}) + + return form +end + +function board:_show_formspec(viewer) + local viewer_name = viewer:get_player_name() + self._fs_viewers[viewer_name] = fslib.show_formspec(viewer, self:_build_formspec(viewer), function(fields) + if fields.quit then + self._fs_viewers[viewer_name] = nil + return + end + + if not self.object:get_pos() then + return -- entity was removed + end + + local game = self._game + + local function get_xy() + for field in pairs(fields) do + local x, y = field:match"P(%d%d)(%d%d)" + x, y = tonumber(x), tonumber(y) + if x and y and x >= 1 and x <= game.board_size and y >= 1 and y <= game.board_size then + return x, y + end + end + end + + local state = game:state() + if state == "in_game" then + if fields.pass then + game:pass(viewer_name) + self:_update_formspecs() + elseif fields.resign then + game:resign(viewer_name) + self:_update_formspecs() + else + local x, y = get_xy() + if x and y then + self:_place(viewer, x, y) + end + end + elseif state == "scoring" then + if fields.score then + self:_approve_scoring(viewer) + elseif fields.resume then + game:resume() + self:_update_formspecs() + else + local x, y = get_xy() + if x and y then + if game:mark_capture(x, y) then + self:_update_formspecs() + end + end + end + else assert(state == "scored") + if fields.reset then + self:_reset() + end + end + end) +end + +function board:_update_formspecs() + for viewer_name, fs_id in pairs(self._fs_viewers) do + local viewer = minetest.get_player_by_name(viewer_name) + if viewer then + fslib.reshow_formspec(viewer, fs_id, self:_build_formspec(viewer)) + else -- viewer left + self._fs_viewers[viewer_name] = nil + end + end +end + +function board:_close_formspecs() + for viewer_name in pairs(self._fs_viewers) do + local viewer = minetest.get_player_by_name(viewer_name) + if viewer then + fslib.close_formspec(viewer) -- HACK (?) should perhaps allow passing the fs_id + end + self._fs_viewers[viewer_name] = nil + end +end + +minetest.register_entity("go:board", board) \ No newline at end of file diff --git a/build/collect_translation_strings.lua b/build/collect_translation_strings.lua new file mode 100644 index 0000000..be99d14 --- /dev/null +++ b/build/collect_translation_strings.lua @@ -0,0 +1,53 @@ +-- Collects translation strings; alternative to tools like https://github.com/minetest-tools/update_translations +--[[ + Calls must be T or S, followed by an optional parens, followed by a string using double quotes + Expects translation files to use @n rather than @\n +]] + +local modname = minetest.get_current_modname() +local base_path = minetest.get_modpath(modname) +local strs = {} +local filenames = minetest.get_dir_list(base_path, false) +table.sort(filenames) +for _, filename in ipairs(filenames) do + if filename:match"%.lua$" then + local lua = modlib.file.read(base_path .. "/" .. filename) + for str in lua:gmatch[[%W[TS]%s*%(?%s*(".-[^\]")]] do + str = setfenv(assert(loadstring("return"..str)), {})():gsub(".", { + ["\n"] = "@n", + ["="] = "@=", + }) + strs[str] = "" + table.insert(strs, str) + end + end +end + +local locale_path = base_path .. "/locale" +for _, filename in ipairs(minetest.get_dir_list(locale_path, false)) do + local filepath = locale_path .. "/" .. filename + local lines = {} + local existing_strs = {} + for line in io.lines(filepath) do + if line:match"^#" then -- preserve comments + table.insert(lines, line) + elseif line:match"%S" then + local str = line:match"^%s*(.-[^=])%s*=" + if strs[str] then + table.insert(lines, line) + existing_strs[str] = true + end + end + end + local textdomain = "# textdomain: " .. modname + if lines[1] ~= textdomain then + table.insert(lines, 1, textdomain) + end + for _, str in ipairs(strs) do + if not existing_strs[str] then + lines[#lines + 1] = str .. "=" + end + end + table.insert(lines, "") -- trailing newline + modlib.file.write(filepath, table.concat(lines, "\n")) +end \ No newline at end of file diff --git a/build/generate_models.lua b/build/generate_models.lua new file mode 100644 index 0000000..7e5b0b2 --- /dev/null +++ b/build/generate_models.lua @@ -0,0 +1,230 @@ +local conf = go.conf + +local board_thickness = conf.board_thickness -- in nodes +local stone_width, stone_height = conf.stone_width, conf.stone_height + +local function texture(file) + return { + file = file, + flags = 1, + blend = 2, + pos = {0, 0}, + scale = {1, 1}, + rotation = 0, + } +end + +local function brush(name, texture_id) + return { + name = name, + texture_id = {texture_id}, + color = {r = 1, g = 1, b = 1, a = 1}, + fx = 0, + blend = 1, + shininess = 0 + } +end + +local function add_box(vertices, tris, center, size) + for axis = 0, 2 do + local other_axis_1, other_axis_2 = 1 + ((axis + 1) % 3), 1 + ((axis - 1) % 3) + axis = axis + 1 + for dir = -1, 1, 2 do + local normal = {0, 0, 0} + normal[axis] = dir + for val_1 = -1, 1, 2 do + for val_2 = -1, 1, 2 do + local pos = {0, 0, 0} + pos[axis] = center[axis] + dir * size[axis]/2 + pos[other_axis_1] = center[other_axis_1] + val_1 * size[other_axis_1]/2 + pos[other_axis_2] = center[other_axis_2] + val_2 * size[other_axis_2]/2 + table.insert(vertices, { + pos = modlib.vector.apply(pos, modlib.math.fround), + tex_coords = {{(val_1+1)/2, (val_2+1)/2}} + }) + end + end + local last = #vertices + local function fix_winding_order(indices) + local poses = {} + for i = 1, 3 do + poses[i] = modlib.vector.new(vertices[indices[i]].pos) + end + local tri_normal = modlib.vector.triangle_normal(poses) + local cos_angle = tri_normal:dot(normal) + assert(cos_angle ~= 0, "normal is orthogonal to face normal") + if cos_angle < 0 then + modlib.table.reverse(indices) + modlib.table.reverse(poses) + tri_normal = modlib.vector.triangle_normal(poses) + assert(tri_normal:dot(normal) > 0) + end + return indices + end + table.insert(tris, fix_winding_order({last - 1, last - 2, last - 3})) + table.insert(tris, fix_winding_order({last - 1, last - 2, last})) + end + end +end + +local function write_board(size, filename) + local vertices = { + flags = 0, + tex_coord_sets = 1, + tex_coord_set_size = 2, + } + local tris = {} + + -- Add board + + add_box(vertices, tris, {0, 0, 0}, {1, board_thickness, 1}) + + local board = { + -- Always two tris per face + top = {tris[7], tris[8]}, + bottom = {tris[5], tris[6]}, + sides = {tris[1], tris[2], tris[3], tris[4], unpack(tris, 9)} -- everything else + } + + -- Add stones + + local stone_bones = {} + + local function add_stones(color) + local stone_tris = {} + + for i = 1, size do + for j = 1, size do + local first_vertex_index = #vertices + 1 + add_box(vertices, stone_tris, + {(i-.5) / size - 0.5, 0, (j -.5) / size - 0.5}, {stone_width / size, stone_height / size, stone_width / size}) + local bonename = ("%s%02d%02d"):format(color, i, j) + local weights = {} + for v_id = first_vertex_index, #vertices do + weights[v_id] = 1 + end + table.insert(stone_bones, { + name = bonename, + scale = {1, 1, 1}, + position = {0, 0, 0}, + rotation = {0, 0, 0, 1}, + children = {}, + bone = weights + }) + end + end + + return stone_tris + end + + local white_stones, black_stones + if size ~= "no_stones" then + white_stones = add_stones"W" + black_stones = add_stones"B" + end + + local board_model = { + version = { + major = 0, + minor = 1 + }, + textures = { + texture"go_board_top.png", + texture"go_board_bottom.png", + texture"go_board_background.png", + }, + brushes = { + brush("Board top", 1), + brush("Board bottom", 2), + brush("Board sides", 3), + }, + node = { + name = "Board", + scale = {1, 1, 1}, + position = {0, 0, 0}, + rotation = {0, 0, 0, 1}, + children = stone_bones, + mesh = { + vertices = vertices, + triangle_sets = { + { + brush_id = 1, + vertex_ids = board.top + }, + { + brush_id = 2, + vertex_ids = board.bottom + }, + { + brush_id = 3, + vertex_ids = board.sides + }, + } + } + }, + } + + if size ~= "no_stones" then + table.insert(board_model.textures, texture"go_stone_W.png") + table.insert(board_model.textures, texture"go_stone_B.png") + table.insert(board_model.brushes, brush("White stones", 4)) + table.insert(board_model.brushes, brush("Black stones", 5)) + table.insert(board_model.node.mesh.triangle_sets, { + brush_id = 4, + vertex_ids = white_stones + }) + table.insert(board_model.node.mesh.triangle_sets, { + brush_id = 5, + vertex_ids = black_stones + }) + end + + local file = assert(io.open(modlib.mod.get_resource("go", "models", filename), "wb")) + modlib.b3d.write(board_model, file) + file:close() +end + +for board_size, filename in pairs(go.models.boards) do + write_board(board_size, filename) +end + +local stone = { + version = { + major = 0, + minor = 1 + }, + textures = { + texture"go_stone_*.png" + }, + brushes = { + brush("Go stone", 1) + }, + node = { + name = "Go stone", + scale = {1, 1, 1}, + position = {0, 0, 0}, + rotation = {0, 0, 0, 1}, + children = {}, + mesh = { + vertices = { + flags = 0, + tex_coord_sets = 1, + tex_coord_set_size = 2, + }, + triangle_sets = { + { + brush_id = 1, + vertex_ids = {} + } + } + } + }, +} + +add_box( + stone.node.mesh.vertices, + stone.node.mesh.triangle_sets[1].vertex_ids, + {0, 0, 0}, {stone_width, stone_height, stone_width} +) + +modlib.file.write(modlib.mod.get_resource("go", "models", go.models.stone), modlib.b3d.write_string(stone)) diff --git a/conf.lua b/conf.lua new file mode 100644 index 0000000..4761034 --- /dev/null +++ b/conf.lua @@ -0,0 +1,8 @@ +-- "Codefiguration" since there's little point in users configuring these values +return { + board_sizes = modlib.table.set{9, 13, 19}, + board_thickness = 0.1, --[m] + -- in squares on the board + stone_width = 0.75, + stone_height = 0.375, +} \ No newline at end of file diff --git a/crafts.lua b/crafts.lua new file mode 100644 index 0000000..f77561f --- /dev/null +++ b/crafts.lua @@ -0,0 +1,40 @@ +local wood, blck = "group:wood", "group:dye,color_black" + +local brd9 = "go:board_9x9" + +minetest.register_craft({ + output = brd9, + recipe = { + {wood, blck, wood}, + {blck, wood, blck}, + {wood, blck, wood}, + }, +}) + +minetest.register_craft({ + output = "go:board_13x13", + recipe = { + {wood, blck, wood}, + {blck, brd9, blck}, + {wood, blck, wood}, + }, +}) +minetest.register_craft({ + output = "go:board_19x19", + recipe = { + {brd9, blck, brd9}, + {blck, wood, blck}, + {brd9, blck, brd9}, + }, +}) + +for color, long_name in pairs{B = "black", W = "white"} do + minetest.register_craft({ + output = "go:stones_" .. color, + type = "shapeless", + recipe = { + "group:stone", -- don't use default:stone here to be compatible with more games + "group:dye,color_" .. long_name, + }, + }) +end diff --git a/game.lua b/game.lua new file mode 100644 index 0000000..9ff6db3 --- /dev/null +++ b/game.lua @@ -0,0 +1,384 @@ +-- Utilities + +local next = next + +local table_empty = modlib.table.is_empty + +local function table_only_entry(tab) + local only = next(tab) + if next(tab, only) ~= nil then return nil end + return only +end + +-- TODO (?) use modlib.table.count_equals(tab, 1) for this once released +local function table_cnt_eq_one(tab) + local first_key = next(tab) + return first_key ~= nil and next(tab, first_key) == nil +end + +-- Go game "class" +-- Strict separation between named constructors Game.* and instance methods game:* +local Game = {} +local M = {} -- methods +local metatable = {__index = M} + +-- "Public" interface + +function Game.deserialize(str) + local self = minetest.deserialize(str) + setmetatable(self, metatable) + local state = self:state() + if state == "in_game" then + -- Also sets self.groups + self.possible_moves = self:_determine_possible_moves() + elseif state == "scoring" then + -- Also sets self.groups, determines two eye groups + self:_count_territory() -- deliberately ignore the result (territory counts) here + end + return self +end + +function Game.new(board_size) + local self = { + turn = "B", + players = {}, + stones = {}, + board_size = board_size, + -- Data derived from the board state: + groups = {}, + possible_moves = setmetatable({}, {__index = function(_, i) + assert(i >= 0 and i < board_size^2) + return true -- all moves are valid initially + end}) + } + setmetatable(self, metatable) + return self +end + +function M:serialize() + local groups, possible_moves = self.groups, self.possible_moves + self.groups, self.possible_moves = nil, nil + local serialized = minetest.serialize(self) + self.groups, self.possible_moves = groups, possible_moves + return serialized +end + +function M:state() + if self.scoring then + return "scoring" + elseif self.winner or self.scores then + return "scored" + else + return "in_game" + end +end + +function M:get_index(x, y) + return (y - 1) * self.board_size + (x - 1) +end + +function M:get_xy(i) + local x = i % self.board_size + local y = (i - x) / self.board_size + return x + 1, y + 1 +end + +function M:xy_stones() + local i + return function() + local stone + i, stone = next(self.stones, i) + if not i then return end + local x, y = self:get_xy(i) + return x, y, stone + end +end + +function M:place(playername, x, y) + if not self:_check_turn(playername) then + return + end + local i = self:get_index(x, y) + if not self.possible_moves[i] then + return false + end + self.stones[i] = self.turn + local captures = {} + self:_adjacent_intersections(x, y, function(nx, ny) + local ni = self:get_index(nx, ny) + local group = self.groups[ni] + if group and group.critical and self.stones[ni] ~= self.turn then + -- Kill critical group + for ci in pairs(group.stones) do + captures[ci] = self.stones[ci] + self.stones[ci] = nil + end + end + end) + self.last_action = {type = "place", ko = table_cnt_eq_one(captures), x = x, y = y, i = i} + self:_next_turn() + self.possible_moves = self:_determine_possible_moves() + if table_empty(self.possible_moves) then + self:pass(self.players[self.turn]) + end + return captures +end + +function M:pass(playername) + if not self:_check_turn(playername) then + return + end + local consecutive_passes = self.last_action.type == "pass" + self.last_action = {type = "pass"} + self:_next_turn() + self.possible_moves = nil + if consecutive_passes then -- 2 consecutive passes end the game + self:_count_territory() -- Also determines two eye groups; deliberately ignore the result (territory counts) here + local all_invincible = true + for _, group in pairs(self.groups) do + if not group.invincible then all_invincible = false break end + end + if all_invincible then + self:score() -- score immediately: no groups may be marked as dead + else + self.scoring = {captures = {}, approvals = {}} -- start scoring phase + end + else + local possible_moves = self:_determine_possible_moves() + if table_empty(possible_moves) then + self:pass(self.players[self.turn]) + else + self.possible_moves = possible_moves + end + end +end + +function M:resign(playername) + if not self:_check_turn(playername) then + return + end + self.last_action = {type = "resign"} + self:_next_turn() + self.winner = self.turn + self.turn = nil +end + +function M:mark_capture(x, y) + local i = self:get_index(x, y) + local group = self.groups[i] + if not group then + return -- can't mark free intersections as captured + end + if group.invincible then + return -- can't mark invincible groups as captured + end + local captures = self.scoring.captures + for j in pairs(group.stones) do + if captures[j] then + captures[j] = nil + else + captures[j] = assert(self.stones[i]) + end + end + self.scoring.approvals = {} -- clear approvals + return true +end + +function M:approve(playername) + local approvals = self.scoring.approvals + if approvals[playername] then + return + end + approvals[playername] = true + if approvals[self.players.B] and approvals[self.players.W] then + local captures = self.scoring.captures + self:score() + return true, captures + end + return true +end + +function M:resume() + self.scoring = nil -- end scoring phase + self.last_action = {type = "resume"} + self.possible_moves = self:_determine_possible_moves() +end + +function M:score() + if self.scoring then + -- Remove marked captures from the board + -- TODO (?) just ignore rather than remove these stones + for i in pairs(self.scoring.captures) do + self.stones[i] = nil + end + self.scoring = nil + end + -- Delete irrelevant information + self.turn, self.groups, self.possible_moves = nil, nil, nil + + local scores = self:_count_territory() + -- Area scoring: One point for each alive stone at the end of the game + for _, stone_color in pairs(self.stones) do + scores[stone_color] = scores[stone_color] + 1 + end + self.scores = scores + if scores.W > scores.B then + self.winner = "W" + elseif scores.B > scores.W then + self.winner = "B" + end +end + +-- "Private" methods + +function M:_adjacent_intersections(x, y, func) + if x > 1 then + func(x - 1, y) + end + if y > 1 then + func(x, y - 1) + end + if x < self.board_size then + func(x + 1, y) + end + if y < self.board_size then + func(x, y + 1) + end +end + +function M:_determine_groups() + -- Build a table of groups + local groups = {} -- [i] = {stones = {[i] = true, ...}, critical = bool} + for x, y, stone in self:xy_stones() do + local group, group_freedoms = {stones = {}, critical = false}, {} + local function visit(x, y) -- luacheck: ignore + local index = self:get_index(x, y) + if self.stones[index] == stone then + if groups[index] then + return + end + group.stones[index] = true + groups[index] = group + self:_adjacent_intersections(x, y, visit) + elseif not self.stones[index] then + group_freedoms[index] = true + end + end + visit(x, y) + -- Mark groups with a single liberty as "critical" + if table_cnt_eq_one(group_freedoms) then + group.critical = true + end + end + return groups +end + + +function M:_determine_possible_moves() + self.groups = self:_determine_groups() + + local possible_moves = {} + for x = 1, self.board_size do + for y = 1, self.board_size do + local i = self:get_index(x, y) + if not self.stones[i] then + local seki, ko = true, false -- seki ("suicide") & ko ("no immediate repetition") rule + self:_adjacent_intersections(x, y, function(nx, ny) + local ni = self:get_index(nx, ny) + local neighbor_stone = self.stones[ni] + if neighbor_stone then + local group = self.groups[ni] + if neighbor_stone == self.turn then -- connects to a friendly group + seki = seki and group.critical -- seki if all neighboring groups are critical + elseif group.critical then -- kills a critical enemy group + seki = false -- no seki since it creates liberties + local one_capture = table_cnt_eq_one(group.stones) + if self.last_action.type == "place" then + ko = ko or (one_capture and self.last_action.i == ni and self.last_action.ko) + end + end + else -- liberty + seki = false + end + end) + if not (seki or ko) then + possible_moves[i] = true + end + end + end + end + return possible_moves +end + +function M:_count_territory() + self.groups = self:_determine_groups() + + local eyes = {} + + local function count_territory(color) + local visited = {} + local area, border_groups, neutral + local function visit(x, y) + local index = self:get_index(x, y) + local stone = self.stones[index] + if stone == color then + border_groups[self.groups[index]] = true + elseif not stone then + if area[index] or visited[index] then + return + end + area[index] = true + visited[index] = true + self:_adjacent_intersections(x, y, visit) + else -- opponent stone + neutral = true + end + end + local territory = 0 + for x = 1, self.board_size do + for y = 1, self.board_size do + local i = self:get_index(x, y) + if not self.stones[i] then + area, border_groups, neutral = {}, {}, false + visit(x, y) + if not neutral then + for _ in pairs(area) do + territory = territory + 1 + end + local border_group = table_only_entry(border_groups) + if border_group ~= nil then + local eye_count = (eyes[border_group] or 0) + 1 + eyes[border_group] = eye_count + if eye_count >= 2 then + border_group.invincible = true + end + end + end + end + end + end + return territory + end + + return {B = count_territory"B", W = count_territory"W"} +end + +-- Turn utils + +function M:_check_turn(playername) + assert(self:state() == "in_game") + if self.players[self.turn] then + if self.players[self.turn] ~= playername then + return false + end + else + self.players[self.turn] = playername + end + return true +end + +function M:_next_turn() + self.turn = self.turn == "W" and "B" or "W" +end + +return Game \ No newline at end of file diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..5209407 --- /dev/null +++ b/init.lua @@ -0,0 +1,21 @@ +go = {} + +go.T = minetest.get_translator"go" + +local function load(name) + go[name] = assert(loadfile(modlib.mod.get_resource(name .. ".lua")))() +end + +load"conf" +load"models" + +-- Build scripts +--[[ +load"build/generate_models" -- depends on models +load"build/collect_translation_strings" +--]] + +load"textures" +load"items" +load"crafts" +load"board_entity" \ No newline at end of file diff --git a/items.lua b/items.lua new file mode 100644 index 0000000..8c910d0 --- /dev/null +++ b/items.lua @@ -0,0 +1,62 @@ +local visible_wielditem = rawget(_G, "visible_wielditem") + +local function tweak_wielditem(itemname, tweaks) + if visible_wielditem then + visible_wielditem.item_tweaks.names[itemname] = tweaks + end +end + +local T, models, textures, conf = go.T, go.models, go.textures, go.conf + +go.board_itemnames = {} + +for board_size in pairs(conf.board_sizes) do + local size_str = ("%dx%d"):format(board_size, board_size) + local itemname = "go:board_" .. size_str + go.board_itemnames[itemname] = true + tweak_wielditem(itemname, {position = vector.new(0, conf.board_thickness/2 - 0.25, 0)}) + -- TODO (?) use the same hack as for items on visible wielditem entities to display board constellation + minetest.register_node(itemname, { + description = T"Go Board" .. " (" .. size_str .. ")", + stack_max = 1, -- unstackable + drawtype = "mesh", + mesh = models.boards.no_stones, + tiles = {textures.boards[board_size], "go_board_background.png"}, + node_placement_prediction = "", -- disables prediction + on_place = function(itemstack, _, pointed_thing) + if pointed_thing.above.y <= pointed_thing.under.y then return end + local top_edge = -math.huge + for _, box in pairs(modlib.minetest.get_node_collisionboxes(pointed_thing.under)) do + if box[5] > top_edge then + top_edge = box[5] + end + end + if top_edge == -math.huge then + top_edge = 0.5 -- nonphysical node + end + local pos = vector.offset(pointed_thing.under, 0, top_edge + conf.board_thickness / 2, 0) + minetest.sound_play("go_board_place", {pos = pos, max_hear_distance = 10}, true) + minetest.add_entity(pos, "go:board", itemstack:get_meta():get"go_staticdata" or tostring(board_size)) + return ItemStack"" + end, + }) +end + +for letter, description in pairs{B = T"Infinite Black Go Stones", W = T"Infinite White Go Stones"} do + local itemname = "go:stones_" .. letter + tweak_wielditem(itemname, {position = vector.new(0, -0.2, 0)}) + minetest.register_node(itemname, { + description = description, + stack_max = 1, -- unstackable + drawtype = "mesh", + mesh = models.stone, -- HACK nodeboxes would work poorly here due to their fixed UV mapping + visual_scale = 0.5, + wield_scale = vector.new(0.5, 0.5, 0.5), + tiles = {("go_stone_%s.png"):format(letter)}, + node_placement_prediction = "", -- disables prediction + on_place = function() end, + -- HACK store this in the tool capabilities as the item that was used to punch is otherwise not known + -- and player:get_wielded_item() may be inaccurate + tool_capabilities = {groupcaps = {[itemname] = {}}} + }) +end \ No newline at end of file diff --git a/locale/go.de.tr b/locale/go.de.tr new file mode 100644 index 0000000..ed6718d --- /dev/null +++ b/locale/go.de.tr @@ -0,0 +1,18 @@ +# textdomain: go +Go Board=Go-Brett +Infinite Black Go Stones=Unendliche schwarze Go-Steine +Infinite White Go Stones=Unendliche weiße Go-Steine +Score=Auszählen +Resume=Wieder aufnehmen +Pass=Passen +Resign=Aufgeben +Reset=Zurücksetzen +Make a move to enter the game=Mach einen Zug, um ins Spiel einzusteigen +Black to play=Schwarz am Zug +White to play=Weiß am Zug +Mark captured groups=Markiere gefangene Gruppen +Black wins (White resigned)=Schwarz gewinnt (Weiß hat aufgegeben) +White wins (Black resigned)=Weiß gewinnt (Schwarz hat aufgegeben) +Black wins @1 to @2=Schwarz gewinnt @1 zu @2 +White wins @1 to @2=Weiß gewinnt @1 zu @2 +Draw (@1 each)=Unentschieden (@1 beide) diff --git a/locale/template.txt b/locale/template.txt new file mode 100644 index 0000000..f10d505 --- /dev/null +++ b/locale/template.txt @@ -0,0 +1,18 @@ +# textdomain: go +Go Board= +Infinite Black Go Stones= +Infinite White Go Stones= +Score= +Resume= +Pass= +Resign= +Reset= +Make a move to enter the game= +Black to play= +White to play= +Mark captured groups= +Black wins (White resigned)= +White wins (Black resigned)= +Black wins @1 to @2= +White wins @1 to @2= +Draw (@1 each)= diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..5c8cd2f --- /dev/null +++ b/mod.conf @@ -0,0 +1,8 @@ +name = go +title = Go +description = A game of Go +depends = modlib, fslib +# dev depends: strictest & dbg: these mods need to load first +# hinted depends: default & dye (for craft recipes; these mods don't need to load first) aren't on the list +# only actual optional dependency that needs to load first: visible_wielditem +optional_depends = strictest,dbg , visible_wielditem diff --git a/models.lua b/models.lua new file mode 100644 index 0000000..92a1231 --- /dev/null +++ b/models.lua @@ -0,0 +1,10 @@ +local boards = { + no_stones = "go_board_no_stones.b3d" +} +for size in pairs(go.conf.board_sizes) do + boards[size] = ("go_board_%dx%d.b3d"):format(size, size) +end +return { + boards = boards, + stone = "go_stone.b3d" +} \ No newline at end of file diff --git a/models/go_board_13x13.b3d b/models/go_board_13x13.b3d new file mode 100644 index 0000000..fcdebb7 Binary files /dev/null and b/models/go_board_13x13.b3d differ diff --git a/models/go_board_19x19.b3d b/models/go_board_19x19.b3d new file mode 100644 index 0000000..bd79ab3 Binary files /dev/null and b/models/go_board_19x19.b3d differ diff --git a/models/go_board_9x9.b3d b/models/go_board_9x9.b3d new file mode 100644 index 0000000..50fdf07 Binary files /dev/null and b/models/go_board_9x9.b3d differ diff --git a/models/go_board_no_stones.b3d b/models/go_board_no_stones.b3d new file mode 100644 index 0000000..3ff2120 Binary files /dev/null and b/models/go_board_no_stones.b3d differ diff --git a/models/go_piece.b3d b/models/go_piece.b3d new file mode 100644 index 0000000..bfef8ae Binary files /dev/null and b/models/go_piece.b3d differ diff --git a/models/go_stone.b3d b/models/go_stone.b3d new file mode 100644 index 0000000..a19852e Binary files /dev/null and b/models/go_stone.b3d differ diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..24fa55e Binary files /dev/null and b/screenshot.png differ diff --git a/sounds/go_board_place.ogg b/sounds/go_board_place.ogg new file mode 100644 index 0000000..ebbac6d Binary files /dev/null and b/sounds/go_board_place.ogg differ diff --git a/sounds/go_stone_place.1.ogg b/sounds/go_stone_place.1.ogg new file mode 100644 index 0000000..f80c79d Binary files /dev/null and b/sounds/go_stone_place.1.ogg differ diff --git a/sounds/go_stone_place.2.ogg b/sounds/go_stone_place.2.ogg new file mode 100644 index 0000000..3c7ccbb Binary files /dev/null and b/sounds/go_stone_place.2.ogg differ diff --git a/sounds/go_stone_place.3.ogg b/sounds/go_stone_place.3.ogg new file mode 100644 index 0000000..660168a Binary files /dev/null and b/sounds/go_stone_place.3.ogg differ diff --git a/textures.lua b/textures.lua new file mode 100644 index 0000000..b1a189e --- /dev/null +++ b/textures.lua @@ -0,0 +1,41 @@ +local conf = go.conf + +local round = modlib.math.round + +local boards = {} +for board_size in pairs(conf.board_sizes) do + local resolution = 12 * board_size + local margin = round(resolution * 0.5 / board_size) + local len = round(resolution * (board_size - 1) / board_size) + 1 + local combine = { + ([[([combine:%dx%d:0,0=go_board_background.png\^\[resize\:%dx%d]]) + :format(resolution, resolution, resolution, resolution) + } + for i = 1, board_size do + local pixel_coord = round(resolution * (i - 0.5) / board_size) + assert(pixel_coord % 1 == 0) + local line = [[%d,%d=go_line_color.png\^\[resize\:%dx%d]] + table.insert(combine, line:format(pixel_coord, margin, 1, len)) + table.insert(combine, line:format(margin, pixel_coord, len, 1)) + end + boards[board_size] = table.concat(combine, ":") .. ")" +end + +local stones = {} + +for _, color in pairs{"B", "W"} do + -- HACK the parentheses are a workaround for https://github.com/minetest/minetest/issues/12209 + local base = ("([combine:20x20:2,2=go_stone_%s.png)"):format(color) + local bg_color_fmt = "blank.png^[noalpha^[colorize:%s:255^[resize:20x20^" .. base + stones[color] = { + plain = base, + hover = base .. "^[opacity:128", + highlight = bg_color_fmt:format"cyan", + winner_highlight = bg_color_fmt:format"yellow" + } +end + +return { + boards = boards, + stones = stones +} \ No newline at end of file diff --git a/textures/go_board_background.png b/textures/go_board_background.png new file mode 100644 index 0000000..989e6ca Binary files /dev/null and b/textures/go_board_background.png differ diff --git a/textures/go_board_bottom.png b/textures/go_board_bottom.png new file mode 100644 index 0000000..989e6ca Binary files /dev/null and b/textures/go_board_bottom.png differ diff --git a/textures/go_line_color.png b/textures/go_line_color.png new file mode 100644 index 0000000..1b72e39 Binary files /dev/null and b/textures/go_line_color.png differ diff --git a/textures/go_stone_B.png b/textures/go_stone_B.png new file mode 100644 index 0000000..308b30b Binary files /dev/null and b/textures/go_stone_B.png differ diff --git a/textures/go_stone_W.png b/textures/go_stone_W.png new file mode 100644 index 0000000..6b3ab7a Binary files /dev/null and b/textures/go_stone_W.png differ