hytale 0.0.1 → 0.1.1

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -0
  3. data/README.md +1430 -15
  4. data/exe/hytale +497 -0
  5. data/lib/hytale/client/assets.rb +207 -0
  6. data/lib/hytale/client/block_type.rb +169 -0
  7. data/lib/hytale/client/config.rb +98 -0
  8. data/lib/hytale/client/cosmetics.rb +95 -0
  9. data/lib/hytale/client/item_type.rb +248 -0
  10. data/lib/hytale/client/launcher_log/launcher_log_entry.rb +58 -0
  11. data/lib/hytale/client/launcher_log/launcher_log_session.rb +57 -0
  12. data/lib/hytale/client/launcher_log.rb +99 -0
  13. data/lib/hytale/client/locale.rb +234 -0
  14. data/lib/hytale/client/map/block.rb +135 -0
  15. data/lib/hytale/client/map/chunk.rb +695 -0
  16. data/lib/hytale/client/map/marker.rb +50 -0
  17. data/lib/hytale/client/map/region.rb +278 -0
  18. data/lib/hytale/client/map/renderer.rb +435 -0
  19. data/lib/hytale/client/map.rb +271 -0
  20. data/lib/hytale/client/memories.rb +59 -0
  21. data/lib/hytale/client/npc_memory.rb +39 -0
  22. data/lib/hytale/client/permissions.rb +52 -0
  23. data/lib/hytale/client/player/entity_stats.rb +46 -0
  24. data/lib/hytale/client/player/inventory.rb +54 -0
  25. data/lib/hytale/client/player/item.rb +102 -0
  26. data/lib/hytale/client/player/item_storage.rb +40 -0
  27. data/lib/hytale/client/player/player_memory.rb +29 -0
  28. data/lib/hytale/client/player/position.rb +12 -0
  29. data/lib/hytale/client/player/rotation.rb +11 -0
  30. data/lib/hytale/client/player/vector3.rb +12 -0
  31. data/lib/hytale/client/player.rb +101 -0
  32. data/lib/hytale/client/player_skin.rb +179 -0
  33. data/lib/hytale/client/prefab/palette_entry.rb +49 -0
  34. data/lib/hytale/client/prefab.rb +184 -0
  35. data/lib/hytale/client/process.rb +57 -0
  36. data/lib/hytale/client/save/backup.rb +43 -0
  37. data/lib/hytale/client/save/server_log.rb +42 -0
  38. data/lib/hytale/client/save.rb +157 -0
  39. data/lib/hytale/client/settings/audio_settings.rb +30 -0
  40. data/lib/hytale/client/settings/builder_tools_settings.rb +27 -0
  41. data/lib/hytale/client/settings/gameplay_settings.rb +23 -0
  42. data/lib/hytale/client/settings/input_bindings.rb +25 -0
  43. data/lib/hytale/client/settings/mouse_settings.rb +23 -0
  44. data/lib/hytale/client/settings/rendering_settings.rb +30 -0
  45. data/lib/hytale/client/settings.rb +74 -0
  46. data/lib/hytale/client/world/client_effects.rb +24 -0
  47. data/lib/hytale/client/world/death_settings.rb +22 -0
  48. data/lib/hytale/client/world.rb +88 -0
  49. data/lib/hytale/client/zone/region.rb +52 -0
  50. data/lib/hytale/client/zone.rb +68 -0
  51. data/lib/hytale/client.rb +142 -0
  52. data/lib/hytale/server/process.rb +11 -0
  53. data/lib/hytale/server.rb +6 -0
  54. data/lib/hytale/version.rb +1 -1
  55. data/lib/hytale.rb +37 -2
  56. metadata +119 -10
@@ -0,0 +1,435 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hytale
4
+ module Client
5
+ class Map
6
+ # Renders map regions and chunks to PNG images
7
+ # Uses average colors from block textures
8
+ class Renderer
9
+ CHUNK_SIZE = 16
10
+ DEFAULT_SCALE = 1
11
+
12
+ attr_reader :color_cache
13
+
14
+ def initialize
15
+ @color_cache = {}
16
+ end
17
+
18
+ def block_color(block_type)
19
+ return color_cache[block_type] if color_cache.key?(block_type)
20
+
21
+ color = calculate_block_color(block_type)
22
+ color_cache[block_type] = color
23
+ color
24
+ end
25
+
26
+ # Render a chunk using actual block textures
27
+ # @param chunk [Chunk] The chunk to render
28
+ # @param texture_scale [Integer] Size of each block in pixels (default 16 = full texture)
29
+ # @param shading [Boolean] If true, apply height-based shading
30
+ # @return [ChunkyPNG::Image] The rendered image
31
+ def render_chunk_textured(chunk, texture_scale: 16, shading: true)
32
+ require "chunky_png"
33
+
34
+ width = CHUNK_SIZE * texture_scale
35
+ height = CHUNK_SIZE * texture_scale
36
+ png = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT)
37
+
38
+ chunk.surface_blocks.each_with_index do |row, block_z|
39
+ row.each_with_index do |block, block_x|
40
+ next unless block
41
+
42
+ texture = load_block_texture(block.id)
43
+ next unless texture
44
+
45
+ out_x = block_x * texture_scale
46
+ out_z = block_z * texture_scale
47
+
48
+ texture_scale.times do |tz|
49
+ texture_scale.times do |tx|
50
+ tex_x = (tx * texture.width / texture_scale) % texture.width
51
+ tex_z = (tz * texture.height / texture_scale) % texture.height
52
+
53
+ color = texture[tex_x, tex_z]
54
+
55
+ color = apply_height_shading(color, block.y, chunk.height) if shading
56
+
57
+ png[out_x + tx, out_z + tz] = color
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ png
64
+ end
65
+
66
+ # Render a single chunk to a PNG image
67
+ # @param chunk [Chunk] The chunk to render
68
+ # @param scale [Integer] Scale factor (1 = 16x16, 2 = 32x32, etc.)
69
+ # @param detailed [Boolean] If true, render each block individually using surface_at
70
+ # @param shading [Boolean] If true, apply height-based shading for depth visualization
71
+ def render_chunk(chunk, scale: DEFAULT_SCALE, detailed: false, shading: true)
72
+ require "chunky_png"
73
+
74
+ width = CHUNK_SIZE * scale
75
+ height = CHUNK_SIZE * scale
76
+ png = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT)
77
+
78
+ if detailed && chunk.height.positive?
79
+ CHUNK_SIZE.times do |block_z|
80
+ CHUNK_SIZE.times do |block_x|
81
+ surface = chunk.surface_at(block_x, block_z)
82
+ block_type = surface&.id || "Empty"
83
+ color = block_color(block_type) || default_color(block_type)
84
+
85
+ color = apply_height_shading(color, surface.y, chunk.height) if shading && surface
86
+
87
+ scale.times do |dz|
88
+ scale.times do |dx|
89
+ px = (block_x * scale) + dx
90
+ pz = (block_z * scale) + dz
91
+ png[px, pz] = color
92
+ end
93
+ end
94
+ end
95
+ end
96
+ else
97
+ block_types = chunk.block_types
98
+ surface_block = find_surface_block(block_types)
99
+ color = block_color(surface_block) || default_color(surface_block)
100
+
101
+ (0...height).each do |y|
102
+ (0...width).each do |x|
103
+ png[x, y] = color
104
+ end
105
+ end
106
+ end
107
+
108
+ png
109
+ end
110
+
111
+ # Render a region to a PNG image
112
+ # @param region [Region] The region to render
113
+ # @param scale [Integer] Scale factor
114
+ # @param detailed [Boolean] If true, render each block individually
115
+ # @param shading [Boolean] If true, apply height-based shading
116
+ def render_region(region, scale: DEFAULT_SCALE, detailed: false, shading: true)
117
+ require "chunky_png"
118
+
119
+ width = 32 * CHUNK_SIZE * scale
120
+ height = 32 * CHUNK_SIZE * scale
121
+ png = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT)
122
+
123
+ region.each_chunk do |chunk|
124
+ next unless chunk.local_x && chunk.local_z
125
+
126
+ chunk_x = chunk.local_x * CHUNK_SIZE * scale
127
+ chunk_z = chunk.local_z * CHUNK_SIZE * scale
128
+
129
+ if detailed && chunk.height.positive?
130
+ CHUNK_SIZE.times do |block_z|
131
+ CHUNK_SIZE.times do |block_x|
132
+ surface = chunk.surface_at(block_x, block_z)
133
+ block_type = surface&.id || "Empty"
134
+ color = block_color(block_type) || default_color(block_type)
135
+
136
+ color = apply_height_shading(color, surface.y, chunk.height) if shading && surface
137
+
138
+ scale.times do |dz|
139
+ scale.times do |dx|
140
+ px = chunk_x + (block_x * scale) + dx
141
+ pz = chunk_z + (block_z * scale) + dz
142
+
143
+ png[px, pz] = color if px < width && pz < height
144
+ end
145
+ end
146
+ end
147
+ end
148
+ else
149
+ surface_block = find_surface_block(chunk.block_types)
150
+ color = block_color(surface_block) || default_color(surface_block)
151
+
152
+ (0...(CHUNK_SIZE * scale)).each do |dz|
153
+ (0...(CHUNK_SIZE * scale)).each do |dx|
154
+ x = chunk_x + dx
155
+ z = chunk_z + dz
156
+ png[x, z] = color if x < width && z < height
157
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ png
163
+ end
164
+
165
+ # Render an entire map to a PNG image
166
+ # @param map [Map] The map to render
167
+ # @param scale [Integer] Scale factor
168
+ # @param detailed [Boolean] If true, render each block individually
169
+ # @param shading [Boolean] If true, apply height-based shading
170
+ def render_map(map, scale: DEFAULT_SCALE, detailed: false, shading: true)
171
+ require "chunky_png"
172
+
173
+ bounds = map.bounds
174
+ return nil unless bounds
175
+
176
+ region_size = 32 * CHUNK_SIZE * scale
177
+ width = bounds[:width] * region_size
178
+ height = bounds[:height] * region_size
179
+
180
+ png = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT)
181
+
182
+ map.regions.each do |region|
183
+ region_x = (region.x - bounds[:min_x]) * region_size
184
+ region_z = (region.z - bounds[:min_z]) * region_size
185
+
186
+ region.each_chunk do |chunk|
187
+ next unless chunk.local_x && chunk.local_z
188
+
189
+ chunk_x = region_x + (chunk.local_x * CHUNK_SIZE * scale)
190
+ chunk_z = region_z + (chunk.local_z * CHUNK_SIZE * scale)
191
+
192
+ if detailed && chunk.height.positive?
193
+ CHUNK_SIZE.times do |block_z|
194
+ CHUNK_SIZE.times do |block_x|
195
+ surface = chunk.surface_at(block_x, block_z)
196
+ block_type = surface&.id || "Empty"
197
+ color = block_color(block_type) || default_color(block_type)
198
+
199
+ color = apply_height_shading(color, surface.y, chunk.height) if shading && surface
200
+
201
+ scale.times do |dz|
202
+ scale.times do |dx|
203
+ px = chunk_x + (block_x * scale) + dx
204
+ pz = chunk_z + (block_z * scale) + dz
205
+
206
+ png[px, pz] = color if px < width && pz < height
207
+ end
208
+ end
209
+ end
210
+ end
211
+ else
212
+ surface_block = find_surface_block(chunk.block_types)
213
+ color = block_color(surface_block) || default_color(surface_block)
214
+
215
+ (0...(CHUNK_SIZE * scale)).each do |dz|
216
+ (0...(CHUNK_SIZE * scale)).each do |dx|
217
+ x = chunk_x + dx
218
+ z = chunk_z + dz
219
+ png[x, z] = color if x < width && z < height
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+
226
+ png
227
+ end
228
+
229
+ def save_chunk(chunk, path, scale: DEFAULT_SCALE, detailed: false, shading: true)
230
+ png = render_chunk(chunk, scale: scale, detailed: detailed, shading: shading)
231
+ png.save(path)
232
+ path
233
+ end
234
+
235
+ def save_region(region, path, scale: DEFAULT_SCALE, detailed: false, shading: true)
236
+ png = render_region(region, scale: scale, detailed: detailed, shading: shading)
237
+ png.save(path)
238
+ path
239
+ end
240
+
241
+ def save_map(map, path, scale: DEFAULT_SCALE, detailed: false, shading: true)
242
+ png = render_map(map, scale: scale, detailed: detailed, shading: shading)
243
+ return nil unless png
244
+
245
+ png.save(path)
246
+ path
247
+ end
248
+
249
+ private
250
+
251
+ # Apply height-based shading to give a sense of depth/elevation
252
+ # Higher blocks are brighter, lower blocks are darker
253
+ # @param color [Integer] ChunkyPNG color value
254
+ # @param y [Integer] Block Y coordinate (height)
255
+ # @param max_height [Integer] Maximum height in the chunk
256
+ # @return [Integer] Shaded color
257
+ def apply_height_shading(color, y, _max_height)
258
+ require "chunky_png"
259
+
260
+ # Normalize height to 0.0-1.0 range
261
+ # Use a reasonable world height range (0-400) for normalization
262
+ world_height = 400
263
+ normalized = (y.to_f / world_height).clamp(0.0, 1.0)
264
+
265
+ # Apply brightness modifier: 0.6 (dark/low) to 1.1 (bright/high)
266
+ brightness = 0.6 + (normalized * 0.5)
267
+
268
+ r = ChunkyPNG::Color.r(color)
269
+ g = ChunkyPNG::Color.g(color)
270
+ b = ChunkyPNG::Color.b(color)
271
+ a = ChunkyPNG::Color.a(color)
272
+
273
+ # Apply brightness and clamp to valid range
274
+ r = (r * brightness).round.clamp(0, 255)
275
+ g = (g * brightness).round.clamp(0, 255)
276
+ b = (b * brightness).round.clamp(0, 255)
277
+
278
+ ChunkyPNG::Color.rgba(r, g, b, a)
279
+ end
280
+
281
+ def calculate_block_color(block_type)
282
+ texture_path = find_texture_path(block_type)
283
+ return nil unless texture_path
284
+
285
+ average_color_from_texture(texture_path)
286
+ rescue StandardError
287
+ nil
288
+ end
289
+
290
+ def load_block_texture(block_type)
291
+ @texture_cache ||= {}
292
+ return @texture_cache[block_type] if @texture_cache.key?(block_type)
293
+
294
+ texture_path = find_texture_path(block_type)
295
+ return @texture_cache[block_type] = nil unless texture_path
296
+
297
+ @texture_cache[block_type] = ChunkyPNG::Image.from_file(texture_path)
298
+ rescue StandardError
299
+ @texture_cache[block_type] = nil
300
+ end
301
+
302
+ def find_texture_path(block_type)
303
+ block = BlockType.new(block_type)
304
+ return block.texture_path if block.texture_exists?
305
+
306
+ base_name = block_type
307
+ variations = [
308
+ base_name,
309
+ "#{base_name}_Sunny",
310
+ "#{base_name}_Deep",
311
+ "#{base_name}_Top",
312
+ "#{base_name}_Side",
313
+ ]
314
+
315
+ variations.each do |name|
316
+ path = Assets.block_texture_path(name)
317
+ return path if File.exist?(path)
318
+ end
319
+
320
+ # Try stripping common suffixes to find base texture
321
+ # e.g., Soil_Grass_Full -> Soil_Grass, Plant_Grass_Sharp_Tall -> Plant_Grass_Sharp
322
+ suffixes_to_strip = ["_Full", "_Short", "_Tall", "_Small", "_Large", "_Medium", "_Wild", "_Stack"]
323
+
324
+ suffixes_to_strip.each do |suffix|
325
+ next unless base_name.end_with?(suffix)
326
+
327
+ stripped = base_name.chomp(suffix)
328
+ stripped_variations = [stripped, "#{stripped}_Sunny", "#{stripped}_Deep"]
329
+ stripped_variations.each do |name|
330
+ path = Assets.block_texture_path(name)
331
+ return path if File.exist?(path)
332
+ end
333
+ end
334
+
335
+ matching_textures = Assets.block_textures.select { |t| t.start_with?(base_name) }
336
+ if matching_textures.any?
337
+ colored = matching_textures.reject { |t| t.end_with?("_GS") }
338
+ colored = matching_textures if colored.empty?
339
+
340
+ preferred = colored.find { |t| t =~ /_Sunny$|_Deep$|_Top$/ }
341
+ texture_name = preferred || colored.first
342
+
343
+ return Assets.block_texture_path(texture_name)
344
+ end
345
+
346
+ nil
347
+ end
348
+
349
+ def average_color_from_texture(texture_path)
350
+ require "chunky_png"
351
+
352
+ png = ChunkyPNG::Image.from_file(texture_path)
353
+
354
+ total_r = 0
355
+ total_g = 0
356
+ total_b = 0
357
+ total_a = 0
358
+ count = 0
359
+
360
+ png.height.times do |y|
361
+ png.width.times do |x|
362
+ pixel = png[x, y]
363
+ alpha = ChunkyPNG::Color.a(pixel)
364
+
365
+ next if alpha < 128
366
+
367
+ total_r += ChunkyPNG::Color.r(pixel)
368
+ total_g += ChunkyPNG::Color.g(pixel)
369
+ total_b += ChunkyPNG::Color.b(pixel)
370
+ total_a += alpha
371
+
372
+ count += 1
373
+ end
374
+ end
375
+
376
+ return nil if count.zero?
377
+
378
+ avg_r = (total_r / count).clamp(0, 255)
379
+ avg_g = (total_g / count).clamp(0, 255)
380
+ avg_b = (total_b / count).clamp(0, 255)
381
+ avg_a = (total_a / count).clamp(0, 255)
382
+
383
+ ChunkyPNG::Color.rgba(avg_r, avg_g, avg_b, avg_a)
384
+ end
385
+
386
+ def find_surface_block(block_types)
387
+ priorities = [
388
+ /Grass/,
389
+ /Water/,
390
+ /Sand/,
391
+ /Snow/,
392
+ /Ice/,
393
+ /Dirt/,
394
+ /Stone/,
395
+ /Rock/,
396
+ ]
397
+
398
+ priorities.each do |pattern|
399
+ match = block_types.find { |t| t =~ pattern }
400
+ return match if match
401
+ end
402
+
403
+ block_types.find { |t| t !~ /^Env_|Empty/ } || block_types.first
404
+ end
405
+
406
+ def default_color(block_type)
407
+ require "chunky_png"
408
+
409
+ case block_type
410
+ when /Grass/
411
+ ChunkyPNG::Color.rgb(86, 125, 70)
412
+ when /Water/
413
+ ChunkyPNG::Color.rgba(64, 164, 223, 200)
414
+ when /Sand/
415
+ ChunkyPNG::Color.rgb(194, 178, 128)
416
+ when /Snow/, /Ice/
417
+ ChunkyPNG::Color.rgb(240, 240, 255)
418
+ when /Dirt/, /Wood/
419
+ ChunkyPNG::Color.rgb(139, 90, 43)
420
+ when /Stone/, /Rock/
421
+ ChunkyPNG::Color.rgb(128, 128, 128)
422
+ when /Plant/
423
+ ChunkyPNG::Color.rgb(34, 139, 34)
424
+ when /Ore/
425
+ ChunkyPNG::Color.rgb(70, 70, 80)
426
+ when nil, /Empty/
427
+ ChunkyPNG::Color::TRANSPARENT
428
+ else
429
+ ChunkyPNG::Color.rgb(100, 100, 100)
430
+ end
431
+ end
432
+ end
433
+ end
434
+ end
435
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Hytale
7
+ module Client
8
+ class Map
9
+ REGION_SIZE = 512 # Blocks per region (32 chunks × 16 blocks)
10
+ CHUNKS_PER_REGION = 32 # Chunks per region in each dimension
11
+ CHUNK_SIZE = 16 # Blocks per chunk in each dimension
12
+
13
+ attr_reader :world_path, :world_name
14
+
15
+ def initialize(world_path, world_name: "default")
16
+ @world_path = world_path
17
+ @world_name = world_name
18
+ end
19
+
20
+ def chunks_path
21
+ File.join(world_path, "universe", "worlds", world_name, "chunks")
22
+ end
23
+
24
+ def resources_path
25
+ File.join(world_path, "universe", "worlds", world_name, "resources")
26
+ end
27
+
28
+ def regions
29
+ return [] unless File.directory?(chunks_path)
30
+
31
+ Dir.glob(File.join(chunks_path, "*.region.bin")).map do |path|
32
+ Region.new(path)
33
+ end
34
+ end
35
+
36
+ def region_at(x, z)
37
+ regions.find { |r| r.x == x && r.z == z }
38
+ end
39
+
40
+ # Get the region containing the given world coordinates
41
+ # @param x [Integer] World X coordinate
42
+ # @param z [Integer] World Z coordinate
43
+ # @return [Region, nil] The region or nil if not loaded
44
+ def region_at_world(x, z)
45
+ region_x, region_z = world_to_region_coords(x, z)
46
+ region_at(region_x, region_z)
47
+ end
48
+
49
+ # Get the chunk containing the given world coordinates
50
+ # @param x [Integer] World X coordinate
51
+ # @param z [Integer] World Z coordinate
52
+ # @return [Chunk, nil] The chunk or nil if not loaded
53
+ def chunk_at(x, z)
54
+ region = region_at_world(x, z)
55
+ return nil unless region
56
+
57
+ local_x, local_z = world_to_chunk_local_coords(x, z)
58
+ region.chunk_data(local_x, local_z)
59
+ end
60
+
61
+ # Get the block at the given world coordinates
62
+ # @param x [Integer] World X coordinate
63
+ # @param y [Integer] World Y coordinate (height)
64
+ # @param z [Integer] World Z coordinate
65
+ # @return [Block, nil] The block or nil if not loaded
66
+ def block_at(x, y, z)
67
+ chunk = chunk_at(x, z)
68
+ return nil unless chunk
69
+
70
+ block_x, block_z = world_to_block_local_coords(x, z)
71
+
72
+ chunk.block_at(block_x, y, block_z)
73
+ end
74
+
75
+ # Convert world coordinates to region coordinates
76
+ # Region 0 covers 0..511, region -1 covers -512..-1, etc.
77
+ # @return [Array<Integer>] [region_x, region_z]
78
+ def world_to_region_coords(x, z)
79
+ region_x = floor_div(x, REGION_SIZE)
80
+ region_z = floor_div(z, REGION_SIZE)
81
+
82
+ [region_x, region_z]
83
+ end
84
+
85
+ # Convert world coordinates to chunk-local coordinates within a region
86
+ # @return [Array<Integer>] [chunk_local_x, chunk_local_z] (0-31)
87
+ def world_to_chunk_local_coords(x, z)
88
+ region_local_x = ((x % REGION_SIZE) + REGION_SIZE) % REGION_SIZE
89
+ region_local_z = ((z % REGION_SIZE) + REGION_SIZE) % REGION_SIZE
90
+
91
+ chunk_local_x = region_local_x / CHUNK_SIZE
92
+ chunk_local_z = region_local_z / CHUNK_SIZE
93
+
94
+ [chunk_local_x, chunk_local_z]
95
+ end
96
+
97
+ # Convert world coordinates to block-local coordinates within a chunk
98
+ # @return [Array<Integer>] [block_local_x, block_local_z] (0-15)
99
+ def world_to_block_local_coords(x, z)
100
+ block_local_x = ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE
101
+ block_local_z = ((z % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE
102
+
103
+ [block_local_x, block_local_z]
104
+ end
105
+
106
+ private
107
+
108
+ def floor_div(a, b)
109
+ (a.to_f / b).floor
110
+ end
111
+
112
+ public
113
+
114
+ def bounds
115
+ return nil if regions.empty?
116
+
117
+ xs = regions.map(&:x)
118
+ zs = regions.map(&:z)
119
+
120
+ {
121
+ min_x: xs.min,
122
+ max_x: xs.max,
123
+ min_z: zs.min,
124
+ max_z: zs.max,
125
+ width: xs.max - xs.min + 1,
126
+ height: zs.max - zs.min + 1,
127
+ }
128
+ end
129
+
130
+ def markers
131
+ markers_path = File.join(resources_path, "BlockMapMarkers.json")
132
+ return [] unless File.exist?(markers_path)
133
+
134
+ data = JSON.parse(File.read(markers_path))
135
+
136
+ (data["Markers"] || {}).map do |id, marker_data|
137
+ Marker.new(marker_data, id: id)
138
+ end
139
+ end
140
+
141
+ def time
142
+ time_path = File.join(resources_path, "Time.json")
143
+ return nil unless File.exist?(time_path)
144
+
145
+ data = JSON.parse(File.read(time_path))
146
+
147
+ data["Now"]
148
+ end
149
+
150
+ def total_size
151
+ regions.sum(&:size)
152
+ end
153
+
154
+ def total_size_mb
155
+ (total_size / 1024.0 / 1024.0).round(2)
156
+ end
157
+
158
+ def block_types
159
+ types = Set.new
160
+
161
+ regions.each do |region|
162
+ types.merge(region.block_types)
163
+ rescue StandardError
164
+ # Skip regions that fail to parse
165
+ end
166
+
167
+ types.to_a.sort
168
+ end
169
+
170
+ # Render map to PNG file
171
+ # @param path [String, nil] Output file path (nil = use cache path)
172
+ # @param scale [Integer] Scale factor (1 = 1 pixel per block)
173
+ # @param detailed [Boolean] If true, render each block individually (slower but accurate)
174
+ # @param shading [Boolean] If true, apply height-based shading for depth visualization
175
+ # @param cache [Boolean] If true, use cached image if available
176
+ def render_to_png(path = nil, scale: 1, detailed: false, shading: true, cache: true)
177
+ path ||= cache_path(scale: scale, detailed: detailed, shading: shading)
178
+
179
+ return path if cache && File.exist?(path)
180
+
181
+ FileUtils.mkdir_p(File.dirname(path))
182
+
183
+ renderer = Renderer.new
184
+ renderer.save_map(self, path, scale: scale, detailed: detailed, shading: shading)
185
+ end
186
+
187
+ def cache_path(scale: 1, detailed: false, shading: true)
188
+ save_name = world_path.split("Saves/")[1]&.split("/")&.first || "unknown"
189
+
190
+ cache_dir = File.join(
191
+ Dir.tmpdir,
192
+ "hytale_cache",
193
+ save_name,
194
+ world_name
195
+ )
196
+
197
+ mode = detailed ? "detailed" : "fast"
198
+ shading_suffix = shading ? "" : "_noshade"
199
+ filename = "map_#{scale}x_#{mode}#{shading_suffix}.png"
200
+
201
+ File.join(cache_dir, filename)
202
+ end
203
+
204
+ def cached?(scale: 1, detailed: false, shading: true)
205
+ File.exist?(cache_path(scale: scale, detailed: detailed, shading: shading))
206
+ end
207
+
208
+ def clear_cache!
209
+ cache_dir = File.dirname(cache_path)
210
+ FileUtils.rm_rf(cache_dir) if File.directory?(cache_dir)
211
+ end
212
+
213
+ def to_ascii(players: [])
214
+ return "No regions explored" if regions.empty?
215
+
216
+ b = bounds
217
+ lines = []
218
+
219
+ header = " "
220
+
221
+ (b[:min_x]..b[:max_x]).each { |x| header += x.to_s.center(3) }
222
+ lines << header
223
+ lines << " #{"-" * (((b[:max_x] - b[:min_x] + 1) * 3) + 2)}"
224
+
225
+ player_regions = players.map do |p|
226
+ pos = p.position
227
+ region_x = (pos.x / 512.0).floor
228
+ region_z = (pos.z / 512.0).floor
229
+
230
+ [region_x, region_z, p.name[0].upcase]
231
+ end
232
+
233
+ (b[:min_z]..b[:max_z]).each do |z|
234
+ row = "#{z.to_s.rjust(3)} |"
235
+
236
+ (b[:min_x]..b[:max_x]).each do |x|
237
+ region = region_at(x, z)
238
+ player_here = player_regions.find { |px, pz, _| px == x && pz == z }
239
+
240
+ cell = if player_here
241
+ " #{player_here[2]} "
242
+ elsif region
243
+ size_indicator = case region.size
244
+ when 0..1_000_000 then " . "
245
+ when 1_000_001..10_000_000 then " o "
246
+ when 10_000_001..20_000_000 then " O "
247
+ else " # "
248
+ end
249
+
250
+ size_indicator
251
+ else
252
+ " "
253
+ end
254
+
255
+ row += cell
256
+ end
257
+
258
+ row += "|"
259
+
260
+ lines << row
261
+ end
262
+
263
+ lines << " #{"-" * (((b[:max_x] - b[:min_x] + 1) * 3) + 2)}"
264
+ lines << ""
265
+ lines << "Legend: . = small, o = medium, O = large, # = huge, Letter = player"
266
+
267
+ lines.join("\n")
268
+ end
269
+ end
270
+ end
271
+ end