Renamed whole gamemode to feedc0de_coop and improved F3 menu
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# garrysmod-coop
|
||||
# feedc0de Coop Campaign
|
||||
|
||||
## Persistent progression and rewards shop
|
||||
|
||||
@@ -83,6 +83,10 @@ cannot award points, submit a purchase price, or debit their own Credits.
|
||||
Administrators get an `Admin Prices` tab in the F3 shop. Select an item, enter a
|
||||
non-negative Credit price, and save it. The server permission-checks and persists
|
||||
the change in SQLite, then broadcasts the new catalogue to all connected clients.
|
||||
The adjacent `Admin Players` tab lists every SQLite player record, including
|
||||
offline players, with Lifetime Score, Credits, playtime, equipped model, weapon,
|
||||
trail, enabled behaviours, and permanent purchases. Connected players' newer
|
||||
in-memory totals are merged into the view without forcing a synchronous save.
|
||||
For testing, an administrator who cannot afford an item is allowed to unlock it
|
||||
without a Credit charge; the server reports the shortfall and Credits remain at
|
||||
their current non-negative value. Set `Progression.AdminShopTestingBypass` to
|
||||
@@ -123,3 +127,6 @@ Keep locally supplied audio outside the gamemode source tree and distribute or
|
||||
back it up separately.
|
||||
|
||||
Player-spawned NPCs and unknown/friendly NPC classes do not award points.
|
||||
|
||||
Shop refreshes preserve the selected F3 tab and catalogue scroll position, so
|
||||
buying or equipping an item does not return the window to Player Models.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"coopgamemode"
|
||||
{
|
||||
"base" "sandbox"
|
||||
"title" "Feedc0de's coop Mode"
|
||||
"menusystem" "1"
|
||||
"maps" "coop"
|
||||
"feedc0de_coop"
|
||||
{
|
||||
"base" "sandbox"
|
||||
"title" "feedc0de Coop Campaign"
|
||||
"category" "pve"
|
||||
"menusystem" "1"
|
||||
"maps" "coop"
|
||||
"settings"
|
||||
{
|
||||
1
|
||||
+249
-3
@@ -16,8 +16,19 @@ local shopState = {
|
||||
ownedBehaviours = {},
|
||||
enabledBehaviours = {},
|
||||
prices = {},
|
||||
isAdmin = false
|
||||
isAdmin = false,
|
||||
adminPlayers = {},
|
||||
adminPlayersLoading = false
|
||||
}
|
||||
local shopViewState = {
|
||||
activeTab = "Player Models",
|
||||
scroll = {}
|
||||
}
|
||||
local adminPlayersList
|
||||
local adminPlayersStatus
|
||||
local adminPlayerDetails
|
||||
local adminPlayerRequestID = 0
|
||||
local RefreshAdminPlayersView
|
||||
local activeMusicChannel
|
||||
local activeMusicSource
|
||||
local activeMusicIsSpatial = false
|
||||
@@ -81,6 +92,20 @@ local function RequestShop()
|
||||
net.SendToServer()
|
||||
end
|
||||
|
||||
local function RequestAdminPlayers()
|
||||
if not shopState.isAdmin then return end
|
||||
|
||||
adminPlayerRequestID = (adminPlayerRequestID + 1) % 65536
|
||||
shopState.adminPlayers = {}
|
||||
shopState.adminPlayersLoading = true
|
||||
if IsValid(adminPlayersStatus) then adminPlayersStatus:SetText("Loading all player records...") end
|
||||
if IsValid(adminPlayersList) then adminPlayersList:Clear() end
|
||||
|
||||
net.Start("CustomCoopProgressionRequestAdminPlayers")
|
||||
net.WriteUInt(adminPlayerRequestID, 16)
|
||||
net.SendToServer()
|
||||
end
|
||||
|
||||
local nextShopToggle = 0
|
||||
local function ToggleShop()
|
||||
if RealTime() < nextShopToggle then return end
|
||||
@@ -287,7 +312,15 @@ local function CreateCataloguePage(sheet, title, itemType, items)
|
||||
AddItemCard(layout, itemType, item)
|
||||
end
|
||||
|
||||
page.CustomCoopScroll = scroll
|
||||
sheet:AddSheet(title, page)
|
||||
|
||||
local savedScroll = shopViewState.scroll[title]
|
||||
if savedScroll and savedScroll > 0 then
|
||||
timer.Simple(0, function()
|
||||
if IsValid(scroll) then scroll:GetVBar():SetScroll(savedScroll) end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
local function SendPriceChange(itemType, itemID, price)
|
||||
@@ -376,11 +409,170 @@ local function CreateAdminPage(sheet)
|
||||
sheet:AddSheet("Admin Prices", page)
|
||||
end
|
||||
|
||||
local function CatalogueItemName(itemType, itemID)
|
||||
if not itemID or itemID == "" or itemID == "none" then return "None" end
|
||||
|
||||
local item = itemType == "model" and Progression.GetModel(itemID)
|
||||
or itemType == "weapon" and Progression.GetWeapon(itemID)
|
||||
or itemType == "music" and Progression.GetMusic(itemID)
|
||||
or itemType == "behaviour" and Progression.GetBehaviour(itemID)
|
||||
or itemType == "trail" and Progression.GetTrail(itemID)
|
||||
or itemType == "entity" and Progression.GetEntity(itemID)
|
||||
or itemType == "helper" and Progression.GetHelper(itemID)
|
||||
return item and item.name or itemID
|
||||
end
|
||||
|
||||
local function DescribeAdminPlayer(record)
|
||||
local purchases = {}
|
||||
for _, purchase in ipairs(record.purchases or {}) do
|
||||
local purchasedAt = purchase.purchasedAt and purchase.purchasedAt > 0
|
||||
and os.date("%Y-%m-%d", purchase.purchasedAt)
|
||||
or "unknown date"
|
||||
purchases[#purchases + 1] = string.format(
|
||||
"%s: %s (%s)",
|
||||
string.upper(string.sub(purchase.itemType or "item", 1, 1)) .. string.sub(purchase.itemType or "item", 2),
|
||||
CatalogueItemName(purchase.itemType, purchase.itemID),
|
||||
purchasedAt
|
||||
)
|
||||
end
|
||||
|
||||
local enabled = {}
|
||||
for _, itemID in ipairs(record.enabledBehaviours or {}) do
|
||||
enabled[#enabled + 1] = CatalogueItemName("behaviour", itemID)
|
||||
end
|
||||
|
||||
return string.format(
|
||||
"Equipped: %s model | %s weapon | %s trail\nEnabled: %s\nPermanent purchases: %s",
|
||||
CatalogueItemName("model", record.equippedModel),
|
||||
CatalogueItemName("weapon", record.equippedWeapon),
|
||||
CatalogueItemName("trail", record.equippedTrail),
|
||||
#enabled > 0 and table.concat(enabled, ", ") or "None",
|
||||
#purchases > 0 and table.concat(purchases, ", ") or "None"
|
||||
)
|
||||
end
|
||||
|
||||
RefreshAdminPlayersView = function()
|
||||
if not IsValid(adminPlayersList) then return end
|
||||
|
||||
adminPlayersList:Clear()
|
||||
for _, record in ipairs(shopState.adminPlayers) do
|
||||
local row = adminPlayersList:AddLine(
|
||||
record.playerName,
|
||||
record.online and "Online" or "Offline",
|
||||
record.steamID64,
|
||||
record.score,
|
||||
record.credits,
|
||||
Progression.FormatPlaytime(record.playtime),
|
||||
CatalogueItemName("model", record.equippedModel),
|
||||
CatalogueItemName("weapon", record.equippedWeapon),
|
||||
CatalogueItemName("trail", record.equippedTrail),
|
||||
#(record.purchases or {})
|
||||
)
|
||||
row.CustomCoopAdminPlayer = record
|
||||
row:SetTooltip(DescribeAdminPlayer(record))
|
||||
end
|
||||
|
||||
if IsValid(adminPlayersStatus) then
|
||||
local suffix = shopState.adminPlayersLoading and " (loading...)" or ""
|
||||
adminPlayersStatus:SetText(string.format("%d player record%s%s", #shopState.adminPlayers, #shopState.adminPlayers == 1 and "" or "s", suffix))
|
||||
end
|
||||
end
|
||||
|
||||
local function CreateAdminPlayersPage(sheet)
|
||||
local page = vgui.Create("DPanel", sheet)
|
||||
page:DockPadding(10, 10, 10, 10)
|
||||
page.Paint = function(_, width, height)
|
||||
draw.RoundedBox(6, 0, 0, width, height, Color(24, 29, 35))
|
||||
end
|
||||
|
||||
local toolbar = vgui.Create("DPanel", page)
|
||||
toolbar:Dock(TOP)
|
||||
toolbar:SetTall(34)
|
||||
toolbar:DockMargin(0, 0, 0, 8)
|
||||
toolbar.Paint = nil
|
||||
|
||||
local refresh = vgui.Create("DButton", toolbar)
|
||||
refresh:Dock(RIGHT)
|
||||
refresh:SetWide(150)
|
||||
refresh:SetText("Refresh players")
|
||||
refresh:SetFont("CustomCoopProgressionBody")
|
||||
refresh.DoClick = RequestAdminPlayers
|
||||
|
||||
adminPlayersStatus = AddText(toolbar, "Loading all player records...", "CustomCoopProgressionSmall", colors.muted)
|
||||
adminPlayersStatus:Dock(FILL)
|
||||
adminPlayersStatus:SetContentAlignment(4)
|
||||
|
||||
local detailsPanel = vgui.Create("DPanel", page)
|
||||
detailsPanel:Dock(BOTTOM)
|
||||
detailsPanel:SetTall(92)
|
||||
detailsPanel:DockMargin(0, 8, 0, 0)
|
||||
detailsPanel:DockPadding(10, 8, 10, 8)
|
||||
detailsPanel.Paint = function(_, width, height)
|
||||
draw.RoundedBox(5, 0, 0, width, height, colors.panel)
|
||||
end
|
||||
|
||||
adminPlayerDetails = AddText(
|
||||
detailsPanel,
|
||||
"Select a player to inspect equipped items, enabled behaviours, and permanent purchases.",
|
||||
"CustomCoopProgressionSmall",
|
||||
colors.muted
|
||||
)
|
||||
adminPlayerDetails:Dock(FILL)
|
||||
|
||||
adminPlayersList = vgui.Create("DListView", page)
|
||||
adminPlayersList:Dock(FILL)
|
||||
adminPlayersList:SetMultiSelect(false)
|
||||
adminPlayersList:AddColumn("Name")
|
||||
adminPlayersList:AddColumn("Status"):SetFixedWidth(58)
|
||||
adminPlayersList:AddColumn("SteamID64"):SetFixedWidth(145)
|
||||
adminPlayersList:AddColumn("XP / Score"):SetFixedWidth(78)
|
||||
adminPlayersList:AddColumn("Credits"):SetFixedWidth(68)
|
||||
adminPlayersList:AddColumn("Played"):SetFixedWidth(78)
|
||||
adminPlayersList:AddColumn("Model"):SetFixedWidth(115)
|
||||
adminPlayersList:AddColumn("Weapon"):SetFixedWidth(125)
|
||||
adminPlayersList:AddColumn("Trail"):SetFixedWidth(110)
|
||||
adminPlayersList:AddColumn("Owned"):SetFixedWidth(55)
|
||||
adminPlayersList.OnRowSelected = function(_, _, row)
|
||||
if IsValid(adminPlayerDetails) and row.CustomCoopAdminPlayer then
|
||||
adminPlayerDetails:SetText(DescribeAdminPlayer(row.CustomCoopAdminPlayer))
|
||||
end
|
||||
end
|
||||
|
||||
sheet:AddSheet("Admin Players", page)
|
||||
RefreshAdminPlayersView()
|
||||
-- The shop-open request has a short server-side rate limit. Wait until it
|
||||
-- expires before requesting the larger administrator-only dataset.
|
||||
timer.Simple(0.3, function()
|
||||
if IsValid(page) then
|
||||
RequestAdminPlayers()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function CaptureShopViewState()
|
||||
if not IsValid(shopFrame) or not IsValid(shopFrame.CustomCoopSheet) then return end
|
||||
|
||||
local sheet = shopFrame.CustomCoopSheet
|
||||
local activeTab = sheet:GetActiveTab()
|
||||
if IsValid(activeTab) then shopViewState.activeTab = activeTab:GetText() end
|
||||
|
||||
for _, item in ipairs(sheet.Items or {}) do
|
||||
if IsValid(item.Tab) and IsValid(item.Panel) and IsValid(item.Panel.CustomCoopScroll) then
|
||||
shopViewState.scroll[item.Tab:GetText()] = item.Panel.CustomCoopScroll:GetVBar():GetScroll()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function OpenShop()
|
||||
CaptureShopViewState()
|
||||
if IsValid(shopFrame) then shopFrame:Remove() end
|
||||
|
||||
adminPlayersList = nil
|
||||
adminPlayersStatus = nil
|
||||
adminPlayerDetails = nil
|
||||
|
||||
shopFrame = vgui.Create("DFrame")
|
||||
shopFrame:SetSize(math.min(ScrW() - 40, 850), math.min(ScrH() - 40, 650))
|
||||
shopFrame:SetSize(math.min(ScrW() - 40, shopState.isAdmin and 1240 or 850), math.min(ScrH() - 40, 650))
|
||||
shopFrame:Center()
|
||||
shopFrame:SetTitle("")
|
||||
shopFrame:ShowCloseButton(true)
|
||||
@@ -443,6 +635,7 @@ local function OpenShop()
|
||||
local sheet = vgui.Create("DPropertySheet", shopFrame)
|
||||
sheet:SetPos(14, 104)
|
||||
sheet:SetSize(shopFrame:GetWide() - 28, shopFrame:GetTall() - 118)
|
||||
shopFrame.CustomCoopSheet = sheet
|
||||
|
||||
CreateCataloguePage(sheet, "Player Models", "model", Progression.Models)
|
||||
CreateCataloguePage(sheet, "Bonus Weapons", "weapon", Progression.Weapons)
|
||||
@@ -451,7 +644,20 @@ local function OpenShop()
|
||||
CreateCataloguePage(sheet, "Player Trails", "trail", Progression.Trails)
|
||||
CreateCataloguePage(sheet, "Entities", "entity", Progression.Entities)
|
||||
CreateCataloguePage(sheet, "Helping NPCs", "helper", Progression.Helpers)
|
||||
if shopState.isAdmin then CreateAdminPage(sheet) end
|
||||
if shopState.isAdmin then
|
||||
CreateAdminPage(sheet)
|
||||
CreateAdminPlayersPage(sheet)
|
||||
end
|
||||
|
||||
sheet.OnActiveTabChanged = function(_, _, newTab)
|
||||
if IsValid(newTab) then shopViewState.activeTab = newTab:GetText() end
|
||||
end
|
||||
for _, item in ipairs(sheet.Items or {}) do
|
||||
if IsValid(item.Tab) and item.Tab:GetText() == shopViewState.activeTab then
|
||||
sheet:SetActiveTab(item.Tab)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function ReadShopPrices()
|
||||
@@ -510,6 +716,46 @@ net.Receive("CustomCoopProgressionPricesChanged", function()
|
||||
notification.AddLegacy("Shop prices were updated by an administrator.", NOTIFY_GENERIC, 4)
|
||||
end)
|
||||
|
||||
net.Receive("CustomCoopProgressionAdminPlayers", function()
|
||||
local requestID = net.ReadUInt(16)
|
||||
if requestID ~= adminPlayerRequestID then return end
|
||||
|
||||
local reset = net.ReadBool()
|
||||
local finished = net.ReadBool()
|
||||
local count = net.ReadUInt(8)
|
||||
if reset then shopState.adminPlayers = {} end
|
||||
|
||||
for _ = 1, count do
|
||||
local record = {
|
||||
steamID64 = net.ReadString(),
|
||||
playerName = net.ReadString(),
|
||||
online = net.ReadBool(),
|
||||
score = net.ReadUInt(32),
|
||||
credits = net.ReadUInt(32),
|
||||
playtime = net.ReadUInt(32),
|
||||
equippedModel = net.ReadString(),
|
||||
equippedWeapon = net.ReadString(),
|
||||
equippedTrail = net.ReadString(),
|
||||
purchases = {},
|
||||
enabledBehaviours = {}
|
||||
}
|
||||
for _ = 1, net.ReadUInt(8) do
|
||||
record.purchases[#record.purchases + 1] = {
|
||||
itemType = net.ReadString(),
|
||||
itemID = net.ReadString(),
|
||||
purchasedAt = net.ReadUInt(32)
|
||||
}
|
||||
end
|
||||
for _ = 1, net.ReadUInt(8) do
|
||||
record.enabledBehaviours[#record.enabledBehaviours + 1] = net.ReadString()
|
||||
end
|
||||
shopState.adminPlayers[#shopState.adminPlayers + 1] = record
|
||||
end
|
||||
|
||||
shopState.adminPlayersLoading = not finished
|
||||
RefreshAdminPlayersView()
|
||||
end)
|
||||
|
||||
net.Receive("CustomCoopProgressionNotice", function()
|
||||
local success = net.ReadBool()
|
||||
local message = net.ReadString()
|
||||
|
||||
@@ -39,6 +39,7 @@ local nextMapVotes = {}
|
||||
local nextMapChoices = {}
|
||||
local nextMapVoteFinished = false
|
||||
local installedMaps
|
||||
local failedMapChanges = {}
|
||||
|
||||
local function RefreshInstalledMaps()
|
||||
installedMaps = {}
|
||||
@@ -50,13 +51,32 @@ end
|
||||
local function MapExists(mapName)
|
||||
if not mapName or mapName == "" then return false end
|
||||
if not installedMaps then RefreshInstalledMaps() end
|
||||
return installedMaps[string.lower(string.StripExtension(mapName))] == true
|
||||
local normalizedName = string.lower(string.StripExtension(mapName))
|
||||
return installedMaps[normalizedName] == true and not failedMapChanges[normalizedName]
|
||||
end
|
||||
|
||||
local function GetRandomAvailableMap(excludeLevel)
|
||||
local candidates = {}
|
||||
local excluded = string.lower(excludeLevel or game.GetMap() or "")
|
||||
|
||||
for _, mapName in ipairs(GetInstalledCoopLevels()) do
|
||||
if mapName ~= excluded and MapExists(mapName) then
|
||||
candidates[#candidates + 1] = mapName
|
||||
end
|
||||
end
|
||||
|
||||
if #candidates == 0 then return nil end
|
||||
return candidates[math.random(#candidates)]
|
||||
end
|
||||
|
||||
local function SetFreshNextLevel()
|
||||
local nextLevel = GetConVar("nextlevel"):GetString()
|
||||
if nextLevel == "" or not MapExists(nextLevel) or string.lower(nextLevel) == string.lower(game.GetMap()) then
|
||||
nextLevel = NextRandomLevel()
|
||||
nextLevel = GetRandomAvailableMap()
|
||||
if nextLevel then
|
||||
print("[custom_coop] Setting nextlevel to " .. nextLevel)
|
||||
RunConsoleCommand("nextlevel", nextLevel)
|
||||
end
|
||||
end
|
||||
return nextLevel
|
||||
end
|
||||
@@ -78,7 +98,7 @@ local function BuildNextMapChoices()
|
||||
local currentMap = string.lower(game.GetMap())
|
||||
|
||||
for _, mapName in ipairs(GetInstalledCoopLevels()) do
|
||||
if mapName ~= currentMap then
|
||||
if mapName ~= currentMap and MapExists(mapName) then
|
||||
candidates[#candidates + 1] = mapName
|
||||
end
|
||||
end
|
||||
@@ -215,7 +235,7 @@ end
|
||||
function QueueCoopMapChange(reason, requestedMap)
|
||||
if changeQueued then return end
|
||||
|
||||
local destination = requestedMap and string.lower(requestedMap) or nil
|
||||
local destination = requestedMap and string.lower(string.StripExtension(requestedMap)) or nil
|
||||
if not MapExists(destination) or destination == string.lower(game.GetMap()) then
|
||||
destination = SetFreshNextLevel()
|
||||
end
|
||||
@@ -233,7 +253,34 @@ function QueueCoopMapChange(reason, requestedMap)
|
||||
print("[custom_coop] Map change queued by " .. (reason or "unknown") .. ": " .. destination)
|
||||
|
||||
timer.Create("CustomCoopChangeCompletedMap", delay, 1, function()
|
||||
local sourceMap = string.lower(game.GetMap() or "")
|
||||
RunConsoleCommand("changelevel", destination)
|
||||
|
||||
-- A rejected changelevel leaves this Lua state alive. Without a
|
||||
-- watchdog, changeQueued remains true forever and all later !skip
|
||||
-- attempts incorrectly claim that a map change is still pending.
|
||||
timer.Create("CustomCoopChangeLevelWatchdog", 10, 1, function()
|
||||
if string.lower(game.GetMap() or "") ~= sourceMap then return end
|
||||
|
||||
failedMapChanges[destination] = true
|
||||
changeQueued = false
|
||||
skipVotes = {}
|
||||
nextMapVotes = {}
|
||||
nextMapChoices = {}
|
||||
nextMapVoteFinished = false
|
||||
|
||||
if string.lower(GetConVar("nextlevel"):GetString()) == destination then
|
||||
RunConsoleCommand("nextlevel", "")
|
||||
end
|
||||
|
||||
local replacement = SetFreshNextLevel()
|
||||
local message = "Could not load " .. destination .. ". Map-change voting is available again."
|
||||
if replacement then
|
||||
message = message .. " The next candidate is " .. replacement .. "."
|
||||
end
|
||||
PrintMessage(HUD_PRINTTALK, "[CO-OP] " .. message)
|
||||
ErrorNoHalt("[custom_coop] changelevel failed for " .. destination .. "; destination quarantined until restart\n")
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -300,7 +347,7 @@ hook.Add("EntityKeyValue", "CustomCoopRepairChangelevelTargets", function(ent, k
|
||||
if string.lower(key or "") ~= "map" then return end
|
||||
if MapExists(value) and string.lower(value) ~= string.lower(game.GetMap()) then return end
|
||||
|
||||
local replacement = GetRandomLevel()
|
||||
local replacement = GetRandomAvailableMap()
|
||||
if replacement then
|
||||
print("[custom_coop] Replacing unavailable changelevel target '" .. tostring(value) .. "' with " .. replacement)
|
||||
return replacement
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
GM.Name = "Feedc0de's coop Mode"
|
||||
GM.Name = "feedc0de Coop Campaign"
|
||||
GM.Author = "0xFEEDC0DE64"
|
||||
GM.Email = "daniel@brunner.ninja"
|
||||
GM.Website = "brunner.ninja"
|
||||
@@ -12,11 +12,11 @@ if (SERVER) then
|
||||
function ChangeRandomLevel()
|
||||
local randomLevel = GetRandomLevel()
|
||||
if not randomLevel then
|
||||
ErrorNoHalt("[custom_coop] No other installed coop map was found in levels.lua\n")
|
||||
ErrorNoHalt("[feedc0de_coop] No other installed coop map was found in levels.lua\n")
|
||||
return false
|
||||
end
|
||||
|
||||
print("[custom_coop] Changing to random level " .. randomLevel)
|
||||
print("[feedc0de_coop] Changing to random level " .. randomLevel)
|
||||
RunConsoleCommand("changelevel", randomLevel)
|
||||
return true
|
||||
end
|
||||
@@ -24,11 +24,11 @@ if (SERVER) then
|
||||
function NextRandomLevel()
|
||||
local randomLevel = GetRandomLevel()
|
||||
if not randomLevel then
|
||||
ErrorNoHalt("[custom_coop] Cannot set nextlevel: no other installed coop map was found\n")
|
||||
ErrorNoHalt("[feedc0de_coop] Cannot set nextlevel: no other installed coop map was found\n")
|
||||
return nil
|
||||
end
|
||||
|
||||
print("[custom_coop] Setting nextlevel to " .. randomLevel)
|
||||
print("[feedc0de_coop] Setting nextlevel to " .. randomLevel)
|
||||
RunConsoleCommand("nextlevel", randomLevel)
|
||||
return randomLevel
|
||||
end
|
||||
|
||||
@@ -43,6 +43,8 @@ util.AddNetworkString("CustomCoopProgressionSetPrice")
|
||||
util.AddNetworkString("CustomCoopProgressionPricesChanged")
|
||||
util.AddNetworkString("CustomCoopProgressionPlayMusic")
|
||||
util.AddNetworkString("CustomCoopProgressionToggleMusicMute")
|
||||
util.AddNetworkString("CustomCoopProgressionRequestAdminPlayers")
|
||||
util.AddNetworkString("CustomCoopProgressionAdminPlayers")
|
||||
|
||||
local function Query(query, context)
|
||||
local startedAt = SysTime()
|
||||
@@ -779,6 +781,193 @@ local function SendShop(ply)
|
||||
net.Send(ply)
|
||||
end
|
||||
|
||||
local function GetAdminPlayerRecord(recordsBySteamID, records, steamID64)
|
||||
steamID64 = tostring(steamID64 or "")
|
||||
if steamID64 == "" then return end
|
||||
|
||||
local record = recordsBySteamID[steamID64]
|
||||
if record then return record end
|
||||
|
||||
record = {
|
||||
steamID64 = steamID64,
|
||||
playerName = steamID64,
|
||||
online = false,
|
||||
score = 0,
|
||||
credits = 0,
|
||||
playtime = 0,
|
||||
equippedModel = Progression.DefaultModelID,
|
||||
equippedWeapon = Progression.DefaultWeaponID,
|
||||
equippedTrail = Progression.DefaultTrailID,
|
||||
purchases = {},
|
||||
purchaseKeys = {},
|
||||
enabledBehaviours = {},
|
||||
enabledKeys = {}
|
||||
}
|
||||
recordsBySteamID[steamID64] = record
|
||||
records[#records + 1] = record
|
||||
return record
|
||||
end
|
||||
|
||||
local function AddAdminPurchase(record, itemType, itemID, purchasedAt)
|
||||
itemType = tostring(itemType or "")
|
||||
itemID = tostring(itemID or "")
|
||||
if not record or itemType == "" or itemID == "" then return end
|
||||
|
||||
local key = PriceKey(itemType, itemID)
|
||||
if record.purchaseKeys[key] then return end
|
||||
record.purchaseKeys[key] = true
|
||||
record.purchases[#record.purchases + 1] = {
|
||||
itemType = itemType,
|
||||
itemID = itemID,
|
||||
purchasedAt = math.max(math.floor(tonumber(purchasedAt) or 0), 0)
|
||||
}
|
||||
end
|
||||
|
||||
local function AddAdminEnabledBehaviour(record, itemID)
|
||||
itemID = tostring(itemID or "")
|
||||
if not record or itemID == "" or record.enabledKeys[itemID] then return end
|
||||
record.enabledKeys[itemID] = true
|
||||
record.enabledBehaviours[#record.enabledBehaviours + 1] = itemID
|
||||
end
|
||||
|
||||
local function ApplyRuntimeStateToAdminRecord(record, state, playerName, online)
|
||||
if not record or not state then return end
|
||||
|
||||
record.playerName = tostring(playerName or state.playerName or record.playerName)
|
||||
record.online = online == true
|
||||
record.score = math.max(math.floor(tonumber(state.score) or 0), 0)
|
||||
record.credits = math.max(math.floor(tonumber(state.credits) or 0), 0)
|
||||
record.playtime = math.max(math.floor(tonumber(state.playtimeSeconds) or 0), 0)
|
||||
record.equippedModel = state.equippedModel or Progression.DefaultModelID
|
||||
record.equippedWeapon = state.equippedWeapon or Progression.DefaultWeaponID
|
||||
record.equippedTrail = state.equippedTrail or Progression.DefaultTrailID
|
||||
|
||||
for _, pending in pairs(state.pendingOwnedItems or {}) do
|
||||
AddAdminPurchase(record, pending.itemType, pending.itemID, pending.purchasedAt)
|
||||
end
|
||||
|
||||
record.enabledBehaviours = {}
|
||||
record.enabledKeys = {}
|
||||
for itemID in pairs(state.enabledBehaviours or {}) do
|
||||
AddAdminEnabledBehaviour(record, itemID)
|
||||
end
|
||||
end
|
||||
|
||||
local function LoadAdminPlayerRecords()
|
||||
local recordsBySteamID = {}
|
||||
local records = {}
|
||||
local playerRows = Query(
|
||||
"SELECT steamid64, player_name, lifetime_score, credits, playtime_seconds, " ..
|
||||
"equipped_model, equipped_weapon, equipped_trail FROM custom_coop_players",
|
||||
"could not load admin player list"
|
||||
)
|
||||
|
||||
if istable(playerRows) then
|
||||
for _, row in ipairs(playerRows) do
|
||||
local record = GetAdminPlayerRecord(recordsBySteamID, records, row.steamid64)
|
||||
if record then
|
||||
record.playerName = tostring(row.player_name or record.steamID64)
|
||||
record.score = math.max(math.floor(tonumber(row.lifetime_score) or 0), 0)
|
||||
record.credits = math.max(math.floor(tonumber(row.credits) or 0), 0)
|
||||
record.playtime = math.max(math.floor(tonumber(row.playtime_seconds) or 0), 0)
|
||||
record.equippedModel = row.equipped_model or Progression.DefaultModelID
|
||||
record.equippedWeapon = row.equipped_weapon or Progression.DefaultWeaponID
|
||||
record.equippedTrail = row.equipped_trail or Progression.DefaultTrailID
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local purchaseRows = Query(
|
||||
"SELECT steamid64, item_type, item_id, purchased_at FROM custom_coop_owned_items " ..
|
||||
"ORDER BY purchased_at, item_type, item_id",
|
||||
"could not load admin player purchases"
|
||||
)
|
||||
if istable(purchaseRows) then
|
||||
for _, row in ipairs(purchaseRows) do
|
||||
AddAdminPurchase(recordsBySteamID[tostring(row.steamid64 or "")], row.item_type, row.item_id, row.purchased_at)
|
||||
end
|
||||
end
|
||||
|
||||
local enabledRows = Query(
|
||||
"SELECT steamid64, item_id FROM custom_coop_enabled_behaviours ORDER BY item_id",
|
||||
"could not load admin player behaviours"
|
||||
)
|
||||
if istable(enabledRows) then
|
||||
for _, row in ipairs(enabledRows) do
|
||||
AddAdminEnabledBehaviour(recordsBySteamID[tostring(row.steamid64 or "")], row.item_id)
|
||||
end
|
||||
end
|
||||
|
||||
for ply, state in pairs(playerStates) do
|
||||
local record = GetAdminPlayerRecord(recordsBySteamID, records, state.steamID64)
|
||||
ApplyRuntimeStateToAdminRecord(record, state, IsValid(ply) and ply:Nick() or nil, IsValid(ply))
|
||||
end
|
||||
for steamID64, state in pairs(disconnectedStates) do
|
||||
local record = GetAdminPlayerRecord(recordsBySteamID, records, steamID64)
|
||||
ApplyRuntimeStateToAdminRecord(record, state, nil, false)
|
||||
end
|
||||
|
||||
for _, record in ipairs(records) do
|
||||
table.sort(record.purchases, function(left, right)
|
||||
if left.itemType == right.itemType then return left.itemID < right.itemID end
|
||||
return left.itemType < right.itemType
|
||||
end)
|
||||
table.sort(record.enabledBehaviours)
|
||||
end
|
||||
table.sort(records, function(left, right)
|
||||
if left.score ~= right.score then return left.score > right.score end
|
||||
if left.credits ~= right.credits then return left.credits > right.credits end
|
||||
return string.lower(left.playerName) < string.lower(right.playerName)
|
||||
end)
|
||||
return records
|
||||
end
|
||||
|
||||
local function SendAdminPlayerBatch(ply, requestID, records, firstIndex, lastIndex)
|
||||
local count = math.max(lastIndex - firstIndex + 1, 0)
|
||||
net.Start("CustomCoopProgressionAdminPlayers")
|
||||
net.WriteUInt(requestID, 16)
|
||||
net.WriteBool(firstIndex == 1)
|
||||
net.WriteBool(lastIndex >= #records)
|
||||
net.WriteUInt(math.min(count, 255), 8)
|
||||
for index = firstIndex, lastIndex do
|
||||
local record = records[index]
|
||||
net.WriteString(record.steamID64)
|
||||
net.WriteString(string.sub(record.playerName, 1, 96))
|
||||
net.WriteBool(record.online)
|
||||
net.WriteUInt(math.min(record.score, 4294967295), 32)
|
||||
net.WriteUInt(math.min(record.credits, 4294967295), 32)
|
||||
net.WriteUInt(math.min(record.playtime, 4294967295), 32)
|
||||
net.WriteString(record.equippedModel)
|
||||
net.WriteString(record.equippedWeapon)
|
||||
net.WriteString(record.equippedTrail)
|
||||
net.WriteUInt(math.min(#record.purchases, 255), 8)
|
||||
for purchaseIndex = 1, math.min(#record.purchases, 255) do
|
||||
local purchase = record.purchases[purchaseIndex]
|
||||
net.WriteString(purchase.itemType)
|
||||
net.WriteString(purchase.itemID)
|
||||
net.WriteUInt(math.min(purchase.purchasedAt, 4294967295), 32)
|
||||
end
|
||||
net.WriteUInt(math.min(#record.enabledBehaviours, 255), 8)
|
||||
for behaviourIndex = 1, math.min(#record.enabledBehaviours, 255) do
|
||||
net.WriteString(record.enabledBehaviours[behaviourIndex])
|
||||
end
|
||||
end
|
||||
net.Send(ply)
|
||||
end
|
||||
|
||||
local function SendAdminPlayers(ply, requestID)
|
||||
local records = LoadAdminPlayerRecords()
|
||||
if #records == 0 then
|
||||
SendAdminPlayerBatch(ply, requestID, records, 1, 0)
|
||||
return
|
||||
end
|
||||
|
||||
local batchSize = 25
|
||||
for firstIndex = 1, #records, batchSize do
|
||||
SendAdminPlayerBatch(ply, requestID, records, firstIndex, math.min(firstIndex + batchSize - 1, #records))
|
||||
end
|
||||
end
|
||||
|
||||
local function SendNotice(ply, success, message)
|
||||
net.Start("CustomCoopProgressionNotice")
|
||||
net.WriteBool(success)
|
||||
@@ -1422,6 +1611,17 @@ net.Receive("CustomCoopProgressionRequestShop", function(_, ply)
|
||||
SendShop(ply)
|
||||
end)
|
||||
|
||||
net.Receive("CustomCoopProgressionRequestAdminPlayers", function(_, ply)
|
||||
if not RequestAllowed(ply) then return end
|
||||
|
||||
local requestID = net.ReadUInt(16)
|
||||
if not ply:IsAdmin() then
|
||||
SendNotice(ply, false, "Only server administrators can view all player records.")
|
||||
return
|
||||
end
|
||||
SendAdminPlayers(ply, requestID)
|
||||
end)
|
||||
|
||||
net.Receive("CustomCoopProgressionSelectItem", function(_, ply)
|
||||
if not RequestAllowed(ply) then return end
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# feedc0de Coop loading screen
|
||||
|
||||
This directory contains the loading page and a dependency-free Python HTTP
|
||||
service. The service exposes only the page, the selected campaign screenshots,
|
||||
a read-only player-summary query, and a top-five all-time Score leaderboard. It
|
||||
does not expose Steam IDs, `sv.db`, Lua files, or directory listings.
|
||||
|
||||
The Kubernetes loading-screen sidecar starts and stops this service together
|
||||
with SRCDS on TCP port `8765`. The configured public URL is:
|
||||
|
||||
https://garrysmod.brunner.ninja/?steamid=%s&map=%m
|
||||
|
||||
`%s` and `%m` are filled in by Garry's Mod. The HTTPS virtual host should
|
||||
reverse-proxy to the loading-screen service on port `8765`.
|
||||
|
||||
For a browser preview with sample engine callbacks:
|
||||
|
||||
python3 server.py --bind 127.0.0.1 --port 8765
|
||||
|
||||
Then open `http://127.0.0.1:8765/?demo=1`.
|
||||
|
||||
The progress percentage is based on Garry's Mod's file-count and join-status
|
||||
callbacks. Garry's Mod does not expose byte-level Workshop progress to loading
|
||||
web pages, so the interface intentionally does not claim to show byte progress.
|
||||
@@ -0,0 +1,358 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var screenshots = [
|
||||
{ file: "evac_1.jpg", title: "The road ahead" },
|
||||
{ file: "evac_2.jpg", title: "Make every round count" },
|
||||
{ file: "evac_3.jpg", title: "Hold the perimeter" },
|
||||
{ file: "evac_4.jpg", title: "Extraction route" },
|
||||
{ file: "abducted_1.jpg", title: "Into the unknown" },
|
||||
{ file: "abducted_2.jpg", title: "No one fights alone" },
|
||||
{ file: "abducted_3.jpg", title: "Containment breached" },
|
||||
{ file: "horror_raid.jpg", title: "After the sirens" },
|
||||
{ file: "scream_1.jpg", title: "Keep moving" },
|
||||
{ file: "scream_2.jpg", title: "Lights out" },
|
||||
{ file: "scream_3.jpg", title: "Something is watching" },
|
||||
{ file: "southtown.jpg", title: "The last safe block" }
|
||||
];
|
||||
|
||||
var tips = [
|
||||
"Stay close. Co-op maps are at their best when the squad moves together.",
|
||||
"Press F3 or type !shop to spend Credits on gear, abilities and companions.",
|
||||
"Harder enemies award more Score and Credits. Lifetime Score determines your rank.",
|
||||
"Type !nextmap to vote for the next mission, or !skip if the current map is stuck.",
|
||||
"Dying can cost Credits, but it never reduces your lifetime Score or rank.",
|
||||
"Hold TAB in game to compare lifetime scores with the rest of the squad."
|
||||
];
|
||||
|
||||
var state = {
|
||||
filesTotal: 0,
|
||||
filesNeeded: 0,
|
||||
maxNeeded: 0,
|
||||
filesStarted: {},
|
||||
startedCount: 0,
|
||||
stage: 0,
|
||||
progress: 4,
|
||||
steamID: "",
|
||||
profileRequested: "",
|
||||
leaderboardRequested: false,
|
||||
currentSlide: 0,
|
||||
activeLayer: 0
|
||||
};
|
||||
|
||||
function byId(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function queryValue(name) {
|
||||
var match = new RegExp("(?:^|&)" + name + "=([^&]*)", "i").exec(window.location.search.substring(1));
|
||||
return match ? decodeURIComponent(match[1].replace(/\+/g, " ")) : "";
|
||||
}
|
||||
|
||||
function cleanInteger(value) {
|
||||
value = parseInt(value, 10);
|
||||
return isFinite(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
var text = String(Math.max(parseInt(value, 10) || 0, 0));
|
||||
return text.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
function formatPlaytime(seconds) {
|
||||
var minutes = Math.max(Math.floor((parseInt(seconds, 10) || 0) / 60), 0);
|
||||
var days = Math.floor(minutes / 1440);
|
||||
var hours = Math.floor((minutes % 1440) / 60);
|
||||
var mins = minutes % 60;
|
||||
if (days > 0) return days + "d " + hours + "h";
|
||||
if (hours > 0) return hours + "h " + mins + "m";
|
||||
return mins + "m";
|
||||
}
|
||||
|
||||
function titleCaseMap(mapName) {
|
||||
if (!mapName) return "Preparing mission";
|
||||
var words = mapName.replace(/\.(bsp|ain)$/i, "").replace(/[_\-]+/g, " ").replace(/\s+/g, " ").trim();
|
||||
return words.replace(/\b([a-z])/g, function (_, letter) { return letter.toUpperCase(); });
|
||||
}
|
||||
|
||||
function setStage(stage) {
|
||||
var items = byId("stages").getElementsByTagName("li");
|
||||
var index;
|
||||
state.stage = Math.max(state.stage, Math.min(stage, items.length - 1));
|
||||
for (index = 0; index < items.length; index += 1) {
|
||||
items[index].className = index < state.stage ? "done" : (index === state.stage ? "active" : "");
|
||||
}
|
||||
}
|
||||
|
||||
function setProgress(value) {
|
||||
value = Math.max(state.progress, Math.min(Math.round(value), 99));
|
||||
state.progress = value;
|
||||
byId("progress-bar").style.width = value + "%";
|
||||
byId("percent").textContent = value;
|
||||
}
|
||||
|
||||
function updateFileProgress() {
|
||||
var needed = state.maxNeeded || state.filesNeeded;
|
||||
var completedFromNeeded = needed && state.filesNeeded <= needed ? needed - state.filesNeeded : 0;
|
||||
var completedFromStarts = Math.max(state.startedCount - 1, 0);
|
||||
var completed = Math.max(completedFromNeeded, completedFromStarts);
|
||||
var receiving = Math.min(Math.max(completed + (state.startedCount ? 1 : 0), 0), needed || state.filesTotal || 0);
|
||||
var ratio;
|
||||
|
||||
if (needed > 0) {
|
||||
completed = Math.min(completed, needed);
|
||||
ratio = Math.min((completed + (state.startedCount ? 0.35 : 0)) / needed, 0.98);
|
||||
setProgress(12 + ratio * 74);
|
||||
byId("file-count").textContent = "FILE " + receiving + " OF " + needed + " REQUIRED";
|
||||
} else if (state.filesTotal > 0) {
|
||||
byId("file-count").textContent = state.filesTotal + " FILES ON MANIFEST";
|
||||
}
|
||||
}
|
||||
|
||||
function sourceForFile(fileName) {
|
||||
if (/workshop|\.gma$|^\s*\d{7,}\s*$/i.test(fileName)) return "WORKSHOP CONTENT";
|
||||
return "SERVER CONTENT";
|
||||
}
|
||||
|
||||
function stageForStatus(status) {
|
||||
if (/lua|client info|spawn|precach|ready|starting/i.test(status)) return 3;
|
||||
if (/download|resource|materials|models|sound|fastdl/i.test(status)) return 2;
|
||||
if (/workshop|addon|mount/i.test(status)) return 1;
|
||||
return state.stage;
|
||||
}
|
||||
|
||||
function progressForStatus(status, stage) {
|
||||
if (stage === 1) return 10;
|
||||
if (stage === 2) return 14;
|
||||
if (/client info sent|ready|fully connected/i.test(status)) return 98;
|
||||
if (/starting lua|lua started/i.test(status)) return 95;
|
||||
if (/sending client info|precach|creating/i.test(status)) return 90;
|
||||
if (stage === 3) return 88;
|
||||
return 6;
|
||||
}
|
||||
|
||||
function requestProfile(steamID) {
|
||||
var request;
|
||||
if (!/^7656119\d{10}$/.test(steamID) || state.profileRequested === steamID) return;
|
||||
state.profileRequested = steamID;
|
||||
request = new XMLHttpRequest();
|
||||
request.open("GET", "/api/player?steamid=" + encodeURIComponent(steamID), true);
|
||||
request.timeout = 3000;
|
||||
request.onreadystatechange = function () {
|
||||
var profile;
|
||||
if (request.readyState !== 4) return;
|
||||
if (request.status >= 200 && request.status < 300) {
|
||||
try {
|
||||
profile = JSON.parse(request.responseText);
|
||||
showProfile(profile);
|
||||
return;
|
||||
} catch (ignore) {}
|
||||
}
|
||||
showProfileUnavailable();
|
||||
};
|
||||
request.ontimeout = showProfileUnavailable;
|
||||
request.onerror = showProfileUnavailable;
|
||||
request.send(null);
|
||||
}
|
||||
|
||||
function monogram(name) {
|
||||
var words = String(name || "?").replace(/[^a-z0-9 ]/gi, " ").trim().split(/\s+/);
|
||||
var result = words.length > 1 ? words[0].charAt(0) + words[words.length - 1].charAt(0) : words[0].slice(0, 2);
|
||||
return result.toUpperCase() || "?";
|
||||
}
|
||||
|
||||
function showProfile(profile) {
|
||||
var name = profile.playerName || "New operative";
|
||||
byId("player-name").textContent = name;
|
||||
byId("profile-monogram").textContent = monogram(name);
|
||||
byId("profile-loading").className = "profile-loading hidden";
|
||||
byId("profile-stats").className = "stats";
|
||||
byId("player-score").textContent = formatNumber(profile.score);
|
||||
byId("player-credits").textContent = formatNumber(profile.credits);
|
||||
byId("player-time").textContent = formatPlaytime(profile.playtimeSeconds);
|
||||
byId("player-rank").textContent = profile.rank || "Newbie";
|
||||
byId("profile-kicker").textContent = profile.found ? "WELCOME BACK, OPERATIVE" : "NEW OPERATIVE RECORD";
|
||||
}
|
||||
|
||||
function showProfileUnavailable() {
|
||||
byId("profile-loading").innerHTML = "<span>Progression record available after deployment.</span>";
|
||||
}
|
||||
|
||||
function showLeaderboard(players) {
|
||||
var list = byId("leaderboard-list");
|
||||
var index;
|
||||
var player;
|
||||
var row;
|
||||
var position;
|
||||
var identity;
|
||||
var name;
|
||||
var rank;
|
||||
var score;
|
||||
|
||||
while (list.firstChild) list.removeChild(list.firstChild);
|
||||
if (!players || !players.length) {
|
||||
row = document.createElement("li");
|
||||
row.className = "leaderboard-loading";
|
||||
row.textContent = "No campaign records yet.";
|
||||
list.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
for (index = 0; index < players.length; index += 1) {
|
||||
player = players[index];
|
||||
row = document.createElement("li");
|
||||
|
||||
position = document.createElement("span");
|
||||
position.className = "leaderboard-position";
|
||||
position.textContent = String(player.position || index + 1).replace(/^(\d)$/, "0$1");
|
||||
|
||||
identity = document.createElement("div");
|
||||
name = document.createElement("strong");
|
||||
name.textContent = player.playerName || "Unknown operative";
|
||||
rank = document.createElement("small");
|
||||
rank.textContent = (player.rank || "Newbie") + " · " + formatPlaytime(player.playtimeSeconds);
|
||||
identity.appendChild(name);
|
||||
identity.appendChild(rank);
|
||||
|
||||
score = document.createElement("span");
|
||||
score.className = "leaderboard-score";
|
||||
score.textContent = formatNumber(player.score);
|
||||
|
||||
row.appendChild(position);
|
||||
row.appendChild(identity);
|
||||
row.appendChild(score);
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function showLeaderboardUnavailable() {
|
||||
var list = byId("leaderboard-list");
|
||||
list.innerHTML = '<li class="leaderboard-loading">Leaderboard temporarily unavailable.</li>';
|
||||
}
|
||||
|
||||
function requestLeaderboard() {
|
||||
var request;
|
||||
if (state.leaderboardRequested) return;
|
||||
state.leaderboardRequested = true;
|
||||
request = new XMLHttpRequest();
|
||||
request.open("GET", "/api/leaderboard", true);
|
||||
request.timeout = 3000;
|
||||
request.onreadystatechange = function () {
|
||||
var payload;
|
||||
if (request.readyState !== 4) return;
|
||||
if (request.status >= 200 && request.status < 300) {
|
||||
try {
|
||||
payload = JSON.parse(request.responseText);
|
||||
showLeaderboard(payload.players || []);
|
||||
return;
|
||||
} catch (ignore) {}
|
||||
}
|
||||
showLeaderboardUnavailable();
|
||||
};
|
||||
request.ontimeout = showLeaderboardUnavailable;
|
||||
request.onerror = showLeaderboardUnavailable;
|
||||
request.send(null);
|
||||
}
|
||||
|
||||
function showSlide(index, immediate) {
|
||||
var next = screenshots[index % screenshots.length];
|
||||
var nextLayer = state.activeLayer ? byId("slide-a") : byId("slide-b");
|
||||
var oldLayer = state.activeLayer ? byId("slide-b") : byId("slide-a");
|
||||
var image = new Image();
|
||||
var url = "/assets/screenshots/" + encodeURIComponent(next.file);
|
||||
|
||||
image.onload = function () {
|
||||
nextLayer.style.backgroundImage = "url('" + url + "')";
|
||||
if (immediate) {
|
||||
nextLayer.style.transition = "none";
|
||||
}
|
||||
nextLayer.className = "slide slide-visible";
|
||||
oldLayer.className = "slide";
|
||||
if (immediate) {
|
||||
window.setTimeout(function () { nextLayer.style.transition = ""; }, 50);
|
||||
}
|
||||
state.activeLayer = state.activeLayer ? 0 : 1;
|
||||
state.currentSlide = index % screenshots.length;
|
||||
byId("photo-index").textContent = (state.currentSlide + 1 < 10 ? "0" : "") + (state.currentSlide + 1) + " / " + screenshots.length;
|
||||
byId("photo-title").textContent = next.title;
|
||||
};
|
||||
image.src = url;
|
||||
}
|
||||
|
||||
window.GameDetails = function (serverName, serverURL, mapName, maxPlayers, steamID, gamemode) {
|
||||
var players = cleanInteger(maxPlayers);
|
||||
byId("server-name").textContent = serverName || "brunner.ninja";
|
||||
byId("map-title").textContent = titleCaseMap(mapName);
|
||||
byId("gamemode").textContent = titleCaseMap(gamemode || "feedc0de_coop").toUpperCase();
|
||||
byId("slots").textContent = players ? players + " PLAYER SLOTS" : "CO-OPERATIVE CAMPAIGNS";
|
||||
byId("server-state").textContent = "SERVER CONTACT ESTABLISHED";
|
||||
state.steamID = String(steamID || state.steamID);
|
||||
setProgress(6);
|
||||
requestProfile(state.steamID);
|
||||
};
|
||||
|
||||
window.SetFilesTotal = function (total) {
|
||||
state.filesTotal = Math.max(state.filesTotal, cleanInteger(total));
|
||||
updateFileProgress();
|
||||
};
|
||||
|
||||
window.SetFilesNeeded = function (needed) {
|
||||
state.filesNeeded = cleanInteger(needed);
|
||||
state.maxNeeded = Math.max(state.maxNeeded, state.filesNeeded);
|
||||
if (state.filesNeeded > 0) setStage(1);
|
||||
updateFileProgress();
|
||||
};
|
||||
|
||||
window.DownloadingFile = function (fileName) {
|
||||
fileName = String(fileName || "Preparing content").replace(/\\/g, "/");
|
||||
if (!state.filesStarted[fileName]) {
|
||||
state.filesStarted[fileName] = true;
|
||||
state.startedCount += 1;
|
||||
}
|
||||
byId("phase-label").textContent = sourceForFile(fileName);
|
||||
byId("status-text").textContent = "Receiving required content";
|
||||
byId("file-name").textContent = fileName;
|
||||
setStage(sourceForFile(fileName) === "WORKSHOP CONTENT" ? 1 : 2);
|
||||
updateFileProgress();
|
||||
};
|
||||
|
||||
window.SetStatusChanged = function (status) {
|
||||
var newStage;
|
||||
status = String(status || "Working…").replace(/^\s+|\s+$/g, "");
|
||||
newStage = stageForStatus(status);
|
||||
setStage(newStage);
|
||||
byId("status-text").textContent = status;
|
||||
byId("phase-label").textContent = newStage === 3 ? "PREPARING YOUR SESSION" : (newStage === 2 ? "SERVER CONTENT" : (newStage === 1 ? "WORKSHOP CONTENT" : "JOINING SERVER"));
|
||||
if (newStage === 3) byId("file-name").textContent = "Downloads complete — initializing game code";
|
||||
setProgress(progressForStatus(status, newStage));
|
||||
};
|
||||
|
||||
function initialize() {
|
||||
var map = queryValue("map");
|
||||
var steamID = queryValue("steamid");
|
||||
var seed = 0;
|
||||
var i;
|
||||
|
||||
for (i = 0; i < (map + steamID).length; i += 1) seed += (map + steamID).charCodeAt(i);
|
||||
state.currentSlide = seed % screenshots.length;
|
||||
byId("tip").textContent = tips[seed % tips.length];
|
||||
if (map) byId("map-title").textContent = titleCaseMap(map);
|
||||
if (steamID) {
|
||||
state.steamID = steamID;
|
||||
requestProfile(steamID);
|
||||
}
|
||||
requestLeaderboard();
|
||||
showSlide(state.currentSlide, true);
|
||||
window.setInterval(function () { showSlide(state.currentSlide + 1, false); }, 8000);
|
||||
|
||||
if (queryValue("demo") === "1") {
|
||||
window.GameDetails("brunner.ninja", window.location.href, map || "tbr_coop_thousand_antlions_v3b", 16, steamID || "76561198171048231", "feedc0de_coop");
|
||||
window.SetFilesTotal(148);
|
||||
window.SetFilesNeeded(37);
|
||||
window.SetStatusChanged("Downloading Workshop content");
|
||||
window.setTimeout(function () { window.DownloadingFile("workshop/706104732/campaign_content.gma"); }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
initialize();
|
||||
}());
|
||||
@@ -0,0 +1,111 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>brunner.ninja // feedc0de Coop Campaign</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="backdrop" aria-hidden="true">
|
||||
<div id="slide-a" class="slide slide-visible"></div>
|
||||
<div id="slide-b" class="slide"></div>
|
||||
<div class="shade"></div>
|
||||
<div class="grain"></div>
|
||||
</div>
|
||||
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark"><i></i></span>
|
||||
<div>
|
||||
<strong>BRUNNER.NINJA</strong>
|
||||
<span>FEEDC0DE COOP</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live"><i></i><span id="server-state">CONNECTING TO SERVER</span></div>
|
||||
</header>
|
||||
|
||||
<main class="hero">
|
||||
<section class="mission" aria-labelledby="map-title">
|
||||
<div class="eyebrow">CURRENT DEPLOYMENT</div>
|
||||
<h1 id="map-title">Preparing mission</h1>
|
||||
<div class="mission-meta">
|
||||
<span id="gamemode">FEEDC0DE COOP CAMPAIGN</span>
|
||||
<span id="slots">CO-OPERATIVE CAMPAIGNS</span>
|
||||
</div>
|
||||
<p id="server-name" class="server-name">brunner.ninja</p>
|
||||
|
||||
<div class="photo-caption">
|
||||
<span id="photo-index">01 / 12</span>
|
||||
<div>
|
||||
<small>FROM THE ARCHIVES</small>
|
||||
<strong id="photo-title">The road ahead</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="profile-column">
|
||||
<section class="profile" aria-live="polite">
|
||||
<div class="profile-head">
|
||||
<span id="profile-monogram" class="monogram">?</span>
|
||||
<div>
|
||||
<small id="profile-kicker">OPERATIVE RECORD</small>
|
||||
<h2 id="player-name">Identifying player</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="profile-loading" class="profile-loading">
|
||||
<i></i><span>Reading your co-op record…</span>
|
||||
</div>
|
||||
<div id="profile-stats" class="stats hidden">
|
||||
<div><strong id="player-score">0</strong><span>LIFETIME SCORE</span></div>
|
||||
<div><strong id="player-credits">0</strong><span>CREDITS</span></div>
|
||||
<div><strong id="player-time">0m</strong><span>PLAYED</span></div>
|
||||
<div class="rank"><strong id="player-rank">Newbie</strong><span>RANK</span></div>
|
||||
</div>
|
||||
<p id="tip" class="tip">Stay close. Co-op maps are at their best when the squad moves together.</p>
|
||||
</section>
|
||||
|
||||
<section class="leaderboard" aria-live="polite">
|
||||
<div class="leaderboard-head">
|
||||
<small>ALL-TIME OPERATIVES</small>
|
||||
<span>TOP 5 BY SCORE</span>
|
||||
</div>
|
||||
<ol id="leaderboard-list">
|
||||
<li class="leaderboard-loading">Reading campaign records…</li>
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<section class="loader" aria-live="polite">
|
||||
<div class="status-row">
|
||||
<div>
|
||||
<span id="phase-label" class="phase-label">INITIALIZING CONNECTION</span>
|
||||
<strong id="status-text">Waiting for server details…</strong>
|
||||
<span id="file-name" class="file-name">Secure channel standing by</span>
|
||||
</div>
|
||||
<div class="percent"><strong id="percent">4</strong><span>%</span></div>
|
||||
</div>
|
||||
|
||||
<div class="progress-track">
|
||||
<div id="progress-bar" class="progress-bar" style="width: 4%"><i></i></div>
|
||||
</div>
|
||||
|
||||
<div class="loader-foot">
|
||||
<ol id="stages" class="stages">
|
||||
<li class="active"><i></i><span>Handshake</span></li>
|
||||
<li><i></i><span>Workshop</span></li>
|
||||
<li><i></i><span>Server files</span></li>
|
||||
<li><i></i><span>Lua & spawn</span></li>
|
||||
</ol>
|
||||
<span id="file-count" class="file-count">Building download manifest</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small, dependency-free web service for the feedc0de co-op loading screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
DEFAULT_DATABASE = HERE.parents[2] / "sv.db"
|
||||
DEFAULT_SCREENSHOTS = HERE.parent / "backgrounds"
|
||||
STEAM_ID = re.compile(r"^7656119\d{10}$")
|
||||
SCREENSHOT = re.compile(r"^[a-z0-9_-]+\.jpg$", re.IGNORECASE)
|
||||
RANKS = (
|
||||
(0, "Newbie"),
|
||||
(10, "Survivor"),
|
||||
(50, "Hunter"),
|
||||
(150, "Slayer"),
|
||||
(500, "Veteran"),
|
||||
(1500, "Exterminator"),
|
||||
(5000, "Legend"),
|
||||
)
|
||||
|
||||
|
||||
def rank_for(score: int) -> str:
|
||||
rank = RANKS[0][1]
|
||||
for threshold, name in RANKS:
|
||||
if score < threshold:
|
||||
break
|
||||
rank = name
|
||||
return rank
|
||||
|
||||
|
||||
class LoadingScreenServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], database: Path, screenshots: Path):
|
||||
self.database = database.resolve()
|
||||
self.screenshots = screenshots.resolve()
|
||||
super().__init__(address, LoadingScreenHandler)
|
||||
|
||||
|
||||
class LoadingScreenHandler(BaseHTTPRequestHandler):
|
||||
server_version = "Feedc0deCoopLoading/1.1"
|
||||
|
||||
def log_message(self, format_string: str, *args: object) -> None:
|
||||
# Do not put joining players' Steam IDs into an access log.
|
||||
if args and str(args[1]).startswith(("4", "5")):
|
||||
super().log_message(format_string, *args)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
request = urlsplit(self.path)
|
||||
path = unquote(request.path)
|
||||
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_file(HERE / "index.html", "text/html; charset=utf-8", no_cache=True)
|
||||
return
|
||||
if path == "/assets/style.css":
|
||||
self.send_file(HERE / "style.css", "text/css; charset=utf-8")
|
||||
return
|
||||
if path == "/assets/app.js":
|
||||
self.send_file(HERE / "app.js", "application/javascript; charset=utf-8")
|
||||
return
|
||||
if path.startswith("/assets/screenshots/"):
|
||||
self.serve_screenshot(path.rsplit("/", 1)[-1])
|
||||
return
|
||||
if path == "/api/player":
|
||||
steam_id = parse_qs(request.query).get("steamid", [""])[0]
|
||||
self.serve_player(steam_id)
|
||||
return
|
||||
if path == "/api/leaderboard":
|
||||
self.serve_leaderboard()
|
||||
return
|
||||
if path == "/api/health":
|
||||
self.send_json({"ok": True, "database": self.server.database.is_file()})
|
||||
return
|
||||
self.send_error_json(404, "Not found")
|
||||
|
||||
def do_HEAD(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
self.do_GET()
|
||||
|
||||
def serve_screenshot(self, name: str) -> None:
|
||||
if not SCREENSHOT.fullmatch(name):
|
||||
self.send_error_json(404, "Not found")
|
||||
return
|
||||
path = self.server.screenshots / name
|
||||
if path.parent != self.server.screenshots or not path.is_file():
|
||||
self.send_error_json(404, "Not found")
|
||||
return
|
||||
self.send_file(path, "image/jpeg")
|
||||
|
||||
def serve_player(self, steam_id: str) -> None:
|
||||
if not STEAM_ID.fullmatch(steam_id):
|
||||
self.send_error_json(400, "Invalid SteamID64")
|
||||
return
|
||||
if not self.server.database.is_file():
|
||||
self.send_error_json(503, "Progression database unavailable")
|
||||
return
|
||||
|
||||
try:
|
||||
database_uri = f"file:{self.server.database.as_posix()}?mode=ro"
|
||||
with sqlite3.connect(database_uri, uri=True, timeout=0.5) as connection:
|
||||
connection.execute("PRAGMA query_only = ON")
|
||||
connection.execute("PRAGMA busy_timeout = 500")
|
||||
row = connection.execute(
|
||||
"SELECT player_name, lifetime_score, credits, playtime_seconds "
|
||||
"FROM custom_coop_players WHERE steamid64 = ? LIMIT 1",
|
||||
(steam_id,),
|
||||
).fetchone()
|
||||
except sqlite3.Error:
|
||||
self.send_error_json(503, "Progression record temporarily unavailable")
|
||||
return
|
||||
|
||||
if row is None:
|
||||
payload = {
|
||||
"found": False,
|
||||
"playerName": "New operative",
|
||||
"score": 0,
|
||||
"credits": 0,
|
||||
"playtimeSeconds": 0,
|
||||
"rank": "Newbie",
|
||||
}
|
||||
else:
|
||||
score = max(int(row[1] or 0), 0)
|
||||
payload = {
|
||||
"found": True,
|
||||
"playerName": str(row[0] or "Returning operative")[:64],
|
||||
"score": score,
|
||||
"credits": max(int(row[2] or 0), 0),
|
||||
"playtimeSeconds": max(int(row[3] or 0), 0),
|
||||
"rank": rank_for(score),
|
||||
}
|
||||
self.send_json(payload, no_cache=True)
|
||||
|
||||
def serve_leaderboard(self) -> None:
|
||||
if not self.server.database.is_file():
|
||||
self.send_error_json(503, "Progression database unavailable")
|
||||
return
|
||||
|
||||
try:
|
||||
database_uri = f"file:{self.server.database.as_posix()}?mode=ro"
|
||||
with sqlite3.connect(database_uri, uri=True, timeout=0.5) as connection:
|
||||
connection.execute("PRAGMA query_only = ON")
|
||||
connection.execute("PRAGMA busy_timeout = 500")
|
||||
rows = connection.execute(
|
||||
"SELECT player_name, lifetime_score, playtime_seconds "
|
||||
"FROM custom_coop_players "
|
||||
"ORDER BY lifetime_score DESC, playtime_seconds DESC, player_name COLLATE NOCASE ASC "
|
||||
"LIMIT 5"
|
||||
).fetchall()
|
||||
except sqlite3.Error:
|
||||
self.send_error_json(503, "Leaderboard temporarily unavailable")
|
||||
return
|
||||
|
||||
players = []
|
||||
for position, row in enumerate(rows, start=1):
|
||||
score = max(int(row[1] or 0), 0)
|
||||
players.append(
|
||||
{
|
||||
"position": position,
|
||||
"playerName": str(row[0] or "Unknown operative")[:64],
|
||||
"score": score,
|
||||
"playtimeSeconds": max(int(row[2] or 0), 0),
|
||||
"rank": rank_for(score),
|
||||
}
|
||||
)
|
||||
self.send_json({"players": players}, no_cache=True)
|
||||
|
||||
def send_file(self, path: Path, content_type: str, no_cache: bool = False) -> None:
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except OSError:
|
||||
self.send_error_json(404, "Not found")
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_common_headers(no_cache=no_cache)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(content)
|
||||
|
||||
def send_json(self, payload: dict[str, object], status: int = 200, no_cache: bool = False) -> None:
|
||||
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_common_headers(no_cache=no_cache)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(content)
|
||||
|
||||
def send_error_json(self, status: int, message: str) -> None:
|
||||
self.send_json({"error": message}, status=status, no_cache=True)
|
||||
|
||||
def send_common_headers(self, no_cache: bool = False) -> None:
|
||||
self.send_header("Cache-Control", "no-store" if no_cache else "public, max-age=3600")
|
||||
self.send_header("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--bind", default="0.0.0.0", help="address to listen on (default: all IPv4 interfaces)")
|
||||
parser.add_argument("--port", type=int, default=8765, help="HTTP port (default: 8765)")
|
||||
parser.add_argument("--database", type=Path, default=DEFAULT_DATABASE, help="path to garrysmod/sv.db")
|
||||
parser.add_argument("--screenshots", type=Path, default=DEFAULT_SCREENSHOTS, help="directory of slideshow JPEGs")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if not 1 <= args.port <= 65535:
|
||||
print("loading screen: port must be between 1 and 65535", file=sys.stderr)
|
||||
return 2
|
||||
server = LoadingScreenServer((args.bind, args.port), args.database, args.screenshots)
|
||||
print(f"loading screen: serving http://{args.bind}:{args.port}", flush=True)
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.5)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,663 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #070b0d;
|
||||
color: #f3f7f5;
|
||||
font-family: "Segoe UI", "Trebuchet MS", Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 640px;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.backdrop,
|
||||
.slide,
|
||||
.shade,
|
||||
.grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
background: #0a1113;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide {
|
||||
opacity: 0;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
transform: scale(1.035);
|
||||
transition: opacity 1.6s ease, transform 9s linear;
|
||||
}
|
||||
|
||||
.slide-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1.085);
|
||||
}
|
||||
|
||||
.shade {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(4, 8, 10, .94) 0%, rgba(5, 9, 11, .72) 35%, rgba(5, 8, 10, .3) 67%, rgba(4, 7, 9, .6) 100%),
|
||||
linear-gradient(0deg, rgba(4, 8, 9, .94) 0%, rgba(4, 8, 9, .08) 56%, rgba(4, 8, 9, .68) 100%);
|
||||
}
|
||||
|
||||
.grain {
|
||||
opacity: .11;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 4.8vh 5.2vw;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .14);
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
letter-spacing: .18em;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-right: 13px;
|
||||
border: 2px solid #9bfbd4;
|
||||
transform: rotate(45deg);
|
||||
box-shadow: 0 0 24px rgba(94, 255, 192, .25);
|
||||
}
|
||||
|
||||
.brand-mark i {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: #9bfbd4;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
.brand span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
color: #9bfbd4;
|
||||
font-size: 9px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(238, 246, 243, .72);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .15em;
|
||||
}
|
||||
|
||||
.live i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 9px;
|
||||
border-radius: 50%;
|
||||
background: #8ff7ce;
|
||||
box-shadow: 0 0 0 4px rgba(143, 247, 206, .12), 0 0 16px #8ff7ce;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: absolute;
|
||||
top: 17vh;
|
||||
left: 5.2vw;
|
||||
right: 5.2vw;
|
||||
bottom: 29vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mission {
|
||||
width: 58%;
|
||||
min-width: 420px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.phase-label,
|
||||
.profile small,
|
||||
.photo-caption small {
|
||||
color: #9bfbd4;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .22em;
|
||||
}
|
||||
|
||||
.eyebrow:before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 34px;
|
||||
height: 1px;
|
||||
margin: 0 11px 3px 0;
|
||||
background: #9bfbd4;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 900px;
|
||||
margin: 14px 0 12px;
|
||||
font-size: clamp(42px, 6.6vw, 106px);
|
||||
line-height: .92;
|
||||
letter-spacing: -.055em;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 3px 38px rgba(0, 0, 0, .5);
|
||||
}
|
||||
|
||||
.mission-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(240, 247, 245, .72);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .16em;
|
||||
}
|
||||
|
||||
.mission-meta span + span:before {
|
||||
content: "/";
|
||||
color: #9bfbd4;
|
||||
margin: 0 11px;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
margin: 11px 0 0;
|
||||
color: rgba(239, 246, 244, .45);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 7.5vh;
|
||||
}
|
||||
|
||||
.photo-caption > span {
|
||||
width: 54px;
|
||||
margin-right: 12px;
|
||||
color: rgba(255, 255, 255, .55);
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.photo-caption div {
|
||||
padding-left: 13px;
|
||||
border-left: 1px solid rgba(255, 255, 255, .24);
|
||||
}
|
||||
|
||||
.photo-caption small,
|
||||
.photo-caption strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-caption small {
|
||||
color: rgba(255, 255, 255, .44);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.photo-caption strong {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-column {
|
||||
width: 350px;
|
||||
flex: 0 0 350px;
|
||||
}
|
||||
|
||||
.profile {
|
||||
width: 100%;
|
||||
min-height: 272px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, .14);
|
||||
background: rgba(7, 13, 15, .78);
|
||||
box-shadow: 0 22px 70px rgba(0, 0, 0, .25);
|
||||
}
|
||||
|
||||
.profile-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
.monogram {
|
||||
display: inline-flex;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-right: 14px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(155, 251, 212, .6);
|
||||
background: rgba(155, 251, 212, .08);
|
||||
color: #9bfbd4;
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.profile h2 {
|
||||
overflow: hidden;
|
||||
max-width: 235px;
|
||||
margin: 5px 0 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-loading {
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(238, 246, 243, .58);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.profile-loading i {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
margin-right: 10px;
|
||||
border: 2px solid rgba(155, 251, 212, .18);
|
||||
border-top-color: #9bfbd4;
|
||||
border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 16px 0 5px;
|
||||
}
|
||||
|
||||
.stats div {
|
||||
width: 33.333%;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.stats div.rank {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, .08);
|
||||
}
|
||||
|
||||
.stats strong,
|
||||
.stats span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stats strong {
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stats span {
|
||||
margin-top: 4px;
|
||||
color: rgba(238, 246, 243, .42);
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
}
|
||||
|
||||
.stats .rank strong {
|
||||
color: #9bfbd4;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin: 15px 0 0;
|
||||
color: rgba(238, 246, 243, .5);
|
||||
font-size: 10px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.tip:before {
|
||||
content: "TIP ";
|
||||
color: #9bfbd4;
|
||||
font-weight: 800;
|
||||
letter-spacing: .12em;
|
||||
}
|
||||
|
||||
.leaderboard {
|
||||
margin-top: 11px;
|
||||
padding: 15px 17px 13px;
|
||||
border: 1px solid rgba(255, 255, 255, .14);
|
||||
background: rgba(7, 13, 15, .82);
|
||||
box-shadow: 0 18px 55px rgba(0, 0, 0, .22);
|
||||
}
|
||||
|
||||
.leaderboard-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.leaderboard-head small {
|
||||
color: #9bfbd4;
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .18em;
|
||||
}
|
||||
|
||||
.leaderboard-head span {
|
||||
color: rgba(238, 246, 243, .36);
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 7px;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
|
||||
.leaderboard ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.leaderboard li {
|
||||
min-height: 27px;
|
||||
display: grid;
|
||||
grid-template-columns: 27px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-top: 1px solid rgba(255, 255, 255, .065);
|
||||
}
|
||||
|
||||
.leaderboard li:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.leaderboard-position,
|
||||
.leaderboard-score {
|
||||
color: #9bfbd4;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.leaderboard li div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leaderboard li strong,
|
||||
.leaderboard li small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leaderboard li strong {
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.leaderboard li small {
|
||||
margin-top: 1px;
|
||||
color: rgba(238, 246, 243, .38);
|
||||
font-size: 7px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.leaderboard-score {
|
||||
color: rgba(243, 247, 245, .88);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.leaderboard-loading {
|
||||
display: block !important;
|
||||
min-height: 30px !important;
|
||||
padding-top: 8px;
|
||||
color: rgba(238, 246, 243, .45);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loader {
|
||||
position: absolute;
|
||||
left: 5.2vw;
|
||||
right: 5.2vw;
|
||||
bottom: 4.8vh;
|
||||
padding-top: 19px;
|
||||
border-top: 1px solid rgba(255, 255, 255, .16);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.status-row > div:first-child {
|
||||
min-width: 0;
|
||||
padding-right: 35px;
|
||||
}
|
||||
|
||||
.phase-label,
|
||||
.status-row strong,
|
||||
.file-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.phase-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.status-row strong {
|
||||
overflow: hidden;
|
||||
max-width: 72vw;
|
||||
font-size: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
overflow: hidden;
|
||||
max-width: 70vw;
|
||||
margin-top: 5px;
|
||||
color: rgba(238, 246, 243, .42);
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.percent {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
color: #9bfbd4;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
.percent strong {
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.percent span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 4px;
|
||||
margin-top: 15px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-width: 4px;
|
||||
background: linear-gradient(90deg, #51cda0, #b5ffe3);
|
||||
box-shadow: 0 0 18px rgba(81, 205, 160, .5);
|
||||
transition: width .65s ease;
|
||||
}
|
||||
|
||||
.progress-bar i {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -120px;
|
||||
width: 120px;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, rgba(155, 251, 212, 0), rgba(255, 255, 255, .9));
|
||||
animation: scan 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loader-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.stages {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.stages li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 25px;
|
||||
color: rgba(238, 246, 243, .32);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stages i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-right: 7px;
|
||||
border: 1px solid rgba(255, 255, 255, .25);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.stages li.active {
|
||||
color: #f3f7f5;
|
||||
}
|
||||
|
||||
.stages li.active i {
|
||||
border-color: #9bfbd4;
|
||||
background: #9bfbd4;
|
||||
box-shadow: 0 0 10px rgba(155, 251, 212, .75);
|
||||
}
|
||||
|
||||
.stages li.done {
|
||||
color: rgba(155, 251, 212, .55);
|
||||
}
|
||||
|
||||
.stages li.done i {
|
||||
border-color: #63d6aa;
|
||||
background: rgba(99, 214, 170, .42);
|
||||
}
|
||||
|
||||
.file-count {
|
||||
color: rgba(238, 246, 243, .42);
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 9px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: .55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { right: 100%; opacity: 0; }
|
||||
30% { opacity: 1; }
|
||||
100% { right: -120px; opacity: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 920px), (max-height: 650px) {
|
||||
.shell { padding: 24px 34px; }
|
||||
.hero { top: 100px; left: 34px; right: 34px; bottom: 190px; }
|
||||
.loader { left: 34px; right: 34px; bottom: 25px; }
|
||||
.profile-column { width: 310px; flex-basis: 310px; }
|
||||
.profile { min-height: 230px; padding: 18px; }
|
||||
.profile h2 { max-width: 195px; }
|
||||
.photo-caption { margin-top: 30px; }
|
||||
.tip { display: none; }
|
||||
h1 { font-size: 50px; }
|
||||
}
|
||||
|
||||
@media (max-height: 850px) {
|
||||
.profile { min-height: 215px; padding: 15px 18px; }
|
||||
.profile-head { padding-bottom: 10px; }
|
||||
.stats { padding: 9px 0 2px; }
|
||||
.stats div { margin-bottom: 7px; }
|
||||
.stats div.rank { padding-top: 6px; }
|
||||
.tip { display: none; }
|
||||
.leaderboard { padding-top: 10px; padding-bottom: 8px; }
|
||||
.leaderboard li { min-height: 24px; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.profile-column { display: none; }
|
||||
.mission { width: 100%; }
|
||||
.stages li { margin-right: 13px; }
|
||||
.file-count { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.slide,
|
||||
.progress-bar,
|
||||
.progress-bar i,
|
||||
.live i,
|
||||
.profile-loading i {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user