+179
-24
Diff
round #1
+1
-1
README.md
+1
-1
README.md
···
26
26
- [x] Make Column/Row count configurable, this allows for way bigger playing fields
27
27
- [x] Add modern multiplayer, where you have to clear the game together
28
28
- [x] Config persistence
29
-
- [ ] Toggle T-Spins
29
+
- [x] T-Spins
30
30
- [ ] Points for perfect clears
31
31
- [ ] Scoreboard/Leaderboard
32
32
- [ ] Add classic Tetris multiplayer (deleted rows will be a penalty for the opponent)
+18
-11
block.go
+18
-11
block.go
···
7
7
type I_Block interface {
8
8
Init(playerId ff.Peer) I_Block
9
9
GetId() int
10
+
GetOffset() (int, int)
11
+
GetRotationState() int
10
12
Draw(offsetX, offsetY int)
11
13
DrawPreview(offsetX, offsetY int)
12
14
Move(rows, columns int)
13
15
GetCellPositions() []Position
14
-
TryRotateWithKicks(grid *Grid, clockwise bool) bool
16
+
TryRotateWithKicks(grid *Grid, clockwise bool) (bool, int)
15
17
FitsInGrid(grid *Grid) bool
16
18
}
17
19
···
26
28
cellPosCache [4]Position
27
29
}
28
30
31
+
func (b *Block) GetOffset() (int, int) {
32
+
return b.RowOffset, b.ColOffset
33
+
}
34
+
35
+
func (b *Block) GetRotationState() int {
36
+
return b.RotationState
37
+
}
38
+
29
39
func (b *Block) GetId() int {
30
40
return b.Id
31
41
}
···
87
97
return true
88
98
}
89
99
90
-
// Returns true if rotation was successful, false otherwise
91
-
// Adhering to the rules of the SRS system
92
-
func (b *Block) TryRotateWithKicks(grid *Grid, clockwise bool) bool {
100
+
// TryRotateWithKicks attempts to rotate the block using SRS wall kicks.
101
+
// Returns (true, kickIndex) on success, (false, -1) on failure.
102
+
func (b *Block) TryRotateWithKicks(grid *Grid, clockwise bool) (bool, int) {
93
103
oldState := b.RotationState
94
104
95
105
if clockwise {
···
101
111
102
112
kickTests := GetKickTests(b.Id, oldState, newState)
103
113
104
-
// Try each kick test in order
105
-
for _, offset := range kickTests {
114
+
for i, offset := range kickTests {
106
115
b.Move(offset.Row, offset.Column)
107
116
108
117
if b.FitsInGrid(grid) {
109
-
// block is rotated and positioned correctly
110
-
return true
118
+
return true, i
111
119
}
112
120
113
-
// kick invalid, undo the offset
114
121
b.Move(-offset.Row, -offset.Column)
115
122
}
116
123
117
-
// kicks invalid, undo rotation
124
+
// All kicks failed โ undo rotation
118
125
if clockwise {
119
126
b.rotateCounterClockwise()
120
127
} else {
121
128
b.rotateClockwise()
122
129
}
123
-
return false
130
+
return false, -1
124
131
}
125
132
126
133
func (b *Block) rotateClockwise() {
+1
-1
config.go
+1
-1
config.go
···
10
10
HardDropPointsEnabled: false,
11
11
SoftDropPointsEnabled: true,
12
12
LockDelayEnabled: true,
13
+
TSpinsEnabled: true,
13
14
BlockBag: BlockBagSevenBag,
14
15
Block: BlockConfig{
15
16
LBlock: BlockStyle{
···
70
71
HardDropPointsEnabled bool
71
72
LockDelayEnabled bool
72
73
73
-
// TODO: Not yet implemented
74
74
TSpinsEnabled bool
75
75
PerfectClearPointsEnabled bool
76
76
}
+140
-5
game.go
+140
-5
game.go
···
1
1
package main
2
2
3
3
import (
4
+
"fmt"
4
5
"strconv"
6
+
"strings"
5
7
6
8
ff "github.com/firefly-zero/firefly-go/firefly"
7
9
)
···
219
221
}
220
222
221
223
func (g *Game) HandleLocking(player *Player) {
224
+
tSpin := detectTSpin(&g.Grid, player) // must be before LockBlockToGrid replaces CurrentBlock
225
+
222
226
dist, success := player.LockBlockToGrid(&g.Grid)
223
227
if !success {
224
228
g.GameOver = true
225
229
g.Screen.SetScreen(ScreenResult)
226
230
return
227
231
}
228
-
g.handleScoring(dist)
232
+
g.handleScoring(dist, tSpin)
229
233
}
230
234
231
-
func (g *Game) handleScoring(hardDropDistance int) {
232
-
// Add hard drop points if enabled
235
+
func (g *Game) handleScoring(hardDropDistance int, tSpin TSpinType) {
233
236
if CONFIG.HardDropPointsEnabled && hardDropDistance > 0 {
234
237
g.Score += hardDropDistance * 2
235
238
}
236
239
237
-
// Clear rows and update score (shared for all players)
238
240
rowsCleared := g.Grid.ClearFullRows()
239
-
if rowsCleared > 0 {
241
+
if tSpin != TSpinNone {
242
+
g.scoreTSpin(tSpin, rowsCleared)
243
+
if rowsCleared > 0 {
244
+
g.Lines += rowsCleared
245
+
if CONFIG.Level != 0 {
246
+
g.UpdateLevel()
247
+
}
248
+
}
249
+
} else if rowsCleared > 0 {
240
250
g.Lines += rowsCleared
241
251
g.UpdateScore(rowsCleared, 0)
242
252
if CONFIG.Level != 0 {
···
245
255
}
246
256
}
247
257
258
+
// TSpinType distinguishes no T-Spin, Mini T-Spin, and full T-Spin.
259
+
type TSpinType int
260
+
261
+
const (
262
+
TSpinNone TSpinType = iota
263
+
TSpinMini
264
+
TSpinFull
265
+
)
266
+
267
+
// detectTSpin checks whether the player's current block qualifies as a T-Spin
268
+
// at the moment of locking. Must be called before LockBlockToGrid.
269
+
//
270
+
// Detection rules (Tetris Guideline):
271
+
// 1. Piece must be a T-block (id 6)
272
+
// 2. Last player action must have been a rotation
273
+
// 3. At least 3 of the 4 diagonal corners around the T's centre are occupied
274
+
//
275
+
// Mini vs Full:
276
+
// - If the last kick used was index 4 (the ยฑ2-row SRS kick) โ Full T-Spin
277
+
// - Otherwise: both "front" corners filled โ Full; only one โ Mini
278
+
//
279
+
// Source: https://tetris.wiki/T-Spin#Current_rules
280
+
func detectTSpin(grid *Grid, player *Player) TSpinType {
281
+
if !CONFIG.TSpinsEnabled {
282
+
return TSpinNone
283
+
}
284
+
if player.CurrentBlock.GetId() != 6 {
285
+
return TSpinNone
286
+
}
287
+
if player.lastKickIndex == -1 {
288
+
return TSpinNone
289
+
}
290
+
291
+
row, col := player.CurrentBlock.GetOffset()
292
+
centerRow, centerCol := row+1, col+1
293
+
294
+
// Count filled diagonal corners (wall/floor counts as filled)
295
+
corners := [4][2]int{
296
+
{centerRow - 1, centerCol - 1},
297
+
{centerRow - 1, centerCol + 1},
298
+
{centerRow + 1, centerCol - 1},
299
+
{centerRow + 1, centerCol + 1},
300
+
}
301
+
filled := 0
302
+
for _, c := range corners {
303
+
if grid.IsCellOutside(c[0], c[1]) || !grid.IsCellEmpty(c[0], c[1]) {
304
+
filled++
305
+
}
306
+
}
307
+
if filled < 3 {
308
+
return TSpinNone
309
+
}
310
+
311
+
// SRS kick index 4 always upgrades to a full T-Spin
312
+
if player.lastKickIndex == 4 {
313
+
return TSpinFull
314
+
}
315
+
316
+
// Front corners by rotation state (the side the T's tip faces)
317
+
rotState := player.CurrentBlock.GetRotationState()
318
+
var frontCorners [2][2]int
319
+
switch rotState {
320
+
case 0: // tip up
321
+
frontCorners = [2][2]int{{centerRow - 1, centerCol - 1}, {centerRow - 1, centerCol + 1}}
322
+
case 1: // tip right
323
+
frontCorners = [2][2]int{{centerRow - 1, centerCol + 1}, {centerRow + 1, centerCol + 1}}
324
+
case 2: // tip down
325
+
frontCorners = [2][2]int{{centerRow + 1, centerCol - 1}, {centerRow + 1, centerCol + 1}}
326
+
case 3: // tip left
327
+
frontCorners = [2][2]int{{centerRow - 1, centerCol - 1}, {centerRow + 1, centerCol - 1}}
328
+
}
329
+
330
+
filledFront := 0
331
+
for _, c := range frontCorners {
332
+
if grid.IsCellOutside(c[0], c[1]) || !grid.IsCellEmpty(c[0], c[1]) {
333
+
filledFront++
334
+
}
335
+
}
336
+
337
+
if filledFront == 2 {
338
+
return TSpinFull
339
+
}
340
+
return TSpinMini
341
+
}
342
+
343
+
// scoreTSpin awards points for a T-Spin according to the Tetris Guideline.
344
+
// Base scores: Mini(0)=100, Mini Single=200, Full(0)=400,
345
+
// Full Single=800, Full Double=1200, Full Triple=1600. All ร (level+1).
346
+
//
347
+
// Source: https://tetris.wiki/T-Spin#Rewards
348
+
func (g *Game) scoreTSpin(tSpin TSpinType, lines int) {
349
+
var base int
350
+
var tSpinMsg string
351
+
352
+
var s strings.Builder
353
+
354
+
if tSpin == TSpinMini {
355
+
tSpinMsg = "T-Spin Mini"
356
+
switch lines {
357
+
case 0:
358
+
base = 100
359
+
case 1:
360
+
base = 200
361
+
case 2:
362
+
base = 400
363
+
}
364
+
} else { // TSpinFull
365
+
tSpinMsg = "T-Spin Full"
366
+
switch lines {
367
+
case 0:
368
+
base = 400
369
+
case 1:
370
+
base = 800
371
+
case 2:
372
+
base = 1200
373
+
case 3:
374
+
base = 1600
375
+
}
376
+
}
377
+
score := base * (g.Level + 1)
378
+
s.WriteString(fmt.Sprintf("%s! (%d points)", tSpinMsg, score))
379
+
ff.LogDebug(s.String())
380
+
g.Score += score
381
+
}
382
+
248
383
func (g *Game) UpdateScore(linesCleared, movedDownPoints int) {
249
384
switch linesCleared {
250
385
case 1:
+19
-6
player.go
+19
-6
player.go
···
29
29
hardDropDistance int // Cells moved by hard drop (for scoring)
30
30
softDropCells int // Cells moved by soft drop this frame
31
31
32
+
// T-Spin detection: kick index of last successful rotation, -1 if last action was not a rotation
33
+
lastKickIndex int
34
+
32
35
gravityAccum float32
33
36
34
37
// Lock delay state
···
42
45
43
46
func newPlayer(peer ff.Peer) *Player {
44
47
return &Player{
45
-
ID: peer,
46
-
canHold: true,
48
+
ID: peer,
49
+
canHold: true,
50
+
lastKickIndex: -1,
47
51
}
48
52
}
49
53
···
61
65
distance := p.computeDropDistance(grid)
62
66
if distance > 0 {
63
67
p.CurrentBlock.Move(distance, 0)
68
+
p.justHardDropped = true // Signal immediate locking
69
+
p.hardDropDistance = distance
70
+
p.lastKickIndex = -1
64
71
}
65
72
p.justHardDropped = true // Signal immediate locking (even if already at bottom)
66
73
p.hardDropDistance = distance
···
256
263
p.softDropARRCounter = 0
257
264
if p.MoveBlockDown(grid) {
258
265
p.softDropCells++
266
+
p.lastKickIndex = -1
259
267
}
260
268
return
261
269
}
···
276
284
if p.softDropARRCounter >= SoftDropARR {
277
285
if p.MoveBlockDown(grid) {
278
286
p.softDropCells++
287
+
p.lastKickIndex = -1
279
288
}
280
289
p.softDropARRCounter = 0
281
290
}
···
295
304
if !p.CurrentBlock.FitsInGrid(grid) {
296
305
p.CurrentBlock.Move(0, -dir)
297
306
} else {
307
+
p.lastKickIndex = -1
298
308
if p.IsOnGround(grid) {
299
309
p.lockDelayCounter = 0
300
310
p.lockMoveCount++
···
359
369
360
370
// rotate attempts to rotate the block
361
371
func (p *Player) rotate(grid *Grid, clockwise bool) {
362
-
p.CurrentBlock.TryRotateWithKicks(grid, clockwise)
363
-
if p.IsOnGround(grid) {
364
-
p.lockDelayCounter = 0
365
-
p.lockMoveCount++
372
+
if ok, kickIdx := p.CurrentBlock.TryRotateWithKicks(grid, clockwise); ok {
373
+
p.lastKickIndex = kickIdx
374
+
if p.IsOnGround(grid) {
375
+
p.lockDelayCounter = 0
376
+
p.lockMoveCount++
377
+
}
366
378
}
367
379
}
368
380
···
425
437
p.NextBlocks[len(p.NextBlocks)-1] = p.GetNewBlock()
426
438
p.canHold = canHold
427
439
p.gravityAccum = 0
440
+
p.lastKickIndex = -1
428
441
p.lockDelayCounter = 0
429
442
p.lockMoveCount = 0
430
443
}
History
2 rounds
0 comments
voigt.tngl.sh
submitted
#1
1 commit
expand
collapse
implement t-spins
expand 0 comments
pull request successfully merged
voigt.tngl.sh
submitted
#0
1 commit
expand
collapse
implement t-spins