Gebaut, im Test
Brauen, Reifung und Alkoholisierung
Das Designstück: ein Brau- und Reifesystem mit von Designern abstimmbaren Wahrscheinlichkeiten sowie ein Blutalkohol-Modell, das Rauscheffekte und Polizeitests antreibt.
- Rolle
- Entwurf und Umsetzung
- Stack
- Lua (GLua)
- Kontext
- Crafting und Wirtschaft auf dem Roleplay-Server von Diverge Networks
- Status
- Gebaut, wartet in der Testwarteschlange des Servers
Das Problem
Der Server wollte, dass Alkohol etwas ist, das Spieler herstellen und nicht nur kaufen: ein Fass, in dem die eigenen Zutaten bestimmen, was man bekommen kann, eine Reifung, die Geduld belohnt, und Trinken mit Konsequenzen, auf die die Polizei testen kann. Die Rezepte mussten sich von den Designern des Servers abstimmen lassen, ohne Code anzufassen.
Brauen
Eine Basiszutat legt die möglichen Ergebnisse fest. Aus Trauben kann zum Beispiel Rotwein, Weißwein oder Essig werden, das schlechte Ergebnis. Jede weitere Zutat gibt den Ergebnissen, die sie mit der Basis teilt, mehr Gewicht, und eine Zutat, die nichts mit ihr gemeinsam hat, drängt den Sud zum schlechten Ergebnis. Beim Versiegeln des Fasses wird ein gewichtetes Ergebnis ausgewürfelt, das nach einer festgelegten Zeit fertig ist.
Spieler sehen die möglichen Ergebnisse und ihre Wahrscheinlichkeiten, bevor sie versiegeln. Designer bearbeiten Rezepte im Spiel über ein rangbeschränktes Konfigurationspanel, das jeden Wert validiert.
Video folgt in Kürze
Das Fass aufstellen, eine Basiszutat hinzufügen, damit die möglichen Ergebnisse erscheinen, Zutaten hinzufügen, während sich die Wahrscheinlichkeiten aktualisieren, dann versiegeln und das Ergebnis abholen.
assets/projects/brewing/brewing-seal.mp4 (30 bis 45 Sekunden)
Screenshot folgt in Kürze
Die Brau-Oberfläche mit eingelegten Zutaten und sichtbaren Wahrscheinlichkeiten.
assets/projects/brewing/brewing-ui.png (enger Zuschnitt auf das Panel)
Reifung
Spieler wählen beim Versiegeln des Fasses, wie lange ein Getränk reifen soll, von einer Minute bis zu anderthalb Stunden. Ich wollte in dieser Entscheidung ein bisschen Glücksspiel und ein bisschen Meisterschaft: Eine kürzere Reifung kann trotzdem ein besseres Ergebnis bringen, und wer experimentiert, lernt die optimalen Zeitpunkte kennen.
Jedes mögliche Reifeergebnis ist eine Glockenkurve über die Zeit: wann sie ihren Höhepunkt hat, wie stark sie wird und wie schnell sie abfällt. Zu jedem Zeitpunkt ergeben die Kurven die Wahrscheinlichkeit jedes Ergebnisses, und das Konfigurationspanel stellt sie grafisch dar, sodass ein Designer genau sehen kann, was eine Reifezeit von 30 Minuten ergibt. Lässt man ein Getränk zu lange stehen, übernimmt die Essig-Kurve.
Video folgt in Kürze
Das Konfigurationspanel für die Reifung mit seinem Graphen, dann ein Reifezyklus.
assets/projects/brewing/aging.mp4 (etwa 15 Sekunden)
Screenshot folgt in Kürze
Das Konfigurationspanel für die Reifung, mit dem Mauszeiger über dem Graphen.
assets/projects/brewing/aging-config.png (enger Zuschnitt auf das Panel)
-- Each possible result of aging is a bell curve over time, tuned by a designer:
-- time = when it peaks (minutes), peak = how strong it gets, sigma = how fast it falls off.
-- (Example values, not the live tuning.)
-- ["young_wine"] = { time = 20, peak = 0.75, sigma = 5 },
-- ["vinegar"] = { time = 90, peak = 0.85, sigma = 8 },
function AgingBarrel.ComputeProbability(o, t)
local sigma = o.sigma or 6
local peak = o.peak or 1
local d = t - o.time
return peak * math.exp(-(d * d) / (2 * sigma * sigma))
end
-- The score of every result at time t, keyed by result.
function AgingBarrel.ComputeProbabilities(outcomes, t)
local scores = {}
for name, o in pairs(outcomes) do
scores[name] = AgingBarrel.ComputeProbability(o, t)
end
return scores
end
-- Weighted roll over the scores. They don't need to sum to 1.
function AgingBarrel.Roll(probs)
local total = 0
for _, p in pairs(probs) do
total = total + p
end
local roll = math.Rand(0, total)
local cumulative = 0
local bestName, bestP = nil, -1
for name, p in pairs(probs) do
cumulative = cumulative + p
if p > bestP then bestName, bestP = name, p end
if roll <= cumulative then
return name
end
end
-- Floating-point safety net: fall back to the most likely result.
return bestName
end
function AgingBarrel.ResolveOutcome(config, t)
return AgingBarrel.Roll(AgingBarrel.ComputeProbabilities(config, t))
endTrinken und Testen
Jeder Charakter hat einen Blutalkoholspiegel von 0 bis 100. Jedes Getränk erhöht ihn um das 2,5-Fache der Wurzel seines Alkoholgehalts (Vol.-%), sodass starke Getränke stärker wirken, ohne linear zu skalieren: Ein Bier fügt etwa 6 bis 8 hinzu, Wein 9 bis 10 und ein Cocktail mit 40 % 16. Die Rauscheffekte skalieren mit diesem Spiegel.
Alkohol baut sich nach 15 Minuten aus dem Körper ab, andere Substanzen nach 30. Alkomaten messen die tatsächliche Trunkenheit, und ein neues Blutanalysegerät übernimmt Drogentests.
Video folgt in Kürze
Ein paar Getränke, während sich die Effekte aufbauen, dann ein Test mit Alkomat oder Blutanalysegerät und sein Ergebnis.
assets/projects/brewing/intoxication.mp4 (15 bis 20 Sekunden)
Screenshot folgt in Kürze
Ein Ergebnis des Blutanalysegeräts oder Alkomaten.
assets/projects/brewing/analyzer-result.png (enger Zuschnitt)
Serverautoritativ bleiben
Wenn ein Spieler ein Fass versiegelt, sendet der Client nur die ID des Fasses. Der Server prüft, ob es wirklich ein Brau-Fass ist, ob dieser Spieler es gerade benutzt und ob jede Zutat gültig ist und in einem gültigen Slot liegt, und berechnet das Ergebnis dann selbst. Der Timer, der auszahlt, prüft vor jeder Lieferung erneut, ob das Fass und sein Zustand noch gültig sind.
-- The client sends only the cask's item ID. Everything else is checked
-- and decided here, on the server.
net.Receive("nutBrewingCaskSeal", function(_, ply)
local cask = nut.item.instances[net.ReadUInt(32)]
if (not cask or cask.uniqueID ~= "brewing_cask") then return reject(ply, "That isn't a brewing cask.") end
local inv = cask:getInv()
if (not inv) then return reject(ply, "That cask has no inventory.") end
-- Only someone who currently has this cask open can seal it.
if (not inv.currentUsers[ply]) then return reject(ply, "You aren't using this cask.") end
-- Every ingredient must be valid and sit in one of the four slots.
local items = {}
for _, item in pairs(inv:getItems(true)) do
local x, y = item:getData("x"), item:getData("y")
if (not x or y ~= 1 or x < 1 or x > 4) then return reject(ply, "An ingredient is in an invalid slot.") end
if (not PLUGIN:IsValidBrewingIngredient(item)) then return reject(ply, "That isn't a valid ingredient.") end
items[x] = item
end
local base = table.remove(items, 1) -- slot 1 is the base
local additives = table.ClearKeys(items)
if (not base) then return reject(ply, "You need a base ingredient to start brewing.") end
-- The base defines the possible results. Additives add weight to results
-- they share with it; an additive that shares nothing pushes toward the bad result.
local results = table.Copy(PLUGIN.Data.Recipes[base.uniqueID])
local bad = results._bad
results._bad = nil
for _, item in pairs(additives) do
local shared = false
for result, weight in pairs(PLUGIN.Data.Recipes[item.uniqueID]) do
if (results[result]) then
results[result] = results[result] + weight
shared = true
end
end
if (not shared) then
results[bad] = (results[bad] or 0) + 1
end
end
local outcome = weightedRoll(results) -- same cumulative roll as the aging module
if (not outcome) then return reject(ply, "Couldn't pick a result. Please report this.") end
-- Clearing the users list closes the cask for everyone, so a second
-- seal request fails the check above. Then the ingredients are consumed.
inv.currentUsers = {}
for _, item in pairs(inv:getItems(true)) do
item:remove()
end
cask:setData("producing", true)
local ent = cask.entity
timer.Create("Brewing_" .. cask:getID(), PLUGIN.BrewingTime, 1, function()
-- Re-check everything before paying out: the cask may have been
-- picked up, deleted or already collected while we waited.
if (not IsValid(ent) or cask.entity ~= ent or not nut.item.instances[cask:getID()]) then return end
if (not cask:getData("producing") or cask:getData("collect")) then return end
cask:setData("producing", nil)
cask:setData("collect", outcome)
end)
-- ... sound, chat emote and closing the player's window omitted
end)