Morpion solitaire/Julia
Appearance
GUI Gtk app version.
using Cairo, Colors, GeometryTypes, Gtk
# Note: Eastward and southward are positive directions. Grid coordinates in the
# far left or upper areas may be negative.
#
# The convention in this program is to normalize coordinates to the origin from
# the point due east of the top of the cross, due north of the left of the cross:
#
# R XXXX
# X X
# X X
# XXXX XXXX
# X x
# X x
# XXXX XXXX
# X X
# X X
# XXXX
#
#
#------------- geometry related general functions ------------------#
Point2i = Point2{Int}
@enum Direction None SE E NE S # 0:4
const delta = Dict(SE => [1, 1], E => [1, 0], NE => [1, -1], S => [0, 1])
function direction(x1, y1, x2, y2)
dx, dy = x2 - x1, y2 - y1
return dx == 0 ? S : dy == 0 ? E : dx == dy ? NE : SE
end
direction(p1, p2) = direction(p1[1], p1[2], p2[1], p2[2])
offset(p1, p2) = (direction(p1, p2) == S ? p2[2] - p1[2] : p2[1] - p1[1])
struct Move
p::Point2i
d::Direction
o::Int
seg::Vector{Point2i}
Move(p, dir, offs, seg) = (@assert(length(seg) == 5); new(p, dir, offs, seg))
end
function Move(p, d::Direction, o)
return Move(p, d, o, [Point2i(p[1] + delta[d][1] * i, p[2] + delta[d][2] * i)
for i in collect(-2:2) .+ o])
end
function Move(pentasol, ref::Point2i, Rposition::Point2i)
if (mat = match(r"\((\d+),(\d+)\) ([\\\-/|]) ([+-]?\d+)", pentasol)) != nothing
p = Point2i(parse(Int, mat[1]), parse(Int, mat[2])) .- ref .+ Rposition
d = Direction(findfirst(mat[3], "\\-/|")[1])
o = parse(Int, mat[4])
return Move(p, d, o)
else
throw("Bad Pentasol input")
end
end
function pentasol(m::Move, ref=Point2i(25, 25))
p = m.p .+ ref
return "($(p[1]),$(p[2])) " * "\\-/|"[Int(m.d)] * (m.o > 0 ? " +" : " ") * "$(m.o)"
end
nooverlap(move, moves) = all(m -> m.d != move.d || isempty(move.seg ∩ m.seg), moves)
function surround(p::Point2i)
x, y = p[1], p[2]
delt = [-1, 0, +1]
return vec([Point2i(x + i, y + j) for i in delt, j in delt if (i !=0 || i != j)])
end
function adjacent(pvec::Vector{Point2i})
points = Point2i[]
for p in pvec, adj in surround(p)
!(adj in pvec) && !(adj in points) && push!(points, adj)
end
return points
end
#--------- game structures and functions ---------------------#
struct Board
Rvalue::Point2i
points::Vector{Point2i}
end
const initialboard() = Board(
Point2i(0, 0),
Point2i.([[3, 0], [4, 0], [5, 0], [6, 0], [3, 1], [6, 1], [3, 2], [6, 2], [0, 3],
[1, 3], [2, 3], [3, 3], [6, 3], [7, 3], [8, 3], [9, 3], [0, 4], [9, 4],
[0, 5], [9, 5], [0, 6], [1, 6], [2, 6], [3, 6], [6, 6], [7, 6], [8, 6],
[9, 6], [3, 7], [6, 7], [3, 8], [6, 8], [3, 9], [4, 9], [5, 9], [6, 9]]),
)
struct MorpionGame
board::Board
moves::Vector{Move}
end
MorpionGame() = MorpionGame(initialboard(), Vector{Move}())
score(mg::MorpionGame) = length(mg.moves)
moveok(m, b) = !(m.p in b.points) && all(x -> x in b.points, filter(x -> x != m.p, m.seg))
addmove(m, b) = if moveok(m.p, b) push!(b.points, m.p) else error("Bad move $m with $b") end
function has4inarowbefore(p, pvec, d)
xsign, ysign = delta[d]
return all(i -> Point2i(p[1] + xsign * i, p[2] + ysign * i) in pvec, -1:-1:-4)
end
function has4inarowafter(p, pvec, d)
xsign, ysign = delta[d]
return all(i -> Point2i(p[1] + xsign * i, p[2] + ysign * i) in pvec, 1:4)
end
function has1gap3inarow(p, pvec, d)
xsign, ysign = delta[d]
return all(i -> Point2i(p[1] + xsign * i, p[2] + ysign * i) in pvec, [-1, 1, 2, 3])
end
function has3gap1inarow(p, pvec, d)
xsign, ysign = delta[d]
return all(i -> Point2i(p[1] + xsign * i, p[2] + ysign * i) in pvec, [-3, -2, -1, 1])
end
function has2gap2inarow(p, pvec, d)
xsign, ysign = delta[d]
return all(i -> Point2i(p[1] + xsign * i, p[2] + ysign * i) in pvec, [-2, -1, 1, 2])
end
function any5inarow(p, pvec, d)
moves = Move[]
if has4inarowbefore(p, pvec, d)
push!(moves, Move(p, d, -2))
end
if has4inarowafter(p, pvec, d)
push!(moves, Move(p, d, 2))
end
if has1gap3inarow(p, pvec, d)
push!(moves, Move(p, d, 1))
end
if has3gap1inarow(p, pvec, d)
push!(moves, Move(p, d, -1))
end
if has2gap2inarow(p, pvec, d)
push!(moves, Move(p, d, 0))
end
return moves
end
function possiblemovesforpoint(p, game)
return filter(m -> nooverlap(m, game.moves),
mapreduce(d -> any5inarow(p, game.board.points, d), vcat, [NE, E, SE, S]))
end
function readmorpion(filename)
foundlisting, foundR, setR, leftmargin, ry, rx = false, false, false, 1000, 0, 1000
lines = strip.(split(read(filename, String), "\n"))
nullpoint = Point2i(typemin(Int), typemin(Int))
xyreference, Rpoint, moves = nullpoint, nullpoint, Move[]
for line in lines
isempty(line) && continue
if !setR
if line[1] == '#' && occursin("X", line[2:end]) && !occursin(r"[a-zA-QS-WYZ]", line)
if !foundR
ry += 1
end
if (x = findfirst("X", line[2:end])) != nothing
leftmargin = min(leftmargin, x[1] - 1)
end
if occursin("R", line) && !occursin(r"[a-zA-QS-WYZ]", line)
foundR = true
if (x = findfirst("R", line[2:end])) != nothing
rx = x[1]
end
end
end
end
if !foundlisting
if (mat = match(r"^\((\d+),(\d+)\)$", line)) != nothing
rx -= leftmargin
Rpoint = Point2i(rx - 1, ry - 1)
xyreference = Point2i(parse(Int, mat[1]), parse(Int, mat[2]))
setR, foundlisting = true, true
end
else # all the rest should be move listings
push!(moves, Move(line, xyreference, Rpoint))
end
end
return xyreference, Rpoint, moves
end
#----------- game app ------------------------------#
function morpionapp(gridsize=42)
win = GtkWindow("Morpion Game", 720, 640) |> (GtkFrame() |> (box = GtkBox(:v)))
hbox1 = GtkBox(:h)
newgame = GtkButton("New Game")
set_gtk_property!(newgame, :label, "New Game")
gamemode = GtkComboBoxText()
modechoices = ["Random Demo", "Solitaire Play", "Pre-Recorded"]
modelabels = [" Random Play in Progress", " Choose A Move", " Playing Back File"]
append!(gamemode, modechoices)
set_gtk_property!(gamemode, :active, 0)
modelabel = GtkLabel(modelabels[1])
savegame = GtkButton("Save Game")
set_gtk_property!(savegame, :label, "Save Game")
push!(hbox1, newgame, gamemode, savegame, modelabel)
hbox2 = GtkBox(:h)
can = GtkCanvas(500, 500)
set_gtk_property!(can, :expand, true)
push!(hbox2, can)
scrwin = GtkScrolledWindow()
txtbuffer = GtkTextBuffer()
set_gtk_property!(txtbuffer, :text, "Move History")
historywindow = GtkTextView()
set_gtk_property!(historywindow, :editable, false)
set_gtk_property!(historywindow, :expand, true)
set_gtk_property!(historywindow, "left-margin", 40)
set_gtk_property!(historywindow, :buffer, txtbuffer)
push!(scrwin, historywindow)
push!(hbox2, scrwin)
push!(box, hbox1, hbox2)
solitaire_gotfirst, solp1, solp2 = false, Point2i(0, 0), Point2i(0, 0)
movehistory, colorhistory, playbackmoves = ["(25,25)"], Color[], Move[]
game = MorpionGame()
gameover, mode, exitrequested, margin, r, filename = false, 0, false, 10, 16, ""
linecolors = distinguishable_colors(200, colorant"black", dropseed=true)
function newgame!(w)
game, gameover, filename, movehistory = MorpionGame(), false, "", ["(25,25)"]
draw(can)
end
@guarded function solitaireclick!(w, event)
get_gtk_property(gamemode, :active, Int) != 1 && return
delta = div(gridsize, 2) - 5
x, y = Int(round(event.x / r - delta)), Int(round(event.y / r - delta))
if solitaire_gotfirst
solp2 = Point2i(x, y)
if solp1 == solp2
movs = filter(m -> m.o == 0, possiblemovesforpoint(solp1, game))
d = isempty(movs) ? SE : movs[1].d
else
d = direction(solp1, solp2)
end
move = Move(solp1, d, offset(solp1, solp2))
if isempty(any5inarow(move.p, game.board.points, move.d)) ||
!nooverlap(move, game.moves)
@warn("bad move $move")
else
pushmove!(game, move)
end
solitaire_gotfirst = false
else
solp1 = Point2i(x, y)
solitaire_gotfirst = true
end
end
can.mouse.button1press = solitaireclick!
function gamemode!(w)
mode = get_gtk_property(gamemode, :active, Int)
if mode == 0 # demo mode
set_gtk_property!(modelabel, :label, modelabels[1])
elseif mode == 1 # solitaire
set_gtk_property!(modelabel, :label, modelabels[2])
info_dialog("Click on a valid empty nearby square to move, " *
"then immediately click on the midpoint square for that move.", win)
newgame!(win)
else # playback
set_gtk_property!(modelabel, :label, modelabels[3])
info_dialog("Pick a Pentasol format text file of the game to replay.", win)
newgame!(win)
end
end
function pushmove!(game, move)
push!(game.board.points, move.p)
push!(game.moves, move)
push!(movehistory, pentasol(move))
push!(colorhistory, rand(linecolors))
draw(can)
show(can)
end
function addmove!(verbose=true)
candidates = mapreduce(p -> possiblemovesforpoint(p, game), vcat,
adjacent(game.board.points))
if isempty(candidates)
outofmoves()
else
move = rand(candidates)
pushmove!(game, move)
end
end
function playback!()
if filename == ""
filename = open_dialog("Pick a Pentasol formal Morpion game file.")
if filename == ""
sleep(3)
return
end
xyreference, Rpoint, playbackmoves = readmorpion(filename)
movehistory = ["(25,25)"]
game = MorpionGame()
gameover = false
end
if isempty(playbackmoves)
outofmoves()
else
move = popfirst!(playbackmoves)
pushmove!(game, move)
end
end
function outofmoves()
warn_dialog("Out of moves! Game over.\nTotal moves: $(length(game.moves))", win)
gameover = true
end
function savegametofile(w)
filename = Gtk.save_dialog_native("File Name to Save")
fp = open(filename, "w")
write(fp, prod(["""
#
# R XXXX
# X X
# X X
# XXXX XXXX
# X X
# X X
# XXXX XXXX
# X X
# X X
# XXXX
#
# R = reference point
# List of moves starts with reference point (col,row) i.e. (25,25) below
#
# Annotations:
# Lines are
# (col,row) <direction> <center>
# direction - +
# | up down
# - left right
# \\ left right
# / left right
# center is the distance -2, -1, ..., +2 from
# the move coordinate to the center of the line being drawn
#
(25,25)
"""; [pentasol(m) * "\n" for m in game.moves]]))
close(fp)
end
@guarded draw(can) do widget
ctx = Gtk.getgc(can)
select_font_face(ctx, "Courier", Cairo.FONT_SLANT_NORMAL, Cairo.FONT_WEIGHT_BOLD)
fontpoints, delt = 12, div(gridsize, 2) - 6
set_font_size(ctx, fontpoints)
function squareat(color, x, y, siz)
set_source(ctx, color)
move_to(ctx, margin + (x + delt) * r, margin + (y + delt) * r)
rectangle(ctx, margin + (x + delt) * r, margin + (y + delt) * r, siz, siz)
fill(ctx)
end
function textat(color, x, y, text)
set_source(ctx, color)
move_to(ctx, margin + (x + delt) * r + 2, margin + (y + delt) * r + fontpoints)
show_text(ctx, text)
stroke(ctx)
end
function point2line(color, p1, p2, siz)
set_line_width(ctx, 3)
set_source(ctx, color)
move_to(ctx, margin + (p1[1] + delt) * r + 5, margin + (p1[2] + delt) * r + 5)
line_to(ctx, margin + (p2[1] + delt) * r + r - 5, margin + (p2[2] + delt) * r + r - 5)
stroke(ctx)
end
pts, adj = game.board.points, adjacent(game.board.points)
# print black box graphic
for i in -delt+1:delt+3, j in -delt+1:delt+3
squareat(colorant"wheat", i, j, r)
i == j == 0 && textat(colorant"maroon", i, j, "R")
pt = Point2i(i, j)
# show current points with an x char
pt in pts && textat(colorant"black", i, j, "x")
pt in adj && squareat(colorant"gold", i, j, r)
# if there is a move, mark it
end
# draw dividing lines
set_line_width(ctx, 1)
set_source(ctx, colorant"navy")
linelen = delt * 2 + 4
for i in 1:linelen
move_to(ctx, margin + i * r, margin + r)
line_to(ctx, margin + i * r, margin + linelen * r)
stroke(ctx)
move_to(ctx, margin + r, margin + i * r)
line_to(ctx, margin + linelen * r, margin + i * r)
stroke(ctx)
end
for (i, m) in enumerate(game.moves)
point2line(colorhistory[i], m.seg[1], m.seg[5], r)
end
# print move record at the same time by sending lines to the label listing
set_gtk_property!(txtbuffer, :text, join(["Move History"; movehistory], "\n"))
set_gtk_property!(historywindow, :buffer, txtbuffer)
Gtk.showall(win)
end
endit(w) = (exitrequested = true)
signal_connect(newgame!, newgame, :clicked)
signal_connect(gamemode!, gamemode, :changed)
signal_connect(savegametofile, savegame, :clicked)
signal_connect(endit, win, :destroy)
while !exitrequested
showall(win)
if gameover
sleep(0.3)
elseif mode == 0 # demo mode
sleep(2)
addmove!()
elseif mode == 1 # solitaire mode
sleep(0.1)
yield()
else # playback mode
sleep(2)
playback!()
end
yield()
end
end
morpionapp()