local RailroadSurroundLight = require 'RailroadSurroundLight' local RailroadUtils = require 'RailroadUtils' local movable = render.PositioningHelper() local lastIndex = os.time() local function nextIndex() lastIndex = lastIndex + 1 return lastIndex end ---@param prefix string ---@param items table local function findNextName(prefix, items) local nextNumber = 1 for i = 1, #items do if items[i] and items[i].name then local n = tonumber(items[i].name:match(prefix..' (%d+)')) if n then nextNumber = math.max(nextNumber, n + 1) end end end return prefix..' '..nextNumber end local dbgOutcomes = {} local dbgDraw3D ---@generic T ---@param obj {index: integer} ---@param fn fun(): T ---@return T function RailroadUtils.tryCreate(obj, fn) local ret = try(fn, function (err) dbgOutcomes[obj.index] = err end) if ret then dbgOutcomes[obj.index] = ret.draw3D and function() ret:draw3D() end or true end return ret end function RailroadUtils.reportError(obj, err) if type(err) == 'string' then ac.error(err) end dbgOutcomes[obj.index] = err or true end local function drawDebugOutcome(key) local dbg = dbgOutcomes[key] if dbg then if type(dbg) == 'string' then local c = ui.getCursor() ui.offsetCursor(vec2(8, 8)) ui.textWrapped('Error: '..dbg) ui.offsetCursorY(4) ui.drawRect(c, vec2(c.x + ui.availableSpaceX(), ui.getCursorY()), rgbm.colors.red, 3) ui.drawRectFilled(c, vec2(c.x + ui.availableSpaceX(), ui.getCursorY()), rgbm(1, 0, 0, 0.1), 3) else local c = ui.getCursor() ui.offsetCursor(vec2(8, 8)) ui.text(type(dbg) == 'function' and 'Created successfully:' or 'Created successfully') if type(dbg) == 'function' then ui.sameLine(0, 6) ui.pushStyleVar(ui.StyleVar.FramePadding, vec2(0, 0)) if ui.checkbox('##debug', dbg == dbgDraw3D) then dbgDraw3D = dbg ~= dbgDraw3D and dbg end ui.popStyleVar() if ui.itemHovered() then ui.setTooltip('Click to see debug outline') end end ui.offsetCursorY(4) ui.drawRect(c, vec2(c.x + ui.availableSpaceX(), ui.getCursorY()), rgbm.colors.green, 3) ui.drawRectFilled(c, vec2(c.x + ui.availableSpaceX(), ui.getCursorY()), rgbm(0, 1, 0, 0.1), 3) end end end ---@alias TrainCartDescription {model: string, tint: rgb, probability: number, surroundLight: string} ---@param s RailroadEditor ---@return TrainCartDescription local function newTrainCartDescription(s) return { model = '', tint = rgb(1, 1, 1), probability = 1 } end ---@alias TrainDescription {index: integer, name: string, mainModel: string, doubleFacedDoors: boolean, head: TrainCartDescription[], carts: TrainCartDescription[], sizeMin: integer, sizeMax: integer, smokeIntensity: number} ---@param s RailroadEditor ---@return TrainDescription local function newTrainDescription(s) return { index = nextIndex(), name = findNextName('Train', s.data.trains), head = {}, carts = {}, sizeMin = 8, sizeMax = 12, headlights = rgb(40, 40, 40), smokeIntensity = 0, } end ---@alias SchedulePointDescription {station: integer, time: integer, duration: number} ---@param s RailroadEditor ---@return SchedulePointDescription local function newSchedulePointDescription(s) return { station = 0, time = 12 * 60 * 60, duration = 10 * 60 } end ---@alias ScheduleDescription {index: integer, name: string, train: integer, points: SchedulePointDescription[], looped: boolean, variation: number} ---@param s RailroadEditor ---@return ScheduleDescription local function newScheduleDescription(s) return { index = nextIndex(), name = findNextName('Shedule', s.data.schedules), train = -1, points = {}, looped = false, variation = 5 * 60 } end ---@alias StationDescription {index: integer, name: string, position: vec3} ---@param s RailroadEditor ---@param position vec3 ---@param name string? ---@return StationDescription local function newStationDescription(s, position, name) return { index = nextIndex(), name = name or findNextName('Station', s.data.stations), position = position } end ---@alias GateModelDescription {index: integer, model: string, position: vec3, direction: vec3, up: vec3, physics: boolean} ---@param s RailroadEditor ---@return GateDescription local function newGateModelDescription(s) return { index = nextIndex(), model = '', position = vec3(0, 0, 0), direction = vec3(0, 0, 1), up = vec3(0, 1, 0), physics = true, } end ---@alias GateDescription {index: integer, name: string, position: vec3, colliding: boolean, models: GateModelDescription[]} ---@param s RailroadEditor ---@param position vec3 ---@return GateDescription local function newGateDescription(s, position) return { index = nextIndex(), name = findNextName('Gate', s.data.gates), position = position, colliding = true, models = {} } end ---@alias LineDescription {index: integer, name: string, priority: number, speedMultiplier: number, colliding: boolean, lights: boolean, points: vec3[]} ---@param s RailroadEditor ---@param points vec3[]? ---@param name string? ---@return LineDescription local function newLineDescription(s, points, name) return { index = nextIndex(), name = name or findNextName('Line', table.chain(s.data.lines, s.editedLine and #s.editedLine.points > 0 and { s.editedLine } or {})), priority = 1, speedMultiplier = 1, points = points or {} } end ---@param line LineDescription local function drawLine(line, color) for i = 1, #line.points do if i > 1 then render.debugLine(line.points[i - 1], line.points[i], color) end render.debugCross(line.points[i], 4, color) end if #line.points > 1 then render.debugText((line.points[1] + line.points[2]) / 2, line.name) end end ---@param line LineDescription ---@param ray ray local function findPointNearby(line, ray) return table.findFirst(line.points, function (v, _, ray_) return ray_:sphere(v, 4) ~= -1 end, ray) end ---@param s RailroadEditor local function saveChanges(s) s.onSave(stringify({ trains = s.data.trains, schedules = s.data.schedules, stations = s.data.stations, gates = s.data.gates, lines = table.chain(s.data.lines, #s.editedLine.points > 1 and {s.editedLine} or {}), settings = s.data.settings, }, true)) if s.currentlyMoving ~= nil then s.dirty = true elseif s.onReload ~= nil then s.onReload() end end ---@param s RailroadEditor ---@param list table ---@param index integer ---@param name string local function cancellableRemove(s, list, index, name) if index then local removed = table.remove(list, index) saveChanges(s) ui.toast(ui.Icons.Delete, name..' removed', function () table.insert(list, index, removed) saveChanges(s) end) end end ---@param self RailroadEditor local function lineEditor(self) for j = 1, #self.data.lines do drawLine(self.data.lines[j], rgbm.colors.blue) end drawLine(self.editedLine, rgbm.colors.cyan) for j = 1, #self.data.stations do render.debugSphere(self.data.stations[j].position, 2, rgbm.colors.yellow) render.debugText(self.data.stations[j].position, self.data.stations[j].name) end for j = 1, #self.data.gates do render.debugSphere(self.data.gates[j].position, 2, rgbm.colors.green) render.debugText(self.data.gates[j].position, self.data.gates[j].name) end -- Inactive if mouse is taken by UI if ac.getUI().wantCaptureMouse then return end -- Removing last (or first, if selected) element if Delete button is pressed if ui.keyPressed(ui.Key.Delete) and #self.editedLine.points > 0 then local firstSelected = self.currentlyMoving == self.editedLine.points[1] table.remove(self.editedLine.points, firstSelected and 1 or #self.editedLine.points) self.currentlyMoving = self.editedLine.points[firstSelected and 1 or #self.editedLine.points] saveChanges(self) end -- Raycasting mouse, checking where it hits track local ray = render.createMouseRay() local distance = ray:track() if distance < 0 then return end local hit = ray.pos + ray.dir * distance if self.settingAxisTo then render.debugCross(self.settingPointTo, 0.5, rgbm.colors.blue) local axisPos = self.settingPointTo + self.settingAxisTo if movable:render(axisPos) then self.settingAxisTo:set((axisPos - self.settingPointTo):normalize()) saveChanges(self) end return end if self.settingPointTo then render.debugCross(self.settingPointTo, 0.5, rgbm.colors.blue) render.debugCross(hit, 1) if ui.mouseReleased() then self.settingPointTo:set(hit) saveChanges(self) end return end -- If Ctrl button is pressed, adding new points, otherwise editing existing points if ui.keyboardButtonDown(ui.KeyIndex.Control) then if ui.keyboardButtonDown(ui.KeyIndex.Menu) then render.debugCross(hit, 4, rgbm(3, 3, 0, 1)) if ui.mouseClicked() then self.currentlyMoving = nil for i = 1, #self.data.stations do if ray:sphere(self.data.stations[i].position, 2) ~= -1 then self.currentlyMoving = self.data.stations[i].position end end if not self.currentlyMoving then table.insert(self.data.stations, newStationDescription(self, hit)) end saveChanges(self) end elseif ui.keyboardButtonDown(ui.KeyIndex.Shift) then render.debugCross(hit, 4, rgbm(0, 3, 0, 1)) if ui.mouseClicked() then self.currentlyMoving = nil for i = 1, #self.data.gates do if ray:sphere(self.data.gates[i].position, 2) ~= -1 then self.currentlyMoving = self.data.gates[i].position end end if not self.currentlyMoving then table.insert(self.data.gates, newGateDescription(self, hit)) end saveChanges(self) end else local addInFront = #self.editedLine.points > 1 and self.currentlyMoving == self.editedLine.points[1] if #self.editedLine.points > 0 then render.debugLine(addInFront and self.currentlyMoving or self.editedLine.points[#self.editedLine.points], hit) end render.debugCross(hit, 4) if ui.mouseClicked() then self.currentlyMoving = hit table.insert(self.editedLine.points, addInFront and 1 or #self.editedLine.points + 1, hit) saveChanges(self) end end else if ui.mouseReleased() then self.currentlyMoving = findPointNearby(self.editedLine, ray) -- If there is no point nearby in currently edited line, remove selection of it and checking other lines if self.currentlyMoving == nil then if #self.editedLine.points > 1 then table.insert(self.data.lines, self.editedLine) self.editedLine = newLineDescription(self) end for i = 1, #self.data.lines do self.currentlyMoving = findPointNearby(self.data.lines[i], ray) if self.currentlyMoving ~= nil then self.editedLine = self.data.lines[i] table.removeItem(self.data.lines, self.editedLine) break end end end if self.currentlyMoving == nil and self.dirty then self.dirty = false if self.onReload ~= nil then self.onReload() end end elseif self.currentlyMoving and movable:render(self.currentlyMoving, false) then self.currentlyMoving:set(hit) saveChanges(self) end end end ---@alias RailroadData {lines: LineDescription[], stations: StationDescription[], gates: GateDescription[], schedules: ScheduleDescription[], trains: TrainDescription[], settings: table} ---@class RailroadEditor ---@field data RailroadData ---@field editedLine LineDescription ---@field currentlyMoving vec3? ---@field dirty boolean ---@field settingPointTo vec3? ---@field settingAxisTo vec3? ---@field onSave nil|fun(data: string) ---@field onReload nil|fun() local RailroadEditor = class 'RailroadEditor' ---@param data string ---@param onSave nil|fun(data: string) ---@return RailroadEditor function RailroadEditor.allocate(data, onSave) return { data = table.chain({ trains = {}, schedules = {}, stations = {}, gates = {}, lines = {}, settings = {} }, stringify.tryParse(data, nil, {})), editedLine = nil, currentlyMoving = nil, dirty = false, onSave = onSave } end function RailroadEditor:initialize() self.editedLine = newLineDescription(self) end ---@param self RailroadEditor ---@param filename string local function loadFromOBJ(self, filename) local vertices = {} local nextName = nil local lines = {} local stations = {} for _, line in ipairs(io.load(filename, ''):split('\n', nil, true, true)) do local b = line:byte(1) if b == 118 then -- v local x, y, z = line:numbers(3) vertices[#vertices + 1] = vec3(x, z, -y) elseif b == 111 then -- o nextName = line:sub(3):trim() elseif b == 108 then -- l local points = table.map({ line:numbers() }, function (i, _, data) return data[i] end, vertices) if #points == 2 and points[1]:closerToThan(points[2], 4) then stations[#stations + 1] = newStationDescription(self, points[1], nextName or error('No next name found')) elseif #points > 1 then lines[#lines + 1] = newLineDescription(self, points, nextName or error('No next name found')) end nextName = nil end end self.data.lines = lines self.data.stations = stations saveChanges(self) end ---@param dt number local function drawUILines(self, dt) if ui.button('Replace lines and stations with OBJ file', vec2(ui.availableSpaceX(), 0)) then os.openFileDialog({ title = 'Open', defaultFolder = ac.getFolder(ac.FolderID.ContentTracks)..'/'..ac.getTrackID(), fileTypes = { { name = 'OBJ Models', mask = '*.obj' } }, }, function (err, filename) if not err and filename then loadFromOBJ(self, filename) end end) end local lines = table.chain(self.data.lines, #self.editedLine.points > 1 and { self.editedLine } or {}) table.sort(lines, function (a, b) return a.index < b.index end) local toRemove for i = 1, #lines do ui.offsetCursorY(12) local line = lines[i] ui.pushID(line.index) ui.text('Line #'..i) ui.beginSubgroup() line.name = ui.inputText('##name', line.name) line.priority = ui.slider('##priority', (line.priority or 1) * 100, 0, 300, 'Priority: %.0f%%') / 100 if ui.itemHovered() then ui.setTooltip('Reduce to increase changes of lane to be used (acts as distance multiplier when finding a shortest route)') end line.speedMultiplier = ui.slider('##speedMultiplier', (line.speedMultiplier or 1) * 100, 0, 300, 'Speed multiplier: %.0f%%') / 100 if ui.itemHovered() then ui.setTooltip('Speed multiplier (not an actual limit: actual speed is controlled by schedule, making sure trains arrive on time)') end if ui.checkbox('Force colliding trains', line.colliding) then line.colliding = not line.colliding saveChanges(self) end if ui.itemHovered() then ui.setTooltip('If active, train on this lane would always get colliders (use it if lane can be reached by a car; if it’s an intersection, gates can enable colliders only when trains are near them)') end if ui.checkbox('Force lights', line.lights) then line.lights = not line.lights saveChanges(self) end if ui.itemHovered() then ui.setTooltip('If active, train on this lane will have its lights active') end if table.contains(self.data.lines, line) and ui.button('Remove lane') then toRemove = i end ui.endSubgroup() if ui.itemEdited() then saveChanges(self) end ui.popID() end cancellableRemove(self, self.data.lines, toRemove, 'Lane') ui.offsetCursorY(12) ui.textWrapped('To start creating new line, hold Ctrl and click on a track.') end local knownSurroundLightList = table.map(RailroadSurroundLight.known(), function (item, key) return { key = key, name = item.name } end) table.sort(knownSurroundLightList, function (a, b) return a.name < b.name end) ---@param self RailroadEditor ---@param list TrainCartDescription[] local function cartsEditor(self, list) if #list == 0 then ui.text('') else local toRemove for i = 1, #list do ui.pushID(i) ui.setNextItemWidth(ui.availableSpaceX() - 84) list[i].model = ui.inputText('KN5 LOD A', list[i].model, ui.InputTextFlags.Placeholder) if ui.itemHovered() then ui.setTooltip('Save LOD B next to LOD A with “_b” postfix') end ui.sameLine(0, 4) if ui.button('Remove', vec2(80, 0)) then toRemove = i end ui.setNextItemWidth(ui.availableSpaceX() - 24) list[i].probability = ui.slider('##probability', list[i].probability * 100, 0, 100, 'Probability: %.0f%%') / 100 ui.sameLine(0, 4) ui.colorButton('##color', list[i].tint, ui.ColorPickerFlags.PickerHueBar) ui.alignTextToFramePadding() ui.text('Surround light:') ui.sameLine(100) ui.setNextItemWidth(ui.availableSpaceX()) local selected = RailroadSurroundLight.known()[list[i].surroundLight] ui.combo('##surround', selected and selected.name or '', function () if ui.selectable('None', not selected) then list[i].surroundLight = nil saveChanges(self) end for _, v in ipairs(knownSurroundLightList) do if ui.selectable(v.name, v.key == list[i].surroundLight) then list[i].surroundLight = v.key saveChanges(self) end end end) ui.popID() end if toRemove then cancellableRemove(self, list, toRemove, 'Item') end end if ui.button('New item') then table.insert(list, newTrainCartDescription(self)) saveChanges(self) end end ---@param self RailroadEditor ---@param dt number local function drawUIStations(self, dt) local toRemove for i = 1, #self.data.stations do ui.offsetCursorY(12) local station = self.data.stations[i] ui.pushID(i) ui.text('Station #'..i) ui.beginSubgroup() station.name = ui.inputText('Name', station.name, ui.InputTextFlags.Placeholder) if ui.button('Remove station') then toRemove = i end ui.endSubgroup() if ui.itemEdited() then saveChanges(self) end ui.popID() end cancellableRemove(self, self.data.stations, toRemove, 'Station') ui.offsetCursorY(12) ui.textWrapped('To create a station, hold Ctrl+Alt and click at the place where a train head should get parked. To move created station, click on it while holding Ctrl+Alt and then move it.') end ---@param self RailroadEditor ---@param list GateModelDescription[] ---@param gate GateDescription local function gateMeshesEditor(self, list, gate) if #list == 0 then ui.text('') else local toRemove for i = 1, #list do ui.pushID(i) ui.setNextItemWidth(ui.availableSpaceX() - 84) list[i].model = ui.inputText('Model', list[i].model, ui.InputTextFlags.Placeholder) ui.sameLine(0, 4) if ui.button('Remove', vec2(80, 0)) then toRemove = i end if ui.checkbox('With physics objects', list[i].physics) then list[i].physics = not list[i].physics saveChanges(self) end if ui.itemHovered() then ui.setTooltip('If active, objects with certain names will use physics (disable for distant gates not reachable by cars but still using the same model)') end ui.popID() end if toRemove then cancellableRemove(self, list, toRemove, 'Gate mesh') end end if ui.button('New gate mesh') then table.insert(list, newGateModelDescription(self)) list[#list].position = gate.position saveChanges(self) end end ---@param self RailroadEditor ---@param dt number local function drawUIGates(self, dt) local toRemove for i = 1, #self.data.gates do ui.offsetCursorY(12) local gate = self.data.gates[i] ui.pushID(i) ui.text('Gate #'..i) ui.beginSubgroup() gate.name = ui.inputText('Name', gate.name, ui.InputTextFlags.Placeholder) if ui.checkbox('Colliding', gate.colliding) then gate.colliding = not gate.colliding saveChanges(self) end if ui.itemHovered() then ui.setTooltip('If active, train would get physics collider when getting near the gate, enabling collisions with cars') end ui.text('Models:') ui.beginSubgroup() gateMeshesEditor(self, gate.models, gate) ui.endSubgroup() if ui.button('Remove gate') then toRemove = i end drawDebugOutcome(gate.index) ui.endSubgroup() if ui.itemEdited() then saveChanges(self) end ui.popID() end cancellableRemove(self, self.data.gates, toRemove, 'Gate') ui.offsetCursorY(12) ui.textWrapped('To create a gate, hold Ctrl+Shift and click at the place where the road intersects a track or a bunch of tracks. To move created gate, click on it while holding Ctrl+Shift and then move it.') end local editingRoot ---@type ac.SceneReference local editingPool = {} ---@param self RailroadEditor local function draw3DGates(self) if not editingRoot then editingRoot = ac.findNodes('$$$rw.editing') if #editingRoot == 0 then editingRoot = ac.findNodes('carsRoot:yes'):createBoundingSphereNode('$$$rw.editing', 1e6) end end local now = ac.getSim().timeToSessionStart for i = 1, #self.data.gates do local gate = self.data.gates[i] for j = 1, #gate.models do local m = gate.models[j] local r = table.getOrCreate(editingPool, m.index..':'..m.model, function () return {kn5 = editingRoot:loadKN5(m.model), lastUsed = 0} end) r.lastUsed = now if r.kn5 then r.kn5:setPosition(m.position) r.kn5:setOrientation(m.direction, m.up) if movable:renderFullyAligned(m.position, m.direction, m.up) then saveChanges(self) end end end end local toRemove = {} for k, v in pairs(editingPool) do if v.lastUsed ~= now then if v.kn5 then v.kn5:dispose() end table.insert(toRemove, k) end end for i = 1, #toRemove do editingPool[toRemove[i]] = nil end end ---@param self RailroadEditor ---@param dt number local function drawUITrains(self, dt) local toRemove, toClone for i = 1, #self.data.trains do ui.offsetCursorY(12) local train = self.data.trains[i] ui.pushID(i) ui.text('Train #'..i) ui.beginSubgroup() train.name = ui.inputText('Name', train.name, ui.InputTextFlags.Placeholder) ui.text('Head:') ui.beginSubgroup() ui.pushID('head') cartsEditor(self, train.head) ui.popID() ui.endSubgroup() ui.text('Carts:') ui.beginSubgroup() ui.pushID('carts') cartsEditor(self, train.carts) ui.popID() ui.endSubgroup() ui.text('Size:') ui.offsetCursorX(20) ui.setNextItemWidth(ui.availableSpaceX() / 2 - 4) train.sizeMin = math.floor(ui.slider('##from', train.sizeMin, 0, 200, 'From: %.0f', 2)) ui.sameLine(0, 4) ui.setNextItemWidth(ui.availableSpaceX()) train.sizeMax = math.floor(ui.slider('##to', train.sizeMax, 0, 200, 'To: %.0f', 2)) ui.text('Visual:') ui.beginSubgroup(20) ui.alignTextToFramePadding() ui.text('Main model:') ui.sameLine(100) train.mainModel = ui.inputText('Main model', train.mainModel, ui.InputTextFlags.Placeholder) if ui.itemHovered() then ui.setTooltip('If all of your train models share the same textures, point to KN5 saved as car (with all of them) here and use save other models as car LODs') end if ui.checkbox('Double-faced doors', train.doubleFacedDoors) then train.doubleFacedDoors = not train.doubleFacedDoors saveChanges(self) end ui.alignTextToFramePadding() ui.text('Smoke intensity:') ui.sameLine(100) ui.setNextItemWidth(ui.availableSpaceX()) train.smokeIntensity = ui.slider('##smoke', train.smokeIntensity * 100, 0, 100, '%.0f%%') / 100 ui.endSubgroup() if ui.button('Remove train') then toRemove = i end ui.sameLine(0, 4) if ui.button('Clone train') then toClone = i end drawDebugOutcome(train.index) ui.endSubgroup() if ui.itemEdited() then saveChanges(self) end ui.popID() end cancellableRemove(self, self.data.trains, toRemove, 'Train') if toClone then table.insert(self.data.trains, stringify.parse(stringify(self.data.trains[toClone]))) saveChanges(self) end ui.offsetCursorY(12) if ui.button('New train') then table.insert(self.data.trains, newTrainDescription(self)) saveChanges(self) end end ---@param self RailroadEditor ---@param list SchedulePointDescription[] local function schedulePointsEditor(self, list) if #list == 0 then ui.text('') else local moveItem for i = 1, #list do ui.pushID(i) local currentStation = table.findByProperty(self.data.stations, 'index', list[i].station) ui.setNextItemWidth(ui.availableSpaceX() / 3 - 8) ui.combo('##station', currentStation and currentStation.name or 'No station selected', function () for _, t in ipairs(self.data.stations) do if ui.selectable(t.name, t == currentStation) then list[i].station = t.index saveChanges(self) end end end) ui.sameLine(0, 4) ui.setNextItemWidth(ui.availableSpaceX() / 2 - 4) list[i].duration = ui.slider('##stayFor', (list[i].duration or (10 * 60)) / 60, 0, 60, 'Duration: %.0f min') * 60 local time = string.format('%02d:%02d', math.floor(list[i].time / 3600), math.floor(list[i].time / 60 % 60)) ui.sameLine(0, 4) ui.setNextItemWidth(ui.availableSpaceX() - 24 * 3) time = ui.inputText('Time', time, ui.InputTextFlags.Placeholder) if ui.itemEdited() then local p = time:split(':') local th = tonumber(p[1]) local tm = tonumber(p[2]) local ts = tonumber(p[3]) or 0 if th and tm then list[i].time = th * 3600 + tm * 60 + ts end end ui.pushStyleVar(ui.StyleVar.FramePadding, vec2()) ui.sameLine(0, 4) if ui.button('↑', vec2(20, 20), i == 1 and ui.ButtonFlags.Disabled) and i > 1 then moveItem = {i, i - 1, list[i]} end ui.sameLine(0, 4) if ui.button('↓', vec2(20, 20), i == #list and ui.ButtonFlags.Disabled) and i < #list then moveItem = {i, i + 1, list[i]} end ui.sameLine(0, 4) if ui.button('×', vec2(20, 20)) then moveItem = {i} end ui.popStyleVar() ui.popID() end if moveItem then table.remove(list, moveItem[1]) if moveItem[2] then table.insert(list, moveItem[2], moveItem[3]) end saveChanges(self) end end if ui.button('New item') then table.insert(list, newSchedulePointDescription(self)) saveChanges(self) end end ---@param self RailroadEditor ---@param dt number local function drawUISchedules(self, dt) local toRemove for i = 1, #self.data.schedules do ui.offsetCursorY(12) local schedule = self.data.schedules[i] ui.pushID(i) ui.text('Schedule #'..i) ui.beginSubgroup() schedule.name = ui.inputText('Name', schedule.name, ui.InputTextFlags.Placeholder) local currentTrain = table.findByProperty(self.data.trains, 'index', schedule.train) ui.combo('##train', currentTrain and 'Train: '..currentTrain.name or 'No train selected', function () for _, t in ipairs(self.data.trains) do if ui.selectable(t.name, t == currentTrain) then schedule.train = t.index saveChanges(self) end end end) ui.text('Stations:') ui.beginSubgroup() schedulePointsEditor(self, schedule.points) ui.endSubgroup() if ui.checkbox('Loop route', schedule.looped) then schedule.looped = not schedule.looped saveChanges(self) end schedule.variation = ui.slider('##variation', schedule.variation / 60, 0, 15, 'Variation: %.0f min') * 60 if ui.itemHovered() then ui.setTooltip('Randomized offset for schedule, to keep things from seeming too even (varies with different train runs)') end if ui.button('Remove schedule') then toRemove = i end drawDebugOutcome(schedule.index) ui.endSubgroup() if ui.itemEdited() then saveChanges(self) end ui.popID() end cancellableRemove(self, self.data.schedules, toRemove, 'Schedule') ui.offsetCursorY(12) if ui.button('New schedule') then table.insert(self.data.schedules, newScheduleDescription(self)) saveChanges(self) end end ---@param self RailroadEditor ---@param dt number local function drawUISettings(self, dt) local settings = self.data.settings ui.beginGroup() ui.header('Lines:') ui.alignTextToFramePadding() ui.text('Surface meshes:') ui.sameLine(120) settings.surfaceMeshes = ui.inputText('Complex mesh filter, such as “GRASS_?” or “( GROUND_? | material:dirt ) & static:yes”##surfaceMeshes', self.data.settings.surfaceMeshes, ui.InputTextFlags.Placeholder) if ui.itemHovered() then ui.setTooltip('If set, trains will align themselves over those meshes, so they could tilt in corners. Meshes don’t have to be visible or renderable.') end ui.offsetCursorY(12) ui.header('Gates:') ui.alignTextToFramePadding() ui.text('Activate colliders for:') ui.sameLine(120) settings.gateColliderActiveDistance = ui.slider('#gateColliderActiveDistance', self.data.settings.gateColliderActiveDistance or 200, 20, 500, '%.0f m') if ui.itemHovered() then ui.setTooltip('Train carts get collider when closer that this to gates that activate colliders.') end ui.alignTextToFramePadding() ui.text('Closing distance:') ui.sameLine(120) settings.gateCloseDistance = ui.slider('#gateCloseDistance', self.data.settings.gateCloseDistance or 200, 20, 500, '%.0f m') if ui.itemHovered() then ui.setTooltip('Gates will close when a train would get closer than this distance.') end ui.alignTextToFramePadding() ui.text('Opening distance:') ui.sameLine(120) settings.gateOpenDistance = ui.slider('#gateOpenDistance', self.data.settings.gateOpenDistance or 50, 20, 500, '%.0f m') if ui.itemHovered() then ui.setTooltip('Gates will open when a train would get further than this distance.') end ui.endGroup() if ui.itemEdited() then saveChanges(self) end end local tabs = { { 'Lines', drawUILines }, { 'Stations', drawUIStations }, { 'Gates', drawUIGates, draw3DGates }, { 'Trains', drawUITrains, false, function (s) return table.some(s.data.trains, function (i) return type(dbgOutcomes[i.index]) == 'string' end) end }, { 'Schedules', drawUISchedules, false, function (s) return table.some(s.data.schedules, function (i) return type(dbgOutcomes[i.index]) == 'string' end) end }, { 'Settings', drawUISettings }, } local activeTab function RailroadEditor:drawUI(dt) activeTab = nil ui.pushFont(ui.Font.Small) ui.tabBar('tabs', function () for _, v in ipairs(tabs) do ui.tabItem(v[1], v[4] and v[4](self) and ui.TabItemFlags.UnsavedDocument, function () activeTab = v ui.childWindow('#scroll', ui.availableSpace(), function () ui.pushItemWidth(ui.availableSpaceX()) v[2](self, dt) ui.popItemWidth() end) end) end if _G['debugUI'] then ui.tabItem('Debug', function () ui.childWindow('#scroll', ui.availableSpace(), function () ui.pushItemWidth(ui.availableSpaceX()) _G['debugUI']() ui.popItemWidth() end) end) end end) ui.popFont() end function RailroadEditor:draw3D(dt) if activeTab and activeTab[3] and (not ui.keyboardButtonDown(ui.KeyIndex.Control) or not ui.keyboardButtonDown(ui.KeyIndex.Shift)) then activeTab[3](self, dt) else lineEditor(self) if _G['debug3D'] then _G['debug3D']() end if dbgDraw3D then dbgDraw3D() end end end function RailroadEditor:isBusy() return activeTab and not not activeTab[3] end return class.emmy(RailroadEditor, RailroadEditor.allocate)