1--[[ Copyright (c) 2009 Peter "Corsix" Cawley 2 3Permission is hereby granted, free of charge, to any person obtaining a copy of 4this software and associated documentation files (the "Software"), to deal in 5the Software without restriction, including without limitation the rights to 6use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7of the Software, and to permit persons to whom the Software is furnished to do 8so, subject to the following conditions: 9 10The above copyright notice and this permission notice shall be included in all 11copies or substantial portions of the Software. 12 13THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19SOFTWARE. --]] 20 21local TH = require("TH") 22 23local pathsep = package.config:sub(1, 1) 24local ourpath = debug.getinfo(1, "S").source:sub(2, -17) 25 26--! Layer for loading (and subsequently caching) graphical resources. 27--! The Graphics class handles loading and caching of graphics resources. 28-- It can adapt as the API to C changes, and hide these changes from most of 29-- the other Lua code. 30class "Graphics" 31 32---@type Graphics 33local Graphics = _G["Graphics"] 34 35local cursors_name = { 36 default = 1, 37 clicked = 2, 38 resize_room = 3, 39 edit_room = 4, 40 ns_arrow = 5, 41 we_arrow = 6, 42 nswe_arrow = 7, 43 move_room = 8, 44 sleep = 9, 45 kill_rat = 10, 46 kill_rat_hover = 11, 47 epidemic_hover = 12, 48 epidemic = 13, 49 grab = 14, 50 quit = 15, 51 staff = 16, 52 repair = 17, 53 patient = 18, 54 queue = 19, 55 queue_drag = 20, 56 bank = 36, 57 banksummary = 44, 58} 59local cursors_palette = { 60 [36] = "bank01v.pal", 61 [44] = "stat01v.pal", 62} 63 64function Graphics:Graphics(app) 65 self.app = app 66 self.target = self.app.video 67 -- The cache is used to avoid reloading an object if it is already loaded 68 self.cache = { 69 raw = {}, 70 tabled = {}, 71 palette = {}, 72 palette_greyscale_ghost = {}, 73 ghosts = {}, 74 anims = {}, 75 language_fonts = {}, 76 cursors = setmetatable({}, {__mode = "k"}), 77 } 78 79 self.custom_graphics = {} 80 -- The load info table records how objects were loaded, and is used to 81 -- persist objects as instructions on how to load them. 82 self.load_info = setmetatable({}, {__mode = "k"}) 83 -- If the video target changes then resources will need to be reloaded 84 -- (at least with some rendering engines). Note that reloading is different 85 -- to loading (as in load_info), as reloading is done while the application 86 -- is running, upon objects which are already loaded, whereas loading might 87 -- be done with a different graphics engine, or might only need to grab an 88 -- object from the cache. 89 self.reload_functions = setmetatable({}, {__mode = "k"}) 90 -- Cursors and fonts need to be reloaded after sprite sheets, as they are 91 -- created from sprite sheets. 92 self.reload_functions_last = setmetatable({}, {__mode = "k"}) 93 94 self:loadFontFile() 95 96 local graphics_folder = nil 97 if self.app.config.use_new_graphics then 98 -- Check if the config specifies a place to look for graphics in. 99 -- Otherwise check in the default "Graphics" folder. 100 graphics_folder = self.app.config.new_graphics_folder or ourpath .. "Graphics" 101 if graphics_folder:sub(-1) ~= pathsep then 102 graphics_folder = graphics_folder .. pathsep 103 end 104 105 local graphics_config_file = graphics_folder .. "file_mapping.txt" 106 local result, err = loadfile_envcall(graphics_config_file) 107 108 if not result then 109 print("Warning: Failed to read custom graphics configuration:\n" .. err) 110 else 111 result(self.custom_graphics) 112 if not self.custom_graphics.file_mapping then 113 print("Error: An invalid custom graphics mapping file was found") 114 end 115 end 116 end 117 self.custom_graphics_folder = graphics_folder 118end 119 120--! Tries to load the font file given in the config file as unicode_font. 121--! If it is not found it tries to find one in the operating system. 122function Graphics:loadFontFile() 123 -- Load the Unicode font, if there is one specified. 124 local font_file = self.app.config.unicode_font 125 if not font_file then 126 -- Try a font which commonly comes with the operating system. 127 local windir = os.getenv("WINDIR") 128 if windir and windir ~= "" then 129 font_file = windir .. pathsep .. "Fonts" .. pathsep .. "ARIALUNI.TTF" 130 elseif self.app.os == "macos" then 131 font_file = "/Library/Fonts/Arial Unicode.ttf" 132 else 133 font_file = "/usr/share/fonts/truetype/arphic/uming.ttc" 134 end 135 end 136 font_file = font_file and io.open(font_file, "rb") 137 if font_file then 138 self.ttf_font_data = font_file:read"*a" 139 font_file:close() 140 end 141end 142 143function Graphics:loadMainCursor(id) 144 if type(id) ~= "number" then 145 id = cursors_name[id] 146 end 147 if id > 20 then -- SPointer cursors 148 local cursor_palette = self:loadPalette("QData", cursors_palette[id]) 149 cursor_palette:setEntry(255, 0xFF, 0x00, 0xFF) -- Make index 255 transparent 150 return self:loadCursor(self:loadSpriteTable("QData", "SPointer", false, cursor_palette), id - 20) 151 else 152 return self:loadCursor(self:loadSpriteTable("Data", "MPointer"), id) 153 end 154end 155 156function Graphics:loadCursor(sheet, index, hot_x, hot_y) 157 local sheet_cache = self.cache.cursors[sheet] 158 if not sheet_cache then 159 sheet_cache = {} 160 self.cache.cursors[sheet] = sheet_cache 161 end 162 local cursor = sheet_cache[index] 163 if not cursor then 164 hot_x = hot_x or 0 165 hot_y = hot_y or 0 166 cursor = TH.cursor() 167 if not cursor:load(sheet, index, hot_x, hot_y) then 168 cursor = { 169 draw = function(canvas, x, y) 170 sheet:draw(canvas, index, x - hot_x, y - hot_y) 171 end, 172 } 173 else 174 local function cursor_reloader(res) 175 assert(res:load(sheet, index, hot_x, hot_y)) 176 end 177 self.reload_functions_last[cursor] = cursor_reloader 178 end 179 sheet_cache[index] = cursor 180 self.load_info[cursor] = {self.loadCursor, self, sheet, index, hot_x, hot_y} 181 end 182 return cursor 183end 184 185local function makeGreyscaleGhost(pal) 186 local remap = {} 187 -- Convert pal from a string to an array of palette entries 188 local entries = {} 189 for i = 1, #pal, 3 do 190 local entry = {pal:byte(i, i + 2)} -- R, G, B at [1], [2], [3] 191 entries[(i - 1) / 3] = entry 192 end 193 -- For each palette entry, convert it to grey and then find the nearest 194 -- entry in the palette to that grey. 195 for i = 0, #entries do 196 local entry = entries[i] 197 local grey = entry[1] * 0.299 + entry[2] * 0.587 + entry[3] * 0.114 198 local grey_index = 0 199 local grey_diff = 100000 -- greater than 3*63^2 (TH uses 6 bit colour channels) 200 for j = 0, #entries do 201 local replace_entry = entries[j] 202 local diff_r = replace_entry[1] - grey 203 local diff_g = replace_entry[2] - grey 204 local diff_b = replace_entry[3] - grey 205 local diff = diff_r * diff_r + diff_g * diff_g + diff_b * diff_b 206 if diff < grey_diff then 207 grey_diff = diff 208 grey_index = j 209 end 210 end 211 remap[i] = string.char(grey_index) 212 end 213 -- Convert remap from an array to a string 214 return table.concat(remap, "", 0, 255) 215end 216 217function Graphics:loadPalette(dir, name) 218 name = name or "MPalette.dat" 219 if self.cache.palette[name] then 220 return self.cache.palette[name], 221 self.cache.palette_greyscale_ghost[name] 222 end 223 224 local data = self.app:readDataFile(dir or "Data", name) 225 local palette = TH.palette() 226 palette:load(data) 227 self.cache.palette_greyscale_ghost[name] = makeGreyscaleGhost(data) 228 self.cache.palette[name] = palette 229 self.load_info[palette] = {self.loadPalette, self, dir, name} 230 return palette, self.cache.palette_greyscale_ghost[name] 231end 232 233function Graphics:loadGhost(dir, name, index) 234 local cached = self.cache.ghosts[name] 235 if not cached then 236 local data = self.app:readDataFile(dir, name) 237 cached = data 238 self.cache.ghosts[name] = cached 239 end 240 return cached:sub(index * 256 + 1, index * 256 + 256) 241end 242 243function Graphics:loadRaw(name, width, height, dir, paldir, pal) 244 if self.cache.raw[name] then 245 return self.cache.raw[name] 246 end 247 248 width = width or 640 249 height = height or 480 250 dir = dir or "QData" 251 local data = self.app:readDataFile(dir, name .. ".dat") 252 data = data:sub(1, width * height) 253 254 local bitmap = TH.bitmap() 255 local palette 256 if pal and paldir then 257 palette = self:loadPalette(paldir, pal) 258 else 259 palette = self:loadPalette(dir, name .. ".pal") 260 end 261 bitmap:setPalette(palette) 262 assert(bitmap:load(data, width, self.target)) 263 264 local function bitmap_reloader(bm) 265 bm:setPalette(palette) 266 local bitmap_data = self.app:readDataFile(dir, name .. ".dat") 267 bitmap_data = bitmap_data:sub(1, width * height) 268 assert(bm:load(bitmap_data, width, self.target)) 269 end 270 self.reload_functions[bitmap] = bitmap_reloader 271 272 self.cache.raw[name] = bitmap 273 self.load_info[bitmap] = {self.loadRaw, self, name, width, height, dir, paldir, pal} 274 return bitmap 275end 276 277function Graphics:loadBuiltinFont() 278 local font = self.builtin_font 279 if not font then 280 local dat, tab, pal = TH.GetBuiltinFont() 281 local function dernc(x) 282 if x:sub(1, 3) == "RNC" then 283 return rnc.decompress(x) 284 else 285 return x 286 end 287 end 288 local palette = TH.palette() 289 palette:load(dernc(pal)) 290 local sheet = TH.sheet() 291 sheet:setPalette(palette) 292 sheet:load(dernc(tab), dernc(dat), true, self.target) 293 font = TH.bitmap_font() 294 font:setSheet(sheet) 295 font:setSeparation(1, 0) 296 self.load_info[font] = {self.loadBuiltinFont, self} 297 self.builtin_font = font 298 end 299 return font 300end 301 302function Graphics:hasLanguageFont(font) 303 if font == nil then 304 -- Original game fonts are always present. 305 return true 306 else 307 if not TH.freetype_font then 308 -- CorsixTH compiled without FreeType2 support, so even if suitable font 309 -- file exists, it cannot be loaded or drawn. 310 return false 311 end 312 313 -- TODO: Handle more than one font 314 315 return not not self.ttf_font_data 316 end 317end 318 319--! Font proxy meta table wrapping the C++ class. 320local font_proxy_mt = { 321 __index = { 322 sizeOf = function(self, ...) 323 return self._proxy:sizeOf(...) 324 end, 325 draw = function(self, ...) 326 return self._proxy:draw(...) 327 end, 328 drawWrapped = function(self, ...) 329 return self._proxy:drawWrapped(...) 330 end, 331 drawTooltip = function(self, ...) 332 return self._proxy:drawTooltip(...) 333 end, 334 } 335} 336 337function Graphics:onChangeLanguage() 338 -- Some fonts might need changing between bitmap and freetype 339 local load_info = self.load_info 340 self.load_info = {} -- Any newly made objects are temporary, and shouldn't 341 -- remember reload information (also avoids insertions 342 -- into a table being iterated over). 343 for object, info in pairs(load_info) do 344 if object._proxy then 345 local fn = info[1] 346 local new_object = fn(unpack(info, 2)) 347 object._proxy = new_object._proxy 348 end 349 end 350 self.load_info = load_info 351end 352 353--! Font reload function. 354--!param font The font to (force) reloading. 355local function font_reloader(font) 356 font:clearCache() 357end 358 359--! Utility function to return preferred font for main menu ui 360function Graphics:loadMenuFont() 361 local font 362 if self.language_font then 363 font = self:loadFont("QData", "Font01V") 364 else 365 font = self:loadBuiltinFont() 366 end 367 return font 368end 369 370function Graphics:loadLanguageFont(name, sprite_table, ...) 371 local font 372 if name == nil then 373 font = self:loadFont(sprite_table, ...) 374 else 375 local cache = self.cache.language_fonts[name] 376 font = cache and cache[sprite_table] 377 if not font then 378 font = TH.freetype_font() 379 -- TODO: Choose face based on "name" rather than always using same face. 380 font:setFace(self.ttf_font_data) 381 font:setSheet(sprite_table) 382 self.reload_functions_last[font] = font_reloader 383 384 if not cache then 385 cache = {} 386 self.cache.language_fonts[name] = cache 387 end 388 cache[sprite_table] = font 389 end 390 end 391 self.load_info[font] = {self.loadLanguageFont, self, name, sprite_table, ...} 392 return font 393end 394 395function Graphics:loadFont(sprite_table, x_sep, y_sep, ...) 396 -- Allow (multiple) arguments for loading a sprite table in place of the 397 -- sprite_table argument. 398 -- TODO: Native number support for e.g. Korean languages. Current use of load_font is a stopgap solution for #1193 and should be eventually removed 399 local load_font = x_sep 400 if type(sprite_table) == "string" then 401 local arg = {sprite_table, x_sep, y_sep, ...} 402 local n_pass_on_args = #arg 403 for i = 2, #arg do 404 if type(arg[i]) == "number" then -- x_sep 405 n_pass_on_args = i - 1 406 break 407 end 408 end 409 sprite_table = self:loadSpriteTable(unpack(arg, 1, n_pass_on_args)) 410 if n_pass_on_args < #arg then 411 x_sep, y_sep = unpack(arg, n_pass_on_args + 1, #arg) 412 else 413 x_sep, y_sep = nil, nil 414 end 415 end 416 417 local use_bitmap_font = true 418 -- Force bitmap font for the moneybar (Font05V) 419 if not sprite_table:isVisible(46) or load_font == "Font05V" then -- uppercase M 420 -- The font doesn't contain an uppercase M, so (in all likelihood) is used 421 -- for drawing special symbols rather than text, so the original bitmap 422 -- font should be used. 423 elseif self.language_font then 424 use_bitmap_font = false 425 end 426 local font 427 if use_bitmap_font then 428 font = TH.bitmap_font() 429 font:setSeparation(x_sep or 0, y_sep or 0) 430 font:setSheet(sprite_table) 431 else 432 font = self:loadLanguageFont(self.language_font, sprite_table) 433 end 434 -- A change of language might cause the font to change between bitmap and 435 -- freetype, so wrap it in a proxy object which allows the actual object to 436 -- be changed easily. 437 font = setmetatable({_proxy = font}, font_proxy_mt) 438 self.load_info[font] = {self.loadFont, self, sprite_table, x_sep, y_sep, ...} 439 return font 440end 441 442function Graphics:loadAnimations(dir, prefix) 443 if self.cache.anims[prefix] then 444 return self.cache.anims[prefix] 445 end 446 447 --! Load a custom animation file (if it can be found) 448 --!param path Path to the file. 449 local function loadCustomAnims(path) 450 local file, err = io.open(path, "rb") 451 if not file then 452 return nil, err 453 end 454 local data = file:read"*a" 455 file:close() 456 return data 457 end 458 459 local sheet = self:loadSpriteTable(dir, prefix .. "Spr-0") 460 local anims = TH.anims() 461 anims:setSheet(sheet) 462 if not anims:load( 463 self.app:readDataFile(dir, prefix .. "Start-1.ani"), 464 self.app:readDataFile(dir, prefix .. "Fra-1.ani"), 465 self.app:readDataFile(dir, prefix .. "List-1.ani"), 466 self.app:readDataFile(dir, prefix .. "Ele-1.ani")) 467 then 468 error("Cannot load original animations " .. prefix) 469 end 470 471 if self.custom_graphics_folder and self.custom_graphics.file_mapping then 472 for _, fname in pairs(self.custom_graphics.file_mapping) do 473 anims:setCanvas(self.target) 474 local data, err = loadCustomAnims(self.custom_graphics_folder .. fname) 475 if not data then 476 print("Error when loading custom animations:\n" .. err) 477 elseif not anims:loadCustom(data) then 478 print("Warning: custom animations loading failed") 479 end 480 end 481 end 482 483 self.cache.anims[prefix] = anims 484 self.load_info[anims] = {self.loadAnimations, self, dir, prefix} 485 return anims 486end 487 488function Graphics:loadSpriteTable(dir, name, complex, palette) 489 local cached = self.cache.tabled[name] 490 if cached then 491 return cached 492 end 493 494 local function sheet_reloader(sheet) 495 sheet:setPalette(palette or self:loadPalette()) 496 local data_tab, data_dat 497 data_tab = self.app:readDataFile(dir, name .. ".tab") 498 data_dat = self.app:readDataFile(dir, name .. ".dat") 499 if not sheet:load(data_tab, data_dat, complex, self.target) then 500 error("Cannot load sprite sheet " .. dir .. ":" .. name) 501 end 502 end 503 local sheet = TH.sheet() 504 self.reload_functions[sheet] = sheet_reloader 505 sheet_reloader(sheet) 506 507 if name ~= "SPointer" then 508 self.cache.tabled[name] = sheet 509 end 510 self.load_info[sheet] = {self.loadSpriteTable, self, dir, name, complex, palette} 511 return sheet 512end 513 514function Graphics:updateTarget(target) 515 self.target = target 516 for _, res_set in ipairs({"reload_functions", "reload_functions_last"}) do 517 for resource, reloader in pairs(self[res_set]) do 518 reloader(resource) 519 end 520 end 521end 522 523--! Utility class for setting animation markers and querying animation length. 524class "AnimationManager" 525 526---@type AnimationManager 527local AnimationManager = _G["AnimationManager"] 528 529function AnimationManager:AnimationManager(anims) 530 self.anim_length_cache = {} 531 self.anims = anims 532end 533 534--! For overriding animations which have builtin repeats or excess frames 535function AnimationManager:setAnimLength(anim, length) 536 self.anim_length_cache[anim] = length 537end 538 539function AnimationManager:getAnimLength(anim) 540 local anims = self.anims 541 if not self.anim_length_cache[anim] then 542 local length = 0 543 local seen = {} 544 local frame = anims:getFirstFrame(anim) 545 while not seen[frame] do 546 seen[frame] = true 547 length = length + 1 548 frame = anims:getNextFrame(frame) 549 end 550 self.anim_length_cache[anim] = length 551 end 552 return self.anim_length_cache[anim] 553end 554 555--[[ Markers can be set using a variety of different arguments: 556 setMarker(anim_number, position) 557 setMarker(anim_number, start_position, end_position) 558 setMarker(anim_number, keyframe_1, keyframe_1_position, keyframe_2, ...) 559 560 position should be a table; {x, y} for a tile position, {x, y, "px"} for a 561 pixel position, with (0, 0) being the origin in both cases. 562 563 The first variant of setMarker sets the same marker for each frame. 564 The second variant does linear interpolation of the two positions between 565 the first frame and the last frame. 566 The third variant does linear interpolation between keyframes, and then the 567 final position for frames after the last keyframe. The keyframe arguments 568 should be 0-based integers, as in the animation viewer. 569 570 To set the markers for multiple animations at once, the anim_number argument 571 can be a table, in which case the marker is set for all values in the table. 572 Alternatively, the values function (defined in utility.lua) can be used in 573 conjection with a for loop to set markers for multiple things. 574--]] 575 576function AnimationManager:setMarker(anim, ...) 577 return self:setMarkerRaw(anim, "setFrameMarker", ...) 578end 579 580local function TableToPixels(t) 581 if t[3] == "px" then 582 return t[1], t[2] 583 else 584 local x, y = Map:WorldToScreen(t[1] + 1, t[2] + 1) 585 return math.floor(x), math.floor(y) 586 end 587end 588 589function AnimationManager:setMarkerRaw(anim, fn, arg1, arg2, ...) 590 if type(anim) == "table" then 591 for _, val in pairs(anim) do 592 self:setMarkerRaw(val, fn, arg1, arg2, ...) 593 end 594 return 595 end 596 local tp_arg1 = type(arg1) 597 local anim_length = self:getAnimLength(anim) 598 local anims = self.anims 599 local frame = anims:getFirstFrame(anim) 600 if tp_arg1 == "table" then 601 if arg2 then 602 -- Linear-interpolation positions 603 local x1, y1 = TableToPixels(arg1) 604 local x2, y2 = TableToPixels(arg2) 605 for i = 0, anim_length - 1 do 606 local n = math.floor(i / (anim_length - 1)) 607 anims[fn](anims, frame, (x2 - x1) * n + x1, (y2 - y1) * n + y1) 608 frame = anims:getNextFrame(frame) 609 end 610 else 611 -- Static position 612 local x, y = TableToPixels(arg1) 613 for _ = 1, anim_length do 614 anims[fn](anims, frame, x, y) 615 frame = anims:getNextFrame(frame) 616 end 617 end 618 elseif tp_arg1 == "number" then 619 -- Keyframe positions 620 local f1, x1, y1 = 0, 0, 0 621 local args 622 if arg1 == 0 then 623 x1, y1 = TableToPixels(arg2) 624 args = {...} 625 else 626 args = {arg1, arg2, ...} 627 end 628 local f2, x2, y2 629 local args_i = 1 630 for f = 0, anim_length - 1 do 631 if f2 and f == f2 then 632 f1, x1, y1 = f2, x2, y2 633 f2, x2, y2 = nil, nil, nil 634 end 635 if not f2 then 636 f2 = args[args_i] 637 if f2 then 638 x2, y2 = TableToPixels(args[args_i + 1]) 639 args_i = args_i + 2 640 end 641 end 642 if f2 then 643 local n = math.floor((f - f1) / (f2 - f1)) 644 anims[fn](anims, frame, (x2 - x1) * n + x1, (y2 - y1) * n + y1) 645 else 646 anims[fn](anims, frame, x1, y1) 647 end 648 frame = anims:getNextFrame(frame) 649 end 650 elseif tp_arg1 == "string" then 651 error("TODO") 652 else 653 error("Invalid arguments to setMarker", 2) 654 end 655end 656