Morpion solitaire/Julia

From Rosetta Code

See Morpion_solitaire

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