Coverage for src/glomph/loaders.py: 89%

135 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-05 21:51 +0800

1"""Asset loaders for mazes, sprites, and tiles.""" 

2 

3from pathlib import Path 

4from typing import NamedTuple 

5 

6 

7class MazeData(NamedTuple): 

8 """Maze file data.""" 

9 

10 width: int 

11 height: int 

12 flags: int 

13 content: str 

14 metadata: dict[str, str] 

15 

16 

17class TileData(NamedTuple): 

18 """Tile file data.""" 

19 

20 width: int 

21 height: int 

22 flags: int 

23 tiles: dict[str, list[str]] 

24 metadata: dict[str, str] 

25 

26 

27class SpriteData(NamedTuple): 

28 """Sprite file data.""" 

29 

30 width: int 

31 height: int 

32 flags: int 

33 frames: dict[str, list[str]] 

34 metadata: dict[str, str] 

35 

36 

37class MazeLoader: 

38 """Load maze files from assets/mazes/.""" 

39 

40 def __init__(self, assets_dir: Path) -> None: 

41 """Initialize maze loader.""" 

42 self.mazes_dir = assets_dir / "mazes" 

43 

44 def load(self, name: str) -> MazeData: 

45 """Load a maze file by name.""" 

46 path = self.mazes_dir / f"{name}.txt" 

47 if not path.exists(): 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true

48 path = self.mazes_dir / f"{name}.asc" 

49 

50 if not path.exists(): 50 ↛ 51line 50 didn't jump to line 51 because the condition on line 50 was never true

51 raise FileNotFoundError(f"Maze not found: {name}") 

52 

53 with open(path, encoding="utf-8") as f: 

54 lines = f.readlines() 

55 

56 # Parse first line: "N WxH" or "N WxH~F arg1=val1 ..." 

57 header = lines[0].strip().split() 

58 dimensions = header[1].split("x") 

59 width = int(dimensions[0]) 

60 

61 # Height and flags 

62 height_and_flags = dimensions[1].split("~") 

63 height = int(height_and_flags[0]) 

64 flags = int(height_and_flags[1]) if len(height_and_flags) > 1 else 0 

65 

66 # Parse metadata arguments 

67 metadata: dict[str, str] = {} 

68 if len(header) > 2: 68 ↛ 75line 68 didn't jump to line 75 because the condition on line 68 was always true

69 for arg in header[2:]: 

70 if "=" in arg: 

71 key, value = arg.split("=", 1) 

72 metadata[key] = value 

73 

74 # Content is everything after first line 

75 content = "".join(lines[1:]) 

76 

77 return MazeData(width, height, flags, content, metadata) 

78 

79 def list_mazes(self) -> list[str]: 

80 """List all available maze names.""" 

81 mazes = [] 

82 for path in self.mazes_dir.glob("*.txt"): 

83 mazes.append(path.stem) 

84 for path in self.mazes_dir.glob("*.asc"): 

85 if path.stem not in mazes: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true

86 mazes.append(path.stem) 

87 return sorted(mazes) 

88 

89 

90class TileLoader: 

91 """Load tile files from assets/tiles/.""" 

92 

93 def __init__(self, assets_dir: Path) -> None: 

94 """Initialize tile loader.""" 

95 self.tiles_dir = assets_dir / "tiles" 

96 

97 def load(self, name: str) -> TileData: 

98 """Load a tile file by name.""" 

99 path = self.tiles_dir / f"{name}.txt" 

100 if not path.exists(): 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true

101 path = self.tiles_dir / f"{name}.asc" 

102 

103 if not path.exists(): 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true

104 raise FileNotFoundError(f"Tile file not found: {name}") 

105 

106 with open(path, encoding="utf-8-sig") as f: 

107 content = f.read() 

108 

109 # Parse header: "WxH" or "WxH~F arg1=val1 ..." 

110 lines = content.split("\n") 

111 header = lines[0].strip().split() 

112 dimensions = header[0].split("x") 

113 width = int(dimensions[0]) 

114 

115 height_and_flags = dimensions[1].split("~") 

116 height = int(height_and_flags[0]) 

117 flags = int(height_and_flags[1]) if len(height_and_flags) > 1 else 0 

118 

119 # Parse metadata 

120 metadata: dict[str, str] = {} 

121 if len(header) > 1: 121 ↛ 128line 121 didn't jump to line 128 because the condition on line 121 was always true

122 for arg in header[1:]: 

123 if "=" in arg: 

124 key, value = arg.split("=", 1) 

125 metadata[key] = value 

126 

127 # Parse tiles 

128 tiles: dict[str, list[str]] = {} 

129 i = 1 

130 while i < len(lines): 

131 line = lines[i].strip() 

132 if not line: 

133 i += 1 

134 continue 

135 

136 # Tile header: "XX~Y" or "XX" 

137 tile_code = line.split("~")[0] 

138 

139 # Read tile lines 

140 tile_lines = [] 

141 for j in range(height): 

142 if i + 1 + j < len(lines): 142 ↛ 141line 142 didn't jump to line 141 because the condition on line 142 was always true

143 tile_line = lines[i + 1 + j] 

144 if tile_line.startswith(":"): 144 ↛ 141line 144 didn't jump to line 141 because the condition on line 144 was always true

145 tile_lines.append(tile_line[1 : width + 1]) 

146 

147 tiles[tile_code] = tile_lines 

148 i += height + 1 

149 

150 return TileData(width, height, flags, tiles, metadata) 

151 

152 

153class SpriteLoader: 

154 """Load sprite files from assets/sprites/.""" 

155 

156 def __init__(self, assets_dir: Path) -> None: 

157 """Initialize sprite loader.""" 

158 self.sprites_dir = assets_dir / "sprites" 

159 

160 def load(self, name: str) -> SpriteData: 

161 """Load a sprite file by name.""" 

162 path = self.sprites_dir / f"{name}.txt" 

163 if not path.exists(): 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 path = self.sprites_dir / f"{name}.asc" 

165 

166 if not path.exists(): 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true

167 raise FileNotFoundError(f"Sprite file not found: {name}") 

168 

169 with open(path, encoding="utf-8-sig") as f: 

170 content = f.read() 

171 

172 # Parse similar to tiles 

173 lines = content.split("\n") 

174 header = lines[0].strip().split() 

175 dimensions = header[0].split("x") 

176 width = int(dimensions[0]) 

177 

178 height_and_flags = dimensions[1].split("~") 

179 height = int(height_and_flags[0]) 

180 flags = int(height_and_flags[1]) if len(height_and_flags) > 1 else 0 

181 

182 metadata: dict[str, str] = {} 

183 if len(header) > 1: 183 ↛ 190line 183 didn't jump to line 190 because the condition on line 183 was always true

184 for arg in header[1:]: 

185 if "=" in arg: 

186 key, value = arg.split("=", 1) 

187 metadata[key] = value 

188 

189 # Parse sprite frames (same format as tiles) 

190 frames: dict[str, list[str]] = {} 

191 i = 1 

192 while i < len(lines): 

193 line = lines[i].strip() 

194 if not line: 

195 i += 1 

196 continue 

197 

198 frame_code = line.split("~")[0] 

199 frame_lines = [] 

200 for j in range(height): 

201 if i + 1 + j < len(lines): 201 ↛ 200line 201 didn't jump to line 200 because the condition on line 201 was always true

202 frame_line = lines[i + 1 + j] 

203 if frame_line.startswith(":"): 203 ↛ 200line 203 didn't jump to line 200 because the condition on line 203 was always true

204 frame_lines.append(frame_line[1 : width + 1]) 

205 

206 frames[frame_code] = frame_lines 

207 i += height + 1 

208 

209 return SpriteData(width, height, flags, frames, metadata)