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.
- dungeoncoder/__init__.py +4 -0
- dungeoncoder/__main__.py +254 -0
- dungeoncoder/_api_config.py +6 -0
- dungeoncoder/_generated/__init__.py +8 -0
- dungeoncoder/_generated/api/__init__.py +1 -0
- dungeoncoder/_generated/api/game/__init__.py +1 -0
- dungeoncoder/_generated/api/game/get_statistics.py +158 -0
- dungeoncoder/_generated/api/game/get_version.py +166 -0
- dungeoncoder/_generated/api/hero/__init__.py +1 -0
- dungeoncoder/_generated/api/hero/ask_oracle.py +192 -0
- dungeoncoder/_generated/api/hero/configure.py +207 -0
- dungeoncoder/_generated/api/hero/drop.py +207 -0
- dungeoncoder/_generated/api/hero/get_inventory.py +188 -0
- dungeoncoder/_generated/api/hero/get_items_at_position.py +188 -0
- dungeoncoder/_generated/api/hero/interact.py +188 -0
- dungeoncoder/_generated/api/hero/is_abyss_in_front.py +188 -0
- dungeoncoder/_generated/api/hero/is_at_goal.py +192 -0
- dungeoncoder/_generated/api/hero/is_collision_in_front.py +188 -0
- dungeoncoder/_generated/api/hero/is_enemy_in_front.py +188 -0
- dungeoncoder/_generated/api/hero/is_facing_north.py +188 -0
- dungeoncoder/_generated/api/hero/is_switch_in_front.py +188 -0
- dungeoncoder/_generated/api/hero/is_torch_in_front.py +188 -0
- dungeoncoder/_generated/api/hero/move.py +188 -0
- dungeoncoder/_generated/api/hero/peek_item_value.py +211 -0
- dungeoncoder/_generated/api/hero/pickup.py +207 -0
- dungeoncoder/_generated/api/hero/read_item_value.py +188 -0
- dungeoncoder/_generated/api/hero/sense_goal.py +192 -0
- dungeoncoder/_generated/api/hero/set_pace.py +207 -0
- dungeoncoder/_generated/api/hero/turn_left.py +188 -0
- dungeoncoder/_generated/api/level/__init__.py +1 -0
- dungeoncoder/_generated/api/level/load_level.py +190 -0
- dungeoncoder/_generated/api/level/reset_level.py +158 -0
- dungeoncoder/_generated/client.py +271 -0
- dungeoncoder/_generated/errors.py +14 -0
- dungeoncoder/_generated/models/__init__.py +47 -0
- dungeoncoder/_generated/models/api_error_response.py +98 -0
- dungeoncoder/_generated/models/api_error_response_status.py +7 -0
- dungeoncoder/_generated/models/boolean_result.py +96 -0
- dungeoncoder/_generated/models/boolean_result_status.py +7 -0
- dungeoncoder/_generated/models/configure_params.py +84 -0
- dungeoncoder/_generated/models/distance_param.py +76 -0
- dungeoncoder/_generated/models/name_param.py +76 -0
- dungeoncoder/_generated/models/nullable_integer_result.py +104 -0
- dungeoncoder/_generated/models/nullable_integer_result_status.py +7 -0
- dungeoncoder/_generated/models/nullable_string_result.py +104 -0
- dungeoncoder/_generated/models/nullable_string_result_status.py +7 -0
- dungeoncoder/_generated/models/pace_params.py +76 -0
- dungeoncoder/_generated/models/statistics.py +217 -0
- dungeoncoder/_generated/models/statistics_result.py +104 -0
- dungeoncoder/_generated/models/statistics_result_status.py +7 -0
- dungeoncoder/_generated/models/string_array_result.py +100 -0
- dungeoncoder/_generated/models/string_array_result_status.py +7 -0
- dungeoncoder/_generated/models/tiled_level.py +67 -0
- dungeoncoder/_generated/models/version_result.py +104 -0
- dungeoncoder/_generated/models/version_result_result.py +84 -0
- dungeoncoder/_generated/models/version_result_status.py +7 -0
- dungeoncoder/_generated/types.py +53 -0
- dungeoncoder/asciimap.py +824 -0
- dungeoncoder/dungeoncoder.py +482 -0
- dungeoncoder/generator.py +213 -0
- dungeoncoder/packs/gelaende.json +1 -0
- dungeoncoder/packs/items.json +67 -0
- dungeoncoder/packs/station.json +1 -0
- dungeoncoder/packs/studio.json +1 -0
- dungeoncoder/packs/tilesets.json +1 -0
- dungeoncoder/packs/werkstatt.json +1 -0
- dungeoncoder/replay.py +80 -0
- dungeoncoder/requirements.txt +2 -0
- dungeoncoder/sim.py +792 -0
- dungeoncoder/testing.py +63 -0
- dungeoncoder/trace.py +92 -0
- dungeoncoder/variants.py +112 -0
- dungeoncoder-0.2.0.dist-info/METADATA +37 -0
- dungeoncoder-0.2.0.dist-info/RECORD +77 -0
- dungeoncoder-0.2.0.dist-info/WHEEL +5 -0
- dungeoncoder-0.2.0.dist-info/licenses/LICENSE.txt +25 -0
- dungeoncoder-0.2.0.dist-info/top_level.txt +1 -0
dungeoncoder/asciimap.py
ADDED
|
@@ -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)
|