-
Notifications
You must be signed in to change notification settings - Fork 1
/
main.lua
550 lines (489 loc) · 18.3 KB
/
main.lua
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
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
meta.name = "AI Visualizer"
meta.version = "0.0.0"
meta.description = "Adds an overlay showing entity AI ranges and targetting information."
meta.author = "Cosine"
local ai_common = require("ai/common")
Entity_AI = require("ai/entity_ai")
geometry = require("geometry")
local drawing = require("drawing")
LABEL_POSITION = drawing.Draw_Item.LABEL_POSITION
local persistence = require("persistence")
local ENTITY_AI_MODULES = {
"ammit",
"anubis",
"anubis_2",
"axolotl",
"bat",
"bee",
"beg",
"bodyguard",
"butterfly",
"cat_mummy",
"caveman",
"celestial_jelly",
"crab",
"crocman",
"drone",
"egg_sac",
"eggplant_king",
"firefly",
"fish",
"flying_fish",
"frog",
"ghist_shopkeeper",
"ghost",
"giant_spider",
"golden_monkey",
"goliath_frog",
"great_humphead",
"grub",
"hang_spider",
"hermit_crab",
"hired_hand",
"horned_lizard",
"hundun",
"imp",
"jiangshi",
"jiangshi_assassin",
"jungle_sister",
"kingu",
"lahamu",
"lamassu",
"lavamander",
"leprechaun",
"locust",
"madame_tusk",
"mech",
"mole",
"monkey",
"mosquito",
"mummy",
"necromancer",
"octopy",
"olmec",
"olmite",
"osiris",
"pangxie",
"quillback",
"robot",
"rock_dog",
"scarab",
"scorpion",
"shopkeeper",
"shopkeeper_clone",
"shopkeeper_generator",
"skeleton",
"sorceress",
"spark_trap",
"sparrow",
"spider",
"sun_challenge_generator",
"tadpole",
"tiamat",
"tiki_man",
"tun",
"turkey",
"ufo",
"vampire",
"van_horsing",
"waddler",
"witch_doctor",
"yang",
"yeti_king",
"yeti_queen"
}
INDENT = 5
ENT_TYPE_ID_TO_KEY_MAP = {}
for key, id in pairs(ENT_TYPE) do
ENT_TYPE_ID_TO_KEY_MAP[id] = key
end
local ORIGIN_CHECK_COLOR = drawing.Draw_Color:new({
Color:new(0.5, 1, 1, 1),
Color:new(0.5, 0.75, 1, 1),
Color:new(0.5, 1, 0.75, 1),
Color:new(0.375, 1, 1, 1)
})
local HITBOX_OVERLAP_COLOR = drawing.Draw_Color:new({
Color:new(0.25, 0.5, 1, 1),
Color:new(0.25, 0.25, 1, 1)
})
local HURTBOX_COLOR = drawing.Draw_Color:new({
Color:new(1, 0.5, 0.5, 1),
Color:new(0.875, 0.625, 0.5, 1)
})
local SOLID_CHECK_COLOR = drawing.Draw_Color:new({
Color:new(1, 0, 0.5, 1)
})
local MISC_BOX_COLOR = drawing.Draw_Color:new({
Color:new(1, 0.875, 0.25, 1),
Color:new(0.875, 1, 0.25, 1)
})
local TARGET_COLOR = drawing.Draw_Color:new({
Color:new(1, 0.5, 0, 1)
})
local default_options = {
options_window_visible = false,
player_origin_visible = true,
entity_ranges_visible = true,
entity_target_visible = true
-- entity_ai will be populated while loading entity AIs.
}
-- List of entity AIs. Contents should be initialized while loading the script.
local entity_ai_list
-- Map of entity AIs with entity type IDs as keys. Generated based on entity_ai_list.
local entity_ai_by_ent_type
-- List of all entity types covered by entity AIs. Generated based on entity_ai_by_ent_type.
local entity_ai_ent_type_list
-- Map of tracked entity IDs and their stored data.
local tracked_ents
-- Builds precomputed data structures and prepares options for the entity AIs. This needs to be called before trying to process any entities.
local function init_entity_ai()
entity_ai_list = {}
for _, module_name in ipairs(ENTITY_AI_MODULES) do
local full_module_name = "ai/"..module_name
-- Load the module with error handling to identify it if it fails to load. The error that gets thrown by default is unhelpful.
local success, module = pcall(function() return require(full_module_name) end)
if success then
if module[1] then
-- The module contains a list of entity AIs.
for _, module_entity_ai in ipairs(module) do
table.insert(entity_ai_list, module_entity_ai)
end
else
-- The module contains a single entity AI.
table.insert(entity_ai_list, module)
end
else
print("Warning: Failed to load entity AI module \""..full_module_name.."\": "..module)
end
end
entity_ai_by_ent_type = {}
default_options.entity_ai = {}
for _, entity_ai in ipairs(entity_ai_list) do
local ent_types
if type(entity_ai.ent_type) == "number" then
ent_types = { entity_ai.ent_type }
elseif type(entity_ai.ent_type) == "table" then
ent_types = entity_ai.ent_type
else
ent_types = {}
end
for _, ent_type in ipairs(ent_types) do
entity_ai_by_ent_type[ent_type] = entity_ai
end
if not entity_ai.parent_id then
default_options.entity_ai[entity_ai.id] = {
visible = true
}
end
end
entity_ai_ent_type_list = {}
for ent_type, _ in pairs(entity_ai_by_ent_type) do
table.insert(entity_ai_ent_type_list, ent_type)
end
end
local function get_entity_ai(ent)
if not ent then
return nil
end
local entity_ai = entity_ai_by_ent_type[ent.type.id]
if entity_ai == nil then
-- TODO: This shouldn't happen, but I keep finding edge cases that cause it.
print("Warning: entity_ai is nil for id="..ent.uid.." type="..ENT_TYPE_ID_TO_KEY_MAP[ent.type.id])
tracked_ents[ent.uid] = nil
end
return entity_ai
end
local function create_tracked_entity(ent)
tracked_ents[ent.uid] = {}
return ent.uid
end
local function should_process_entities(screen)
return screen == SCREEN.CAMP or screen == SCREEN.LEVEL or screen == SCREEN.DEATH
end
local function clip_line_of_sight(process_ctx, shape, max_checks, extra_length)
local facing_mult = process_ctx.is_facing_left and -1 or 1
local check_count = 1
while true do
if ai_common.is_point_solid_grid_entity_or_active_floor(
process_ctx.ent_x + (facing_mult * check_count), process_ctx.ent_y, process_ctx.ent_layer)
then
break
elseif check_count >= max_checks then
return
else
check_count = check_count + 1
end
end
local clip_offset = check_count - 1 + extra_length
if process_ctx.is_facing_left then
shape:clip_left(process_ctx.ent_x - clip_offset)
else
shape:clip_right(process_ctx.ent_x + clip_offset)
end
end
local function process_tracked_entity(id)
local ent = get_entity(id)
if not ent then
-- This entity no longer exists.
tracked_ents[id] = nil
return
end
local tracked_ent_data = {
draw_items = {}
}
tracked_ents[id] = tracked_ent_data
local entity_ai = get_entity_ai(ent)
if not entity_ai or entity_ai.is_dead(ent) then
return
end
if not options.entity_ai[entity_ai.parent_id or entity_ai.id].visible then
return
end
local ranges_visible = options.entity_ranges_visible
local targetting_visible = options.entity_target_visible and entity_ai.targetting
if not ranges_visible and not targetting_visible then
return
end
local ent_x, ent_y, ent_layer = get_position(ent.uid)
-- This processing context has a lifespan of one processing call, and is passed into every Entity_AI function. The entity is also passed into every function as its own parameter for convenience due to how frequently it is used.
local process_ctx = {
ent = ent,
ent_x = ent_x,
ent_y = ent_y,
ent_layer = ent_layer
}
if ranges_visible then
if entity_ai.preprocess then
entity_ai.preprocess(ent, process_ctx)
end
process_ctx.is_facing_left = test_flag(ent.flags, ENT_FLAG.FACING_LEFT)
-- TODO: The "stuck in something" flag is currently missing from ENT_MORE_FLAG.
local is_stuck = test_flag(ent.more_flags, 8)
-- TODO: Is state, stun_timer, or stun_state responsible for this?
local is_stunned = ent.state == CHAR_STATE.STUNNED
local is_frozen = ent.frozen_timer and ent.frozen_timer > 0
if entity_ai.ranges then
for i, range in ipairs(entity_ai.ranges) do
if range.is_visible and not range.is_visible(ent, process_ctx) then
goto skip_range
end
local layer
if range.layer then
if type(range.layer) == "function" then
layer = range.layer(ent, process_ctx)
else
layer = range.layer
end
else
layer = ent_layer
end
if layer ~= LAYER.BOTH and layer ~= state.camera_layer then
goto skip_range
end
local shapes = {}
if range.shape then
table.insert(shapes, range.shape:clone())
end
if range.shapes then
for _, shape in ipairs(range.shapes) do
table.insert(shapes, shape:clone())
end
end
local draw_color = ORIGIN_CHECK_COLOR
if range.type == Entity_AI.RANGE_TYPE.MISC then
draw_color = MISC_BOX_COLOR
elseif range.type == Entity_AI.RANGE_TYPE.HITBOX_OVERLAP then
-- TODO: Make it more obvious that this is a hitbox overlap check. It's difficult to understand with just a color.
draw_color = HITBOX_OVERLAP_COLOR
elseif range.type == Entity_AI.RANGE_TYPE.HURTBOX then
-- TODO: Make it more obvious that this is a hitbox overlap check. It's difficult to understand with just a color.
draw_color = HURTBOX_COLOR
elseif range.type == Entity_AI.RANGE_TYPE.SOLID_CHECK then
draw_color = SOLID_CHECK_COLOR
end
local is_inactive_when_stuck = range.is_inactive_when_stuck == nil or range.is_inactive_when_stuck
local is_active = not is_stunned and not is_frozen
and not (is_inactive_when_stuck and is_stuck)
and (not range.is_active or range.is_active(ent, process_ctx))
local ucolors = is_active and draw_color:get_variant(i).bright or draw_color:get_variant(i).dim
local label
local label_position = range.label_position
if range.label ~= nil then
if type(range.label) == "function" then
label = range.label(ent, process_ctx)
else
label = tostring(range.label)
end
end
local x, y
if range.translate_shape then
x, y = range.translate_shape(ent, process_ctx)
end
if not x or not y then
x, y = ent_x, ent_y
end
for _, shape in ipairs(shapes) do
if range.flip_with_ent and process_ctx.is_facing_left then
shape:flip_horizontal()
label_position = drawing.Draw_Item.flip_label_position_horizontal(label_position)
end
shape:translate(x, y)
if range.flip_with_ent and range.line_of_sight_checks then
clip_line_of_sight(process_ctx, shape, range.line_of_sight_checks, range.line_of_sight_extra_length or 0.5)
end
if range.post_transform_shape then
shape = range.post_transform_shape(ent, process_ctx, shape)
end
table.insert(tracked_ent_data.draw_items, drawing.Draw_Item:new({
shape = shape,
ucolors = ucolors,
label = label,
label_position = label_position
}))
end
::skip_range::
end
end
end
-- TODO: Dim the targetting when the entity is in a state where target doesn't matter, similar to dimming ranges. Maybe dim when entity cannot update targetting timer or find new targets.
-- TODO: Put the timer somewhere on the entity if it has no target (target was destroyed or changed layer), but it's still seeking a new target.
-- TODO: Indicate the direction of the targetting for when both the entity and target are off-screen.
if targetting_visible then
local target = get_entity(ai_common.get_field(ent, entity_ai.targetting.id_field))
if target then
local timer_value = ai_common.get_field(ent, entity_ai.targetting.timer_field)
local timer
if timer_value then
timer = { value = timer_value, max_value = entity_ai.targetting.timer_max }
end
local target_x, target_y = get_position(target.uid)
-- TODO: Don't show targetting when both entities are not on the camera layer.
-- TODO: Handle targetting between both layers.
table.insert(tracked_ent_data.draw_items, drawing.Draw_Item:new({
shape = geometry.create_line_shape(Vec2:new(ent_x, ent_y), Vec2:new(target_x, target_y)),
ucolors = TARGET_COLOR:get().bright,
label = "Target",
timer = timer
}))
end
end
end
local function clear_tracked_entities()
tracked_ents = {}
end
local function scan_for_tracked_entities()
if should_process_entities(state.screen) then
for _, ent_id in ipairs(get_entities_by_type(entity_ai_ent_type_list)) do
process_tracked_entity(create_tracked_entity(get_entity(ent_id)))
end
end
end
local function draw_options(ctx, is_window)
if not is_window then
if ctx:win_button("Detach options into window") then
options.options_window_visible = true
end
end
options.player_origin_visible = ctx:win_check("Show player origin point", options.player_origin_visible)
ctx:win_separator()
ctx:win_section("Entity AI visualizations", function()
ctx:win_indent(INDENT)
options.entity_ranges_visible = ctx:win_check("Ranges visible", options.entity_ranges_visible)
ctx:win_text("Global toggle for entity range overlay.")
options.entity_target_visible = ctx:win_check("Targetting visible", options.entity_target_visible)
ctx:win_text("Global toggle for entity targetting overlay.")
if ctx:win_button("Check all") then
for id, _ in pairs(options.entity_ai) do
options.entity_ai[id].visible = true
end
end
ctx:win_inline()
if ctx:win_button("Uncheck all") then
for id, _ in pairs(options.entity_ai) do
options.entity_ai[id].visible = false
end
end
for _, entity_ai in ipairs(entity_ai_list) do
if not entity_ai.parent_id then
options.entity_ai[entity_ai.id].visible = ctx:win_check(entity_ai.name, options.entity_ai[entity_ai.id].visible)
end
end
ctx:win_indent(-INDENT)
end)
ctx:win_separator()
if ctx:win_button("Save options") then
if not save_script() then
print("Save occurred too recently. Wait a few seconds and try again.")
end
end
ctx:win_text("Immediately save the current options. Saves also happen automatically during screen changes.")
if ctx:win_button("Reset options") then
options = persistence.deep_copy(default_options)
end
ctx:win_text("Reset all options to their default values.")
end
local function on_draw_gui(ctx)
ctx:draw_layer(DRAW_LAYER.BACKGROUND)
if should_process_entities(state.screen) then
drawing.compute_screen_vars()
for _, tracked_ent_data in pairs(tracked_ents) do
if tracked_ent_data.draw_items then
for _, draw_item in ipairs(tracked_ent_data.draw_items) do
draw_item:draw(ctx)
end
end
end
if options.player_origin_visible then
for _, player in ipairs(players) do
local x, y, layer = get_position(player.uid)
if layer == state.camera_layer then
drawing.draw_point_mark(ctx, x, y)
end
end
end
end
ctx:draw_layer(DRAW_LAYER.WINDOW)
if options.options_window_visible then
options.options_window_visible = ctx:window(meta.name.." Options", -1, -0.1, 0.35, 0.85, true, function()
ctx:win_indent(INDENT)
draw_options(ctx, true)
ctx:win_indent(-INDENT)
end)
end
end
local function on_game_frame()
if should_process_entities(state.screen) then
for id, _ in pairs(tracked_ents) do
process_tracked_entity(id)
end
end
end
local function on_pre_load_screen()
-- Check whether the game is unloading a screen that could be tracking entities. It isn't normally possible to move from the options screen directly into a new level, so that case isn't handled here.
if should_process_entities(state.screen) and state.screen_next ~= SCREEN.OPTIONS and state.screen_next ~= SCREEN.DEATH then
clear_tracked_entities()
end
end
set_callback(function(ctx)
init_entity_ai()
local load_table = persistence.load(ctx)
options = persistence.combine_tables(default_options, load_table.options)
register_option_callback("", options, draw_options)
set_callback(persistence.save, ON.SAVE)
set_callback(on_draw_gui, ON.GUIFRAME)
-- TODO: Using a global interval here because ON.GAMEFRAME isn't triggering during OL frame advances.
set_global_interval(on_game_frame, 1)
set_callback(on_pre_load_screen, ON.PRE_LOAD_SCREEN)
-- Immediately initialize the tracked entities and scan any entities that currently exist.
clear_tracked_entities()
scan_for_tracked_entities()
-- Track any entities that are created after the initial scan.
if #entity_ai_ent_type_list > 0 then
set_post_entity_spawn(function(ent)
if should_process_entities(state.screen) then
process_tracked_entity(create_tracked_entity(ent))
end
end, SPAWN_TYPE.ANY, MASK.ANY, entity_ai_ent_type_list)
end
end, ON.LOAD)