624 lines
21 KiB
GDScript
624 lines
21 KiB
GDScript
extends RefCounted
|
|
class_name ChessMapGenerator
|
|
|
|
|
|
|
|
var min_levels = 10
|
|
var max_levels = 20
|
|
var max_connections_per_node = 4
|
|
|
|
var min_nodes_per_level = 1
|
|
var max_nodes_per_level = 4
|
|
var positions_per_level = 6
|
|
var starting_elo = 1000
|
|
var final_elo = 2100
|
|
var current_max_level = 0;
|
|
# var level_unit_distribution = ["", "", "", "nkr", "rnkr", "rnkbr", "rnqkbr", "rnqkbnr", "rnbqkbnr", "rbnqknbnr", "rnbnqknbnr", "rnbnqknbnbr", "rbnbnqknbnbr"]
|
|
var level_unit_distribution = ["", "nk", "nkr", "rnkr", "rnkr", "rnkbr", "rnqkbr", "rnqkbnr", "rnbqkbnr", "rbnqknbnr", "rnbnqknbnr"]
|
|
# ["", "", "nkr", "rnkr", "rnkr", "rnkbr", "rnqkbr", "rnqkbnr", "rnbqkbnr", "rbnqknbnr", "rnbnqknbnr", "rnbnqknbnbr", "rbnbnqknbnbr"]
|
|
var _rng = RandomNumberGenerator.new()
|
|
var _next_id = 0
|
|
|
|
func _init(seed_value = null):
|
|
# Set seed for reproducible maps
|
|
if seed_value != null:
|
|
_rng.seed = seed_value
|
|
else:
|
|
_rng.randomize()
|
|
|
|
func generate_map():
|
|
var nodes = []
|
|
var connections = []
|
|
_next_id = 0
|
|
|
|
var num_levels = _rng.randi_range(min_levels, max_levels)
|
|
current_max_level = num_levels;
|
|
var elo_step = float(final_elo - starting_elo) / (num_levels - 1)
|
|
|
|
var start_node = {
|
|
"id": _get_next_id(),
|
|
"type": Utils.RoomType.STARTING,
|
|
"level": 0,
|
|
"position": Vector2(3, 0),
|
|
"metadata": {
|
|
"is_escape": false,
|
|
},
|
|
"elo": starting_elo
|
|
}
|
|
nodes.append(start_node)
|
|
|
|
# Create final boss node
|
|
var final_node = {
|
|
"id": _get_next_id(),
|
|
"type": Utils.RoomType.FINAL,
|
|
"level": num_levels - 1,
|
|
"position": Vector2(3, num_levels - 1),
|
|
"metadata": {
|
|
"is_escape": true,
|
|
},
|
|
"elo": final_elo
|
|
}
|
|
nodes.append(final_node)
|
|
|
|
var levels_nodes = {0: [start_node], (num_levels - 1): [final_node]}
|
|
|
|
for level in range(1, num_levels - 1):
|
|
var level_nodes = []
|
|
var level_elo = starting_elo + (elo_step * level)
|
|
# Change this so that its more weighted towards the lower end with rare occurances of max_nodes_per_level rather than an even split
|
|
var num_nodes = get_weighted_node_count(min_nodes_per_level, max_nodes_per_level)
|
|
var available_positions = []
|
|
for pos in range(positions_per_level):
|
|
available_positions.append(pos)
|
|
available_positions.shuffle()
|
|
|
|
|
|
for i in range(num_nodes):
|
|
var node_type = _get_random_room_type(level, num_levels)
|
|
var node = {
|
|
"id": _get_next_id(),
|
|
"type": node_type,
|
|
"level": level,
|
|
"position": Vector2(available_positions[i], level),
|
|
"elo": level_elo,
|
|
"metadata": {}
|
|
}
|
|
node = generate_node_data(node)
|
|
nodes.append(node)
|
|
level_nodes.append(node)
|
|
|
|
levels_nodes[level] = level_nodes
|
|
|
|
# Connect nodes between levels
|
|
# First connect starting node to level 1
|
|
if levels_nodes.has(1) and levels_nodes[1].size() > 0:
|
|
var num_connections = min(_rng.randi_range(2, 3), levels_nodes[1].size())
|
|
var targets = levels_nodes[1].duplicate()
|
|
targets.shuffle()
|
|
|
|
for i in range(num_connections):
|
|
connections.append({
|
|
"from": start_node.id,
|
|
"to": targets[i].id
|
|
})
|
|
|
|
# Keep track of which nodes are connected
|
|
var connected_nodes = [start_node.id]
|
|
for level in range(1, num_levels - 1):
|
|
if not levels_nodes.has(level) or not levels_nodes.has(level + 1):
|
|
continue
|
|
|
|
var current_level_nodes = levels_nodes[level]
|
|
var next_level_nodes = levels_nodes[level + 1].duplicate()
|
|
next_level_nodes.shuffle()
|
|
|
|
# For each node in current level that is connected from previous level
|
|
for node in current_level_nodes:
|
|
if _is_node_connected_to(node.id, connections):
|
|
# Add to connected nodes
|
|
connected_nodes.append(node.id)
|
|
|
|
# Connect to 1-2 nodes in next level if not the final level
|
|
if level < num_levels - 2:
|
|
var num_next_connections = _rng.randi_range(1, max_connections_per_node)
|
|
num_next_connections = min(num_next_connections, next_level_nodes.size())
|
|
var shouldTrim = _rng.randi_range(1, 15) > 3 || num_next_connections > 3
|
|
for i in range(num_next_connections):
|
|
if i < next_level_nodes.size():
|
|
connections.append({
|
|
"from": node.id,
|
|
"to": next_level_nodes[i].id
|
|
})
|
|
|
|
# # Remove the selected nodes so they aren't picked again
|
|
# if lots of ocnnection earlier and deeper than lvl 3
|
|
# if num_next_connections >= 2 && level > 3:
|
|
if shouldTrim:
|
|
for i in range(num_next_connections):
|
|
if next_level_nodes.size() > 0:
|
|
next_level_nodes.pop_front()
|
|
|
|
# Connect to final boss if at the level before
|
|
elif level == num_levels - 2:
|
|
connections.append({
|
|
"from": node.id,
|
|
"to": final_node.id
|
|
})
|
|
|
|
# Remove any nodes that aren't connected
|
|
var valid_nodes = []
|
|
for node in nodes:
|
|
if connected_nodes.has(node.id) or node.id == final_node.id:
|
|
valid_nodes.append(node)
|
|
|
|
# Clean up connections to removed nodes
|
|
var valid_connections = []
|
|
for conn in connections:
|
|
var from_valid = false
|
|
var to_valid = false
|
|
|
|
for node in valid_nodes:
|
|
if node.id == conn.from:
|
|
from_valid = true
|
|
if node.id == conn.to:
|
|
to_valid = true
|
|
|
|
if from_valid and to_valid:
|
|
valid_connections.append(conn)
|
|
var index = 0
|
|
for valid_node in valid_nodes:
|
|
var isLeaf = true;
|
|
for connection in valid_connections:
|
|
# if theres outgoing connection we arent at a dead end
|
|
# dont change final node colour
|
|
if connection.from == valid_node.id || valid_node.id == 1:
|
|
isLeaf = false
|
|
break;
|
|
valid_node.metadata.is_escape = isLeaf
|
|
valid_nodes[index] = valid_node;
|
|
index += 1
|
|
|
|
return {
|
|
"nodes": valid_nodes,
|
|
"connections": valid_connections,
|
|
"levels": num_levels,
|
|
"seed": _rng.seed
|
|
}
|
|
|
|
func _get_random_room_type(level, total_levels):
|
|
var boss_chance = 0.1 + (level / float(total_levels) * 0.1)
|
|
var shop_chance = 0.1 + (level / float(total_levels) * 0.05)
|
|
var event_chance = 0.05
|
|
|
|
if level == total_levels - 2:
|
|
boss_chance += 0.3
|
|
|
|
var roll = _rng.randf()
|
|
if roll < boss_chance:
|
|
return Utils.RoomType.BOSS
|
|
elif roll < boss_chance + shop_chance:
|
|
return Utils.RoomType.SHOP
|
|
elif roll < boss_chance + shop_chance + event_chance:
|
|
return Utils.RoomType.EVENT
|
|
else:
|
|
return Utils.RoomType.NORMAL
|
|
|
|
func _is_node_connected_to(node_id, connections):
|
|
for conn in connections:
|
|
if conn.to == node_id:
|
|
return true
|
|
return false
|
|
|
|
func _get_next_id():
|
|
var id = _next_id
|
|
_next_id += 1
|
|
return id
|
|
|
|
|
|
|
|
func get_weighted_node_count(min_count, max_count):
|
|
# Use exponential distribution to weight toward lower values
|
|
var range_size = max_count - min_count + 1
|
|
|
|
var rand_val = _rng.randf()
|
|
|
|
# Apply exponential weighting (higher exponent = more weight toward minimum)
|
|
var weight_exponent = 2 # Adjust this to control the curve
|
|
var weighted_val = pow(rand_val, weight_exponent)
|
|
|
|
var node_count = min_count + floor(weighted_val * range_size)
|
|
|
|
if rand_val > 0.70:
|
|
node_count = max_count
|
|
|
|
return node_count
|
|
|
|
func generate_node_data(node):
|
|
var data = {
|
|
"id": node.id,
|
|
"type": node.type,
|
|
"level": node.level,
|
|
"position": node.position,
|
|
"elo": node.elo,
|
|
"metadata": node.metadata
|
|
}
|
|
|
|
match data.type:
|
|
Utils.RoomType.STARTING:
|
|
data.metadata = generate_starting_data(data, )
|
|
Utils.RoomType.NORMAL:
|
|
data.metadata = generate_chess_data(data)
|
|
Utils.RoomType.BOSS:
|
|
data.metadata = generate_boss_data(data)
|
|
Utils.RoomType.FINAL:
|
|
data.metadata = { "is_escape": true}
|
|
Utils.RoomType.SHOP:
|
|
data.metadata = generate_shop_data(data)
|
|
Utils.RoomType.EVENT:
|
|
data.metadata = generate_event_data(data)
|
|
_:
|
|
data.metadata = {}
|
|
return data
|
|
|
|
|
|
func generate_boss_data(node):
|
|
# level_unit_distribution
|
|
# current_max_level
|
|
var rng = float(node.level) / int(current_max_level)
|
|
var game_type = "zerg"
|
|
var height = 6;
|
|
# (current_value, min_value, max_value, min_index, max_index)
|
|
var index = map_to_array_index(node.level, 2, current_max_level - 2, 1, level_unit_distribution.size() - 1);
|
|
if game_type == "zerg":
|
|
index = 7
|
|
height = 7
|
|
# if game_type == "zerg":
|
|
# index = map_to_array_index(node.level, 5, current_max_level - 2, 1, level_unit_distribution.size() - 1);
|
|
var unit_string = level_unit_distribution[index]
|
|
var pawn_string = ""
|
|
var enemy_unit_depth = 3
|
|
|
|
for x in unit_string.length():
|
|
pawn_string += "p"
|
|
|
|
|
|
var fen = "";
|
|
# "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
if node.level >= 7 and node.level <= 10:
|
|
height = node.level
|
|
enemy_unit_depth = 3
|
|
elif node.level > 10:
|
|
height = 10
|
|
enemy_unit_depth = 4
|
|
for x in height - enemy_unit_depth:
|
|
if x == 0:
|
|
fen += unit_string + "/"
|
|
elif x == 1:
|
|
fen += pawn_string + "/"
|
|
# elif x == height - 3:
|
|
# fen += "u"
|
|
# # for y in unit_string.length() - 1:
|
|
# # fen += "*"
|
|
# fen += "u/"
|
|
else:
|
|
fen += str(unit_string.length()) + "/"
|
|
|
|
|
|
if game_type == "zerg":
|
|
for x in enemy_unit_depth - 1:
|
|
fen += pawn_string.to_upper() + "/"
|
|
fen += pawn_string.to_upper()
|
|
else:
|
|
fen += pawn_string.to_upper() + "/" + unit_string.to_upper()
|
|
var fen_ending = " w KQkq - 0 1"
|
|
# print("generate_chess_data ", fen + fen_ending)
|
|
return {
|
|
"is_escape": node.metadata.is_escape if node.metadata.has("is_escape") else false,
|
|
"fen": fen + fen_ending,
|
|
"game_type": game_type,
|
|
"win_condition": Utils.WinConditionType.TURN_NUMBER,
|
|
"win_target_turn": 30,
|
|
"has_opponent": true,
|
|
"loss_target_unit": "King",
|
|
"loss_condition": Utils.LossConditionType.UNIT_LOST,
|
|
"reward": {
|
|
"gold": 50 * node.level,
|
|
"cards": [],
|
|
"selection": generate_shop_cards(3),
|
|
"selection_limit": 2
|
|
},
|
|
"elo": node.elo,
|
|
}
|
|
|
|
|
|
func generate_shop_data(node):
|
|
var num_shop_cards = min(randi_range(5, 7), Utils.CardTypes.size())
|
|
return {
|
|
"is_escape": node.metadata.is_escape if node.metadata.has("is_escape") else false,
|
|
"cards": generate_shop_cards(num_shop_cards),
|
|
}
|
|
func generate_shop_cards(num_shop_cards):
|
|
var shop_cards = []
|
|
|
|
var all_cards = []
|
|
|
|
for card_class in Utils.CardTypes:
|
|
var card = card_class.new()
|
|
all_cards.append(card)
|
|
|
|
all_cards.shuffle()
|
|
|
|
|
|
for i in range(num_shop_cards):
|
|
shop_cards.append(all_cards[i % Utils.CardTypes.size()])
|
|
|
|
return shop_cards
|
|
|
|
|
|
func generate_starting_data(node):
|
|
return {
|
|
"fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
|
"elo": node.elo,
|
|
}
|
|
|
|
func generate_event_data(node):
|
|
var index = map_to_array_index(node.level, 2, current_max_level - 2, 1, level_unit_distribution.size() - 1);
|
|
var unit_string = level_unit_distribution[index]
|
|
|
|
var height = 6
|
|
var width = unit_string.length()
|
|
if node.level > 7 and node.level <= 10:
|
|
height = node.level
|
|
elif node.level > 10:
|
|
height = 10
|
|
# var mazedata = generate_maze(width, height)
|
|
var mazedata = generate_maze(12, 12)
|
|
# print("**************************")
|
|
# print("**************************")
|
|
# print("**************************")
|
|
# print(mazedata)
|
|
# print("**************************")
|
|
# print("**************************")
|
|
# print("**************************")
|
|
return {
|
|
"is_escape": node.metadata.is_escape if node.metadata.has("is_escape") else false,
|
|
"fen": mazedata.fen + " w KQkq - 0 1",
|
|
"game_type": "maze",
|
|
"has_opponent": false,
|
|
"win_condition": Utils.WinConditionType.TILE_REACHED,
|
|
"win_target": [mazedata.end],
|
|
"win_target_unit": "King",
|
|
"elo": node.elo,
|
|
"reward": {
|
|
"gold": 150 * node.level,
|
|
"cards": [],
|
|
"selection": generate_shop_cards(5),
|
|
"selection_limit": 1
|
|
},
|
|
}
|
|
# "rnbqkbnr1/pppppppp1/9/9/9/9/9/PPPPPPPP1/RNBQKBNR1 w KQkq - 0 1"
|
|
func generate_chess_data(node):
|
|
# level_unit_distribution
|
|
# current_max_level
|
|
var rng = float(node.level) / int(current_max_level)
|
|
# (current_value, min_value, max_value, min_index, max_index)
|
|
var index = map_to_array_index(node.level, 2, current_max_level - 2, 1, level_unit_distribution.size() - 1);
|
|
var unit_string = level_unit_distribution[index]
|
|
var pawn_string = ""
|
|
|
|
for x in unit_string.length():
|
|
pawn_string += "p"
|
|
|
|
var height = 6;
|
|
# "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
var fen = "";
|
|
if node.level > 8 and node.level <= 10:
|
|
height = node.level
|
|
elif node.level > 10:
|
|
height = 10
|
|
for x in height - 2:
|
|
if x == 0:
|
|
fen += unit_string + "/"
|
|
elif x == 1:
|
|
fen += pawn_string + "/"
|
|
# elif x == height - 3:
|
|
# fen += "u"
|
|
# # for y in unit_string.length() - 1:
|
|
# # fen += "*"
|
|
# fen += "u/"
|
|
else:
|
|
fen += str(unit_string.length()) + "/"
|
|
|
|
fen += pawn_string.to_upper() + "/" + unit_string.to_upper()
|
|
var fen_ending = " w KQkq - 0 1"
|
|
# print("generate_chess_data ", fen + fen_ending)
|
|
var win_condition = Utils.WinConditionType.CAPTURE_UNIT # Default
|
|
var loss_condition = Utils.LossConditionType.UNIT_LOST # Default
|
|
|
|
# Randomly select a win condition with increasing variety at higher levels
|
|
var rng_val = _rng.randf()
|
|
var conditions_pool = [Utils.WinConditionType.CAPTURE_UNIT] # Default pool
|
|
|
|
# # Add more varied win conditions at higher levels
|
|
# if node.level >= 3:
|
|
# conditions_pool.append(Utils.WinConditionType.BOARD_CLEARED)
|
|
# if node.level >= 5:
|
|
# conditions_pool.append(Utils.WinConditionType.TILE_REACHED)
|
|
# if node.level >= 7:
|
|
# conditions_pool.append(Utils.WinConditionType.TURN_NUMBER)
|
|
|
|
# # For certain special nodes, always use specific conditions
|
|
# if node.type == Utils.RoomType.EVENT:
|
|
# win_condition = Utils.WinConditionType.TILE_REACHED
|
|
# elif node.type == Utils.RoomType.BOSS and node.level > 5:
|
|
# # Bosses at higher levels have more varied win conditions
|
|
# win_condition = conditions_pool[_rng.randi() % conditions_pool.size()]
|
|
# else:
|
|
# # Regular nodes have weighted randomness
|
|
# if rng_val < 0.7: # 70% chance of default capture king
|
|
# win_condition = Utils.WinConditionType.CAPTURE_UNIT
|
|
# else:
|
|
# # Pick randomly from the conditions pool
|
|
# win_condition = conditions_pool[_rng.randi() % conditions_pool.size()]
|
|
|
|
# Create additional metadata for specific win conditions
|
|
var additional_metadata = {}
|
|
|
|
# match win_condition:
|
|
# Utils.WinConditionType.TILE_REACHED:
|
|
# # Generate target tiles for the Tile win condition
|
|
# var target_tiles = []
|
|
# var target_count = 1 # Default to 1 target tile
|
|
|
|
# # Determine target unit (default is any piece)
|
|
# var target_unit = ""
|
|
# if _rng.randf() < 0.5: # 50% chance of specific piece
|
|
# var pieces = ["Pawn", "Knight", "Bishop", "Rook", "Queen"]
|
|
# target_unit = pieces[_rng.randi() % pieces.size()]
|
|
|
|
# # Create target tiles at the enemy's back rank
|
|
# var target_x = _rng.randi() % unit_string.length()
|
|
# target_tiles.append(str(target_x) + "-0") # Top row
|
|
|
|
# additional_metadata["target_tiles"] = target_tiles
|
|
# additional_metadata["target_unit"] = target_unit
|
|
|
|
# Utils.WinConditionType.TURN_NUMBER:
|
|
# # Set a target turn number
|
|
# additional_metadata["target_turn"] = (node.level * 2) + _rng.randi_range(5, 10)
|
|
|
|
# # Also add a turn limit for loss condition
|
|
# loss_condition = Utils.LossConditionType.TURN_NUMBER
|
|
# additional_metadata["turn_limit"] = additional_metadata["target_turn"] + _rng.randi_range(5, 15)
|
|
|
|
# Build the result metadata
|
|
var result = {
|
|
"is_escape": node.metadata.is_escape if node.metadata.has("is_escape") else false,
|
|
"fen": fen + fen_ending,
|
|
"game_type": "chess",
|
|
"win_condition": win_condition,
|
|
"loss_condition": loss_condition,
|
|
"has_opponent": true,
|
|
"elo": node.elo,
|
|
"reward": {
|
|
"gold": 50 * node.level
|
|
}
|
|
}
|
|
|
|
# Merge additional metadata
|
|
for key in additional_metadata:
|
|
result[key] = additional_metadata[key]
|
|
|
|
return result
|
|
|
|
func map_to_array_index(current_value, min_value, max_value, min_index, max_index):
|
|
# Ensure the current value is within bounds
|
|
var clamped_value = clamp(current_value, min_value, max_value)
|
|
|
|
# Calculate how far along the input range we are (0.0 to 1.0)
|
|
var normalized_position = float(clamped_value - min_value) / float(max_value - min_value)
|
|
|
|
# Map this to our target index range
|
|
var index_range = max_index - min_index
|
|
var mapped_index = min_index + round(normalized_position * index_range)
|
|
|
|
# Ensure we're returning an integer within the valid array index range
|
|
return int(clamp(mapped_index, min_index, max_index))
|
|
|
|
|
|
|
|
func generate_maze(width: int, height: int) -> Dictionary:
|
|
# Ensure dimensions are odd to have proper walls
|
|
if width % 2 == 0:
|
|
width += 1
|
|
if height % 2 == 0:
|
|
height += 1
|
|
|
|
# Initialize the maze with all walls
|
|
var maze = []
|
|
for y in range(height):
|
|
var row = []
|
|
for x in range(width):
|
|
row.append("*") # * represents wall
|
|
maze.append(row)
|
|
|
|
# Use a recursive backtracking algorithm to generate the maze
|
|
var rng = RandomNumberGenerator.new()
|
|
rng.randomize()
|
|
|
|
# Start at a random odd position
|
|
var start_x = rng.randi_range(0, width/2-1) * 2 + 1
|
|
var start_y = rng.randi_range(0, height/2-1) * 2 + 1
|
|
|
|
# Carve the maze recursively
|
|
_carve_maze(maze, start_x, start_y, width, height, rng)
|
|
|
|
# Pick a random end point (far from start point)
|
|
var end_x = 0
|
|
var end_y = 0
|
|
var max_distance = 0
|
|
|
|
for y in range(1, height, 2):
|
|
for x in range(1, width, 2):
|
|
if maze[y][x] == " ": # Only consider path cells
|
|
var distance = abs(x - start_x) + abs(y - start_y)
|
|
if distance > max_distance:
|
|
max_distance = distance
|
|
end_x = x
|
|
end_y = y
|
|
|
|
maze[start_y][start_x] = "k"
|
|
|
|
# Mark the end position with a space (keep it as a path)
|
|
# It's already a space, but we ensure it here
|
|
maze[end_y][end_x] = " "
|
|
|
|
# Convert the maze to a FEN-like string
|
|
var fen_string = ""
|
|
for y in range(height):
|
|
var empty_count = 0
|
|
for x in range(width):
|
|
if maze[y][x] == " ": # Empty space
|
|
empty_count += 1
|
|
else:
|
|
if empty_count > 0:
|
|
fen_string += str(empty_count)
|
|
empty_count = 0
|
|
fen_string += maze[y][x]
|
|
|
|
# Add any remaining empty count
|
|
if empty_count > 0:
|
|
fen_string += str(empty_count)
|
|
|
|
# Add row separator (except for the last row)
|
|
if y < height - 1:
|
|
fen_string += "/"
|
|
return {
|
|
"fen": fen_string,
|
|
"start": str(start_x) + "-"+ str(start_y),
|
|
"end": str(end_x) + "-"+ str(end_y)
|
|
}
|
|
|
|
# Recursive function to carve out the maze
|
|
func _carve_maze(maze, x, y, width, height, rng):
|
|
# Mark the current cell as a path
|
|
maze[y][x] = " "
|
|
|
|
# Define the four possible directions (up, right, down, left)
|
|
var directions = [[0, -2], [2, 0], [0, 2], [-2, 0]]
|
|
|
|
# Shuffle directions for randomness
|
|
directions.shuffle()
|
|
|
|
# Try each direction
|
|
for dir in directions:
|
|
var nx = x + dir[0]
|
|
var ny = y + dir[1]
|
|
|
|
# Check if the new position is valid and unvisited
|
|
if nx > 0 and nx < width-1 and ny > 0 and ny < height-1 and maze[ny][nx] == "*":
|
|
# Carve a path between the current cell and the new cell
|
|
maze[y + dir[1]/2][x + dir[0]/2] = " "
|
|
|
|
# Recursively carve from the new cell
|
|
_carve_maze(maze, nx, ny, width, height, rng)
|