mirror of
https://github.com/cyberarm/w3d_hub_linux_launcher.git
synced 2026-09-20 15:23:53 +00:00
Compare commits
32 Commits
9ad43f61b6
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 53e7a72ceb | |||
| 0a1909a84c | |||
| b7739de136 | |||
| 17614f4416 | |||
| f8ceb69402 | |||
| 6707f260c9 | |||
| 56699bb644 | |||
| 09ef9d613f | |||
| b505f22b8c | |||
| b551312751 | |||
| 1d3164da72 | |||
| 73a9a80a34 | |||
| 7adb23d71f | |||
| 4ba71b760a | |||
| db0dd9fc7c | |||
| 2cf8146553 | |||
| af438e35bc | |||
| 2f07cde109 | |||
| 5092c828a7 | |||
| 21e0888f8e | |||
| 132d39d3a4 | |||
| 7a5667a53a | |||
| 79dfa963c2 | |||
| b0fefb9f7f | |||
| 0693733179 | |||
| b52f6e2183 | |||
| 915e643a98 | |||
| 6823f9bade | |||
| 8c14ecffc1 | |||
| a7ae460446 | |||
| 969a82a262 | |||
| efc9c2f150 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
pkg/
|
||||
*.json
|
||||
*.xml
|
||||
data/cache/*
|
||||
!data/cache/.gitkeep
|
||||
data/logs/*
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -8,7 +8,8 @@ gem "json"
|
||||
|
||||
# "game" library gem
|
||||
gem "cyberarm_engine"
|
||||
gem "sdl2-bindings"
|
||||
gem "sdl3-bindings"
|
||||
gem "gosu", '1.5.0' # temporary, manually built, stand-in for SDL3 supporting gosu
|
||||
|
||||
# networking libs
|
||||
gem "async"
|
||||
@@ -19,3 +20,4 @@ gem "async-websocket"
|
||||
gem "digest-crc"
|
||||
gem "ircparser"
|
||||
gem "rubyzip"
|
||||
gem "chunky_png"
|
||||
|
||||
112
Gemfile.lock
112
Gemfile.lock
@@ -1,24 +1,20 @@
|
||||
GEM
|
||||
remote: https://gem.coop/
|
||||
specs:
|
||||
async (2.39.0)
|
||||
async (2.46.0)
|
||||
console (~> 1.29)
|
||||
fiber-annotation
|
||||
io-event (~> 1.11)
|
||||
metrics (~> 0.12)
|
||||
traces (~> 0.18)
|
||||
async-http (0.95.1)
|
||||
async (>= 2.10.2)
|
||||
async-pool (~> 0.11)
|
||||
io-endpoint (~> 0.14)
|
||||
io-stream (~> 0.6)
|
||||
metrics (~> 0.12)
|
||||
protocol-http (~> 0.62)
|
||||
protocol-http1 (~> 0.39)
|
||||
protocol-http2 (~> 0.26)
|
||||
io-event (~> 1.21)
|
||||
async-http (0.105.0)
|
||||
async (>= 2.35.1)
|
||||
async-pool (~> 0.12)
|
||||
io-endpoint (~> 0.18)
|
||||
io-stream (~> 0.14)
|
||||
protocol-http (~> 0.72)
|
||||
protocol-http1 (~> 0.41)
|
||||
protocol-http2 (~> 0.28)
|
||||
protocol-url (~> 0.2)
|
||||
traces (~> 0.10)
|
||||
async-pool (0.11.2)
|
||||
async-pool (0.12.0)
|
||||
async (>= 2.0)
|
||||
async-websocket (0.30.1)
|
||||
async-http (~> 0.76)
|
||||
@@ -26,7 +22,8 @@ GEM
|
||||
protocol-rack (~> 0.7)
|
||||
protocol-websocket (~> 0.17)
|
||||
base64 (0.3.0)
|
||||
console (1.36.0)
|
||||
chunky_png (1.4.0)
|
||||
console (1.38.0)
|
||||
fiber-annotation
|
||||
fiber-local (~> 1.1)
|
||||
json
|
||||
@@ -39,35 +36,38 @@ GEM
|
||||
fiber-local (1.1.0)
|
||||
fiber-storage
|
||||
fiber-storage (1.0.1)
|
||||
gosu (1.4.6)
|
||||
io-endpoint (0.17.2)
|
||||
io-event (1.16.2)
|
||||
io-stream (0.13.1)
|
||||
gosu (1.5.0-x86_64-linux)
|
||||
ffi (~> 1.12)
|
||||
io-endpoint (0.18.0)
|
||||
openssl (>= 3.3.0)
|
||||
io-event (1.22.0)
|
||||
io-stream (0.14.0)
|
||||
openssl (>= 3.3)
|
||||
ircparser (1.0.0)
|
||||
json (2.19.9)
|
||||
json (3.0.2)
|
||||
logger (1.7.0)
|
||||
metrics (0.15.0)
|
||||
openssl (4.0.2)
|
||||
protocol-hpack (1.5.1)
|
||||
protocol-http (0.62.2)
|
||||
protocol-http1 (0.39.0)
|
||||
protocol-http (~> 0.62)
|
||||
protocol-http2 (0.26.0)
|
||||
protocol-http (0.72.0)
|
||||
protocol-http1 (0.41.0)
|
||||
protocol-http (~> 0.68)
|
||||
protocol-http2 (0.28.0)
|
||||
protocol-hpack (~> 1.4)
|
||||
protocol-http (~> 0.62)
|
||||
protocol-rack (0.22.1)
|
||||
protocol-rack (0.23.0)
|
||||
console (~> 1.0)
|
||||
io-stream (>= 0.10)
|
||||
protocol-http (~> 0.58)
|
||||
rack (>= 1.0)
|
||||
protocol-url (0.4.0)
|
||||
protocol-websocket (0.21.1)
|
||||
protocol-url (0.19.0)
|
||||
protocol-websocket (0.21.2)
|
||||
protocol-http (~> 0.2)
|
||||
rack (3.2.6)
|
||||
rack (3.2.7)
|
||||
rake (13.4.2)
|
||||
rexml (3.4.4)
|
||||
rubyzip (3.4.0)
|
||||
sdl2-bindings (0.2.3)
|
||||
ffi (~> 1.15)
|
||||
traces (0.18.2)
|
||||
rubyzip (3.6.0)
|
||||
sdl3-bindings (1.0.4)
|
||||
ffi (~> 1.16)
|
||||
|
||||
PLATFORMS
|
||||
x86_64-linux
|
||||
@@ -77,49 +77,51 @@ DEPENDENCIES
|
||||
async-http
|
||||
async-websocket
|
||||
base64
|
||||
chunky_png
|
||||
cyberarm_engine
|
||||
digest-crc
|
||||
gosu (= 1.5.0)
|
||||
ircparser
|
||||
json
|
||||
logger
|
||||
rexml
|
||||
rubyzip
|
||||
sdl2-bindings
|
||||
sdl3-bindings
|
||||
|
||||
CHECKSUMS
|
||||
async (2.39.0) sha256=df18730073f2bbb45788077dfa20cb365ecc1b9453969f44de6796b5191a00aa
|
||||
async-http (0.95.1) sha256=0c3dd458c204c06d5c4b20b01bbec4794a1203db627fb2ce536e1799ec14786c
|
||||
async-pool (0.11.2) sha256=0a43a17b02b04d9c451b7d12fafa9a50e55dc6dd00d4369aca00433f16a7e3ed
|
||||
async (2.46.0) sha256=34f984733dbf63a997376346451ec995e858cacc3105e86ccfeaf8877b4f490b
|
||||
async-http (0.105.0) sha256=55191969d5cf61be5fbc2e0aadd51d77085318ba192a5ff5e12e3aba0826906a
|
||||
async-pool (0.12.0) sha256=5580493dc024bd704dab2ed0bf1307100722e49cbb825c7965f8501194f58506
|
||||
async-websocket (0.30.1) sha256=54bb8a8f184e4aa64434c7a78ecc55850a67a3d1dcd02e3ae787376e2c673936
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
console (1.36.0) sha256=45599ea906cf80a73d8941f03abf873fe66a6a954e0bac5bc1c01e2cdc406f07
|
||||
chunky_png (1.4.0) sha256=89d5b31b55c0cf4da3cf89a2b4ebc3178d8abe8cbaf116a1dba95668502fdcfe
|
||||
console (1.38.0) sha256=35b82f59130e95e9be6b832d9e539c8b3988fc192e4a8e9a86ab4922f44ad5de
|
||||
cyberarm_engine (0.25.1) sha256=6f851a2ed0a32bdd25fc619172b546a8e26eefeb13a62c509f226337170d0efe
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
|
||||
fiber-annotation (0.2.0) sha256=7abfadf1d119f508867d4103bf231c0354d019cc39a5738945dec2edadaf6c03
|
||||
fiber-local (1.1.0) sha256=c885f94f210fb9b05737de65d511136ea602e00c5105953748aa0f8793489f06
|
||||
fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1
|
||||
gosu (1.4.6) sha256=80c7fa48cf61f4960ef067fdd1c000d98b4adc8e11faeaa486543d00908a5c15
|
||||
io-endpoint (0.17.2) sha256=3feaf766c116b35839c11fac68b6aaadc47887bb488902a57bf8e1d288fb3338
|
||||
io-event (1.16.2) sha256=9f9cb0a96ea5c3850a672606c65f27bc96d7621399ef6196acbfe2be0cd1279c
|
||||
io-stream (0.13.1) sha256=570d7c4dfb0fbd767480b4a222048a2be6d9b78febc1ec68258d0e0a4cde20de
|
||||
gosu (1.5.0-x86_64-linux)
|
||||
io-endpoint (0.18.0) sha256=720d186bcd584e8ba490bfc91dab332cc3c5ffee848b5b03894555cb275151aa
|
||||
io-event (1.22.0) sha256=4936d38dfcf2291d897707fd94690f1a63fdb1ea3ab4fa63c6739fc6c61e1534
|
||||
io-stream (0.14.0) sha256=07d9b13ea2066ccbbc761fff8d69ad5a31e47561d31e02cc271f3ed9c1a8f1cd
|
||||
ircparser (1.0.0) sha256=0d4598f691172e2ad078207c50ba62c0d3428ae841836b0a682e6cd01d62878d
|
||||
json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
|
||||
json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
metrics (0.15.0) sha256=61ded5bac95118e995b1bc9ed4a5f19bc9814928a312a85b200abbdac9039072
|
||||
openssl (4.0.2) sha256=1037ad2868ae58df9ad917891c0c0f9815a1172f6846d4bcdd508e4c2ee747c2
|
||||
protocol-hpack (1.5.1) sha256=6feca238b8078da1cd295677d6f306c6001af92d75fe0643d33e6956cbc3ad91
|
||||
protocol-http (0.62.2) sha256=e1c1f2f56029c1af8c4e2b8a67d0d096c76620f3afd8d99d1dcd2f6b8ffa773b
|
||||
protocol-http1 (0.39.0) sha256=e49b3f4cda6f5d94c76a323d2b7f6977cba3ebd082d2da437039594da77ad8eb
|
||||
protocol-http2 (0.26.0) sha256=bac89cd78082b241ccd0cf7246f5160e4bb0c9c975fb4bf7deef5f88cc317486
|
||||
protocol-rack (0.22.1) sha256=1185d245927ef9849a603700d6991ca353bc89724fbf98efa4a4333ed62a9fc3
|
||||
protocol-url (0.4.0) sha256=64d4c03b6b51ad815ac6fdaf77a1d91e5baf9220d26becb846c5459dacdea9e1
|
||||
protocol-websocket (0.21.1) sha256=34325e4325697f0956877e67784bcc838cfd51ebbf4f8e9e5201be292041ee61
|
||||
rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
|
||||
protocol-http (0.72.0) sha256=b153474785979495b9f2df78f0fdc2e02edc7e92c1f7ea00425eed73bd02194c
|
||||
protocol-http1 (0.41.0) sha256=9863874a1228948a896c3e7ea16d13a680c8f3ed50706007c78b7033018b0ea3
|
||||
protocol-http2 (0.28.0) sha256=b7c6195dcfa80ec2484670e9fd8fc21024cc654a391aa0792433aff1188c1691
|
||||
protocol-rack (0.23.0) sha256=63eec3341b20565b3dd155345fd1426ecbbed322cdc8a6accc993905388c03d7
|
||||
protocol-url (0.19.0) sha256=3f6b9f6aa742a259a0b3610fbbd9685dcd9506adf6734f428ff96259faabfb21
|
||||
protocol-websocket (0.21.2) sha256=cbe35be8e045597b221874fba5928d7cd2483884126e3e36faece4741dc88eb7
|
||||
rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7
|
||||
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rubyzip (3.4.0) sha256=6de39bc9eba302b635a476d16c9e16b0872ad24517c2f98f2b3a7ea23caff57b
|
||||
sdl2-bindings (0.2.3) sha256=878c0755a4bea1aed0fa85c01596effd1c2b7d1878141ceec5bef808038bd309
|
||||
traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214
|
||||
rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695
|
||||
sdl3-bindings (1.0.4) sha256=326f497c30e6a5cab402e6178403b30c9df4a11a4e109e08c5da6e920c7bb55b
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.8
|
||||
|
||||
68
lib/application_helper.rb
Normal file
68
lib/application_helper.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
module W3DHubLauncher
|
||||
class ApplicationHelper
|
||||
def self.wine_command(app)
|
||||
# FIXME: account for the cursed whitespace and wrap in quotes
|
||||
format("%s ", MemCache[:settings]&.preferences&.wine_command || "wine")
|
||||
end
|
||||
def self.wine_prefix(app)
|
||||
# TODO: fallback from app specific prefix to launcher prefix to global prefix
|
||||
# format("%s ", app&.wine_prefix_path || MemCache[:settings]&.preferences&.wine_prefix_path || "")
|
||||
|
||||
""
|
||||
end
|
||||
|
||||
def self.run(app, *args)
|
||||
exe_path = app.id == "ecw" ? format("%s/game500.exe", app.installation_path) : format("%s/game.exe", app.installation_path)
|
||||
|
||||
env = {}
|
||||
unless wine_prefix(app).to_s.empty?
|
||||
env["WINEPREFIX"] = wine_prefix(app).to_s
|
||||
end
|
||||
env["DXVK_HUD"] = "full"
|
||||
|
||||
pid = Process.spawn(
|
||||
env,
|
||||
"#{wine_command(app)}#{exe_path} -launcher #{args.join(' ')}"
|
||||
)
|
||||
|
||||
Process.detach(pid)
|
||||
end
|
||||
|
||||
def self.join_server(app, server, password: nil, token: nil, multi: false)
|
||||
parameters = []
|
||||
|
||||
parameters << format("+connect %s:%s", server.address, server.port)
|
||||
parameters << format("+netplayername \"%s\"", "cyberarm")
|
||||
parameters << format("+password \"%s\"", password) if password
|
||||
parameters << format("+userkey \"%s\"", token) if token
|
||||
parameters << "+multi" if multi
|
||||
|
||||
run(
|
||||
app,
|
||||
parameters.join(" ")
|
||||
)
|
||||
end
|
||||
|
||||
# select "best" server to join
|
||||
def self.play_now_server(app)
|
||||
channel = MemCache[:applications].find { |appl| appl.id == app.id }&.channels&.find { |c| c.id == app.channel }
|
||||
return nil unless channel
|
||||
|
||||
server_options = MemCache[:servers].select do |server|
|
||||
server.game == app.id &&
|
||||
server.channel == channel.server_channel &&
|
||||
!server.password? &&
|
||||
server.player_count < server.max_players
|
||||
end.sort_by do |s|
|
||||
[s.player_count, -s.ping]
|
||||
end.reverse
|
||||
|
||||
# try to find server with lowest ping and matching app version
|
||||
found_server = server_options.find { |s| s.version == app.version }
|
||||
# try to find server with lowest ping and undefined version
|
||||
found_server ||= server_options.find { |s| s.version == Worker::Api::GameServer::NO_OR_DEFAULT_VERSION }
|
||||
|
||||
found_server
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -12,7 +12,7 @@ module W3DHubLauncher
|
||||
LIBRARIES = [
|
||||
Item.new("Ruby", "Programming language. A Programmer's Best Friend", "https://ruby-lang.org", "BSD 2-Clause", "https://www.ruby-lang.org/en/about/license.txt"),
|
||||
Item.new("gosu", "Light-weight game library", "https://libgosu.org", "MIT", "https://github.com/gosu/gosu/blob/master/COPYING"),
|
||||
Item.new("SDL2", "Simple DirectMedia Layer", "https://libsdl.org", "MIT", "https://github.com/libsdl-org/SDL/blob/SDL2/LICENSE.txt"),
|
||||
Item.new("SDL3", "Simple DirectMedia Layer", "https://libsdl.org", "zlib", "https://github.com/libsdl-org/SDL/blob/main/LICENSE.txt"),
|
||||
Item.new("MojoAL", "OpenAL sound library implementation in a single C file", "https://icculus.org/mojoAL/", "MIT", "https://github.com/icculus/mojoAL/blob/main/LICENSE.txt"),
|
||||
|
||||
Item.new("async", "Asynchronous event-driven reactor for Ruby", "https://github.com/socketry/async", "MIT", "https://github.com/socketry/async/blob/main/license.md"),
|
||||
|
||||
280
lib/dds.rb
Normal file
280
lib/dds.rb
Normal file
@@ -0,0 +1,280 @@
|
||||
require "stringio"
|
||||
|
||||
module W3DHubLauncher
|
||||
class DDS
|
||||
# https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header
|
||||
# https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-pixelformat
|
||||
# https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dx-graphics-dds-pguide
|
||||
# https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-file-layout-for-textures
|
||||
|
||||
DXT1 = 827611204
|
||||
DXT2 = 844388420
|
||||
DXT3 = 861165636
|
||||
DXT4 = 877942852
|
||||
DXT5 = 894720068
|
||||
|
||||
DDSD_CAPS = 0x1
|
||||
DDSD_HEIGHT = 0x2
|
||||
DDSD_WIDTH = 0x4
|
||||
DDSD_PITCH = 0x8
|
||||
DDSD_PIXELFORMAT = 0x1000
|
||||
DDSD_MIPMAPCOUNT = 0x20_000
|
||||
DDSD_LINEARSIZE = 0x80_000
|
||||
DDSD_DEPTH = 0x800_000
|
||||
|
||||
DDSCAPS_COMPLEX = 0x8
|
||||
DDSCAPS_MIPMAP = 0x400_000
|
||||
DDSCAPS_TEXTURE = 0x1000
|
||||
|
||||
DDSCAPS2_CUBEMAP = 0x200
|
||||
DDSCAPS2_CUBEMAP_POSITIVEX = 0x400
|
||||
DDSCAPS2_CUBEMAP_NEGATIVEX = 0x800
|
||||
DDSCAPS2_CUBEMAP_POSITIVEY = 0x1000
|
||||
DDSCAPS2_CUBEMAP_NEGATIVEY = 0x2000
|
||||
DDSCAPS2_CUBEMAP_POSITIVEZ = 0x4000
|
||||
DDSCAPS2_CUBEMAP_NEGATIVEZ = 0x8000
|
||||
DDSCAPS2_VOLUME = 0x200_000
|
||||
|
||||
DDPF_ALPHAPIXELS = 0x1
|
||||
DDPF_ALPHA = 0x2
|
||||
DDPF_FOURCC = 0x4
|
||||
DDPF_RGB = 0x40
|
||||
DDPF_YUV = 0x200
|
||||
DDPF_LUMINANCE = 0x20_000
|
||||
|
||||
Header = Struct.new(
|
||||
:byte_size, :flags, :height, :width, :pitch_or_linear_size,
|
||||
:depth, :mipmap_count, :reserved1, :pixel_format,
|
||||
:caps, :caps2, :caps3, :caps4, :reserved2
|
||||
)
|
||||
|
||||
HeaderX10 = Struct.new(:value)
|
||||
|
||||
PixelFormat = Struct.new(
|
||||
:byte_size, :flags, :four_cc, :rgb_bit_count,
|
||||
:red_bit_mask, :green_bit_mask, :blue_bit_mask, :alpha_bit_mask
|
||||
)
|
||||
|
||||
Image = Struct.new(:data, :width, :height)
|
||||
|
||||
attr_reader :header, :images
|
||||
def initialize(path: nil, io: nil, shallow: true)
|
||||
if path
|
||||
@data = StringIO.new(File.binread(path))
|
||||
elsif io
|
||||
@data = io
|
||||
else
|
||||
raise "Nah man."
|
||||
end
|
||||
|
||||
@shallow = shallow
|
||||
@images = []
|
||||
|
||||
decode_header
|
||||
decode_images
|
||||
end
|
||||
|
||||
# typedef struct {
|
||||
# DWORD dwSize;
|
||||
# DWORD dwFlags;
|
||||
# DWORD dwHeight;
|
||||
# DWORD dwWidth;
|
||||
# DWORD dwPitchOrLinearSize;
|
||||
# DWORD dwDepth;
|
||||
# DWORD dwMipMapCount;
|
||||
# DWORD dwReserved1[11];
|
||||
# DDS_PIXELFORMAT ddspf;
|
||||
# DWORD dwCaps;
|
||||
# DWORD dwCaps2;
|
||||
# DWORD dwCaps3;
|
||||
# DWORD dwCaps4;
|
||||
# DWORD dwReserved2;
|
||||
# } DDS_HEADER;
|
||||
|
||||
def decode_header
|
||||
@data.pos = 0
|
||||
|
||||
raise "File is not a DDS" unless read_u32 == 0x20534444 # magic byte "DDS"
|
||||
|
||||
size = read_u32
|
||||
flags = read_u32
|
||||
height = read_u32
|
||||
width = read_u32
|
||||
pitch_or_linear_size = read_u32
|
||||
depth = read_u32
|
||||
mipmap_count = read_u32
|
||||
reserved1 = 11.times.map { read_u32 }
|
||||
|
||||
pixel_format = read_pixelformat
|
||||
|
||||
caps = read_u32
|
||||
caps2 = read_u32
|
||||
caps3 = read_u32
|
||||
caps4 = read_u32
|
||||
reserved2 = read_u32
|
||||
|
||||
@header = Header.new(
|
||||
size, flags, height, width, pitch_or_linear_size, depth, mipmap_count,
|
||||
reserved1, pixel_format, caps, caps2, caps3, caps4, reserved2
|
||||
)
|
||||
|
||||
@header.freeze
|
||||
|
||||
raise "Invalid dds header size: #{size}, expected 124" unless size == 124
|
||||
raise "Invalid pixel format header size: #{pixel_format.byte_size}, expected 32" unless pixel_format.byte_size == 32
|
||||
end
|
||||
|
||||
def decode_images
|
||||
pixel_format = @header.pixel_format
|
||||
|
||||
raise "Unsupported dds format: #{pixel_format.four_cc}" unless (pixel_format.four_cc == DXT1 || pixel_format.four_cc == DXT3 || pixel_format.four_cc == DXT5)
|
||||
raise "Unsupported pixel format: #{pixel_format.flags}" unless pixel_format.flags == DDPF_FOURCC
|
||||
|
||||
width = @header.width
|
||||
height = @header.height
|
||||
mipmap_count = [1, @header.mipmap_count].max
|
||||
mipmap_count = 1 if @shallow
|
||||
block_bytes = 8
|
||||
block_bytes = 16 if pixel_format.four_cc == DXT3 || pixel_format.four_cc == DXT5
|
||||
data_offset = @header.byte_size + 4
|
||||
|
||||
mipmap_count.times do
|
||||
data_length = [4, width].max / 4 * [4, height].max / 4 * block_bytes
|
||||
|
||||
@data.pos = data_offset
|
||||
image = Image.new(
|
||||
to_rgba(@data.read(data_length), width, height, pixel_format.four_cc == DXT1),
|
||||
width,
|
||||
height
|
||||
)
|
||||
|
||||
@images << image
|
||||
|
||||
data_offset += data_length
|
||||
width /= 2
|
||||
height /= 2
|
||||
end
|
||||
end
|
||||
|
||||
def read_u32
|
||||
@data.read(4).unpack1("L")
|
||||
end
|
||||
|
||||
# struct DDS_PIXELFORMAT {
|
||||
# DWORD dwSize;
|
||||
# DWORD dwFlags;
|
||||
# DWORD dwFourCC;
|
||||
# DWORD dwRGBBitCount;
|
||||
# DWORD dwRBitMask;
|
||||
# DWORD dwGBitMask;
|
||||
# DWORD dwBBitMask;
|
||||
# DWORD dwABitMask;
|
||||
# };
|
||||
|
||||
def read_pixelformat
|
||||
|
||||
size = read_u32
|
||||
flags = read_u32
|
||||
four_cc = read_u32
|
||||
rgb_bit_count = read_u32
|
||||
red_bit_mask = read_u32
|
||||
green_bit_mask = read_u32
|
||||
blue_bit_mask = read_u32
|
||||
alpha_bit_mask = read_u32
|
||||
|
||||
PixelFormat.new(
|
||||
size, flags, four_cc, rgb_bit_count,
|
||||
red_bit_mask, green_bit_mask, blue_bit_mask, alpha_bit_mask
|
||||
)
|
||||
end
|
||||
|
||||
# https://github.com/kchapelier/decode-dxt/
|
||||
def to_rgba(data, width, height, dxt1 = false)
|
||||
conversion_started = Gosu.milliseconds
|
||||
|
||||
data = StringIO.new(data)
|
||||
rgba = Array.new(width * height * 4, 0) # StringIO.new("\0" * (width * height * 4), "wb") #
|
||||
width_4 = (width / 4) | 0
|
||||
height_4 = (height / 4) | 0
|
||||
stride = 4
|
||||
|
||||
height_4.times do |h|
|
||||
width_4.times do |w|
|
||||
a, b, index = data.read(8).unpack("vvV")
|
||||
color_values = interpolate_color_values(a, b, dxt1)
|
||||
color_indices = index
|
||||
|
||||
4.times do |y|
|
||||
4.times do |x|
|
||||
pixel_index = (3 - x) + (y * 4)
|
||||
rgba_index = (h * 4 + 3 - y) * width * 4 + (w * 4 + x) * 4
|
||||
color_index = (color_indices >> (2 * (15 - pixel_index))) & 0x03
|
||||
|
||||
rgba[rgba_index] = color_values[color_index * 4]
|
||||
rgba[rgba_index + 1] = color_values[color_index * 4 + 1]
|
||||
rgba[rgba_index + 2] = color_values[color_index * 4 + 2]
|
||||
rgba[rgba_index + 3] = color_values[color_index * 4 + 3]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
puts "Inital set complete after: #{Gosu.milliseconds - conversion_started}ms"
|
||||
v = rgba.pack("C*") #.map { |v| v.chr }.join
|
||||
puts "type set complete after: #{Gosu.milliseconds - conversion_started}ms"
|
||||
|
||||
v
|
||||
end
|
||||
|
||||
def interpolate_color_values(a, b, dxt1 = false)
|
||||
first_color = convert_565_byte_to_rgb(a)
|
||||
second_color = convert_565_byte_to_rgb(b)
|
||||
|
||||
color_values = [
|
||||
first_color[0], first_color[1], first_color[2], 255,
|
||||
second_color[0], second_color[1], second_color[2], 255
|
||||
]
|
||||
|
||||
if (dxt1 && a <= b)
|
||||
color_values.push(
|
||||
(first_color[0] + second_color[0]) / 2,
|
||||
(first_color[1] + second_color[1]) / 2,
|
||||
(first_color[2] + second_color[2]) / 2,
|
||||
255,
|
||||
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
else
|
||||
color_values.push(
|
||||
lerp(first_color[0], second_color[0], 1 / 3),
|
||||
lerp(first_color[1], second_color[1], 1 / 3),
|
||||
lerp(first_color[2], second_color[2], 1 / 3),
|
||||
255,
|
||||
|
||||
lerp(first_color[0], second_color[0], 2 / 3),
|
||||
lerp(first_color[1], second_color[1], 2 / 3),
|
||||
lerp(first_color[2], second_color[2], 2 / 3),
|
||||
255
|
||||
)
|
||||
end
|
||||
|
||||
color_values
|
||||
end
|
||||
|
||||
def convert_565_byte_to_rgb(byte)
|
||||
[
|
||||
((byte >> 11) & 31) * (255 / 31),
|
||||
((byte >> 5) & 63) * (255 / 63),
|
||||
(byte & 31) * (255 / 31)
|
||||
]
|
||||
end
|
||||
|
||||
def lerp(a, b, r)
|
||||
a * (1 - r) + b * r
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,7 +13,7 @@ module W3DHubLauncher
|
||||
title NAME
|
||||
tagline format("v%s (%s)", VERSION, VERSION_NAME), margin_left: PADDING
|
||||
para "© 2021 - #{Time.now.year} cyberarm", margin_left: PADDING
|
||||
link "MIT licence", margin_left: PADDING
|
||||
link "MIT license", margin_left: PADDING
|
||||
|
||||
title "Special Thanks", margin_top: LARGE_PADDING
|
||||
W3DHubLauncher::Attribution::SPECIAL_THANKS.each do |item|
|
||||
@@ -40,12 +40,18 @@ module W3DHubLauncher
|
||||
|
||||
def present_item(item)
|
||||
unless item.url.empty?
|
||||
link item.name, tip: item.url, margin_left: PADDING, font: FONT_BOLD
|
||||
link item.name, tip: item.url, margin_left: PADDING, font: FONT_BOLD do
|
||||
SDL.OpenURL(item.url)
|
||||
end
|
||||
else
|
||||
tagline item.name, margin_left: PADDING
|
||||
end
|
||||
para item.description, margin_left: LARGE_PADDING
|
||||
link item.license, tip: item.license_url, margin_left: PADDING + LARGE_PADDING unless item.license.empty?
|
||||
unless item.license.empty?
|
||||
link format("%s license", item.license), tip: item.license_url, margin_left: PADDING + LARGE_PADDING do
|
||||
SDL.OpenURL(item.license_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
77
lib/dialogs/import_application.rb
Normal file
77
lib/dialogs/import_application.rb
Normal file
@@ -0,0 +1,77 @@
|
||||
module W3DHubLauncher
|
||||
class Dialog
|
||||
class ImportApplication < W3DHubLauncher::Dialog
|
||||
def setup
|
||||
super
|
||||
|
||||
@application = @options[:application]
|
||||
@channel = @options[:channel]
|
||||
|
||||
stack(width: 1.0, max_width: 600, height: 1.0, max_height: 600, h_align: :center, v_align: :center, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0xee_63452c) do
|
||||
flow(width: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0) do
|
||||
banner "Import Application", width: 1.0, text_align: :center
|
||||
end
|
||||
|
||||
stack(width: 1.0, fill: true, scroll: true, margin_left: PADDING, margin_right: PADDING, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_BLACK) do
|
||||
title format("%s (%s)", @application.name, @channel.name)
|
||||
caption "Path to application executable:"
|
||||
@path_input = edit_line "", width: 1.0
|
||||
button "Browse..." do
|
||||
Gosu.choose_file do |file_list|
|
||||
pp file_list
|
||||
|
||||
unless file_list.empty?
|
||||
@path_input.value = file_list.first
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@path_input.subscribe(:changed) do |e, value|
|
||||
found = false
|
||||
|
||||
if File.directory?(File.dirname(value))
|
||||
Dir.glob("#{File.dirname(value)}/game*.exe").each do |file|
|
||||
if File.exist?(file)
|
||||
found = true
|
||||
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@import_button.enabled = found
|
||||
end
|
||||
end
|
||||
|
||||
flow(width: 1.0, padding: PADDING, background_nine_slice: NINE_SLICE_ROUNDED_BOTTOM, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0) do
|
||||
button "Close" do
|
||||
pop_state
|
||||
end
|
||||
|
||||
flow(fill: true)
|
||||
|
||||
@import_button = button "Import", enabled: false, **CTA_BUTTON_THEME do
|
||||
pop_state
|
||||
|
||||
app = Worker::Api::Settings::Application.create(
|
||||
id: @application.id,
|
||||
channel: @channel.id,
|
||||
version: @channel.version,
|
||||
installation_path: File.dirname(@path_input.value),
|
||||
wine_prefix_path: "",
|
||||
launch_command: "%COMMAND%",
|
||||
timestamp: Time.now.to_i
|
||||
)
|
||||
|
||||
MemCache[:settings].applications << app
|
||||
|
||||
Worker::Api.update_settings(MemCache[:settings])
|
||||
|
||||
# FIXME: broadcast app installed on CyberarmEngine::EventBus
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
36
lib/gosu_ext.rb
Normal file
36
lib/gosu_ext.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
module Gosu
|
||||
# ! nonblocking
|
||||
def self.choose_file(&block)
|
||||
callback_id = "gc_protect_#{Gosu.milliseconds}"
|
||||
|
||||
callback = lambda do |user_data, file_list, filter|
|
||||
data = nil
|
||||
|
||||
if file_list
|
||||
data = file_list.read_array_of_string
|
||||
end
|
||||
|
||||
block.call(data)
|
||||
|
||||
W3DHubLauncher::MemCache.delete(callback_id)
|
||||
end
|
||||
|
||||
W3DHubLauncher::MemCache[callback_id] = callback
|
||||
|
||||
SDL.ShowOpenFileDialog(
|
||||
callback,
|
||||
nil,
|
||||
CyberarmEngine::Window.instance.sdl_window,
|
||||
nil,
|
||||
0,
|
||||
W3DHubLauncher::CACHE_PATH,
|
||||
false
|
||||
)
|
||||
end
|
||||
|
||||
def self.choose_folder()
|
||||
end
|
||||
|
||||
def self.save_file()
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,7 @@ module W3DHubLauncher
|
||||
BLACK_IMAGE = Gosu.render(64, 64, retro: true) { Gosu.draw_rect(0, 0, 32, 32, Gosu::Color::BLACK) }
|
||||
WHITE_IMAGE = Gosu.render(64, 64, retro: true) { Gosu.draw_rect(0, 0, 32, 32, Gosu::Color::WHITE) }
|
||||
|
||||
def safe_get_image(path, retro: false)
|
||||
def safe_get_image(path, fallback_path: "#{ROOT_PATH}/media/default.png", retro: false)
|
||||
raise RuntimeError, "Images may only be loaded from the main thread!" unless Thread.current == Thread.main
|
||||
|
||||
begin
|
||||
@@ -12,8 +12,11 @@ module W3DHubLauncher
|
||||
pp e
|
||||
end
|
||||
|
||||
path = "#{ROOT_PATH}/media/default.png"
|
||||
return get_image(path, retro: retro) if File.exist?(path)
|
||||
begin
|
||||
return get_image(fallback_path, retro: retro) if File.exist?(fallback_path)
|
||||
rescue RuntimeError => e
|
||||
pp e
|
||||
end
|
||||
|
||||
WHITE_IMAGE
|
||||
end
|
||||
|
||||
6
lib/hardware_information.rb
Normal file
6
lib/hardware_information.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
module W3DHubLauncher
|
||||
class HardwareInformation
|
||||
def initialize
|
||||
end
|
||||
end
|
||||
end
|
||||
145
lib/ico.rb
Normal file
145
lib/ico.rb
Normal file
@@ -0,0 +1,145 @@
|
||||
require "stringio"
|
||||
|
||||
module W3DHubLauncher
|
||||
class ICO
|
||||
PNG_IDENT = "\211PNG\r\n\032\n".force_encoding("ASCII-8BIT").freeze
|
||||
|
||||
IconDirectory = Struct.new(:reserved, :type, :image_count)
|
||||
|
||||
IconDirectoryEntity = Struct.new(
|
||||
:width,
|
||||
:height,
|
||||
:palette_size,
|
||||
:reserved,
|
||||
:color_planes,
|
||||
:bit_depth,
|
||||
:image_size,
|
||||
:image_offset
|
||||
)
|
||||
|
||||
|
||||
def initialize(file:)
|
||||
@file = StringIO.new(File.binread(file))
|
||||
|
||||
@images = []
|
||||
|
||||
parse
|
||||
end
|
||||
|
||||
def directory
|
||||
@icon_directory
|
||||
end
|
||||
|
||||
def images
|
||||
@images
|
||||
end
|
||||
|
||||
# SEE: https://en.wikipedia.org/wiki/ICO_(file_format)
|
||||
def parse
|
||||
# Parse IconDirectory
|
||||
@icon_directory = IconDirectory.new(
|
||||
read_u16, read_u16, read_u16
|
||||
)
|
||||
|
||||
@icon_directory.image_count.times do
|
||||
@images << IconDirectoryEntity.new(
|
||||
read_u8,
|
||||
read_u8,
|
||||
read_u8,
|
||||
read_u8,
|
||||
read_u16,
|
||||
read_u16,
|
||||
read_u32,
|
||||
read_u32
|
||||
)
|
||||
|
||||
@images.last.width = 256 if @images.last.width == 0
|
||||
@images.last.height = 256 if @images.last.height == 0
|
||||
end
|
||||
end
|
||||
|
||||
def read_u8
|
||||
@file.read(1).unpack1("C")
|
||||
end
|
||||
|
||||
def read_u16
|
||||
@file.read(2).unpack1("v")
|
||||
end
|
||||
|
||||
def read_u32
|
||||
@file.read(4).unpack1("V")
|
||||
end
|
||||
|
||||
def select_pngs
|
||||
@images.select do |image|
|
||||
image_png?(image)
|
||||
end
|
||||
end
|
||||
|
||||
def image_png?(image)
|
||||
@file.pos = image.image_offset
|
||||
buf = @file.read(8).unpack1("a*")
|
||||
|
||||
buf == PNG_IDENT
|
||||
end
|
||||
|
||||
def select_bmps
|
||||
@images.select do |image|
|
||||
image_bmp?(image) && image.palette_size.zero? && image.bit_depth == 32
|
||||
end
|
||||
end
|
||||
|
||||
def image_bmp?(image)
|
||||
!image_png?(image)
|
||||
end
|
||||
|
||||
def to_rgba32_blob(image)
|
||||
@file.pos = image.image_offset
|
||||
buf = StringIO.new(@file.read(image.image_size))
|
||||
|
||||
raise NotImplementedError "Cannot parse png based icons!" unless image_bmp?(image)
|
||||
|
||||
raise NotImplementedError "Cannot parse #{image.bit_depth}" unless image.bit_depth == 32
|
||||
|
||||
blob = "".force_encoding("ASCII-8BIT")
|
||||
|
||||
width = image.width
|
||||
height = image.height - 1
|
||||
|
||||
image.height.times do |y|
|
||||
image.width.times do |x|
|
||||
buf.pos = ((height - y) * width + x + 10) * 4
|
||||
|
||||
blue = buf.read(1)
|
||||
green = buf.read(1)
|
||||
red = buf.read(1)
|
||||
alpha = buf.read(1)
|
||||
|
||||
blob << red
|
||||
blob << green
|
||||
blob << blue
|
||||
blob << alpha
|
||||
end
|
||||
end
|
||||
|
||||
# Gosu::Image.from_blob(image.width, image.height, blob)
|
||||
ChunkyPNG::Canvas.from_rgba_stream(image.width, image.height, blob)
|
||||
end
|
||||
|
||||
def image_data(image)
|
||||
@file.pos = image.image_offset
|
||||
StringIO.new(@file.read(image.image_size)).string
|
||||
end
|
||||
|
||||
def save(image, filename)
|
||||
puts filename
|
||||
if image_bmp?(image)
|
||||
puts :gosu_image
|
||||
to_rgba32_blob(image).save(filename)
|
||||
else
|
||||
puts :png
|
||||
File.open(filename, "wb") { |f| f.write(image_data(image)) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,13 +9,18 @@ module W3DHubLauncher
|
||||
@current_app = @games.first
|
||||
@current_channel = @current_app&.channels&.first
|
||||
|
||||
@display_mode = :all_games
|
||||
|
||||
# game bar container
|
||||
flow(width: 1.0, height: 60) do
|
||||
widget(width: 220, height: 1.0, style_class: [:all_button]) do
|
||||
flow(width: 1.0, height: 40, margin_left: PADDING, v_align: :center, h_align: :center) do
|
||||
widget(width: 220, height: 1.0, style_class: [:all_button]) do |w|
|
||||
flow(width: 1.0, height: 40, margin_left: PADDING, v_align: :center) do
|
||||
image safe_get_image("#{ROOT_PATH}/media/icons/menuGrid.png"), height: 40, color: 0xff_bbbbbb
|
||||
link "ALL GAMES", text_size: 24, font: FONT_BLACK, height: 1.0, text_v_align: :center
|
||||
end
|
||||
w.subscribe(:clicked_left_mouse_button) do
|
||||
populate_all_games
|
||||
end
|
||||
end
|
||||
|
||||
@games_list_container = flow(fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY, margin_left: PADDING) do
|
||||
@@ -23,26 +28,70 @@ module W3DHubLauncher
|
||||
end
|
||||
|
||||
# game content container
|
||||
flow(width: 1.0, fill: true, margin_top: LARGE_PADDING) do
|
||||
# game info container
|
||||
@game_info_container = stack(width: 340, height: 1.0) do
|
||||
end
|
||||
|
||||
# game events and news container
|
||||
stack(fill: true, height: 1.0, margin_left: LARGE_PADDING, scroll: true) do
|
||||
@event_container = flow(width: 1.0, height: 1.0, max_height: 380, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
|
||||
end
|
||||
|
||||
# news container
|
||||
@news_container = flow(width: 1.0, margin_top: PADDING) do
|
||||
end
|
||||
end
|
||||
@game_content_container = flow(width: 1.0, fill: true, margin_top: LARGE_PADDING) do
|
||||
end
|
||||
|
||||
populate_game
|
||||
end
|
||||
|
||||
def focus
|
||||
@parent.show_battleview_panel
|
||||
end
|
||||
|
||||
def blur
|
||||
@parent.hide_battleview_panel
|
||||
end
|
||||
|
||||
def populate_game_content_container
|
||||
@game_content_container.clear do
|
||||
# game info container
|
||||
@game_info_container = stack(width: 340, height: 1.0) do
|
||||
end
|
||||
|
||||
# game events and news container
|
||||
stack(fill: true, height: 1.0, margin_left: PADDING, scroll: true) do
|
||||
@event_container = stack(width: 1.0, padding: PADDING, margin_left: PADDING, margin_bottom: PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0x88_26a269) do
|
||||
end
|
||||
|
||||
# news container
|
||||
@news_container = flow(width: 1.0) do
|
||||
end
|
||||
|
||||
# "dynamically" adjust news item widths
|
||||
@news_container.subscribe(:size_changed) do |event|
|
||||
@news_container.children.each do |box|
|
||||
box.style.width = 1.0 / news_item_width_ratio
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def populate_all_games
|
||||
@display_mode = :all_games
|
||||
|
||||
@game_content_container.clear do
|
||||
flow(fill: true, height: 1.0, scroll: true, tag: :hi_mom) do |e|
|
||||
@games.each do |app|
|
||||
image safe_get_image("#{ROOT_PATH}/media/default_game_cover.png"), aspect_ratio: 3 / 4.0, width: 1.0 / game_cover_width_ratio(e), margin_left: HALF_PADDING, margin_right: HALF_PADDING, margin_bottom: PADDING, tip: app.name
|
||||
end
|
||||
|
||||
e.subscribe(:size_changed) do |e|
|
||||
e.children.each do |child|
|
||||
child.style.width = 1.0 / game_cover_width_ratio(e)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def populate_game(game = @current_app, channel = @current_channel)
|
||||
if @display_mode == :all_games
|
||||
@display_mode = :game
|
||||
|
||||
populate_game_content_container
|
||||
end
|
||||
|
||||
@current_app = game
|
||||
@current_channel = game&.channels&.first
|
||||
|
||||
@@ -62,6 +111,29 @@ module W3DHubLauncher
|
||||
return
|
||||
end
|
||||
|
||||
app_id = @current_app.id
|
||||
unless MemCache[:"events_#{app_id}"]
|
||||
Worker::Api.events(app_id) do |result|
|
||||
if result.okay?
|
||||
File.write("events_#{app_id}.json", result.data)
|
||||
MemCache[:"events_#{app_id}"] = JSON.parse(result.data)&.map { |item| Worker::Api::ServerEvent.new(item) } || []
|
||||
|
||||
populate_game_event if app_id == @current_app.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
unless MemCache[:"news_#{app_id}"]
|
||||
Worker::Api.news(app_id) do |result|
|
||||
if result.okay?
|
||||
File.write("news_#{app_id}.json", result.data)
|
||||
MemCache[:"news_#{app_id}"] = JSON.parse(result.data)["news"]&.map { |item| Worker::Api::NewsItem.new(item) } || []
|
||||
|
||||
populate_game_news if app_id == @current_app.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
populate_games_list
|
||||
populate_game_info
|
||||
populate_game_event
|
||||
@@ -70,32 +142,36 @@ module W3DHubLauncher
|
||||
|
||||
def populate_games_list
|
||||
@games_list_container.clear do
|
||||
@games.each_with_index do |game, i|
|
||||
button(safe_get_image("#{ROOT_PATH}/data/cache/#{game.id}.png"), tag: :"image_icon_#{game.id}", style_class: [:app_icon_button], enabled: @current_app.id != game.id, tip: game.name) do |btn|
|
||||
@games.each do |game|
|
||||
btn = button(safe_get_image("#{CACHE_PATH}/icon_#{game.id}.png"), tag: :"image_icon_#{game.id}", style_class: [:app_icon_button], enabled: @current_app.id != game.id, tip: game.name) do |btn|
|
||||
populate_game(game, game&.channels&.first)
|
||||
end
|
||||
remote_image("#{CACHE_PATH}/icon_#{game.id}.ico", url: "https://s3.w3d.cyberarm.dev/games/#{game.id}/#{game.id}.ico", element: btn) do |e, path|
|
||||
Worker::Api.ico_to_png(ico_path: path, png_path: path.sub(".ico", ".png")) do |result|
|
||||
e&.value = safe_get_image(result.data["path"]) if result.okay?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
puts
|
||||
end
|
||||
end
|
||||
|
||||
def populate_game_info
|
||||
# FIXME: We should be able to query an api to figure out the installation state, instead of explictly writing it out like this...
|
||||
application = MemCache[:settings].applications.find { |app| app.id == @current_app.id }
|
||||
application = MemCache[:settings].application_installed?(@current_app, @current_channel)
|
||||
# pp application
|
||||
|
||||
@game_info_container.clear do
|
||||
# logo
|
||||
img = image safe_get_image("#{ROOT_PATH}/data/cache/logo_#{@current_app.id}.png"), tag: :"image_logo_#{@current_app.id}", width: 1.0, max_height: 124
|
||||
remote_image("#{ROOT_PATH}/data/cache/logo_#{@current_app.id}.png", url: "https://s3.w3d.cyberarm.dev/games/#{@current_app.id}/logo.png", element: img) do |e, path|
|
||||
img = image safe_get_image("#{CACHE_PATH}/logo_#{@current_app.id}.png"), tag: :"image_logo_#{@current_app.id}", width: 1.0, max_height: 124
|
||||
remote_image("#{CACHE_PATH}/logo_#{@current_app.id}.png", url: "https://s3.w3d.cyberarm.dev/games/#{@current_app.id}/logo.png", element: img) do |e, path|
|
||||
e&.value = safe_get_image(path)
|
||||
end
|
||||
|
||||
# web links
|
||||
stack(width: 1.0, fill: true, padding: 0, padding_top: LARGE_PADDING) do
|
||||
@current_app.web_links.each do |link|
|
||||
link link.name, text_size: 24, tip: link.uri
|
||||
link link.name, text_size: 24, tip: link.uri do
|
||||
SDL.OpenURL(link.uri)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -107,57 +183,136 @@ module W3DHubLauncher
|
||||
populate_game_info
|
||||
end
|
||||
end
|
||||
|
||||
flow(width: 1.0, height: 60) do
|
||||
if application
|
||||
button "JOIN", tip: "Join most populated or lowest ping server", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_LEFT, **CTA_BUTTON_THEME
|
||||
button safe_get_image("#{ROOT_PATH}/media/icons/singleplayer.png"), tip: "Single player", image_height: 1.0, background_nine_slice: NINE_SLICE_SQUARE, **CTA_BUTTON_THEME
|
||||
button safe_get_image("#{ROOT_PATH}/media/icons/gear.png"), tip: "Options", image_height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_RIGHT, **CTA_BUTTON_THEME
|
||||
button "JOIN", tip: "Join most populated or lowest ping server", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_LEFT, **CTA_BUTTON_THEME do |btn|
|
||||
server = ApplicationHelper.play_now_server(application)
|
||||
|
||||
if server
|
||||
ApplicationHelper.join_server(application, server)
|
||||
else
|
||||
puts "No server available."
|
||||
end
|
||||
end
|
||||
button safe_get_image("#{ROOT_PATH}/media/icons/singleplayer.png"), tip: "Single player", image_height: 1.0, background_nine_slice: NINE_SLICE_SQUARE, **CTA_BUTTON_THEME do
|
||||
ApplicationHelper.run(application)
|
||||
end
|
||||
button safe_get_image("#{ROOT_PATH}/media/icons/gear.png"), tip: "Options", image_height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_RIGHT, **CTA_BUTTON_THEME do |btn|
|
||||
menu(parent: btn) do
|
||||
menu_item("v#{application.version}", enabled: false, disabled: { color: 0xdd_ffffff }, tip: "Installed application version")
|
||||
stack(width: 1.0, height: 2, background: 0xff_bbbbbb)
|
||||
menu_item("Installation Folder", tip: "Open application installation folder:\n#{application.installation_path}")
|
||||
menu_item("Screenshots Folder", tip: "Open application installation folder:\n#{Dir.home}/Documents/W3D Hub/#{application.id}-#{application.channel}/Screenshots")
|
||||
stack(width: 1.0, height: 2, background: 0xff_bbbbbb)
|
||||
menu_item("Application Settings", tip: "Edit application launch options")
|
||||
menu_item("Repair Installation", tip: "Attempt to fix and repair installation")
|
||||
menu_item("Check for Updates", tip: "Manually check for application updates")
|
||||
stack(width: 1.0, height: 2, background: 0xff_bbbbbb)
|
||||
menu_item("Move Installation", tip: "Move application installation to a different directory or disk")
|
||||
menu_item("Unlink Installation", tip: "The application will be removed from the launcher\nbut the application's files will not be touched")
|
||||
menu_item("Uninstall", tip: "Uninstall application and delete files")
|
||||
end.show
|
||||
end
|
||||
elsif @current_app.servicable?
|
||||
# pp @current_app
|
||||
button "Import", enabled: false, tip: "Import existing application installation", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_LEFT, **CTA_BUTTON_THEME
|
||||
button "Download", tip: "Download and install application", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_RIGHT, **CTA_BUTTON_THEME
|
||||
button "Import", tip: "Import existing application installation", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_LEFT, **CTA_BUTTON_THEME do |btn|
|
||||
dialog(Dialog::ImportApplication, application: @current_app, channel: @current_channel)
|
||||
end
|
||||
button "Download", tip: "Download and install application", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_RIGHT, **CTA_BUTTON_THEME do |btn|
|
||||
btn.enabled = false
|
||||
|
||||
Worker::Api.install_application(@current_app.id, @current_channel.id) do |status, result|
|
||||
handle_task_update(status, result)
|
||||
end
|
||||
end
|
||||
else
|
||||
button "Import", enabled: false, tip: "Import existing application installation", fill: true, height: 1.0, **CTA_BUTTON_THEME
|
||||
end
|
||||
end
|
||||
# FIXME: the reported version should be the _installed_ version, not the current channel version
|
||||
inscription "Version: #{@current_channel.version}", margin_top: PADDING, tip: "Installed version" if application
|
||||
end
|
||||
end
|
||||
|
||||
def populate_game_event
|
||||
# TODO: Hide event container if there is no event
|
||||
@event_container.show if false
|
||||
@event_container.hide if true
|
||||
app_events = MemCache[:"events_#{@current_app.id}"] || []
|
||||
if app_events.empty?
|
||||
@event_container.hide
|
||||
else
|
||||
@event_container.show
|
||||
|
||||
@event_container.clear do
|
||||
image safe_get_image("#{ROOT_PATH}/media/background.png"), fill: true, aspect_ratio: 16.0 / 9.0
|
||||
event = app_events.sort(&:start_time).last
|
||||
|
||||
stack(fill: true, height: 1.0, margin_left: PADDING) do
|
||||
caption "Upcoming Event".upcase, color: 0xff_22aa11
|
||||
title "Red Alert: A Path Beyond Game Night"
|
||||
tagline "July 11, 2028"
|
||||
|
||||
flow(fill: true)
|
||||
|
||||
button "Read More", margin_left: PADDING, margin_right: LARGE_PADDING, margin_bottom: PADDING, width: 1.0
|
||||
@event_container.clear do
|
||||
caption "Upcoming Event".upcase, color: 0xaa_ffffff
|
||||
tagline event.title # "Red Alert: A Path Beyond Game Night"
|
||||
caption event.start_time.strftime("%B %e, %Y • %T") # "July 11, 2028"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def populate_game_news
|
||||
app_news = MemCache[:"news_#{@current_app.id}"] || []
|
||||
|
||||
@news_container.clear do
|
||||
9.times do
|
||||
stack(width: 1.0 / 3, min_width: 345, height: 345, aspect_ratio: 1, margin_left: HALF_PADDING, margin_right: HALF_PADDING, margin_bottom: PADDING) do
|
||||
stack(width: 1.0, fill: true, background_image: safe_get_image("#{ROOT_PATH}/media/background.png"), background_image_mode: :fill)
|
||||
stack(width: 1.0, height: 1.0 / 3, padding: PADDING, v_align: :bottom, background_nine_slice: NINE_SLICE_ROUNDED_BOTTOM, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY, border_thickness_top: 1, border_color_top: Gosu::Color::BLACK) do
|
||||
caption "NEWS", color: 0x88_ffffff
|
||||
tagline "A News Item Post A News Item Post"
|
||||
app_news.each do |item|
|
||||
stack(width: 1.0 / news_item_width_ratio, height: 345, aspect_ratio: 1, margin_left: PADDING, margin_bottom: PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
|
||||
stack(width: 1.0, height: 1.0 / 2.5, padding: PADDING, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
|
||||
para item.timestamp.strftime("%B %e, %Y") #"September 29, 2026"
|
||||
tagline item.title
|
||||
end
|
||||
|
||||
stack(width: 1.0, fill: true, padding: PADDING, padding_bottom: 0, scroll: false) do
|
||||
para item.blurb
|
||||
end
|
||||
|
||||
button "Read More", margin: PADDING, width: 1.0, tip: item.uri do
|
||||
SDL.OpenURL(item.uri)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def game_cover_width_ratio(e)
|
||||
(e.width / 300.0).round.clamp(1..10)
|
||||
end
|
||||
|
||||
def news_item_width_ratio
|
||||
(@news_container.width / 400.0).round.clamp(1..10)
|
||||
end
|
||||
|
||||
def handle_task_update(result, status)
|
||||
pp [result, status]
|
||||
|
||||
status_bar = current_state.application_task_status_bar_container
|
||||
status_bar_title = find_element_by_tag(status_bar, :status_bar_title)
|
||||
status_bar_label = find_element_by_tag(status_bar, :status_bar_label)
|
||||
status_bar_progress = find_element_by_tag(status_bar, :status_bar_progress)
|
||||
|
||||
data = result.data
|
||||
|
||||
case status
|
||||
when Worker::Request::STATUS_COMPLETE
|
||||
status_bar.hide
|
||||
|
||||
MemCache[:settings].applications << Worker::Api::Settings::Application.new(data["application"])
|
||||
Worker::Api.update_settings(MemCache[:settings])
|
||||
|
||||
populate_game_info
|
||||
when Worker::Request::STATUS_IN_PROGRESS
|
||||
status_bar.show
|
||||
|
||||
status_bar_title.value = data["status"]["title"]
|
||||
status_bar_label.value = data["status"]["label"]
|
||||
status_bar_progress.value = data["status"]["fraction"]
|
||||
when Worker::Request::STATUS_ERROR
|
||||
status_bar.hide
|
||||
|
||||
populate_game_info
|
||||
|
||||
# FIXME: Present error to player
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,22 +9,23 @@ module W3DHubLauncher
|
||||
# game bar container
|
||||
flow(width: 1.0, height: 60) do
|
||||
widget(width: 220, height: 1.0, style_class: [:all_button]) do |w|
|
||||
flow(width: 1.0, height: 40, margin_left: PADDING, v_align: :center, h_align: :center) do
|
||||
flow(width: 1.0, height: 40, margin_left: PADDING, v_align: :center) do
|
||||
image safe_get_image("#{ROOT_PATH}/media/icons/menuGrid.png"), height: 40, color: 0xff_bbbbbb
|
||||
link "ALL SERVERS", text_size: 24, font: FONT_BLACK, height: 1.0, text_v_align: :center
|
||||
end
|
||||
|
||||
w.subscribe(:clicked_left_mouse_button) do
|
||||
@games_filter.clear
|
||||
@app_filters_container.children.each { |c| c.enabled = true }
|
||||
populate_server_list
|
||||
end
|
||||
end
|
||||
|
||||
flow(fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY, margin_left: PADDING) do
|
||||
@app_filters_container = flow(fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY, margin_left: PADDING) do
|
||||
MemCache[:applications].each do |app|
|
||||
next unless app.game?
|
||||
|
||||
button(safe_get_image("#{ROOT_PATH}/data/cache/#{app.id}.png"), style_class: [:app_icon_button], tip: app.name) do |btn|
|
||||
button(safe_get_image("#{CACHE_PATH}/icon_#{app.id}.png"), style_class: [:app_icon_button], tag: :"app_filter_#{app.id}", tip: app.name) do |btn|
|
||||
if shift_down?
|
||||
@games_filter << app.id
|
||||
@games_filter.uniq!
|
||||
@@ -33,6 +34,10 @@ module W3DHubLauncher
|
||||
@games_filter << app.id
|
||||
end
|
||||
|
||||
buttons = btn.parent.children
|
||||
filters = @games_filter.map { |f| :"app_filter_#{f}" }
|
||||
buttons.each { |e| e.enabled = !filters.include?(e.style.tag) }
|
||||
|
||||
btn.enabled = false
|
||||
|
||||
populate_server_list
|
||||
@@ -49,18 +54,34 @@ module W3DHubLauncher
|
||||
populate_server_list
|
||||
end
|
||||
|
||||
def focus
|
||||
CyberarmEngine::EventBus.subscribe("game_server_pings", self, :handle_game_server_pings)
|
||||
|
||||
@parent.show_server_details_panel
|
||||
end
|
||||
|
||||
def blur
|
||||
CyberarmEngine::EventBus.unsubscribe("game_server_pings", self)
|
||||
|
||||
@parent.hide_server_details_panel
|
||||
end
|
||||
|
||||
def handle_game_server_pings(payload)
|
||||
# TODO: Update displayed server ping
|
||||
end
|
||||
|
||||
def populate_server_list
|
||||
@servers_container.clear do
|
||||
(MemCache[:servers] || []).select { |s| @games_filter.empty? ? true : @games_filter.include?(s.game) }.sort_by { |s| s.player_count }.reverse.each do |server|
|
||||
(MemCache[:servers] || []).select { |s| @games_filter.empty? ? true : @games_filter.include?(s.game) }.sort_by { |s| [s.player_count, -s.ping] }.reverse.each do |server|
|
||||
app = MemCache[:applications].find { |a| a.id == server.game }
|
||||
|
||||
widget(width: 1.0, height: 48, padding_top: HALF_PADDING, padding_bottom: HALF_PADDING, margin_bottom: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: server.channel == "release" ? ALPHA_GRAY : 0xaa_c64600, hover: { background_nine_slice_color: 0xff_5e5c64 } , active: { background_nine_slice_color: 0xaa_5e5c64 }) do
|
||||
widget(width: 1.0, height: 56 + PADDING, padding_top: HALF_PADDING, padding_bottom: HALF_PADDING, margin_bottom: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: server.channel == "release" ? ALPHA_GRAY : 0xaa_c64600, hover: { background_nine_slice_color: 0xff_5e5c64 } , active: { background_nine_slice_color: 0xaa_5e5c64 }) do |w|
|
||||
# app icon container
|
||||
image(safe_get_image("#{ROOT_PATH}/data/cache/#{app.id}.png"), tip: app.name, width: 48, height: 1.0, margin_left: HALF_PADDING)
|
||||
image(safe_get_image("#{CACHE_PATH}/icon_#{app.id}.png"), tip: app.name, width: 56, height: 1.0, margin_left: HALF_PADDING)
|
||||
|
||||
# server name, region, and times container
|
||||
stack(fill: true, height: 1.0, margin_left: HALF_PADDING) do
|
||||
stack(v_align: :center) do
|
||||
stack(width: 1.0, v_align: :center) do
|
||||
# server name
|
||||
caption server.name, text_wrap: :none, tip: server.name
|
||||
# server info
|
||||
@@ -87,10 +108,14 @@ module W3DHubLauncher
|
||||
# server ping
|
||||
flow(width: 96, height: 1.0, margin_left: HALF_PADDING, margin_right: HALF_PADDING) do
|
||||
stack(fill: true, height: 1.0, v_align: :center) do
|
||||
caption rand > 0.85 ? "?" : format("%d ms", rand(16..360)), width: 1.0, text_align: :center
|
||||
caption server.ping == Worker::Api::GameServer::BAD_OR_UNKNOWN_PING ? "?" : format("%ims", server.ping), width: 1.0, text_align: :center, tip: server.ping == Worker::Api::GameServer::BAD_OR_UNKNOWN_PING ? "Server has not replied yet or did not reply to ICMP Echo Request.\nPing unknown." : ""
|
||||
inscription "ping", text_wrap: :none, width: 1.0, text_align: :center, margin_top: -HALF_PADDING
|
||||
end
|
||||
stack(width: 8, height: rand(0.25..1.0), v_align: :center, min_height: 8, background_nine_slice: NINE_SLICE_ROUNDED_SMALL, background_nine_slice_from_edge: NINE_SLICE_EDGE_SMALL, background_nine_slice_color: [0xff_26a269, 0xff_e5a50a, 0xff_a51d2d, 0xff_3d3846].sample)
|
||||
stack(width: 8, height: server.ping_score_ratio, v_align: :center, min_height: 8, background_nine_slice: NINE_SLICE_ROUNDED_SMALL, background_nine_slice_from_edge: NINE_SLICE_EDGE_SMALL, background_nine_slice_color: server.ping_score_color)
|
||||
end
|
||||
|
||||
w.subscribe(:clicked_left_mouse_button) do
|
||||
@parent.populate_server_information(server)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
module W3DHubLauncher
|
||||
class States
|
||||
class Interface < W3DHubLauncher::State
|
||||
attr_reader :application_task_status_bar_container
|
||||
|
||||
def setup
|
||||
super
|
||||
|
||||
@@ -11,7 +13,7 @@ module W3DHubLauncher
|
||||
# content container
|
||||
stack(width: 1.0, fill: true, margin: PADDING) do
|
||||
# header bar container
|
||||
flow(width: 1.0, height: 80, margin_bottom: PADDING) do |c|
|
||||
flow(width: 1.0, height: 100, margin_bottom: PADDING) do |c|
|
||||
# logo + menu button
|
||||
button(safe_get_image("#{ROOT_PATH}/media/logo.png"), image_height: 1.0, background: 0, border_color: 0, hover: { background: 0 }, active: { background: 0, color: 0xff_ffffff }) do |btn|
|
||||
menu(parent: btn) do
|
||||
@@ -30,9 +32,9 @@ module W3DHubLauncher
|
||||
# stack(fill: true)
|
||||
|
||||
# navigation container
|
||||
flow(width: 1.0) do
|
||||
link("GAMES", text_v_align: :center, font: FONT_BLACK, margin_left: PADDING) { page(Page::Games) }
|
||||
link("SERVERS", text_v_align: :center, font: FONT_BLACK, margin_left: PADDING) { page(Page::ServerBrowser) }
|
||||
flow(width: 1.0, fill: true) do
|
||||
link("GAMES",height: 1.0, text_v_align: :center, font: FONT_BLACK, margin_left: PADDING) { page(Page::Games) }
|
||||
link("SERVERS",height: 1.0, text_v_align: :center, font: FONT_BLACK, margin_left: PADDING) { page(Page::ServerBrowser) }
|
||||
stack(fill: true)
|
||||
image safe_get_image("#{ROOT_PATH}/media/icons/import.png"), height: 40, color: 0xff_bbbbbb, tip: "Downloads" do |img|
|
||||
dialog(Dialog::Downloads)
|
||||
@@ -40,31 +42,31 @@ module W3DHubLauncher
|
||||
image safe_get_image("#{ROOT_PATH}/media/icons/information.png"), height: 40, color: 0xff_bbbbbb, tip: "Notifications"
|
||||
end
|
||||
# application task status bar container
|
||||
stack(width: 1.0, fill: true, margin_left: PADDING) do
|
||||
@application_task_status_bar_container = stack(width: 1.0, fill: true, margin_left: PADDING, visible: false) do
|
||||
flow(width: 1.0) do
|
||||
para "Updating Red Alert: A Path Beyond (Release)"
|
||||
para "Updating Red Alert: A Path Beyond (Release)", tag: :status_bar_title
|
||||
stack(fill: true)
|
||||
para "Fetching manifests..."
|
||||
para "Fetching manifests...", tag: :status_bar_label
|
||||
end
|
||||
progress(width: 1.0, fraction: 0.75)
|
||||
progress(width: 1.0, fraction: 0.0, tag: :status_bar_progress)
|
||||
end
|
||||
end
|
||||
|
||||
# self account container
|
||||
flow(width: 300, height: 80, margin_left: LARGE_PADDING) do
|
||||
@account_container = flow(width: 400, height: 80, margin_left: LARGE_PADDING) do
|
||||
# self avatar container
|
||||
stack(width: 80, height: 1.0, background_image: rounded_avatar(safe_get_image("#{ROOT_PATH}/media/default.png"))) do |i|
|
||||
stack(width: 80, height: 1.0, background_image: rounded_avatar(safe_get_image("#{ROOT_PATH}/media/default.png")), tag: :account_avatar) do |i|
|
||||
i.subscribe(:clicked_left_mouse_button) do
|
||||
dialog(Dialog::Account)
|
||||
end
|
||||
# self online state container
|
||||
stack(width: 20, height: 20, v_align: :bottom, h_align: :right, background_image: safe_get_image("#{ROOT_PATH}/media/ui/circle_small.png"), background_image_color: 0xff_26a269)
|
||||
stack(width: 20, height: 20, v_align: :bottom, h_align: :right, background_image: safe_get_image("#{ROOT_PATH}/media/ui/circle_small.png"), background_image_color: 0xff_26a269, tag: :account_online_state)
|
||||
end
|
||||
|
||||
stack(fill: true, height: 1.0, margin_left: HALF_PADDING) do
|
||||
flow(fill: true)
|
||||
# self name
|
||||
caption "cyberarm", font: FONT_BLACK, text_wrap: :none
|
||||
caption "cyberarm", font: FONT_BLACK, text_wrap: :none, width: 1.0, tag: :account_name
|
||||
# self set online state
|
||||
link "Online ▼", text_size: 18 do |l|
|
||||
menu(parent: l) do
|
||||
@@ -86,8 +88,12 @@ module W3DHubLauncher
|
||||
@page_host = stack(fill: true, height: 1.0) do
|
||||
end
|
||||
|
||||
# server details container
|
||||
@server_details_container = stack(width: 400, height: 1.0, margin_left: LARGE_PADDING, padding: PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
|
||||
end
|
||||
|
||||
# battleview/friends container
|
||||
@battleview_container = stack(width: 300, height: 1.0, margin_left: LARGE_PADDING, visible: true) do
|
||||
@battleview_container = stack(width: 400, height: 1.0, margin_left: LARGE_PADDING) do
|
||||
# friend management container
|
||||
flow(width: 1.0, height: 60) do
|
||||
flow(width: 1.0, v_align: :center) do
|
||||
@@ -101,25 +107,25 @@ module W3DHubLauncher
|
||||
stack(width: 1.0, fill: true, margin_top: LARGE_PADDING, scroll: true) do
|
||||
50.times do |i|
|
||||
# friend container
|
||||
widget(width: 1.0, height: 48, padding_top: HALF_PADDING, padding_bottom: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0, hover: { background_nine_slice_color: ALPHA_GRAY }, active: { background_nine_slice_color: ALPHA_BLACK }) do |w|
|
||||
widget(width: 1.0, height: 68, padding_top: HALF_PADDING, padding_bottom: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0, hover: { background_nine_slice_color: ALPHA_GRAY }, active: { background_nine_slice_color: ALPHA_BLACK }) do |w|
|
||||
w.subscribe(:clicked_left_mouse_button) do
|
||||
puts "HELLO THERE"
|
||||
end
|
||||
|
||||
|
||||
# friend avatar container
|
||||
stack(width: 48, height: 1.0, margin_left: HALF_PADDING, background_image: rounded_avatar(safe_get_image("#{ROOT_PATH}/media/default.png"))) do
|
||||
stack(width: 48 + HALF_PADDING, height: 1.0, margin_left: HALF_PADDING, background_image: rounded_avatar(safe_get_image("#{ROOT_PATH}/media/default.png")), background_image_mode: :fill) do
|
||||
stack(width: 12, height: 12, v_align: :bottom, h_align: :right, background_image: safe_get_image("#{ROOT_PATH}/media/ui/circle_small.png"), background_image_color: 0xff_26a269)
|
||||
end
|
||||
# friend name and status container
|
||||
stack(fill: true, height: 1.0, margin_left: HALF_PADDING, margin_right: HALF_PADDING) do
|
||||
stack(v_align: :center) do
|
||||
stack(width: 1.0, v_align: :center) do
|
||||
caption ["Silverlight", "PXD2000", "Alstar", "SteelGhost", "FRAYDO"].sample, text_wrap: :none
|
||||
inscription "RA_Under • 13:52", text_wrap: :none, margin_top: -HALF_PADDING
|
||||
end
|
||||
end
|
||||
# friend active application container
|
||||
stack(width: 48, height: 1.0, margin_right: HALF_PADDING, background_image: safe_get_image("#{ROOT_PATH}/media/logo.png"))
|
||||
stack(width: 48, height: 1.0, margin_right: HALF_PADDING, background_image: safe_get_image("#{ROOT_PATH}/media/logo.png"), background_image_mode: :fill)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -129,9 +135,226 @@ module W3DHubLauncher
|
||||
end
|
||||
end
|
||||
|
||||
hide_server_details_panel
|
||||
|
||||
page(Page::Games)
|
||||
end
|
||||
|
||||
def hide_battleview_panel
|
||||
@battleview_container.hide
|
||||
end
|
||||
|
||||
def show_battleview_panel
|
||||
@battleview_container.show
|
||||
end
|
||||
|
||||
def hide_server_details_panel
|
||||
@server_details_container.hide
|
||||
end
|
||||
|
||||
def show_server_details_panel
|
||||
@server_details_container.show
|
||||
end
|
||||
|
||||
def populate_server_information(server)
|
||||
return unless @server_details_container.visible?
|
||||
|
||||
# FIXME: check server channel against application channel's `server_channel`
|
||||
application = MemCache[:settings].applications.find { |app| app.id == server.game && app.channel == server.channel }
|
||||
# channel = MemCache[:applications].find { |appl| appl.id == application&.id }&.channels&.find { |c| c.id == application&.channel }
|
||||
|
||||
@server_details_container.clear do
|
||||
tagline server.name, width: 1.0, text_wrap: :none, tip: server.name
|
||||
map_image_container = stack(width: 1.0, height: (@server_details_container.content_width / 640.0) * 360, background_image: safe_get_image(server.map_preview_image_path, fallback_path: "#{ROOT_PATH}/media/default_map_preview.png")) do
|
||||
caption(
|
||||
server.current_map,
|
||||
text_border: false,
|
||||
text_border_size: 1,
|
||||
text_border_color: Gosu::Color::RED,
|
||||
text_shadow: false,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
padding_bottom: PADDING,
|
||||
text_align: :center,
|
||||
text_v_align: :bottom,
|
||||
tip: server.current_map
|
||||
)
|
||||
end
|
||||
unless File.exist?(server.map_preview_image_path)
|
||||
Worker::Api.server_map_image(server) do |result, status|
|
||||
# If the requested image is still the needed image?
|
||||
next unless @server_details_container.children.include?(map_image_container)
|
||||
next unless result.okay?
|
||||
|
||||
post_process_map_preview_image(result.data["image_path"])
|
||||
map_image_container.background_image = safe_get_image(result.data["image_path"])
|
||||
end
|
||||
end
|
||||
|
||||
flow(width: 1.0, margin_top: PADDING) do
|
||||
button "JOIN SERVER", **CTA_BUTTON_THEME, width: 1.0, enabled: !application.nil?, tip: application.nil? ? "Application not installed" : "" do
|
||||
ApplicationHelper.join_server(application, server)
|
||||
end
|
||||
end
|
||||
|
||||
stack(width: 1.0, fill: true, scroll: true, margin_top: PADDING) do
|
||||
# misc. server details
|
||||
stack(width: 1.0, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
|
||||
flow(width: 1.0) do
|
||||
stack(width: 1.0 / 3) do
|
||||
caption "Map", text_wrap: :none, tip: "Map", width: 1.0
|
||||
inscription server.current_map, text_wrap: :none, width: 1.0, tip: server.current_map
|
||||
end
|
||||
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
|
||||
display_ping = server.ping == Worker::Api::GameServer::BAD_OR_UNKNOWN_PING ? "?" : format("%dms", server.ping)
|
||||
caption "Ping", text_wrap: :none, tip: "Ping", width: 1.0
|
||||
inscription display_ping, text_wrap: :none, width: 1.0, tip: display_ping
|
||||
end
|
||||
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
|
||||
caption "Players", text_wrap: :none, tip: "Players", width: 1.0
|
||||
inscription format("%d/%d", server.player_count, server.max_players), text_wrap: :none, width: 1.0, tip: format("%d/%d", server.player_count, server.max_players)
|
||||
end
|
||||
end
|
||||
|
||||
flow(width: 1.0) do
|
||||
stack(width: 1.0 / 3) do
|
||||
caption "Next Map", text_wrap: :none, tip: "Next Map", width: 1.0
|
||||
inscription server.next_map, text_wrap: :none, width: 1.0, tip: server.next_map
|
||||
end
|
||||
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
|
||||
caption "Time", text_wrap: :none, tip: "Time", width: 1.0
|
||||
inscription "00:00:00", text_wrap: :none, width: 1.0, tip: "00:00:00"
|
||||
end
|
||||
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
|
||||
caption "Time Left", text_wrap: :none, tip: "Time Left", width: 1.0
|
||||
inscription server.match_remaining_time, text_wrap: :none, width: 1.0, tip: server.match_remaining_time
|
||||
end
|
||||
end
|
||||
|
||||
flow(width: 1.0) do
|
||||
stack(width: 1.0 / 3) do
|
||||
caption "Region", text_wrap: :none, tip: "Region", width: 1.0
|
||||
inscription server.region, text_wrap: :none, width: 1.0, tip: server.region
|
||||
end
|
||||
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
|
||||
caption "Channel", text_wrap: :none, tip: "Channel", width: 1.0
|
||||
inscription server.channel, text_wrap: :none, width: 1.0, tip: server.channel
|
||||
end
|
||||
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
|
||||
caption "Version", text_wrap: :none, tip: "Version", width: 1.0
|
||||
inscription server.version, text_wrap: :none, width: 1.0, tip: server.version
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# team data
|
||||
server.teams.each do |team|
|
||||
# ignore empty, non-standard teams
|
||||
next if !team.id.between?(0, 1) && team.players.empty?
|
||||
|
||||
stack(width: 1.0, margin_top: PADDING, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
|
||||
tagline team.name, text_wrap: :none, tip: team.name
|
||||
|
||||
flow(width: 1.0) do
|
||||
stack(width: 1 / 4.0) do
|
||||
caption "Score", text_wrap: :none, tip: "Score"
|
||||
inscription team.score, text_wrap: :none, tip: team.score
|
||||
end
|
||||
stack(width: 1 / 4.0, margin_left: HALF_PADDING) do
|
||||
caption "Kills", text_wrap: :none, tip: "Kills"
|
||||
inscription team.kills, text_wrap: :none, tip: team.kills
|
||||
end
|
||||
stack(width: 1 / 4.0, margin_left: HALF_PADDING) do
|
||||
caption "Deaths", text_wrap: :none, tip: "Deaths"
|
||||
inscription team.deaths, text_wrap: :none, tip: team.deaths
|
||||
end
|
||||
stack(width: 1 / 4.0, margin_left: HALF_PADDING) do
|
||||
caption "Players", text_wrap: :none, tip: "Players"
|
||||
inscription format("%d/%d", team.players.size, server.max_players), text_wrap: :none, tip: format("%d/%d", team.players.size, server.max_players)
|
||||
end
|
||||
end
|
||||
|
||||
next if team.players.empty?
|
||||
|
||||
flow(width: 1.0, height: 2, background: 0xaa_000000)
|
||||
|
||||
flow(width: 1.0) do
|
||||
stack(fill: true, margin_right: HALF_PADDING) do
|
||||
inscription "Name", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Name"
|
||||
end
|
||||
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
|
||||
inscription "Score", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Score"
|
||||
end
|
||||
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
|
||||
inscription "Kills", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Kills"
|
||||
end
|
||||
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
|
||||
inscription "Deaths", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Deaths"
|
||||
end
|
||||
# stack(width: 1 / 8.0, margin_right: HALF_PADDING) do
|
||||
# inscription "Ping", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Ping"
|
||||
# end
|
||||
# stack(width: 1 / 6.0) do
|
||||
# inscription "Time", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Time"
|
||||
# end
|
||||
end
|
||||
|
||||
team.players.sort_by(&:score).reverse.each_with_index do |player, i|
|
||||
flow(width: 1.0, background: i.odd? ? 0 : 0xaa_000000) do
|
||||
stack(fill: true, margin_right: HALF_PADDING) do
|
||||
inscription player.name, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.name
|
||||
end
|
||||
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
|
||||
inscription player.score, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.score
|
||||
end
|
||||
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
|
||||
inscription player.kills, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.kills
|
||||
end
|
||||
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
|
||||
inscription player.deaths, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.deaths
|
||||
end
|
||||
# stack(width: 1 / 8.0, margin_right: HALF_PADDING) do
|
||||
# inscription player.ping, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.ping
|
||||
# end
|
||||
# stack(width: 1 / 5.0) do
|
||||
# inscription player.time, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.time
|
||||
# end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Get them rounded corners!
|
||||
def post_process_map_preview_image(path)
|
||||
image = Gosu::Image.new(path)
|
||||
|
||||
target_width = 640
|
||||
target_height= 360
|
||||
|
||||
nine_slice = CyberarmEngine::BackgroundNineSlice.new(
|
||||
image_path: NINE_SLICE_ROUNDED,
|
||||
mode: :stretch,
|
||||
width: target_width,
|
||||
height: target_height,
|
||||
left: NINE_SLICE_EDGE,
|
||||
right: NINE_SLICE_EDGE,
|
||||
top: NINE_SLICE_EDGE,
|
||||
bottom: NINE_SLICE_EDGE,
|
||||
render_mode: :multiply
|
||||
)
|
||||
|
||||
composite = Gosu.render(target_width, target_height) do
|
||||
image_scale = [target_width / image.width.to_f, target_height / image.height.to_f].max
|
||||
image.draw_rot(target_width / 2, target_height / 2, 0, 0, 0.5, 0.5, image_scale, image_scale)
|
||||
nine_slice.draw
|
||||
end
|
||||
|
||||
composite.save(path)
|
||||
end
|
||||
|
||||
def button_up(id)
|
||||
super
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ module W3DHubLauncher
|
||||
|
||||
@main_thread_queue = []
|
||||
|
||||
CyberarmEngine::EventBus.subscribe("game_server_pings", self) do |payload|
|
||||
MemCache[:game_server_pings] = payload
|
||||
end
|
||||
|
||||
push_state(States::Boot)
|
||||
# push_state(States::Interface)
|
||||
end
|
||||
|
||||
181
lib/worker.rb
181
lib/worker.rb
@@ -4,15 +4,19 @@ module W3DHubLauncher
|
||||
["user-agent", W3DHubLauncher::USER_AGENT]
|
||||
].freeze
|
||||
DEFAULT_NETWORK_TIMEOUT = 30
|
||||
RESPONSE_SEPARATOR = "\04".freeze
|
||||
|
||||
Response = Data.define(:status, :request_id, :result)
|
||||
|
||||
attr_reader :w3dhub_api, :settings
|
||||
|
||||
def initialize
|
||||
end
|
||||
|
||||
def connect
|
||||
puts :connect
|
||||
|
||||
@buffer = StringIO.new
|
||||
@socket = UNIXSocket.new(IPC_PATH)
|
||||
end
|
||||
|
||||
@@ -27,8 +31,11 @@ module W3DHubLauncher
|
||||
def init_server
|
||||
@threads = []
|
||||
@requests = []
|
||||
@tasks = []
|
||||
|
||||
@settings = 0# Settings.new
|
||||
@client = nil
|
||||
|
||||
@settings = nil
|
||||
@game_servers = []
|
||||
@host_pings = {}
|
||||
|
||||
@@ -44,6 +51,8 @@ module W3DHubLauncher
|
||||
Async do |task|
|
||||
UNIXServer.open(IPC_PATH) do |server|
|
||||
while(socket = server.accept)
|
||||
@client = socket
|
||||
|
||||
task.async do
|
||||
while(data = socket.gets)
|
||||
json = JSON.parse(data)
|
||||
@@ -55,7 +64,9 @@ module W3DHubLauncher
|
||||
response = send(query.type, query)
|
||||
# pp [:server_to_client, response]
|
||||
payload = { status: response.status, request_id: response.request_id, data: response.result.data, error: response.result.error }.to_json
|
||||
socket.puts(payload)
|
||||
socket.write(payload)
|
||||
socket.write(RESPONSE_SEPARATOR)
|
||||
socket.flush
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -111,6 +122,8 @@ module W3DHubLauncher
|
||||
# puts "Timed out waiting for: #{echo_requests.values.select { |v| v[:replied] == false }.map { |v| v[:address] }.join(', ')}"
|
||||
end
|
||||
|
||||
broadcast(:game_server_pings, data: @host_pings)
|
||||
|
||||
sleep 5
|
||||
end
|
||||
end
|
||||
@@ -119,6 +132,23 @@ module W3DHubLauncher
|
||||
socket&.close
|
||||
end
|
||||
|
||||
def broadcast(type, data:)
|
||||
payload = { type: :eventbus, request_id: -1, data: { type: type, data: data } }.to_json
|
||||
|
||||
@client&.write(payload)
|
||||
@client&.write(RESPONSE_SEPARATOR)
|
||||
@client&.flush
|
||||
end
|
||||
|
||||
def message_requester(request_id:, status:, data:, error: nil)
|
||||
payload = { status: status, request_id: request_id, data: data, error: error }.to_json
|
||||
|
||||
@client&.write(payload)
|
||||
@client&.write(RESPONSE_SEPARATOR)
|
||||
@client&.flush
|
||||
end
|
||||
|
||||
|
||||
# Send request to server
|
||||
def request(query)
|
||||
# pp [:client_request, query]
|
||||
@@ -133,19 +163,36 @@ module W3DHubLauncher
|
||||
|
||||
def service
|
||||
data = @socket.read_nonblock(1_048_576) # 1 MB
|
||||
# pp [:CLIENT, data]
|
||||
json = JSON.parse(data)
|
||||
request = W3DHubLauncher::Worker::Request.requests.find { |r| r.request_id == json["request_id"] }
|
||||
@buffer << data
|
||||
|
||||
# pp [json, request]
|
||||
return unless request
|
||||
@buffer.rewind
|
||||
payloads = @buffer.readlines(RESPONSE_SEPARATOR, chomp: true)
|
||||
@buffer.reopen(@buffer.string[@buffer.pos..@buffer.length])
|
||||
@buffer.pos = @buffer.length
|
||||
|
||||
CyberarmEngine::Window.instance&.add_to_queue(proc {
|
||||
request.handle_event(
|
||||
json["status"],
|
||||
CyberarmEngine::Result.new(data: json["data"], error: json["error"])
|
||||
)
|
||||
})
|
||||
payloads.each do |payload|
|
||||
# pp [:CLIENT, data]
|
||||
json = JSON.parse(payload)
|
||||
request = W3DHubLauncher::Worker::Request.requests.find { |r| r.request_id == json["request_id"] }
|
||||
|
||||
# pp [json, request]
|
||||
if request.nil? && json["request_id"] && json["type"] == "eventbus"
|
||||
CyberarmEngine::Window.instance&.add_to_queue(proc {
|
||||
CyberarmEngine::EventBus.publish(json["data"]["type"], json["data"]["data"])
|
||||
})
|
||||
else
|
||||
CyberarmEngine::Window.instance&.add_to_queue(proc {
|
||||
request.handle_event(
|
||||
json["status"],
|
||||
CyberarmEngine::Result.new(data: json["data"], error: json["error"])
|
||||
)
|
||||
})
|
||||
end
|
||||
|
||||
rescue JSON::ParserError => e
|
||||
puts "This should never happen!?!?!"
|
||||
pp data, payload
|
||||
end
|
||||
|
||||
rescue Errno::EWOULDBLOCK
|
||||
end
|
||||
@@ -157,6 +204,14 @@ module W3DHubLauncher
|
||||
response
|
||||
end
|
||||
|
||||
def create_directory(path)
|
||||
pathname = Pathname.new(path)
|
||||
|
||||
unless Dir.exist?(pathname.dirname)
|
||||
FileUtils.mkdir_p(pathname.dirname)
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# ------------ query / request handlers ------------
|
||||
#
|
||||
@@ -194,6 +249,8 @@ module W3DHubLauncher
|
||||
headers = query.data["headers"] || DEFAULT_HEADERS
|
||||
body = query.data["body"]
|
||||
|
||||
create_directory(path)
|
||||
|
||||
Sync do |task|
|
||||
task.with_timeout(DEFAULT_NETWORK_TIMEOUT) do
|
||||
Async::HTTP::Internet.send(method, url, headers, body) do |response|
|
||||
@@ -230,6 +287,80 @@ module W3DHubLauncher
|
||||
deliver_response(result, query)
|
||||
end
|
||||
|
||||
def ico_to_png(query)
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
begin
|
||||
ico = ICO.new(file: query.data["ico_path"])
|
||||
ico.save(ico.images.max_by(&:width), query.data["png_path"])
|
||||
result.data = { path: query.data["png_path"] }
|
||||
rescue => e
|
||||
result.error = e
|
||||
end
|
||||
|
||||
deliver_response(result, query)
|
||||
end
|
||||
|
||||
def server_map_image(query)
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
# TODO: Ideally battleview would host readily accessible map previews
|
||||
server = @game_servers.find { |s| s.address == query.data["server"]["address"] && s.port == query.data["server"]["port"] }
|
||||
return deliver_response(result, query) unless server
|
||||
|
||||
# is application installed to source data from?
|
||||
application = @settings.application_installed?(server.game, server.channel)
|
||||
return deliver_response(result, query) unless application
|
||||
|
||||
save_path = server.map_preview_image_path
|
||||
if File.exist?(save_path)
|
||||
result.data = { image_path: save_path }
|
||||
|
||||
return deliver_response(result, query)
|
||||
end
|
||||
|
||||
map_mix = Dir.glob("#{application.installation_path}/**/#{query.data["server"]["map"]}")&.first
|
||||
return deliver_response(result, query) unless map_mix
|
||||
|
||||
mix = W3DHubLauncher::WWMix.new(path: map_mix)
|
||||
return deliver_response(result, query) unless mix.load
|
||||
|
||||
entry = nil
|
||||
canvas = nil
|
||||
|
||||
entry = mix.entries.find { |e| e.name.match?(/_map_preview\.png/i) }
|
||||
entry ||= mix.entries.find { |e| e.name.match?(/_map_preview\.dds/i) }
|
||||
entry ||= mix.entries.find { |e| e.name.match?(/screenshot\.png/i) }
|
||||
entry ||= mix.entries.find { |e| e.name.match?(/screenshot\.dds/i) }
|
||||
entry ||= mix.entries.find { |e| e.name.match?(/_map\.png/i) }
|
||||
entry ||= mix.entries.find { |e| e.name.match?(/_map\.dds/i) }
|
||||
|
||||
return deliver_response(result, query) unless entry
|
||||
return deliver_response(result, query) unless entry.read
|
||||
|
||||
if entry.name.match?(/\.dds/i)
|
||||
begin
|
||||
dds = W3DHubLauncher::DDS.new(io: StringIO.new(entry.blob))
|
||||
image_data = dds.images.first
|
||||
|
||||
canvas = ChunkyPNG::Canvas.from_rgba_stream(image_data.width, image_data.height, image_data.data)
|
||||
canvas.save(save_path)
|
||||
rescue StandardError => e
|
||||
result.error = e
|
||||
|
||||
return deliver_response(result, query)
|
||||
end
|
||||
|
||||
elsif entry.name.match?(/\.png/i)
|
||||
# canvas = ChunkyPNG::Canvas.from_blob(entry.blob)
|
||||
File.binwrite(save_path, entry.blob)
|
||||
end
|
||||
|
||||
result.data = { image_path: save_path }
|
||||
|
||||
deliver_response(result, query)
|
||||
end
|
||||
|
||||
def dns_resolution(query)
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
@@ -263,6 +394,7 @@ module W3DHubLauncher
|
||||
begin
|
||||
if File.exist?(path) && File.size(path).positive?
|
||||
json = File.read(path)
|
||||
@settings = Api::Settings.new(JSON.parse(json))
|
||||
result.data = json
|
||||
else
|
||||
result.error = RuntimeError.new("Launcher settings file does not exist or is empty.")
|
||||
@@ -310,19 +442,32 @@ module W3DHubLauncher
|
||||
deliver_response(response.result, query)
|
||||
end
|
||||
|
||||
def install_application(query)
|
||||
def task_install_application(query)
|
||||
application = @w3dhub_api.applications.find { |app| app.id == query.data["app_id"] }
|
||||
channel = application.channels.find { |chan| chan.id == query.data["channel_id"] }
|
||||
|
||||
@tasks << Task::InstallApplication.new(
|
||||
request_id: query.request_id,
|
||||
application: application,
|
||||
channel: channel,
|
||||
installed_version: nil,
|
||||
target_version: channel.version
|
||||
)
|
||||
@tasks.last.start(self)
|
||||
|
||||
Response.new(Request::STATUS_PENDING, query.request_id, CyberarmEngine::Result.new(data: true))
|
||||
end
|
||||
|
||||
def update_application(query)
|
||||
def task_update_application(query)
|
||||
end
|
||||
|
||||
def repair_application(query)
|
||||
def task_repair_application(query)
|
||||
end
|
||||
|
||||
def move_application(query)
|
||||
def task_move_application(query)
|
||||
end
|
||||
|
||||
def uninstall_application(query)
|
||||
def task_uninstall_application(query)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,6 +12,14 @@ module W3DHubLauncher
|
||||
Worker::Request.new(:download_url, { url: url, path: path, method: method, headers: headers, body: body }, &block)
|
||||
end
|
||||
|
||||
def self.ico_to_png(ico_path:, png_path:, &block)
|
||||
Worker::Request.new(:ico_to_png, { ico_path: ico_path, png_path: png_path }, &block)
|
||||
end
|
||||
|
||||
def self.server_map_image(server, &block)
|
||||
Worker::Request.new(:server_map_image, { server: { address: server.address, port: server.port, map: server.current_map } }, &block)
|
||||
end
|
||||
|
||||
def self.dns_resolution(&block)
|
||||
Worker::Request.new(:dns_resolution, "", &block)
|
||||
end
|
||||
@@ -50,42 +58,47 @@ module W3DHubLauncher
|
||||
|
||||
# returns news for application
|
||||
def self.news(category = "launcher-home", &block)
|
||||
Worker::Request.new(:news, category, &block)
|
||||
W3DHubLauncher::Worker::Request.new(:w3dhub_api_call, { call: :fetch_news, arguments: [category] }, &block)
|
||||
end
|
||||
|
||||
# returns news for application
|
||||
def self.events(app_id, &block)
|
||||
Worker::Request.new(:events, app_id, &block)
|
||||
def self.events(category, &block)
|
||||
W3DHubLauncher::Worker::Request.new(:w3dhub_api_call, { call: :fetch_events, arguments: [category] }, &block)
|
||||
end
|
||||
|
||||
# request installation of application
|
||||
#
|
||||
# periodically reports progress until completion
|
||||
def self.install_application(app_id, channel_id)
|
||||
def self.install_application(app_id, channel_id, &block)
|
||||
Worker::Request.new(:task_install_application, { app_id: app_id, channel_id: channel_id }, &block)
|
||||
end
|
||||
|
||||
# request update of application
|
||||
#
|
||||
# periodically reports progress until completion
|
||||
def self.update_application(app_id, channel_id)
|
||||
def self.update_application(app_id, channel_id, &block)
|
||||
Worker::Request.new(:task_update_application, { app_id: app_id, channel_id: channel_id }, &block)
|
||||
end
|
||||
|
||||
# request repair of application
|
||||
#
|
||||
# periodically reports progress until completion
|
||||
def self.repair_application(app_id, channel_id)
|
||||
def self.repair_application(app_id, channel_id, &block)
|
||||
Worker::Request.new(:task_repair_application, { app_id: app_id, channel_id: channel_id }, &block)
|
||||
end
|
||||
|
||||
# request relocation of application
|
||||
#
|
||||
# periodically reports progress until completion
|
||||
def self.move_application(app_id, channel_id)
|
||||
def self.move_application(app_id, channel_id, target_directory, &block)
|
||||
Worker::Request.new(:task_move_application, { app_id: app_id, channel_id: channel_id, target_directory: target_directory }, &block)
|
||||
end
|
||||
|
||||
# request removal of application
|
||||
#
|
||||
# periodically reports progress until completion
|
||||
def self.uninstall_application(app_id, channel_id)
|
||||
def self.uninstall_application(app_id, channel_id, &block)
|
||||
Worker::Request.new(:task_uninstall_application, { app_id: app_id, channel_id: channel_id }, &block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -68,7 +68,7 @@ module W3DHubLauncher
|
||||
def extended_data(key, default)
|
||||
v = @extended_data&.find { |d| d.name == key }&.value
|
||||
if v.nil?
|
||||
v = @_app.extended_data(key, value)
|
||||
v = @_app.extended_data(key, default)
|
||||
|
||||
return default if v.nil?
|
||||
end
|
||||
|
||||
@@ -3,6 +3,9 @@ module W3DHubLauncher
|
||||
class Api
|
||||
# Designed to work with cyberarm's Game Server Hub service data, may work with W3D Hub's service with degraded metadata.
|
||||
class GameServer
|
||||
BAD_OR_UNKNOWN_PING = 1_000_000
|
||||
NO_OR_DEFAULT_VERSION = "838".freeze
|
||||
|
||||
attr_reader :id, :game, :channel, :address, :port, :region,
|
||||
:name, :password, :current_map, :player_count, :max_players,
|
||||
:match_start_time, :estimated_end_time, :match_remaining_time,
|
||||
@@ -36,15 +39,17 @@ module W3DHubLauncher
|
||||
|
||||
# status player data
|
||||
data["status"]["players"].each do |player_data|
|
||||
team = @teams.find { |t| t.id == player_data["id"] }
|
||||
team = @teams.find { |t| t.id == player_data["team"] }
|
||||
next unless team
|
||||
|
||||
team.players << Player.new(player_data)
|
||||
end
|
||||
|
||||
# Cyberarm GSH extras
|
||||
@version = data["version"] || "838"
|
||||
@version = data["version"] || NO_OR_DEFAULT_VERSION
|
||||
@next_map = data["status"]["nextmap"] || ""
|
||||
|
||||
@version = Gem::Version.new(@version) if @version.split(".").size == 4
|
||||
end
|
||||
|
||||
# TODO: implement :)
|
||||
@@ -55,6 +60,39 @@ module W3DHubLauncher
|
||||
def password?
|
||||
@password
|
||||
end
|
||||
|
||||
def ping
|
||||
MemCache.dig(:game_server_pings, @address) || BAD_OR_UNKNOWN_PING
|
||||
end
|
||||
|
||||
def ping_score_ratio
|
||||
# under 50 is best, over 200 is worst
|
||||
case ping
|
||||
when 0..49
|
||||
return 1.0
|
||||
when 50..200
|
||||
1.0 - (ping / 200.0) + 0.1
|
||||
else
|
||||
return 0.1
|
||||
end
|
||||
end
|
||||
|
||||
def ping_score_color
|
||||
case ping
|
||||
when 0..49 # gentle green
|
||||
0xff_26a269
|
||||
when 50..149 # rich orange
|
||||
0xff_e5a50a
|
||||
when 150..200 # dark red
|
||||
0xff_a51d2d
|
||||
else # dark gray
|
||||
0xff_3d3846
|
||||
end
|
||||
end
|
||||
|
||||
def map_preview_image_path
|
||||
format("%s/map_preview_%s.png", W3DHubLauncher::CACHE_PATH, Digest::SHA256.hexdigest("#{@game}_#{@channel}_#{@current_map}"))
|
||||
end
|
||||
end
|
||||
|
||||
class Team
|
||||
|
||||
95
lib/worker/api/legacy_manifest.rb
Normal file
95
lib/worker/api/legacy_manifest.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
class LegacyManifest
|
||||
attr_reader :game, :type, :version, :base_version, :files, :dependencies
|
||||
|
||||
def initialize(file_path)
|
||||
@document = REXML::Document.new(File.read(file_path))
|
||||
root = @document.root
|
||||
|
||||
@game = root["game"]
|
||||
@type = root["type"]
|
||||
@version = root["version"] ? Gem::Version.new(root["version"]) : nil
|
||||
@base_version = root["baseVersion"] ? Gem::Version.new(root["baseVersion"]) : nil
|
||||
|
||||
@files = []
|
||||
@dependencies = []
|
||||
|
||||
parse_files
|
||||
parse_dependencies
|
||||
end
|
||||
|
||||
def patch?
|
||||
@type == "Patch"
|
||||
end
|
||||
|
||||
def full?
|
||||
@type == "Full"
|
||||
end
|
||||
|
||||
def parse_files
|
||||
@document.root.elements.each("//File") do |element|
|
||||
@files.push(ManifestFile.new(element, @version))
|
||||
end
|
||||
end
|
||||
|
||||
def parse_dependencies
|
||||
@document.root.elements.each("//Dependency") do |element|
|
||||
@dependencies.push(Dependency.new(element))
|
||||
end
|
||||
end
|
||||
|
||||
class ManifestFile
|
||||
attr_reader :name, :checksum, :package, :patch_package, :removed_since, :from, :version
|
||||
|
||||
def initialize(xml, version)
|
||||
@data = xml
|
||||
|
||||
@name = @data["name"]
|
||||
@checksum = @data["checksum"]
|
||||
@package = @data["package"]
|
||||
@removed_since = @data["removedsince"] ? Gem::Version.new(@data["removedsince"]) : nil
|
||||
|
||||
xml.elements.each("Patch") do |patch|
|
||||
@patch = true
|
||||
|
||||
@from = patch["from"]
|
||||
|
||||
if @package.to_s.empty?
|
||||
@package = patch["package"]
|
||||
else
|
||||
@patch_package = patch["package"]
|
||||
end
|
||||
end
|
||||
|
||||
@version = version ? Gem::Version.new(version) : nil
|
||||
end
|
||||
|
||||
def removed?
|
||||
@removed_since
|
||||
end
|
||||
|
||||
def patch?
|
||||
@patch
|
||||
end
|
||||
|
||||
def full_and_patch?
|
||||
@package && @patch_package
|
||||
end
|
||||
end
|
||||
|
||||
class Dependency
|
||||
attr_reader :name, :removed_since
|
||||
|
||||
def initialize(xml)
|
||||
@data = xml
|
||||
|
||||
@name = @data["name"]
|
||||
@removed_since = @data["removed_since"]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
72
lib/worker/api/legacy_manifest_package.rb
Normal file
72
lib/worker/api/legacy_manifest_package.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
require "json"
|
||||
require "digest/sha2"
|
||||
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
class LegacyManifestPackage
|
||||
attr_reader :category, :subcategory, :version, :name, :error, :file_size,
|
||||
:checksum_chunk_size, :sha256_checksum, :checksum_chunks, :download_url
|
||||
|
||||
def initialize(hash)
|
||||
@category = hash["category"]
|
||||
@subcategory = hash["subcategory"]
|
||||
@version = Gem::Version.new(hash["version"] || "0.0.0.0")
|
||||
@name = hash["name"]
|
||||
@error = hash["error"]
|
||||
@file_size = Integer(hash["size"] || 0)
|
||||
@checksum_chunk_size = Integer(hash["checksum-chunk-size"] || 0)
|
||||
@sha256_checksum = hash["checksum"]
|
||||
@checksum_chunks = hash["checksum-chunks"]
|
||||
@download_url = hash["download_url"]
|
||||
end
|
||||
|
||||
def error?
|
||||
@error
|
||||
end
|
||||
|
||||
def version?
|
||||
@version != Gem::Version.new("0.0.0.0")
|
||||
end
|
||||
|
||||
# checksum whole file and file chunks until a mismatch occurs or the whole file is verified.
|
||||
def verify_file(filename)
|
||||
return false unless File.exist?(filename) && !File.directory?(filename)
|
||||
|
||||
file_size = File.size(filename)
|
||||
|
||||
overall_digest = Digest::SHA256.new
|
||||
chunk_digest = Digest::SHA256.new
|
||||
|
||||
File.open(filename, "rb") do |f|
|
||||
f.pos = 0
|
||||
offset = 0
|
||||
last_valid_offset = 0
|
||||
|
||||
while (chunk = f.read(@checksum_chunk_size))
|
||||
overall_digest << chunk
|
||||
|
||||
# return last valid chunk on invalid chunk
|
||||
return last_valid_offset unless @checksum_chunks[offset.to_s] == chunk_digest.update(chunk).hexdigest.upcase
|
||||
|
||||
last_valid_offset = offset
|
||||
offset += @checksum_chunk_size
|
||||
chunk_digest.reset
|
||||
end
|
||||
end
|
||||
|
||||
# return boolean after completely digesting file
|
||||
@sha256_checksum == overall_digest.hexdigest.upcase
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def package_cache_filename(category, subcategory)
|
||||
if subcategory.empty?
|
||||
category
|
||||
else
|
||||
"#{category}-#{subcategory}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
28
lib/worker/api/news_item.rb
Normal file
28
lib/worker/api/news_item.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
class NewsItem
|
||||
attr_reader :topic_id, :title, :blurb, :uri, :image_uri, :author_id, :author, :author_uri, :timestamp
|
||||
|
||||
def initialize(data)
|
||||
@topic_id = Integer(data["topic-id"] || 0)
|
||||
@title = data["title"].to_s
|
||||
@blurb = clean_and_scrub_blurb(data["blurb"].to_s)
|
||||
@uri = data["uri"].to_s
|
||||
@image_uri = data["image"].to_s
|
||||
|
||||
@author_id = data["author-id"].to_s
|
||||
@author = data["author"].to_s
|
||||
@author_uri = data["author-uri"].to_s
|
||||
|
||||
@timestamp = Time.at(Integer(data["timestamp"] || 0), in: "UTC").localtime
|
||||
end
|
||||
|
||||
# Replaces multiple newlines with a single newline, and trims off any leading and trailing whitespace.
|
||||
def clean_and_scrub_blurb(string)
|
||||
string.gsub(/\n+/, "\n").strip
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
17
lib/worker/api/server_event.rb
Normal file
17
lib/worker/api/server_event.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
class ServerEvent
|
||||
attr_reader :title, :image_uri, :app_id, :start_time, :end_time, :timestamp
|
||||
def initialize(data)
|
||||
@title = data["title"]
|
||||
@image_uri = data["image"]
|
||||
@app_id = data["server"]
|
||||
@start_time = Time.parse(data["starttime"], in: "UTC").localtime
|
||||
@end_time = Time.parse(data["endtime"], in: "UTC").localtime
|
||||
@timestamp = Time.parse(data["dateTime"], in: "UTC").localtime
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -34,6 +34,13 @@ module W3DHubLauncher
|
||||
}.to_json(options)
|
||||
end
|
||||
|
||||
def application_installed?(application, channel)
|
||||
app_id = application.is_a?(String) ? application : application.id
|
||||
channel_id = channel.is_a?(String) ? channel : channel.id
|
||||
|
||||
applications.find { |app| app.id == app_id && app.channel == channel_id }
|
||||
end
|
||||
|
||||
# User explictly set options
|
||||
class Preferences
|
||||
SCHEMA = 0
|
||||
@@ -74,17 +81,18 @@ module W3DHubLauncher
|
||||
SCHEMA = 0
|
||||
|
||||
attr_reader :id, :channel
|
||||
attr_accessor :version, :installation_path, :wine_prefix_path, :launch_command
|
||||
attr_accessor :version, :installation_path, :wine_prefix_path, :launch_command, :timestamp
|
||||
|
||||
def self.create(id:, channel:, version:, installation_path:, wine_prefix_path:, launch_command:)
|
||||
def self.create(id:, channel:, version:, installation_path:, wine_prefix_path:, launch_command:, timestamp:)
|
||||
hash = {
|
||||
"schema" => SCHEMA,
|
||||
"id" => id,
|
||||
"channel" => channel,
|
||||
"version" => Gem::Version.new(version),
|
||||
"version" => version,
|
||||
"installation_path" => installation_path,
|
||||
"wine_prefix_path" => wine_prefix_path,
|
||||
"launch_command" => launch_command,
|
||||
"timestamp" => timestamp
|
||||
}
|
||||
|
||||
self.new(hash)
|
||||
@@ -99,6 +107,7 @@ module W3DHubLauncher
|
||||
@installation_path = data["installation_path"]
|
||||
@wine_prefix_path = data["wine_prefix_path"]
|
||||
@launch_command = data["launch_command"]
|
||||
@timestamp = Time.at(data["timestamp"])
|
||||
end
|
||||
|
||||
def to_json(options = {})
|
||||
@@ -109,7 +118,8 @@ module W3DHubLauncher
|
||||
version: @version.to_s,
|
||||
installation_path: @installation_path,
|
||||
wine_prefix_path: @wine_prefix_path,
|
||||
launch_command: @launch_command
|
||||
launch_command: @launch_command,
|
||||
timestamp: @timestamp.to_i
|
||||
}.to_json(options)
|
||||
end
|
||||
end
|
||||
|
||||
19
lib/worker/api/test_event.rb
Normal file
19
lib/worker/api/test_event.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
class TestEvent
|
||||
attr_reader :title, :image_uri, :timestamp
|
||||
|
||||
def initialize(data)
|
||||
@title = data["name"]
|
||||
# @title = data["title"]
|
||||
@image_uri = data["image"]
|
||||
# @app_id = data["server"]
|
||||
# @start_time = Time.parse(data["starttime"]).localtime
|
||||
# @end_time = Time.parse(data["endtime"]).localtime
|
||||
@timestamp = Time.parse(data["dateTime"], in: "UTC").localtime
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,12 +3,6 @@ module W3DHubLauncher
|
||||
class Request
|
||||
Query = Data.define(:type, :request_id, :data)
|
||||
|
||||
FETCH_URL = 0
|
||||
DOWNLOAD_URL = 1
|
||||
W3DHUB_API_CALL = 10
|
||||
LAUNCHER_SETTINGS = 1000
|
||||
LAUNCHER_UPDATE_SETTINGS = 1001
|
||||
|
||||
STATUS_ERROR = -1 # request has failed
|
||||
STATUS_PENDING = 0 # request has not yet started
|
||||
STATUS_OK = 1 # request completed successfully
|
||||
@@ -54,9 +48,10 @@ module W3DHubLauncher
|
||||
def handle_event(event, data)
|
||||
# pp [event, data]
|
||||
|
||||
@callback&.call(data, event)
|
||||
|
||||
case event
|
||||
when STATUS_ERROR, STATUS_COMPLETE
|
||||
@callback&.call(data)
|
||||
Request.requests.delete(self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,561 @@
|
||||
module W3DHubLauncher
|
||||
class Task
|
||||
# ignore *.meta and paths.ini files
|
||||
IGNORED_FILES = [/\A.+\.meta\z/i, /\A.+\/paths\.ini\z/i].freeze
|
||||
|
||||
IndexedFile = Data.define(:type, :path)
|
||||
|
||||
attr_reader :request_id, :application, :channel, :installed_version, :target_version
|
||||
|
||||
def initialize(request_id:, application:, channel:, installed_version:, target_version:)
|
||||
@request_id = request_id
|
||||
@application = application
|
||||
@channel = channel
|
||||
@installed_version = installed_version
|
||||
@target_version = target_version
|
||||
|
||||
pp self
|
||||
|
||||
# caching mechinism for file path normalization
|
||||
@file_index = {}
|
||||
# manifests!
|
||||
@manifests = {}
|
||||
# files that must exist for a valid installation
|
||||
@manifest_files = []
|
||||
# files that should be removed
|
||||
@deleted_manifest_files = []
|
||||
# files needed to make installation valid for target version
|
||||
@required_manifest_files = []
|
||||
# packages to verify and/or download
|
||||
@packages = []
|
||||
|
||||
setup
|
||||
end
|
||||
|
||||
def setup
|
||||
end
|
||||
|
||||
def start(worker)
|
||||
@worker = worker
|
||||
|
||||
@installation_directory ||= application_target_installation_directory
|
||||
|
||||
update_status(label: "Starting...", fraction: 0.0)
|
||||
|
||||
execute
|
||||
end
|
||||
|
||||
def abort_task!(reason = "")
|
||||
puts reason
|
||||
|
||||
message_requester(Worker::Request::STATUS_ERROR, data: nil, error: reason)
|
||||
|
||||
raise reason
|
||||
end
|
||||
|
||||
# high level methods
|
||||
|
||||
def index_installation_directory
|
||||
return unless File.directory?(@installation_directory)
|
||||
|
||||
Dir.glob("#{@installation_directory}/**/**").each do |path|
|
||||
add_to_file_index(path)
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_manifests(version = @target_version)
|
||||
update_status(label: "Fetching manifests...", fraction: 0.0)
|
||||
|
||||
while (manifest = fetch_manifest(version))
|
||||
@manifests[version] = manifest
|
||||
|
||||
version = manifest.base_version
|
||||
break unless version
|
||||
break if manifest.full?
|
||||
end
|
||||
end
|
||||
|
||||
def build_package_list
|
||||
update_status(label: "Building package list...", fraction: 0.0)
|
||||
|
||||
channel_manifests = []
|
||||
version = @target_version
|
||||
|
||||
while(manifest = @manifests[version])
|
||||
channel_manifests << manifest
|
||||
|
||||
break if manifest.full?
|
||||
|
||||
version = manifest.base_version
|
||||
end
|
||||
|
||||
files = []
|
||||
deleted_files = []
|
||||
channel_manifests.reverse.each do |manifest|
|
||||
manifest.files.each do |file|
|
||||
if file.removed?
|
||||
files.delete_if { |f| f.name.casecmp?(file.name) }
|
||||
deleted_files << file
|
||||
|
||||
next
|
||||
end
|
||||
|
||||
# remove files that are total replacements, not patches
|
||||
files.delete_if { |f| f.name.casecmp?(file.name) } unless file.patch?
|
||||
|
||||
# exclude ignored files from consideration
|
||||
files.delete_if { |f| ignored_file?(f.name) }
|
||||
|
||||
# add file to file list
|
||||
files << file
|
||||
end
|
||||
end
|
||||
|
||||
@manifest_files = files
|
||||
@deleted_manifest_files = deleted_files
|
||||
end
|
||||
|
||||
def verify_files
|
||||
update_status(label: "Verifying files...", fraction: 0.0)
|
||||
|
||||
@required_manifest_files = @manifest_files.clone
|
||||
|
||||
# Process manifest game files in NEWEST to OLDEST order so that we don't erroneously flag
|
||||
# valid files as invalid due to an OLDER version of the file being checked FIRST.
|
||||
@manifest_files.reverse.each do |file|
|
||||
break unless File.directory?(@installation_directory)
|
||||
|
||||
# skip irrelevant older versions of files
|
||||
next unless @required_manifest_files.include?(file)
|
||||
|
||||
file_path = normalize_path(file.name)
|
||||
|
||||
next unless File.exist?(file_path) # && !File.directory?(file_path)
|
||||
|
||||
puts "verifying #{file_path}..."
|
||||
checksum = Digest::SHA256.file(file_path).hexdigest.upcase
|
||||
|
||||
if checksum == file.checksum
|
||||
@required_manifest_files.delete(file)
|
||||
|
||||
# remove irrelevant older versions of the file from consideration
|
||||
@required_manifest_files.delete_if { |f| f.name.casecmp?(file.name) && f.version < file.version }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_packages
|
||||
update_status(label: "Downloading packages...", fraction: 0.0)
|
||||
|
||||
required_packages = {}
|
||||
@required_manifest_files.each do |file|
|
||||
required_packages[file.version] ||= []
|
||||
|
||||
next if required_packages[file.version].find { |pkg| pkg.casecmp?(file.package) }
|
||||
|
||||
required_packages[file.version] << file.package
|
||||
end
|
||||
|
||||
packages = required_packages.map do |version, packages|
|
||||
packages.map do |package_name|
|
||||
{
|
||||
category: @application.category,
|
||||
subcategory: @application.id,
|
||||
name: format("%s.zip", package_name),
|
||||
version: version.to_s
|
||||
}
|
||||
end
|
||||
end.flatten
|
||||
|
||||
return if packages.empty?
|
||||
|
||||
result = @worker.w3dhub_api.fetch_package_details(packages)
|
||||
|
||||
unless result.okay?
|
||||
abort_task!("Failed to fetch required package details!")
|
||||
end
|
||||
|
||||
response = JSON.parse(result.data)
|
||||
manifest_packages = response["packages"]&.map { |item| Worker::Api::LegacyManifestPackage.new(item) }
|
||||
if (failed_packages = manifest_packages.select(&:error?)) && !failed_packages.empty?
|
||||
abort_task!("Failed to retrieve packages details for: #{failed_packages.map { |pkg| "#{pkg.name}:#{pkg.version}: #{pkg.error}"}.join(', ') }")
|
||||
end
|
||||
|
||||
@packages = manifest_packages
|
||||
|
||||
manifest_packages.each do |pkg|
|
||||
file_path = package_cache_path(pkg)
|
||||
unless File.directory?(File.dirname(file_path))
|
||||
puts "creating directory: #{File.dirname(file_path)}"
|
||||
create_directory(File.dirname(file_path))
|
||||
end
|
||||
|
||||
partially_valid_at = 0
|
||||
state = pkg.verify_file(file_path)
|
||||
if state.is_a?(Integer)
|
||||
puts "partially valid at: #{state} bytes (#{file_path})" # 20971520
|
||||
partially_valid_at = state
|
||||
else
|
||||
if state == true # completely verified, skip download!
|
||||
puts "skipping #{file_path}"
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
result = if pkg.download_url
|
||||
puts "downloading #{pkg.download_url} to #{file_path}"
|
||||
@worker.w3dhub_api.download(pkg.download_url, path: file_path, headers: @worker.w3dhub_api.headers(range: partially_valid_at))
|
||||
else
|
||||
# TODO xD
|
||||
@worker.w3dhub_api.fetch_package("TODO")
|
||||
end
|
||||
|
||||
abort_task!("Failed to download required package: #{pkg.name}:#{pkg.version} (#{result.error})") unless result.okay?
|
||||
end
|
||||
end
|
||||
|
||||
def install_packages
|
||||
update_status(label: "Installing...", fraction: 0.0)
|
||||
|
||||
create_directory(@installation_directory)
|
||||
|
||||
processed_packages = {}
|
||||
@required_manifest_files.each do |manifest_file|
|
||||
package = @packages.find do |pkg|
|
||||
pkg.name.casecmp?("#{manifest_file.package}.zip") && manifest_file.version == pkg.version
|
||||
end
|
||||
package_path = package_cache_path(package)
|
||||
|
||||
next if processed_packages[package_path]
|
||||
|
||||
if manifest_file.patch?
|
||||
apply_patch(manifest_file, package)
|
||||
else
|
||||
unzip(package_path)
|
||||
end
|
||||
|
||||
processed_packages[package_path] = manifest_file
|
||||
end
|
||||
end
|
||||
|
||||
def remove_deleted_files
|
||||
update_status(label: "Removing files...", fraction: 0.0)
|
||||
|
||||
@deleted_manifest_files.each do |manifest_file|
|
||||
file_path = normalize_path(manifest_file.name)
|
||||
|
||||
if File.exist?(file_path) && !File.directory?(file_path)
|
||||
puts "Removing file: #{file_path}"
|
||||
File.delete(file_path)
|
||||
|
||||
remove_from_file_index(file_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def write_paths_ini
|
||||
File.open(normalize_path("data/paths.ini"), "w") do |file|
|
||||
file.puts("[paths]")
|
||||
file.puts("RegBase=W3D Hub")
|
||||
file.puts("RegClient=#{@application.category}\\#{@application.id}-#{@channel.id}")
|
||||
file.puts("RegFDS=#{@application.category}\\#{@application.id}-#{@channel.id}-server")
|
||||
file.puts("FileBase=W3D Hub");
|
||||
file.puts("FileClient=#{@application.category}\\#{@application.id}-#{@channel.id}")
|
||||
file.puts("FileFDS=#{@application.category}\\#{@application.id}-#{@channel.id}-server")
|
||||
|
||||
file.puts("UseRenFolder=#{@channel.extended_data("usesRenFolder", false)}")
|
||||
end
|
||||
end
|
||||
|
||||
# updated, and moved applications will overwrite existing application data in settings
|
||||
def mark_application_installed
|
||||
app = Worker::Api::Settings::Application.create(
|
||||
id: @application.id,
|
||||
channel: @channel.id,
|
||||
version: @target_version.to_s,
|
||||
installation_path: @installation_directory,
|
||||
wine_prefix_path: "",
|
||||
launch_command: "%COMMAND%",
|
||||
timestamp: Time.now.to_i
|
||||
)
|
||||
|
||||
message_requester(Worker::Request::STATUS_COMPLETE, data: { application: app })
|
||||
|
||||
puts "APPLICATION: #{@application.name} #{@application.id}:#{@channel.id}:#{@target_version} installed."
|
||||
end
|
||||
|
||||
def mark_application_uninstalled
|
||||
message_requester(Worker::Request::STATUS_COMPLETE, data: {})
|
||||
end
|
||||
|
||||
# helper functions
|
||||
|
||||
def message_requester(status = Worker::Request::STATUS_IN_PROGRESS, data:, error: nil)
|
||||
@worker.message_requester(request_id: @request_id, status: status, data: data, error: error)
|
||||
end
|
||||
|
||||
def update_status(label:, fraction:)
|
||||
message_requester(data: { status: { title: "TASK #{@application.name} (#{@channel.name})", label: label, fraction: fraction } } )
|
||||
end
|
||||
|
||||
def application_target_installation_directory
|
||||
target_application = @worker.settings.applications.find { |app| app.id == @application.id && app.channel == @channel.id }
|
||||
if target_application
|
||||
target_application.installation_path
|
||||
else
|
||||
format("%s/%s/%s", @worker.settings.preferences.application_installation_directory, @application.id, @channel.id)
|
||||
end
|
||||
end
|
||||
|
||||
def package_cache_path(package)
|
||||
directory = @worker.settings.preferences.launcher_package_cache_directory
|
||||
|
||||
if package.version?
|
||||
format("%s/%s/%s/%s", directory, @application.id, package.version.to_s, package.name)
|
||||
else
|
||||
format("%s/%s/%s", directory, @application.id, package.name)
|
||||
end
|
||||
end
|
||||
|
||||
def ignored_file?(path)
|
||||
IGNORED_FILES.any? { |regex| path.match?(regex) }
|
||||
end
|
||||
|
||||
def create_directory(path)
|
||||
path.gsub!("\\", "/")
|
||||
|
||||
# directory doesn't exists, create it!
|
||||
unless File.directory?(path)
|
||||
FileUtils.mkdir_p(path)
|
||||
|
||||
add_directory_to_file_index(path)
|
||||
end
|
||||
end
|
||||
|
||||
# add EXISTING file or directory to index
|
||||
def add_to_file_index(path)
|
||||
path.gsub!("\\", "/")
|
||||
|
||||
@file_index[path.downcase] = IndexedFile.new(File.directory?(path) ? :directory : :file, path)
|
||||
end
|
||||
|
||||
# add EXISTING directory to file index, walking up the directory tree until existing directory entries are found
|
||||
# NOTE: this will **NOT** "scan" the directory for new directories or files
|
||||
def add_directory_to_file_index(path)
|
||||
path.gsub!("\\", "/")
|
||||
|
||||
segments = path.split("/")
|
||||
sub_path = path
|
||||
until (file_index = @file_index[sub_path.downcase])
|
||||
@file_index[sub_path.downcase]
|
||||
|
||||
segments.pop
|
||||
|
||||
break if segments.empty?
|
||||
|
||||
sub_path = segments.join("/")
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_file_index(path)
|
||||
path.gsub!("\\", "/")
|
||||
|
||||
@file_index.delete(path.downcase)
|
||||
end
|
||||
|
||||
# And behold, the backslashes were slain and the path deemed sane!
|
||||
def normalize_path(base_path)
|
||||
base_path = base_path.gsub("\\", "/")
|
||||
path = "#{@installation_directory}/#{base_path}".gsub("\\", "/")
|
||||
|
||||
# try to find exact, existing match
|
||||
index_file = @file_index[path.downcase]
|
||||
return index_file.path if index_file
|
||||
|
||||
# try to find directory structure
|
||||
if (index_directory = @file_index[File.dirname(path).downcase])
|
||||
return format("%s/%s", index_directory.path, File.basename(path))
|
||||
end
|
||||
|
||||
# walk directories to see if we have a partial path match
|
||||
sub_paths = base_path.split("/")
|
||||
sub_paths.pop # drop file name from sub path reconstruction
|
||||
unless sub_paths.empty?
|
||||
partial_index_directory = nil
|
||||
sub_path = format("%s/%s", @installation_directory, sub_paths.shift).downcase
|
||||
while (partial_index_directory = @file_index[sub_path.downcase])
|
||||
sub_path = format("%s/%s", sub_path, sub_paths.shift)
|
||||
end
|
||||
|
||||
if partial_index_directory
|
||||
if sub_paths.empty? # we've found the path!
|
||||
return format("%s/%s", partial_index_directory.path, File.basename(base_path))
|
||||
else # we has a directory or two that don't exist, downcase them, and then head out to the pub!
|
||||
return format("%s/%s/%s", partial_index_directory.path, sub_paths.map(&:downcase).join("/"), File.basename(base_path))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# path doesn't exist, convert directories to lower-case, and use provided file case
|
||||
if File.basename(path) == base_path # base_path doesn't have a sub directory
|
||||
format("%s/%s", File.dirname(path), File.basename(path))
|
||||
else # base_path has sub directory/directories
|
||||
format("%s/%s/%s", @installation_directory, File.dirname(base_path).downcase, File.basename(path))
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_manifest(version)
|
||||
result = @worker.w3dhub_api.fetch_package_details([
|
||||
{
|
||||
category: "games",
|
||||
subcategory: @application.id,
|
||||
name: "manifest.xml",
|
||||
version: version
|
||||
}
|
||||
])
|
||||
|
||||
return result unless result.okay?
|
||||
|
||||
response = JSON.parse(result.data)
|
||||
manifest_package = response["packages"]&.map { |item| Worker::Api::LegacyManifestPackage.new(item) }&.first
|
||||
|
||||
if manifest_package.nil? || manifest_package.error?
|
||||
pp manifest_package
|
||||
abort_task!("Failed to fetch package details for manifest #{version}")
|
||||
end
|
||||
|
||||
# FIXME: check if locally downloaded manifest is still valid, if present.
|
||||
file_path = package_cache_path(manifest_package)
|
||||
|
||||
create_directory(File.dirname(file_path))
|
||||
|
||||
result = if manifest_package.download_url
|
||||
@worker.w3dhub_api.download(manifest_package.download_url, path: file_path)
|
||||
else
|
||||
# TODO xD
|
||||
@worker.w3dhub_api.fetch_package("TODO")
|
||||
end
|
||||
|
||||
if result.okay?
|
||||
return Worker::Api::LegacyManifest.new(package_cache_path(manifest_package))
|
||||
else
|
||||
pp result
|
||||
abort_task!("Failed to fetch manifest #{version}")
|
||||
end
|
||||
end
|
||||
|
||||
def apply_patch(manifest_file, package)
|
||||
Tempfile.create do |f|
|
||||
package_path = package_cache_path(package)
|
||||
|
||||
puts "unpacking patch..."
|
||||
unzip(package_path, f.path)
|
||||
|
||||
puts "reading patch data.."
|
||||
patch_mix = W3DHubLauncher::WWMix.new(path: f.path)
|
||||
raise patch_mix.error_reason unless patch_mix.load
|
||||
|
||||
patch_entry = patch_mix.entries.find { |e| e.name.casecmp?(".w3dhub.patch") || e.name.casecmp?(".bhppatch") }
|
||||
patch_entry.read
|
||||
# "remove" patch meta file from patch before copying patch data
|
||||
patch_mix.entries.delete(patch_entry)
|
||||
|
||||
patch_info = JSON.parse(patch_entry.blob)
|
||||
|
||||
puts "loading target mix metadata... (#{normalize_path(manifest_file.name)})"
|
||||
target_mix = W3DHubLauncher::WWMix.new(path: normalize_path(manifest_file.name))
|
||||
raise target_mix.error_reason unless target_mix.load
|
||||
|
||||
patch_info["removedFiles"].each do |file|
|
||||
puts "removing file from target: #{file}"
|
||||
target_mix.entries.delete_if { |e| e.name.casecmp?(file) }
|
||||
end
|
||||
|
||||
patch_info["updatedFiles"].each do |file|
|
||||
puts "adding/updating file from target: #{file}"
|
||||
patch_mix.entries.each do |entry|
|
||||
target_mix.add_entry(entry: entry, replace: true)
|
||||
end
|
||||
end
|
||||
|
||||
Tempfile.create do |temp|
|
||||
temp_mix = W3DHubLauncher::WWMix.new(path: temp.path, encrypted: target_mix.encrypted?)
|
||||
target_mix.entries.each { |e| temp_mix.add_entry(entry: e, replace: true) }
|
||||
raise temp_mix.error_reason unless temp_mix.save
|
||||
|
||||
puts "copying file..."
|
||||
FileUtils.cp(temp.path, normalize_path(manifest_file.name))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def unzip(zip_path, output_path = nil)
|
||||
stream = Zip::InputStream.new(File.open(zip_path))
|
||||
|
||||
while(entry = stream.get_next_entry)
|
||||
file_path = output_path || normalize_path(entry.name)
|
||||
|
||||
next if ignored_file?(file_path)
|
||||
|
||||
pp file_path
|
||||
|
||||
create_directory(File.dirname(file_path)) unless output_path
|
||||
|
||||
File.open(file_path, "wb") do |f|
|
||||
entry_stream = entry.get_input_stream
|
||||
|
||||
while(chunk = entry_stream.read(4_194_304))
|
||||
f.write(chunk)
|
||||
end
|
||||
end
|
||||
|
||||
add_to_file_index(file_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# def execute_task
|
||||
# show_application_taskbar
|
||||
|
||||
# fail_fast
|
||||
# return false if failed?
|
||||
|
||||
# fetch_manifests
|
||||
# return false if failed?
|
||||
|
||||
# build_package_list
|
||||
# return false if failed?
|
||||
|
||||
# remove_deleted_files
|
||||
# return false if failed?
|
||||
|
||||
# verify_files
|
||||
# return false if failed?
|
||||
|
||||
# fetch_packages
|
||||
# return false if failed?
|
||||
|
||||
# verify_packages
|
||||
# return false if failed?
|
||||
|
||||
# unpack_packages
|
||||
# return false if failed?
|
||||
|
||||
# create_wine_prefix
|
||||
# return false if failed?
|
||||
|
||||
# install_dependencies
|
||||
# return false if failed?
|
||||
|
||||
# write_paths_ini
|
||||
# return false if failed?
|
||||
|
||||
# mark_application_installed
|
||||
# return false if failed?
|
||||
|
||||
# sleep 1
|
||||
# hide_application_taskbar
|
||||
|
||||
# true
|
||||
# end
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
module W3DHubLauncher
|
||||
class Task
|
||||
class InstallApplication < Task
|
||||
def setup
|
||||
end
|
||||
|
||||
def execute
|
||||
# TODO:
|
||||
# fail_fast
|
||||
|
||||
# get case-insensitive file list
|
||||
index_installation_directory
|
||||
|
||||
# fetch manifests for currently installed version
|
||||
# AND the target version so we can detect files that need to be removed
|
||||
# between versions
|
||||
fetch_manifests
|
||||
|
||||
# build list of packages we'll need to download
|
||||
build_package_list
|
||||
|
||||
# verify our pre-existing files for presence and validity
|
||||
verify_files
|
||||
|
||||
# download required packages to get missing or out of date files
|
||||
fetch_packages
|
||||
|
||||
# unpack packages and patch files to reach target version
|
||||
install_packages
|
||||
|
||||
# delete files that are no longer part of the application (and whose presence may break the application)
|
||||
remove_deleted_files
|
||||
|
||||
# tell the application to behive
|
||||
write_paths_ini
|
||||
|
||||
# store application install state and notify frontend/ui that the application is ready for use!
|
||||
mark_application_installed
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
0
lib/worker/tasks/move_application.rb
Normal file
0
lib/worker/tasks/move_application.rb
Normal file
@@ -6,13 +6,17 @@ module W3DHubLauncher
|
||||
PRIMARY_W3DHUB_API_ENDPOINT = "https://secure.w3dhub.com".freeze
|
||||
ALTERNATIVE_W3DHUB_API_ENDPOINT = "https://backend.w3d.cyberarm.dev".freeze
|
||||
|
||||
attr_reader :applications
|
||||
|
||||
def initialize
|
||||
@access_token = nil
|
||||
|
||||
@applications = []
|
||||
|
||||
@http_clients = {}
|
||||
end
|
||||
|
||||
def headers(form_encoded: false)
|
||||
def headers(form_encoded: false, range: nil)
|
||||
array = [
|
||||
["user-agent", W3DHubLauncher::USER_AGENT],
|
||||
["accept", "application/json"],
|
||||
@@ -20,6 +24,7 @@ module W3DHubLauncher
|
||||
|
||||
array << ["content-type", "application/x-www-form-urlencoded"] if form_encoded
|
||||
array << ["authorization", "Bearer #{@access_token}"] if @access_token
|
||||
array << ["range", "bytes=#{range}-"] if range
|
||||
|
||||
# pp array
|
||||
|
||||
@@ -33,10 +38,10 @@ module W3DHubLauncher
|
||||
Sync do |task|
|
||||
task.with_timeout(API_TIMEOUT) do
|
||||
Async::HTTP::Internet.send(method, url, headers, body) do |response|
|
||||
pp [method, url, headers, body, response]
|
||||
if response.success?
|
||||
result.data = response.read
|
||||
else
|
||||
# pp response
|
||||
result.error = true
|
||||
end
|
||||
end
|
||||
@@ -57,27 +62,28 @@ module W3DHubLauncher
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
Sync do |task|
|
||||
task.with_timeout(API_TIMEOUT) do
|
||||
Async::HTTP::Internet.send(method, url, headers, body) do |response|
|
||||
if response.success?
|
||||
content_length = response.headers["content-length"] || 0
|
||||
Async::HTTP::Internet.send(method, url, headers, body) do |response|
|
||||
if response.success?
|
||||
content_length = response.headers["content-length"] || 0
|
||||
|
||||
total_downloaded_bytes = 0
|
||||
File.open(path, "wb") do |file|
|
||||
response.each do |chunk|
|
||||
file.write(chunk)
|
||||
downloaded_bytes = chunk.length
|
||||
total_downloaded_bytes += downloaded_bytes
|
||||
total_downloaded_bytes = 0
|
||||
range = Integer(headers.find { |key, value| key == "range" }&.last&.split("=")&.last&.split("-")&.first || 0)
|
||||
File.open(path, range.positive? ? "r+b" : "wb") do |file|
|
||||
file.pos = range if range.positive?
|
||||
|
||||
block&.call(downloaded_bytes, total_downloaded_bytes, content_length)
|
||||
end
|
||||
response.each do |chunk|
|
||||
file.write(chunk)
|
||||
downloaded_bytes = chunk.length
|
||||
total_downloaded_bytes += downloaded_bytes
|
||||
|
||||
block&.call(downloaded_bytes, total_downloaded_bytes, content_length)
|
||||
end
|
||||
|
||||
result.data = true
|
||||
end
|
||||
rescue StandardError => e
|
||||
result.error = e
|
||||
|
||||
result.data = true
|
||||
end
|
||||
rescue StandardError => e
|
||||
result.error = e
|
||||
rescue Async::TimeoutError
|
||||
result.error = e
|
||||
end
|
||||
@@ -104,47 +110,56 @@ module W3DHubLauncher
|
||||
primary_result = @access_token ? fetch("#{PRIMARY_W3DHUB_API_ENDPOINT}/apis/launcher/1/get-applications") : CyberarmEngine::Result.new(error: true)
|
||||
alternate_result = fetch("#{ALTERNATIVE_W3DHUB_API_ENDPOINT}/apis/launcher/1/get-applications")
|
||||
|
||||
single_source = false
|
||||
|
||||
# We've failed to retrieve data
|
||||
if primary_result.error? && alternate_result.error?
|
||||
return result
|
||||
# We've only gotten data back from the primary backend
|
||||
elsif primary_result.okay? && alternate_result.error?
|
||||
result.data = primary_result.data
|
||||
return result
|
||||
single_source = true
|
||||
# We've only gotten data back from the alternate backend
|
||||
elsif primary_result.error? && alternate_result.okay?
|
||||
result.data = alternate_result.data
|
||||
return result
|
||||
single_source = true
|
||||
end
|
||||
|
||||
# We've gotten data back from both backends, merge them.
|
||||
# pp [primary_result, alternate_result]
|
||||
unless single_source
|
||||
puts "FIXME: merge applications source"
|
||||
# We've gotten data back from both backends, merge them.
|
||||
# pp [primary_result, alternate_result]
|
||||
|
||||
# FIXME: Merge primary and alternate results
|
||||
end
|
||||
|
||||
@applications = JSON.parse(result.data)["applications"]&.map { |app| W3DHubLauncher::Worker::Api::Application.new(app) } || []
|
||||
|
||||
# FIXME: Merge primary and alternate results
|
||||
result
|
||||
end
|
||||
|
||||
def fetch_news()
|
||||
result = CyberarmEngine::Result.new
|
||||
def fetch_news(category)
|
||||
fetch("#{PRIMARY_W3DHUB_API_ENDPOINT}/apis/w3dhub/1/get-news", method: :post, body: "data={\"category\":\"#{category}\"}", headers: headers(form_encoded: true))
|
||||
end
|
||||
|
||||
def fetch_events()
|
||||
result = CyberarmEngine::Result.new
|
||||
def fetch_events(category)
|
||||
fetch("#{PRIMARY_W3DHUB_API_ENDPOINT}/apis/w3dhub/1/get-server-events", method: :post, body: "data={\"serverPath\":\"#{category}\"}", headers: headers(form_encoded: true))
|
||||
end
|
||||
|
||||
def fetch_test_events(category)
|
||||
fetch("#{PRIMARY_W3DHUB_API_ENDPOINT}/apis/w3dhub/1/get-testing-times")
|
||||
end
|
||||
|
||||
def fetch_manifest()
|
||||
result = CyberarmEngine::Result.new
|
||||
end
|
||||
|
||||
def fetch_manifests()
|
||||
result = CyberarmEngine::Result.new
|
||||
def fetch_package_details(packages)
|
||||
body = URI.encode_www_form("data": JSON.dump({ packages: packages }))
|
||||
fetch("#{@access_token ? PRIMARY_W3DHUB_API_ENDPOINT : ALTERNATIVE_W3DHUB_API_ENDPOINT}/apis/launcher/1/get-package-details", method: :post, body: body, headers: headers(form_encoded: true))
|
||||
end
|
||||
|
||||
def fetch_package_details()
|
||||
result = CyberarmEngine::Result.new
|
||||
end
|
||||
|
||||
def fetch_package()
|
||||
def download_package()
|
||||
download()
|
||||
end
|
||||
end
|
||||
|
||||
287
lib/ww_mix.rb
Normal file
287
lib/ww_mix.rb
Normal file
@@ -0,0 +1,287 @@
|
||||
require "digest"
|
||||
require "stringio"
|
||||
|
||||
module W3DHubLauncher
|
||||
# Reimplementating MIX1 reader/writer with years more
|
||||
# experience working with these formats and having the
|
||||
# advantage of being able to reference the renegade source
|
||||
# code :)
|
||||
class WWMix
|
||||
MIX1_HEADER = 0x3158494D
|
||||
MIX2_HEADER = 0x3258494D
|
||||
|
||||
MixHeader = Struct.new(
|
||||
:mime_type, # int32
|
||||
:file_data_offset, # int32
|
||||
:file_names_offset, # int32
|
||||
:_reserved # int32
|
||||
)
|
||||
|
||||
EntryInfoHeader = Struct.new(
|
||||
:crc32, # uint32
|
||||
:content_offset, # uint32
|
||||
:content_length # uint32
|
||||
)
|
||||
|
||||
class Entry
|
||||
attr_accessor :path, :name, :info, :blob, :is_blob
|
||||
|
||||
def initialize(name:, path:, info:, blob: nil)
|
||||
@name = name
|
||||
@path = path
|
||||
@info = info
|
||||
@blob = blob
|
||||
|
||||
@info.content_length = blob.size if blob?
|
||||
end
|
||||
|
||||
def blob?
|
||||
@blob
|
||||
end
|
||||
|
||||
def calculate_crc32
|
||||
Digest::CRC32.hexdigest(@name.upcase).upcase.to_i(16)
|
||||
end
|
||||
|
||||
# Write entry's data to stream.
|
||||
# Caller is responsible for ensuring stream is valid for writing
|
||||
def copy_to(stream)
|
||||
if blob?
|
||||
return false if @blob.size.zero?
|
||||
|
||||
stream.write(blob)
|
||||
return true
|
||||
else
|
||||
if read
|
||||
stream.write(@blob)
|
||||
@blob = nil
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def read
|
||||
return false unless File.exist?(@path)
|
||||
return false if File.directory?(@path)
|
||||
return false if File.size(@path) < @info.content_offset + @info.content_length
|
||||
|
||||
@blob = File.binread(@path, @info.content_length, @info.content_offset)
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :path, :encrypted, :entries, :error_reason
|
||||
|
||||
def initialize(path:, encrypted: false)
|
||||
@path = path
|
||||
@encrypted = encrypted
|
||||
@entries = []
|
||||
|
||||
@error_reason = ""
|
||||
end
|
||||
|
||||
# Load entries from MIX file. Entry data is NOT loaded.
|
||||
# @return true on success or false on failure. Check m_error_reason for why.
|
||||
def load
|
||||
unless File.exist?(@path)
|
||||
@error_reason = format("Path does not exist: %s", @path)
|
||||
return false
|
||||
end
|
||||
|
||||
if File.directory?(@path)
|
||||
@error_reason = format("Path is a directory: %s", @path)
|
||||
return false
|
||||
end
|
||||
|
||||
File.open(@path, "rb") do |f|
|
||||
header = MixHeader.new(0, 0, 0, 0)
|
||||
header.mime_type = read_i32(f)
|
||||
header.file_data_offset = read_i32(f)
|
||||
header.file_names_offset = read_i32(f)
|
||||
header._reserved = read_i32(f)
|
||||
|
||||
unless header.mime_type == MIX1_HEADER || header.mime_type == MIX2_HEADER
|
||||
@error_reason = format("Invalid mime type: %d", header.mime_type)
|
||||
return false
|
||||
end
|
||||
|
||||
@encrypted = header.mime_type == MIX2_HEADER
|
||||
|
||||
# Read entry info
|
||||
f.pos = header.file_data_offset
|
||||
file_count = read_i32(f)
|
||||
|
||||
file_count.times do |i|
|
||||
entry_info = EntryInfoHeader.new(0, 0, 0)
|
||||
entry_info.crc32 = read_u32(f)
|
||||
entry_info.content_offset = read_u32(f)
|
||||
entry_info.content_length = read_u32(f)
|
||||
|
||||
@entries << Entry.new(name: "", path: @path, info: entry_info)
|
||||
end
|
||||
|
||||
# Read entry names
|
||||
f.pos = header.file_names_offset
|
||||
file_count = read_i32(f)
|
||||
|
||||
file_count.times do |i|
|
||||
@entries[i].name = read_string(f)
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def save
|
||||
unless @entries.size.positive?
|
||||
@error_reason = "No entries to write."
|
||||
return false
|
||||
end
|
||||
|
||||
if File.directory?(@path)
|
||||
@error_reason = format("Path is a directory: %s", @path)
|
||||
return false
|
||||
end
|
||||
|
||||
File.open(@path, "wb") do |f|
|
||||
header = MixHeader.new(encrypted? ? MIX2_HEADER : MIX1_HEADER, 0, 0, 0)
|
||||
|
||||
# write mime type
|
||||
write_i32(f, header.mime_type)
|
||||
|
||||
f.pos = 16
|
||||
|
||||
# sort entries by crc32 of their name
|
||||
sort_entries
|
||||
|
||||
# write file blobs
|
||||
@entries.each do |entry|
|
||||
# store current io position
|
||||
pos = f.pos
|
||||
|
||||
# copy entry to stream
|
||||
entry.copy_to(f)
|
||||
|
||||
# update entry with new offset
|
||||
entry.info.content_offset = pos
|
||||
|
||||
# add alignment padding
|
||||
padding = (-f.pos & 7)
|
||||
padding.times do |i|
|
||||
write_u8(f, 0)
|
||||
end
|
||||
end
|
||||
|
||||
# Save file data offset
|
||||
header.file_data_offset = f.pos
|
||||
|
||||
# write number of entries
|
||||
write_i32(f, @entries.size)
|
||||
|
||||
# write entries file data
|
||||
@entries.each do |entry|
|
||||
write_u32(f, entry.info.crc32)
|
||||
write_u32(f, entry.info.content_offset)
|
||||
write_u32(f, entry.info.content_length)
|
||||
end
|
||||
|
||||
# save file names offset
|
||||
header.file_names_offset = f.pos
|
||||
|
||||
# write number of entries
|
||||
write_i32(f, @entries.size)
|
||||
|
||||
# write entry names
|
||||
@entries.each do |entry|
|
||||
write_string(f, entry.name)
|
||||
end
|
||||
|
||||
# jump to io_position 4
|
||||
f.pos = 4
|
||||
# write rest of header
|
||||
|
||||
write_i32(f, header.file_data_offset)
|
||||
write_i32(f, header.file_names_offset)
|
||||
write_i32(f, header._reserved)
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def valid?
|
||||
# ALL entries MUST have unique case-insensitive names
|
||||
@entries.each do |a|
|
||||
@entries.each do |b|
|
||||
next if a == b
|
||||
|
||||
return false if a.name.upcase == b.name.upcase
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def encrypted?
|
||||
@encrypted
|
||||
end
|
||||
|
||||
def add_file(path:, replace: false)
|
||||
return false unless File.exist?(path)
|
||||
return false if File.directory?(path)
|
||||
|
||||
entry = Entry.new(name: File.basename(path), path: path, info: EntryInfoHeader.new(0, 0, File.size(path)))
|
||||
add_entry(entry: entry, replace: replace)
|
||||
end
|
||||
|
||||
def add_blob(path:, blob:, replace: false)
|
||||
info = EntryInfoHeader.new(0, 0, blob.size)
|
||||
entry = Entry.new(name: File.basename(path), path: path, info: info, blob: blob)
|
||||
into.crc32 = @entries.last.calculate_crc32
|
||||
|
||||
add_entry(entry: entry, replace: replace)
|
||||
end
|
||||
|
||||
def add_entry(entry:, replace: false)
|
||||
duplicate = @entries.find { |e| e.name.upcase == entry.name.upcase }
|
||||
|
||||
if duplicate
|
||||
if replace
|
||||
@entries.delete(duplicate)
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
@entries << entry
|
||||
true
|
||||
end
|
||||
|
||||
def sort_entries
|
||||
return false if @entries.any? { |e| e.info.crc32 == 0 }
|
||||
|
||||
@entries.sort! { |a, b| a.info.crc32 <=> b.info.crc32 }
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def read_i32(f) = f.read(4).unpack1("l")
|
||||
def read_u32(f) = f.read(4).unpack1("L")
|
||||
def read_u8(f) = f.read(1).unpack1("C")
|
||||
def read_string(f)
|
||||
f.read(read_u8(f)).strip
|
||||
end
|
||||
|
||||
def write_i32(f, value) = f.write([value].pack("l"))
|
||||
def write_u32(f, value) = f.write([value].pack("L"))
|
||||
def write_u8(f, value) = f.write([value].pack("C"))
|
||||
def write_string(f, string)
|
||||
length = string.size + 1 # include null byte
|
||||
write_u8(f, length)
|
||||
f.write(string)
|
||||
write_u8(f, 0) # null byte
|
||||
end
|
||||
end
|
||||
end
|
||||
BIN
media/default_game_cover.png
Normal file
BIN
media/default_game_cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
media/default_map_preview.png
Normal file
BIN
media/default_map_preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -9,6 +9,9 @@ require "rexml"
|
||||
require "base64"
|
||||
require "logger"
|
||||
require "digest"
|
||||
require "pathname"
|
||||
require "fileutils"
|
||||
require "tempfile"
|
||||
|
||||
require "async"
|
||||
require "async/http/internet/instance"
|
||||
@@ -16,6 +19,10 @@ require "async/websocket"
|
||||
require "digest/crc"
|
||||
require "ircparser"
|
||||
require "zip"
|
||||
require "chunky_png"
|
||||
require "sdl3"
|
||||
|
||||
require_relative "lib/gosu_ext"
|
||||
|
||||
require_relative "lib/version"
|
||||
require_relative "lib/constants"
|
||||
@@ -33,15 +40,26 @@ require_relative "lib/dialogs/about"
|
||||
require_relative "lib/dialogs/downloads"
|
||||
require_relative "lib/dialogs/server"
|
||||
require_relative "lib/dialogs/account"
|
||||
require_relative "lib/dialogs/import_application"
|
||||
require_relative "lib/states/boot"
|
||||
require_relative "lib/states/interface"
|
||||
require_relative "lib/window"
|
||||
require_relative "lib/application_helper"
|
||||
|
||||
require_relative "lib/ico"
|
||||
require_relative "lib/dds"
|
||||
require_relative "lib/ww_mix"
|
||||
|
||||
require_relative "lib/worker"
|
||||
require_relative "lib/worker/api"
|
||||
require_relative "lib/worker/api/application"
|
||||
require_relative "lib/worker/api/game_server"
|
||||
require_relative "lib/worker/api/settings"
|
||||
require_relative "lib/worker/api/news_item"
|
||||
require_relative "lib/worker/api/server_event"
|
||||
require_relative "lib/worker/api/test_event"
|
||||
require_relative "lib/worker/api/legacy_manifest"
|
||||
require_relative "lib/worker/api/legacy_manifest_package"
|
||||
require_relative "lib/worker/request"
|
||||
require_relative "lib/worker/w3dhub_api"
|
||||
require_relative "lib/worker/task"
|
||||
@@ -49,6 +67,7 @@ require_relative "lib/worker/tasks/install_application"
|
||||
require_relative "lib/worker/tasks/uninstall_application"
|
||||
require_relative "lib/worker/tasks/repair_application"
|
||||
require_relative "lib/worker/tasks/update_application"
|
||||
require_relative "lib/worker/tasks/move_application"
|
||||
|
||||
module W3DHubLauncher
|
||||
MemCache = {}
|
||||
@@ -63,6 +82,13 @@ module W3DHubLauncher
|
||||
WORKER.connect
|
||||
end
|
||||
|
||||
SDL.load_lib("SDL3")
|
||||
|
||||
window = W3DHubLauncher::Window.new(width: 1280, height: 800, resizable: true)
|
||||
# window = W3DHubLauncher::Window.new(width: 1920, height: 1080, resizable: true)
|
||||
|
||||
# Gosu.choose_file do |files|
|
||||
# pp files
|
||||
# end
|
||||
|
||||
window.show
|
||||
|
||||
Reference in New Issue
Block a user