-
Notifications
You must be signed in to change notification settings - Fork 0
/
SphereGroup.lua
1499 lines (1211 loc) · 46.5 KB
/
SphereGroup.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
local class = require "com.class"
---Represents a Sphere Group, which is a single group of spheres connected to each other. Sphere Groups can magnetize with each other.
---@class SphereGroup
---@overload fun(sphereChain, deserializationTable):SphereGroup
local SphereGroup = class:derive("SphereGroup")
local Vec2 = require("src.Essentials.Vector2")
local Sprite = require("src.Essentials.Sprite")
local Color = require("src.Essentials.Color")
local Sphere = require("src.Sphere")
---@param sphereChain SphereChain
---@param deserializationTable table?
function SphereGroup:new(sphereChain, deserializationTable)
self.sphereChain = sphereChain
self.map = sphereChain.map
-- these two are filled later by the sphere chain object
self.prevGroup = nil
self.nextGroup = nil
if deserializationTable then
self:deserialize(deserializationTable)
else
self.offset = 0
self.speed = 0
self.speedTime = nil -- this is used ONLY in zuma knockback; aka the speed of this group will be locked for this time
self.spheres = {}
self.matchCheck = true -- this is used ONLY in vise destruction to not trigger a chain reaction
end
self.maxSpeed = 0
self.config = _Game.configManager.gameplay.sphereBehaviour
self.delQueue = false
end
function SphereGroup:update(dt)
-- Empty sphere groups are not updated.
if #self.spheres == 0 then
return
end
-- Correct the speed bound if this chain is daisy-chained with another chain.
local speedGrp = self:getLastChainedGroup()
local speedBound = speedGrp.sphereChain.path:getSpeed(speedGrp:getLastSphereOffset())
local speedDesired = self.sphereChain.speedOverrideBase + speedBound * self.sphereChain.speedOverrideMult
if self:isMagnetizing() and not self:hasImmobileSpheres() then
self.maxSpeed = self.config.attractionSpeedBase + self.config.attractionSpeedMult * math.max(self.sphereChain.combo, 1)
else
self.maxSpeed = 0
self.matchCheck = true
end
-- FORK-SPECIFIC CODE:
-- For Zuma sphere physics, immediately stop the next group from sliding back if
-- the player blocks a same-colored sphere, preventing magnetization.
-- Fixes issue #14
if self.nextGroup and self.nextGroup:isMagnetizing() then
local gapSize = self.nextGroup:getBackPos() - self:getFrontPos()
local nextFirst = self.nextGroup:getFirstSphere().color
local thisLast = self:getLastSphere().color
-- this doesn't account for scarabs yet, please refactor when adding to OpenSMCE upstream
if gapSize > 0 and not (nextFirst == thisLast) then
self.nextGroup.speed = 0
end
end
-- If this group is the first one, it can push spheres forward.
if not self.prevGroup then
if self.map.level.lost then
-- If this level is lost, apply the foul speed.
self.maxSpeed = self.config.foulSpeed
elseif self:hasImmobileSpheres() then
-- If this group has immobile spheres, prevent from moving.
self.maxSpeed = 0
elseif speedGrp:hasImmobileSpheres() then
-- The same goes for the first daisy-chained group.
self.maxSpeed = 0
elseif speedDesired >= 0 then
-- Note that this group only pushes, so it must have positive speed in order to work!
self.maxSpeed = speedDesired
end
end
-- If this group is last, it can pull spheres back when the speed is going to be negative.
if not self.nextGroup then
-- If the level is lost or this group is magnetizing at this moment, do not apply any other speed.
if not self.map.level.lost and not self:isMagnetizing() and not self:hasImmobileSpheres() then
if speedDesired < 0 then
-- Note that this group only pulls, so it must have negative speed in order to work!
self.maxSpeed = speedDesired
end
end
end
if self.speedTime then
-- Omit speed calculation if this group's speed is locked.
self.speedTime = self.speedTime - dt
if self.speedTime <= 0 then
self.speedTime = nil
if self.config.knockbackStopAfterTime then
self.speed = 0
end
end
else
if self.speed > self.maxSpeed then
-- Decceleration rate used in this frame.
local deccel = self.config.decceleration
-- Can be different if defined accordingly, such as reverse powerup, magnetizing or under a slow powerup.
if self.sphereChain.speedOverrideTime > 0 and speedDesired < 0 and not self:isMagnetizing() then
deccel = self.sphereChain.speedOverrideDecc or deccel
elseif self:isMagnetizing() then
deccel = self.config.attractionAcceleration or deccel
if self.speed > 0 then
deccel = self.config.attractionForwardDecceleration or deccel
if self.prevGroup:getLastMatureSphere().color == 0 then
deccel = self.config.attractionForwardDeccelerationScarab or deccel
end
end
elseif self.prevGroup then
deccel = self.config.decceleration or deccel
elseif self.sphereChain.speedOverrideTime > 0 then
deccel = self.sphereChain.speedOverrideDecc or deccel
end
self.speed = math.max(self.speed - deccel * dt, self.maxSpeed)
end
if self.speed < self.maxSpeed then
-- Acceleration rate used in this frame.
local accel = self.config.acceleration
-- Can be different if defined accordingly, such as when the level is lost.
if self.map.level.lost then
accel = self.config.foulAcceleration or accel
elseif self.speed < 0 then
accel = self.config.backwardsDecceleration or accel
end
self.speed = math.min(self.speed + accel * dt, self.maxSpeed)
end
-- anti-slow-catapulting
if self.config.overspeedCheck and not self.map.level.lost and self.speed > speedBound then
self.speed = speedBound
end
end
-- stop spheres when away from board and rolling back
if self.speed < 0 and self:getFrontPos() < 0 and not self:isMagnetizing() then
self.speed = 0
self.speedTime = nil
end
-- FORK-SPECIFIC-CODE: Teleport the group to the spawn point if behind and nothing obstructs it.
if self:getFrontPos() < 0 and not self:isMagnetizing() and (not self.nextGroup or (self.nextGroup:getBackPos() >= 0 and not self.nextGroup:isMagnetizing())) then
self.offset = self.offset - self:getLastSphereOffset()
end
-- force balls to a certain speed on initial rollout, since it doesn't respect speedDesired and accelerates too slowly
if self.sphereChain.path.spawnDistanceHit == false then
self.speed = speedBound
end
self:move(self.speed * dt)
-- Tick the powerup timers, but only if the current speed matches the desired value.
if self.sphereChain.speedOverrideTime > 0 then
local fw = not self.prevGroup and speedDesired >= 0 and speedDesired == self.speed
local bw = not self.nextGroup and speedDesired < 0 and speedDesired == self.speed
if fw or bw then
self.sphereChain.speedOverrideTime = math.max(self.sphereChain.speedOverrideTime - dt, 0)
end
-- Reset the timer if the spheres are outside of the screen.
if not self.nextGroup and self:getLastSphereOffset() < 0 and speedDesired <= 0 then
self.sphereChain.speedOverrideTime = 0
end
if self.sphereChain.speedOverrideTime == 0 then
-- The time has elapsed, reset values.
self.sphereChain.speedOverrideBase = 0
self.sphereChain.speedOverrideMult = 1
end
end
for i = #self.spheres, 1, -1 do
-- Remove spheres at the end of path when the level is lost/it's a dummy path.
if (self.map.level.lost or self.map.isDummy) and self:getSphereOffset(i) >= self.sphereChain.path.length then
self:destroySphere(i)
end
end
-- Ultra-Safe Loop (TM)
local i = 1
while self.spheres[i] do
local sphere = self.spheres[i]
if not sphere.delQueue then
sphere:update(dt)
end
if self.spheres[i] == sphere then
i = i + 1
end
end
end
function SphereGroup:pushSphereBack(color)
-- color - the color of sphere.
-- This GENERATES a sphere without any animation.
self:addSphere(color, nil, nil, nil, 1)
end
function SphereGroup:pushSphereFront(color)
-- color - the color of sphere.
-- This GENERATES a sphere without any animation.
self:addSphere(color, nil, nil, nil, #self.spheres + 1)
end
---Adds a new sphere to this group.
---@param color integer The color of a sphere to be inserted.
---@param pos Vector2? The position in where was the shot sphere.
---@param time number? How long will that sphere "grow" until it's completely in its place.
---@param sphereEntity SphereEntity? A sphere entity to be used (nil = create a new entity).
---@param position integer The new sphere will gain this ID, which means it will be created BEHIND a sphere of this ID in this group.
---@param effects table? A list of effects to be applied.
---@param gaps table? A list of gaps through which this sphere has traversed.
function SphereGroup:addSphere(color, pos, time, sphereEntity, position, effects, gaps)
local sphere = Sphere(self, nil, color, pos, time, sphereEntity, gaps)
local prevSphere = self.spheres[position - 1]
local nextSphere = self.spheres[position]
sphere.prevSphere = prevSphere
sphere.nextSphere = nextSphere
if prevSphere then prevSphere.nextSphere = sphere end
if nextSphere then nextSphere.prevSphere = sphere end
table.insert(self.spheres, position, sphere)
if effects then
for i, effect in ipairs(effects) do
sphere:applyEffect(effect)
end
end
-- if it's a first sphere in the group, lower the offset
if position == 1 then
self.offset = self.offset - sphere:getDesiredSize() / 2
if nextSphere then
self.offset = self.offset - nextSphere:getDesiredSize() / 2
end
self:updateSphereOffsets()
end
sphere:updateOffset()
end
function SphereGroup:getAddSpherePos(position)
-- we can't add spheres behind the vise
if not self.prevGroup and position == 1 and not self.config.noScarabs then
return 2
end
return position
end
-- position will check for not nil since 0 is a valid value for position
function SphereGroup:destroySphere(position, crushed)
-- no need to divide if it's the first or last sphere in this group
if position ~= nil then
if position == 1 then
-- Shift the group offset to the next sphere. It might not exist.
self.offset = self.offset + self.spheres[position].size / 2
if self.spheres[position + 1] then
self.offset = self.offset + self.spheres[position + 1].size / 2
end
self.spheres[position]:delete(crushed)
table.remove(self.spheres, position)
self:updateSphereOffsets()
self:checkUnfinishedDestructionAtSpawn()
elseif position == #self.spheres then
self.spheres[position]:delete(crushed)
table.remove(self.spheres, position)
else
self:divide(position)
self.spheres[position]:delete(crushed)
table.remove(self.spheres, position)
end
end
self:checkDeletion()
end
function SphereGroup:destroySphereVisually(position, ghostTime, crushed)
if position ~= nil then
self.spheres[position]:deleteVisually(ghostTime, crushed)
end
end
function SphereGroup:destroySpheres(position1, position2)
-- to avoid more calculation than is needed
-- example:
-- before: oooo[ooo]ooooooo (p1 = 5, p2 = 7) !BOTH INCLUSIVE!
-- after: oooo ooooooo gap length: 3
-- check if it's on the beginning or on the end of the group
if position1 == 1 then
-- Shift the group offset to the next sphere. It might not exist.
self.offset = self.offset + self.spheres[position2].size / 2
if self.spheres[position2 + 1] then
self.offset = self.offset + self.spheres[position2 + 1].size / 2
end
for i = 1, position2 do
self.spheres[1]:delete()
table.remove(self.spheres, 1)
end
self:updateSphereOffsets()
self:checkUnfinishedDestructionAtSpawn()
elseif position2 == #self.spheres then -- or maybe on the end?
for i = position1, position2 do
self.spheres[#self.spheres]:delete()
table.remove(self.spheres, #self.spheres)
end
else
-- divide on the end of the broken spheres
self:divide(position2)
for i = position1, position2 do
self.spheres[#self.spheres]:delete()
table.remove(self.spheres, #self.spheres)
end
end
self:checkDeletion()
end
function SphereGroup:checkUnfinishedDestructionAtSpawn()
-- If this is an unfinished group, this means we're removing spheres at the spawn point.
-- Thus, in order to avoid bugs, we need to create a new sphere group behind this one at the path origin point
-- and flag that one as the new unfinished group.
-- Spawn that sphere group only when there are no spheres behind the spawn point. Either in this, or the next sphere group.
local noSpheresHere = #self.spheres == 0 or self:getBackPos() > 0
local noSpheresNext = not self.nextGroup or self.nextGroup:getBackPos() > 0
if self:isUnfinished() and noSpheresHere and noSpheresNext then
local newGroup = SphereGroup(self.sphereChain, nil)
-- Update group links.
self.prevGroup = newGroup
newGroup.nextGroup = self
-- add to the master, on the back
table.insert(self.sphereChain.sphereGroups, newGroup)
elseif #self.spheres == 0 then
-- If there's no fresh group which would be unfinished, we're passing that title to the next group. Let this one die...
self:delete()
end
end
function SphereGroup:updateSphereOffsets()
for i, sphere in ipairs(self.spheres) do
sphere:updateOffset()
end
end
function SphereGroup:checkDeletion()
-- abort if this group is unfinished
if self:isUnfinished() then
return
end
-- if this group contains no spheres, it gets yeeted
local shouldDelete = true
for i, sphere in ipairs(self.spheres) do
if not sphere.delQueue then
shouldDelete = false
end
end
if shouldDelete then
self:delete()
end
-- if there's only a vise in this chain, the whole chain gets yeeted!
if not self.prevGroup and not self.nextGroup then
if self.config.noScarabs then
if #self.spheres == 0 then
self.sphereChain:delete(false)
end
else
if #self.spheres == 1 and self.spheres[1].color == 0 then
self.spheres[1]:delete()
self.sphereChain:delete(false)
end
end
end
end
function SphereGroup:move(offset)
self.offset = self.offset + offset
self:updateSphereOffsets()
-- if reached the end of the level, it's over
if self:getLastSphereOffset() >= self.sphereChain.path.length and
not self:isMagnetizing() and
not self:hasShotSpheres() and
not self:hasLossProtectedSpheres() and
not self:hasGhostSpheres() and
not self.map.level:areMatchesPredicted() and
not self.map.isDummy
then
self.map.level:lose()
end
if offset <= 0 then
-- if it's gonna crash into the previous group, move only what is needed
-- join the previous group if this group starts to overlap the previous one
if self.prevGroup and #self.prevGroup.spheres > 0 and self:getBackPos() - self.prevGroup:getFrontPos() < 0 then
self:join()
end
end
-- check the other direction too
if offset > 0 then
if self.nextGroup and self.nextGroup:getBackPos() - self:getFrontPos() < 0 then
self.nextGroup:join()
end
end
end
function SphereGroup:join()
-- join this group with a previous group
-- the first group has nothing to join to
if not self.prevGroup then
return
end
-- deploy the value to check for combo and linking later
local joinPosition = #self.prevGroup.spheres
-- modify the previous group to contain the joining spheres
for i, sphere in ipairs(self.spheres) do
table.insert(self.prevGroup.spheres, sphere)
sphere.sphereGroup = self.prevGroup
end
if self.speed < 0 then
self.prevGroup.speed = self.config.knockbackSpeedBase + self.config.knockbackSpeedMult * math.max(self.sphereChain.combo, 1)
self.prevGroup.speedTime = self.config.knockbackTime
end
-- link the spheres from both groups
self.prevGroup.spheres[joinPosition].nextSphere = self.spheres[1]
self.spheres[1].prevSphere = self.prevGroup.spheres[joinPosition]
-- recalculate sphere positions
self.prevGroup:updateSphereOffsets()
-- remove this group
self:delete()
-- check for combo
if not self.map.level.lost and _Game.session:colorsMatch(self.prevGroup.spheres[joinPosition].color, self.spheres[1].color) and self.matchCheck and self.prevGroup:shouldMatch(joinPosition) then
self.prevGroup:matchAndDelete(joinPosition)
end
-- check for fragile spheres
self.prevGroup:destroyFragileSpheres()
self:destroyFragileSpheres()
-- play a sound
_Game:playSound(self.config.joinSound, 1, self.sphereChain.path:getPos(self.offset))
end
function SphereGroup:divide(position)
-- example:
-- group: ooooooo
-- position: 3
-- groups after: ooo | oooo
-- that means this group will remain with 3 spheres (the break appears AFTER the specified position) and the rest will be given to a new group
-- first, create a new group and give its properties there
local newGroup = SphereGroup(self.sphereChain)
newGroup.offset = self:getSphereOffset(position + 1)
newGroup.speed = ((self.config.luxorized and self.sphereChain.combo > 1) or self.config.knockbackStopAfterTime) and 0 or self.speed
for i = position + 1, #self.spheres do
local sphere = self.spheres[i]
sphere.sphereGroup = newGroup
table.insert(newGroup.spheres, sphere)
self.spheres[i] = nil
end
-- break sphere links between two new groups
self.spheres[position].nextSphere = nil
newGroup.spheres[1].prevSphere = nil
-- recalculate sphere positions
newGroup:updateSphereOffsets()
-- rearrange group links
newGroup.prevGroup = self
newGroup.nextGroup = self.nextGroup
if self.nextGroup then
self.nextGroup.prevGroup = newGroup
end
-- here, the previous group stays the same
self.nextGroup = newGroup
-- add to the master
table.insert(self.sphereChain.sphereGroups, self.sphereChain:getSphereGroupID(self), newGroup)
end
function SphereGroup:delete()
if self.delQueue then
return
end
-- do this if this group is empty
self.delQueue = true
-- update links !!!
if self.prevGroup then
self.prevGroup.nextGroup = self.nextGroup
self.prevGroup:checkDeletion() -- if the vise might be alone in its own group and last spheres ahead of him are being just destroyed
end
if self.nextGroup then
self.nextGroup.prevGroup = self.prevGroup
end
table.remove(self.sphereChain.sphereGroups, self.sphereChain:getSphereGroupID(self))
end
function SphereGroup:destroyFragileSpheres()
-- Ultra-Safe Loop (TM)
local i = 1
while self.spheres[i] do
local sphere = self.spheres[i]
if not sphere.delQueue and sphere:isFragile() then
sphere:matchEffectFragile()
end
if self.spheres[i] == sphere then
i = i + 1
end
end
end
-- Unloads this group.
function SphereGroup:destroy()
for i, sphere in ipairs(self.spheres) do
sphere:destroy()
end
end
function SphereGroup:shouldFit(position)
return
self:getSphereInChain(position - 1) and _Game.session:colorsMatch(self:getSphereInChain(position - 1).color, self.spheres[position].color)
or
self:getSphereInChain(position + 1) and _Game.session:colorsMatch(self:getSphereInChain(position + 1).color, self.spheres[position].color)
end
function SphereGroup:shouldBoostCombo(position)
return self:getMatchLengthInChain(position) >= 3
end
function SphereGroup:shouldMatch(position)
local position1, position2 = self:getMatchBounds(position)
-- if not enough spheres
if position2 - position1 < 2 then
return false
end
if self.config.permitLongMatches then
-- if is magnetizing with previous group and we want to maximize the count of spheres
if self.prevGroup and not self.prevGroup.delQueue and _Game.session:colorsMatch(self.prevGroup:getLastSphere().color, self.spheres[1].color) and position1 == 1 then
return false
end
-- same check with the next group
if self.nextGroup and not self.nextGroup.delQueue and _Game.session:colorsMatch(self:getLastSphere().color, self.nextGroup.spheres[1].color) and position2 == #self.spheres then
return false
end
end
-- all checks passed?
return true
end
function SphereGroup:matchAndDelete(position)
local position1, position2 = self:getMatchBounds(position)
local length = (position2 - position1 + 1)
local boostCombo = false
-- abort if any sphere from the given ones has not joined yet and see if we have to boost the combo
for i = position1, position2 do
if self.spheres[i].appendSize < 1 then
return
end
boostCombo = boostCombo or self.spheres[i].boostCombo
end
local pos = self.sphereChain.path:getPos((self:getSphereOffset(position1) + self:getSphereOffset(position2)) / 2)
local color = self.spheres[position].color
-- First, check if any of the matched spheres do have the match effect already.
local effectName = self.map.level.matchEffect
local effectGroupID = nil
for i = position1, position2 do
if self.spheres[i]:hasEffect(effectName) then
effectGroupID = self.spheres[i]:getEffectGroupID(effectName)
break
end
end
-- If not found, create a new sphere effect group with a sphere at position, so that sphere is noted down as a cause.
if not effectGroupID then
effectGroupID = self.sphereChain.path:createSphereEffectGroup(self.spheres[position])
end
-- Now, apply the effect.
for i = position1, position2 do
self.spheres[i]:applyEffect(effectName, nil, nil, effectGroupID)
end
end
-- Similar to the one above, because it also grants score and destroys spheres collectively, however the bounds are based on an effect.
function SphereGroup:matchAndDeleteEffect(position, effect)
local effectConfig = _Game.configManager.sphereEffects[effect]
local effectGroupID = self.spheres[position]:getEffectGroupID(effect)
-- Prepare a list of spheres to be destroyed.
local spheres = {}
local position1 = nil
local position2 = 0
if effectConfig.causeCheck then
-- Cause check: destroy all spheres in the same group if they have the same cause.
for i, sphere in ipairs(self.spheres) do
if sphere:hasEffect(effect, effectGroupID) and not sphere:isGhost() then
table.insert(spheres, sphere)
if not position1 then
position1 = i
end
position2 = i
end
end
else
-- No cause check: destroy all spheres in the same group if they lay near each other.
position1, position2 = self:getEffectBounds(position, effect)
for i = position1, position2 do
if not self.spheres[i]:isGhost() then
table.insert(spheres, self.spheres[i])
end
end
end
local length = #spheres
-- If there are precisely zero spheres to be destroyed, abort.
if length == 0 then
return
end
local prevSphere = self.spheres[position1 - 1]
local nextSphere = self.spheres[position2 + 1]
local boostCombo = false
-- Abort if any sphere from the given ones has not joined yet and see if we have to boost the combo.
for i, sphere in ipairs(spheres) do
if sphere.appendSize < 1 then
return
end
boostCombo = boostCombo or sphere.boostCombo
end
boostCombo = boostCombo and effectConfig.canBoostCombo
-- Retrieve and simplify a list of gaps. Only the cause sphere is checked.
local gaps = self.spheres[position].gaps
-- Determine the center position and destroy spheres.
local pos = self.sphereChain.path:getPos((self:getSphereOffset(position1) + self:getSphereOffset(position2)) / 2)
local color = self.sphereChain.path:getSphereEffectGroup(effectGroupID).cause.color
for i = #spheres, 1, -1 do
local n = self:getSphereID(spheres[i])
if effectConfig.ghostTime then
self:destroySphereVisually(n, effectConfig.ghostTime)
else
self:destroySphere(n)
end
end
-- Destroy adjacent spheres if they are stone spheres.
if nextSphere and nextSphere:isStone() then
self.nextGroup:destroySphere(self.nextGroup:getSphereID(nextSphere))
end
if prevSphere and prevSphere:isStone() then
self:destroySphere(self:getSphereID(prevSphere))
end
-- Play a sound.
-- FORK-SPECIFIC CODE: Move the settings from game module manager. Temporary fix while integrating Sound Events
if effectConfig.destroySound == "hardcoded" then
local destroySoundParams_name = "sound_events/sphere_destroy_chime_1.json"
local destroySoundParams_pitch = 1 + (math.min(self.sphereChain.combo,7))/12
_Game:playSound(destroySoundParams_name, destroySoundParams_pitch, pos)
_Game:playSound("sound_events/sphere_destroy_1.json", 1, pos)
else
_Game:playSound(effectConfig.destroySound, 1, pos)
end
if #gaps > 0 then
-- NOTE: Zuma Blitz does not pitch/repeat the Gap Bonus sound in case of double+ gap bonuses.
_Game:playSound("sound_events/gap_bonus.json")
end
-- Boost chain and combo values.
if effectConfig.canBoostChain then
self.sphereChain.combo = self.sphereChain.combo + 1
end
if boostCombo then
self.map.level.combo = self.map.level.combo + 1
end
-- FORK-SPECIFIC CODE: chain chime
-- Place this below chain and combo value boosting.
if boostCombo and self.map.level.combo > 5 then
local comboSoundParams_name = "sound_events/chain_bonus_1.json"
local comboSoundParams_pitch = 1 + (math.min(self.map.level.combo-5, 10))/12
_Game:playSound(comboSoundParams_name, comboSoundParams_pitch, pos)
end
-- chain blast
if self.map.level:getParameter("chainBlastEnabled") > 0 then
local chain_start = self.map.level:getParameter("chainBlastMinimum")
local chain_each = self.map.level:getParameter("chainBlastIncrement")
if self.map.level.combo >= chain_start and (self.map.level.combo - chain_start) % chain_each == 0 then
_Game.session:destroyRadius(pos, self.map.level:getParameter("chainBlastExplosionRadius"), "chainblast")
_Game:spawnParticle("particles/explosion.json", pos)
_Game:playSound("sound_events/sphere_hit_fire.json")
end
end
-- speed bonus
if self.map.level.speedTimer <= 0 then
self.map.level.speedBonusIncrement = 0
end
if self.map.level.speedBonusIncrement < self.map.level:getParameter("speedBonusMaxMult") then
self.map.level.speedBonusIncrement = self.map.level.speedBonusIncrement + 1
end
self.map.level.speedTimer = self.map.level:getParameter("speedBonusTimeBase")
local finalSpeedBonus = self.map.level.speedBonusIncrement * self.map.level:getParameter("speedBonusPointsBase")
-- Calculate and grant score.
local score = length * (self.map.level:getParameter("matchPointsBase") + finalSpeedBonus)
-- Calculate score from chain bonus
local finalChainBonus = 0
if boostCombo then
if self.map.level.combo >= self.map.level:getParameter("chainBonusChainMin") then
finalChainBonus = self.map.level:getParameter("chainBonusPointsBase") + ((self.map.level.combo - self.map.level:getParameter("chainBonusChainMin")) * self.map.level:getParameter("chainBonusPointsInc"))
-- Starting from Chain x10, grant +500 to score,
-- then add +250 every 5th chain
if self.map.level.combo == self.map.level:getParameter("chainBonusJackpotStart") then
finalChainBonus = finalChainBonus + self.map.level:getParameter("chainBonusJackpotPoints")
elseif self.map.level.combo > self.map.level:getParameter("chainBonusJackpotStart") and score % self.map.level:getParameter("chainBonusJackpotEach") == 0 then
finalChainBonus = finalChainBonus + self.map.level:getParameter("chainBonusJackpotEachPoints")
end
score = score + finalChainBonus
end
end
-- Calculate score from combos
-- Combos give score + 1000 x combo
local finalComboBonus = 0
if effectConfig.applyChainMultiplier then
finalComboBonus = (self.map.level:getParameter("comboBonusPointsBase") * (self.sphereChain.combo - 1))
score = score + finalComboBonus
end
-- Calculate score from gap bonus
local gapbonus = 0
if #gaps > 0 then
table.unpack = table.unpack or unpack
local largestGap = math.max(table.unpack(gaps))
-- adjustment to largest gap size
largestGap = largestGap - 64 - self.map.level:getParameter("gapMinAdjustment")
if largestGap < 0 then
largestGap = 0
end
-- kroakatoa sep 2012 gap algo
--gapbonus = math.max((((300 - largestGap) / 300))^2 * 10000, 50)
--gapbonus = _MathRoundDown(gapbonus, 10) * #gaps
-- regular gap algo
-- apply a certain multiplier based on number of gaps:
local gapMultiplier = 1
if #gaps == 1 then
gapMultiplier = self.map.level:getParameter("gapMultSingle")
elseif #gaps == 2 then
gapMultiplier = self.map.level:getParameter("gapMultDouble")
elseif #gaps >= 3 then
gapMultiplier = self.map.level:getParameter("gapMultTriple")
end
local gapMax = self.map.level:getParameter("gapGapMax")
gapbonus = math.max(((gapMax - largestGap) / gapMax) * self.map.level:getParameter("gapPointsBase"), self.map.level:getParameter("gapPointMin"))
gapbonus = _MathRoundDown(gapbonus, self.map.level:getParameter("gapPointsRounding")) * gapMultiplier
score = score + gapbonus
end
self.map.level:grantScore(score)
self.sphereChain.comboScore = self.sphereChain.comboScore + score
-- add to various statistics.
self.map.level.combosScore = self.map.level.combosScore + (finalComboBonus * self.map.level.multiplier)
self.map.level.gapsScore = self.map.level.gapsScore + (gapbonus * self.map.level.multiplier)
self.map.level.speedScore = self.map.level.speedScore + (finalSpeedBonus * self.map.level.multiplier)
self.map.level.gapsNum = self.map.level.gapsNum + #gaps
self.map.level.chainScore = self.map.level.chainScore + (finalChainBonus * self.map.level.multiplier)
if self.sphereChain.combo > 1 then
self.map.level.combosNum = self.map.level.combosNum + 1
end
if self.map.level.combo > 5 then
self.map.level.chainsNum = self.map.level.chainsNum + 1
end
-- Determine and display the floating text.
-- Level:grantScore() already multiplies our score for us, so let's multiply here.
local scoreText = "+".._NumStr(score * self.map.level.multiplier)
-- Zuma's meanings of "Combo" and "Chain" is reverse from Luxor's.
-- Keep this in mind when modifying code as OpenSMCE is based off Luxor.
-- Start counting chains from Chain x6.
if boostCombo and self.map.level.combo > 5 then
scoreText = scoreText .. "\n CHAIN x" .. tostring(self.map.level.combo)
end
if effectConfig.applyChainMultiplier and self.sphereChain.combo ~= 1 then
scoreText = scoreText .. "\n COMBO x" .. tostring(self.sphereChain.combo - 1)
end
local scoreGapTexts = {"GAP SHOT", "DOUBLE GAP", "TRIPLE GAP", "QUADRUPLE GAP", "QUINTUPLE GAP"}
if #gaps > 0 then
scoreText = scoreText .. "\n" .. scoreGapTexts[#gaps]
end
local scoreFont = effectConfig.destroyFont
if scoreFont == "hardcoded" then
scoreFont = _Game.configManager.spheres[color].matchFont
end
self.map.level:spawnFloatingText(scoreText, pos, scoreFont)
-- Spawn a coin if applicable.
_Vars:set("length", length)
_Vars:set("comboLv", self.map.level.combo)
_Vars:set("chainLv", self.sphereChain.combo)
_Vars:set("comboBoost", boostCombo)
if effectConfig.destroyCollectible then
self.map.level:spawnCollectiblesFromEntry(pos, effectConfig.destroyCollectible)
end
-- Update max combo and max chain stats.
self.map.level.maxCombo = math.max(self.map.level.combo, self.map.level.maxCombo)
self.map.level.maxChain = math.max(self.sphereChain.combo, self.map.level.maxChain)
-- Update Hot Frog meter
self.map.level:incrementBlitzMeter()
end
-- Only removes the already destroyed spheres which have been ghosts.
function SphereGroup:deleteGhost(position)
local position1, position2 = self:getGhostBounds(position)
self:destroySpheres(position1, position2)
end
function SphereGroup:isMagnetizing()
--print("----- " .. (self.prevGroup and self.prevGroup:getDebugText() or "xxx") .. " -> " .. self:getDebugText() .. " -> " .. (self.nextGroup and self.nextGroup:getDebugText() or "xxx"))
--print("----- " .. tostring(self.sphereChain:getSphereGroupID(self.prevGroup)) .. " -> " .. tostring(self.sphereChain:getSphereGroupID(self)) .. " -> " .. tostring(self.sphereChain:getSphereGroupID(self.nextGroup)))
-- If this group is empty, is pending deletion or would have nothing to magnetize to, abort.
if not self.prevGroup or self.prevGroup.delQueue or #self.spheres == 0 then
return false
end
-- Get mature spheres on both ends.
local sphere1 = self.prevGroup:getLastMatureSphere()
local sphere2 = self:getFirstMatureSphere()
-- If there are no candidate spheres, abort.
if not sphere1 or not sphere2 then
return false
end
-- Abort if there are ghost spheres on any end.
if sphere1:isGhost() or sphere2:isGhost() then
return false
end
-- Check if on each side of the empty area there's the same color.
local byColor = _Game.session:colorsMatch(sphere1.color, sphere2.color)
-- The scarab can magnetize any color.
local byScarab = sphere1.color == 0
return byColor or byScarab
end
function SphereGroup:willBeMagnetizingAfterGhostDeletion()
-- Returns true if there will be magnetization processes happening after all ghost spheres in this chain are deleted.
local position = 1
while position <= #self.spheres do
local sphere = self.spheres[position]
if sphere:isGhost() then
-- Get ghost boundaries.
local position1, position2 = self:getGhostBounds(position)
-- Get spheres we will be comparing. Only non-ghost and mature spheres are counted.
local sphere1 = self:getSphereInChain(position1 - 1)
local sphere2 = self:getSphereInChain(position2 + 1)
-- Abort if there are no spheres on either end of the ghost segment.
if not sphere1 or not sphere2 then
return false
end
while sphere1:isGhost() or sphere1.size < 1 do
position1 = position1 - 1
sphere1 = self:getSphereInChain(position1)
if not sphere1 then
-- There are no more spheres, so nothing will be magnetized to. Abort the whole operation.
return false
end
end
while sphere2:isGhost() or sphere2.size < 1 do
position2 = position2 + 1
sphere2 = self:getSphereInChain(position2)
if not sphere2 then
-- There are no more spheres, so nothing will be magnetized to. Abort the whole operation.
return false
end
end
-- Now the actual part.
-- Check if on each side of the empty area there's the same color.
local byColor = _Game.session:colorsMatch(sphere1.color, sphere2.color)
-- The scarab can magnetize any color.
local byScarab = sphere1.color == 0
-- If any of these checks has passed, return true. else, keep on searching.
if byColor or byScarab then
return true
end
-- Skip all remaining ghost spheres.
position = position2
end
position = position + 1
end
return false
end
function SphereGroup:draw(color, hidden, shadow)
-- color: draw only spheres with a given color - this will enable batching and will reduce drawing time significantly
-- hidden: with that, you can filter the spheres drawn either to the visible ones or to the invisible ones
-- shadow: to make all shadows rendered before spheres
--love.graphics.print(self:getDebugText2(), 10, 10 * #self.spheres)
for i, sphere in ipairs(self.spheres) do
sphere:draw(color, hidden, shadow)
end
if _Debug.sphereDebugVisible2 then
self:drawDebug()
end
end