From 1a3bbffb57d8f9bf7c37beec360748effd95709c Mon Sep 17 00:00:00 2001 From: "Y. Wang" Date: Tue, 2 May 2023 14:36:59 +0200 Subject: [PATCH] Initial commit --- README.md | 31 +++ init.lua | 437 ++++++++++++++++++++++++++++++ mod.conf | 3 + textures/xiangqi_blank.png | Bin 0 -> 82 bytes textures/xiangqi_board.png | Bin 0 -> 355 bytes textures/xiangqi_figure_frame.png | Bin 0 -> 89 bytes textures/xiangqi_figure_side.png | Bin 0 -> 82 bytes textures/xiangqi_figures.png | Bin 0 -> 424 bytes 8 files changed, 471 insertions(+) create mode 100644 README.md create mode 100644 init.lua create mode 100644 mod.conf create mode 100644 textures/xiangqi_blank.png create mode 100644 textures/xiangqi_board.png create mode 100644 textures/xiangqi_figure_frame.png create mode 100644 textures/xiangqi_figure_side.png create mode 100644 textures/xiangqi_figures.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f810a2 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Xiangqi - Chinese chess for Minetest + +## Usage + +The board currently does not have a crafting recipe. You currently need +to get it from the inventory in creative mode. + +Right-click a figure to select it (or, if it is already selected, +deselect it). The selected figure is marked green, and the possible +target positions are marked, with (in particular) targetable figures +from the opposing side marked blue. Left-click on the target to move +the selected figure to the target position. + +The figures face the side that should make a move. + +Wikipedia has a page on the rules (and pieces) of Xiangqi. + +## Limitations/To-do + +* The game does not check whether the move results in the king/general +(帥/將) being captured. +* The game does not check for check(mate)s. +* The player is not notified when the game ends. +* The moves are not recorded. +* The figures may appear weird in certain circumstances. + +## Licenses + +* The source code is licensed under GNU AGPLv3. +* Textures (see below for exceptions) are licensed under CC0. +* `xiangqi_figures.png` is licensed under SIL OFL. diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..1c0fc40 --- /dev/null +++ b/init.lua @@ -0,0 +1,437 @@ +local S = minetest.get_translator "xiangqi" + +local yoff = -15/32 +local vstep = 16/177 +local figheight = 1/2+yoff + +local function objects_in_cube(pos, r) + return minetest.get_objects_in_area(vector.offset(pos, -r, -r, -r), vector.offset(pos, r, r, r)) +end + +local v2d = {} +local v2d_mt = { + __add = function(a, b) + return v2d.new(a.x+b.x, a.y+b.y) + end, + __sub = function(a, b) + return v2d.new(a.x-b.x, a.y-b.y) + end, + __unm = function(v) + return v2d.new(-v.x, -v.y) + end, + __eq = function(a, b) + return a.x == b.x and a.y == b.y + end, + __index = v2d, +} + +function v2d.new(x, y) + return setmetatable({x = x, y = y}, v2d_mt) +end + +function v2d:copy() + return v2d.new(self.x, self.y) +end + +function v2d:transpose() + return v2d.new(self.y, self.x) +end + +function v2d:to3d(base) + local row, col = self.y, self.x + local v = vector.new((col-4)*vstep, yoff+figheight/2, (row-4.5)*vstep) + if base then + return base + v + else + return v + end +end + +function v2d:in_bound() + return self.x >= 0 and self.x <= 8 and self.y >= 0 and self.y <= 9 +end + +function v2d:assert_bound() + if self:in_bound() then + return self + end + return nil +end + +function v2d.raynext(step, pos) + return (step+pos):assert_bound() +end + +function v2d.forray(pos, step) + return v2d.raynext, step, pos +end + +local game = {} +local game_mt = {__index = game} + +function game.new(pos) + return setmetatable({ + basepos = pos, + }, game_mt) +end + +local function spawn_piece(...) + local p0, row, col = ... + local obj = minetest.add_entity(v2d.new(col, row):to3d(p0), "xiangqi:figure", minetest.serialize{...}) + return obj +end + +local function spawn_target_marker(p0, row, col) + return minetest.add_entity(v2d.new(col, row):to3d(p0), "xiangqi:target_marker", minetest.serialize{p0, row, col}) +end + +function game:setup() + self:set_done(false) + local pos = self.basepos + for ptype, rmod in pairs{ + [false] = function(x) return x end, + [true] = function(x) return 9-x end, + } do + for _, cmod in pairs { + function(x) return x end, + function(x) return 8-x end, + } do + for _, t in pairs { + {0, 0, "chariot"}, + {0, 1, "horse"}, + {0, 2, "elephant"}, + {0, 3, "advisor"}, + {2, 1, "cannon"}, + {3, 0, "pawn"}, + {3, 2, "pawn"}, + } do + spawn_piece(pos, rmod(t[1]), cmod(t[2]), t[3], ptype) + end + end + for r, fig in pairs { + [0] = "king", + [3] = "pawn", + } do + spawn_piece(pos, rmod(r), 4, fig, ptype) + end + end +end + +function game:teardown() + for _, obj in pairs(objects_in_cube(self.basepos, 0.5)) do + local ename = obj:get_entity_name() + if ename == "xiangqi:figure" or ename == "xiangqi:target_marker" then + obj:remove() + end + end +end + +function game:node_meta() + local meta = minetest.get_meta(self.basepos) + self.node_meta = function() return meta end + return meta +end + +function game:get_done() + return minetest.is_yes(self:node_meta():get_int("gameend")) +end + +function game:set_done(st) + self:node_meta():set_int("gameend", st and 1 or 0) +end + +function game:get_side() + return minetest.is_yes(self:node_meta():get_int("pturn")) +end + +function game:set_side(s) + self:node_meta():set_int("pturn", s and 1 or 0) +end + +function game:flip_side() + return self:set_side(not self:get_side()) +end + +function game:get_selection() + local meta = self:node_meta() + local row, col = meta:get_int("selrow"), meta:get_int("selcol") + return v2d.new(col-1, row-1):assert_bound() +end + +function game:set_selection(pos) + local meta = self:node_meta() + local row, col = 0, 0 + if pos then + row, col = pos.y+1, pos.x+1 + end + meta:set_int("selrow", row) + meta:set_int("selcol", col) +end + +function game:get_figure(pos) + if not pos then + return nil + end + for _, obj in pairs(minetest.get_objects_inside_radius(pos:to3d(self.basepos), vstep/2)) do + if obj:get_entity_name() == "xiangqi:figure" then + return obj:get_luaentity() + end + end + return nil +end + +function game:get_selected_figure() + return self:get_figure(self:get_selection()) +end + +function game:update_board() + for _, obj in pairs(objects_in_cube(self.basepos, 0.5)) do + local ename = obj:get_entity_name() + if ename == "xiangqi:figure" then + local ent = obj:get_luaentity() + ent._reachable = false + ent:_update_appearance() + elseif ename == "xiangqi:target_marker" then + obj:remove() + end + end + local selfig = self:get_selected_figure() + if selfig then + for _, pos in pairs(selfig:_list_reachable()) do + local fig = self:get_figure(pos) + if fig then + if fig._pside ~= selfig._pside then + fig._reachable = true + fig:_update_appearance() + end + else + spawn_target_marker(self.basepos, pos.y, pos.x) + end + end + end +end + +function game:move(fig, tpos) + if not fig then + return false + end + local tfig = self:get_figure(tpos) + if tfig then + if tfig._ptype == "king" then + self:set_done(true) + end + tfig.object:remove() + end + fig._row, fig._col, fig._pos = tpos.y, tpos.x, tpos + fig.object:set_pos(tpos:to3d(self.basepos)) + return true +end + +function game:gamemove(tpos) + local selfig = self:get_selected_figure() + self:set_selection() + self:update_board() + minetest.after(0, function() + if self:move(selfig, tpos) then + self:flip_side() + self:update_board() + end + end) +end + +local figstex = "xiangqi_figure_side.png" + +minetest.register_entity("xiangqi:target_marker", { + visual = "cube", + visual_size = {x = vstep, y = figheight}, + selectionbox = {-vstep/2, -figheight/2, -vstep/2, vstep/2, figheight/2, vstep/2}, + textures = {figstex, figstex, figstex, figstex, figstex, figstex}, + on_activate = function(self, staticdata) + self._p0, self._row, self._col = unpack(minetest.deserialize(staticdata or {})) + self._game = game.new(self._p0) + self._pos = v2d.new(self._col, self._row) + self.object:set_armor_groups{immortal = 100} + end, + on_punch = function(self) + self._game:gamemove(self._pos) + end, +}) + +minetest.register_entity("xiangqi:figure", { + initial_properties = { + visual = "cube", + visual_size = {x = vstep, y = figheight}, + selectionbox = {-vstep/2, -figheight/2, -vstep/2, vstep/2, figheight/2, vstep/2} + }, + on_activate = function(self, staticdata) + self._p0, self._row, self._col, self._ptype, self._pside = unpack(minetest.deserialize(staticdata or {})) + self.object:set_armor_groups{immortal = 100} + self._game = game.new(self._p0) + self._pos = v2d.new(self._col, self._row) + self:_update_appearance() + end, + get_staticdata = function(self) + return minetest.serialize{self._p0, self._row, self._col, self._ptype, self._pside} + end, + on_rightclick = function(self, clicker) + if self._game:get_side() ~= self._pside then + if clicker:is_player() then + minetest.chat_send_player(clicker:get_player_name(), S("It is not yet your turn.")) + end + return + end + if self:_is_selected() then + self._game:set_selection() + elseif self._game:get_done() then + if clicker:is_player() then + minetest.chat_send_player(clicker:get_player_name(), S("The game has ended")) + end + else + self._game:set_selection(self._pos) + end + self._game:update_board() + end, + on_punch = function(self) + if self._reachable then + self._game:gamemove(self._pos) + end + end, + _list_reachable = function(self) + local game = self._game + local t = {} + local fourdirs = {v2d.new(0, 1), v2d.new(0, -1), v2d.new(1, 0), v2d.new(-1, 0)} + local diagdirs = {v2d.new(1, 1), v2d.new(1, -1), v2d.new(-1, 1), v2d.new(-1, -1)} + if self._ptype == "king" then + local midrow = self._pside and 8 or 1 + for _, step in pairs(fourdirs) do + local pos = self._pos+step + if math.abs(pos.x-4) <= 1 and math.abs(pos.y-midrow) <= 1 then + table.insert(t, pos) + end + for pos in self._pos:forray(step) do + local fig = game:get_figure(pos) + if fig then + if fig._ptype == "king" then + table.insert(t, pos) + end + break + end + end + end + elseif self._ptype == "advisor" then + local midrow = self._pside and 8 or 1 + for _, step in pairs(diagdirs) do + local pos = self._pos+step + if math.abs(pos.x-4) <= 1 and math.abs(pos.y-midrow) <= 1 then + table.insert(t, pos) + end + end + elseif self._ptype == "elephant" then + for _, step in pairs(diagdirs) do + local p1 = self._pos+step + if (not game:get_figure(p1)) then + local tar = p1+step + if (tar.y > 4.5) == self._pside then + table.insert(t, tar:assert_bound()) + end + end + end + elseif self._ptype == "horse" then + for _, s1 in pairs(fourdirs) do + local p1 = self._pos+s1 + if not game:get_figure(p1) then + local p2 = p1+s1 + local n = s1:transpose() + for _, s2 in pairs{n, -n} do + table.insert(t, (p2+s2):assert_bound()) + end + end + end + elseif self._ptype == "chariot" then + for _, step in pairs(fourdirs) do + for pos in self._pos:forray(step) do + table.insert(t, pos) + if game:get_figure(pos) then + break + end + end + end + elseif self._ptype == "cannon" then + for _, step in pairs(fourdirs) do + local o = 0 + for pos in self._pos:forray(step) do + if game:get_figure(pos) then + o = o+1 + if o == 2 then + table.insert(t, pos) + break + end + end + if o == 0 then + table.insert(t, pos) + end + end + end + elseif self._ptype == "pawn" then + table.insert(t, (self._pos+v2d.new(0, self._pside and -1 or 1)):assert_bound()) + if (self._pos.y <= 4.5) == self._pside then + for _, d in pairs{v2d.new(1, 0), v2d.new(-1, 0)} do + table.insert(t, (self._pos+d):assert_bound()) + end + end + end + return t + end, + _is_selected = function(self) + return self._game:get_selection() == self._pos + end, + _update_appearance = function(self) + local tex = { + king = 0, + advisor = 1, + elephant = 2, + horse = 3, + chariot = 4, + cannon = 5, + pawn = 6, + } + local tex = tex[self._ptype] + if tex then + tex = ("xiangqi_figures.png^[sheet:7x2:%d,%d"):format(tex, self._pside and 1 or 0) + else + tex = figstex + end + if self:_is_selected() then + tex = tex .. "^(xiangqi_figure_frame.png^[multiply:green)" + elseif self._reachable then + tex = tex .. "^(xiangqi_figure_frame.png^[multiply:blue)" + end + self.object:set_properties { + textures = {tex, figstex, figstex, figstex, figstex, figstex}, + } + if self._game:get_side() then + self.object:set_yaw(math.pi) + else + self.object:set_yaw(0) + end + end, +}) + +local blankbg = "xiangqi_blank.png" +minetest.register_node("xiangqi:board", { + description = "Xiangqi board", + drawtype = "nodebox", + node_box = { + type = "fixed", + fixed = {-1/2, -1/2, -1/2, 1/2, yoff, 1/2} + }, + tiles = {"xiangqi_board.png", blankbg, blankbg, blankbg, blankbg, blankbg}, + inventory_image = "xiangqi_board.png", + groups = {cracky=1}, + on_construct = function(pos) + game.new(pos):setup() + end, + on_destruct = function(pos) + game.new(pos):teardown() + end +}) diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..2549189 --- /dev/null +++ b/mod.conf @@ -0,0 +1,3 @@ +name=xiangqi +title=Xiangqi +description=Xiangqi (Chinese chess) diff --git a/textures/xiangqi_blank.png b/textures/xiangqi_blank.png new file mode 100644 index 0000000000000000000000000000000000000000..0dc40e94c3a53a44eb3306f1cabcc57d0f752733 GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^j3CU&3?x-=hn)ga%mF?juJ3k-N|*^%0J&VAE{-7_ bvdIZRHWLHm`4gs3fh-13S3j3^P6ig$MdAxs4j>}%}7keK!=@05p_P(=vV6lDx z^+v2e|FiyF>_=Gt!ulQ7AAMhcl75Z#cgP~&$Uh%NU4!x+Z002ovPDHLkV1hr7 BuCxFE literal 0 HcmV?d00001 diff --git a/textures/xiangqi_figure_frame.png b/textures/xiangqi_figure_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..def5e726b07697c37af7ad3f12b29a01206e6a5b GIT binary patch literal 89 zcmeAS@N?(olHy`uVBq!ia0vp^0wBx?BpA#)4xIr~OeH~n!3+##lh0ZJc|x8pjv*Y^ klYji5zp!y)#Fzi}3_L5ixvw3NkOk@SboFyt=akR{0AS%4lK=n! literal 0 HcmV?d00001 diff --git a/textures/xiangqi_figure_side.png b/textures/xiangqi_figure_side.png new file mode 100644 index 0000000000000000000000000000000000000000..bfd567e78a344cd2d1b13155e8c308ff04fefecf GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^j3CU&3?x-=hn)ga%mF?juK(|@k}m%-8OY`Gba4#f ckWEeivY8kd&z~@T3S=>Oy85}Sb4q9e0GA^YnE(I) literal 0 HcmV?d00001 diff --git a/textures/xiangqi_figures.png b/textures/xiangqi_figures.png new file mode 100644 index 0000000000000000000000000000000000000000..63a471649d5600a27f7f55ddf5c75fe27c0d346a GIT binary patch literal 424 zcmV;Z0ayNsP)KA1 zNrjk+p)3%A5