dungeoncoder 0.2.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.
Files changed (77) hide show
  1. dungeoncoder/__init__.py +4 -0
  2. dungeoncoder/__main__.py +254 -0
  3. dungeoncoder/_api_config.py +6 -0
  4. dungeoncoder/_generated/__init__.py +8 -0
  5. dungeoncoder/_generated/api/__init__.py +1 -0
  6. dungeoncoder/_generated/api/game/__init__.py +1 -0
  7. dungeoncoder/_generated/api/game/get_statistics.py +158 -0
  8. dungeoncoder/_generated/api/game/get_version.py +166 -0
  9. dungeoncoder/_generated/api/hero/__init__.py +1 -0
  10. dungeoncoder/_generated/api/hero/ask_oracle.py +192 -0
  11. dungeoncoder/_generated/api/hero/configure.py +207 -0
  12. dungeoncoder/_generated/api/hero/drop.py +207 -0
  13. dungeoncoder/_generated/api/hero/get_inventory.py +188 -0
  14. dungeoncoder/_generated/api/hero/get_items_at_position.py +188 -0
  15. dungeoncoder/_generated/api/hero/interact.py +188 -0
  16. dungeoncoder/_generated/api/hero/is_abyss_in_front.py +188 -0
  17. dungeoncoder/_generated/api/hero/is_at_goal.py +192 -0
  18. dungeoncoder/_generated/api/hero/is_collision_in_front.py +188 -0
  19. dungeoncoder/_generated/api/hero/is_enemy_in_front.py +188 -0
  20. dungeoncoder/_generated/api/hero/is_facing_north.py +188 -0
  21. dungeoncoder/_generated/api/hero/is_switch_in_front.py +188 -0
  22. dungeoncoder/_generated/api/hero/is_torch_in_front.py +188 -0
  23. dungeoncoder/_generated/api/hero/move.py +188 -0
  24. dungeoncoder/_generated/api/hero/peek_item_value.py +211 -0
  25. dungeoncoder/_generated/api/hero/pickup.py +207 -0
  26. dungeoncoder/_generated/api/hero/read_item_value.py +188 -0
  27. dungeoncoder/_generated/api/hero/sense_goal.py +192 -0
  28. dungeoncoder/_generated/api/hero/set_pace.py +207 -0
  29. dungeoncoder/_generated/api/hero/turn_left.py +188 -0
  30. dungeoncoder/_generated/api/level/__init__.py +1 -0
  31. dungeoncoder/_generated/api/level/load_level.py +190 -0
  32. dungeoncoder/_generated/api/level/reset_level.py +158 -0
  33. dungeoncoder/_generated/client.py +271 -0
  34. dungeoncoder/_generated/errors.py +14 -0
  35. dungeoncoder/_generated/models/__init__.py +47 -0
  36. dungeoncoder/_generated/models/api_error_response.py +98 -0
  37. dungeoncoder/_generated/models/api_error_response_status.py +7 -0
  38. dungeoncoder/_generated/models/boolean_result.py +96 -0
  39. dungeoncoder/_generated/models/boolean_result_status.py +7 -0
  40. dungeoncoder/_generated/models/configure_params.py +84 -0
  41. dungeoncoder/_generated/models/distance_param.py +76 -0
  42. dungeoncoder/_generated/models/name_param.py +76 -0
  43. dungeoncoder/_generated/models/nullable_integer_result.py +104 -0
  44. dungeoncoder/_generated/models/nullable_integer_result_status.py +7 -0
  45. dungeoncoder/_generated/models/nullable_string_result.py +104 -0
  46. dungeoncoder/_generated/models/nullable_string_result_status.py +7 -0
  47. dungeoncoder/_generated/models/pace_params.py +76 -0
  48. dungeoncoder/_generated/models/statistics.py +217 -0
  49. dungeoncoder/_generated/models/statistics_result.py +104 -0
  50. dungeoncoder/_generated/models/statistics_result_status.py +7 -0
  51. dungeoncoder/_generated/models/string_array_result.py +100 -0
  52. dungeoncoder/_generated/models/string_array_result_status.py +7 -0
  53. dungeoncoder/_generated/models/tiled_level.py +67 -0
  54. dungeoncoder/_generated/models/version_result.py +104 -0
  55. dungeoncoder/_generated/models/version_result_result.py +84 -0
  56. dungeoncoder/_generated/models/version_result_status.py +7 -0
  57. dungeoncoder/_generated/types.py +53 -0
  58. dungeoncoder/asciimap.py +824 -0
  59. dungeoncoder/dungeoncoder.py +482 -0
  60. dungeoncoder/generator.py +213 -0
  61. dungeoncoder/packs/gelaende.json +1 -0
  62. dungeoncoder/packs/items.json +67 -0
  63. dungeoncoder/packs/station.json +1 -0
  64. dungeoncoder/packs/studio.json +1 -0
  65. dungeoncoder/packs/tilesets.json +1 -0
  66. dungeoncoder/packs/werkstatt.json +1 -0
  67. dungeoncoder/replay.py +80 -0
  68. dungeoncoder/requirements.txt +2 -0
  69. dungeoncoder/sim.py +792 -0
  70. dungeoncoder/testing.py +63 -0
  71. dungeoncoder/trace.py +92 -0
  72. dungeoncoder/variants.py +112 -0
  73. dungeoncoder-0.2.0.dist-info/METADATA +37 -0
  74. dungeoncoder-0.2.0.dist-info/RECORD +77 -0
  75. dungeoncoder-0.2.0.dist-info/WHEEL +5 -0
  76. dungeoncoder-0.2.0.dist-info/licenses/LICENSE.txt +25 -0
  77. dungeoncoder-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,824 @@
1
+ """Levels from ASCII maps: `Game("karte.txt")` builds the level from a text file.
2
+
3
+ A map file has an optional header, a line "---", and the map:
4
+
5
+ start: north # hero's start direction (default south)
6
+ hero: 7 # hero sprite 0-15 (default 7); a co-op map: one per hero, "hero: 7 13 1"
7
+ style: dungeon # tile style: dungeon (default), corridor, arena, bridge, maze;
8
+ # other worlds: station (a hospital ward), studio (a light studio),
9
+ # werkstatt (a production hall), gelaende (a survey area)
10
+ controls: 8,8 -> 25,10 # switch/torch at (8,8) toggles the object at (25,10); repeatable
11
+ pattern: 5,2 = 101 @ 2,0 3,0 4,0 # door at (5,2) is open exactly while these torches show 101
12
+ values: 5 3 8 # weights of the crystals K, in reading order (never drawn)
13
+ colors: weiss orange weiss # optional: colour of each crystal (weiss or orange)
14
+ guards: 5,2 patrol east # guard W at (5,2) walks east and back; "x,y chase" follows the hero
15
+ orakel: true # the level has an oracle (hero.ask_oracle())
16
+ view: fit # show only the map, with larger fields (default: full, the 30x20 field)
17
+ deko: 6 # scatter small details on 6 % of the plain floor and walls (seeded, default 0)
18
+ region: s 2,2 17,13 # variants: the switches s in this rectangle may lie on any of its
19
+ # free fields; Game("karte.txt", seed=17) picks one (variants.py)
20
+ fog: dark # any other key becomes a map property (bool/int/str)
21
+ ---
22
+ ##########
23
+ #H..s....#
24
+ #....D...Z
25
+ ##########
26
+
27
+ A header with "generate: maze" (or rooms, pillars) makes the file a recipe: the level is generated,
28
+ with "size: 15x11", "loops: 2" and "seed: random" (a new level per load) or a number (generator.py).
29
+
30
+ Legend: # wall . floor ~ abyss (blank) nothing
31
+ H hero Z goal T/t torch (burning/off, on a wall)
32
+ 2 3 4 more heroes (a co-op level: Game.get_heroes(), win: all_heroes)
33
+ s switch D door G grille C chest J jug * sweets
34
+ K crystal o pebble W guard (see guards:)
35
+ Decoration (looks only; where a style has no pictures for it, x is a wall and , " are floor):
36
+ x an obstacle: furniture, crates, a rock, a tree (a wall for the rules)
37
+ , a path or carpet; neighbouring fields join up " a small detail on the floor
38
+ A block of touching x that is a rectangle of exactly the size of one of the style's large
39
+ props becomes that prop (a 5 x 3 house, a 2 x 2 tank); any other block is filled with the
40
+ style's filling props, largest first (trees, rocks, benches), the rest with small ones.
41
+ In another world the same symbols mean that world's things: in `station`, K is a bed
42
+ (class "Bett"), * a sample ("Probe"), o a floor mark ("Markierung"), T a lamp, ~ stairs;
43
+ in `studio`, K is a sketch ("Entwurf"), * a colour gel ("Farbfolie"), o a tape mark
44
+ ("Klebepunkt"), T one lamp of the display, ~ the edge of the stage; in `werkstatt`, K is a
45
+ workpiece ("Teil"), * a load carrier ("Kiste"), o a floor mark ("Marke"), ~ a danger zone,
46
+ W a person; in `gelaende`, K is a survey point ("Messpunkt"), * a soil sample
47
+ ("Bodenprobe"), o a stake ("Pflock"), # woods, ~ water.
48
+
49
+ Coordinates are 0-based (column, row) in the map as written. The map is placed
50
+ centred on the 30x20 game field (a larger map makes the field as large as the map);
51
+ with "view: fit" the level is then cut to the map and one field around it, so the
52
+ game shows it larger.
53
+
54
+ The goal Z is drawn facing the way the hero comes in, where the style has exits
55
+ for the four directions (stairs going down away from the hero).
56
+
57
+ Which tile goes where is decided by a *style pack* (packs/<style>.json): for every
58
+ 3x3 neighbourhood of terrain it lists the tile stacks that good levels use there,
59
+ and which stacks fit next to each other. The packs are learned from the levels
60
+ that ship with the game (tools/ascii2level.py in the development workspace).
61
+ """
62
+ import collections
63
+ import importlib.util
64
+ import json
65
+ import os
66
+ import random
67
+
68
+ WALL, FLOOR, VOID, ABYSS = "#", ".", " ", "~"
69
+ GRID_W, GRID_H = 30, 20
70
+ OBJECT_CLASSES = {"s": "Switch", "C": "Chest", "J": "Jug", "Z": "Goal", "*": "Sweets"}
71
+ NEIGHBOURS = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]
72
+ WEIGHTS = [1, 2, 1, 2, 2, 1, 2, 1] # orthogonal neighbours matter more than diagonal ones
73
+ DIRECTIONS = ("north", "east", "south", "west")
74
+ HEADER_KEYS = ("start", "hero", "style", "controls", "pattern", "values", "colors", "guards", "view", "deko",
75
+ "region", "generate", "size")
76
+ PROP, PATH, DETAIL = "x", ",", '"' # decoration symbols
77
+ OFFSETS = {"north": (0, -1), "east": (1, 0), "south": (0, 1), "west": (-1, 0)}
78
+ VIEWS = ("full", "fit")
79
+ GUARD_SPRITE = 3 # the characters tileset's sprite number used for guards
80
+ EXTRA_HEROES = ("2", "3", "4") # co-op heroes, after H
81
+ EXTRA_HERO_SPRITES = ["13", "1", "10"] # their sprites unless "hero:" names them
82
+ # items that aren't learned from the levels: K a crystal, o a pebble. Their tiles come from the
83
+ # asset packs' manifests (pack.json "items", as the engine's start inventory: sim.item_tiles);
84
+ # the colour of a crystal (header colors:) picks the item "Crystal" or "Crystal:<colour>"
85
+ CRYSTAL_ITEMS = {"weiss": "Crystal", "orange": "Crystal:orange"}
86
+ PACK_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "packs")
87
+ NOT_STYLES = ("tilesets.json", "items.json") # files in packs/ that aren't style packs
88
+
89
+
90
+ def _sim():
91
+ """The simulator module (its tile rules and item tiles). This file is also loaded on its
92
+ own (tools/ascii2level.py), so the sibling sim.py is loaded by path then."""
93
+ try:
94
+ from . import sim
95
+ except ImportError:
96
+ spec = importlib.util.spec_from_file_location(
97
+ "dungeoncoder_sim", os.path.join(os.path.dirname(os.path.abspath(__file__)), "sim.py"))
98
+ sim = importlib.util.module_from_spec(spec)
99
+ spec.loader.exec_module(sim)
100
+ return sim
101
+
102
+
103
+ def item_tile(item, symbol):
104
+ """(tileset source, local id) of an item the builder places (K, o), from the packs."""
105
+ tile = _sim().item_tiles().get(item)
106
+ if tile is None:
107
+ raise MapError(f"no tile for {symbol} ({item}): no asset pack names one (pack.json, \"items\")")
108
+ return tile
109
+ PACK_VERSION = 1
110
+
111
+
112
+ class MapError(ValueError):
113
+ """The map file can't be turned into a level; the message says why and where."""
114
+
115
+
116
+ def parse_text(text):
117
+ """(header dict, map rows) from the text of a map file."""
118
+ lines = text.split("\n")
119
+ header, rows = {}, lines
120
+ if "---" in [l.strip() for l in lines]:
121
+ split = [l.strip() for l in lines].index("---")
122
+ for line in lines[:split]:
123
+ if ":" in line:
124
+ key, value = line.split(":", 1)
125
+ header.setdefault(key.strip(), []).append(value.strip())
126
+ rows = lines[split + 1:]
127
+ # only trailing blank lines go: leading ones are part of the map's coordinates
128
+ while rows and not rows[-1].strip():
129
+ rows.pop()
130
+ return header, rows
131
+
132
+
133
+ def base_terrain(ch):
134
+ """Terrain under an ASCII symbol. Torches always stand on a wall or a pedestal; an
135
+ obstacle x is a wall for the rules."""
136
+ if ch in (WALL, "T", "t", PROP):
137
+ return WALL
138
+ if ch in (VOID, ABYSS):
139
+ return ch
140
+ return FLOOR
141
+
142
+
143
+ def pattern(terrain, c, r):
144
+ """(terrain of the cell, terrain of its 8 neighbours); outside the field is VOID."""
145
+ h, w = len(terrain), len(terrain[0])
146
+
147
+ def at(x, y):
148
+ return terrain[y][x] if 0 <= x < w and 0 <= y < h else VOID
149
+ return at(c, r), tuple(at(c + dx, r + dy) for dx, dy in NEIGHBOURS)
150
+
151
+
152
+ def similarity(a, b):
153
+ return sum(w for w, x, y in zip(WEIGHTS, a, b) if x == y)
154
+
155
+
156
+ class Pack:
157
+ """What a style's good levels contain. A stack is a tuple of (role, source, local id)
158
+ per tile layer; roles are bg, floor and wall."""
159
+
160
+ def __init__(self):
161
+ self.stacks = collections.defaultdict(collections.Counter) # (center, neighbours) -> Counter(stack)
162
+ self.hpairs = collections.Counter() # (left stack, right stack)
163
+ self.vpairs = collections.Counter() # (upper stack, lower stack)
164
+ self.objects = collections.defaultdict(collections.Counter) # (class, context) -> Counter((source, local))
165
+ self.goal_decor = collections.Counter() # ((dx, dy, source, local), ...) -> count
166
+ self.neighbour_fit = True
167
+ self.tilesets = {} # source -> {"count", "tile_w", "tile_h", "classes": {local: class}}
168
+ self.hero = {} # "north_7" -> (source, local)
169
+ self.torch_off = {} # source -> [local ids of "off" torches]
170
+ self.sweets = None # (source, local)
171
+ self.abyss_tiles = [] # [(source, local)] tiles of class Abyss; the first is the plain fallback
172
+ self.tile_size = 16 # pixels per field in the style's levels (their tilewidth)
173
+ self.items = {} # symbol -> (source, local, class): a world's own items for K, * and o
174
+ self.world = None # name of the world (not the dungeon): the game shows its end screens
175
+ self.pack = None # the asset pack the style's art comes from (map property pack, G5)
176
+ self.guard_sprite = GUARD_SPRITE # the figure number of the agents "W"
177
+ # decoration (optional; tiles are (source, local)):
178
+ self.props = [] # obstacles x: [their tile, tile drawn into the field behind them or None]
179
+ self.big_props = [] # large obstacles: {"w", "h", "fill", "parts": [(dx, dy, source, local)]}: a part
180
+ # per field of the w x h footprint (walls), parts outside it (a crown) are deko;
181
+ # "fill": may fill blocks of other sizes (trees), else only a block of its size
182
+ self.paths = {} # paths ",": 8-neighbour mask ("0"/"1" in NEIGHBOURS order) -> tile
183
+ self.details = [] # small details '"' on the floor
184
+ self.wall_variants = {} # wall tile -> [decorated variants] (deko:)
185
+ self.exits = {} # walking direction into the goal -> (source, local, Tiled flip flags)
186
+
187
+ # --- choosing tiles ----------------------------------------------------
188
+ def candidates(self, key):
189
+ """Stacks seen for this exact pattern; otherwise those of the most similar patterns."""
190
+ if key in self.stacks:
191
+ return self.stacks[key]
192
+ center, neigh = key
193
+ keys = [k for k in self.stacks if k[0] == center]
194
+ if not keys:
195
+ return collections.Counter({(): 1})
196
+ best = max(similarity(k[1], neigh) for k in keys)
197
+ merged = collections.Counter()
198
+ for k in keys:
199
+ if similarity(k[1], neigh) == best:
200
+ merged.update(self.stacks[k])
201
+ return merged
202
+
203
+ def choose(self, key, left, up, rng, variety):
204
+ """Pick a stack for a cell: first of all it must *fit* the already chosen
205
+ neighbours on the left and above (that pair occurs in the corpus), then it
206
+ should be frequent for this neighbourhood. If no candidate of the pattern fits,
207
+ look at stacks of patterns with the same orthogonal neighbours."""
208
+ def fit(stack):
209
+ return ((left is None or self.hpairs[(left, stack)] > 0)
210
+ + (up is None or self.vpairs[(up, stack)] > 0))
211
+
212
+ counter = self.candidates(key)
213
+ ranked = sorted(counter, key=lambda s: (fit(s), counter[s]), reverse=True)
214
+ if fit(ranked[0]) < 2:
215
+ center, neigh = key
216
+ ortho = [neigh[i] for i in (1, 3, 4, 6)]
217
+ pool = collections.Counter()
218
+ for k, stacks in self.stacks.items():
219
+ if k[0] == center and [k[1][i] for i in (1, 3, 4, 6)] == ortho:
220
+ pool.update(stacks)
221
+ fitting = [s for s in pool if fit(s) == 2]
222
+ if fitting:
223
+ ranked = sorted(fitting, key=lambda s: pool[s], reverse=True)
224
+ if variety > 0 and len(ranked) > 1 and rng.random() < variety:
225
+ best_fit = fit(ranked[0])
226
+ same = [s for s in ranked[:4] if fit(s) == best_fit]
227
+ return rng.choice(same)
228
+ return ranked[0]
229
+
230
+ def object_tile(self, cls, context):
231
+ for ctx in (context, FLOOR, WALL):
232
+ if self.objects[(cls, ctx)]:
233
+ return self.objects[(cls, ctx)].most_common(1)[0][0]
234
+ return None
235
+
236
+ def tile_class(self, tile):
237
+ return self.tilesets[tile[0]]["classes"].get(tile[1], "")
238
+
239
+ def wall_tiles(self):
240
+ """Tiles that are wall pieces in the style's levels."""
241
+ return {(source, local) for stacks in self.stacks.values() for stack in stacks
242
+ for role, source, local in stack if role == "wall"}
243
+
244
+ # --- storage -----------------------------------------------------------
245
+ def to_json(self):
246
+ """A JSON-ready dict. Orders are kept: ties are broken by first occurrence."""
247
+ table, index = [], {}
248
+
249
+ def ref(stack):
250
+ if stack not in index:
251
+ index[stack] = len(table)
252
+ table.append([list(t) for t in stack])
253
+ return index[stack]
254
+ stacks = {k[0] + "".join(k[1]): [[ref(s), n] for s, n in counter.items()]
255
+ for k, counter in self.stacks.items()}
256
+ return {
257
+ "version": PACK_VERSION,
258
+ "neighbour_fit": self.neighbour_fit,
259
+ "stacks": stacks,
260
+ "stack_table": table,
261
+ "hpairs": [[ref(a), ref(b)] for (a, b), n in self.hpairs.items() if n > 0],
262
+ "vpairs": [[ref(a), ref(b)] for (a, b), n in self.vpairs.items() if n > 0],
263
+ "objects": [[cls, ctx, [[s, l, n] for (s, l), n in counter.items()]]
264
+ for (cls, ctx), counter in self.objects.items() if counter],
265
+ "goal_decor": [[[list(d) for d in decor], n] for decor, n in self.goal_decor.items()],
266
+ "tilesets": {s: dict(info, classes={str(k): v for k, v in info["classes"].items()})
267
+ for s, info in self.tilesets.items()},
268
+ "hero": {k: list(v) for k, v in self.hero.items()},
269
+ "torch_off": self.torch_off,
270
+ "sweets": list(self.sweets) if self.sweets else None,
271
+ "abyss_tiles": [list(t) for t in self.abyss_tiles],
272
+ **({"tile_size": self.tile_size} if self.tile_size != 16 else {}),
273
+ **({"items": {k: list(v) for k, v in self.items.items()}} if self.items else {}),
274
+ **({"world": self.world} if self.world else {}),
275
+ **({"pack": self.pack} if self.pack else {}),
276
+ **({"guard_sprite": self.guard_sprite} if self.guard_sprite != GUARD_SPRITE else {}),
277
+ **({"props": [[list(b), list(t) if t else None] for b, t in self.props]} if self.props else {}),
278
+ **({"big_props": [{"w": p["w"], "h": p["h"], "fill": p["fill"], "parts": [list(t) for t in p["parts"]]}
279
+ for p in self.big_props]} if self.big_props else {}),
280
+ **({"paths": {k: list(v) for k, v in self.paths.items()}} if self.paths else {}),
281
+ **({"details": [list(t) for t in self.details]} if self.details else {}),
282
+ **({"wall_variants": [[list(k), [list(t) for t in v]] for k, v in self.wall_variants.items()]}
283
+ if self.wall_variants else {}),
284
+ **({"exits": {k: list(v) for k, v in self.exits.items()}} if self.exits else {}),
285
+ }
286
+
287
+ @classmethod
288
+ def from_json(cls, data):
289
+ if data.get("version") != PACK_VERSION:
290
+ raise MapError(f"style pack version {data.get('version')}, expected {PACK_VERSION}")
291
+ pack = cls()
292
+ table = [tuple(tuple(t) for t in stack) for stack in data["stack_table"]]
293
+ for key, entries in data["stacks"].items():
294
+ pack.stacks[(key[0], tuple(key[1:]))] = collections.Counter({table[i]: n for i, n in entries})
295
+ for a, b in data["hpairs"]:
296
+ pack.hpairs[(table[a], table[b])] = 1
297
+ for a, b in data["vpairs"]:
298
+ pack.vpairs[(table[a], table[b])] = 1
299
+ for obj_cls, ctx, entries in data["objects"]:
300
+ pack.objects[(obj_cls, ctx)] = collections.Counter({(s, l): n for s, l, n in entries})
301
+ for decor, n in data["goal_decor"]:
302
+ pack.goal_decor[tuple(tuple(d) for d in decor)] = n
303
+ pack.neighbour_fit = data["neighbour_fit"]
304
+ pack.tilesets = {s: dict(info, classes={int(k): v for k, v in info["classes"].items()})
305
+ for s, info in data["tilesets"].items()}
306
+ pack.hero = {k: tuple(v) for k, v in data["hero"].items()}
307
+ pack.torch_off = data["torch_off"]
308
+ pack.sweets = tuple(data["sweets"]) if data["sweets"] else None
309
+ pack.abyss_tiles = [tuple(t) for t in data.get("abyss_tiles", [])]
310
+ pack.tile_size = data.get("tile_size", 16)
311
+ pack.items = {k: tuple(v) for k, v in data.get("items", {}).items()}
312
+ pack.world = data.get("world")
313
+ pack.pack = data.get("pack")
314
+ pack.guard_sprite = data.get("guard_sprite", GUARD_SPRITE)
315
+ pack.props = [(tuple(b), tuple(t) if t else None) for b, t in data.get("props", [])]
316
+ pack.big_props = [{"w": p["w"], "h": p["h"], "fill": p.get("fill", False), "parts": [tuple(t) for t in p["parts"]]}
317
+ for p in data.get("big_props", [])]
318
+ pack.paths = {k: tuple(v) for k, v in data.get("paths", {}).items()}
319
+ pack.details = [tuple(t) for t in data.get("details", [])]
320
+ pack.wall_variants = {tuple(k): [tuple(t) for t in v] for k, v in data.get("wall_variants", [])}
321
+ pack.exits = {k: tuple(v) for k, v in data.get("exits", {}).items()}
322
+ return pack
323
+
324
+ @classmethod
325
+ def load(cls, style):
326
+ """The pack of a style. An asset pack in DC_ASSET_PACKS may bring its own packs
327
+ (styles/<style>.json, first pack wins, as for tilesets in the game): so a private
328
+ pack can draw a style with other art than the one shipped here."""
329
+ folders = [os.path.join(p, "styles") for p in os.environ.get("DC_ASSET_PACKS", "").split(os.pathsep) if p]
330
+ path = next((os.path.join(d, f"{style}.json") for d in folders + [PACK_DIR]
331
+ if f"{style}.json" not in NOT_STYLES and os.path.exists(os.path.join(d, f"{style}.json"))), None)
332
+ if path is None:
333
+ styles = sorted(f[:-5] for f in os.listdir(PACK_DIR) if f.endswith(".json") and f not in NOT_STYLES)
334
+ raise MapError(f"unknown style {style!r}; available: {', '.join(styles)} (more styles come with "
335
+ "asset packs: setting dungeonCoder.assetPacks, outside VS Code DC_ASSET_PACKS)")
336
+ with open(path, encoding="utf-8") as f:
337
+ return cls.from_json(json.load(f))
338
+
339
+
340
+ def _hash(c, r, salt=0):
341
+ """A fixed pseudo-random number per field: the same map always gets the same decoration."""
342
+ h = (c * 73856093) ^ (r * 19349663) ^ (salt * 83492791)
343
+ return (h ^ (h >> 13)) & 0x7FFFFFFF
344
+
345
+
346
+ def exit_direction(grid, c, r):
347
+ """The direction the hero walks into the goal at (c, r): from its one open neighbour;
348
+ with several, the one whose opposite side is solid (stairs lead into the wall)."""
349
+ h, w = len(grid), len(grid[0])
350
+
351
+ def at(x, y):
352
+ return grid[y][x] if 0 <= x < w and 0 <= y < h else VOID
353
+
354
+ def open_(ch):
355
+ return ch not in (WALL, "T", "t", PROP, VOID, ABYSS)
356
+ options = [d for d, (dx, dy) in OFFSETS.items() if open_(at(c - dx, r - dy))]
357
+ if not options:
358
+ return None
359
+ solid = [d for d in options if not open_(at(c + OFFSETS[d][0], r + OFFSETS[d][1]))]
360
+ return (solid or options)[0]
361
+
362
+
363
+ def decorate(grid, terrain, layers, pack, header):
364
+ """The decoration of a map (it changes no rule): obstacles x, paths ",", details '"',
365
+ and with "deko: N" small details on N % of the plain floor and decorated walls."""
366
+ try:
367
+ density = int(header.get("deko", ["0"])[0])
368
+ except ValueError:
369
+ raise MapError(f"deko: a whole number (percent) expected, got {header['deko'][0]!r}") from None
370
+ h, w = len(grid), len(grid[0])
371
+
372
+ def is_path(x, y):
373
+ return 0 <= x < w and 0 <= y < h and grid[y][x] == PATH
374
+ # obstacles that touch form one group and get the same picture (a row of machines, of trees)
375
+ group = {}
376
+ for r in range(h):
377
+ for c in range(w):
378
+ if grid[r][c] == PROP and (c, r) not in group:
379
+ todo = [(c, r)]
380
+ while todo:
381
+ x, y = todo.pop()
382
+ if (x, y) in group or not (0 <= x < w and 0 <= y < h) or grid[y][x] != PROP:
383
+ continue
384
+ group[(x, y)] = (c, r)
385
+ todo += [(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]
386
+ big = place_big_props(grid, terrain, layers, pack, group)
387
+ for r in range(h):
388
+ for c in range(w):
389
+ ch = grid[r][c]
390
+ if (c, r) in big:
391
+ continue
392
+ if ch == PROP and pack.props:
393
+ bottom, top = pack.props[_hash(*group[(c, r)], 1) % len(pack.props)]
394
+ layers["wall"][(c, r)] = bottom
395
+ if top and r > 0 and terrain[r - 1][c] in (FLOOR, WALL):
396
+ layers["deko"][(c, r - 1)] = top
397
+ elif ch == PATH and pack.paths:
398
+ layers["floor2"][(c, r)] = pack.paths.get(path_mask(is_path, c, r), pack.paths.get("0" * 8))
399
+ elif ch == DETAIL and pack.details:
400
+ layers["floor2"][(c, r)] = pack.details[_hash(c, r, 2) % len(pack.details)]
401
+ elif not density:
402
+ continue
403
+ elif ch == FLOOR and pack.details and (c, r) not in layers["floor2"] and _hash(c, r, 3) % 100 < density:
404
+ layers["floor2"][(c, r)] = pack.details[_hash(c, r, 2) % len(pack.details)]
405
+ elif ch == WALL and layers["wall"].get((c, r)) in pack.wall_variants and _hash(c, r, 4) % 100 < 2 * density:
406
+ variants = pack.wall_variants[layers["wall"][(c, r)]]
407
+ layers["wall"][(c, r)] = variants[_hash(c, r, 5) % len(variants)]
408
+
409
+
410
+ def deko_layer(n):
411
+ """Name of the n-th deko layer (1: "deko"; more where large props overlap)."""
412
+ return "deko" if n == 1 else f"deko{n}"
413
+
414
+
415
+ def place_big_props(grid, terrain, layers, pack, group):
416
+ """Large obstacles. A block of touching x (two fields or more) that is a rectangle of a
417
+ prop's footprint size becomes that prop (several of that size: chosen by position). Any other block is filled in
418
+ reading order with the largest filling prop that still fits; what is left gets the small
419
+ props. Every field of a footprint gets its own wall tile, so the rules see the same walls
420
+ as with small props. Parts outside the footprint (above it, or a crown wider than the
421
+ trunk) go into the deko layers (the later prop's on top). Returns the fields covered."""
422
+ covered = set()
423
+ if not pack.big_props:
424
+ return covered
425
+ h, w = len(grid), len(grid[0])
426
+ blocks = collections.defaultdict(set)
427
+ for cell, anchor in group.items():
428
+ blocks[anchor].add(cell)
429
+
430
+ def put(prop, c, r):
431
+ for dx, dy, source, local in prop["parts"]:
432
+ x, y = c + dx, r + dy
433
+ if 0 <= dx < prop["w"] and 0 <= dy < prop["h"]:
434
+ layers["wall"][(x, y)] = (source, local)
435
+ covered.add((x, y))
436
+ elif 0 <= y < h and 0 <= x < w and terrain[y][x] in (FLOOR, WALL):
437
+ n = 1 # the first deko layer that is free here; a new one if none
438
+ while (x, y) in layers.setdefault(deko_layer(n), {}):
439
+ n += 1
440
+ layers[deko_layer(n)][(x, y)] = (source, local)
441
+
442
+ fillers = [p for p in pack.big_props if p["fill"]]
443
+ for anchor in sorted(blocks, key=lambda a: (a[1], a[0])):
444
+ cells = blocks[anchor]
445
+ x0, y0 = min(c for c, _ in cells), min(r for _, r in cells)
446
+ bw, bh = max(c for c, _ in cells) - x0 + 1, max(r for _, r in cells) - y0 + 1
447
+ # a single field keeps the style's small props (their variety); a full rectangle of a prop's size is that prop
448
+ exact = ([p for p in pack.big_props if (p["w"], p["h"]) == (bw, bh)]
449
+ if len(cells) == bw * bh > 1 else [])
450
+ if exact:
451
+ put(exact[_hash(x0, y0, 6) % len(exact)], x0, y0)
452
+ continue
453
+ for c, r in sorted(cells, key=lambda a: (a[1], a[0])):
454
+ if (c, r) in covered:
455
+ continue
456
+ fitting = [p for p in fillers
457
+ if all((c + dx, r + dy) in cells and (c + dx, r + dy) not in covered
458
+ for dx in range(p["w"]) for dy in range(p["h"]))]
459
+ if fitting:
460
+ largest = max(p["w"] * p["h"] for p in fitting)
461
+ options = [p for p in fitting if p["w"] * p["h"] == largest]
462
+ put(options[_hash(c, r, 6) % len(options)], c, r)
463
+ return covered
464
+
465
+
466
+ def path_mask(same, c, r):
467
+ """The 8-neighbour mask of a path field ("1" = path there, NEIGHBOURS order); a diagonal
468
+ counts only where both fields beside it are path too (it changes nothing otherwise)."""
469
+ bits = {d: same(c + d[0], r + d[1]) for d in NEIGHBOURS}
470
+ for dx, dy in ((-1, -1), (1, -1), (-1, 1), (1, 1)):
471
+ bits[(dx, dy)] = bits[(dx, dy)] and bits[(dx, 0)] and bits[(0, dy)]
472
+ return "".join("1" if bits[d] else "0" for d in NEIGHBOURS)
473
+
474
+
475
+ def build(header, rows, pack, seed=0, variety=0.0):
476
+ """(level as a Tiled JSON dict, terrain grid) for a parsed map."""
477
+ height, width = len(rows), max((len(r) for r in rows), default=0)
478
+ if sum(row.count("H") for row in rows) != 1:
479
+ raise MapError(f"the map needs exactly one hero H, it has {sum(row.count('H') for row in rows)}")
480
+ grid_w, grid_h = max(GRID_W, width), max(GRID_H, height) # a larger map, a larger field
481
+ ox, oy = (grid_w - width) // 2, (grid_h - height) // 2
482
+ grid = [[VOID] * grid_w for _ in range(grid_h)]
483
+ for r, line in enumerate(rows):
484
+ for c, ch in enumerate(line):
485
+ grid[r + oy][c + ox] = ch
486
+
487
+ terrain = [[base_terrain(ch) for ch in row] for row in grid]
488
+ # an obstacle x stands on floor: its neighbours see floor, it gets the floor's tiles and the
489
+ # obstacle's picture on the wall layer; without pictures for obstacles it is a wall
490
+ looks = [[FLOOR if ch == PROP and pack.props else t for ch, t in zip(row, trow)]
491
+ for row, trow in zip(grid, terrain)]
492
+
493
+ rng = random.Random(seed)
494
+ layers = {"bg": {}, "floor": {}, "floor2": {}, "wall": {}, "deko": {}}
495
+ chosen = {}
496
+ for r in range(grid_h):
497
+ for c in range(grid_w):
498
+ if pack.neighbour_fit:
499
+ left, up = chosen.get((c - 1, r)), chosen.get((c, r - 1))
500
+ else:
501
+ left = up = None # no constraint: the most frequent stack for the pattern
502
+ stack = pack.choose(pattern(looks, c, r), left, up, rng, variety)
503
+ if terrain[r][c] == ABYSS and pack.abyss_tiles and not (
504
+ stack and (stack[-1][1], stack[-1][2]) in {tuple(t) for t in pack.abyss_tiles}):
505
+ # the game only sees an abyss where an Abyss tile is the topmost tile
506
+ source, local = pack.abyss_tiles[0]
507
+ stack = (("floor", source, local),)
508
+ chosen[(c, r)] = stack
509
+ floors = 0
510
+ for role, source, local in stack:
511
+ if role == "floor":
512
+ layers["floor" if floors == 0 else "floor2"][(c, r)] = (source, local)
513
+ floors += 1
514
+ elif role in ("bg", "wall"):
515
+ layers[role][(c, r)] = (source, local)
516
+
517
+ decorate(grid, terrain, layers, pack, header)
518
+
519
+ # what the style draws under/above its goal (e.g. stairs in the bridge style);
520
+ # the part above is only used where that field is floor as well
521
+ if pack.goal_decor:
522
+ decor = pack.goal_decor.most_common(1)[0][0]
523
+ for r in range(grid_h):
524
+ for c in range(grid_w):
525
+ if grid[r][c] != "Z":
526
+ continue
527
+ # the stairs climb eastwards: mirrored when the hero comes in walking west
528
+ flags = 0x80000000 if exit_direction(grid, c, r) == "west" else 0
529
+ for dx, dy, source, local in decor:
530
+ x, y = c + dx, r + dy
531
+ if dy == 0 or (0 <= y < grid_h and terrain[y][x] == FLOOR):
532
+ layers["floor2"][(x, y)] = (source, local, flags) if flags else (source, local)
533
+
534
+ # objects
535
+ objects, cell_to_id = [], {}
536
+ numbers = header.get("hero", ["7"])[0].replace(",", " ").split() or ["7"]
537
+ numbers += EXTRA_HERO_SPRITES[len(numbers) - 1:]
538
+ number = numbers[0]
539
+ direction = header.get("start", ["south"])[0]
540
+ if direction not in DIRECTIONS:
541
+ raise MapError(f"start must be one of {', '.join(DIRECTIONS)}, not {direction!r}")
542
+ for r in range(grid_h):
543
+ for c in range(grid_w):
544
+ ch, context = grid[r][c], terrain[r][c]
545
+ name = ""
546
+ if ch == "H":
547
+ cls, name = "Character", "MainCharacter"
548
+ tile = pack.hero.get(f"{direction}_{number}")
549
+ if not tile:
550
+ raise MapError(f"no hero sprite {number!r} (hero: 0 to 15)")
551
+ elif ch in EXTRA_HEROES:
552
+ cls, name = "Character", f"Hero{ch}" # the engine finds co-op heroes by name
553
+ tile = pack.hero.get(f"{direction}_{numbers[int(ch) - 1]}")
554
+ if not tile:
555
+ raise MapError(f"no hero sprite {numbers[int(ch) - 1]!r} for hero {ch} (hero: 0 to 15)")
556
+ elif ch in pack.items:
557
+ source, local, cls = pack.items[ch] # the world's own item for this symbol
558
+ tile = (source, local)
559
+ elif ch in ("T", "t"):
560
+ cls, tile = "Torch", pack.object_tile("Torch", context)
561
+ if tile and ch == "t":
562
+ # the "off" tile of the same torch kind: the nearest "off" id in its tileset
563
+ off = pack.torch_off.get(tile[0])
564
+ if off:
565
+ tile = (tile[0], min(off, key=lambda tid: abs(tid - tile[1])))
566
+ elif ch in ("D", "G"):
567
+ # a door in a vertical wall line (walls above and below) blocks an east-west passage
568
+ solid = (WALL, "T", "t")
569
+ vertical = (0 < r < grid_h - 1 and grid[r - 1][c] in solid and grid[r + 1][c] in solid)
570
+ base = "Door" if ch == "D" else "Grille"
571
+ cls = "Vertical" + base if vertical else base
572
+ tile = pack.object_tile(cls, FLOOR) or pack.object_tile(base, FLOOR)
573
+ elif ch == "*":
574
+ cls, tile = "Sweets", pack.sweets
575
+ elif ch == "K":
576
+ cls, tile = "Crystal", item_tile("Crystal", "K")
577
+ elif ch == "o":
578
+ cls, tile = "Pebble", item_tile("Pebble", "o")
579
+ elif ch == "W":
580
+ cls, tile = "Guard", pack.hero.get(f"south_{pack.guard_sprite}")
581
+ elif ch == "Z" and exit_direction(grid, c, r) in pack.exits:
582
+ cls, tile = "Goal", pack.exits[exit_direction(grid, c, r)] # facing the hero's way in
583
+ elif ch in OBJECT_CLASSES:
584
+ cls = OBJECT_CLASSES[ch]
585
+ tile = pack.object_tile(cls, context)
586
+ elif ch in (WALL, FLOOR, VOID, ABYSS, PROP, PATH, DETAIL):
587
+ continue
588
+ else:
589
+ raise MapError(f"unknown symbol {ch!r} at ({c - ox},{r - oy})")
590
+ if not tile:
591
+ raise MapError(f"no tile known for {ch!r} at ({c - ox},{r - oy})")
592
+ objects.append({"cell": (c, r), "tile": tile, "name": name, "cls": cls, "symbol": ch})
593
+
594
+ crystals = [o for o in objects if o["symbol"] == "K"] # the items that carry a value
595
+ values = " ".join(header.get("values", [])).replace(",", " ").split()
596
+ colors = " ".join(header.get("colors", [])).replace(",", " ").split()
597
+ if crystals or values:
598
+ if len(values) != len(crystals):
599
+ raise MapError(f"values: {len(values)} value(s) for {len(crystals)} crystal(s) K")
600
+ try:
601
+ values = [int(v) for v in values]
602
+ except ValueError:
603
+ raise MapError(f"values: whole numbers expected, got {' '.join(values)!r}") from None
604
+ if colors:
605
+ if len(colors) != len(crystals) or set(colors) - set(CRYSTAL_ITEMS):
606
+ raise MapError(f"colors: one of {', '.join(CRYSTAL_ITEMS)} per crystal K")
607
+ for o, color in zip(crystals, colors):
608
+ o["tile"] = item_tile(CRYSTAL_ITEMS[color], "K")
609
+ for o, value in zip(crystals, values):
610
+ o["properties"] = [{"name": "value", "type": "int", "value": value}]
611
+
612
+ def tileset_info(source):
613
+ """What the style knows about a tileset; for an item tileset (not learned with the style)
614
+ the tile count from the simulator's rules, one field per tile, no classes (the object
615
+ gets its class as its type)."""
616
+ if source in pack.tilesets:
617
+ return pack.tilesets[source]
618
+ rules = _sim()._rules().get(source)
619
+ if rules is None:
620
+ raise MapError(f"{source}: no asset pack has this tileset")
621
+ return {"count": rules["count"], "tile_w": pack.tile_size, "tile_h": pack.tile_size, "classes": {}}
622
+
623
+ guard_specs = {}
624
+ for spec in header.get("guards", []):
625
+ parts = spec.replace(",", " ").split()
626
+ try:
627
+ x, y, behaviour = int(parts[0]), int(parts[1]), parts[2]
628
+ except (ValueError, IndexError):
629
+ raise MapError(f"guards: expected 'x,y patrol east' or 'x,y chase', got {spec!r}") from None
630
+ direction = parts[3] if len(parts) > 3 else "east"
631
+ if behaviour not in ("patrol", "chase") or direction not in DIRECTIONS:
632
+ raise MapError(f"guards {spec!r}: behaviour patrol or chase, direction one of {', '.join(DIRECTIONS)}")
633
+ guard_specs[(x + ox, y + oy)] = (behaviour, direction)
634
+ for o in objects:
635
+ if o["cls"] == "Guard":
636
+ behaviour, direction = guard_specs.pop(o["cell"], ("patrol", "east"))
637
+ o["properties"] = [{"name": "behaviour", "type": "string", "value": behaviour},
638
+ {"name": "direction", "type": "string", "value": direction}]
639
+ o["tile"] = pack.hero.get(f"{direction if behaviour == 'patrol' else 'south'}_{pack.guard_sprite}") or o["tile"]
640
+ if guard_specs:
641
+ x, y = next(iter(guard_specs))
642
+ raise MapError(f"guards: no guard W at ({x - ox},{y - oy})")
643
+
644
+ # tilesets: every source used, in a stable order
645
+ used = sorted({t[0] for layer in layers.values() for t in layer.values()} | {o["tile"][0] for o in objects})
646
+ firstgid, tilesets = {}, []
647
+ next_gid = 1
648
+ for source in used:
649
+ firstgid[source] = next_gid
650
+ tilesets.append({"firstgid": next_gid, "source": source})
651
+ next_gid += tileset_info(source)["count"]
652
+
653
+ def gid(tile):
654
+ """The global id; a third element are Tiled's flip flags (a mirrored exit)."""
655
+ return firstgid[tile[0]] + tile[1] + (tile[2] if len(tile) > 2 else 0)
656
+
657
+ def tile_layer(layer_id, name, cells, collision=False):
658
+ data = [gid(cells[(c, r)]) if (c, r) in cells else 0 for r in range(grid_h) for c in range(grid_w)]
659
+ layer = {"data": data, "height": grid_h, "id": layer_id, "name": name, "opacity": 1,
660
+ "type": "tilelayer", "visible": True, "width": grid_w, "x": 0, "y": 0}
661
+ if collision:
662
+ layer["properties"] = [{"name": "collision", "type": "bool", "value": True}]
663
+ return layer
664
+
665
+ tiled_objects = []
666
+ for i, o in enumerate(objects, start=1):
667
+ info = tileset_info(o["tile"][0])
668
+ c, r = o["cell"]
669
+ # the engine takes the class from the object's `type`, else from its tile;
670
+ # some tiles (e.g. goals) carry no class, so set it explicitly then
671
+ tile_cls = info["classes"].get(o["tile"][1], "")
672
+ obj_type = "" if tile_cls == o["cls"] or o["cls"] == "Character" else o["cls"]
673
+ # where the style draws its goal as decoration (e.g. stairs), the goal object is
674
+ # an invisible marker; the engine only needs its position
675
+ visible = not (o["cls"] == "Goal" and pack.goal_decor)
676
+ tiled_objects.append({"gid": gid(o["tile"]), "height": info["tile_h"], "id": i, "name": o["name"],
677
+ "rotation": 0, "type": obj_type, "visible": visible, "width": info["tile_w"],
678
+ "x": c * pack.tile_size, "y": (r + 1) * pack.tile_size})
679
+ if o.get("properties"):
680
+ tiled_objects[-1]["properties"] = o["properties"]
681
+ cell_to_id[(c - ox, r - oy)] = i
682
+ # pattern doors: "pattern: X,Y = 101 @ x1,y1 x2,y2 x3,y3" (door, bits, torches in bit order)
683
+ for spec in header.get("pattern", []):
684
+ try:
685
+ door_part, rest = spec.split("=", 1)
686
+ bits, torch_part = rest.split("@", 1)
687
+ door = tuple(int(v) for v in door_part.split(","))
688
+ torches = [tuple(int(v) for v in t.split(",")) for t in torch_part.split()]
689
+ bits = bits.strip()
690
+ except ValueError:
691
+ raise MapError(f"pattern: expected 'X,Y = 101 @ x1,y1 x2,y2 x3,y3', got {spec!r}") from None
692
+ if len(bits) != len(torches) or set(bits) - {"0", "1"}:
693
+ raise MapError(f"pattern {spec!r}: need one 0/1 per torch")
694
+ for cell in [door] + torches:
695
+ if cell not in cell_to_id:
696
+ raise MapError(f"pattern {spec!r}: no object at {cell}")
697
+ obj = tiled_objects[cell_to_id[door] - 1]
698
+ obj["type"] = "PatternDoor"
699
+ obj["properties"] = [{"name": "pattern", "type": "string", "value": bits},
700
+ {"name": "torches", "type": "string",
701
+ "value": ",".join(str(cell_to_id[t]) for t in torches)}]
702
+
703
+ for wiring in header.get("controls", []):
704
+ try:
705
+ src, dst = (tuple(int(v) for v in part.split(",")) for part in wiring.split("->"))
706
+ except ValueError:
707
+ raise MapError(f"controls: expected 'x,y -> x,y', got {wiring!r}") from None
708
+ if src not in cell_to_id or dst not in cell_to_id:
709
+ raise MapError(f"controls {wiring}: no object at {src if src not in cell_to_id else dst}")
710
+ obj = tiled_objects[cell_to_id[src] - 1]
711
+ obj["properties"] = [{"name": "controls", "type": "object", "value": cell_to_id[dst]}]
712
+
713
+ # variants: "region: s 2,2 17,13" - the objects s inside the rectangle may lie on any of its
714
+ # free fields (Game(path, seed=17), variants.py): a Region rectangle and a property per object
715
+ for n, spec in enumerate(header.get("region", []), start=1):
716
+ try:
717
+ symbol, a, b = spec.split()
718
+ (c0, r0), (c1, r1) = (tuple(int(v) for v in corner.split(",")) for corner in (a, b))
719
+ except ValueError:
720
+ raise MapError(f"region: expected 'SYMBOL x1,y1 x2,y2', got {spec!r}") from None
721
+ c0, c1, r0, r1 = min(c0, c1), max(c0, c1), min(r0, r1), max(r0, r1)
722
+ inside = [i for i, o in enumerate(objects) if o["symbol"] == symbol
723
+ and c0 <= o["cell"][0] - ox <= c1 and r0 <= o["cell"][1] - oy <= r1]
724
+ if not inside:
725
+ raise MapError(f"region {spec!r}: no {symbol!r} inside the rectangle")
726
+ name = f"region{n}"
727
+ for i in inside:
728
+ tiled_objects[i].setdefault("properties", []).append({"name": "region", "type": "string", "value": name})
729
+ tiled_objects.append({"height": (r1 - r0 + 1) * pack.tile_size, "id": len(tiled_objects) + 1, "name": name,
730
+ "rotation": 0, "type": "Region", "visible": False,
731
+ "width": (c1 - c0 + 1) * pack.tile_size,
732
+ "x": (c0 + ox) * pack.tile_size, "y": (r0 + oy) * pack.tile_size})
733
+
734
+ properties = [{"name": "world", "type": "string", "value": pack.world}] if pack.world else []
735
+ if pack.pack: # which asset pack the art needs: named in "missing tileset" (G5)
736
+ properties.append({"name": "pack", "type": "string", "value": pack.pack})
737
+ for key, values in header.items():
738
+ if key in HEADER_KEYS:
739
+ continue
740
+ value = values[0]
741
+ if value.lower() in ("true", "false"):
742
+ properties.append({"name": key, "type": "bool", "value": value.lower() == "true"})
743
+ elif value.lstrip("-").isdigit():
744
+ properties.append({"name": key, "type": "int", "value": int(value)})
745
+ else:
746
+ properties.append({"name": key, "type": "string", "value": value})
747
+
748
+ # what reaches from obstacles into other fields (tops, crowns), in as many layers as overlap there
749
+ deko_layers, n = [], 1
750
+ while layers.get(deko_layer(n)):
751
+ deko_layers.append(tile_layer(5 + n, "Deko" if n == 1 else f"Deko{n}", layers[deko_layer(n)]))
752
+ n += 1
753
+ level = {
754
+ "compressionlevel": -1, "height": grid_h, "width": grid_w, "infinite": False,
755
+ "layers": [
756
+ tile_layer(1, "Background", layers["bg"]),
757
+ tile_layer(2, "Floor", layers["floor"]),
758
+ tile_layer(3, "Floor2", layers["floor2"]),
759
+ tile_layer(4, "Walls", layers["wall"], collision=True),
760
+ ] + deko_layers + [
761
+ # what reaches from an obstacle into the field behind it: below the figures (a hero
762
+ # behind a tree stays visible), above the walls
763
+ {"draworder": "topdown", "id": 5, "name": "Objects", "objects": tiled_objects, "opacity": 1,
764
+ "type": "objectgroup", "visible": True, "x": 0, "y": 0},
765
+ ],
766
+ "nextlayerid": 6 + len(deko_layers), "nextobjectid": len(tiled_objects) + 1, "orientation": "orthogonal",
767
+ "renderorder": "right-down", "tiledversion": "1.11.2", "tileheight": pack.tile_size,
768
+ "tilewidth": pack.tile_size,
769
+ "tilesets": tilesets, "type": "map", "version": "1.10",
770
+ }
771
+ if properties:
772
+ level["properties"] = properties
773
+ view = header.get("view", ["full"])[0]
774
+ if view not in VIEWS:
775
+ raise MapError(f"view must be one of {', '.join(VIEWS)}, not {view!r}")
776
+ if view == "fit":
777
+ return fit_view(level, terrain)
778
+ return level, terrain
779
+
780
+
781
+ def fit_view(level, terrain):
782
+ """(level, terrain) cut to the map and one field around it (where the styles draw the
783
+ fronts and edges of the outer walls). The game's view is as large as the level, so
784
+ a smaller level has larger fields."""
785
+ width, height, tw, th = level["width"], level["height"], level["tilewidth"], level["tileheight"]
786
+ cells = {(c + dx, r + dy) for r, row in enumerate(terrain) for c, t in enumerate(row) if t != VOID
787
+ for dx in (-1, 0, 1) for dy in (-1, 0, 1)}
788
+ cells = {(c, r) for c, r in cells if 0 <= c < width and 0 <= r < height}
789
+ if not cells:
790
+ return level, terrain
791
+ x0, x1 = min(c for c, _ in cells), max(c for c, _ in cells)
792
+ y0, y1 = min(r for _, r in cells), max(r for _, r in cells)
793
+ new_width, new_height = x1 - x0 + 1, y1 - y0 + 1
794
+ for layer in level["layers"]:
795
+ if "data" in layer:
796
+ layer["data"] = [layer["data"][r * width + c] for r in range(y0, y1 + 1) for c in range(x0, x1 + 1)]
797
+ layer["width"], layer["height"] = new_width, new_height
798
+ for o in layer.get("objects", []):
799
+ o["x"] -= x0 * tw
800
+ o["y"] -= y0 * th
801
+ level["width"], level["height"] = new_width, new_height
802
+ return level, [row[x0:x1 + 1] for row in terrain[y0:y1 + 1]]
803
+
804
+
805
+ def level_from_text(text, style=None):
806
+ """The level (Tiled JSON dict) for the text of a map file. The style comes from
807
+ the argument, else from the header ("style: bridge"), else "dungeon".
808
+ A map with the header "generate: maze" is a recipe: the level is generated (generator.py),
809
+ a new one per load with "seed: random" (the seed is in the map property `seed`)."""
810
+ header, rows = parse_text(text)
811
+ if "generate" in header:
812
+ try:
813
+ from . import generator # noqa: PLC0415 - generator imports this module
814
+ except ImportError: # this file loaded on its own (tools/ascii2level.py)
815
+ raise MapError("a recipe (generate:) needs the dungeoncoder package") from None
816
+ text = generator.from_recipe(header)
817
+ header, rows = parse_text(text)
818
+ style = style or header.get("style", ["dungeon"])[0]
819
+ return build(header, rows, Pack.load(style))[0]
820
+
821
+
822
+ def level_from_file(path, style=None):
823
+ with open(path, encoding="utf-8") as f:
824
+ return level_from_text(f.read(), style)