-
Notifications
You must be signed in to change notification settings - Fork 354
/
world.lua
2838 lines (2579 loc) · 101 KB
/
world.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--[[ Copyright (c) 2009 Peter "Corsix" Cawley
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. --]]
local TH = require("TH")
local ipairs, _G, table_remove
= ipairs, _G, table.remove
corsixth.require("entities.humanoids.patient")
corsixth.require("entities.humanoids.staff.doctor")
corsixth.require("entities.humanoids.staff.nurse")
corsixth.require("entities.humanoids.staff.handyman")
corsixth.require("entities.humanoids.staff.receptionist")
corsixth.require("entities.humanoids.vip")
corsixth.require("entities.humanoids.grim_reaper")
corsixth.require("entities.humanoids.inspector")
corsixth.require("staff_profile")
corsixth.require("hospital")
corsixth.require("hospitals.player_hospital")
corsixth.require("hospitals.ai_hospital")
corsixth.require("cheats")
corsixth.require("epidemic")
corsixth.require("calls_dispatcher")
corsixth.require("research_department")
corsixth.require("entity_map")
corsixth.require("date")
corsixth.require("announcer")
corsixth.require("endconditions")
local AnnouncementPriority = _G["AnnouncementPriority"]
--! Manages entities, rooms, and the date.
class "World"
---@type World
local World = _G["World"]
-- time between each damage caused by an earthquake
local earthquake_damage_time = 16 -- hours
local earthquake_warning_period = 600 -- hours between warning and real thing
local earthquake_warning_length = 25 -- length of early warning quake
function World:World(app)
self.app = app
self.map = app.map
self.wall_types = app.walls
self.object_types = app.objects
self.anims = app.anims
self.animation_manager = app.animation_manager
self.pathfinder = TH.pathfinder()
self.pathfinder:setMap(app.map.th)
self.entities = {} -- List of entities in the world.
self.dispatcher = CallsDispatcher(self)
self.objects = {}
self.objects_notify_occupants = {}
self.rooms = {} -- List that can have gaps when a room is deleted, so use pairs to iterate.
self.entity_map = EntityMap(self.map)
-- All information relating to the next or current earthquake, nil if
-- there is no scheduled earthquake.
-- Contains the following fields:
-- active (boolean) Whether we are currently running the warning or damage timers (after start_day of start_month is passed).
-- start_month (integer) The month the earthquake warning is triggered.
-- start_day (integer) The day of the month the earthquake warning is triggered.
-- size (integer) The amount of damage the earthquake causes (1-9).
-- remaining_damage (integer) The amount of damage this earthquake has yet to inflict.
-- damage_timer (integer) The number of hours until the earthquake next inflicts damage if active.
-- warning_timer (integer) The number of hours left until the real damaging earthquake begins.
self.next_earthquake = { active = false }
-- Time
self.hours_per_tick = 2
self.tick_rate = 3
self.tick_timer = 0
self.game_date = Date() -- Current date in the game.
self.room_information_dialogs = app.config.room_information_dialogs
-- This is false when the game is paused.
self.user_actions_allowed = true
-- The system pause method is used as an additional layer to pause the game, where the user
-- needs to deal with a recoverable error
self.system_pause = false
-- In Free Build mode?
if tonumber(self.map.level_number) then
self.free_build_mode = false
else
self.free_build_mode = app.config.free_build_mode
end
-- If set, do not create salary raise requests.
self.debug_disable_salary_raise = self.free_build_mode
self.idle_cache = {} -- Cached queue standing positions for all queues.
self.delayed_map_objects = {} -- Initial objects in the map for parcels without owner.
self.room_remove_callbacks = {--[[a set rather than a list]]}
self.room_built = {} -- List of room types that have been built
self.hospitals = {}
self.floating_dollars = {}
self.game_log = {} -- saves list of useful debugging information
self.savegame_version = app.savegame_version -- Savegame version number
self.release_version = app:getVersion(self.savegame_version) -- Savegame release version (e.g. 0.60), or Trunk
-- Also preserve this throughout future updates.
self.original_savegame_version = app.savegame_version
-- Initialize available rooms.
local avail_rooms = self:getAvailableRooms()
self.available_rooms = {} -- Both a list and a set, use ipairs to iterate through the available rooms.
for _, avail_room in ipairs(avail_rooms) do
local room = avail_room.room
self.available_rooms[#self.available_rooms + 1] = room
self.available_rooms[room.id] = room
end
-- Initialize available diseases and winning conditions.
self:initLevel(app, avail_rooms)
-- Construct hospitals.
self.hospitals[1] = PlayerHospital(self, avail_rooms, app.config.player_name)
-- Add computer players
-- TODO: Right now they're only names
local level_config = self.map.level_config
for key, value in pairs(level_config.computer) do
if value.Playing == 1 then
self.hospitals[#self.hospitals + 1] = AIHospital(tonumber(key) + 1,
self, avail_rooms, value.Name)
end
end
-- Setup research.
for _, hospital in ipairs(self.hospitals) do
hospital.research:setResearchConcentration()
end
self:updateInitialsCache()
-- TODO: Add (working) AI and/or multiplayer hospitals
-- TODO: Needs to be changed for multiplayer support
self.hospitals[1]:initStaff()
self:initWallTypes()
self.object_id_by_thob = {}
for _, object_type in ipairs(self.object_types) do
self.object_id_by_thob[object_type.thob] = object_type.id
end
self:makeAvailableStaff(0)
self.spawn_points = self:calculateSpawnTiles()
-- Next Events dates
-- emergencies
-- The emergency control level data starts with an array of 0
self.next_emergency_no = 0
self:nextEmergency()
-- vip
self.next_vip_date = self:_generateNextVipDate()
-- earthquakes
-- current_map_earthquakes is a counter that tracks which number of earthquake
-- we are currently on in maps which have information for earthquakes in them
self.current_map_earthquake = 0
self:nextEarthquake()
-- Set initial spawn rate in people per month.
-- Assumes that the first entry is always the first month.
self.spawn_rate = self.map.level_config.popn[0].Change
self.monthly_spawn_increase = self.spawn_rate
self.spawn_hours = {}
self.spawn_dates = {}
self:updateSpawnDates()
self.cheat_announcements = {
"cheat001.wav", "cheat002.wav", "cheat003.wav",
}
self:gameLog("Created game with savegame version " .. self.savegame_version .. ".")
end
--! Register key shortcuts for controlling the world (game speed, etc.)
function World:setUI(ui)
self.ui = ui
self.ui:addKeyHandler("ingame_pause", self, self.pauseOrUnpause, "Pause")
self.ui:addKeyHandler("ingame_gamespeed_slowest", self, self.setSpeed, "Slowest")
self.ui:addKeyHandler("ingame_gamespeed_slower", self, self.setSpeed, "Slower")
self.ui:addKeyHandler("ingame_gamespeed_normal", self, self.setSpeed, "Normal")
self.ui:addKeyHandler("ingame_gamespeed_max", self, self.setSpeed, "Max speed")
self.ui:addKeyHandler("ingame_gamespeed_thensome", self, self.setSpeed, "And then some more")
self.ui:addKeyHandler("ingame_zoom_in", self, self.adjustZoom, 1)
self.ui:addKeyHandler("ingame_zoom_in_more", self, self.adjustZoom, 5)
self.ui:addKeyHandler("ingame_zoom_out", self, self.adjustZoom, -1)
self.ui:addKeyHandler("ingame_zoom_out_more", self, self.adjustZoom, -5)
self.ui:addKeyHandler("ingame_reset_zoom", self, self.resetZoom)
end
function World:adjustZoom(delta)
local scr_w = self.ui.app.config.width
local factor = self.ui.app.config.zoom_speed
local virtual_width = scr_w / (self.ui.zoom_factor or 1)
-- The modifier is a normal distribution to make it more difficult to zoom at the extremes
local modifier = math.exp(-((self.ui.zoom_factor - 1) ^ 2) / 2) / math.sqrt(2 * math.pi)
if modifier < 0.05 or modifier > 1 then
modifier = 0.05
end
virtual_width = virtual_width - delta * factor * modifier
if virtual_width < 200 then
return false
end
return self.ui:setZoom(scr_w / virtual_width)
end
function World:resetZoom()
return self.ui:setZoom(1)
end
--! Initialize the game level (available diseases, winning conditions).
--!param app Game application.
--!param avail_rooms (list) Available rooms in the level.
function World:initLevel(app, avail_rooms)
local existing_rooms = {}
for _, avail_room in ipairs(avail_rooms) do
existing_rooms[avail_room.room.id] = true
end
-- Determine available diseases
self.available_diseases = {}
local level_config = self.map.level_config
local visual = level_config.visuals
local non_visual = level_config.non_visuals
for _, disease in ipairs(app.diseases) do
if not disease.pseudo then
local vis_id = disease.visuals_id
local nonvis_id = disease.non_visuals_id
local vis = 1
if visual and (visual[vis_id] or non_visual[nonvis_id]) then
vis = vis_id and visual[vis_id].Value or non_visual[nonvis_id].Value
end
if vis ~= 0 then
for _, room_id in ipairs(disease.treatment_rooms) do
if existing_rooms[room_id] == nil then
print("Warning: Removing disease \"" .. disease.id ..
"\" due to missing treatment room \"" .. room_id .. "\".")
vis = 0 -- Missing treatment room, disease cannot be treated. Remove it.
break
end
end
end
-- TODO: Where the value is greater that 0 should determine the frequency of the patients
if vis ~= 0 then
self.available_diseases[#self.available_diseases + 1] = disease
self.available_diseases[disease.id] = disease
end
end
end
if #self.available_diseases == 0 and self.map.level_number ~= "MAP EDITOR" then
-- No diseases are needed if we're actually in the map editor!
print("Warning: This level does not contain any diseases")
end
self:determineWinningConditions()
end
function World:toggleInformation()
self.room_information_dialogs = not self.room_information_dialogs
end
--! In EndConditions, collect and test the win and lose conditions of the level.
function World:determineWinningConditions()
self.endconditions = EndConditions(self.map.level_config,
self.map.level_number, self.free_build_mode)
end
--! Find the rooms available at the level.
--!return (list) Available rooms, with discovery state at start, and build_cost.
function World:getAvailableRooms()
local avail_rooms = {}
local cfg_objects = self.map.level_config.objects
local cfg_rooms = self.map.level_config.rooms
for _, room in ipairs(TheApp.rooms) do
-- Add build cost based on level files for all rooms.
-- For now, sum it up so that the result is the same as before.
-- TODO: Change the whole build process so that this value is
-- the room cost only? (without objects)
local build_cost = cfg_rooms[room.level_config_id].Cost
local available = true
local is_discovered = true
-- Make sure that all objects needed for this room are available
for name, no in pairs(room.objects_needed) do
local spec = cfg_objects[TheApp.objects[name].thob]
if spec.AvailableForLevel == 0 then
-- It won't be possible to build this room at all on the level.
available = false
elseif spec.StartAvail == 0 then
-- Ok, it will be available at some point just not from the beginning.
is_discovered = false
end
-- Add cost for this object.
build_cost = build_cost + cfg_objects[TheApp.objects[name].thob].StartCost * no
end
if available then
avail_rooms[#avail_rooms + 1] = {room = room, is_discovered = is_discovered, build_cost = build_cost}
end
end
return avail_rooms
end
--! Get the hospital controlled by the (single) player.
--!return (Hospital) The hospital controlled by the (single) player.
function World:getLocalPlayerHospital()
-- NB: UI code can get the hospital to use via ui.hospital
-- TODO: Make this work in multiplayer?
return self.hospitals[1]
end
--! Identify the tiles on the map suitable for spawning `Humanoid`s from.
--!return (table) 0-8 spawn points of the map.
function World:calculateSpawnTiles()
local spawn_points = {}
local w, h = self.map.width, self.map.height
local directions = {
{direction = "north", origin = {1, 1}, step = { 1, 0}},
{direction = "east" , origin = {w, 1}, step = { 0, 1}},
{direction = "south", origin = {w, h}, step = {-1, 0}},
{direction = "west" , origin = {1, h}, step = { 0, -1}},
}
for _, edge in ipairs(directions) do
-- Find all possible spawn points on the edge
local xs = {}
local ys = {}
local x, y = edge.origin[1], edge.origin[2]
repeat
if self.pathfinder:isReachableFromHospital(x, y) then
xs[#xs + 1] = x
ys[#ys + 1] = y
end
x = x + edge.step[1]
y = y + edge.step[2]
until x < 1 or x > w or y < 1 or y > h
-- Choose at most 8 points for the edge
local num = math.min(8, #xs)
for i = 1, num do
local index = math.floor((i - 0.5) / num * #xs + 1)
spawn_points[#spawn_points + 1] = {x = xs[index], y = ys[index], direction = edge.direction}
end
end
return spawn_points
end
--! Function to determine whether a given disease is available for new patients.
--!param self (World) World object.
--!param disease (disease) Disease to test.
--!param hospital (Hospital) Hospital that needs a new patient.
--!return (boolean) Whether the disease is usable for new spawned patients.
local function isDiseaseUsableForNewPatient(self, disease, hospital)
if disease.only_emergency then return false end
local level_config = self.map.level_config
if disease.visuals_id then
-- level files can delay visuals to a given month
-- and / or until a given number of patients have arrived
local hold_visual_months = level_config.gbv.HoldVisualMonths
local hold_visual_peep_count = level_config.gbv.HoldVisualPeepCount
-- if the month is greater than either of these values then visuals will not appear in the game
if (hold_visual_months and hold_visual_months > self.game_date:monthOfGame()) or
(hold_visual_peep_count and hold_visual_peep_count > hospital.num_visitors) then
return false
end
-- The value against #visuals_available determines from which month a disease can appear.
-- 0 means it can show up anytime.
return level_config.visuals_available[disease.visuals_id].Value < self.game_date:monthOfGame()
elseif disease.non_visuals_id then
-- Non-visual disease. Simply check the month it can appear from has passed
-- 0 means it can show up anytime.
return level_config.non_visuals_available[disease.non_visuals_id].Value < self.game_date:monthOfGame()
end
error("disease has neither visuals_id or non_visuals_id")
end
--! Private function to assess the shortest path between all spawn points and desks
--!param staffed_desks (table) All staffed reception desks
--!param spawns (table) All available spawn points
--!return Optimal spawn point to the nearest reception desk or false on failure
function World:_findClosestSpawnToDesk(staffed_desks, spawns)
local best_spawn_point, distance
local function testNewSpawnPath(desk, spawn)
local new_dist = self:getPathDistance(desk.tile_x, desk.tile_y, spawn.x, spawn.y)
if not new_dist then return end
if not distance or new_dist < distance then
distance = new_dist
best_spawn_point = spawn
end
end
for _, spawn in ipairs(spawns) do
for _, desk in ipairs(staffed_desks) do
testNewSpawnPath(desk, spawn)
end
end
if not best_spawn_point then
local msg =
"Warning: Path not found between any spawn point and staffed desk. " ..
"Humanoid will still likely find its own way."
self:gameLog(msg)
return false
end
return best_spawn_point
end
--! Spawn a patient from a spawn point for the given hospital.
--!param hospital (Hospital) Hospital that the new patient should visit.
--!return (Patient entity) The spawned patient, or 'nil' if no patient spawned.
function World:spawnPatient(hospital)
if not hospital then
hospital = self:getLocalPlayerHospital()
end
-- The level might not contain any diseases
if #self.available_diseases < 1 then
self.ui:addWindow(UIInformation(self.ui, {"There are no diseases on this level! Please add some to your level."}))
return
end
if #self.spawn_points == 0 then
self.ui:addWindow(UIInformation(self.ui, {"Could not spawn patient because no spawn points are available. Please place walkable tiles on the edge of your level."}))
return
end
if not hospital:hasStaffedDesk() then return nil end
-- Construct disease, take a random guess first, as a quick clear-sky attempt.
local disease = self.available_diseases[math.random(1, #self.available_diseases)]
if not isDiseaseUsableForNewPatient(self, disease, hospital) then
-- Lucky shot failed, do a proper calculation.
local usable_diseases = {}
for _, d in ipairs(self.available_diseases) do
if isDiseaseUsableForNewPatient(self, d, hospital) then
usable_diseases[#usable_diseases + 1] = d
end
end
if #usable_diseases == 0 then return nil end
disease = usable_diseases[math.random(1, #usable_diseases)]
end
-- Construct patient.
local spawns = self.spawn_points
-- Let the first patient to the player use the shortest path to a reception desk
local spawn_point = hospital:isPlayerHospital() and not hospital:hadPatients() and
self:_findClosestSpawnToDesk(hospital:getStaffedDesks(), spawns) or
spawns[math.random(1, #spawns)]
local patient = self:newEntity("Patient", 2)
patient:setDisease(disease)
patient:setNextAction(SpawnAction("spawn", spawn_point))
patient:setHospital(hospital)
return patient
end
--A VIP is invited (or he invited himself) to the player hospital.
--!param name Name of the VIP
function World:spawnVIP(name)
local hospital = self:getLocalPlayerHospital()
local vip = self:newEntity("Vip", 2)
vip:setType("VIP")
vip.name = name
vip.enter_deaths = hospital.num_deaths
vip.enter_visitors = hospital.num_visitors
vip.enter_cures = hospital.num_cured
vip.enter_patients = #hospital.patients
-- VIP's room visit chance is 50% if total rooms in hospital is less than 80 (makes a math.random with 0 and 1 possibilities).
-- Else decided by total rooms / 40 (0, 1, 2 [33%]; 0, 1, 2, 3 [25%] etc)
local rooms_threshold = 79
if #self.rooms > rooms_threshold then
vip.room_visit_chance = math.floor(#self.rooms / 40)
end
local spawn_point = self.spawn_points[math.random(1, #self.spawn_points)]
vip:setNextAction(SpawnAction("spawn", spawn_point))
vip:setHospital(hospital)
vip:updateDynamicInfo()
hospital:onSpawnVIP()
vip:queueAction(SeekReceptionAction())
end
--! Perform actions to simulate an active earthquake.
function World:tickEarthquake()
if self:isCurrentSpeed("Pause") then return end
-- check if this is the day that the earthquake is supposed to stop
if self.next_earthquake.remaining_damage == 0 then
self.next_earthquake.active = false
self.ui:endShakeScreen()
-- if the earthquake measured more than 7 on the richter scale, tell the user about it
if self.next_earthquake.size > 7 then
self.ui.adviser:say(_A.earthquake.ended:format(math.floor(self.next_earthquake.size)))
end
-- set up the next earthquake date
self:nextEarthquake()
else
local announcements = {
"quake001.wav", "quake002.wav", "quake003.wav", "quake004.wav",
}
-- start of warning quake
if self.next_earthquake.warning_timer == earthquake_warning_period then
self.ui:beginShakeScreen(0.2)
self.ui:playAnnouncement(announcements[math.random(1, #announcements)], AnnouncementPriority.Critical)
end
-- end of warning quake
if self.next_earthquake.warning_timer >= earthquake_warning_period - earthquake_warning_length and
self.next_earthquake.warning_timer - self.hours_per_tick < earthquake_warning_period - earthquake_warning_length then
self.ui:endShakeScreen()
end
if self.next_earthquake.warning_timer > 0 then
self.next_earthquake.warning_timer = self.next_earthquake.warning_timer - self.hours_per_tick
-- nothing more to do during inactive warning period
if self.next_earthquake.warning_timer < earthquake_warning_period - earthquake_warning_length then
return
end
-- start of real earthquake
if self.next_earthquake.warning_timer <= 0 then
self.ui:playAnnouncement(announcements[math.random(1, #announcements)], AnnouncementPriority.Critical)
end
end
-- All earthquakes start and end small (small earthquakes never become
-- larger), so when there has been less than 2 damage applied or only
-- 2 damage remaining to be applied, move the screen with less
-- intensity than otherwise.
if self.next_earthquake.remaining_damage <= 2 or
self.next_earthquake.size - self.next_earthquake.remaining_damage <= 2 then
self.ui:beginShakeScreen(0.5)
else
self.ui:beginShakeScreen(1)
end
-- Play the earthquake sound. It has different names depending on language used though.
if TheApp.audio:soundExists("quake2.wav") then
self.ui:playSound("quake2.wav")
else
self.ui:playSound("quake.wav")
end
-- do not continue to damage phase while in a warning quake
if self.next_earthquake.warning_timer > 0 then
return
end
self.next_earthquake.damage_timer = self.next_earthquake.damage_timer - self.hours_per_tick
if self.next_earthquake.damage_timer <= 0 then
for _, room in pairs(self.rooms) do
for object, _ in pairs(room.objects) do
if object.strength then
object:machineUsed(room)
end
end
end
self.next_earthquake.remaining_damage = self.next_earthquake.remaining_damage - 1
self.next_earthquake.damage_timer = self.next_earthquake.damage_timer + earthquake_damage_time
end
--[[
local hospital = self:getLocalPlayerHospital()
-- loop through the patients and allow the possibility for them to fall over
for _, patient in ipairs(hospital.patients) do
if not patient.in_room and patient.falling_anim then
-- make the patients fall
-- jpirie: this is currently disabled. Calling this function
-- really screws up the action queue, sometimes the patients
-- end up with nil action queues, and sometimes the resumed
-- actions throw exceptions. Also, patients in the hospital
-- who have not yet found reception throw exceptions after
-- they visit reception. Some debugging needed here to get
-- this working.
patient:falling()
end
end
--]]
end
end
--! Enable or disable salary raise events.
--!param mode (boolean) If true, do not create salary raise events.
function World:debugDisableSalaryRaise(mode)
self.debug_disable_salary_raise = mode
end
local staff_to_make = {
{class = "Doctor", name = "doctor", conf = "Doctors" },
{class = "Nurse", name = "nurse", conf = "Nurses" },
{class = "Handyman", name = "handyman", conf = "Handymen" },
{class = "Receptionist", name = "receptionist", conf = "Receptionists"},
}
function World:makeAvailableStaff(month)
local conf_entry = 0
local cfg_staff_levels = self.map.level_config.staff_levels
while cfg_staff_levels[conf_entry + 1] and cfg_staff_levels[conf_entry + 1].Month <= month do
conf_entry = conf_entry + 1
end
self.available_staff = {}
for _, info in ipairs(staff_to_make) do
local num
local ind = conf_entry
while not num do
assert(ind >= 0, "Staff amount " .. info.conf .. " not existent (should at least be given by base_config).")
num = cfg_staff_levels[ind][info.conf]
ind = ind - 1
end
local group = {}
for i = 1, num do
group[i] = StaffProfile(self, info.class, _S.staff_class[info.name])
group[i]:randomise(month)
end
self.available_staff[info.class] = group
end
end
--[[ Register a callback for when `Humanoid`s enter or leave a given tile.
! Note that only one callback may be registered to each tile.
!param x (integer) The 1-based X coordinate of the tile to monitor.
!param y (integer) The 1-based Y coordinate of the tile to monitor.
!param object (Object) Something with an `onOccupantChange` method, which will
be called whenever a `Humanoid` enters or leaves the given tile. The method
will receive one argument (after `self`), which will be `1` for an enter event
and `-1` for a leave event.
]]
function World:notifyObjectOfOccupants(x, y, object)
local idx = (y - 1) * self.map.width + x
self.objects_notify_occupants[idx] = object or nil
end
function World:getObjectToNotifyOfOccupants(x, y)
local idx = (y - 1) * self.map.width + x
return self.objects_notify_occupants[idx]
end
--! Place objects from a map file onto the map.
--!param objects Objects to place.
function World:createMapObjects(objects)
self.delayed_map_objects = {}
for _, object in ipairs(objects) do
self:_createMapObject(object)
end
end
local flag_cache = {}
--! Internal function for placing an object from the map file.
--!param object Object to place.
function World:_createMapObject(object)
local x, y, thob, flags = unpack(object)
local object_id = self.object_id_by_thob[thob]
if not object_id then
print("Warning: Map contained object with unrecognised THOB (" .. thob
.. ") at " .. x .. "," .. y)
return
end
local object_type = self.object_types[object_id]
if not object_type or not object_type.supports_creation_for_map then
print("Warning: Unable to create map object " .. object_id .. " at "
.. x .. "," .. y)
return
end
local map = self.map.th
local parcel = map:getCellFlags(x, y, flag_cache).parcelId
if parcel ~= 0 and map:getPlotOwner(parcel) == 0 then
-- Delay making objects which are on plots which haven't been purchased yet
self.delayed_map_objects[{object_id, x, y, flags, "map object"}] = parcel
else
self:newObject(object_id, x, y, flags, "map object")
end
end
--! Change owner of a plot.
--!param parcel (int) Plot to change.
--!param owner (int) New owner (may be 0).
function World:setPlotOwner(parcel, owner)
self.map:setPlotOwner(parcel, owner)
if owner ~= 0 and self.delayed_map_objects then
for info, p in pairs(self.delayed_map_objects) do
if p == parcel then
self:newObject(unpack(info))
self.delayed_map_objects[info] = nil
end
end
end
self.map.th:updateShadows()
end
function World:getAnimLength(anim)
return self.animation_manager:getAnimLength(anim)
end
-- Register a function to be called whenever a room has been deactivated (crashed or edited).
--!param callback (function) A function taking one argument: a `Room`.
function World:registerRoomRemoveCallback(callback)
self.room_remove_callbacks[callback] = true
end
-- Unregister a function from being called whenever a room has been deactivated (crashed or edited).
--!param callback (function) A function previously passed to
-- `registerRoomRemoveCallback`.
function World:unregisterRoomRemoveCallback(callback)
self.room_remove_callbacks[callback] = nil
end
function World:newRoom(x, y, w, h, room_info, ...)
local id = #self.rooms + 1
-- Note: Room IDs will be unique, but they may not form continuous values
-- from 1, as IDs of deleted rooms may not be re-issued for a while
local class = room_info.class and _G[room_info.class] or Room
local hospital = self:getHospital(x, y)
local room = class(x, y, w, h, id, room_info, self, hospital, ...)
self.rooms[id] = room
self:clearCaches()
return room
end
--! Called when a room has been completely built and is ready to use.
--!param room (Room) The new room.
function World:markRoomAsBuilt(room)
room:roomFinished()
local hosp = room.hospital
local diag_disease = hosp.disease_casebook["diag_" .. room.room_info.id]
if diag_disease and not diag_disease.discovered then
hosp.disease_casebook["diag_" .. room.room_info.id].discovered = true
end
for _, entity in ipairs(self.entities) do
if entity.notifyNewRoom then
entity:notifyNewRoom(room)
end
end
end
--! Called when a room has been deactivated (crashed or edited)
function World:notifyRoomRemoved(room)
self.dispatcher:dropFromQueue(room)
for callback in pairs(self.room_remove_callbacks) do
callback(room)
end
end
--! Clear all internal caches which are dependent upon map state / object position
function World:clearCaches()
self.idle_cache = {}
end
function World:initWallTypes()
self.wall_id_by_block_id = {}
for _, wall_type in ipairs(self.wall_types) do
for _, set in ipairs({"inside_tiles", "outside_tiles", "window_tiles"}) do
for _, id in pairs(wall_type[set]) do
self.wall_id_by_block_id[id] = wall_type.id
end
end
end
self.wall_set_by_block_id = {}
for _, wall_type in ipairs(self.wall_types) do
for _, set in ipairs({"inside_tiles", "outside_tiles", "window_tiles"}) do
for _, id in pairs(wall_type[set]) do
self.wall_set_by_block_id[id] = set
end
end
end
self.wall_dir_by_block_id = {}
for _, wall_type in ipairs(self.wall_types) do
for _, set in ipairs({"inside_tiles", "outside_tiles", "window_tiles"}) do
for name, id in pairs(wall_type[set]) do
self.wall_dir_by_block_id[id] = name
end
end
end
end
function World:getWallIdFromBlockId(block_id)
-- Remove the transparency flag if present.
if self.ui.transparent_walls then
block_id = block_id - 1024
end
return self.wall_id_by_block_id[block_id]
end
function World:getWallSetFromBlockId(block_id)
-- Remove the transparency flag if present.
if self.ui.transparent_walls then
block_id = block_id - 1024
end
return self.wall_set_by_block_id[block_id]
end
function World:getWallDirFromBlockId(block_id)
-- Remove the transparency flag if present.
if self.ui.transparent_walls then
block_id = block_id - 1024
end
return self.wall_dir_by_block_id[block_id]
end
-- Game speeds. The second value is the number of world clicks that pass for each
-- in-game tick and the first is the number of hours to progress when this
-- happens.
local tick_rates = {
["Pause"] = {0, 1},
["Slowest"] = {1, 28}, -- 1 tick per s
["Slower"] = {1, 6}, -- 1 tick per 216ms
["Normal"] = {2, 3}, -- 2 ticks per 108ms = 1 tick per 54 ms
["Max speed"] = {1, 1}, -- 1 tick per 36 ms
["And then some more"] = {2, 1}, -- 2 ticks per 36 ms = 1 tick 18ms
["Speed Up"] = {4, 1},
}
function World:speedUp()
self:setSpeed("Speed Up")
end
function World:previousSpeed()
if self:isCurrentSpeed("Speed Up") then
self:setSpeed(self.prev_speed)
end
end
--! Return true if the given speed the same as the current speed.
function World:isCurrentSpeed(speed)
local numerator, denominator = unpack(tick_rates[speed])
return self.hours_per_tick == numerator and self.tick_rate == denominator
end
--! Return the name of the current speed, relating to a key in tick_rates.
function World:getCurrentSpeed()
for name, rate in pairs(tick_rates) do
if rate[1] == self.hours_per_tick and rate[2] == self.tick_rate then
return name
end
end
end
-- Set the (approximate) number of seconds per tick.
--!param speed (string) One of: "Pause", "Slowest", "Slower", "Normal",
-- "Max speed", or "And then some more".
function World:setSpeed(speed)
if self:isCurrentSpeed(speed) then
return
end
if speed == "Pause" or self.system_pause then
-- stop screen shaking if there was an earthquake in progress
if self.next_earthquake.active then
self.ui:endShakeScreen()
end
-- By default actions are not allowed when the game is paused.
self.user_actions_allowed = TheApp.config.allow_user_actions_while_paused
elseif self:getCurrentSpeed() == "Pause" then
self.user_actions_allowed = true
end
local currentSpeed = self:getCurrentSpeed()
if currentSpeed ~= "Pause" and currentSpeed ~= "Speed Up" then
self.prev_speed = self:getCurrentSpeed()
end
local was_paused = currentSpeed == "Pause"
local numerator, denominator = unpack(tick_rates[speed])
self.hours_per_tick = numerator
self.tick_rate = denominator
if was_paused then
TheApp.audio:onEndPause()
end
-- Set the blue filter according to whether the user can build or not.
TheApp.video:setBlueFilterActive(not self.user_actions_allowed and not self.ui:checkForMustPauseWindows())
return false
end
function World:isPaused()
return self:isCurrentSpeed("Pause")
end
--! Dedicated function to allow unpausing by pressing 'p' again
function World:pauseOrUnpause()
if self:isSystemPauseActive() then return end -- System pause takes precedence
if not self:isCurrentSpeed("Pause") then
self:setSpeed("Pause")
elseif self.prev_speed then
self:setSpeed(self.prev_speed)
end
end
--! Sets the system_pause parameter
--!param state (bool)
function World:setSystemPause(state)
self.system_pause = state
end
--! Reports the system pause status
--!return (bool) true is system pause is active, else false
function World:isSystemPauseActive()
return self.system_pause
end
--! Function to check if player can perform actions when paused
--!return (bool) Returns true if player hasn't allowed editing while paused
function World:isUserActionProhibited()
if self:isSystemPauseActive() then return true end
return self:isCurrentSpeed("Pause") and not self.user_actions_allowed
end
-- Outside (air) temperatures based on climate data for Oxford, taken from
-- Wikipedia. For scaling, 0 degrees C becomes 0 and 50 degrees C becomes 1
local outside_temperatures = {
4.1 / 50, -- January
4.4 / 50, -- February
6.3 / 50, -- March
8.65 / 50, -- April
11.95 / 50, -- May
15 / 50, -- June
16.95 / 50, -- July
16.55 / 50, -- August
14.15 / 50, -- September
10.5 / 50, -- October
6.8 / 50, -- November
4.75 / 50, -- December
}
local start_date = Date()
--! World ticks are translated to game ticks (or hours) depending on the
-- current speed of the game. There are 50 hours in a TH day.
function World:onTick()
if self.map.level_number == "MAP EDITOR" then return end
if self.tick_timer == 0 then
if self.autosave_next_tick then
self.autosave_next_tick = nil
local pathsep = package.config:sub(1, 1)
local dir = TheApp.savegame_dir
if dir:sub(-1, -1) ~= pathsep then
dir = dir .. pathsep
end
if not lfs.attributes(dir .. "Autosaves", "modification") then
lfs.mkdir(dir .. "Autosaves")
end
local status, err = pcall(TheApp.save, TheApp, dir .. "Autosaves" .. pathsep .. "Autosave" .. self.game_date:monthOfYear() .. ".sav")
if not status then
print("Error while autosaving game: " .. err)
end
end
if self.game_date == start_date then
if not self.ui.start_tutorial then
self.ui:addWindow(UIWatch(self.ui, "initial_opening"))
end
self.ui:showBriefing()
end
self.tick_timer = self.tick_rate
-- if an earthquake is supposed to be going on, call the earthquake function
if self.next_earthquake.active then
self:tickEarthquake()