diff --git a/ghost.lua b/ghost.lua new file mode 100644 index 0000000..35ff1c3 --- /dev/null +++ b/ghost.lua @@ -0,0 +1,750 @@ +local DEBUG = false + +freeslot("MT_PLAYER_GHOST") + +mobjinfo[MT_PLAYER_GHOST] = { + -1, -- doomednum + S_KART_STND1, -- spawnstate + 1, -- spawnhealth + S_KART_WALK1, -- seestate + sfx_None, -- seesound + 0, -- reactiontime + sfx_thok, -- attacksound + S_KART_PAIN, -- painstate + MT_THOK, -- painchance + sfx_None, -- painsound + S_NULL, -- meleestate + S_NULL, -- missilestate + S_KART_PAIN, -- deathstate + S_NULL, -- xdeathstate + sfx_None, -- deathsound + 1, -- speed + 16*FRACUNIT, -- radius + 48*FRACUNIT, -- height + 0, -- display offset + 1000, -- mass + MT_THOK, -- damage + sfx_None, -- activesound + MF_NOCLIP, -- flags + MT_THOK -- raisestate +} + +local nenc = lb_base62_encode +local ndec = lb_base62_decode +local mapChecksum = lb_map_checksum +local Transmitter = lb_transmitter +local Reciever = lb_reciever + +local Client +local Server + +local Index +local Ghost +local Ghosts +local Recording + +local PREFIX -- Dir +local INDEX = "ghosts" -- Filename + +local cv_disable = CV_RegisterVar({ + name = "lb_ghost_disable", + defaultvalue = "No", + flags = CV_NETVAR | CV_CALL, + PossibleValue = CV_YesNo, + func = function(v) + Recording = nil + end +}) + + +local function Columns(line, sep) + local t = {} + sep = sep or " " + for str in (line..sep):gmatch("(.-)"..sep) do + table.insert(t, str) + end + return { + index = 0, + items = t, + next = function(this) + this.index = $ + 1 + return this.items[this.index] + end + } +end + +Ghosts = { + map, + players = {}, + headers = {}, + data = {}, + + dirty = true, + + getData = function(this, player) + return this.data[player] + end, + + set = function(this, header, data) + if not this.headers[header.player] then + table.insert(this.players, header.player) + end + + this.data[header.player] = data + this.headers[header.player] = header + end, + + iterate = function(this) + local i = 0 + local m = #this.players + local player + return function() + i = i + 1 + if i <= m then + player = this.players[i] + return i, this.headers[player], this.data[player] + end + end + end, + + init = function(this) + if this.map == gamemap and not this.dirty then return end + this:reset() + + local headers = Index:getMap(gamemap, mapChecksum(gamemap)) + local data + for _, header in ipairs(headers) do + data = Ghost.read(header, Ghost.tableReader) + this:set(header, data) + end + end, + + reset = function(this) + this.players = {} + this.headers = {} + this.data = {} + this.map = gamemap + this.dirty = false + end, + + play = function(this) + for _, header in pairs(this.headers) do + local ghost = P_SpawnMobj(0, 0, 0, MT_PLAYER_GHOST) + ghost.playerName = header.player + ghost.skin = header.skin + ghost.color = header.color + end + end +} + +local function GhostTable(header) + local function frame(this, frameNum) + return this.frames[frameNum] + end + + local function append(this, frame) + table.insert(this.frames, frame) + end + + -- Calculate the momentum of the last frame + local function thrust(this, frameNum) + local a = this:frame(frameNum-1) + local b = this:frame(frameNum) + local angle = R_PointToAngle2(a.x, a.y, b.x, b.y) + local momentum = R_PointToDist2(a.x, a.y, b.x, b.y) / FRACUNIT + + local x = momentum * cos(angle) + local y = momentum * sin(angle) + return x, y + end + + local t = { + frames = {}, + frame = frame, + append = append, + thrust = thrust, + } + + if header then + for key, value in pairs(header) do + t[key] = value + end + end + + return t +end + +local function ghost_think(mobj) + if mobj.done then return end + if leveltime < 1 then return end + + if not Recording then + P_RemoveMobj(mobj) + return + end + + local ghost = Ghosts:getData(mobj.playerName) + local frame = ghost:frame(leveltime) + if frame == nil then + --ghost.flags = $ | MF_NOTHINK + mobj.done = true + mobj.state = S_KART_STND1 + mobj.momx, mobj.momy = ghost:thrust(leveltime-1) + return + end + + P_MoveOrigin(mobj, frame.x, frame.y, frame.z) + mobj.angle = frame.angle + mobj.state = frame.state + mobj.frame = mobj.frame & (~FF_TRANSMASK) + mobj.frame = mobj.frame | FF_TRANS30 + + --if leveltime < TICRATE * 10 then + -- mobj.flags2 = $ | MF2_DONTDRAW + --else + -- mobj.flags2 = $ & (~MF2_DONTDRAW) + --end + + return true +end +addHook("MobjThinker", ghost_think, MT_PLAYER_GHOST) + +local function drawGhostProgress(v) + local x, y = 0, 0 + local flags = V_SNAPTOTOP | V_SNAPTOLEFT + + local function fmt(rec) + local stale = rec.tics > (TICRATE * 10) and "(STALE) " or "" + if not rec.len then return stale.."0%" end + local n = #rec.packets + local p = ((n * 100) / rec.len).."%" + return stale.."["..n.."/"..rec.len.."] "..p + end + + for _, rec in pairs(Client.data.recievers) do + v.drawString(x, y, fmt(rec), flags, "small") + y = y + 5 + end +end + +hud.add(function(v) + if not Recording then return end + + drawGhostProgress(v) + + local leveltime = leveltime + local frame, patch, skin, color + for i, header, data in Ghosts:iterate() do + frame = data:frame(leveltime) + if not frame then continue end + + skin = skins[header.skin] or skins["sonic"] + patch = v.cachePatch(skin.facemmap) + color = v.getColormap(header.skin, header.color) + + v.drawOnMinimap(frame.x, frame.y, FRACUNIT, patch, color) + end +end) + +if lb_hook then +lb_hook("Finish", function(data) + if not data.position then + return + end + + if data.score.flags & 0x1 then + Recording = nil + return + end + + local score = data.score + + local header = { + version = 1, + encoding = "b62", + player = score.name, + skin = score.skin, + color = score.color, + map = score.map, + checksum = score.checksum, + time = score.time, + flags = score.flags, + } + + Ghost.write(header, Ghost.tableWriter(Recording)) + Index:insert(header) + Index:write() + Ghosts.dirty = true + Recording = nil +end) +end + +--COM_AddCommand("save_ghost", function(player) +-- if not Recording then return end +-- +-- print("Saving Ghost") +-- +-- local header = { +-- version = 1, +-- encoding = "b62", +-- player = player.name, +-- skin = player.mo.skin, +-- color = player.skincolor, +-- map = gamemap, +-- checksum = mapChecksum(gamemap), +-- time = leveltime, +-- flags = 0, +-- } +-- +-- Ghost.write(header, Ghost.tableWriter(Recording)) +-- Index:insert(header) +-- Index:write() +-- +-- Recording = nil +--end) + +local function singlePlayer() + local player + for p in players.iterate do + if p.valid and not p.spectator then + if player then + return nil + end + player = p + end + end + + return player +end + +addHook("ThinkFrame", function() + if not Recording then return end + + local p = singlePlayer() + if not p then + Server:stop() + Client:stop() + Recording = nil + return + end + + local frame = { + x = p.mo.x, + y = p.mo.y, + z = p.mo.z, + angle = p.mo.angle, + state = p.mo.state + } + + Recording:append(frame) +end) + +Server = { + header = { + transmitter, + send = function(this) + if this.transmitter + or #Server.command.transmitters + then + return + end + + this.transmitter = Transmitter("GM", {free = this.free, handle = this}) + local mapindex = Index:getMap(gamemap, mapChecksum(gamemap)) + + local s = "" + for _, header in ipairs(mapindex) do + s = s..Index.headerFmt(header) + end + + this.transmitter:transmit(s) + end, + + free = function(tr, this) + this.transmitter = nil + end, + + stop = function(this) + if this.transmitter then + this.transmitter:close() + end + end + }, + + command = { + reciever, + listen = function(this) + if this.reciever then return end + this.reciever = Reciever("GC", this.callback, {stream = true, handle = this}) + this.reciever:listen() + end, + + transmitters = {}, + callback = function(cmd, handle) + local mapindex = Index:getMap(gamemap, mapChecksum(gamemap)) + local header = assert(mapindex[tonumber(cmd)]) + local data = Ghost.read(header, Ghost.stringReader) + + local tr = Transmitter( + "GC"..cmd, + { + free = handle.free, + handle = { + this = handle, + cmd = cmd + } + } + ) + handle.transmitters[cmd] = tr + tr:transmit(data) + end, + + free = function(tr, handle) + handle.this.transmitters[handle.cmd] = nil + end, + + stop = function(this) + for _, tr in pairs(this.transmitters) do + tr:close() + end + end + }, + + stop = function(this) + this.header:stop() + this.command:stop() + end +} + +Client = { + header = { + reciever, + listen = function(this) + if this.reciever then return end + this.reciever = Reciever( + "GM", + this.callback, + { + free = this.free, + handle = this + } + ) + this.reciever:listen() + end, + + callback = function(data, this) + local header + local cmd = 1 + for str in data:gmatch("(.-)\n") do + if Client.data:busy(cmd) then + continue + end + + header = Index.parseHeader(str) + if not Index:find(header) then + local tr = Transmitter("GC", {stream = true}) + tr:transmit(cmd) + Client.data:listen(header, cmd) + end + cmd = $ + 1 + end + end, + + free = function(rc, handle) + handle.reciever = nil + end, + + stop = function(this) + if this.reciever then + this.reciever:close() + end + end + }, + + data = { + recievers = {}, + listen = function(this, header, cmd) + local rc = Reciever( + "GC"..cmd, + this.callback, + { + free = this.free, + handle = { + this = this, + header = header, + cmd = cmd + }, + } + ) + + this.recievers[cmd] = rc + rc:listen() + end, + + callback = function(data, handle) + Ghost.write(handle.header, Ghost.stringWriter(data)) + Index:insert(handle.header) + Index:write() + + Ghosts.dirty = true + end, + + free = function(rc, handle) + handle.this.recievers[handle.cmd] = nil + end, + + busy = function(this, cmd) + for com in pairs(this.recievers) do + if com == cmd then return true end + end + end, + + stop = function(this) + for _, rec in pairs(this.recievers) do + rec:close() + end + end + }, + + stop = function(this) + this.header:stop() + this.data:stop() + end +} + +addHook("MapLoad", function(num) + if cv_disable.value + or not singlePlayer() + then + Recording = nil + return + end + + Recording = GhostTable() + + if isserver then + Server.header:send() + Server.command:listen() + return + end + + Ghosts:init() + Client.header:listen() + Ghosts:play() +end) + +local function open(filename, mode, fn) + local f, err = io.open(filename, mode) + if err then + return nil, err + end + + local ok, err = pcall(fn, f) + f:close() + + if not ok then + err = string.format("%s\n(%s (%s))", err, filename, mode) + end + + return ok, err +end + +Index = { + headers, + + insert = function(this, header) + local map = this:getMap(header.map, header.checksum) + for i, h in ipairs(map) do + if h.player == header.player then + map[i] = header + this:setMap(header.map, header.checksum, map) + return + end + end + + table.insert(map, header) + this:setMap(header.map, header.checksum, map) + end, + + find = function(this, header) + local index = this:getMap(header.map, header.checksum) + for i, h in ipairs(index) do + if h.player == header.player then + return h + end + end + end, + + join = function(sep, ...) + local t = {...} + local ret = ""..t[1] + local v + for i = 2, #t do + v = t[i] + ret = $..sep..(v ~= nil and v or "") + end + return ret + end, + + headerFmt = function(h) + return Index.join( + "\t", + h.version, + h.encoding, + h.map, + h.checksum, + h.player, + h.time, + h.flags or "0", + h.skin, + h.color or "0" + ).."\n" + end, + + parseHeader = function(line) + local c = Columns(line, "\t") + return { + version = c:next(), + encoding = c:next(), + map = c:next(), + checksum = c:next(), + player = c:next(), + time = c:next(), + flags = c:next(), + skin = c:next(), + color = c:next() + } + end, + + filename = function(prefix, filename) + return string.format( + "%s/%s.txt", + prefix, + filename + ) + end, + + write = function(this) + local filename = this.filename(PREFIX, INDEX) + assert(open(filename, "w", function(f) + for _, map in pairs(this.headers) do + for _, header in ipairs(map) do + f:write(this.headerFmt(header)) + end + end + end)) + end, + + read = function(this) + local filename = this.filename(PREFIX, INDEX) + this.headers = {} + assert(open(filename, "r", function(f) + for line in f:lines() do + this:insert( + this.parseHeader(line) + ) + end + end)) + end, + + getMap = function(this, map, checksum) + if not this.headers then + this:read() + end + + return this.headers[map..checksum] or {} + end, + + setMap = function(this, map, checksum, entry) + assert(this.headers, "Index: header unread") + this.headers[map..checksum] = entry + end +} + +Ghost = { + stringReader = function(f) + return f:read("*a") + end, + + tableReader = function(f) + local data = GhostTable(header) + local c + f:read() -- skip first frame + for line in f:lines() do + c = Columns(line) + data:append({ + state = ndec(c:next()), + angle = ndec(c:next()), + x = ndec(c:next()), + y = ndec(c:next()), + z = ndec(c:next()), + }) + end + return data + end, + + filename = function(prefix, header) + return string.format( + "%s/%d_%s_%s.txt", + prefix, + header.map, + header.checksum, + header.player + ) + end, + + read = function(header, reader) + local filename = Ghost.filename(PREFIX, header) + local data + assert(open(filename, "r", function(f) + data = reader(f) + end)) + + return data + end, + + stringWriter = function(dataStr) + return function(f) + f:setvbuf("full") + f:write(dataStr) + end + end, + + frameFmt = function(frame) + return string.format( + "%s %s %s %s %s\n", + nenc(frame.state), + nenc(frame.angle), + nenc(frame.x), + nenc(frame.y), + nenc(frame.z) + ) + end, + + tableWriter = function(data) + return function(f) + f:setvbuf("line") + for _, frame in ipairs(data.frames) do + f:write(Ghost.frameFmt(frame)) + end + end + end, + + write = function(header, writer) + local filename = Ghost.filename(PREFIX, header) + assert(open(filename, "w", writer)) + end, +} + +local cv_prefix = CV_RegisterVar({ + name = "lb_ghost_dir", + defaultvalue = "ghosts", + flags = CV_CALL | CV_NETVAR, + func = function(cv) + PREFIX = cv.string + if DEBUG and not isserver then + PREFIX = $.."_client" + end + Index.headers = nil + end +}) diff --git a/lb_common.lua b/lb_common.lua index d50bfd7..1c90acc 100644 --- a/lb_common.lua +++ b/lb_common.lua @@ -124,3 +124,44 @@ rawset(_G, "lb_fire_event", function(event, ...) pcall(callback, ...) end end) + +local b62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +local function base62_encode(n) + if n <= 0 then return "0" end + local b62 = b62 + local q = n + local r = "" + local t + while q > 0 do + t = q % 62 + 1 + r = b62:sub(t, t)..r + q = q / 62 + end + return r +end + +local function base62_decode(s) + local n = b62:find(s:sub(1,1)) - 1 + for i = 2, #s do + n = n * 62 + b62:find(s:sub(i, i)) - 1 + end + + return n +end + +local function neg_base62_encode(n) + if n == INT32_MIN then + return "-2lkCB2" + end + + if n < 0 then return "-"..base62_encode(abs(n)) end + return base62_encode(n) +end + +local function neg_base62_decode(s) + if s:sub(1, 1) == "-" then return -base62_decode(s:sub(2)) end + return base62_decode(s) +end + +rawset(_G, "lb_base62_encode", neg_base62_encode) +rawset(_G, "lb_base62_decode", neg_base62_decode) diff --git a/transmission.lua b/transmission.lua new file mode 100644 index 0000000..f05fb57 --- /dev/null +++ b/transmission.lua @@ -0,0 +1,249 @@ +local MSG = "<<~" +local CHN = "~>>" + +local PACKET_MAX_SIZE = 200 +local MAX_TICS = TICRATE * 2 + +local function encode(data) + data = string.gsub(data, "\n", "\\n") + data = string.gsub(data, "\t", "\\t") + return data +end + +local function decode(data) + data = string.gsub(data, "\\n", "\n") + data = string.gsub(data, "\\t", "\t") + return data +end + +local transmitters = {} + +local function Transmitter(channel, opts) + assert(channel, "Transmitter: channel is required") + opts = $ or {} + + return { + packets = {}, + + push = function(this, packet) + table.insert(this.packets, MSG..channel..CHN..packet) + end, + + pop = function(this) + return table.remove(this.packets) + end, + + sendPacket = function(this) + local sender = consoleplayer or server + COM_BufInsertText(sender, "say \""..this:pop().."\"") + return not #this.packets + end, + + writeHeader = function(this) + this:push(#this.packets) + end, + + close = function(this) + for i, tr in ipairs(transmitters) do + if tr == this then + table.remove(transmitters, i) + break + end + end + + if opts.free then + opts.free(this, opts.handle) + end + end, + + enqueue = function(this) + table.insert(transmitters, this) + end, + + transmit = function(this, data) + assert(data, "Transmitter: nil data") + + data = encode(data) + + if opts.stream then + assert( + #data < PACKET_MAX_SIZE, + "Transmitter: data packet too large for stream" + ) + + this:push(data) + this:enqueue() + return + end + + local sub + for i = 1, #data, PACKET_MAX_SIZE do + sub = data:sub(i, min(#data, i + PACKET_MAX_SIZE-1)) + this:push(sub) + end + + this:writeHeader() + this:enqueue() + end + } +end +rawset(_G, "lb_transmitter", Transmitter) + +addHook("ThinkFrame", function() + if not #transmitters then return end + + local index = (leveltime % #transmitters) + 1 + local transmitter = transmitters[index] + + if transmitter:sendPacket() then + transmitter:close() + end +end) + +local Channels = { + channel = {}, + + add = function(this, ch, reciever) + this.channel[ch] = $ or {} + table.insert(this.channel[ch], reciever) + end, + + chan = function(this, ch) + local c = this.channel[ch] + local i = c and #c + 1 or 0 + return function() + if i > 1 then + i = i - 1 + return i, c[i] + end + end + end, + + remove = function(this, ch, index) + table.remove(this.channel[ch], index) + end, + + recieve = function(this) + return function(packet, ch) + for i, reciever in this:chan(ch) do + if reciever:push(packet) then + reciever:close() + end + end + end + end +} + +addHook("ThinkFrame", function() + for _, ch in pairs(Channels.channel) do + for _, rec in pairs(ch) do + if rec:tick() then + rec:close() + end + end + end +end) + +local function Reciever(channel, callback, opts) + assert(callback, "Reciever: callback is required") + opts = $ or {} + + local ticker, pusher + local MAX_TICS = MAX_TICS + if opts.stream then + ticker = function(this) end + + pusher = function(this, packet) + callback(decode(packet), opts.handle) + end + else + ticker = function(this) + this.tics = $ + 1 + return this.tics > MAX_TICS + end + + pusher = function(this, packet) + if not this.len then + this:recieveHeader(packet) + return + end + + table.insert(this.packets, packet) + + if opts.progress then + opts.progress(#this.packets, this.len, opts.handle) + end + + if #this.packets >= this.len then + this:finish() + return true + end + + this.tics = 0 + end + end + + return { + len, + packets = {}, + tics = 0, + tick = ticker, + + close = function(this) + for i, rec in Channels:chan(channel) do + if rec == this then + Channels:remove(channel, i) + if opts.free then + opts.free(this, opts.handle) + end + return + end + end + end, + + push = pusher, + + recieveHeader = function(this, header) + local len = tonumber(header) + assert(len ~= nil, + "Reciever: invalid header '"..(header or "nil").."'") + this.len = len + end, + + listen = function(this) + Channels:add(channel, this) + end, + + pop = function(this) + return table.remove(this.packets) + end, + + finish = function(this) + local data = "" + + local s = this:pop() + while s do + data = $..s + s = this:pop() + end + + callback(decode(data), opts.handle) + end + } +end +rawset(_G, "lb_reciever", Reciever) + +local function scan(sym, msg, fn) + if msg:find(sym) == 1 then + msg = msg:sub(#sym+1) + local chi = msg:find(CHN) + local ch = msg:sub(1, chi-1) + msg = msg:sub(chi+#CHN) + fn(msg, ch) + return true + end +end + +addHook("PlayerMsg", function(source, type, target, msg) + return scan(MSG, msg, Channels:recieve()) +end)