-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathworld.gd
More file actions
372 lines (306 loc) · 10.3 KB
/
world.gd
File metadata and controls
372 lines (306 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
extends Node
const ESCAPE_LEVEL = "_exit"
# World is a main global singleton that holds the game state and handles
# mutations. Eventually it should be serializable and loadable from a save file.
signal world_initialized
signal map_changed(map: Map)
signal effect_occurred(effect: ActionEffect)
signal message_logged(message: String, level: int)
signal turn_started
signal turn_ended
signal game_ended
signal energy_updated(monster: Monster)
# Like NetHack, we world_plan the dungeon in advance, but levels are only created when
# they are first visited.
var world_plan: WorldPlan
# Always keep a reference to the player
var player: Monster
# Keep track of generated maps
var maps: Dictionary # Map[id] -> Map
var current_map: Map
# Turn management
var current_turn: int
# Is the game over?
var game_over: bool = false
# Keep track of the max depth reached
var max_depth: int = 1
# The player's faction affinity
var faction_affinities: Dictionary = {
Factions.Type.HUMAN: 100, # There could be different human factions with different affinities
Factions.Type.CRITTERS: -30, # Somewhat hostile. Maybe add taming?
Factions.Type.MONSTERS: -100, # Initially hostile but can improve
Factions.Type.UNDEAD: -100, # Initially hostile but can improve
}
func _init() -> void:
Log.i("===========================")
Log.i("= Godot Roguelike Example =")
Log.i("===========================")
Log.i("")
func _ready() -> void:
initialize()
func initialize() -> void:
Log.i("Initializing world...")
# Initialize all vars
current_turn = 1
game_over = false
max_depth = 1
# Create a new world world_plan
world_plan = WorldPlan.new(WorldPlan.WorldType.NORMAL)
Log.i("World world_plan created: %s" % world_plan)
# Create the player with starting equipment
# TODO: Choose role based at main menu
player = MonsterFactory.create_monster(&"knight", Roles.Type.KNIGHT)
Roles.equip_monster(player, Roles.Type.KNIGHT)
Log.i("Player created: %s" % player)
# Create the first level
maps.clear()
var plan := world_plan.get_first_level_plan()
var map := _generate_map(plan)
maps[map.id] = map
current_map = map
# Add the player to the main entrance
assert(
map.add_monster_at_stairs(player, Obstacle.Type.STAIRS_UP),
"Failed to add player to main entrance"
)
# Compute FOV before the first turn
update_vision()
# Signal that the world is ready
map_changed.emit(current_map)
world_initialized.emit()
func _generate_map(plan: WorldPlan.LevelPlan) -> Map:
match plan.type:
WorldPlan.LevelType.ARENA:
var generator := MapGeneratorFactory.create_generator(
MapGeneratorFactory.GeneratorType.ARENA
)
return (
generator
. generate_map(
20,
15,
{
"depth": plan.depth,
}
)
)
WorldPlan.LevelType.DUNGEON:
var generator := MapGeneratorFactory.create_generator(
MapGeneratorFactory.GeneratorType.DUNGEON
)
return (
generator
. generate_map(
30,
20,
{
# Dungeon generation parameters
"min_room_size": 5,
"max_room_size": 9,
"size_variation": 0.6,
"room_placement_attempts": 500,
"target_room_count": 30,
"border_buffer": 3,
"room_expansion_chance": 0.5,
"max_expansion_attempts": 3,
"horizontal_expansion_bias": 0.5,
# Level parameters
"depth": plan.depth,
"has_up_stairs": plan.up_destination != "",
"has_down_stairs": plan.down_destination != "",
"has_amulet": plan.has_amulet
}
)
)
_:
Log.e("Unsupported level type: %s" % plan.type)
assert(false)
return null
# Apply an action (presumably from the player) to the world and complete the turn.
func apply_player_action(action: BaseAction) -> ActionResult:
Log.i("[color=lime]======== TURN %d STARTED ========[/color]" % World.current_turn)
turn_started.emit()
# Apply the player's action
Log.i("Applying action: %s" % action)
var result := action.apply(current_map)
if not result:
Log.i("[color=gray]==== TURN CANCELLED (Action Failed) ====[/color]")
return null
# If the action failed, return early without advancing the turn
if not result.success:
if result.message:
message_logged.emit(result.message)
Log.i("[color=gray]==== TURN CANCELLED (Result False) ====[/color]")
return result
# Update all monster systems
for monster in current_map.get_monsters():
# Update status effects
monster.tick_status_effects()
# Check encumbrance
monster.tick_encumbrance()
# Process player nutrition
var nutrition_cost := 1 + result.extra_nutrition_consumed
var nutrition_result := player.nutrition.decrease(nutrition_cost)
if nutrition_result.message:
message_logged.emit(nutrition_result.message, LogMessages.Level.BAD)
if nutrition_result.died:
player.is_dead = true
effect_occurred.emit(
DeathEffect.new(player, current_map.find_monster_position(player), true)
)
game_over = true
game_ended.emit()
return result
# Process natural healing
if player.nutrition.value >= Nutrition.THRESHOLD_STARVING and player.hp < player.max_hp:
# Base healing of 1 HP every 3 turns
if current_turn % 3 == 0:
var heal_amount := 1
# Bonus healing when well fed
if player.nutrition.value >= Nutrition.THRESHOLD_SATIATED:
heal_amount += 1
player.hp = mini(player.hp + heal_amount, player.max_hp)
# Accumulate energy for all monsters
for monster in current_map.get_monsters():
monster.energy += monster.get_speed()
# Build a list of results from the action
var results: Array[ActionResult] = [result]
# Give turns to monsters that have enough energy
var monsters := current_map.get_monsters()
Log.d("Checking %d monsters for turns" % monsters.size())
for monster in monsters:
if monster == player:
continue
# Only act if we have enough energy
if monster.energy >= Monster.SPEED_NORMAL:
var monster_action := monster.get_next_action(current_map)
if monster_action:
var monster_result := monster_action.apply(current_map)
results.append(monster_result)
# Consume energy after acting
monster.energy -= Monster.SPEED_NORMAL
energy_updated.emit(monster)
# Update area effects
update_area_effects()
# Update vision
update_vision()
# Now emit all the results
for res in results:
# Emit effects
for effect in res.effects:
effect_occurred.emit(effect)
# Emit messages
if res.message:
message_logged.emit(res.message, res.message_level)
# Emit turn ended signal
Log.i("[color=lime]-------- TURN %d ENDED --------[/color]" % World.current_turn)
turn_ended.emit()
# Mark the turn as over
current_turn += 1
# Is the player dead?
if player.is_dead:
game_over = true
game_ended.emit()
return result
func handle_special_level(id: String) -> void:
match id:
ESCAPE_LEVEL:
# Request confirmation before letting the player leave
var confirmed: Variant = await Modals.confirm(
"Confirm Escape",
"Are you sure you want to leave the dungeon? This will end your adventure."
)
if confirmed:
current_map.find_and_remove_monster(player)
message_logged.emit("[color=cyan]You have escaped the dungeon.[/color]")
game_ended.emit()
func handle_level_transition(destination_level: String, coming_from_stairs: Obstacle.Type) -> void:
# Get the level plan for the destination
var plan := world_plan.get_level_plan(destination_level)
if not plan:
Log.e("No level plan found for %s" % destination_level)
return
# Generate or load the next level
if not maps.has(destination_level):
var map := _generate_map(plan)
map.id = destination_level
maps[destination_level] = map
# Remove player from current map
current_map.find_and_remove_monster(player)
# Switch to the new map
current_map = maps[destination_level]
max_depth = maxi(max_depth, current_map.depth)
# Add player at appropriate entrance based on which stairs they used
var target_stairs_type := (
Obstacle.Type.STAIRS_DOWN
if coming_from_stairs == Obstacle.Type.STAIRS_UP
else Obstacle.Type.STAIRS_UP
)
assert(
current_map.add_monster_at_stairs(player, target_stairs_type),
"Failed to add player at stairs"
)
# Update FOV for new position
var player_pos := current_map.find_monster_position(player)
current_map.compute_fov(player_pos)
# Signal that the map has changed
map_changed.emit(current_map)
## Updates all area effects and applies their damage
func update_area_effects() -> void:
var messages: Array[String] = []
for x in range(current_map.width):
for y in range(current_map.height):
var cell := current_map.get_cell(Vector2i(x, y))
var pos := Vector2i(x, y)
# Check for armed grenades and handle their countdown
for item in cell.items:
if item.type == Item.Type.GRENADE and item.is_armed:
item.turns_to_activate -= 1
if item.turns_to_activate <= 0:
# Remove the grenade from the map
current_map.remove_item(pos, item)
# Apply the grenade's area effect
if item.aoe_config:
current_map.apply_aoe(
pos,
item.aoe_config.radius,
item.aoe_config.type,
item.damage,
item.aoe_config.turns
)
messages.append("%s explodes!" % item.get_name(Item.NameFormat.THE))
# Create visual explosion effect
await VisualEffects.create_explosion(
get_tree().current_scene, pos, true
)
else:
Log.e("Armed grenade has no AOE config: %s" % item)
# Apply damage from each effect *after* the grenades have exploded
for x in range(current_map.width):
for y in range(current_map.height):
var cell := current_map.get_cell(Vector2i(x, y))
var pos := Vector2i(x, y)
# Apply damage from each effect
for effect in cell.area_effects:
if cell.monster:
var monster: Monster = cell.monster
var result := Combat.resolve_aoe_damage(monster, effect.damage, effect.type)
if result.killed:
monster.is_dead = true
if monster != player:
messages.append(
"%s is killed!" % monster.get_name(Monster.NameFormat.THE)
)
effect_occurred.emit(DeathEffect.new(monster, pos, monster == player))
monster.drop_everything()
# Update effect durations
cell.update_effects()
# Log all messages at once
for msg in messages:
message_logged.emit(msg)
func update_vision() -> void:
var player_pos := current_map.find_monster_position(player)
if player.has_status_effect(StatusEffect.Type.BLIND):
current_map.clear_fov(player_pos)
else:
current_map.compute_fov(player_pos)