-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.lua
More file actions
2712 lines (2583 loc) · 108 KB
/
Copy pathmain.lua
File metadata and controls
2712 lines (2583 loc) · 108 KB
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
-- main.lua
-- Entry point: orchestrates modules and game loop
-- Ensure LÖVE's require path can resolve modules inside 'src/' on all platforms (incl. Android)
pcall(function()
if love and love.filesystem and love.filesystem.setRequirePath then
local getPath = love.filesystem.getRequirePath and love.filesystem.getRequirePath or function() return '' end
local current = getPath()
local wanted = '?.lua;?/init.lua;src/?.lua;src/?/init.lua'
if not current or current == '' then
love.filesystem.setRequirePath(wanted)
elseif not current:find('src/%?%.lua', 1, true) then
love.filesystem.setRequirePath(wanted .. ';' .. current)
end
end
end)
-- Robust module aliasing: ensure both 'name' and 'src.name' resolve to the same module
local function _ensureModuleAliases(baseName)
local rootKey = baseName
local srcKey = 'src.' .. baseName
if package.loaded[srcKey] and package.loaded[rootKey] then return true end
local function tryRequire(name)
local ok, mod = pcall(require, name)
if ok and mod ~= true then return mod end
return nil
end
local function loadFromVFS(path)
if not (love and love.filesystem and love.filesystem.getInfo and love.filesystem.load) then return nil end
if not love.filesystem.getInfo(path) then return nil end
local chunk, err = love.filesystem.load(path)
if not chunk then return nil end
local ok, mod = pcall(chunk)
if ok and mod ~= true then return mod end
return nil
end
-- Prefer existing files if we can detect them
if love and love.filesystem and love.filesystem.getInfo then
if love.filesystem.getInfo('src/' .. baseName .. '.lua') then
local mod = tryRequire(srcKey) or loadFromVFS('src/' .. baseName .. '.lua')
if mod then
package.loaded[srcKey] = mod
package.loaded[rootKey] = package.loaded[rootKey] or mod
return true
end
elseif love.filesystem.getInfo(baseName .. '.lua') then
local mod = tryRequire(rootKey) or loadFromVFS(baseName .. '.lua')
if mod then
package.loaded[rootKey] = mod
package.loaded[srcKey] = package.loaded[srcKey] or mod
return true
end
end
end
-- Fallback: try both names via require
local mod = tryRequire(srcKey) or tryRequire(rootKey)
if mod then
package.loaded[srcKey] = package.loaded[srcKey] or mod
package.loaded[rootKey] = package.loaded[rootKey] or mod
return true
end
return false
end
do
local modules = {
'constants','utils','state','trees','grid','particles','buildings','workers','ui','save','roads','missions'
}
for i = 1, #modules do _ensureModuleAliases(modules[i]) end
end
-- Module imports
local C = require('src.constants')
local utils = require('src.utils')
local state = require('src.state')
local trees = require('src.trees')
local grid = require('src.grid')
local particles = require('src.particles')
local buildings = require('src.buildings')
local workers = require('src.workers')
local ui = require('src.ui')
local save = require('src.save')
local roads = require('src.roads')
local missions = require('src.missions')
-- Shorthand
local TILE_SIZE = C.TILE_SIZE
local colors = C.colors
-- Converts mouse screen position to world tile coordinates
local function getMouseTile()
local mx, my = love.mouse.getX(), love.mouse.getY()
if state.ui._handheldMode and state.ui._useVirtualCursor and state.ui._virtualCursor then
mx, my = state.ui._virtualCursor.x or mx, state.ui._virtualCursor.y or my
end
-- If a gamepad stick is used, allow a virtual cursor for handhelds
if state.ui._useVirtualCursor and state.ui._virtualCursor then mx, my = state.ui._virtualCursor.x, state.ui._virtualCursor.y end
local worldX = state.camera.x + mx / state.camera.scale
local worldY = state.camera.y + my / state.camera.scale
local tileX = math.floor(worldX / TILE_SIZE)
local tileY = math.floor(worldY / TILE_SIZE)
return tileX, tileY
end
-- Convert explicit screen coords to tile (use for click handling to avoid drift)
local function screenToTile(sx, sy)
if state.ui._handheldMode and state.ui._useVirtualCursor and state.ui._virtualCursor and (sx == nil or sy == nil) then
sx, sy = state.ui._virtualCursor.x, state.ui._virtualCursor.y
end
local worldX = state.camera.x + (sx or 0) / state.camera.scale
local worldY = state.camera.y + (sy or 0) / state.camera.scale
return math.floor(worldX / TILE_SIZE), math.floor(worldY / TILE_SIZE)
end
-- Gamepad virtual cursor state
state.ui._virtualCursor = state.ui._virtualCursor or { x = 200, y = 200 }
local gamepad = nil
-- Returns true if mouse is over any UI panel (build button or build menu)
local function isOverUI(mx, my)
if ui.isOverBuildButton(mx, my) then return true end
local m = ui.buildMenu
if state.ui.isBuildMenuOpen then
-- support handheld radial bounds too
if state.ui._handheldMode and state.ui._buildMenuBounds then
for _, opt in ipairs(state.ui._buildMenuBounds) do
local b = opt._bounds
if b and utils.isPointInRect(mx, my, b.x, b.y, b.w, b.h) then return true end
end
else
if utils.isPointInRect(mx, my, m.x, m.y, m.width, m.height) then
return true
end
end
end
return false
end
-- Handheld build menu grid navigation (2 columns)
local function moveBuildMenuFocus(dx, dy)
local ui_mod = require('src.ui')
local opts = ui_mod.buildMenu.options or {}
local count = #opts
if count == 0 then return end
local cols = 2
local idx = state.ui._buildMenuFocus or 1
local col = (idx - 1) % cols
local row = math.floor((idx - 1) / cols)
if dy and dy ~= 0 then
local new = idx + dy * cols
if new >= 1 and new <= count then
state.ui._buildMenuFocus = new
return
end
end
if dx and dx ~= 0 then
if dx < 0 and col > 0 then
state.ui._buildMenuFocus = idx - 1
elseif dx > 0 and col < (cols - 1) and (idx + 1) <= count then
state.ui._buildMenuFocus = idx + 1
end
end
end
-- Handheld pause menu navigation (single column)
local function movePauseMenuFocus(dy)
local ui_mod = require('src.ui')
local opts = ui_mod.pauseMenu.options or {}
local count = #opts
if count == 0 then return end
local idx = state.ui._pauseMenuFocus or 1
if dy and dy ~= 0 then
local new = idx + dy
if new >= 1 and new <= count then
state.ui._pauseMenuFocus = new
return
end
end
end
-- Apply startup preset and initialize world
local function startGameWithPreset(preset)
local ww, wh, flags = love.window.getMode()
flags = flags or {}
if preset == 'retroid' then
-- Retroid Pocket 4 Pro landscape resolution
flags.highdpi = false
flags.resizable = false
flags.fullscreen = false
flags.borderless = false
-- Ensure we leave any fullscreen/maximized state first
pcall(love.window.setFullscreen, false)
pcall(love.window.restore)
love.window.setMode(1334, 750, flags)
-- Center the window on the primary display if possible
if love.window.getDesktopDimensions then
local dw, dh = love.window.getDesktopDimensions(1)
if dw and dh and love.window.setPosition then
local px = math.max(0, math.floor((dw - 1334) / 2))
local py = math.max(0, math.floor((dh - 750) / 2))
pcall(love.window.setPosition, px, py, 1)
end
end
state.ui._useVirtualCursor = true
state.ui._forceSmallScreen = true
state.ui._handheldMode = true
state.ui.showMinimap = true
end
-- Compute tiles and UI, then reset world and generate
state.resetWorldTilesFromScreen()
ui.computeBuildMenuHeight()
state.restart()
trees.generate(state)
missions.init(state)
-- Close startup choice
state.ui._startupChoiceOpen = false
end
-- Count warehouses in the world
local function countWarehouses()
local c = 0
for _, b in ipairs(state.game.buildings) do
if b.type == 'warehouse' then c = c + 1 end
end
return c
end
-- Compute total wood across base and warehouses
local function computeTotalWood()
local total = state.game.resources.wood or 0
for _, b in ipairs(state.game.buildings) do
if b.type == 'warehouse' and b.storage and b.storage.wood then
total = total + b.storage.wood
end
end
return total
end
-- Compute total wood capacity (base + per-warehouse)
local function computeWoodCapacity()
return 50 + 100 * countWarehouses()
end
-- Draw a placement preview at mouse tile, including lumberyard radius
local function drawPlacementPreview()
if state.ui.isPaused then return end
if not state.ui.isPlacingBuilding or not state.ui.selectedBuildingType then return end
local tileX, tileY = getMouseTile()
local px = tileX * TILE_SIZE
local py = tileY * TILE_SIZE
local isValid = buildings.canPlaceAt(state, tileX, tileY)
and not isOverUI(love.mouse.getX(), love.mouse.getY())
-- Show radius while previewing for buildings with area effects
if state.ui.selectedBuildingType == 'lumberyard' or state.ui.selectedBuildingType == 'market' or state.ui.selectedBuildingType == 'flowerbed' then
local def = state.buildingDefs[state.ui.selectedBuildingType]
local radiusPx = (def.radiusTiles or 0) * TILE_SIZE
local cx = px + TILE_SIZE / 2
local cy = py + TILE_SIZE / 2
love.graphics.setColor(colors.radius)
love.graphics.circle('fill', cx, cy, radiusPx)
love.graphics.setColor(colors.radiusOutline)
love.graphics.circle('line', cx, cy, radiusPx)
end
-- Show farm surrounding plots while previewing
if state.ui.selectedBuildingType == 'farm' then
for dy = -1, 1 do
for dx = -1, 1 do
if not (dx == 0 and dy == 0) then
local nx = px + dx * TILE_SIZE
local ny = py + dy * TILE_SIZE
love.graphics.setColor(0.35, 0.6, 0.2, 0.35)
love.graphics.rectangle('fill', nx, ny, TILE_SIZE, TILE_SIZE, 4, 4)
love.graphics.setColor(colors.outline[1], colors.outline[2], colors.outline[3], 0.25)
love.graphics.rectangle('line', nx, ny, TILE_SIZE, TILE_SIZE, 4, 4)
end
end
end
end
if not isValid then
love.graphics.setColor(colors.invalid)
else
local t = state.ui.selectedBuildingType
if t == 'house' then
love.graphics.setColor(0.9, 0.6, 0.2, colors.preview[4])
elseif t == 'lumberyard' then
love.graphics.setColor(0.3, 0.7, 0.3, colors.preview[4])
else
love.graphics.setColor(colors.preview)
end
end
love.graphics.rectangle('fill', px, py, TILE_SIZE, TILE_SIZE, 4, 4)
-- pulsing outline
local pulse = 0.5 + 0.5 * math.sin(state.ui.previewT * 6)
love.graphics.setColor(colors.outline[1], colors.outline[2], colors.outline[3], 0.2 + 0.4 * pulse)
love.graphics.setLineWidth(2)
love.graphics.rectangle('line', px, py, TILE_SIZE, TILE_SIZE, 4, 4)
love.graphics.setLineWidth(1)
end
-- Pause menu click handling
local function handlePauseMenuClick(x, y)
if not state.ui.isPaused then return false end
for _, opt in ipairs(ui.pauseMenu.options) do
local b = opt._bounds
if b and utils.isPointInRect(x, y, b.x, b.y, b.w, b.h) then
if opt.key == 'resume' then
state.ui.isPaused = false
elseif opt.key == 'save' then
state.ui._saveLoadMode = 'save'
elseif opt.key == 'load' then
state.ui._saveLoadMode = 'load'
elseif opt.key == 'restart' then
state.restart()
trees.generate(state)
missions.init(state)
elseif opt.key == 'quit' then
love.event.quit()
end
return true
end
end
return true
end
function love.load()
love.window.setTitle('City Builder - Prototype')
love.graphics.setBackgroundColor(colors.background)
math.randomseed(os.time())
state.resetWorldTilesFromScreen()
ui.computeBuildMenuHeight()
if not state.ui._startupChoiceOpen then
trees.generate(state)
missions.init(state)
end
-- Start at beginning of the day (around sunrise ~06:00)
state.time.t = state.time.dayLength * 0.25
state.time.normalized = state.time.t / state.time.dayLength
-- Start with free builder placement preview if not waiting for startup choice
if not state.ui._startupChoiceOpen then
state.ui.isPlacingBuilding = true
state.ui.selectedBuildingType = 'builder'
state.ui._isFreeInitialBuilder = true
state.ui._pauseTimeForInitial = true
state.ui.promptText = "Place your Builders Workplace for free. Left-click a tile to place."
state.ui.promptT = 0
state.ui.promptDuration = 9999
end
end
function love.update(dt)
if state.ui._startupChoiceOpen then return end
-- Allow time to flow even with panels if desired; keep original pause behavior
-- Handheld: hide virtual cursor when a navigable panel/menu is open; show it again when closed
if state.ui._handheldMode then
local navigableOpen = state.ui.isBuildMenuOpen == true
or state.ui.isMissionSelectorOpen == true
or state.ui.isVillagersPanelOpen == true
or state.ui.isBuildQueueOpen == true
or state.ui.isFoodPanelOpen == true
or state.ui.isPaused == true
or state.ui._wheelMenuActive == true
or state.ui._controlsOverlayOpen == true
or state.ui.isMinimapFullscreen == true
if navigableOpen then
state.ui._useVirtualCursor = false
else
state.ui._useVirtualCursor = true
end
-- Track stick state for discrete movement
state.ui._lastStickState = state.ui._lastStickState or { x = 0, y = 0 }
-- Track last D-pad axis values for edge detection (handheld)
state.ui._lastDpadAxis = state.ui._lastDpadAxis or { up = 0, down = 0, left = 0 }
-- Wheel menu state for retroid mode
state.ui._wheelMenuActive = state.ui._wheelMenuActive or false
state.ui._wheelMenuSelection = state.ui._wheelMenuSelection or 1
end
if state.ui.isPaused then return end
local isInitial = state.ui._pauseTimeForInitial
-- Auto speed by day/night
local isDay = (state.time.normalized >= 0.25 and state.time.normalized < 0.75)
if state.time.lastIsDay == nil then
state.time.lastIsDay = isDay
else
if isDay ~= state.time.lastIsDay then
if isDay then
-- restore pre-night speed
state.time.speed = state.time.preNightSpeed or 1
-- new day: reset synchronized mealtime flag
state.time.mealConsumedToday = false
state.time.mealtimeActive = false
state.game.starving = false
-- reset per-villager meal flags
if state.game and state.game.villagers then
for _, v in ipairs(state.game.villagers) do v._ateToday = false end
end
if state.game and state.game.buildings then
for _, b in ipairs(state.game.buildings) do
if b.workers then
for _, w in ipairs(b.workers) do w._ateToday = false end
end
end
end
else
-- entering night: remember current speed then switch to 8x
state.time.preNightSpeed = state.time.speed or 1
state.time.speed = 8
state.time.mealtimeActive = false
end
state.time.lastIsDay = isDay
end
end
-- Clear stale food-shortage prompt if conditions are now OK (e.g., after deliveries)
do
local popNow = state.game.population.total or 0
local marketsFoodNow, marketsCount = 0, 0
for _, b in ipairs(state.game.buildings) do
if b.type == 'market' then
marketsCount = marketsCount + 1
if b.storage and b.storage.food then marketsFoodNow = marketsFoodNow + b.storage.food end
end
end
if marketsCount > 0 and marketsFoodNow >= popNow then
if state.ui.prompts then
local newList = {}
for _, p in ipairs(state.ui.prompts) do
if p.tag ~= 'market_food' then table.insert(newList, p) end
end
state.ui.prompts = newList
end
state.game.starving = false
end
end
-- Time of day (apply time speed)
local sdt = dt * (state.time.speed or 1)
if not isInitial then
state.time.t = (state.time.t + sdt) % state.time.dayLength
state.time.normalized = state.time.t / state.time.dayLength
end
-- Apply smooth zoom from right stick on handheld
if state.ui._handheldMode and state.ui._zoomVel and math.abs(state.ui._zoomVel) > 1e-3 then
local oldScale = state.camera.scale
local newScale = utils.clamp(oldScale * (1 + state.ui._zoomVel * dt), state.camera.minScale, state.camera.maxScale)
if math.abs(newScale - oldScale) > 1e-6 then
local mx = love.graphics.getWidth()/2
local my = love.graphics.getHeight()/2
local preWorldX = state.camera.x + mx / oldScale
local preWorldY = state.camera.y + my / oldScale
state.camera.scale = newScale
state.camera.x = utils.clamp(preWorldX - mx / newScale, 0, math.max(0, state.world.tilesX * TILE_SIZE - love.graphics.getWidth() / newScale))
state.camera.y = utils.clamp(preWorldY - my / newScale, 0, math.max(0, state.world.tilesY * TILE_SIZE - love.graphics.getHeight() / newScale))
end
-- friction
state.ui._zoomVel = state.ui._zoomVel * 0.9
if math.abs(state.ui._zoomVel) < 1e-3 then state.ui._zoomVel = 0 end
end
-- Synchronized daily mealtime: all villagers eat just before nightfall
do
local pop = state.game.population.total or 0
if pop > 0 and not isInitial then
-- Trigger mealtime during late day window before night (day ends at tnorm 0.75)
local tnorm = state.time.normalized
-- start directing villagers to market slightly earlier
if (not state.time.mealtimeActive) and (tnorm >= 0.70 and tnorm < 0.75) then
state.time.mealtimeActive = true
end
if (not state.time.mealConsumedToday) and (tnorm >= 0.73 and tnorm < 0.75) then
local remaining = pop
local consumed = 0
local markets = {}
for _, b in ipairs(state.game.buildings) do
if b.type == 'market' then table.insert(markets, b) end
end
if #markets > 0 then
for _, m in ipairs(markets) do
if remaining <= 0 then break end
local stock = (m.storage and m.storage.food) or 0
if stock > 0 then
local take = math.min(remaining, stock)
m.storage.food = stock - take
remaining = remaining - take
consumed = consumed + take
end
end
end
local mealOk = (consumed >= pop)
state.time.mealConsumedToday = true
state.time.mealtimeActive = true
state.time.lastMealOk = mealOk
state.game.starving = not mealOk
-- Prompt player if villagers could not get food at the market
state.ui.prompts = state.ui.prompts or {}
local function upsertPrompt(tag, text)
local found = false
for _, p in ipairs(state.ui.prompts) do
if p.tag == tag then
p.text = text; p.duration = 999999; p.useRealTime = true; found = true; break
end
end
if not found then table.insert(state.ui.prompts, { text = text, t = 0, duration = 999999, useRealTime = true, tag = tag }) end
end
local function removePrompt(tag)
local newList = {}
for _, p in ipairs(state.ui.prompts) do if p.tag ~= tag then table.insert(newList, p) end end
state.ui.prompts = newList
end
if not mealOk then
if #markets == 0 then
upsertPrompt('market_food', 'Villagers could not eat: build a Market and stock it with food before dusk.')
else
upsertPrompt('market_food', 'Villagers could not eat: not enough food in Markets. Stock them before dusk.')
end
else
removePrompt('market_food')
end
-- Safety: immediately clear the prompt if at any time after mealtime stock becomes sufficient
if state.ui.prompts and state.time.mealConsumedToday then
local totalMarketFood = 0
for _, m in ipairs(state.game.buildings) do
if m.type == 'market' and m.storage and m.storage.food then totalMarketFood = totalMarketFood + m.storage.food end
end
if totalMarketFood >= (state.game.population.total or 0) then
removePrompt('market_food')
state.game.starving = false
end
end
end
end
end
-- Passive production placeholder (none currently for lumberyard)
state.game.productionRates.wood = 0
-- Global prompt for full storage (base or warehouses)
do
local totalWood = computeTotalWood()
local cap = computeWoodCapacity()
local warehouses = countWarehouses()
if totalWood >= cap and not state.ui._pauseTimeForInitial and state.game.resources._spentAny then
local text
if warehouses == 0 then
text = "Storage is full (50). Build a Warehouse to increase capacity (+100)."
else
text = string.format("Storage is full (%d). Build another Warehouse to increase capacity (+100).", cap)
end
state.ui.prompts = state.ui.prompts or {}
local found = false
for _, p in ipairs(state.ui.prompts) do
if p.tag == 'capacity' then
p.text = text
p.duration = 999999
p.useRealTime = true
found = true
break
end
end
if not found then
table.insert(state.ui.prompts, { text = text, t = 0, duration = 999999, useRealTime = true, tag = 'capacity' })
end
state.ui._lastCapacityPrompted = cap
else
-- Not full anymore or still in initial placement; remove any capacity prompt
if state.ui.prompts then
local newList = {}
for _, p in ipairs(state.ui.prompts) do
if p.tag ~= 'capacity' then table.insert(newList, p) end
end
state.ui.prompts = newList
end
if state.ui._lastCapacityPrompted then
state.ui.promptText = nil
state.ui.promptDuration = 0
state.ui.promptSticky = false
state.ui._lastCapacityPrompted = nil
end
end
end
-- Systems
-- Use game-time delta so mission timers ("full day") respect time speed
missions.update(state, sdt)
if not isInitial then
workers.update(state, sdt)
end
-- Smooth camera pan when fullscreen minimap is active (left-stick only)
if state.ui.isMinimapFullscreen and state.ui._handheldMode then
if not gamepad or not gamepad:isConnected() then
if love.joystick and love.joystick.getJoysticks then
local joys = love.joystick.getJoysticks()
if joys and #joys > 0 then gamepad = joys[1] end
end
end
if gamepad and gamepad:isConnected() then
local ax = gamepad:getGamepadAxis('leftx') or 0
local ay = gamepad:getGamepadAxis('lefty') or 0
local dz = 0.08
local function axisToVel(v)
local av = math.abs(v)
if av <= dz then return 0 end
local n = (av - dz) / (1.0 - dz)
local base = 2200
local maxAdd = 3800
local speed = base + (n * n) * maxAdd
return (v > 0 and 1 or -1) * n * speed
end
local targetVX = axisToVel(ax)
local targetVY = axisToVel(ay)
local smooth = 0.18
state.ui._minimapPanVX = state.ui._minimapPanVX * (1 - smooth) + targetVX * smooth
state.ui._minimapPanVY = state.ui._minimapPanVY * (1 - smooth) + targetVY * smooth
local screenW, screenH = love.graphics.getDimensions()
local viewW = screenW / state.camera.scale
local viewH = screenH / state.camera.scale
local TILE = C.TILE_SIZE
local maxX = math.max(0, (state.world.tilesX * TILE) - viewW)
local maxY = math.max(0, (state.world.tilesY * TILE) - viewH)
state.camera.x = utils.clamp(state.camera.x + state.ui._minimapPanVX * dt, 0, maxX)
state.camera.y = utils.clamp(state.camera.y + state.ui._minimapPanVY * dt, 0, maxY)
end
end
buildings.update(state, sdt)
particles.update(state.game.particles, sdt)
trees.updateShake(state, sdt)
roads.update(state, sdt)
-- Preview timer for pulsing outline
state.ui.previewT = state.ui.previewT + sdt
-- Virtual cursor update (left stick moves cursor) - disabled while navigable menu is open in handheld mode
if gamepad and gamepad:isConnected() == false then gamepad = nil end
if (not gamepad) and love.joystick and love.joystick.getJoysticks then
local joys = love.joystick.getJoysticks()
if joys and #joys > 0 then gamepad = joys[1] end
end
if gamepad and gamepad:isConnected() then
local ax = gamepad:getGamepadAxis("leftx") or 0
local ay = gamepad:getGamepadAxis("lefty") or 0
-- Discrete menu navigation in handheld mode (works even when virtual cursor is suppressed)
if state.ui._handheldMode and not state.ui.isMinimapFullscreen then
local lastX, lastY = state.ui._lastStickState.x, state.ui._lastStickState.y
local threshold = 0.5
-- Check for discrete movement (stick crosses threshold) - only when wheel menu is not active
if not state.ui._wheelMenuActive and math.abs(ax) > threshold and math.abs(lastX) <= threshold then
if ax > 0 then
-- Right movement
if state.ui.isBuildMenuOpen then
moveBuildMenuFocus(1, 0)
end
else
-- Left movement
if state.ui.isBuildMenuOpen then
moveBuildMenuFocus(-1, 0)
end
end
end
-- Wheel menu directional control (when active, disable other stick functions)
if state.ui._wheelMenuActive then
local ui_mod = require('src.ui')
local opts = ui_mod.buildMenu.options or {}
local count = #opts
if count > 0 then
-- Add deadzone to prevent accidental selections
local stickMagnitude = math.sqrt(ax * ax + ay * ay)
if stickMagnitude > 0.2 then
-- Calculate angle from stick position
local angle = math.atan2(ay, ax)
-- Convert angle to selection (0 = top, clockwise)
local normalizedAngle = (angle + math.pi / 2) % (2 * math.pi)
local selection = math.floor((normalizedAngle / (2 * math.pi)) * count) + 1
selection = math.max(1, math.min(count, selection))
state.ui._wheelMenuSelection = selection
else
-- Return to neutral center when stick is released
state.ui._wheelMenuSelection = 0
end
end
else
-- Normal discrete navigation when wheel menu is not active
if math.abs(ay) > threshold and math.abs(lastY) <= threshold then
if ay > 0 then
-- Down movement
if state.ui.isBuildMenuOpen then
moveBuildMenuFocus(0, 1)
elseif state.ui.isPaused then
movePauseMenuFocus(1)
elseif state.ui.isMissionSelectorOpen and state.ui._missionSelectorButtons then
local count = #(state.ui._missionSelectorButtons or {})
local idx = (state.ui._missionSelectorFocus or 1)
if idx < count then state.ui._missionSelectorFocus = idx + 1 end
elseif state.ui.isVillagersPanelOpen and state.ui._villagersPanelButtons then
local count = #(state.ui._villagersPanelButtons or {})
state.ui._villagersPanelFocus = math.min(count, (state.ui._villagersPanelFocus or 1) + 1)
elseif state.ui.isBuildQueueOpen then
local count = #(state.game.buildQueue or {})
state.ui._queueFocusIndex = math.min(count, (state.ui._queueFocusIndex or 1) + 1)
end
else
-- Up movement
if state.ui.isBuildMenuOpen then
moveBuildMenuFocus(0, -1)
elseif state.ui.isPaused then
movePauseMenuFocus(-1)
elseif state.ui.isMissionSelectorOpen and state.ui._missionSelectorButtons then
local idx = (state.ui._missionSelectorFocus or 1)
if idx > 1 then state.ui._missionSelectorFocus = idx - 1 end
elseif state.ui.isVillagersPanelOpen and state.ui._villagersPanelButtons then
state.ui._villagersPanelFocus = math.max(1, (state.ui._villagersPanelFocus or 1) - 1)
elseif state.ui.isBuildQueueOpen then
local count = #(state.game.buildQueue or {})
state.ui._queueFocusIndex = math.max(1, (state.ui._queueFocusIndex or 1) - 1)
end
end
end
end
-- Update last stick state
state.ui._lastStickState.x = ax
state.ui._lastStickState.y = ay
end
-- Virtual cursor movement (only when not suppressed)
local suppressCursor = state.ui._handheldMode and (
state.ui.isBuildMenuOpen or state.ui.isMissionSelectorOpen or state.ui.isVillagersPanelOpen or state.ui.isBuildQueueOpen or state.ui.isFoodPanelOpen or state.ui.isPaused or state.ui._wheelMenuActive or state.ui._controlsOverlayOpen
)
if not suppressCursor then
-- Micro-step and hold-repeat: very small deflection moves 1px per tick
state.ui._lastCursorStickX = state.ui._lastCursorStickX or 0
state.ui._lastCursorStickY = state.ui._lastCursorStickY or 0
state.ui._cursorRepeatTX = state.ui._cursorRepeatTX or 0
state.ui._cursorRepeatTY = state.ui._cursorRepeatTY or 0
local prevCX, prevCY = state.ui._lastCursorStickX, state.ui._lastCursorStickY
local microDZ, microHi = 0.18, 0.55
local stepX, stepY = 0, 0
local inMicroX = math.abs(ax) > microDZ and math.abs(ax) <= microHi
local inMicroY = math.abs(ay) > microDZ and math.abs(ay) <= microHi
-- Edge step when crossing into micro zone
if inMicroX and math.abs(prevCX) <= microDZ then stepX = (ax > 0) and 1 or -1 end
if inMicroY and math.abs(prevCY) <= microDZ then stepY = (ay > 0) and 1 or -1 end
-- Hold repeat while staying in micro zone
local initialDelay = state.ui.isPlacingBuilding and 0.22 or 0.18
local repeatDelay = state.ui.isPlacingBuilding and 0.05 or 0.04
if inMicroX then
state.ui._cursorRepeatTX = state.ui._cursorRepeatTX + dt
if state.ui._cursorRepeatTX > ((state.ui._cursorHadFirstX and repeatDelay) or initialDelay) then
stepX = stepX + ((ax > 0) and 1 or -1)
state.ui._cursorRepeatTX = 0
state.ui._cursorHadFirstX = true
end
else
state.ui._cursorRepeatTX = 0; state.ui._cursorHadFirstX = false
end
if inMicroY then
state.ui._cursorRepeatTY = state.ui._cursorRepeatTY + dt
if state.ui._cursorRepeatTY > ((state.ui._cursorHadFirstY and repeatDelay) or initialDelay) then
stepY = stepY + ((ay > 0) and 1 or -1)
state.ui._cursorRepeatTY = 0
state.ui._cursorHadFirstY = true
end
else
state.ui._cursorRepeatTY = 0; state.ui._cursorHadFirstY = false
end
if stepX ~= 0 or stepY ~= 0 then
state.ui._useVirtualCursor = true
state.ui._virtualCursor.x = utils.clamp((state.ui._virtualCursor.x or 0) + stepX, 0, love.graphics.getWidth())
state.ui._virtualCursor.y = utils.clamp((state.ui._virtualCursor.y or 0) + stepY, 0, love.graphics.getHeight())
end
-- For larger deflections, use smooth analog movement with gentle ramp
local usedAnalog = false
if math.abs(ax) > microHi or math.abs(ay) > microHi then
local dz = 0.15
local function axisToDelta(v)
local av = math.abs(v)
if av <= dz then return 0 end
local n = (av - dz) / (1.0 - dz)
local base = state.ui.isPlacingBuilding and 10 or 25
local maxAdd = state.ui.isPlacingBuilding and 120 or 200
local speed = base + (n * n) * maxAdd
return (v > 0 and 1 or -1) * speed * dt
end
local dx = axisToDelta(ax)
local dy = axisToDelta(ay)
if dx ~= 0 or dy ~= 0 then
state.ui._useVirtualCursor = true
state.ui._virtualCursor.x = utils.clamp((state.ui._virtualCursor.x or 0) + dx, 0, love.graphics.getWidth())
state.ui._virtualCursor.y = utils.clamp((state.ui._virtualCursor.y or 0) + dy, 0, love.graphics.getHeight())
usedAnalog = true
end
end
-- Update cursor stick history for micro-step edge detection
state.ui._lastCursorStickX = ax
state.ui._lastCursorStickY = ay
end
end
-- Stacked prompts update
do
state.ui.prompts = state.ui.prompts or {}
local newList = {}
local seenTags = {}
for _, p in ipairs(state.ui.prompts) do
-- filter out initial placement prompt once initial phase ended
if not (not state.ui._pauseTimeForInitial and p.text and p.text:find('Place your Builders Workplace')) then
local inc = (p.useRealTime and dt) or sdt
p.t = (p.t or 0) + inc
-- de-dupe by tag: keep first only
local tag = p.tag
if tag then
if not seenTags[tag] and (not p.duration or p.t < p.duration) then
table.insert(newList, p)
seenTags[tag] = true
end
else
if not p.duration or p.t < p.duration then
table.insert(newList, p)
end
end
end
end
state.ui.prompts = newList
end
-- Back-compat single prompt funnels into stacked list
if state.ui.promptText and state.ui.promptDuration and state.ui.promptDuration > 0 then
table.insert(state.ui.prompts, { text = state.ui.promptText, t = 0, duration = state.ui.promptDuration, useRealTime = state.ui._promptUseRealTime })
state.ui.promptText = nil; state.ui.promptDuration = 0; state.ui._promptUseRealTime = nil
end
-- Mouse/touch edge panning (not speed-scaled) and virtual-cursor edge pan on handheld
local mx, my = love.mouse.getPosition()
if state.ui._handheldMode and state.ui._useVirtualCursor and state.ui._virtualCursor and not state.ui._wheelMenuActive then
mx, my = state.ui._virtualCursor.x or mx, state.ui._virtualCursor.y or my
end
local screenW, screenH = love.graphics.getDimensions()
local margin = 24
local dx, dy = 0, 0
if mx <= margin then dx = -1 end
if mx >= screenW - margin then dx = 1 end
if my <= margin then dy = -1 end
if my >= screenH - margin then dy = 1 end
state.camera.x = state.camera.x + dx * state.camera.panSpeed * dt / state.camera.scale
state.camera.y = state.camera.y + dy * state.camera.panSpeed * dt / state.camera.scale
local maxCamX = math.max(0, state.world.tilesX * TILE_SIZE - screenW / state.camera.scale)
local maxCamY = math.max(0, state.world.tilesY * TILE_SIZE - screenH / state.camera.scale)
state.camera.x = utils.clamp(state.camera.x, 0, maxCamX)
state.camera.y = utils.clamp(state.camera.y, 0, maxCamY)
end
local function getAmbientColor()
-- Map normalized time [0..1) to ambient brightness/color
-- Dawn 0.2, Day 0.8, Dusk 0.2, Night 0.05
local t = state.time.normalized
local function lerp(a, b, u) return a + (b - a) * u end
local brightness
if t < 0.25 then
-- Night -> Dawn
brightness = lerp(0.05, 0.8, t / 0.25)
elseif t < 0.5 then
-- Day
brightness = 0.8
elseif t < 0.75 then
-- Dusk
brightness = lerp(0.8, 0.2, (t - 0.5) / 0.25)
else
-- Night
brightness = 0.05
end
-- Slight warm tint at sunrise/sunset
local warm = math.max(0, 0.5 - math.abs(t - 0.5)) * 0.2
local r = brightness + warm * 0.2
local g = brightness + warm * 0.1
local b = brightness
return r, g, b
end
local function drawDayNightOverlay()
local screenW, screenH = love.graphics.getDimensions()
local r, g, b = getAmbientColor()
-- Darken based on inverse brightness
local darkness = 1 - math.min(1, (r + g + b) / 3)
love.graphics.setColor(0, 0, 0, 0.5 * darkness)
love.graphics.rectangle('fill', 0, 0, screenW, screenH)
end
function love.draw()
if state.ui._startupChoiceOpen then
local w, h = love.graphics.getDimensions()
love.graphics.setColor(0,0,0,0.5)
love.graphics.rectangle('fill', 0, 0, w, h)
local mw, mh = 520, 240
local mx, my = (w - mw) / 2, (h - mh) / 2
love.graphics.setColor(0.95, 0.90, 0.80, 1.0)
love.graphics.rectangle('fill', mx, my, mw, mh, 10, 10)
love.graphics.setColor(0.25, 0.18, 0.10, 1.0)
love.graphics.setLineWidth(3)
love.graphics.rectangle('line', mx, my, mw, mh, 10, 10)
love.graphics.setLineWidth(1)
local title = "Choose Display Mode"
local tw = love.graphics.getFont():getWidth(title)
love.graphics.print(title, mx + (mw - tw) / 2, my + 18)
local bx, by, bw, bh = mx + 40, my + 80, 200, 60
local bx2 = mx + mw - 40 - 200
local focus = state.ui._startupChoiceFocus or 1
-- Desktop button
love.graphics.setColor(0.85, 0.8, 0.7, 1)
love.graphics.rectangle('fill', bx, by, bw, bh, 8, 8)
if focus == 1 then
love.graphics.setColor(0.2, 0.7, 0.3, 0.3)
love.graphics.rectangle('fill', bx+4, by+4, bw-8, bh-8, 8, 8)
end
love.graphics.setColor(0.2, 0.15, 0.1, 1)
love.graphics.rectangle('line', bx, by, bw, bh, 8, 8)
local l1 = "Desktop (current size)"
love.graphics.print(l1, bx + (bw - love.graphics.getFont():getWidth(l1)) / 2, by + 20)
-- Retroid button
love.graphics.setColor(0.85, 0.8, 0.7, 1)
love.graphics.rectangle('fill', bx2, by, bw, bh, 8, 8)
if focus == 2 then
love.graphics.setColor(0.2, 0.7, 0.3, 0.3)
love.graphics.rectangle('fill', bx2+4, by+4, bw-8, bh-8, 8, 8)
end
love.graphics.setColor(0.2, 0.15, 0.1, 1)
love.graphics.rectangle('line', bx2, by, bw, bh, 8, 8)
local l2 = "Retroid Pocket 4 Pro (1334x750)"
love.graphics.print(l2, bx2 + (bw - love.graphics.getFont():getWidth(l2)) / 2, by + 20)
return
end
-- World space draw
love.graphics.push()
love.graphics.scale(state.camera.scale, state.camera.scale)
love.graphics.translate(-state.camera.x, -state.camera.y)
-- Grass-like background tiling for depth
do
local TILE = C.TILE_SIZE
local screenW, screenH = love.graphics.getDimensions()
local visibleW = screenW / (state.camera.scale or 1)
local visibleH = screenH / (state.camera.scale or 1)
local startX = math.floor(state.camera.x / TILE) - 1
local startY = math.floor(state.camera.y / TILE) - 1
local endX = math.ceil((state.camera.x + visibleW) / TILE) + 1
local endY = math.ceil((state.camera.y + visibleH) / TILE) + 1
for ty = startY, endY do
for tx = startX, endX do
local px = tx * TILE
local py = ty * TILE
-- base patch (lighter)
love.graphics.setColor(0.22, 0.40, 0.22, 1.0)
love.graphics.rectangle('fill', px, py, TILE, TILE)
-- blades overlay with slight noise (hash)
local n = math.abs(((tx * 73856093 + ty * 19349663) % 5))
local a = 0.07 + (n * 0.02)
love.graphics.setColor(0.30, 0.50, 0.26, a)
love.graphics.rectangle('fill', px + 2, py + 2, TILE - 4, TILE - 4, 6, 6)
love.graphics.setColor(0.26, 0.46, 0.22, a * 0.9)
love.graphics.rectangle('line', px + 3, py + 3, TILE - 6, TILE - 6, 6, 6)
end
end
end
if state.ui.isPlacingBuilding and state.ui.selectedBuildingType then
grid.draw(state)
end
buildings.drawSelectedRadius(state)
trees.draw(state)
roads.draw(state)
buildings.drawAll(state)
workers.draw(state)