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