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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +1430 -15
- data/exe/hytale +497 -0
- data/lib/hytale/client/assets.rb +207 -0
- data/lib/hytale/client/block_type.rb +169 -0
- data/lib/hytale/client/config.rb +98 -0
- data/lib/hytale/client/cosmetics.rb +95 -0
- data/lib/hytale/client/item_type.rb +248 -0
- data/lib/hytale/client/launcher_log/launcher_log_entry.rb +58 -0
- data/lib/hytale/client/launcher_log/launcher_log_session.rb +57 -0
- data/lib/hytale/client/launcher_log.rb +99 -0
- data/lib/hytale/client/locale.rb +234 -0
- data/lib/hytale/client/map/block.rb +135 -0
- data/lib/hytale/client/map/chunk.rb +695 -0
- data/lib/hytale/client/map/marker.rb +50 -0
- data/lib/hytale/client/map/region.rb +278 -0
- data/lib/hytale/client/map/renderer.rb +435 -0
- data/lib/hytale/client/map.rb +271 -0
- data/lib/hytale/client/memories.rb +59 -0
- data/lib/hytale/client/npc_memory.rb +39 -0
- data/lib/hytale/client/permissions.rb +52 -0
- data/lib/hytale/client/player/entity_stats.rb +46 -0
- data/lib/hytale/client/player/inventory.rb +54 -0
- data/lib/hytale/client/player/item.rb +102 -0
- data/lib/hytale/client/player/item_storage.rb +40 -0
- data/lib/hytale/client/player/player_memory.rb +29 -0
- data/lib/hytale/client/player/position.rb +12 -0
- data/lib/hytale/client/player/rotation.rb +11 -0
- data/lib/hytale/client/player/vector3.rb +12 -0
- data/lib/hytale/client/player.rb +101 -0
- data/lib/hytale/client/player_skin.rb +179 -0
- data/lib/hytale/client/prefab/palette_entry.rb +49 -0
- data/lib/hytale/client/prefab.rb +184 -0
- data/lib/hytale/client/process.rb +57 -0
- data/lib/hytale/client/save/backup.rb +43 -0
- data/lib/hytale/client/save/server_log.rb +42 -0
- data/lib/hytale/client/save.rb +157 -0
- data/lib/hytale/client/settings/audio_settings.rb +30 -0
- data/lib/hytale/client/settings/builder_tools_settings.rb +27 -0
- data/lib/hytale/client/settings/gameplay_settings.rb +23 -0
- data/lib/hytale/client/settings/input_bindings.rb +25 -0
- data/lib/hytale/client/settings/mouse_settings.rb +23 -0
- data/lib/hytale/client/settings/rendering_settings.rb +30 -0
- data/lib/hytale/client/settings.rb +74 -0
- data/lib/hytale/client/world/client_effects.rb +24 -0
- data/lib/hytale/client/world/death_settings.rb +22 -0
- data/lib/hytale/client/world.rb +88 -0
- data/lib/hytale/client/zone/region.rb +52 -0
- data/lib/hytale/client/zone.rb +68 -0
- data/lib/hytale/client.rb +142 -0
- data/lib/hytale/server/process.rb +11 -0
- data/lib/hytale/server.rb +6 -0
- data/lib/hytale/version.rb +1 -1
- data/lib/hytale.rb +37 -2
- 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
|