INTEGRADED AI

This commit is contained in:
2ManyProjects 2025-02-11 16:22:40 -06:00
parent 42ea425656
commit 086b114853
12 changed files with 502 additions and 81 deletions

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
# Godot 4+ specific ignores
.godot/
/android/
stockfish
Assets/ChessEngines

View file

@ -12,15 +12,15 @@ stateDiagram
HandSetup --> DrawPhase
DrawPhase --> ResolvePersistentEffects : Draw/Discard
ResolvePersistentEffects --> ApplyTileEffects : Update Durations
ApplyTileEffects --> PreMovePhase
ResolvePersistentEffects --> PreMovePhase : Update Durations
PreMovePhase --> AttachCards : Play Cards
AttachCards --> ApplyCardEffects
ApplyCardEffects --> Movement
Movement --> PostMovePhase
PostMovePhase --> EvaluatePosition : Resolve Move Effects
PostMovePhase --> ApplyTileEffects : Resolve Move Effects
ApplyTileEffects --> EvaluatePosition
state EvaluatePosition {
[*] --> CheckStatus

View file

@ -0,0 +1,181 @@
# Stockfish.gd
extends Node
# References to game objects
var board: Array
var game: ChessGame
# Engine state
var engine_path: String = ""
var mutex: Mutex
var running := false
# Game state
var moves: Array = []
var move_time: int = 1000 # in ms
var generated_move: Dictionary = {} # Stores the last generated move
# Piece type mapping
var symbol_from_piece_type := {
"PAWN": "p", "KNIGHT": "n", "BISHOP": "b",
"ROOK": "r", "QUEEN": "q", "KING": "k"
}
var piece_type_from_symbol := {
"p": "PAWN", "n": "KNIGHT", "b": "BISHOP",
"r": "ROOK", "q": "QUEEN", "k": "KING"
}
func _init(boardRef: Array):
board = boardRef
mutex = Mutex.new()
func _ready():
game = get_parent() as ChessGame
func _exit_tree():
disconnect_engine()
func connect_to_engine(path: String) -> bool:
if running:
return false
engine_path = path
# Test if we can execute stockfish
var output = []
var exit_code = OS.execute(engine_path, ["uci"], output, true)
print("Exit code: ", exit_code)
print("Output: ", output)
if exit_code != OK:
printerr("Failed to start Stockfish engine: ", exit_code)
return false
running = true
print("Connected to engine: ", engine_path)
# Initialize with current game state
load_fen(game.getCurrentFen())
return true
func disconnect_engine():
if running:
mutex.lock()
var output = []
OS.execute(engine_path, ["quit"], output, true)
mutex.unlock()
running = false
engine_path = ""
print("Disconnected from engine")
func limit_strength_to(elo_value: int):
mutex.lock()
if elo_value != -1: # Using -1 instead of int.MaxValue
_send_command("setoption name UCI_LimitStrength value true")
_send_command("setoption name UCI_Elo value " + str(elo_value))
else:
_send_command("setoption name UCI_LimitStrength value false")
mutex.unlock()
func stop_calculating():
mutex.lock()
_send_command("stop")
mutex.unlock()
func load_fen(fen: String):
moves.clear()
update_position(fen)
func update_position(fen: String):
mutex.lock()
_send_command("position fen " + fen)
mutex.unlock()
func generateMove(think_time_ms: int = 1000) -> void:
if not running:
return
move_time = think_time_ms
# Update position first
mutex.lock()
var command = "position fen " + game.getCurrentFen()
if moves.size() > 0:
command += " moves " + " ".join(moves)
print(command)
var output = _send_command(command)
# Then get move
output = _send_command("go movetime " + str(move_time))
if output.size() == 0:
return
print(type_string(typeof(output[0])))
var lines = output[0].split("\n")
mutex.unlock()
# Parse the output
for line in lines:
# print("-")
# print(line)
# print("-")
if line.begins_with("bestmove"):
var parts = line.split(" ")
print( parts)
if parts.size() >= 2:
generated_move = {
"move": parts[1],
"ponder": parts[3]
}
print("Generated move: ", generated_move)
return
generated_move = {}
func getGeneratedMove() -> Dictionary:
var move = generated_move.duplicate()
generated_move.clear() # Clear after retrieving
return move
func from_move_to_string(move_data: Dictionary) -> String:
var board_size = len(board)
# Get source and target squares
var source_i = move_data.source % board_size
var source_j = move_data.source / board_size
var target_i = move_data.target % board_size
var target_j = move_data.target / board_size
# Convert to algebraic notation
var letters = "abcdefghijklmnopqrstuvwxyz".substr(0, board_size)
var str_move = "%s%d%s%d" % [
letters[source_i],
board_size - source_j,
letters[target_i],
board_size - target_j
]
# Add promotion piece if needed
if move_data.get("flags", "") == "PROMOTION":
str_move += symbol_from_piece_type[move_data.promotion_piece]
return str_move
func send_move(move_data: Dictionary):
var str_move = from_move_to_string(move_data)
moves.append(str_move)
print("move: ", str_move)
# Update engine with the new move
mutex.lock()
var command = "position fen " + game.getCurrentFen()
if moves.size() > 0:
command += " moves " + " ".join(moves)
_send_command(command)
mutex.unlock()
func _send_command(command: String) -> Array:
if not running:
return []
var output = []
OS.execute(engine_path, [command], output, true)
return output

View file

@ -8,6 +8,11 @@ signal send_location(location: String)
signal turn_changed
var currentPlayer: String = WHITE
var board: Array
var isWhiteToMove: bool = true
var castlingRights: String = "KQkq"
var enPassantTarget: String = "-"
var halfMoveClock: int = 0
var moveCount: int = 1
var currentHand: Array
var selectedNode: String = ""
var locationX: String = ""
@ -21,7 +26,9 @@ var currentlyMovingPiece = null
var p1Points: int = 0
var p2Points: int = 0
var Turn: int = 0
const StockfishController = preload("res://Systems/FairyStockfish/Stockfish.gd")
var stockfishController: StockfishController
var stockfishPath = "res://Assets/ChessEngines/stockfish/stockfish.exe"
@onready var turnIndicator: ColorRect = $TurnIndicator
@onready var p1String: RichTextLabel = $Player1Points
@onready var p2String: RichTextLabel = $Player2Points
@ -41,18 +48,31 @@ var Turn: int = 0
@onready var boardContainer: FlowContainer = $Flow
@onready var stateMachine: StateMachine = $StateMachine
var currentFen = ""
var lightStyle = null
var darkStyle = null
var highlightStyle = null
var cpuElo = 1500
func _ready() -> void:
if OS.get_name() == "Windows":
stockfishPath = "res://Assets/ChessEngines/stockfish/stockfish.exe"
else:
stockfishPath = ProjectSettings.globalize_path("res://Assets/ChessEngines/Fairy-Stockfish/src/stockfish")
add_to_group("ChessGame")
currentFen = FEN
DisplayServer.window_set_size(Vector2i(windowXSize, windowYSize))
initializeGame()
initializeTiles()
stateMachine.transitionToNextState(Constants.WHITE_TURN)
stockfishController = StockfishController.new(board)
add_child(stockfishController)
if stockfishController.connect_to_engine(stockfishPath):
stockfishController.limit_strength_to(cpuElo)
func _exit_tree():
stockfishController.disconnect_engine()
func initializeTiles() -> void:
tileManager = TileManager.new($Flow, self)
@ -83,6 +103,45 @@ func initializeCardPreview() -> void:
func getCurrentFen() -> String:
var fen = ""
# For a standard chess board, we want to generate FEN from top (black side, rank 8)
# to bottom (white side, rank 1)
for y in range(boardYSize):
var emptySquares = 0
for x in range(boardXSize):
# print("CHECKING ", str(x) + "-" + str(y))
var container = boardContainer.get_node(str(x) + "-" + str(y)) as PieceContainer
var piece = container.get_piece()
if piece == null:
emptySquares += 1
else:
if emptySquares > 0:
fen += str(emptySquares)
emptySquares = 0
# Convert piece to FEN notation
var fenChar = getPieceFenChar(piece)
fen += fenChar
# Add any remaining empty squares at the end of the rank
if emptySquares > 0:
fen += str(emptySquares)
# Add rank separator (except for the last rank)
if y < boardYSize - 1:
fen += "/"
# Add the rest of the FEN string components
fen += " %s %s %s %d %d" % [
"w" if isWhiteToMove else "b",
castlingRights,
enPassantTarget,
halfMoveClock,
moveCount
]
return fen
func initializeBoard() -> void:
# Parse FEN to get board dimensions
# rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
@ -485,3 +544,99 @@ func cleanupPhase() -> void:
func isNull(location: String) -> bool:
return get_node_or_null("Flow/" + location) == null
func getPieceFenChar(piece: Pawn) -> String:
if piece == null:
return ""
var fenChar = ""
match piece.name:
"Pawn": fenChar = "p"
"Knight": fenChar = "n"
"Bishop": fenChar = "b"
"Rook": fenChar = "r"
"Queen": fenChar = "q"
"King": fenChar = "k"
# In our system, Item_Color == 1 is white, 0 is black
return fenChar.to_upper() if piece.Item_Color == 0 else fenChar
func updateStateFromMove(fromIdx: int, toIdx: int) -> void:
# Update game state based on the move
isWhiteToMove = !isWhiteToMove
if isWhiteToMove:
moveCount += 1
# Update castling rights if needed
updateCastlingRights(fromIdx, toIdx)
# Update en passant target
updateEnPassantTarget(fromIdx, toIdx)
# Update halfmove clock
updateHalfMoveClock(fromIdx, toIdx)
func updateCastlingRights(fromIdx: int, toIdx: int) -> void:
var piece = board[fromIdx]
if piece == null:
return
# Remove castling rights when king or rook moves
match piece.type:
"king":
if piece.isWhite:
castlingRights = castlingRights.replace("K", "").replace("Q", "")
else:
castlingRights = castlingRights.replace("k", "").replace("q", "")
"rook":
var startRank = 7 if piece.isWhite else 0
if fromIdx == startRank * 8: # Queen-side rook
castlingRights = castlingRights.replace("Q" if piece.isWhite else "q", "")
elif fromIdx == startRank * 8 + 7: # King-side rook
castlingRights = castlingRights.replace("K" if piece.isWhite else "k", "")
if castlingRights == "":
castlingRights = "-"
func updateEnPassantTarget(fromIdx: int, toIdx: int) -> void:
var piece = board[fromIdx]
if piece == null or piece.type != "pawn":
enPassantTarget = "-"
return
# Check for double pawn move
var fromRank = fromIdx / 8
var toRank = toIdx / 8
if abs(fromRank - toRank) == 2:
var file = fromIdx % 8
var targetRank = (fromRank + toRank) / 2
enPassantTarget = "%s%d" % [char(97 + file), 8 - targetRank]
else:
enPassantTarget = "-"
func updateHalfMoveClock(fromIdx: int, toIdx: int) -> void:
var piece = board[fromIdx]
if piece == null:
return
# Reset on pawn move or capture
if piece.type == "pawn" or board[toIdx] != null:
halfMoveClock = 0
else:
halfMoveClock += 1
func _on_ai_move_generated(move: String) -> void:
if Turn == 1: # Only process AI moves during black's turn
# The move will be automatically handled by the Movement state
# which is listening for the moveGenerated signal
pass
func executeAiMove(fromLocation: String, toLocation: String) -> void:
selectedNode = fromLocation
executeMove(toLocation)
func convertNotationToLocation(notation: String) -> String:
var file = notation[0].unicode_at(0) - 'a'.unicode_at(0)
var rank = 8 - int(notation[1])
return str(file) + "-" + str(rank)

View file

@ -79,3 +79,4 @@ func get_overlay(overlay_name: String) -> Node:
func has_piece() -> bool:
return piece != null

View file

@ -3,4 +3,4 @@ extends "res://Systems/StateMachine/ChessGameState.gd"
func enter(_previous: String, _data := {}) -> void:
print("ENTERING STATE ", Constants.TILE_EFFECTS)
game.applyTileEffects()
finished.emit(Constants.PRE_MOVE)
finished.emit(Constants.EVALUATE_POSITION)

View file

@ -1,6 +1,31 @@
extends "res://Systems/StateMachine/ChessGameState.gd"
var moveTimer: Timer
func _ready() -> void:
moveTimer = Timer.new()
moveTimer.one_shot = true # Timer only fires once
moveTimer.connect("timeout", _on_move_timer_timeout)
add_child(moveTimer)
func enter(_previous: String, _data := {}) -> void:
print("ENTERING STATE ", Constants.BLACK_TURN)
game.currentPlayer = game.BLACK
finished.emit(Constants.HAND_SETUP)
# Delay to avoid duplication during animation
if game.stockfishController:
moveTimer.start(2)
func _on_move_timer_timeout() -> void:
# Generate AI move after delay
print("------------------GENERATING MOVE --------------------")
if game.stockfishController:
print("------------------STARTING GENERATING MOVE --------------------")
game.stockfishController.generateMove(1000) # 1 second think time
finished.emit(Constants.HAND_SETUP)
func exit() -> void:
moveTimer.stop()

View file

@ -6,8 +6,19 @@ var multiMoving = ""
func _ready() -> void:
print("Movement state ready")
func _setup_movement_modifiers() -> void:
for piece_id in game.deckManager.attached_cards:
var card = game.deckManager.attached_cards[piece_id]
if card.effectType == Card.EffectType.MOVEMENT_MODIFIER:
var effects = card.modify_moves()
moves_remaining[piece_id] = effects.get("extra_moves", 0) + 1
func enter(_previous: String, _data := {}) -> void:
print("ENTERING STATE ", Constants.MOVEMENT)
print("ENTERING STATE ", Constants.MOVEMENT, " ", game.currentPlayer)
_setup_movement_modifiers()
if !game.boardContainer.is_connected("tile_pressed", handleMovement):
game.boardContainer.connect("tile_pressed", handleMovement)
if game.selectedNode != "":
@ -32,15 +43,48 @@ func enter(_previous: String, _data := {}) -> void:
else:
moves_remaining[piece_id] = effects.get("extra_moves", 0) + 1
if game.currentPlayer == game.BLACK and game.stockfishController:
var move = game.stockfishController.getGeneratedMove()
print("GENERATED MOVE ", move)
if move:
var move_str = move.move # e.g., "e2e4"
var source_square = move_str.substr(0, 2) # "e2"
var target_square = move_str.substr(2, 2) # "e4"
# First select the piece
var source_location = convert_algebraic_to_location(source_square)
game.selectedNode = source_location
print("source_location ", source_location)
# Then make the move
var target_location = convert_algebraic_to_location(target_square)
print("target_location ", target_location)
handleMovement(target_location, true)
return
func exit() -> void:
if game.boardContainer.is_connected("tile_pressed", handleMovement):
game.boardContainer.disconnect("tile_pressed", handleMovement)
func handleMovement(location: String) -> void:
func convert_algebraic_to_location(square: String) -> String:
var file = square[0] # letter (a-h)
var rank = int(square[1]) # number (1-8)
# Convert file letter to number (a=0, b=1, etc)
var file_num = file.unicode_at(0) - 'a'.unicode_at(0)
# Convert rank to 0-based index from bottom
var rank_num = rank - 1
# Return location in your game's format
return "%d-%d" % [file_num, rank_num]
func handleMovement(location: String, generated: bool = false) -> void:
# we need to prevent swapping of focus between peices after the double move process has started
# maybe once u start nmoving a peice global stat var is set
# and any moving peice needs ot match that
# print("HANDLING MOVEMENT ", location, " | ", multiMoving, " | ", game.selectedNode)
print("HANDLING MOVEMENT ", location, " | ", multiMoving, " | ", game.selectedNode)
var node = game.get_node("Flow/" + location) as PieceContainer
# Only try to get piece if we have a selected node
@ -89,17 +133,16 @@ func handleMovement(location: String) -> void:
if piece_id in moves_remaining:
moves_remaining.erase(piece_id)
# finished.emit(Constants.POST_MOVE)
if game.selectedNode == location:
if !is_multi_moving and multiMoving == "":
print("CLEAR SELECTION*************")
game.clearSelection()
elif isCastlingMove(node, location):
handleCastling(node)
handleCastling(node, generated)
finished.emit(Constants.POST_MOVE)
# En Passant
elif isEnPassantMove(node, location):
handleEnPassant(node)
handleEnPassant(node, generated)
# Reselect piece
elif isReselectMove(node):
if !is_multi_moving and multiMoving == "":
@ -108,17 +151,17 @@ func handleMovement(location: String) -> void:
game.getMovableAreas()
# Capture piece
elif isCaptureMove(node):
handleCapture(node)
handleCapture(node, generated)
finished.emit(Constants.POST_MOVE)
# Regular move
# elif isRegularMove(node):
# handleRegularMove(node)
# finished.emit(Constants.POST_MOVE)
else:
if game.isValidMove(location):
if game.isValidMove(location) || generated:
# executeMove(location)
handleRegularMove(node, consumeMove)
handleRegularMove(node, consumeMove, generated)
if consumeMove:
multiMoving = ""
game.clearSelection()
@ -167,86 +210,107 @@ func isCaptureMove(node: PieceContainer) -> bool:
func isRegularMove(node: PieceContainer) -> bool:
return game.selectedNode != "" && node.get_piece() != null
func handleCastling(node: PieceContainer) -> void:
func handleCastling(node: PieceContainer, generated: bool = false) -> void:
print("handleCastling")
if generated:
castleMovement(node)
return
for i in game.areas:
if i == node.name:
var kingContainer = game.get_node("Flow/" + game.selectedNode) as PieceContainer
var rookContainer = node as PieceContainer
var kingTargetContainer = game.get_node("Flow/" + game.specialArea[1]) as PieceContainer
var rookTargetContainer = game.get_node("Flow/" + game.specialArea[0]) as PieceContainer
castleMovement(node)
func castleMovement(node: PieceContainer) -> void:
var kingContainer = game.get_node("Flow/" + game.selectedNode) as PieceContainer
var rookContainer = node as PieceContainer
var kingTargetContainer = game.get_node("Flow/" + game.specialArea[1]) as PieceContainer
var rookTargetContainer = game.get_node("Flow/" + game.specialArea[0]) as PieceContainer
var king = kingContainer.get_piece()
var rook = rookContainer.get_piece()
var king = kingContainer.get_piece()
var rook = rookContainer.get_piece()
# kingContainer.remove_piece(true)
# rookContainer.remove_piece(true)
# kingTargetContainer.set_piece(king)
# rookTargetContainer.set_piece(rook)
# kingContainer.remove_piece(true)
# rookContainer.remove_piece(true)
# kingTargetContainer.set_piece(king)
# rookTargetContainer.set_piece(rook)
kingTargetContainer.animate_movement(kingContainer, king);
kingTargetContainer.animate_movement(kingContainer, king);
rookTargetContainer.animate_movement(rookContainer, rook);
rookTargetContainer.animate_movement(rookContainer, rook);
game.currentlyMovingPiece = king
game.resolveMoveEffects()
game.currentlyMovingPiece = king
game.resolveMoveEffects()
func handleEnPassant(node: PieceContainer) -> void:
func handleEnPassant(node: PieceContainer, generated: bool = false) -> void:
print("handleEnPassant")
for i in game.specialArea:
if generated:
handleEnPassantMovement(node)
return
for i in game.areas:
if i == node.name:
var targetContainer = game.get_node("Flow/" + game.selectedNode) as PieceContainer
var pawn = (game.get_node("Flow/" + game.selectedNode) as PieceContainer).get_piece()
# node.remove_piece()
# targetContainer.set_piece(pawn)
game.currentlyMovingPiece = pawn
handleEnPassantMovement(node)
targetContainer.animate_movement(node, pawn);
game.resolveMoveEffects()
func handleEnPassantMovement(node: PieceContainer) -> void:
var targetContainer = game.get_node("Flow/" + game.selectedNode) as PieceContainer
var pawn = (game.get_node("Flow/" + game.selectedNode) as PieceContainer).get_piece()
# node.remove_piece()
# targetContainer.set_piece(pawn)
game.currentlyMovingPiece = pawn
func handleCapture(node: PieceContainer) -> void:
targetContainer.animate_movement(node, pawn);
game.resolveMoveEffects()
func handleCapture(node: PieceContainer, generated: bool = false) -> void:
print("handleCapture")
if generated:
handleCaptureMovement(node)
return
for i in game.areas:
if i == node.name:
handleCaptureMovement(node)
var source_container = game.get_node("Flow/" + game.selectedNode) as PieceContainer
func handleCaptureMovement(node: PieceContainer) -> void:
var source_container = game.get_node("Flow/" + game.selectedNode) as PieceContainer
var moving_piece = source_container.get_piece()
var captured_piece = node.get_piece()
var moving_piece = source_container.get_piece()
var captured_piece = node.get_piece()
if moving_piece && captured_piece:
await game.animatePieceCapture(captured_piece)
game.updatePointsAndCapture(captured_piece, false)
node.animate_movement(source_container, moving_piece);
var tile = game.tileManager.get_tile(node.name)
if tile:
tile.apply_effect(moving_piece)
if moving_piece && captured_piece:
await game.animatePieceCapture(captured_piece)
game.updatePointsAndCapture(captured_piece, false)
node.animate_movement(source_container, moving_piece);
var tile = game.tileManager.get_tile(node.name)
if tile:
tile.apply_effect(moving_piece)
game.currentlyMovingPiece = moving_piece
game.resolveMoveEffects()
game.currentlyMovingPiece = moving_piece
game.resolveMoveEffects()
func handleRegularMove(node: PieceContainer, consume: bool) -> void:
func handleRegularMove(node: PieceContainer, consume: bool, generated: bool = false) -> void:
print("handleRegularMove", node, game.selectedNode)
if generated:
moveAndConsume(node, consume)
return
for i in game.areas:
if i == node.name:
print(i)
var sourceContainer = game.get_node("Flow/" + game.selectedNode) as PieceContainer
var piece = sourceContainer.get_piece()
print("Removing Piece 1")
node.animate_movement(sourceContainer, piece);
game.currentlyMovingPiece = piece
if consume:
game.resolveMoveEffects()
else:
game.togglePieceChessEffect()
moveAndConsume(node, consume)
func moveAndConsume(node: PieceContainer, consume: bool) -> void:
var sourceContainer = game.get_node("Flow/" + game.selectedNode) as PieceContainer
var piece = sourceContainer.get_piece()
print("Removing Piece 1")
node.animate_movement(sourceContainer, piece);
game.currentlyMovingPiece = piece
if consume:
game.resolveMoveEffects()
else:
game.togglePieceChessEffect()
func executeMove(targetLocation: String) -> void:

View file

@ -8,4 +8,4 @@ func enter(_previous: String, _data := {}) -> void:
game.updateEffectDurations()
# if (game.isPlayerTurn()):
# game.deckManager.updateCardDurations()
finished.emit(Constants.EVALUATE_POSITION)
finished.emit(Constants.TILE_EFFECTS)

View file

@ -3,4 +3,4 @@ extends "res://Systems/StateMachine/ChessGameState.gd"
func enter(_previous: String, _data := {}) -> void:
print("ENTERING STATE ", Constants.PERSISTENT_EFFECTS)
finished.emit(Constants.TILE_EFFECTS)
finished.emit(Constants.PRE_MOVE)

View file

@ -27,13 +27,6 @@ offset_bottom = -5.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_fkb2r")
boardXSize = null
boardYSize = null
tileXSize = null
tileYSize = null
windowXSize = null
windowYSize = null
FEN = null
[node name="Flow" type="FlowContainer" parent="."]
layout_mode = 1

View file

@ -33,4 +33,4 @@ import/blender/enabled=false
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method="mobile"