A game of Go

master
Lars Mueller 2022-07-13 14:21:03 +02:00
commit d28dfcae3d
31 changed files with 1525 additions and 0 deletions

11
.luacheckrc Normal file
View File

@ -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";
}

123
Readme.md Normal file
View File

@ -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 <kbd>F1</kbd>) 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

498
board_entity.lua Normal file
View File

@ -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)

View File

@ -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

230
build/generate_models.lua Normal file
View File

@ -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))

8
conf.lua Normal file
View File

@ -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,
}

40
crafts.lua Normal file
View File

@ -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

384
game.lua Normal file
View File

@ -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

21
init.lua Normal file
View File

@ -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"

62
items.lua Normal file
View File

@ -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

18
locale/go.de.tr Normal file
View File

@ -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)

18
locale/template.txt Normal file
View File

@ -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)=

8
mod.conf Normal file
View File

@ -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

10
models.lua Normal file
View File

@ -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"
}

BIN
models/go_board_13x13.b3d Normal file

Binary file not shown.

BIN
models/go_board_19x19.b3d Normal file

Binary file not shown.

BIN
models/go_board_9x9.b3d Normal file

Binary file not shown.

Binary file not shown.

BIN
models/go_piece.b3d Normal file

Binary file not shown.

BIN
models/go_stone.b3d Normal file

Binary file not shown.

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
sounds/go_board_place.ogg Normal file

Binary file not shown.

BIN
sounds/go_stone_place.1.ogg Normal file

Binary file not shown.

BIN
sounds/go_stone_place.2.ogg Normal file

Binary file not shown.

BIN
sounds/go_stone_place.3.ogg Normal file

Binary file not shown.

41
textures.lua Normal file
View File

@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 B

BIN
textures/go_line_color.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 B

BIN
textures/go_stone_B.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 B

BIN
textures/go_stone_W.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 B