├── .gitignore ├── metadata.tic ├── README.md ├── .luarc.json ├── LICENSE ├── convert_map.py └── lzw.lua /.gitignore: -------------------------------------------------------------------------------- 1 | .local/ 2 | -------------------------------------------------------------------------------- /metadata.tic: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HanamileH/Portal-TIC-80/HEAD/metadata.tic -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Portal in TIC-80 2 | 3 | This is the development repository for a Portal fangame made in [TIC-80](https://tic80.com/). 4 | 5 | Development requires TIC-80 Pro, as the source is stored in `.lua` form. 6 | -------------------------------------------------------------------------------- /.luarc.json: -------------------------------------------------------------------------------- 1 | { 2 | "Lua.diagnostics.globals": [ 3 | "btn", 4 | "btnp", 5 | "circ", 6 | "circb", 7 | "clip", 8 | "cls", 9 | "elli", 10 | "ellib", 11 | "exit", 12 | "fget", 13 | "font", 14 | "fset", 15 | "key", 16 | "keyp", 17 | "line", 18 | "map", 19 | "memcpy", 20 | "memset", 21 | "mget", 22 | "mouse", 23 | "mset", 24 | "music", 25 | "peek", 26 | "peek1", 27 | "peek2", 28 | "peek4", 29 | "pix", 30 | "pmem", 31 | "poke", 32 | "poke1", 33 | "poke2", 34 | "poke4", 35 | "print", 36 | "rect", 37 | "rectb", 38 | "reset", 39 | "sfx", 40 | "spr", 41 | "sync", 42 | "time", 43 | "trace", 44 | "tri", 45 | "trib", 46 | "tstamp", 47 | "ttri", 48 | "vbank", 49 | ], 50 | "Lua.diagnostics.disable": [ 51 | "lowercase-global" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 HanamileH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /convert_map.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Generated by ChatGPT, improved by a human 3 | 4 | import sys 5 | from pathlib import Path 6 | 7 | PATH_LEN = 50 # Maximum path length for printing 8 | is_interactive = False 9 | 10 | # Shorten a path to fit within a limited length (truncate if necessary) 11 | def shorten_path(path, limit): 12 | path = path.absolute() 13 | parts = list(path.parts) 14 | 15 | # Collapse home directory 16 | if path.is_relative_to(Path.home()): 17 | parts = ["~"] + parts[len(Path.home().parts) :] 18 | 19 | # Shorten directories, one at a time 20 | for i in range(1, len(parts) - 1): 21 | if len(str(path)) <= limit: 22 | break 23 | parts[i] = parts[i][0] 24 | path = Path(*parts) 25 | 26 | result = str(Path(*parts)) 27 | 28 | # Truncate 29 | if len(result) > limit: 30 | result = "…" + result[-(limit - 1) :] 31 | 32 | return result 33 | 34 | 35 | # Ask user to select a map file interactively 36 | def select_interactive(): 37 | map_files = list(Path(".").glob("**/*.map")) 38 | if len(map_files) == 0: 39 | sys.exit("No .map files in current directory.") 40 | 41 | # Print current dir and list of files 42 | print(f"Current directory: {shorten_path(Path('.'), PATH_LEN)}") 43 | print() 44 | print("Available .map files:") 45 | for i, f in enumerate(map_files, 1): 46 | print(f"[{i}] {f}") 47 | print() 48 | 49 | # Ask the user for the index of the desired file 50 | while True: 51 | try: 52 | selection = int(input("Please select a file: ")) 53 | if 1 <= selection <= len(map_files): 54 | break 55 | print("Out of range.") 56 | except ValueError: 57 | print("Not a number.") 58 | 59 | return map_files[selection - 1] 60 | 61 | 62 | # Find the input path, either from command line or interactively 63 | if len(sys.argv) >= 2: 64 | input_path = Path(sys.argv[1]) 65 | else: 66 | input_path = select_interactive() 67 | is_interactive = True 68 | output_path = input_path.with_suffix(".lua") 69 | if is_interactive: 70 | print() 71 | print(f'Reading "{input_path}".') 72 | 73 | 74 | # Check that the file exists, isn't a directory, and it is 32640 bytes long (1 map bank) 75 | if not input_path.exists(): 76 | sys.exit(f'The file "{input_path}" was not found.') 77 | if not input_path.is_file(): 78 | sys.exit(f'"{input_path}" is not a regular file.') 79 | if input_path.stat().st_size != 32640: 80 | sys.exit(f'"{input_path}" is not 32640 bytes long.') 81 | 82 | 83 | def read_bitfield(value, fields): 84 | result = [] 85 | for field in reversed(fields): 86 | result.insert(0, value & (1 << field) - 1) 87 | value >>= field 88 | return result 89 | 90 | 91 | # Open a binary reading file in binary mode 92 | with input_path.open("rb") as f: 93 | # Start by reading walls until we hit a block of 3 zero bytes 94 | walls = [] 95 | while True: 96 | block = f.read(3) 97 | if block == b"\x00\x00\x00": 98 | # Indicates the end of wall data 99 | break 100 | val = int.from_bytes(block, "big") 101 | walls.append(read_bitfield(val, [4, 4, 4, 2, 2, 6])) 102 | 103 | # Then read objects until we hit a block of 6 zero bytes 104 | objs = [] 105 | while True: 106 | block = f.read(6) 107 | if len(block) != 6 or block == b"\x00\x00\x00\x00\x00\x00": 108 | # End of object data 109 | break 110 | val = int.from_bytes(block, "big") 111 | # val >> 4 because hanamileh has never heard of consistency 112 | obj_data = read_bitfield(val >> 4, [12, 10, 12, 5, 5]) 113 | obj_data[0] -= 1520 114 | obj_data[1] -= 116 115 | obj_data[2] -= 1520 116 | objs.append(obj_data) 117 | 118 | # Save the blocks to the text file 119 | with output_path.open("w") as f: 120 | f.write("maps[1][lvl_id] = { --map by: [your name]\n") 121 | f.write(" w={ -- walls\n") 122 | for wall in walls: 123 | f.write(" {" + ", ".join(str(num) for num in wall) + "},\n") 124 | f.write( 125 | """ }, 126 | o={ --table for objects 127 | """, 128 | ) 129 | for obj in objs: 130 | f.write(" {" + ", ".join(str(num) for num in obj) + "},\n") 131 | f.write( 132 | """ }, 133 | p={}, --table for portals (leave empty if the portals are not needed) 134 | lg={}, --light bridge generators 135 | lift={nil,nil}, --Initial and final elevator (X Y Z angle) 136 | pg_lvl=0, --portal gun lvl 137 | init = function() 138 | --its executed once before starting this lvl 139 | end, 140 | scripts = function() 141 | --its executed once per frame (usually 60 times per second) 142 | end 143 | } 144 | """, 145 | ) 146 | 147 | if is_interactive: 148 | print(f'Map converted. Result is in "{output_path}".') 149 | -------------------------------------------------------------------------------- /lzw.lua: -------------------------------------------------------------------------------- 1 | -- This file was originally created to prototype the compression that parts of the game use, 2 | -- and is now kept around as a useful reference or standalone compression tool (requires some simple tweaks). 3 | 4 | local function bitwriter() 5 | return { 6 | cur = 0, 7 | bits = 0, 8 | output = {}, 9 | 10 | write = function(self, val, bits) 11 | self.cur = self.cur | (val << self.bits) 12 | self.bits = self.bits + bits 13 | self:flush() 14 | end, 15 | flush = function(self) 16 | while self.bits >= 8 do 17 | self.output[#self.output + 1] = string.char(self.cur & 0xFF) 18 | self.bits = self.bits - 8 19 | self.cur = self.cur >> 8 20 | end 21 | end, 22 | finish = function(self) 23 | self.output[#self.output + 1] = string.char(self.cur) 24 | self.bits = 0 25 | self.cur = 0 26 | return table.concat(self.output) 27 | end, 28 | } 29 | end 30 | 31 | local function bitreader(data) 32 | return { 33 | data = data, 34 | pos = 1, 35 | bits = 0, 36 | 37 | read = function(self, bits) 38 | local value = 0 39 | local cur = 0 40 | 41 | if self.bits > 0 then 42 | if bits >= (8 - self.bits) then 43 | value = self:byte() >> self.bits 44 | cur = 8 - self.bits 45 | self.bits = 0 46 | self.pos = self.pos + 1 47 | else 48 | value = (self:byte() >> self.bits) & ((1 << bits) - 1) 49 | self.bits = self.bits + bits 50 | return value 51 | end 52 | end 53 | 54 | while (cur + 8) <= bits do 55 | value = value | (self:byte() << cur) 56 | cur = cur + 8 57 | self.pos = self.pos + 1 58 | end 59 | 60 | if cur < bits then 61 | value = value | (self:byte() & ((1 << bits - cur) - 1)) << cur 62 | self.bits = bits - cur 63 | end 64 | 65 | return value 66 | end, 67 | byte = function(self) 68 | return string.byte(self.data, self.pos, self.pos) 69 | end, 70 | } 71 | end 72 | 73 | local function compress(str) 74 | local writer = bitwriter() 75 | 76 | local codes = {} 77 | for i = 1, 256 do 78 | codes[string.char(i - 1)] = i 79 | end 80 | local count = 256 81 | local bits = 9 82 | local inc = 512 83 | 84 | local start = 1 85 | 86 | while start <= #str do 87 | for i = start, #str do 88 | local cur = str:sub(start, i) 89 | if i == #str then 90 | writer:write(codes[cur], bits) 91 | start = i + 1 92 | break 93 | end 94 | 95 | local nxt = str:sub(start, i + 1) 96 | if not codes[nxt] then 97 | writer:write(codes[cur], bits) 98 | count = count + 1 99 | codes[nxt] = count 100 | start = i + 1 101 | break 102 | end 103 | end 104 | 105 | if count == inc then 106 | inc = inc * 2 107 | bits = bits + 1 108 | end 109 | end 110 | 111 | writer:write(0, bits) 112 | return writer:finish() 113 | end 114 | 115 | local function decompress(str) 116 | local reader = bitreader(str) 117 | 118 | local codes = {} 119 | for i = 1, 256 do 120 | codes[i] = string.char(i - 1) 121 | end 122 | local bits = 9 123 | local inc = 512 124 | 125 | local result = {} 126 | local prev 127 | 128 | while true do 129 | local code = reader:read(bits) 130 | if code == 0 then 131 | return table.concat(result) 132 | end 133 | 134 | if codes[code] then 135 | result[#result + 1] = codes[code] 136 | if prev then 137 | codes[#codes + 1] = prev .. codes[code]:sub(1, 1) 138 | end 139 | prev = codes[code] 140 | else 141 | local new = prev .. prev:sub(1, 1) 142 | result[#result + 1] = new 143 | codes[#codes + 1] = new 144 | prev = new 145 | end 146 | 147 | if #codes == inc - 1 then 148 | inc = inc * 2 149 | bits = bits + 1 150 | end 151 | end 152 | end 153 | 154 | local function tohex(str) 155 | return ({str:gsub('.', function(c) return string.format('%02x', string.byte(c)) end)})[1] 156 | end 157 | 158 | local function fromhex(str) 159 | return ({str:gsub('..', function(c) return string.char(tonumber(c, 16)) end)})[1] 160 | end 161 | 162 | local data = io.read('a') 163 | print(#data .. ' bytes uncompressed') 164 | local compressed = compress(data) 165 | print(#compressed .. ' bytes compressed') 166 | print(string.format('Space saved: %.2f%%', 100 - #compressed / #data * 100)) 167 | print('Data correct: ' .. (data == decompress(compressed) and 'yes' or 'no')) 168 | 169 | --------------------------------------------------------------------------------