spawnpoint-studio 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spawnpoint/__init__.py +3 -0
- spawnpoint/_data/examples/coin_hopper.json +228 -0
- spawnpoint/_data/examples/neon_arena.json +251 -0
- spawnpoint/_data/examples/pixel_arena.json +242 -0
- spawnpoint/_data/examples/pixel_hopper.json +221 -0
- spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
- spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
- spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
- spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
- spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
- spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
- spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
- spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
- spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
- spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
- spawnpoint/analyze.py +387 -0
- spawnpoint/build.py +146 -0
- spawnpoint/cli.py +290 -0
- spawnpoint/cockpit/__init__.py +0 -0
- spawnpoint/cockpit/server.py +473 -0
- spawnpoint/cockpit/static/app.css +111 -0
- spawnpoint/cockpit/static/app.js +327 -0
- spawnpoint/cockpit/static/index.html +79 -0
- spawnpoint/diagnostics.py +91 -0
- spawnpoint/expr.py +219 -0
- spawnpoint/gif.py +58 -0
- spawnpoint/godot.py +107 -0
- spawnpoint/graph.py +175 -0
- spawnpoint/level.py +157 -0
- spawnpoint/llm_server.py +77 -0
- spawnpoint/manifest.py +222 -0
- spawnpoint/playtest/__init__.py +104 -0
- spawnpoint/playtest/report.py +148 -0
- spawnpoint/playtest/runner.py +51 -0
- spawnpoint/playtest/tuner.py +151 -0
- spawnpoint/project.py +94 -0
- spawnpoint/py.typed +0 -0
- spawnpoint/rules.py +310 -0
- spawnpoint/sfx.py +352 -0
- spawnpoint/sprites/__init__.py +0 -0
- spawnpoint/sprites/ai.py +222 -0
- spawnpoint/sprites/contour.py +147 -0
- spawnpoint/sprites/generate.py +507 -0
- spawnpoint/sprites/importer.py +212 -0
- spawnpoint/sprites/resolve.py +139 -0
- spawnpoint/sprites/spriteset.py +159 -0
- spawnpoint/sprites/tiles.py +102 -0
- spawnpoint/studio/__init__.py +0 -0
- spawnpoint/studio/assemble.py +528 -0
- spawnpoint/studio/brief.py +197 -0
- spawnpoint/studio/creative.py +124 -0
- spawnpoint/studio/dataset.py +136 -0
- spawnpoint/studio/edits.py +206 -0
- spawnpoint/studio/levelgen.py +352 -0
- spawnpoint/studio/llm.py +145 -0
- spawnpoint/studio/orchestrator.py +341 -0
- spawnpoint/studio/prompts.py +122 -0
- spawnpoint/studio/web.py +123 -0
- spawnpoint/studio/webcheck.py +210 -0
- spawnpoint/studio_cli.py +232 -0
- spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
- spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
- spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
- spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
- spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
- tests/conftest.py +49 -0
- tests/test_ai_sprites.py +122 -0
- tests/test_analyze.py +116 -0
- tests/test_cockpit.py +148 -0
- tests/test_e2e.py +33 -0
- tests/test_expr.py +59 -0
- tests/test_gif.py +19 -0
- tests/test_godot_library.py +17 -0
- tests/test_graph.py +44 -0
- tests/test_level.py +64 -0
- tests/test_live_llm.py +38 -0
- tests/test_manifest.py +54 -0
- tests/test_playtest.py +129 -0
- tests/test_rules.py +85 -0
- tests/test_sfx.py +30 -0
- tests/test_sprites.py +261 -0
- tests/test_studio.py +335 -0
- tests/test_web.py +41 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
extends Node
|
|
2
|
+
## Introspects every component script and writes raw metadata as JSON.
|
|
3
|
+
## Run: godot4 --headless --path <project> res://addons/spawn_point/tools/dump_manifest.tscn -- <out.json>
|
|
4
|
+
## The Python side merges this with doc comments into manifest.json.
|
|
5
|
+
|
|
6
|
+
const COMPONENT_DIR := "res://addons/spawn_point/components"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
func _ready() -> void:
|
|
10
|
+
var args := OS.get_cmdline_user_args()
|
|
11
|
+
var out_path := args[0] if args.size() > 0 else "user://manifest_raw.json"
|
|
12
|
+
var components := []
|
|
13
|
+
var files := DirAccess.get_files_at(COMPONENT_DIR)
|
|
14
|
+
files.sort()
|
|
15
|
+
for f in files:
|
|
16
|
+
if f.ends_with("_component.gd"):
|
|
17
|
+
components.append(_describe(COMPONENT_DIR + "/" + f))
|
|
18
|
+
var file := FileAccess.open(out_path, FileAccess.WRITE)
|
|
19
|
+
if file == null:
|
|
20
|
+
push_error("cannot write %s" % out_path)
|
|
21
|
+
get_tree().quit(1)
|
|
22
|
+
return
|
|
23
|
+
file.store_string(JSON.stringify({"godot": Engine.get_version_info().string, "components": components}, " ", false))
|
|
24
|
+
file.close()
|
|
25
|
+
get_tree().quit(0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
func _describe(path: String) -> Dictionary:
|
|
29
|
+
var s := load(path) as GDScript
|
|
30
|
+
var consts := s.get_script_constant_map()
|
|
31
|
+
var params := []
|
|
32
|
+
var state := []
|
|
33
|
+
for p in s.get_script_property_list():
|
|
34
|
+
var pname: String = p.name
|
|
35
|
+
if not (p.usage & PROPERTY_USAGE_SCRIPT_VARIABLE) or pname.begins_with("_"):
|
|
36
|
+
continue
|
|
37
|
+
if not (p.usage & PROPERTY_USAGE_EDITOR):
|
|
38
|
+
state.append({"name": pname, "type": type_string(p.type)})
|
|
39
|
+
continue
|
|
40
|
+
params.append({
|
|
41
|
+
"name": p.name,
|
|
42
|
+
"type": type_string(p.type),
|
|
43
|
+
"hint": _hint_name(p.hint),
|
|
44
|
+
"hint_string": p.hint_string,
|
|
45
|
+
"default": s.get_property_default_value(p.name),
|
|
46
|
+
})
|
|
47
|
+
var signals := []
|
|
48
|
+
for sig in s.get_script_signal_list():
|
|
49
|
+
signals.append({"name": sig.name, "args": _args(sig.args)})
|
|
50
|
+
var methods := []
|
|
51
|
+
var internal: Array = Array(consts.get("SP_INTERNAL", []))
|
|
52
|
+
for m in s.get_script_method_list():
|
|
53
|
+
var mname: String = m.name
|
|
54
|
+
if mname.begins_with("_") or mname.begins_with("@") or mname in internal:
|
|
55
|
+
continue
|
|
56
|
+
methods.append({
|
|
57
|
+
"name": mname,
|
|
58
|
+
"args": _args(m.args),
|
|
59
|
+
"default_count": m.default_args.size(),
|
|
60
|
+
"returns": type_string(m.return.type),
|
|
61
|
+
})
|
|
62
|
+
return {
|
|
63
|
+
"name": consts.get("SP_NAME", ""),
|
|
64
|
+
"class": s.get_global_name(),
|
|
65
|
+
"script": path,
|
|
66
|
+
"base": s.get_instance_base_type(),
|
|
67
|
+
"hosts": Array(consts.get("SP_HOSTS", [])),
|
|
68
|
+
"requires": Array(consts.get("SP_REQUIRES", [])),
|
|
69
|
+
"category": consts.get("SP_CATEGORY", ""),
|
|
70
|
+
"entity_params": Array(consts.get("SP_ENTITY_PARAMS", [])),
|
|
71
|
+
"params": params,
|
|
72
|
+
"state": state,
|
|
73
|
+
"signals": signals,
|
|
74
|
+
"methods": methods,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
func _args(list: Array) -> Array:
|
|
79
|
+
var out := []
|
|
80
|
+
for a in list:
|
|
81
|
+
out.append({"name": a.name, "type": type_string(a.type) if a.type != TYPE_OBJECT else String(a.class_name) if a.class_name != &"" else "Object"})
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
func _hint_name(hint: int) -> String:
|
|
86
|
+
match hint:
|
|
87
|
+
PROPERTY_HINT_RANGE:
|
|
88
|
+
return "range"
|
|
89
|
+
PROPERTY_HINT_ENUM:
|
|
90
|
+
return "enum"
|
|
91
|
+
return ""
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
extends Node
|
|
2
|
+
## Headless playtest: bots of different skill play the level and report telemetry as JSON.
|
|
3
|
+
## Run: godot4 --headless --path <project> --fixed-fps 60 res://addons/spawn_point/tools/playtest.tscn \
|
|
4
|
+
## -- <out.json> <profiles csv> <runs> <seed> <time_limit_s>
|
|
5
|
+
##
|
|
6
|
+
## Bots press the same input actions as a human, so coyote time, jump buffering, knockback and
|
|
7
|
+
## invincibility are exercised for real. Every run is seeded and therefore reproducible.
|
|
8
|
+
|
|
9
|
+
const LEVEL := "res://levels/main.tscn"
|
|
10
|
+
|
|
11
|
+
const PROFILES := {
|
|
12
|
+
"novice": {"reaction": 12, "late": 10, "full_hop": 0.6, "panic": 0.25, "trigger": 0.6, "dodge": 0.2},
|
|
13
|
+
"average": {"reaction": 6, "late": 5, "full_hop": 0.85, "panic": 0.08, "trigger": 0.85, "dodge": 0.55},
|
|
14
|
+
"expert": {"reaction": 2, "late": 1, "full_hop": 1.0, "panic": 0.0, "trigger": 1.0, "dodge": 0.9},
|
|
15
|
+
}
|
|
16
|
+
const MOVE_ACTIONS := [&"move_left", &"move_right", &"move_up", &"move_down"]
|
|
17
|
+
const ALL_ACTIONS := [&"move_left", &"move_right", &"move_up", &"move_down", &"jump", &"dash", &"shoot"]
|
|
18
|
+
const STUCK_FRAMES := 240
|
|
19
|
+
const TARGET_PATIENCE := 20 * 60 # a person moves on after ~20 s of failing at one thing
|
|
20
|
+
const NAV := preload("res://addons/spawn_point/tools/playtest_nav.gd")
|
|
21
|
+
|
|
22
|
+
var _rng := RandomNumberGenerator.new()
|
|
23
|
+
var _p: Dictionary # current profile
|
|
24
|
+
var _frame := 0
|
|
25
|
+
var _queue: Array[Dictionary] = [] # delayed actions: {at, action, hold}
|
|
26
|
+
var _holds := {} # action -> frames left
|
|
27
|
+
var _panic_left := 0
|
|
28
|
+
var _double_used := false
|
|
29
|
+
var _nav: NAV
|
|
30
|
+
var _path: Array[Vector2i] = []
|
|
31
|
+
var _target: Node2D
|
|
32
|
+
var _blocked_targets := {}
|
|
33
|
+
var _unreachable: Array = []
|
|
34
|
+
var _target_since := 0
|
|
35
|
+
var _wants_move := false
|
|
36
|
+
|
|
37
|
+
# Telemetry of the current run.
|
|
38
|
+
var _tile := 32.0
|
|
39
|
+
var _deaths: Array = []
|
|
40
|
+
var _damage: Array = []
|
|
41
|
+
var _missed: Array = []
|
|
42
|
+
var _stuck: Array = []
|
|
43
|
+
var _intended := 0
|
|
44
|
+
var _performed := 0
|
|
45
|
+
var _pending_jump := 0
|
|
46
|
+
var _last_jump_frame := -9999
|
|
47
|
+
var _anchor := Vector2.ZERO
|
|
48
|
+
var _anchor_frame := 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
func _ready() -> void:
|
|
52
|
+
var args := OS.get_cmdline_user_args()
|
|
53
|
+
var out_path := args[0] if args.size() > 0 else "user://playtest.json"
|
|
54
|
+
var profiles := (args[1] if args.size() > 1 else "novice,average,expert").split(",")
|
|
55
|
+
var runs := int(args[2]) if args.size() > 2 else 3
|
|
56
|
+
var base_seed := int(args[3]) if args.size() > 3 else 1
|
|
57
|
+
var limit_s := float(args[4]) if args.size() > 4 else 90.0
|
|
58
|
+
var results := []
|
|
59
|
+
for prof in profiles:
|
|
60
|
+
for r in runs:
|
|
61
|
+
results.append(await _play(prof.strip_edges(), r, base_seed * 1000 + r, limit_s))
|
|
62
|
+
var f := FileAccess.open(out_path, FileAccess.WRITE)
|
|
63
|
+
f.store_string(JSON.stringify({"runs": results}, " "))
|
|
64
|
+
f.close()
|
|
65
|
+
print("SPAWNPOINT_PLAYTEST_OK ", results.size())
|
|
66
|
+
get_tree().quit(0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --- one run --------------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
func _play(profile: String, run: int, run_seed: int, limit_s: float) -> Dictionary:
|
|
72
|
+
_p = PROFILES.get(profile, PROFILES["average"])
|
|
73
|
+
seed(run_seed)
|
|
74
|
+
_rng.seed = run_seed
|
|
75
|
+
_reset_bot()
|
|
76
|
+
var level: Node2D = (load(LEVEL) as PackedScene).instantiate()
|
|
77
|
+
GameState.reset_run(int(level.get(&"lives")))
|
|
78
|
+
add_child(level)
|
|
79
|
+
_tile = float(level.get(&"tile_size"))
|
|
80
|
+
await get_tree().physics_frame
|
|
81
|
+
var player := World.player() as CharacterBody2D
|
|
82
|
+
var pickups_total := _count_pickups()
|
|
83
|
+
_setup_nav(level, player)
|
|
84
|
+
_hook(player)
|
|
85
|
+
_anchor = player.global_position
|
|
86
|
+
var limit := int(limit_s * 60.0)
|
|
87
|
+
var outcome := "timeout"
|
|
88
|
+
for i in limit:
|
|
89
|
+
_frame = i
|
|
90
|
+
_drive(player)
|
|
91
|
+
await get_tree().physics_frame
|
|
92
|
+
_observe(player)
|
|
93
|
+
if GameState.phase == GameState.Phase.WON:
|
|
94
|
+
outcome = "won"
|
|
95
|
+
break
|
|
96
|
+
if GameState.phase == GameState.Phase.LOST:
|
|
97
|
+
outcome = "lost"
|
|
98
|
+
break
|
|
99
|
+
var result := {
|
|
100
|
+
"profile": profile, "run": run, "seed": run_seed, "outcome": outcome,
|
|
101
|
+
"time": snappedf(GameState.time, 0.01), "score": GameState.score, "lives_left": GameState.lives,
|
|
102
|
+
"deaths": _deaths, "damage": _damage, "stuck": _stuck, "unreachable": _unreachable,
|
|
103
|
+
"jumps": {"intended": _intended, "performed": _performed, "missed": _missed},
|
|
104
|
+
"pickups": {"total": pickups_total, "collected": pickups_total - _count_pickups()},
|
|
105
|
+
}
|
|
106
|
+
for a in ALL_ACTIONS:
|
|
107
|
+
Input.action_release(a)
|
|
108
|
+
level.queue_free()
|
|
109
|
+
await get_tree().physics_frame
|
|
110
|
+
await get_tree().physics_frame
|
|
111
|
+
Engine.time_scale = 1.0
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
func _reset_bot() -> void:
|
|
116
|
+
_queue.clear()
|
|
117
|
+
_holds.clear()
|
|
118
|
+
_panic_left = 0
|
|
119
|
+
_double_used = false
|
|
120
|
+
_deaths = []
|
|
121
|
+
_damage = []
|
|
122
|
+
_missed = []
|
|
123
|
+
_stuck = []
|
|
124
|
+
_intended = 0
|
|
125
|
+
_performed = 0
|
|
126
|
+
_pending_jump = 0
|
|
127
|
+
_last_jump_frame = -9999
|
|
128
|
+
_anchor_frame = 0
|
|
129
|
+
_target = null
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
func _hook(player: Node) -> void:
|
|
133
|
+
var health := SP.component(player, &"Health") as HealthComponent
|
|
134
|
+
if health:
|
|
135
|
+
health.died.connect(_on_player_died.bind(player))
|
|
136
|
+
health.damaged.connect(_on_player_damaged.bind(player))
|
|
137
|
+
var mv := SP.movement(player)
|
|
138
|
+
if mv and mv.has_signal(&"jumped"):
|
|
139
|
+
mv.jumped.connect(_on_jumped)
|
|
140
|
+
if mv and mv.has_signal(&"landed"):
|
|
141
|
+
mv.landed.connect(func(_s: float) -> void: _double_used = false)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
func _on_player_died(player: Node2D) -> void:
|
|
145
|
+
var c := SP.core(player)
|
|
146
|
+
_deaths.append({"x": _tx(player), "y": _ty(player), "cause": c.death_cause if c else "unknown",
|
|
147
|
+
"t": snappedf(GameState.time, 0.01), "after_jump": _frame - _last_jump_frame < 150})
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
func _on_player_damaged(_amount: int, player: Node2D) -> void:
|
|
151
|
+
var hb := SP.component(player, &"Hurtbox") as HurtboxComponent
|
|
152
|
+
var cause := "unknown"
|
|
153
|
+
if hb and is_instance_valid(hb.last_hit_by) and SP.core(hb.last_hit_by):
|
|
154
|
+
cause = SP.core(hb.last_hit_by).entity_type
|
|
155
|
+
_damage.append({"x": _tx(player), "y": _ty(player), "cause": cause, "t": snappedf(GameState.time, 0.01)})
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
func _on_jumped() -> void:
|
|
159
|
+
_performed += 1
|
|
160
|
+
_last_jump_frame = _frame
|
|
161
|
+
if _pending_jump > 0:
|
|
162
|
+
_pending_jump = 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
func _tx(n: Node2D) -> int:
|
|
166
|
+
return int(floor(n.global_position.x / _tile))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
func _ty(n: Node2D) -> int:
|
|
170
|
+
return int(floor(n.global_position.y / _tile))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
func _observe(player: CharacterBody2D) -> void:
|
|
174
|
+
# A ground jump the bot asked for that never happened (e.g. pressed after leaving the ledge
|
|
175
|
+
# and after coyote time ran out).
|
|
176
|
+
if _pending_jump > 0:
|
|
177
|
+
_pending_jump -= 1
|
|
178
|
+
if _pending_jump == 0:
|
|
179
|
+
_missed.append({"x": _tx(player), "y": _ty(player)})
|
|
180
|
+
if not player.visible:
|
|
181
|
+
_anchor_frame = _frame
|
|
182
|
+
_anchor = player.global_position
|
|
183
|
+
return
|
|
184
|
+
if player.global_position.distance_to(_anchor) > 24.0 or not _wants_move:
|
|
185
|
+
_anchor = player.global_position
|
|
186
|
+
_anchor_frame = _frame
|
|
187
|
+
elif _frame - _anchor_frame >= STUCK_FRAMES:
|
|
188
|
+
_stuck.append({"x": _tx(player), "y": _ty(player), "t": snappedf(GameState.time, 0.01)})
|
|
189
|
+
_anchor_frame = _frame
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
func _count_pickups() -> int:
|
|
193
|
+
var n := 0
|
|
194
|
+
for e in get_tree().get_nodes_in_group(SP.ENTITY_GROUP):
|
|
195
|
+
if SP.component(e, &"Collectible") and not e.is_queued_for_deletion():
|
|
196
|
+
n += 1
|
|
197
|
+
return n
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- input plumbing -------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
func _press(action: StringName, frames: int) -> void:
|
|
203
|
+
Input.action_press(action)
|
|
204
|
+
_holds[action] = maxi(int(_holds.get(action, 0)), frames)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
func _later(action: StringName, hold: int, extra_delay := 0) -> void:
|
|
208
|
+
var delay := int(_p.reaction) + extra_delay
|
|
209
|
+
_queue.append({"at": _frame + delay, "action": action, "hold": hold})
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
func _tick_inputs() -> void:
|
|
213
|
+
for a in _holds.keys():
|
|
214
|
+
_holds[a] -= 1
|
|
215
|
+
if _holds[a] <= 0:
|
|
216
|
+
Input.action_release(a)
|
|
217
|
+
_holds.erase(a)
|
|
218
|
+
var keep: Array[Dictionary] = []
|
|
219
|
+
for q in _queue:
|
|
220
|
+
if q.at <= _frame:
|
|
221
|
+
_press(q.action, q.hold)
|
|
222
|
+
else:
|
|
223
|
+
keep.append(q)
|
|
224
|
+
_queue = keep
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --- bots -----------------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
func _setup_nav(level: Node2D, player: CharacterBody2D) -> void:
|
|
230
|
+
_nav = NAV.new()
|
|
231
|
+
var tiles := level.get_node_or_null(^"Tiles") as TileMapLayer
|
|
232
|
+
var mv := SP.movement(player)
|
|
233
|
+
var platformer := mv is PlatformerMovementComponent
|
|
234
|
+
var up := 3
|
|
235
|
+
var across := 4
|
|
236
|
+
if platformer:
|
|
237
|
+
var m := mv as PlatformerMovementComponent
|
|
238
|
+
var g: float = ProjectSettings.get_setting("physics/2d/default_gravity", 1600.0) * m.gravity_scale
|
|
239
|
+
var jumps := 1 + m.air_jumps
|
|
240
|
+
var h := m.jump_velocity * m.jump_velocity / (2.0 * g)
|
|
241
|
+
var t_air := m.jump_velocity / g + sqrt(2.0 * h / (g * m.fall_gravity_multiplier))
|
|
242
|
+
up = int(h * jumps / _tile)
|
|
243
|
+
across = maxi(1, int(m.max_speed * t_air * jumps / _tile))
|
|
244
|
+
if tiles:
|
|
245
|
+
_nav.setup(tiles, _tile, platformer, SP.half_size(player).y * 2.0, up, across)
|
|
246
|
+
for e in get_tree().get_nodes_in_group(SP.ENTITY_GROUP):
|
|
247
|
+
# Static things that hurt (spikes): a damaging hitbox on something that never moves.
|
|
248
|
+
if SP.component(e, &"Hitbox") and SP.movement(e) == null and SP.component(e, &"Projectile") == null:
|
|
249
|
+
_nav.mark_hazard((e as Node2D).global_position)
|
|
250
|
+
_path = []
|
|
251
|
+
_blocked_targets.clear()
|
|
252
|
+
_unreachable = []
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
func _drive(p: CharacterBody2D) -> void:
|
|
256
|
+
_tick_inputs()
|
|
257
|
+
if p == null or not p.visible or _frame < 10:
|
|
258
|
+
return
|
|
259
|
+
if _panic_left > 0:
|
|
260
|
+
_panic_left -= 1
|
|
261
|
+
for a in MOVE_ACTIONS:
|
|
262
|
+
Input.action_release(a)
|
|
263
|
+
return
|
|
264
|
+
if _rng.randf() < float(_p.panic) / 60.0:
|
|
265
|
+
_panic_left = 30
|
|
266
|
+
var mv := SP.movement(p)
|
|
267
|
+
if mv is PlatformerMovementComponent:
|
|
268
|
+
_platformer(p, mv as PlatformerMovementComponent)
|
|
269
|
+
elif mv is TopDownMovementComponent:
|
|
270
|
+
_topdown(p)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
## Picks the next target node (pickup, exit, or enemy), skipping ones known to be unreachable.
|
|
274
|
+
func _pick_target(p: CharacterBody2D) -> Node2D:
|
|
275
|
+
var best: Node2D = null
|
|
276
|
+
var best_d := INF
|
|
277
|
+
var health := SP.component(p, &"Health") as HealthComponent
|
|
278
|
+
var hurt := health != null and health.current < health.max_health
|
|
279
|
+
for e in get_tree().get_nodes_in_group(SP.ENTITY_GROUP):
|
|
280
|
+
var n := e as Node2D
|
|
281
|
+
if n == p or n.is_queued_for_deletion() or _blocked_targets.has(n.get_instance_id()):
|
|
282
|
+
continue
|
|
283
|
+
var weight := INF
|
|
284
|
+
var c := SP.component(n, &"Collectible")
|
|
285
|
+
if c:
|
|
286
|
+
weight = 1.0 if (int(c.get(&"score")) > 0 or hurt) else INF
|
|
287
|
+
elif SP.component(n, &"TriggerZone"):
|
|
288
|
+
weight = 4.0
|
|
289
|
+
elif SP.team_of(n) == SP.TEAM_ENEMY and SP.component(n, &"Hurtbox") and SP.component(n, &"Projectile") == null:
|
|
290
|
+
weight = 2.5
|
|
291
|
+
if weight == INF:
|
|
292
|
+
continue
|
|
293
|
+
var d := n.global_position.distance_to(p.global_position) * weight
|
|
294
|
+
if d < best_d:
|
|
295
|
+
best_d = d
|
|
296
|
+
best = n
|
|
297
|
+
return best
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
func _give_up(target: Node2D, reason := "no_path") -> void:
|
|
301
|
+
_blocked_targets[target.get_instance_id()] = true
|
|
302
|
+
var c := Vector2i(floori(target.global_position.x / _tile), floori(target.global_position.y / _tile))
|
|
303
|
+
var entry := {"x": c.x, "y": c.y, "entity": SP.core(target).entity_type if SP.core(target) else "?",
|
|
304
|
+
"reason": reason}
|
|
305
|
+
if entry not in _unreachable:
|
|
306
|
+
_unreachable.append(entry)
|
|
307
|
+
_path = []
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
func _platformer(p: CharacterBody2D, mv: PlatformerMovementComponent) -> void:
|
|
311
|
+
var half := SP.half_size(p)
|
|
312
|
+
var cur := _nav.cell_of(p.global_position, half.y)
|
|
313
|
+
var target := _pick_target(p)
|
|
314
|
+
if target != _target:
|
|
315
|
+
_target = target
|
|
316
|
+
_target_since = _frame
|
|
317
|
+
_path = []
|
|
318
|
+
if target and _frame - _target_since > TARGET_PATIENCE:
|
|
319
|
+
_give_up(target, "too_hard")
|
|
320
|
+
return
|
|
321
|
+
_wants_move = true
|
|
322
|
+
if p.is_on_floor() and target and (_path.is_empty() or _frame % 20 == 0 or cur not in _path):
|
|
323
|
+
if _nav.standable(cur):
|
|
324
|
+
_path = _nav.path(cur, _nav.reaches(target.global_position))
|
|
325
|
+
if _path.is_empty():
|
|
326
|
+
_give_up(target)
|
|
327
|
+
return
|
|
328
|
+
if OS.has_environment("SPAWNPOINT_PLAYTEST_DEBUG") and _frame % 120 == 0:
|
|
329
|
+
print("f=%d cur=%s target=%s path=%s" % [_frame, cur, target.global_position.round() if target else null, _path.slice(0, 4)])
|
|
330
|
+
# Advance along the path.
|
|
331
|
+
while _path.size() > 1 and _path[0] != cur and _path[1] == cur:
|
|
332
|
+
_path.pop_front()
|
|
333
|
+
var idx := _path.find(cur)
|
|
334
|
+
if idx > 0:
|
|
335
|
+
_path = _path.slice(idx)
|
|
336
|
+
var next: Vector2i = _path[1] if _path.size() > 1 else cur
|
|
337
|
+
var want_x: float = _nav.center(next).x if _path.size() > 1 else (target.global_position.x if target else p.global_position.x + 64.0)
|
|
338
|
+
var dx := want_x - p.global_position.x
|
|
339
|
+
var dir := 0
|
|
340
|
+
if absf(dx) > 4.0:
|
|
341
|
+
dir = 1 if dx > 0.0 else -1
|
|
342
|
+
Input.action_release(&"move_left")
|
|
343
|
+
Input.action_release(&"move_right")
|
|
344
|
+
if dir != 0:
|
|
345
|
+
Input.action_press(&"move_right" if dir > 0 else &"move_left")
|
|
346
|
+
|
|
347
|
+
var enemy_ahead := false
|
|
348
|
+
for e in get_tree().get_nodes_in_group(&"team_enemy"):
|
|
349
|
+
var d := (e as Node2D).global_position - p.global_position
|
|
350
|
+
if SP.component(e, &"Hurtbox") and d.x * (dir if dir != 0 else 1) > 0 and absf(d.x) < 70 and absf(d.y) < 40:
|
|
351
|
+
enemy_ahead = true
|
|
352
|
+
var step := next - cur
|
|
353
|
+
var needs_jump := _path.size() > 1 and (step.y < 0 or absi(step.x) > 1)
|
|
354
|
+
var at_edge := dir != 0 and not _nav.standable(cur + Vector2i(dir, 0))
|
|
355
|
+
var target_above := _path.size() <= 1 and target != null and target.global_position.y < p.global_position.y - half.y
|
|
356
|
+
var busy := _pending_jump > 0 or _queue.any(func(q: Dictionary) -> bool: return q.action == &"jump")
|
|
357
|
+
if p.is_on_floor() and not busy and ((needs_jump and (absi(step.x) <= 1 or at_edge)) or enemy_ahead or target_above):
|
|
358
|
+
# People hold the button when they need height; short hops happen on flat ground.
|
|
359
|
+
var hold := 18 if (step.y < 0 or target_above or _rng.randf() < float(_p.full_hop)) else 4
|
|
360
|
+
_later(&"jump", hold, _rng.randi_range(0, int(_p.late)))
|
|
361
|
+
_intended += 1
|
|
362
|
+
_pending_jump = int(_p.reaction) + int(_p.late) + 14
|
|
363
|
+
elif not p.is_on_floor() and p.velocity.y > 40 and mv.air_jumps > 0 and not _double_used \
|
|
364
|
+
and (step.y < 0 or not _ray(p.get_world_2d().direct_space_state, p, p.global_position, p.global_position + Vector2(0, 200))):
|
|
365
|
+
_double_used = true
|
|
366
|
+
_later(&"jump", 16)
|
|
367
|
+
if SP.component(p, &"Weapon") and enemy_ahead and _rng.randf() < float(_p.trigger):
|
|
368
|
+
_press(&"shoot", 2)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
func _topdown(p: CharacterBody2D) -> void:
|
|
372
|
+
var aim := SP.component(p, &"Aim")
|
|
373
|
+
if aim and aim.get(&"mode") == "mouse":
|
|
374
|
+
aim.set(&"mode", "nearest_enemy") # bots have no mouse
|
|
375
|
+
var pos := p.global_position
|
|
376
|
+
var cur := _nav.cell_of(pos, 0.0)
|
|
377
|
+
var target := _pick_target(p)
|
|
378
|
+
if target != _target:
|
|
379
|
+
_target = target
|
|
380
|
+
_target_since = _frame
|
|
381
|
+
_path = []
|
|
382
|
+
var steer := Vector2.ZERO
|
|
383
|
+
if target:
|
|
384
|
+
var is_enemy := SP.team_of(target) == SP.TEAM_ENEMY
|
|
385
|
+
if _path.is_empty() or _frame % 30 == 0:
|
|
386
|
+
_path = _nav.path(cur, _nav.reaches(target.global_position))
|
|
387
|
+
if _path.is_empty() and not is_enemy:
|
|
388
|
+
_give_up(target)
|
|
389
|
+
var idx := _path.find(cur)
|
|
390
|
+
if idx > 0:
|
|
391
|
+
_path = _path.slice(idx)
|
|
392
|
+
var goal := _nav.center(_path[1]) if _path.size() > 1 else target.global_position
|
|
393
|
+
var to := goal - pos
|
|
394
|
+
var keep := 150.0 if is_enemy and _path.size() <= 6 else 0.0
|
|
395
|
+
var dist := target.global_position.distance_to(pos)
|
|
396
|
+
if dist > keep:
|
|
397
|
+
steer += to.normalized()
|
|
398
|
+
elif keep > 0.0:
|
|
399
|
+
steer -= (target.global_position - pos).normalized() * 0.6
|
|
400
|
+
var bullet_close := false
|
|
401
|
+
for e in get_tree().get_nodes_in_group(&"team_enemy"):
|
|
402
|
+
var n := e as Node2D
|
|
403
|
+
var d := pos - n.global_position
|
|
404
|
+
var is_bullet := SP.component(n, &"Projectile") != null
|
|
405
|
+
var radius := 110.0 if is_bullet else 80.0
|
|
406
|
+
if d.length() < radius and d.length() > 0.1:
|
|
407
|
+
steer += d.normalized() * (1.0 - d.length() / radius) * (1.8 if is_bullet else 1.2)
|
|
408
|
+
if is_bullet and d.length() < 70.0:
|
|
409
|
+
bullet_close = true
|
|
410
|
+
_wants_move = steer.length() > 0.5
|
|
411
|
+
for a in MOVE_ACTIONS:
|
|
412
|
+
Input.action_release(a)
|
|
413
|
+
if steer.x < -0.25:
|
|
414
|
+
Input.action_press(&"move_left")
|
|
415
|
+
elif steer.x > 0.25:
|
|
416
|
+
Input.action_press(&"move_right")
|
|
417
|
+
if steer.y < -0.25:
|
|
418
|
+
Input.action_press(&"move_up")
|
|
419
|
+
elif steer.y > 0.25:
|
|
420
|
+
Input.action_press(&"move_down")
|
|
421
|
+
if SP.component(p, &"Weapon") and _rng.randf() < float(_p.trigger):
|
|
422
|
+
_press(&"shoot", 2)
|
|
423
|
+
if bullet_close and _rng.randf() < float(_p.dodge) * 0.1:
|
|
424
|
+
_later(&"dash", 3)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
func _ray(space: PhysicsDirectSpaceState2D, p: CharacterBody2D, from: Vector2, to: Vector2) -> bool:
|
|
428
|
+
return not space.intersect_ray(PhysicsRayQueryParameters2D.create(from, to, SP.LAYER_WORLD, [p.get_rid()])).is_empty()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
extends RefCounted
|
|
2
|
+
## Grid navigation for playtest bots, built from the level's TileMapLayer.
|
|
3
|
+
## Platformer: graph of standable cells linked by walks, drops and jumps within the player's jump
|
|
4
|
+
## reach. Top-down: 4-neighbour grid of free cells. Both use BFS (levels are small).
|
|
5
|
+
|
|
6
|
+
const DOWN := Vector2i(0, 1)
|
|
7
|
+
|
|
8
|
+
var platformer := true
|
|
9
|
+
var ts := 32.0
|
|
10
|
+
var cells := {} # Vector2i -> 1 solid, 2 one-way
|
|
11
|
+
var hazards := {} # Vector2i -> true: static damaging entities (spikes) — never stand there
|
|
12
|
+
var rect := Rect2i()
|
|
13
|
+
var ph := 1 # player height in tiles
|
|
14
|
+
var up := 3
|
|
15
|
+
var across := 4
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
func setup(tiles: TileMapLayer, tile_size: float, is_platformer: bool, player_h: float, jump_up: int, jump_across: int) -> void:
|
|
19
|
+
ts = tile_size
|
|
20
|
+
platformer = is_platformer
|
|
21
|
+
ph = maxi(1, ceili(player_h / ts))
|
|
22
|
+
up = jump_up
|
|
23
|
+
across = jump_across
|
|
24
|
+
rect = tiles.get_used_rect()
|
|
25
|
+
for c in tiles.get_used_cells():
|
|
26
|
+
cells[c] = 2 if tiles.get_cell_atlas_coords(c) == Vector2i(2, 0) else 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
func blocked(c: Vector2i) -> bool:
|
|
30
|
+
return cells.get(c, 0) == 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
func floor_at(c: Vector2i) -> bool:
|
|
34
|
+
return cells.has(c)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
func inside(c: Vector2i) -> bool:
|
|
38
|
+
return c.x >= rect.position.x and c.x < rect.end.x and c.y >= rect.position.y - 4 and c.y < rect.end.y
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
func standable(c: Vector2i) -> bool:
|
|
42
|
+
if not inside(c) or not floor_at(c + DOWN) or hazards.has(c):
|
|
43
|
+
return false
|
|
44
|
+
for k in ph:
|
|
45
|
+
if blocked(c - Vector2i(0, k)):
|
|
46
|
+
return false
|
|
47
|
+
return true
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
func open_cell(c: Vector2i) -> bool:
|
|
51
|
+
return inside(c) and not blocked(c) and (platformer or not hazards.has(c))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
func mark_hazard(pos: Vector2) -> void:
|
|
55
|
+
hazards[Vector2i(floori(pos.x / ts), floori(pos.y / ts))] = true
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
## Cell containing the feet (platformer) or the center (top-down) of a body.
|
|
59
|
+
func cell_of(pos: Vector2, half_h: float) -> Vector2i:
|
|
60
|
+
if platformer:
|
|
61
|
+
return Vector2i(floori(pos.x / ts), floori((pos.y + half_h - 2.0) / ts))
|
|
62
|
+
return Vector2i(floori(pos.x / ts), floori(pos.y / ts))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
func center(c: Vector2i) -> Vector2:
|
|
66
|
+
return Vector2((c.x + 0.5) * ts, (c.y + 0.5) * ts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
func _drop(c: Vector2i) -> Vector2i:
|
|
70
|
+
var d := c
|
|
71
|
+
while inside(d) and not standable(d):
|
|
72
|
+
if blocked(d):
|
|
73
|
+
return Vector2i(-99999, 0)
|
|
74
|
+
d += DOWN
|
|
75
|
+
return d if inside(d) else Vector2i(-99999, 0)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
func neighbors(c: Vector2i) -> Array[Vector2i]:
|
|
79
|
+
var out: Array[Vector2i] = []
|
|
80
|
+
if not platformer:
|
|
81
|
+
for d in [Vector2i.LEFT, Vector2i.RIGHT, Vector2i.UP, Vector2i.DOWN]:
|
|
82
|
+
if open_cell(c + d):
|
|
83
|
+
out.append(c + d)
|
|
84
|
+
return out
|
|
85
|
+
for dx in [-1, 1]:
|
|
86
|
+
var side := c + Vector2i(dx, 0)
|
|
87
|
+
if standable(side):
|
|
88
|
+
out.append(side)
|
|
89
|
+
elif open_cell(side):
|
|
90
|
+
var land := _drop(side)
|
|
91
|
+
if land.x != -99999:
|
|
92
|
+
out.append(land)
|
|
93
|
+
for dx in range(-across, across + 1):
|
|
94
|
+
for dy in range(-up, up + 3):
|
|
95
|
+
if absi(dx) <= 1 and dy == 0:
|
|
96
|
+
continue
|
|
97
|
+
var t := c + Vector2i(dx, dy)
|
|
98
|
+
if dy > 0 and absi(dx) > across - 1 + dy:
|
|
99
|
+
continue
|
|
100
|
+
if standable(t) and not blocked(c - Vector2i(0, ph)):
|
|
101
|
+
out.append(t)
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
## Cheapest path (Dijkstra) from `from` to the first cell satisfying `goal`; walking is cheap,
|
|
106
|
+
## jumps cost more the longer they are, so bots move like people instead of leaping everywhere.
|
|
107
|
+
## Returns the path (both ends included) or [].
|
|
108
|
+
func path(from: Vector2i, goal: Callable, max_nodes := 6000) -> Array[Vector2i]:
|
|
109
|
+
var prev := {from: from}
|
|
110
|
+
var cost := {from: 0.0}
|
|
111
|
+
var frontier: Array = [[0.0, from]] # sorted by cost
|
|
112
|
+
var done := {}
|
|
113
|
+
var expanded := 0
|
|
114
|
+
while not frontier.is_empty() and expanded < max_nodes:
|
|
115
|
+
var item: Array = frontier.pop_front()
|
|
116
|
+
var c: Vector2i = item[1]
|
|
117
|
+
if done.has(c):
|
|
118
|
+
continue
|
|
119
|
+
done[c] = true
|
|
120
|
+
expanded += 1
|
|
121
|
+
if goal.call(c):
|
|
122
|
+
var out: Array[Vector2i] = [c]
|
|
123
|
+
while c != from:
|
|
124
|
+
c = prev[c]
|
|
125
|
+
out.push_front(c)
|
|
126
|
+
return out
|
|
127
|
+
for n in neighbors(c):
|
|
128
|
+
var d: Vector2i = n - c
|
|
129
|
+
var step := 1.0
|
|
130
|
+
if platformer and (absi(d.x) > 1 or d.y < 0):
|
|
131
|
+
step = 2.0 + absi(d.x) * 0.6 + maxi(0, -d.y) * 0.5
|
|
132
|
+
elif d.y > 0:
|
|
133
|
+
step = 1.0 + d.y * 0.2
|
|
134
|
+
var nc: float = cost[c] + step
|
|
135
|
+
if nc < cost.get(n, INF):
|
|
136
|
+
cost[n] = nc
|
|
137
|
+
prev[n] = c
|
|
138
|
+
var i := frontier.bsearch_custom([nc, n], func(a: Array, b: Array) -> bool: return a[0] < b[0])
|
|
139
|
+
frontier.insert(i, [nc, n])
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
## Goal predicate: a stand cell from which a target at `pos` can be touched.
|
|
144
|
+
func reaches(pos: Vector2) -> Callable:
|
|
145
|
+
var tc := Vector2i(floori(pos.x / ts), floori(pos.y / ts))
|
|
146
|
+
if not platformer:
|
|
147
|
+
return func(c: Vector2i) -> bool: return absi(c.x - tc.x) <= 1 and absi(c.y - tc.y) <= 1
|
|
148
|
+
# Pickups floating over gaps are grabbed mid-jump, so the horizontal reach grows with height.
|
|
149
|
+
var reach_x := 1 if tc.y >= rect.end.y - 3 else maxi(1, across / 2)
|
|
150
|
+
return func(c: Vector2i) -> bool:
|
|
151
|
+
var dx := absi(c.x - tc.x)
|
|
152
|
+
return (dx <= 1 or (dx <= reach_x and tc.y < c.y)) and tc.y <= c.y + 1 and tc.y > c.y - up - ph
|