-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplayer.lua
More file actions
799 lines (744 loc) · 34.2 KB
/
Copy pathplayer.lua
File metadata and controls
799 lines (744 loc) · 34.2 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
local Shape = require "shape"
local Circle=require"circle"
local PolyLine=require"polyline"
local BackgroundPattern = require "backgroundPattern"
local invertShader = love.graphics.newShader("shaders/circleInvert.glsl")
local Player = Shape:extend()
Player.invertShader=invertShader
Player.moveModes={
-- North Pole (player.x, Shape.axisY). Move directions are same as Polar Coordinate System in Euclid space, that is Up / Down -> close to / away from pole. Left / Right -> along the arc centered at North Pole.
-- Up & Down: not in hyperbolic line.
-- Left & Right: in hyperbolic line.
-- Orthogonality: True.
Monopolar='Monopolar',
-- North Pole (player.x, Shape.axisY), East Pole (+∞, player.y). Up / Down -> only change y coordinate. Left / Right -> same as Monopolar, along the arc.
-- Up & Down: in hyperbolic line.
-- Left & Right: in hyperbolic line.
-- Orthogonality: False.
Bipolar='Bipolar',
Euclid='Euclid',
Natural='Natural'
}
---@class ShootType
---@field direction string front, side, back
---@field mode string straight, homing
---@field num number number of bullets
---@field damage number damage of each bullet
---@field sprite Sprite sprite of each bullet
---@field spread number spread of each bullet (angle between bullets)
---@field readyFrame number? if nil, the damage is [damage] from the beginning. if not, damage begins with 0 and increases to [damage] in [readyFrame].
---@field shootFunc nil|fun(shootType:ShootType, player:table, chargeFrame:integer):nil custom shoot function. for charge shoot mode.
local shootSprite='snake'
---@type ShootType[]
--- the order does matter, because for each direction, each bullet is arranged from center to edge (front and back) or from up to down (side). so changing order will change the order of bullets, like if 2 front straights before 2 front homings, 4 rows will be H S S H, but if 2 front homings before 2 front straights, they are S H H S.
Player.shootRows={
{
direction='front',
mode='straight',
num=4,
damage=3,
sprite=BulletSprites[shootSprite].cyan,
spread=0
},
{
direction='front',
mode='homing',
num=0,
damage=3,
sprite=BulletSprites[shootSprite].red,
spread=0
},
{
direction='side',
mode='straight',
num=0,
damage=3,
sprite=BulletSprites[shootSprite].blue,
spread=0
},
{
direction='back',
mode='straight',
num=0,
damage=6,
sprite=BulletSprites[shootSprite].purple,
spread=0
}
}
Player.shootModes={
Normal='Normal', -- shoot some bullets every shootInterval frames pressing Z key
Charge='Charge', -- hold Z key to charge, release to shoot bullets according to charge level
}
function Player:new(args)
Player.super.new(self, {x=args.x, y=args.y})
self.direction=0
-- in natural move mode, the direction of the right-hand-side. initially it's 0, means without moving, the right to player is the same as the right to the screen. (it's not the "up" direction where player's sprite faces.)
self.naturalDirection=0
self.lifeFrame=9999999
self.speed=0
self.moveSpeed=args.moveSpeed or 60
self.diagonalSpeedAddition=false -- if false, speed is always moveSpeed. if true, speed is the addition of 2 vectors of U/D and L/R. (Vanilla game is false but dunno why I implemented true from very beginning (^^;))
self.focusFactor=0.4444
self.centerX=400
self.radius = 0.5
self.drawRadius=0.5
-- orientation determines extra rotation of player sprite and focus sprite. since player sprite faces up, orientation is normally 0. It's not 0 in rare cases, like when calculating mirrored player sprite in 7-4.
self.orientation=0
if args.noBorder then
self.border=nil
else
-- rectangle border, only used in level 1 and 2. very spaghetti i know
self.border=args.border or {
inside=function(_,x,y)
return x>=150 and x<=650 and y>=0 and y<=600
end,
default=true,
remove=function()end
}
end
self.maxhp=3
self.hp=self.maxhp
self.hpRegen=0
self.grazeHpRegen=0
self.grazeCount=0
self.grazeCountForFlashbomb=0
self.grazeReqForFlashbomb=100
self.enableFlashbomb=false
self.accumulativeFlashbomb=false
self.hurt=false --to check perfect completion
self.damageTaken=0
self.invincibleTime=0
self.hitInvincibleFrame=60
self.grazeRadiusFactor=15
self.shootMode=Player.shootModes.Normal
self.shootRows=copy_table(Player.shootRows)
self.shootRadius=0.5
self.shootTransparency=0.1
self.shootInterval=3
self.canShootDuringInvincible=false
self.chargeFrame=0 -- frames of holding Z key, Charge shoot mode
-- instant retry when hurt upgrade
self.instantRetry=false
self.moveMode=args.moveMode or Player.moveModes.Euclid
self.dieShockwaveRadius=2
self.keyRecord={}
self.replaying=args.replaying or false
if self.replaying then
self:setReplaying()
end
self.key2Value={up=1,right=2,down=4,left=8,lshift=16,z=32,x=64,c=128}
self.keyEverPressed={} -- to check restriction nicknames like no focus
self.keyIsDown=love.keyboard.isDown
self.keyIsPressed=isPressed -- check if current frame is the first frame that key be pressed down. only used for switching hyperbolic model (X key)
self.realCreatedTime=os.date('%Y-%m-%d %H:%M:%S')
end
function Player:setReplaying()
self.replaying=true
self.keyIsDown=function(key,frame)
local record=self.keyRecord[(frame or self.frame)+1] --this is because when recording keys first frame is stored at index 1 (by table.insert), while when playing at first frame key value is loaded from keyRecord before update, so self.frame=0
local val=self.key2Value[key]
if record and val then
return record%(val*2)>=val
end
return false
end
self.keyIsPressed=function(key)
local currentDown=self.keyIsDown(key)
local lastDown=self.keyIsDown(key,self.frame-1)
return currentDown and not lastDown
end
end
function Player:isDownInt(keyname)
return self.keyIsDown(keyname)and 1 or 0
end
function Player:update(dt)
if not self.replaying then -- record keys pressed
local keyVal=0
for key, value in pairs(self.key2Value) do
if self.keyIsDown(key)then
self.keyEverPressed[key]=true
keyVal=keyVal+value
end
end
table.insert(self.keyRecord,keyVal)
end
self:calculateShoot()
self:moveUpdate(dt)
-- handle invincible time from hit
self.invincibleTime=self.invincibleTime-dt
if self.invincibleTime<=0 then
self.invincibleTime=0
end
-- hp regen
self.hp=math.clamp(self.hp+self.hpRegen*dt,0,self.maxhp)
self:calculateMovingTransitionSprite()
self:calculateFocusPointTransparency()
self:calculateFlashbomb()
if self.keyIsPressed('c') then
self:tryReleaseFlashBomb()
end
self.grazeCountThisFrame=0
end
function Player:remove()
if self:distanceRemoveCheck() then
-- removed by distance, unlock secret nickname
EventManager.post(EventManager.EVENTS.PLAYER_REMOVED_BY_DISTANCE)
end
Player.super.remove(self)
end
function Player:calculateShoot()
-- shooting bullet
if self.shootMode==Player.shootModes.Normal then
if self.keyIsDown('z') and self.frame%self.shootInterval==0 then
self:shoot()
end
elseif self.shootMode==Player.shootModes.Charge then
if self.keyIsDown('z') then
if self.chargeFrame==0 then
SFX:play('notice',true,1)
end
self.chargeFrame=self.chargeFrame+1
elseif self.chargeFrame>0 then
SFX:play('cancel',true,1) -- some sfx rare in level (they are used in menus) for more distinct
self:chargeShoot(self.chargeFrame)
self.chargeFrame=0
end
end
if self.keyIsDown('z') then
EventManager.post(EventManager.EVENTS.PLAYER_SHOOTING,self)
end
end
-- return vx, vy from keyboard input. normally this will directly be player's move speed, but in some levels simulating platformer (with gravity) more calculation is needed.
function Player:getKeyboardMoveSpeed()
local rightDirOffset,downDirOffset
local the=math.atan2(self.y-Shape.axisY,self.x-self.centerX)-math.pi/2
if self.moveMode==Player.moveModes.Monopolar then
rightDirOffset=the
downDirOffset=rightDirOffset
elseif self.moveMode==Player.moveModes.Bipolar then
rightDirOffset,downDirOffset=the,0
elseif self.moveMode==Player.moveModes.Euclid then
rightDirOffset,downDirOffset=0,0
elseif self.moveMode==Player.moveModes.Natural then
rightDirOffset=self.naturalDirection
downDirOffset=self.naturalDirection
end
local rightx,righty=math.rTheta2xy(1,rightDirOffset)
local downx,downy=math.rTheta2xy(1,downDirOffset+math.pi/2)
-- self.direction=rightDir
local rightAmount=self:isDownInt("right")-self:isDownInt("left")
local downAmount=self:isDownInt("down")-self:isDownInt("up")
local vxunit,vyunit=rightx*rightAmount+downx*downAmount, righty*rightAmount+downy*downAmount
local vlen,dir=math.xy2rTheta(vxunit,vyunit)
local speed=vlen>0 and self.moveSpeed or 0 -- if vlen==0, then player is not moving, so speed is 0.
if rightAmount~=0 and downAmount~=0 and self.diagonalSpeedAddition then
speed=speed*math.sqrt(vxunit^2+vyunit^2) -- it means when moving diagonally, the speed is the addition of 2 vectors of U/D and L/R. Not multiplying by sqrt(2) is because U/D vector and L/R vector could be not orthogonal.
end
if self.keyIsDown('lshift') then
speed=speed*self.focusFactor
end
return speed, dir
end
function Player:limitInBorder()
if self.border and self.border.default then
local miny=10
local maxy=580
self.y=math.clamp(self.y,miny,maxy)
local minx=150
local maxx=650
local bias=20*(self.y-Shape.axisY)/(maxy-Shape.axisY) -- when y=Shape.axisY, bias=0, when y=maxy, bias=20
self.x=math.clamp(self.x,minx+bias,maxx-bias)
return
end
local count=0
while self.border and count<10 do
count=count+1
local line={self.border:inside(self.x,self.y)}
if line[1] then -- inside border
break
end
local p=Shape.nearestToLine(self.x,self.y,line[2],line[3],line[4],line[5])
self.x=p[1]--xref+dot*dirx
self.y=p[2]--yref+dot*diry
end
end
function Player:moveUpdate(dt)
local xref=self.x
local yref=self.y
self.speed, self.direction=self:getKeyboardMoveSpeed()
self.super.update(self,dt) -- actually move
-- limit player in border
self:limitInBorder()
local moveDistance=math.distance(self.x,self.y,xref,yref)
self.moveSum=(self.moveSum or 0)+moveDistance
if self.moveMode==Player.moveModes.Natural then
-- problems & thoughts: 1. when player is blocked by border, the moveDistance is still the assumed distance before calculating border. (solved by calculating math.distance of current pos and ref pos)
-- 2. while not calling self:testRotate, the rightward direction is changing correctly. So, the ideal operation is posing a hyperbolic rotate transform before drawing all things (and restore after it), but obviously love2d doesn't support that. Hyperbolic rotate transform is simple as the testRotate, and the difference between normal rotate is that, the y=-100 line doesn't change (rotating player's view shouldn't change the line). Applying testRotate to all objects, changing their real position and cancelling out naturalDirection's update is not ideal and actually wrong.
-- 3. A paradox? We know that this hyperbolic space H² is isotropic, but in this implementation the naturalDirection only changes when player's x coordinate changes, so that x and y aren't equal. The reason is probably that the projection used to map H² to E² (half-plane model) is not isotropic, and the coordinates to store objects are actually in E², not H².
self.direction=Shape.to(xref,yref,self.x,self.y)
local dtheta=-moveDistance/self:getMoveRadius()
if love.keyboard.isDown('[') then --debug use
self.naturalDirection=self.naturalDirection-0.03
elseif love.keyboard.isDown(']') then
self.naturalDirection=self.naturalDirection+0.03
end
self.naturalDirection=(self.naturalDirection+dtheta)%(math.pi*2)
end
end
-- calculate which player sprite to use (normal, moveTransition and moving). Specifically, when not moving, loop through 8 normal sprites for each 8 frames. when moving, loop through 4 moveTransition sprites for each 2 frames, and after it loop through 8 moving sprites for each 8 frames. Use [tilt] to record.
function Player:calculateMovingTransitionSprite()
if Shape.timeSpeed==0 then
return -- stop sprite transition when time is stopped
end
local lingerFrame={normal=8,moveTransition=2,moving=8}
local tiltMax=#Asset.player.moveTransition.left*lingerFrame.moveTransition
local right=self:isDownInt("right")-self:isDownInt("left")
local tilt=self.tilt or 0
local keptFrame=self.keptFrame or 0 -- how long player has been keeping unmove or moving at the same direction (after transition of tiltMax frames)
if tilt==0 then
if right==0 then
keptFrame=keptFrame+1 -- at current frame keeping unmove
else
keptFrame=0 -- start moving
tilt=tilt+right
end
elseif math.abs(tilt)==tiltMax then
if math.sign(right)==math.sign(tilt) then -- keep moving at the same direction
keptFrame=keptFrame+1
else
keptFrame=0
tilt=tilt-math.sign(tilt) -- reduce tilt as not moving at the same direction
end
else
keptFrame=0
tilt=tilt+(right==0 and -math.sign(tilt) or right) -- if do move, change tilt to the moving direction. if not moving, reduce tilt towards 0.
end
self.tilt=tilt
self.keptFrame=keptFrame
local direction=tilt>0 and 'right' or 'left'
local sprite
if tilt==0 then
sprite=Asset.player.normal[math.ceil(keptFrame/lingerFrame.normal)%#Asset.player.normal+1]
elseif math.abs(tilt)==tiltMax then
sprite=Asset.player.moving[direction][math.ceil(keptFrame/lingerFrame.moving)%#Asset.player.moving[direction]+1]
else
sprite=Asset.player.moveTransition[direction][math.ceil(math.abs(tilt)/lingerFrame.moveTransition)]
end
self.sprite=sprite
end
function Player:calculateFocusPointTransparency()
local focus=self.keyIsDown('lshift')
self.focusPointTransparency=self.focusPointTransparency or 0
local add=0.2
if focus then
self.focusPointTransparency=math.min(1,self.focusPointTransparency+add)
else
self.focusPointTransparency=math.max(0,self.focusPointTransparency-add)
end
end
-- hyperbolic rotate all points by angle around player (actually change coordinates, not a visual effect, so it must be reverted later)
-- restoring value is quicker and more accurate than calling (-angle)
-- G.hyperbolicRotateShader can be used to rotate quad vertices, so player:testRotate doesn't need to loop through circle, laser (and anything with sprite). shader cannot be used on nonsprites (like polyline) and i don't know why. upon test, when followModeTransform is identity matrix nonsprites works correctly, from there i can't make further progress on debugging. moreover for nonsprites draw i set different line width based on y, and when using shader this cannot be properly done. but whatever nonsprites are of small number (and expected to be replaced eventually) so it won't be a big problem on efficiency. main reason on not recommending shader is glsl only uses float so precision problem will cause frequent flickering sprites, and the improvement seems around 10%. (see rep42-103.00-100.00s-noshader.txt and rep42-103.00-100.00s-withshader.txt)
-- change shader implementation to mobius transform eliminates flickering and seems faster. mobius transform can't directly calculate direction change so can't be used in player:testRotate (in shader a quad's four vertices after transformation automatically changes direction, though with little shear)
function Player:testRotate(angle,restore)
---@param v table the object to be rotated. It must have x, y and direction (optional) attributes.
---@param canOmit boolean? if true, the object will not be rotated if the distance between player and object is greater than 200. This is to avoid unnecessary calculation for objects that are far away from player to increase efficiency.
local function rotate(v,canOmit)
local r=Shape.distance(self.x,self.y,v.x,v.y)
v.testRotateRef={v.x,v.y,v.direction}
if canOmit and r>200 then
return
end
local theta,thetaRev=Shape.to(self.x,self.y,v.x,v.y),Shape.to(v.x,v.y,self.x,self.y)
v.x,v.y=Shape.rThetaPos(self.x,self.y,r,theta+angle)
local thetaRev2=Shape.to(v.x,v.y,self.x,self.y)
if v.direction then
v.direction=v.direction+thetaRev2-thetaRev -- not using +angle is because the uninterchangeability of direction at both point of a straight line (in hyperbolic plane).
end
end
--[[
(T(z)-p)/(T(z)-q)=re^(iθ)(z-p)/(z-q)
for this rotation, p is self.x+i*self.y, q is self.x+i*(2*Shape.axisY-self.y), re^(iθ) is cos(angle)+i*sin(angle)
reference: https://math.libretexts.org/Bookshelves/Geometry/Geometry_with_an_Introduction_to_Cosmic_Topology_(Hitchman)/03%3A_Transformations/3.05%3A_Mobius_Transformations%3A_A_Closer_Look
]]
-- local S=Mobius(1,Complex(-self.x,-self.y),1,Complex(-self.x,self.y-2*Shape.axisY))
-- local U=Mobius(Complex(math.cos(angle),math.sin(angle)),0,0,1)
-- local Sinv=S:inverse()
-- local T=Sinv:compose(U):compose(S)
-- local function rotate(v)
-- v.testRotateRef={v.x,v.y,v.direction}
-- local z=Complex(v.x,v.y)
-- local z2=T:apply(z)
-- v.x,v.y=z2.re,z2.im
-- end
if restore then
rotate=function(v)
v.x,v.y,v.direction=v.testRotateRef[1],v.testRotateRef[2],v.testRotateRef[3]
end
end
local list={BulletSpawner,Enemy,Circle,Laser} -- due to different implementation, PolyLine has to be handled separately. not ideal
if G.UseHypRotShader then
list={} -- sprites don't need to be rotated
end
for k,cls in pairs(list)do
for k2,obj in pairs(cls.objects)do
rotate(obj)--,true -- this doesn't seem speed things
end
end
local unomitableList={Laser.LaserUnit,Effect.Larger,Effect.Shockwave} -- Laser can be very long so shouldn't omit rotation
if G.UseHypRotShader then
unomitableList={} -- sprites don't need to be rotated
end
for k,cls in pairs(unomitableList)do
for k2,obj in pairs(cls.objects)do
rotate(obj)
end
end
if not(G.UseHypRotShader) then
for k2,obj in pairs(PolyLine.objects)do
for k,point in pairs(obj.points) do
rotate(point)
end
end
end
if (G.backgroundPattern:is(BackgroundPattern.FixedTesselation) or G.backgroundPattern:is(BackgroundPattern.FollowingTesselation)) and not G.UseHypRotShader then
local pattern=G.backgroundPattern
for i=1,#pattern.sidesTable do
rotate(pattern.sidesTable[i][1])
rotate(pattern.sidesTable[i][2])
end
end
end
-- pos is like: -3, -2, -1, 1, 2, 3
function Player:shootDirStraight(pos,shootType,theta)
local damage,sprite=shootType.damage,shootType.sprite or BulletSprites[shootSprite].cyan
local x,y,r=Shape.getCircle(self.x,self.y,self.radius)
local dx,dy=r*2,r*(math.abs(pos)*2-1)*(pos<0 and -1 or 1)
local cir=Circle({x=self.x+dx*math.cos(theta)-dy*math.sin(theta), y=self.y+dx*math.sin(theta)+dy*math.cos(theta), radius=self.shootRadius, lifeFrame=shootType.lifeFrame or 60, sprite=sprite, batch=Asset.playerBulletBatch, spriteTransparency=self.shootTransparency})
Event.DelayEvent{
obj=cir,delayFrame=3,executeFunc=function()
cir.spriteTransparency=0.5
end
}
cir.fromPlayer=true
cir.safe=true
cir.direction=theta+shootType.spread*pos
cir.speed=200
if shootType.readyFrame then
cir.damage=0
Event.EaseEvent{
obj=cir,easeFrame=shootType.readyFrame,aimTable=cir,aimKey='damage',aimValue=damage,
}
else
cir.damage=damage
end
return cir
end
function Player:shootFrontStraight(pos,shootType)
return self:shootDirStraight(pos,shootType,-math.pi/2+self.naturalDirection)
end
function Player:shootBackStraight(pos,shootType)
return self:shootDirStraight(pos,shootType,math.pi/2+self.naturalDirection)
end
-- note that this shoots 2 bullets, 1 on each side
function Player:shootSideStraight(pos,shootType)
for side=0,1 do
self:shootDirStraight(pos,shootType,math.pi*side+self.naturalDirection)
end
end
-- let [cir] trace the closest enemy.
-- [mode] determines how direction changes.
-- 'abrupt': directly set to the aim direction.
-- 'portion': (1-arg)*cir.direction+arg*aimDirection. arg defaults to 0.1.
-- 'clamp': math.clamp(aimDirection,cir.direction-arg,cir.direction+arg). defaults to 0.1.
function Player:addHoming(cir,mode,arg)
mode=mode or 'abrupt'
if mode=='portion' then
arg=arg or 0.1
elseif mode=='clamp' then
arg=arg or 0.1
end
cir.homing=true
Event.LoopEvent{
obj=cir,
period=1,
executeFunc=function()
if not cir.homing then -- some level effect removing homing
return
end
local closestEnemy
local closestDistance=self.homingDistance or 9e9
for key, value in pairs(Enemy.objects) do
local dis=Shape.distance(cir.x,cir.y,value.x,value.y)
if dis<closestDistance then
closestDistance=dis
closestEnemy=value
end
end
if closestEnemy then
local aim=math.modClamp(Shape.to(cir.x,cir.y,closestEnemy.x,closestEnemy.y),cir.direction)
if mode=='abrupt'then
cir.direction=aim
elseif mode=='portion'then
cir.direction=(1-arg)*cir.direction+arg*aim
elseif mode=='clamp'then
local da=arg
cir.direction=math.clamp(aim,cir.direction-da,cir.direction+da)
end
end
end
}
end
function Player:shootFrontHoming(pos,shootType)
local cir=self:shootFrontStraight(pos,shootType)
self:addHoming(cir,self.homingMode,self.homingArg)
end
local directionMode2ShootFunc={
front={straight=Player.shootFrontStraight,homing=Player.shootFrontHoming},
side={straight=Player.shootSideStraight,homing=Player.shootSideStraight},
back={straight=Player.shootBackStraight,homing=Player.shootBackStraight}
}
function Player:shoot()
if Shape.timeSpeed==0 then
return -- dont shoot when time is stopped
end
if self.invincibleTime>0 and not self.canShootDuringInvincible then
return -- don't shoot when invincible
end
-- local x,y,r=Shape.getCircle(self.x,self.y,self.radius)
local rows={front=0,side=0,back=0}
for k,shootType in pairs(self.shootRows) do
local direction=shootType.direction
local mode=shootType.mode
local shootFunc=directionMode2ShootFunc[direction][mode]
if not shootFunc then
error('Invalid shoot type: '..direction..' '..mode)
end
local num=shootType.num
for i=1,num do
rows[direction]=rows[direction]+1
local pos=math.ceil(rows[direction]/2)*(-1)^rows[direction]
shootFunc(self,pos,shootType)
end
end
end
function Player:findShootType(direction,mode)
for k,shootType in pairs(self.shootRows) do
if shootType.direction==direction and shootType.mode==mode then
return shootType
end
end
end
function Player:chargeShoot(chargeFrame)
for k,shootType in pairs(self.shootRows) do
if shootType.shootFunc then -- just let upgrades inject their own shoot functions
shootType.shootFunc(shootType,self,chargeFrame)
end
end
end
function Player:draw()
-- Formula: center (x,y) and radius r should be drawn as center (x,y*cosh(r)) and radius y*sinh(r)
local color={love.graphics.getColor()}
love.graphics.setColor(1,1,0)
if self.invincibleTime and self.invincibleTime>0 then
love.graphics.setColor(1,0,0)
end
-- Shape.drawCircle(self.x,self.y,self.drawRadius)
love.graphics.setColor(color[1],color[2],color[3],color[4])
-- love.graphics.circle("line", self.x, self.y, 1) -- center point
-- love.graphics.print(tostring(self.hp),self.x-5,self.y-8)
local orientation=(self.orientation or 0)+(G.UseHypRotShader and self.naturalDirection or 0)
local horizontalFlip=self.horizontalFlip or false
-- x and y is the actual position on screen
local x,y,r=Shape.getCircle(self.x,self.y,self.drawRadius)
--draw hit point
local focusSizeFactor=0.5
local focusOrientation=self.time*4+orientation
local drawColor={1,1,1,(self.focusPointTransparency or 1)*color[4]}
if self.forceQuadDraw then -- for now nowhere uses old method. 10-2 17 players do not lagging
Asset.playerFocusBatch:setColor(unpack(drawColor))
Asset.playerFocusBatch:add(BulletSprites.playerFocus.quad,x,y,focusOrientation,r*focusSizeFactor*(horizontalFlip and -1 or 1),r*focusSizeFactor,32,32)
else
local fanMesh=Shape.fanMesh(self.x,self.y,self.drawRadius*16,focusOrientation,BulletSprites.playerFocus.quad,Asset.bulletImage,8,drawColor) -- *16 is because sprite half width 32 * focusSizeFactor 0.5 = 16. 8 is number of triangles
-- Asset.playerFocusMeshes:add(ringMesh)
Asset.playerFocusMeshes:add(fanMesh)
end
local spriteSizeFactor=0.53
if self.sprite then
Asset.playerBatch:setColor(1,1,1,color[4])
Asset.playerBatch:add(self.sprite,x,y,orientation,r*spriteSizeFactor*(horizontalFlip and -1 or 1),r*spriteSizeFactor,Asset.player.width/2,Asset.player.height/2)
end
end
-- this function draws which keys are pressed. The keys are arranged as:
--[[
U
Shift Z X C L D R
]]
function Player:displayKeysPressed()
local x0,y0=15,500
local gridSize=15
local keysPoses={up={6,0},down={6,1},left={5,1},right={7,1},lshift={0,1},z={1,1},x={2,1},c={3,1}}
local keysText={up={text='↑',offset={0,0}},down={text='↓',offset={0,0}},left={text='←',offset={0,1}},right={text='→',offset={0,1}},lshift={text='⇧',offset={-0.5,0}},z={text='Z',offset={1,0}},x={text='X',offset={1,0}},c={text='C',offset={1,0}}}
local color={love.graphics.getColor()}
for key, value in pairs(keysPoses) do
local x,y=x0+value[1]*gridSize,y0+value[2]*gridSize
if self.keyIsDown(key) then
love.graphics.setColor(1,1,1)
love.graphics.rectangle("fill",x,y,gridSize,gridSize)
end
love.graphics.setColor(0,0,0)
love.graphics.rectangle("line",x,y,gridSize,gridSize)
SetFont(10,Fonts.zh_cn)
love.graphics.print(keysText[key].text,x+3+keysText[key].offset[1],y+2+keysText[key].offset[2])
end
love.graphics.setColor(color[1],color[2],color[3])
end
function Player:drawText()
self:displayKeysPressed()
SetFont(24)
love.graphics.print(Localize{'ui','playerHP',HP=string.format("%.2f", self.hp)},40,110)
if DEV_MODE then
love.graphics.print('X='..string.format("%.2f", self.x)..'\nY='..string.format("%.2f", self.y),30,140)
end
-- love.graphics.print(''..self.naturalDirection,100,120)
if self.enableFlashbomb then -- draw fill rate for flashbomb
local color={love.graphics.getColor()}
local x0,y0=30,570
love.graphics.setColor(1,1,1)
love.graphics.rectangle("line",x0,y0,100,10)
if self.accumulativeFlashbomb then
local times=math.floor(self.grazeCountForFlashbomb/self.grazeReqForFlashbomb)
if times>0 then
SetFont(16)
love.graphics.print('x'..times,x0,y0-20)
SetFont(24)
end
end
love.graphics.setColor(1,0.2,0.5)
love.graphics.rectangle("fill",x0,y0,100*(self.grazeCountForFlashbomb%self.grazeReqForFlashbomb)/self.grazeReqForFlashbomb,10)
love.graphics.setColor(color[1],color[2],color[3])
end
SetFont(12)
if DEV_MODE and G.UseHypRotShader then
love.graphics.print('Using rotation shader',700,580)
end
if self.unlockDiskModels then
local model=G.viewMode.hyperbolicModel
local texts={[0]='HalfPlane',[1]='PoincareDisk',[2]='KleinDisk'}
local text=texts[model]
love.graphics.print(Localize{'ui','hyperbolicModels','model'}..Localize{'ui','hyperbolicModels',text},700,560)
end
end
-- spawn a white dot to show the graze effect. Actually this random speed and direction particle has broken old replays sooooo many times each time I tweak bullet size or graze range :(
function Player:grazeEffect(amount)
amount=amount or 1
SFX:play('graze')
if self.version and isVersionSmaller(self.version,'0.2.0.1') then
Effect.Larger{x=self.x,y=self.y,speed=math.eval(50,30),direction=math.eval(1,9999),sprite=Asset.shards.dot,radius=1.25,growSpeed=1,animationFrame=20}
else -- non-random graze effect
Effect.Larger{x=self.x,y=self.y,speed=50+30*math.sin(self.x*51323.35131+self.y*46513.1333+self.frame*653.13),direction=9999*math.sin(self.x*513.35131+self.y*413.1333+self.frame*6553.13),sprite=Asset.shards.dot,radius=1.25,growSpeed=1,animationFrame=20}
end
self.grazeCountThisFrame=(self.grazeCountThisFrame or 0)+amount
if self.grazeCountThisFrame>20 then
return -- avoid too many grazes in a short time
end
-- grazeHpRegen
self.hp=math.clamp(self.hp+self.grazeHpRegen*amount,0,self.maxhp)
self.grazeCount=self.grazeCount+amount
local prevCount=self.grazeCountForFlashbomb
local newCount=prevCount+amount
local req=self.grazeReqForFlashbomb
self.grazeCountForFlashbomb=newCount
if self.accumulativeFlashbomb and math.modClamp(newCount,0,req/2)>=0 and math.modClamp(prevCount,0,req/2)<0 then -- play sound when reaching each flashbomb threshold. It's possible to graze multiple or <1 graze at once, so %req==0 will miss some cases.
SFX:play('extend',true)
EventManager.post(EventManager.EVENTS.PLAYER_ACCUMULATE_FLASHBOMB,self)
end
end
EventManager.listenTo(EventManager.EVENTS.PLAYER_GRAZE,Player.grazeEffect)
-- it's hit effect, not hp = 0 effect
-- isDirect: if true, damage is directly deducted from hp without considering invincibility time, and will not trigger invincibility time and shockwave effect. currently used for flashbomb without enough graze.
function Player:hitEffect(damage,isDirect)
if self.invincibleTime>0 and not isDirect then
return
end
if self.instantRetry and not G.replay then -- G.replay should not be true: when instant retry is on, there is no chance to save replay if player is hurt, and saved replay must be perfect completion. if it's an old replay broken it could happen
SFX:play('dead',true) -- still play dead sound for hint
G:retryLevel()
return
end
damage=damage or 1
self.damageTaken=self.damageTaken+math.min(damage,self.hp) -- only count actual damage taken
self.hp=self.hp-damage
self.hurt=true
self.hitFrame=self.frame
if self.hp<=0 then
G:lose()
end
if not isDirect then
self.invincibleTime=self.invincibleTime+self.hitInvincibleFrame/60
Effect.Shockwave{x=self.x,y=self.y,radius=self.dieShockwaveRadius,growSpeed=1.1,animationFrame=30}
end
SFX:play('dead',true)
end
EventManager.listenTo(EventManager.EVENTS.PLAYER_HIT,Player.hitEffect)
function Player:useInvertShader()
return self.hitFrame and self.frame-self.hitFrame<self.hitInvincibleFrame
end
-- part of hit effect so based on hitFrame not invincibleTime
function Player:invertShaderEffect()
-- if self.invincibleTime<=0 then return end
if not (self.hitFrame and self.frame-self.hitFrame<self.hitInvincibleFrame) then
invertShader:send("radiusInner",0)
invertShader:send("radiusOuter",0)
return
end
local t=1-(self.frame-self.hitFrame)/self.hitInvincibleFrame
-- love.graphics.setShader(invertShader)
local x,y=Shape.screenPosition(self.x,self.y)
if G.viewMode.mode==G.CONSTANTS.VIEW_MODES.FOLLOW and G.viewMode.hyperbolicModel~=G.CONSTANTS.HYPERBOLIC_MODELS.UHP then
invertShader:send("centerInner",{x,y})
invertShader:send("radiusInner",y*t*0.5)
invertShader:send("centerOuter",{x,y})
invertShader:send("radiusOuter",y*t)
return
end
local r=t*50
local x1,y1,r1=Shape.getCircle(x,y,r)
invertShader:send("centerInner",{x1,y1})
invertShader:send("radiusInner",r1)
r=t*100
x1,y1,r1=Shape.getCircle(x,y,r)
invertShader:send("centerOuter",{x1,y1})
invertShader:send("radiusOuter",r1)
end
function Player:calculateFlashbomb()
if not self.enableFlashbomb then
return
end
if self.grazeCountForFlashbomb>=self.grazeReqForFlashbomb and not self.accumulativeFlashbomb then -- release flashbomb once enough graze
self.grazeCountForFlashbomb=0
self:releaseFlashbomb()
end
end
function Player:tryReleaseFlashBomb()
local missingGrazes=self.grazeReqForFlashbomb - self.grazeCountForFlashbomb
if missingGrazes<=0 and self.accumulativeFlashbomb then -- possible accumulable flashbomb
self.grazeCountForFlashbomb=self.grazeCountForFlashbomb - self.grazeReqForFlashbomb
self:releaseFlashbomb()
return
end
if not self.emergencyBomb then
return
end
self.grazeCountForFlashbomb=0
local deductHP=missingGrazes*self.emergencyBombCostPerGraze
EventManager.post(EventManager.EVENTS.PLAYER_HIT,self,deductHP,true) -- direct damage
self:releaseFlashbomb()
end
function Player:releaseFlashbomb()
local count=self.flashbombCount or 1
self.flashbombCount=count+1
SFX:play('enemyPowerfulShot',true,0.8)
Effect.FlashBomb{x=self.x,y=self.y,direction=self.naturalDirection-math.pi/2}
end
return Player