extends NpcScript class_name TicTacToeGlobal # static var BOARD_POSITION : Vector2i = Vector2i(120, 80) * 32 + Vector2i(16, 32) # Tile 120,80 with an offset of 16,32 pixels static var CELL_ID : int = "TicTacToeCell".hash() const CELL_COUNT : int = 9 const WINNING_LINES : Array[Array] = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ] const IDLE_TIMEOUT : float = 30.0 const NPC_DELAY_MIN : float = 0.2 const NPC_DELAY_MAX : float = 1.0 # enum State { NONE = 0, X, O } var boardStates : Array[State] = [ State.NONE, State.NONE, State.NONE, State.NONE, State.NONE, State.NONE, State.NONE, State.NONE, State.NONE ] var boardNpcs : Array[NpcAgent] = [ null, null, null, null, null, null, null, null, null ] var playerX : PlayerAgent = null var playerO : PlayerAgent = null var idleTimer : Timer = null var startStep : State = State.NONE var currentTurn : State = State.NONE # Board handling func SpawnCells(): var map : WorldMap = WorldAgent.GetMapFromAgent(npc) if not map: return var inst : WorldInstance = WorldAgent.GetInstanceFromAgent(npc) if not inst: return var spawn : SpawnObject = SpawnObject.new() spawn.id = CELL_ID spawn.type = "Npc" spawn.state = ActorCommons.State.IDLE spawn.is_persistant = false spawn.map = map for cellIdx in CELL_COUNT: spawn.spawn_position = BOARD_POSITION + Vector2i((cellIdx % 3) * 32, int(cellIdx / 3.0) * 32) var spawnedAgent : BaseAgent = WorldAgent.CreateAgent(spawn, inst.id) if spawnedAgent and spawnedAgent is NpcAgent: boardNpcs[cellIdx] = spawnedAgent boardNpcs[cellIdx].interacted.connect(CellSelected.bind(boardNpcs[cellIdx])) func CellSelected(playerAgent : BaseAgent, cellAgent : NpcAgent): if playerAgent is not PlayerAgent: return if cellAgent: var cellIdx : int = boardNpcs.find(cellAgent) if cellIdx >= 0: MakeMove(playerAgent, cellIdx) func DespawnCells(): for cellIdx in CELL_COUNT: if boardNpcs[cellIdx]: if is_instance_valid(boardNpcs[cellIdx]): WorldAgent.RemoveAgent.call_deferred(boardNpcs[cellIdx]) boardNpcs[cellIdx] = null func DespawnUnusedCells(): for cellIdx in CELL_COUNT: if boardStates[cellIdx] == State.NONE and boardNpcs[cellIdx] and is_instance_valid(boardNpcs[cellIdx]): WorldAgent.RemoveAgent.call_deferred(boardNpcs[cellIdx]) boardNpcs[cellIdx] = null # PvP matchmaking func StartPvP(player : PlayerAgent) -> State: if not player or not is_instance_valid(player): return State.NONE if startStep != State.O: if not playerX or not is_instance_valid(playerX) or WorldAgent.GetInstanceFromAgent(playerX) != WorldAgent.GetInstanceFromAgent(player): playerX = player Callback.AddCallback(playerX.tree_exiting, LeaveQueue, [playerX], ConnectFlags.CONNECT_ONE_SHOT) startStep = State.X return startStep elif not playerO or not is_instance_valid(playerO) or WorldAgent.GetInstanceFromAgent(playerO) != WorldAgent.GetInstanceFromAgent(player): playerO = player startStep = State.O StartGame() return startStep return State.NONE func LeavePvP(player : PlayerAgent): if not player or not is_instance_valid(player): if playerX and is_instance_valid(playerX): NpcCommons.PushNotification(playerX, "Your opponent left. You win!") elif playerO and is_instance_valid(playerO): NpcCommons.PushNotification(playerO, "Your opponent left. You win!") elif startStep == State.O and (player == playerX or player == playerO): var opponent : PlayerAgent = playerO if player == playerX else playerX if opponent and is_instance_valid(opponent): NpcCommons.PushNotification(opponent, "Your opponent left. You win!") EndGame() func LeaveQueue(player : PlayerAgent): if player and is_instance_valid(player) and player == playerX: EndGame() # Game management func StartGame(): if currentTurn != State.NONE: return startStep = State.O currentTurn = State.X DespawnCells() SpawnCells() StartIdleTimer() if playerX: Callback.RemoveCallback(playerX.tree_exiting, LeaveQueue) Callback.AddCallback(playerX.tree_exiting, LeavePvP, [playerX], ConnectFlags.CONNECT_ONE_SHOT) NpcCommons.PushNotification(playerX, "Tic Tac Toe started! You play X!") if playerO: Callback.AddCallback(playerO.tree_exiting, LeavePvP, [playerO], ConnectFlags.CONNECT_ONE_SHOT) NpcCommons.PushNotification(playerO, "Tic Tac Toe started! You play O!") func StartPvE(player : PlayerAgent) -> bool: if startStep == State.NONE: playerX = player playerO = null StartGame() return startStep == State.O return false # Move handling func MakeMove(player : PlayerAgent, cellIndex : int) -> bool: if currentTurn == State.NONE or cellIndex < 0 or cellIndex >= CELL_COUNT: return false var mark : State = boardStates[cellIndex] if mark != State.NONE: return false if player == playerX and currentTurn == State.X: mark = State.X elif player == playerO and currentTurn == State.O: mark = State.O else: return false boardStates[cellIndex] = mark UpdateCellVisual(cellIndex) currentTurn = State.X if currentTurn == State.O else State.O OnMovePlayed() StartIdleTimer() return true func OnMovePlayed(): if startStep != State.O: return var result : State = CheckWin() if result != State.NONE or CheckDraw(): AnnounceResult(result) elif not playerO: NotifyTurn() var delay : float = randf_range(NPC_DELAY_MIN, NPC_DELAY_MAX) Callback.SelfDestructTimer(npc, delay, PlayNPCMove) else: NotifyTurn() func PlayNPCMove(): if currentTurn != State.O or playerO: return var npcMove : int = GetNPCMove() if npcMove >= 0: MakeMove(null, npcMove) func NotifyTurn(): if currentTurn == State.NONE: return if playerO: var activePlayer : PlayerAgent = playerX if currentTurn == State.X else playerO var waitingPlayer : PlayerAgent = playerO if currentTurn == State.X else playerX if activePlayer and is_instance_valid(activePlayer): NpcCommons.PushNotification(activePlayer, "Your turn!") if waitingPlayer and is_instance_valid(waitingPlayer): NpcCommons.PushNotification(waitingPlayer, "Waiting for opponent...") else: if currentTurn == State.X: if playerX and is_instance_valid(playerX): NpcCommons.PushNotification(playerX, "Your turn!") else: if playerX and is_instance_valid(playerX): NpcCommons.PushNotification(playerX, "Waiting for opponent...") func UpdateCellVisual(cellIndex : int): if cellIndex < 0 or cellIndex >= CELL_COUNT: return var cellNpc : NpcAgent = boardNpcs[cellIndex] if not cellNpc or not is_instance_valid(cellNpc): return match boardStates[cellIndex]: State.NONE: cellNpc.state = ActorCommons.State.IDLE State.X: cellNpc.state = ActorCommons.State.TRIGGER if cellNpc.interacted.is_connected(CellSelected): cellNpc.interacted.disconnect(CellSelected) State.O: cellNpc.state = ActorCommons.State.SIT if cellNpc.interacted.is_connected(CellSelected): cellNpc.interacted.disconnect(CellSelected) cellNpc.set_physics_process(true) cellNpc.requireFullUpdate = true # Idle timer func StartIdleTimer(): StopIdleTimer() idleTimer = Callback.SelfDestructTimer(npc, IDLE_TIMEOUT, IdleTimeout) func StopIdleTimer(): if idleTimer and is_instance_valid(idleTimer) and not idleTimer.is_queued_for_deletion(): idleTimer.stop() idleTimer.queue_free() idleTimer = null func IdleTimeout(): idleTimer = null if currentTurn == State.NONE: return var loser : State = currentTurn var winner : State = State.X if loser == State.O else State.O var loserPlayer : PlayerAgent = playerX if loser == State.X else playerO if loserPlayer and is_instance_valid(loserPlayer): NpcCommons.PushNotification(loserPlayer, "You took too long! You lose.") AnnounceResult(winner) # Game end func EndGame(): StopIdleTimer() ClearPlayerCallbacks() DespawnUnusedCells() ResetBoard() func ClearPlayerCallbacks(): if playerX and is_instance_valid(playerX): Callback.RemoveCallback(playerX.tree_exiting, LeavePvP) if playerO and is_instance_valid(playerO): Callback.RemoveCallback(playerO.tree_exiting, LeavePvP) func ResetBoard(): playerX = null playerO = null startStep = State.NONE currentTurn = State.NONE for cellIdx in CELL_COUNT: boardStates[cellIdx] = State.NONE # Win detection func CheckWin() -> State: for line in WINNING_LINES: var state : State = boardStates[line[0]] if state != State.NONE and state == boardStates[line[1]] and state == boardStates[line[2]]: return state return State.NONE func CheckDraw() -> bool: for cell in boardStates: if cell == State.NONE: return false return true func AnnounceResult(result : State): var msg : String = "" match result: State.NONE: msg = "It's a draw!" State.X: msg = "%s wins!" % (playerX.nick if playerX else "X") State.O: msg = "%s wins!" % (playerO.nick if playerO else npc.nick) if playerX and is_instance_valid(playerX): NpcCommons.PushNotification(playerX, msg) if playerO and is_instance_valid(playerO): NpcCommons.PushNotification(playerO, msg) EndGame() # NPC AI func GetNPCMove() -> int: var winMove : int = FindWinningMove(State.O) if winMove >= 0: return winMove var blockMove : int = FindWinningMove(State.X) if blockMove >= 0: return blockMove if boardStates[4] == State.NONE: return 4 var corners : Array[int] = [0, 2, 6, 8] corners.shuffle() for corner in corners: if boardStates[corner] == State.NONE: return corner var edges : Array[int] = [1, 3, 5, 7] edges.shuffle() for edge in edges: if boardStates[edge] == State.NONE: return edge return -1 func FindWinningMove(state : State) -> int: for line : Array in WINNING_LINES: var totalPointInLine : int = 0 var firstEmptyIdx : int = -1 for idx : int in line: if boardStates[idx] == state: totalPointInLine += 1 elif boardStates[idx] == State.NONE: firstEmptyIdx = idx # Winning move found if 2 out of 3 point in a line are already matching the given state and if an empty index is available if totalPointInLine == 2 and firstEmptyIdx >= 0: return firstEmptyIdx return -1